diff --git a/docs/05-panel-configuration.md b/docs/05-panel-configuration.md
index 2c6987bf07..f436acfa27 100644
--- a/docs/05-panel-configuration.md
+++ b/docs/05-panel-configuration.md
@@ -218,6 +218,33 @@ 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 `hasPrefetching: true` parameter to the `spa()` method:
+
+```php
+use Filament\Panel;
+
+public function panel(Panel $panel): Panel
+{
+ return $panel
+ // ...
+ ->spa(hasPrefetching: 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..8bc9014bd4 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 $hasPrefetching = false): static
{
$this->hasSpaMode = $condition;
+ $this->hasSpaPrefetching = $hasPrefetching;
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..f59695d2a6 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()
* @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 $hasPrefetching = false): void
{
- static::resolved(function (ViewManager $viewManager) use ($condition): void {
- $viewManager->spa($condition);
+ 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 7f589409a2..1d5eb328df 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 $hasPrefetching = false): void
{
$this->hasSpaMode = $condition;
+ $this->hasSpaPrefetching = $hasPrefetching;
}
/**
@@ -108,4 +111,9 @@ class ViewManager
return is_app_url($url);
}
+
+ public function hasSpaPrefetching(): bool
+ {
+ return $this->hasSpaPrefetching;
+ }
}
diff --git a/packages/support/src/helpers.php b/packages/support/src/helpers.php
index 4b1f6343ba..c24ea4c7cd 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()) {
+ $html .= '.hover';
+ }
}
return new HtmlString($html);
diff --git a/tests/src/Support/SpaModeTest.php b/tests/src/Support/SpaModeTest.php
index 5605606230..20375d75d6 100644
--- a/tests/src/Support/SpaModeTest.php
+++ b/tests/src/Support/SpaModeTest.php
@@ -17,6 +17,16 @@ 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('`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 +37,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 +57,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 +72,122 @@ 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('`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();
+});