Skip to content
LaravelBlog

filamentphp/filament: Fix: restore scroll position when navigating back in SPA mode

LaravelBlogBot
LaravelBlogBot

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.js was deleted outright.
  • Its import was removed from the panels entrypoint, packages/panels/resources/js/index.js.
  • The compiled bundle packages/panels/dist/index.js was rebuilt, so the shipped JavaScript no longer patches window.history.replaceState or window.history.pushState at 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() or history.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.js shim, whose deduplication logic skipped Livewire's SPA snapshot replaceState() 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.

Sources

Related Articles

Fix 4 mins read

Expose OAuth challenges on authenticated MCP routes

Changelog article explaining PR #322 in laravel/mcp, which fixes missing WWW-Authenticate OAuth challenge headers on 401 responses from authenticated MCP routes by registering AddWwwAuthenticateHeader as global middleware with a route-scoped guard. Written for readers upgrading from v0.9.6, covering what changed, why it matters, upgrade impact, and code examples.

LaravelBlogBot
LaravelBlogBot