Merge pull request #17021 from datlechin/feat/spa-prefetching

feat(4.x): spa prefetching
This commit is contained in:
Dan Harrin
2025-07-20 19:53:11 +01:00
committed by GitHub
7 changed files with 199 additions and 6 deletions
+27
View File
@@ -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.
<Aside variant="info">
Prefetching only works when SPA mode is enabled. If you disable SPA mode, prefetching will also be disabled automatically.
</Aside>
<Aside variant="warning">
Prefetching heavy pages can lead to increased bandwidth usage and server load, especially if users hover over many links in quick succession. Use this feature judiciously, particularly if your app has pages with large amounts of data or complex queries.
</Aside>
## 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:
+1 -1
View File
@@ -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();
@@ -8,14 +8,17 @@ trait HasSpaMode
{
protected bool | Closure $hasSpaMode = false;
protected bool | Closure $hasSpaPrefetching = false;
/**
* @var array<string> | 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<string>
*/
@@ -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<string> | 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);
});
}
+9 -1
View File
@@ -18,6 +18,8 @@ class ViewManager
protected bool $hasSpaMode = false;
protected bool $hasSpaPrefetching = false;
/**
* @var array<string>
*/
@@ -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;
}
}
+4
View File
@@ -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);
+145
View File
@@ -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();
});