From 2f5a01427644852a8ed2fe50d854f707004e4216 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 18 Jul 2025 11:56:53 +0700 Subject: [PATCH 1/2] feat(4.x): spa prefetching --- docs/05-panel-configuration.md | 23 +++ packages/panels/src/Panel.php | 2 +- .../panels/src/Panel/Concerns/HasSpaMode.php | 10 +- packages/support/src/Facades/FilamentView.php | 7 +- packages/support/src/View/ViewManager.php | 10 +- packages/support/src/helpers.php | 4 + tests/src/Support/SpaModeTest.php | 161 ++++++++++++++++++ 7 files changed, 211 insertions(+), 6 deletions(-) diff --git a/docs/05-panel-configuration.md b/docs/05-panel-configuration.md index 2c6987bf07..393b55c977 100644 --- a/docs/05-panel-configuration.md +++ b/docs/05-panel-configuration.md @@ -218,6 +218,29 @@ public function panel(Panel $panel): Panel } ``` +### Enabling SPA prefetching + +SPA prefetching enhances the user experience by automatically prefetching pages when users hover over links, making navigation feel even more responsive. This feature utilizes [Livewire's `wire:navigate.hover` functionality](https://livewire.laravel.com/docs/navigate#prefetching-links). + +To enable SPA mode with prefetching, you can pass the `prefetch` parameter to the `spa()` method: + +```php +use Filament\Panel; + +public function panel(Panel $panel): Panel +{ + return $panel + // ... + ->spa(prefetch: true); +} +``` + +When prefetching is enabled, all links within your panel will automatically include `wire:navigate.hover`, which prefetches the page content when users hover over the link. This works seamlessly with [URL exceptions](#disabling-spa-navigation-for-specific-urls) - any URLs excluded from SPA mode will also be excluded from prefetching. + + + ## Unsaved changes alerts You may alert users if they attempt to navigate away from a page without saving their changes. This is applied on [Create](resources/creating-records) and [Edit](resources/editing-records) pages of a resource, as well as any open action modals. To enable this feature, you can use the `unsavedChangesAlerts()` method: diff --git a/packages/panels/src/Panel.php b/packages/panels/src/Panel.php index 70b828ad63..08505f3d41 100644 --- a/packages/panels/src/Panel.php +++ b/packages/panels/src/Panel.php @@ -97,7 +97,7 @@ class Panel extends Component FilamentIcon::register($this->getIcons()); - FilamentView::spa($this->hasSpaMode()); + FilamentView::spa($this->hasSpaMode(), $this->hasSpaPrefetching()); FilamentView::spaUrlExceptions($this->getSpaUrlExceptions()); $this->registerRenderHooks(); diff --git a/packages/panels/src/Panel/Concerns/HasSpaMode.php b/packages/panels/src/Panel/Concerns/HasSpaMode.php index 01bd89432c..ad0aac748b 100644 --- a/packages/panels/src/Panel/Concerns/HasSpaMode.php +++ b/packages/panels/src/Panel/Concerns/HasSpaMode.php @@ -8,14 +8,17 @@ trait HasSpaMode { protected bool | Closure $hasSpaMode = false; + protected bool | Closure $hasSpaPrefetching = false; + /** * @var array | Closure */ protected array | Closure $spaModeUrlExceptions = []; - public function spa(bool | Closure $condition = true): static + public function spa(bool | Closure $condition = true, bool | Closure $prefetch = false): static { $this->hasSpaMode = $condition; + $this->hasSpaPrefetching = $prefetch; return $this; } @@ -35,6 +38,11 @@ trait HasSpaMode return (bool) $this->evaluate($this->hasSpaMode); } + public function hasSpaPrefetching(): bool + { + return (bool) $this->evaluate($this->hasSpaPrefetching); + } + /** * @return array */ diff --git a/packages/support/src/Facades/FilamentView.php b/packages/support/src/Facades/FilamentView.php index 2114b5cf66..d1c7cc33c6 100644 --- a/packages/support/src/Facades/FilamentView.php +++ b/packages/support/src/Facades/FilamentView.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Facade; /** * @method static bool hasSpaMode(?string $url = null) + * @method static bool hasSpaPrefetching(?string $url = null) * @method static Htmlable renderHook(string $name, string | array | null $scopes = null) * * @see ViewManager @@ -30,10 +31,10 @@ class FilamentView extends Facade }); } - public static function spa(bool $condition = true): void + public static function spa(bool $condition = true, bool $prefetch = false): void { - static::resolved(function (ViewManager $viewManager) use ($condition): void { - $viewManager->spa($condition); + static::resolved(function (ViewManager $viewManager) use ($condition, $prefetch): void { + $viewManager->spa($condition, $prefetch); }); } diff --git a/packages/support/src/View/ViewManager.php b/packages/support/src/View/ViewManager.php index 7f589409a2..bd37e9ada3 100644 --- a/packages/support/src/View/ViewManager.php +++ b/packages/support/src/View/ViewManager.php @@ -18,6 +18,8 @@ class ViewManager protected bool $hasSpaMode = false; + protected bool $hasSpaPrefetching = false; + /** * @var array */ @@ -76,9 +78,10 @@ class ViewManager return new HtmlString(implode('', $hooks)); } - public function spa(bool $condition = true): void + public function spa(bool $condition = true, bool $prefetch = false): void { $this->hasSpaMode = $condition; + $this->hasSpaPrefetching = $prefetch; } /** @@ -108,4 +111,9 @@ class ViewManager return is_app_url($url); } + + public function hasSpaPrefetching(?string $url = null): bool + { + return $this->hasSpaMode($url) && $this->hasSpaPrefetching; + } } diff --git a/packages/support/src/helpers.php b/packages/support/src/helpers.php index 4b1f6343ba..f915090981 100644 --- a/packages/support/src/helpers.php +++ b/packages/support/src/helpers.php @@ -133,6 +133,10 @@ if (! function_exists('Filament\Support\generate_href_html')) { $html .= ' target="_blank"'; } elseif ($shouldOpenInSpaMode ?? (FilamentView::hasSpaMode($url))) { $html .= ' wire:navigate'; + + if (FilamentView::hasSpaPrefetching($url)) { + $html .= '.hover'; + } } return new HtmlString($html); diff --git a/tests/src/Support/SpaModeTest.php b/tests/src/Support/SpaModeTest.php index 5605606230..7ad7578f05 100644 --- a/tests/src/Support/SpaModeTest.php +++ b/tests/src/Support/SpaModeTest.php @@ -17,6 +17,24 @@ test('SPA mode can be toggled', function (): void { expect(FilamentView::hasSpaMode())->toBeFalse(); }); +test('SPA prefetching can be toggled', function (): void { + expect(FilamentView::hasSpaPrefetching())->toBeFalse(); + + FilamentView::spa(true, true); + expect(FilamentView::hasSpaPrefetching())->toBeTrue(); + + FilamentView::spa(true, false); + expect(FilamentView::hasSpaPrefetching())->toBeFalse(); +}); + +test('SPA prefetching requires SPA mode to be enabled', function (): void { + FilamentView::spa(false, true); + expect(FilamentView::hasSpaPrefetching())->toBeFalse(); + + FilamentView::spa(true, true); + expect(FilamentView::hasSpaPrefetching())->toBeTrue(); +}); + test('`href` HTML can be generated with `wire:navigate` based on SPA mode', function (): void { FilamentView::spa(); expect(generate_href_html('http://localhost/page')) @@ -27,6 +45,16 @@ test('`href` HTML can be generated with `wire:navigate` based on SPA mode', func ->toHtml()->toBe('href="http://localhost/page"'); }); +test('`href` HTML can be generated with `wire:navigate.hover` when prefetching is enabled', function (): void { + FilamentView::spa(true, true); + expect(generate_href_html('http://localhost/page')) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate.hover'); + + FilamentView::spa(true, false); + expect(generate_href_html('http://localhost/page')) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate'); +}); + test('`wire:navigate` is not used in the `href` HTML if it doesn\'t match the request\'s domain', function (): void { FilamentView::spa(); expect(generate_href_html('http://another-localhost/page')) @@ -37,6 +65,12 @@ test('`wire:navigate` is not used in the `href` HTML if it doesn\'t match the re ->toHtml()->toBe('href="http://another-localhost/page"'); }); +test('`wire:navigate.hover` is not used for external URLs even when prefetching is enabled', function (): void { + FilamentView::spa(true, true); + expect(generate_href_html('http://another-localhost/page')) + ->toHtml()->toBe('href="http://another-localhost/page"'); +}); + test('`target` HTML can be generated if the URL should open in a new tab', function (): void { FilamentView::spa(); expect(generate_href_html('http://localhost/page', shouldOpenInNewTab: true)) @@ -46,3 +80,130 @@ test('`target` HTML can be generated if the URL should open in a new tab', funct expect(generate_href_html('http://localhost/page', shouldOpenInNewTab: true)) ->toHtml()->toBe('href="http://localhost/page" target="_blank"'); }); + +test('`target` HTML takes precedence over SPA prefetching', function (): void { + FilamentView::spa(true, true); + expect(generate_href_html('http://localhost/page', shouldOpenInNewTab: true)) + ->toHtml()->toBe('href="http://localhost/page" target="_blank"'); +}); + +test('SPA URL exceptions work correctly', function (): void { + FilamentView::spa(); + FilamentView::spaUrlExceptions(['*/admin/*']); + + expect(FilamentView::hasSpaMode('http://localhost/admin/users'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/dashboard'))->toBeTrue(); +}); + +test('SPA prefetching respects URL exceptions', function (): void { + FilamentView::spa(true, true); + FilamentView::spaUrlExceptions(['*/admin/*']); + + expect(FilamentView::hasSpaPrefetching('http://localhost/admin/users'))->toBeFalse(); + expect(FilamentView::hasSpaPrefetching('http://localhost/dashboard'))->toBeTrue(); +}); + +test('`href` HTML respects SPA URL exceptions for prefetching', function (): void { + FilamentView::spa(true, true); + FilamentView::spaUrlExceptions(['*/admin/*']); + + expect(generate_href_html('http://localhost/admin/users')) + ->toHtml()->toBe('href="http://localhost/admin/users"'); + + expect(generate_href_html('http://localhost/dashboard')) + ->toHtml()->toBe('href="http://localhost/dashboard" wire:navigate.hover'); +}); + +test('`shouldOpenInSpaMode` parameter overrides default SPA mode behavior', function (): void { + FilamentView::spa(false); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: true)) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate'); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: false)) + ->toHtml()->toBe('href="http://localhost/page"'); +}); + +test('`shouldOpenInSpaMode` parameter works with prefetching', function (): void { + FilamentView::spa(true, true); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: true)) + ->toHtml()->toBe('href="http://localhost/page" wire:navigate.hover'); + + expect(generate_href_html('http://localhost/page', shouldOpenInSpaMode: false)) + ->toHtml()->toBe('href="http://localhost/page"'); +}); + +test('blank URL returns empty HTML string', function (): void { + expect(generate_href_html(''))->toHtml()->toBe(''); + expect(generate_href_html(null))->toHtml()->toBe(''); +}); + +test('SPA URL exceptions can be chained', function (): void { + FilamentView::spa(); + FilamentView::spaUrlExceptions(['*/admin/*']); + FilamentView::spaUrlExceptions(['*/api/*']); + + expect(FilamentView::hasSpaMode('http://localhost/admin/users'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/api/data'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/dashboard'))->toBeTrue(); +}); + +test('SPA URL exceptions work with complex patterns', function (): void { + FilamentView::spa(); + FilamentView::spaUrlExceptions([ + '*/admin/users/*', + '*/api/v1/*', + '*/public/*', + ]); + + expect(FilamentView::hasSpaMode('http://localhost/admin/users/123'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/api/v1/users'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/public/files'))->toBeFalse(); + expect(FilamentView::hasSpaMode('http://localhost/admin/dashboard'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/api/v2/users'))->toBeTrue(); +}); + +test('SPA mode works with different URL formats', function (): void { + FilamentView::spa(); + + $root = request()->root(); + + expect(FilamentView::hasSpaMode($root . '/page'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root . '/page?param=value'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root . '/page#fragment'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root . '/'))->toBeTrue(); + expect(FilamentView::hasSpaMode($root))->toBeTrue(); +}); + +test('SPA prefetching works with different URL formats', function (): void { + FilamentView::spa(true, true); + + $root = request()->root(); + + expect(FilamentView::hasSpaPrefetching($root . '/page'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root . '/page?param=value'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root . '/page#fragment'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root . '/'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching($root))->toBeTrue(); +}); + +test('SPA mode handles edge cases gracefully', function (): void { + FilamentView::spa(); + + expect(FilamentView::hasSpaMode('http://localhost'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost///'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/page with spaces'))->toBeTrue(); + expect(FilamentView::hasSpaMode('http://localhost/page%20with%20encoding'))->toBeTrue(); +}); + +test('SPA prefetching handles edge cases gracefully', function (): void { + FilamentView::spa(true, true); + + expect(FilamentView::hasSpaPrefetching('http://localhost'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost/'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost///'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost/page with spaces'))->toBeTrue(); + expect(FilamentView::hasSpaPrefetching('http://localhost/page%20with%20encoding'))->toBeTrue(); +}); From 2aa7ff852c24be6d014b3285e8bea3e0ed147d6f Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Sun, 20 Jul 2025 19:52:33 +0100 Subject: [PATCH 2/2] simplify and add docs warning --- docs/05-panel-configuration.md | 8 ++++++-- .../panels/src/Panel/Concerns/HasSpaMode.php | 4 ++-- packages/support/src/Facades/FilamentView.php | 8 ++++---- packages/support/src/View/ViewManager.php | 8 ++++---- packages/support/src/helpers.php | 2 +- tests/src/Support/SpaModeTest.php | 16 ---------------- 6 files changed, 17 insertions(+), 29 deletions(-) diff --git a/docs/05-panel-configuration.md b/docs/05-panel-configuration.md index 393b55c977..f436acfa27 100644 --- a/docs/05-panel-configuration.md +++ b/docs/05-panel-configuration.md @@ -222,7 +222,7 @@ public function panel(Panel $panel): Panel SPA prefetching enhances the user experience by automatically prefetching pages when users hover over links, making navigation feel even more responsive. This feature utilizes [Livewire's `wire:navigate.hover` functionality](https://livewire.laravel.com/docs/navigate#prefetching-links). -To enable SPA mode with prefetching, you can pass the `prefetch` parameter to the `spa()` method: +To enable SPA mode with prefetching, you can pass the `hasPrefetching: true` parameter to the `spa()` method: ```php use Filament\Panel; @@ -231,7 +231,7 @@ public function panel(Panel $panel): Panel { return $panel // ... - ->spa(prefetch: true); + ->spa(hasPrefetching: true); } ``` @@ -241,6 +241,10 @@ When prefetching is enabled, all links within your panel will automatically incl Prefetching only works when SPA mode is enabled. If you disable SPA mode, prefetching will also be disabled automatically. + + ## Unsaved changes alerts You may alert users if they attempt to navigate away from a page without saving their changes. This is applied on [Create](resources/creating-records) and [Edit](resources/editing-records) pages of a resource, as well as any open action modals. To enable this feature, you can use the `unsavedChangesAlerts()` method: diff --git a/packages/panels/src/Panel/Concerns/HasSpaMode.php b/packages/panels/src/Panel/Concerns/HasSpaMode.php index ad0aac748b..8bc9014bd4 100644 --- a/packages/panels/src/Panel/Concerns/HasSpaMode.php +++ b/packages/panels/src/Panel/Concerns/HasSpaMode.php @@ -15,10 +15,10 @@ trait HasSpaMode */ protected array | Closure $spaModeUrlExceptions = []; - public function spa(bool | Closure $condition = true, bool | Closure $prefetch = false): static + public function spa(bool | Closure $condition = true, bool | Closure $hasPrefetching = false): static { $this->hasSpaMode = $condition; - $this->hasSpaPrefetching = $prefetch; + $this->hasSpaPrefetching = $hasPrefetching; return $this; } diff --git a/packages/support/src/Facades/FilamentView.php b/packages/support/src/Facades/FilamentView.php index d1c7cc33c6..f59695d2a6 100644 --- a/packages/support/src/Facades/FilamentView.php +++ b/packages/support/src/Facades/FilamentView.php @@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Facade; /** * @method static bool hasSpaMode(?string $url = null) - * @method static bool hasSpaPrefetching(?string $url = null) + * @method static bool hasSpaPrefetching() * @method static Htmlable renderHook(string $name, string | array | null $scopes = null) * * @see ViewManager @@ -31,10 +31,10 @@ class FilamentView extends Facade }); } - public static function spa(bool $condition = true, bool $prefetch = false): void + public static function spa(bool $condition = true, bool $hasPrefetching = false): void { - static::resolved(function (ViewManager $viewManager) use ($condition, $prefetch): void { - $viewManager->spa($condition, $prefetch); + static::resolved(function (ViewManager $viewManager) use ($condition, $hasPrefetching): void { + $viewManager->spa($condition, $hasPrefetching); }); } diff --git a/packages/support/src/View/ViewManager.php b/packages/support/src/View/ViewManager.php index bd37e9ada3..1d5eb328df 100644 --- a/packages/support/src/View/ViewManager.php +++ b/packages/support/src/View/ViewManager.php @@ -78,10 +78,10 @@ class ViewManager return new HtmlString(implode('', $hooks)); } - public function spa(bool $condition = true, bool $prefetch = false): void + public function spa(bool $condition = true, bool $hasPrefetching = false): void { $this->hasSpaMode = $condition; - $this->hasSpaPrefetching = $prefetch; + $this->hasSpaPrefetching = $hasPrefetching; } /** @@ -112,8 +112,8 @@ class ViewManager return is_app_url($url); } - public function hasSpaPrefetching(?string $url = null): bool + public function hasSpaPrefetching(): bool { - return $this->hasSpaMode($url) && $this->hasSpaPrefetching; + return $this->hasSpaPrefetching; } } diff --git a/packages/support/src/helpers.php b/packages/support/src/helpers.php index f915090981..c24ea4c7cd 100644 --- a/packages/support/src/helpers.php +++ b/packages/support/src/helpers.php @@ -134,7 +134,7 @@ if (! function_exists('Filament\Support\generate_href_html')) { } elseif ($shouldOpenInSpaMode ?? (FilamentView::hasSpaMode($url))) { $html .= ' wire:navigate'; - if (FilamentView::hasSpaPrefetching($url)) { + if (FilamentView::hasSpaPrefetching()) { $html .= '.hover'; } } diff --git a/tests/src/Support/SpaModeTest.php b/tests/src/Support/SpaModeTest.php index 7ad7578f05..20375d75d6 100644 --- a/tests/src/Support/SpaModeTest.php +++ b/tests/src/Support/SpaModeTest.php @@ -27,14 +27,6 @@ test('SPA prefetching can be toggled', function (): void { expect(FilamentView::hasSpaPrefetching())->toBeFalse(); }); -test('SPA prefetching requires SPA mode to be enabled', function (): void { - FilamentView::spa(false, true); - expect(FilamentView::hasSpaPrefetching())->toBeFalse(); - - FilamentView::spa(true, true); - expect(FilamentView::hasSpaPrefetching())->toBeTrue(); -}); - test('`href` HTML can be generated with `wire:navigate` based on SPA mode', function (): void { FilamentView::spa(); expect(generate_href_html('http://localhost/page')) @@ -95,14 +87,6 @@ test('SPA URL exceptions work correctly', function (): void { expect(FilamentView::hasSpaMode('http://localhost/dashboard'))->toBeTrue(); }); -test('SPA prefetching respects URL exceptions', function (): void { - FilamentView::spa(true, true); - FilamentView::spaUrlExceptions(['*/admin/*']); - - expect(FilamentView::hasSpaPrefetching('http://localhost/admin/users'))->toBeFalse(); - expect(FilamentView::hasSpaPrefetching('http://localhost/dashboard'))->toBeTrue(); -}); - test('`href` HTML respects SPA URL exceptions for prefetching', function (): void { FilamentView::spa(true, true); FilamentView::spaUrlExceptions(['*/admin/*']);