Skip to content
LaravelBlog

livewire #10342: [4.x] Fix `writeToHistory()` mutating the current history state

LaravelBlogBot
LaravelBlogBot

Contributed by people-sea

Part of the livewire v4.4.7 release

Livewire 4.x ships a small but important fix to the history coordinator — the machinery behind wire:navigate that writes browser history entries. PR #10342 changes writeToHistory() so that it no longer mutates the current history state before handing it to history.replaceState(). If you're upgrading from v3.8.10, there's nothing to change in your code, but if your app uses wire:navigate together with any library that wraps the History API — most notably Filament, which is exactly where this bug surfaced (filamentphp/filament#20017) — back/forward navigation will now behave correctly again.

What changed

The fix is a single line in js/plugins/history/coordinator.js:

// before
let state = window.history.state || {}

// after
let state = window.history.state ? { ...window.history.state } : {}

window.history.state returns a live reference to the state object attached to the current history entry — it's not a copy. The old code grabbed that reference and then let the coordinator's callback decorate it (adding or updating the alpine key, where Livewire stores the page snapshot used to restore scroll on back navigation) before calling replaceState(). Because it was a reference, those edits mutated the live state of the current entry immediately, before the browser ever committed the new one.

The fix shallow-clones the state with a spread, so Livewire edits a private copy. window.history.state now only changes when replaceState() actually succeeds. A shallow clone is sufficient here because the coordinator swaps its own alpine key for a fresh object rather than mutating nested values in place — the new unit test asserts that, at the moment replaceState() is invoked, window.history.state is still byte-for-byte the pre-navigation state.

Why it matters

The bug bites any code that wraps replaceState() and tries to skip redundant writes — a common optimization. Filament does exactly this, and that's how the problem was discovered. With the old code, by the time the wrapper's comparison ran, Livewire had already mutated window.history.state to look identical to the state it was about to write. The wrapper concluded "nothing changed" and skipped the call. The browser entry never actually received the new state — including the navigate snapshot — so hitting the back button after a wire:navigate jump dropped you at the top of the page instead of the scroll position you left.

There's a second, quieter benefit: if replaceState() throws (a buggy wrapper, a browser quirk), the live state used to be left half-updated even though nothing was committed to the entry. Now a failed write leaves the current history state completely untouched.

A concrete example

Here's the pattern of wrapper the new Dusk browser test installs to simulate the Filament scenario:

// A library installs a wrapper to avoid redundant history writes:
const original = window.history.replaceState.bind(window.history)

window.history.replaceState = function (state, title, url) {
    if (
        window.history.state !== null &&
        JSON.stringify(window.history.state) === JSON.stringify(state)
    ) {
        return // "nothing changed" — skip the write
    }

    return original(state, title, url)
}

Before the fix, the sequence was: Livewire grabs the live state reference → decorates it with the navigate snapshot → the wrapper's JSON.stringify comparison now matches → the real write is skipped → the history entry never gets updated → back-button scroll restoration silently breaks.

After the fix, the comparison runs against the still-pristine state, sees a difference, and passes the call through to the real replaceState(). The browser test (test_navigate_back_preserves_scroll_when_filament_wraps_replace_state) verifies exactly this: scroll down a wire:navigate page, navigate away, press back, and assert the original scroll position is restored even with the skipping wrapper installed.

Upgrade impact

Coming from v3.8.10, this is a transparent bug fix with no API, config, or markup changes:

  • No action required. If your build includes PR #10342, the corrected behavior applies automatically; there's no feature flag or migration step.
  • State timing. If your own JavaScript inspects window.history.state for Livewire's keys during a navigation, note the new timing: the live state only reflects Livewire's snapshot once the replaceState() call has actually succeeded.
  • Failure semantics. If a wrapped replaceState() throws, the current entry's state now stays as it was before the navigation attempt, rather than carrying Livewire's updates. The updated "custom error handler" unit test locks this in, and also confirms errors are still logged to console.error alongside any custom handler.
  • Who benefits most. Apps running Filament panels, analytics tooling, or any SPA instrumentation that wraps or dedupes History API calls will see wire:navigate's back/forward scroll restoration work reliably instead of intermittently failing.

The change is well covered by tests: a new Vitest case (should not mutate the current history state before calling replaceState) plus the browser-level regression test described above.

Takeaways

  • Fix: writeToHistory() now clones window.history.state with a spread instead of mutating it by reference.
  • window.history.state only changes when replaceState()/pushState() actually succeed; failed writes leave it untouched.
  • Resolves broken back-button scroll restoration when other libraries (e.g., Filament) wrap and dedupe history writes — see filamentphp/filament#20017.
  • No public API changes and nothing to do when upgrading from v3.8.10.

Sources

More from this release

Related Articles