filamentphp/filament: Fix: restore scroll position when navigating back in SPA mode
Contributed by people-sea
Part of the filament v5.10.1 release
In SPA mode, pressing the browser's back button in a Filament panel now returns you to the exact scroll position you left behind. PR #20106 resolves issue #20017 by deleting a small legacy shim, history-state.js, from the panels JavaScript bundle. The shim was written to work around Livewire 3 behavior, and under Livewire 4 — which Filament v5 ships with — it became obsolete and started actively breaking scroll restoration. If you're upgrading from v4.15.1 into v5, this is a transparent bug fix: no configuration changes, no public API changes, nothing to migrate.
The bug: back navigation forgot where you were
In SPA mode, clicking a link doesn't trigger a full page load. Before navigating, Livewire calls history.replaceState() to store a snapshot of the current page — its component state and scroll position — into the current history entry, then history.pushState() for the new URL. Pressing back pops that entry, and Livewire uses the snapshot to rebuild the previous page exactly as it was, scroll offset included.
Filament shipped a wrapper around both History API methods. Its deduplication logic skipped replaceState() calls whose new state appeared identical to the current state. Under Livewire 4, that comparison also caught Livewire's genuine SPA snapshot updates — the wrapper silently dropped them, no snapshot was ever written, and back navigation dumped users at the top of the page instead of where they had scrolled to.
What the removed shim was doing
The deleted file, packages/panels/resources/js/history-state.js, monkey-patched the History API like this (condensed):
window.history.replaceState = function (state, unused, url) {
// Pass through whenever the URL changes…
const targetUrl = url || state?.url || window.location.href
const currentUrl = window.location.href
if (targetUrl !== currentUrl) {
originalReplaceState.call(window.history, state, unused, url)
return
}
// …otherwise skip “duplicate” state writes
const currentState = window.history.state
const stateChanged =
JSON.stringify(state) !== JSON.stringify(currentState)
if (stateChanged) {
originalReplaceState.call(window.history, state, unused, url)
}
}
The wrapper existed because Livewire 3 could fire repeated replaceState() calls from #[Url]-bound properties, causing history churn. Livewire 4 batches those URL history updates natively, so the workaround was no longer needed — but its dedup check kept eating the one replaceState() call that mattered: Livewire's SPA snapshot.
What changed in the code
Three things, all internal:
packages/panels/resources/js/history-state.jswas deleted outright.- Its import was removed from the panels entrypoint,
packages/panels/resources/js/index.js. - The compiled bundle
packages/panels/dist/index.jswas rebuilt, so the shipped JavaScript no longer patcheswindow.history.replaceStateorwindow.history.pushStateat runtime.
Nothing else in the panels bundle was touched. The Mousetrap keyboard shortcuts, the sidebar store, dark mode, error notifications, and the unsaved-changes alerts are unchanged — the sidebar even keeps its own scroll-preservation logic, which was already working. The fix simply stops Filament's own code from interfering with Livewire's history handling.
Why it matters day to day
This only affects panels using SPA mode:
use Filament\Panel;
public function panel(Panel $panel): Panel
{
return $panel
->spa();
}
But that's a common setup, and the failure mode was painful: scroll through a long table, open a record, press back, and you'd land at the top of the list instead of the row you came from. Pages that sync state to the URL through Livewire's #[Url] attribute were the most exposed:
use Livewire\Attributes\Url;
#[Url]
public string $activeTab = 'all';
Under v4.15.1 (Livewire 3), Filament kept the wrapper because Livewire 3 lacks the fix upstream. On v5, Livewire 4 handles the batching itself, and snapshot writes now go through untouched — so both the URL history and the scroll-restoration snapshots survive.
Upgrading: what you need to do
Practically nothing:
- No code, config, or migration changes are required. The deleted file and import were internal implementation details, never public API.
- If you published and customized Filament's panel JavaScript, republish or rebuild your copy so it picks up the change; otherwise the updated bundle is served automatically.
- If your own JavaScript also monkey-patches
history.replaceState()orhistory.pushState(), it now runs without Filament's wrapper in the mix — worth a quick sanity check, though nothing in Filament depended on the wrapper's behavior. - If you remain on the v4 branch, nothing changes: the wrapper is deliberately kept there.
Takeaways
- Back/forward navigation in SPA mode now restores the previous page's scroll position.
- The cause was Filament's
history-state.jsshim, whose deduplication logic skipped Livewire's SPA snapshotreplaceState()calls under Livewire 4. - Livewire 4 batches
#[Url]history updates natively, making the shim obsolete on v5; it has been removed from the panels bundle. - The shim remains on the v4 branch (Livewire 3), where it is still needed.
- Upgrade impact is zero-config: rebuild published assets if you've customized them, and you're done.