livewire/livewire: [4.x] Fix duplicate x-if output after browser history navigation
Contributed by joshhanley
Part of the livewire v4.4.7 release
Livewire v4 includes a fix for a subtle but annoying bug in wire:navigate: content rendered by Alpine's x-if directive could appear twice — and accumulate further with each click — after leaving a page and returning via the browser's back and forward buttons. The cause was a missing cleanup step in the browser-history code path of the navigate plugin, not anything in your templates or components. Since the fix ships as part of v4, upgrading from v3.8.10 picks it up automatically with no code changes required. This article walks through the symptom, the mechanics behind it, what changed in the patch, and what it means for your upgrade.
The symptom: duplicated content after back and forward
The bug shows up on pages that mix wire:navigate links with Alpine conditionals like x-if. Take a component like this:
use Livewire\Component;
class PageWithConditionalButton extends Component
{
public function render()
{
return <<<'HTML'
<div x-data="{ canAdd: true }">
<a href="/second" wire:navigate>Go to second page</a>
<div>
<template x-if="canAdd">
<button type="button">Conditional item</button>
</template>
</div>
</div>
HTML;
}
}
Visit the page, click the wire:navigate link, then use the browser's back and forward buttons. On returning to the page, the "Conditional item" button could be rendered more than once — and each additional back/forward round trip could add another copy.
What was happening under the hood
To make back/forward navigation feel instant, wire:navigate caches a snapshot of each page's HTML as you move between pages. When you navigate away by clicking a link, Livewire runs cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement(), which strips out the DOM nodes Alpine materialized from <template> tags (x-if, x-for, and friends) before storing the outgoing HTML. Only the original <template> tags remain, so Alpine can initialize cleanly when the page is restored.
The problem: the back/forward path (the popstate handler) also refreshes the current page's entry in the snapshot cache — but it skipped that cleanup step. A restored page could therefore be re-cached while its DOM contained both the original <template x-if> and the button Alpine had already rendered from it. When that snapshot was restored again, Alpine treated the template as fresh and rendered a second button next to the restored one. Elements inside wire:persist containers were already exempt from cleanup, which is why persisted UI was never affected.
The fix: one missing cleanup call
The patch is intentionally small. The popstate path now reuses the exact same cleanup routine before recapturing the current page into the browser-history snapshot cache:
onSwap: (callback) => swapCallbacks.push(callback)
})
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement()
// Update the snapshot (not the history state, as the history state has
// already changed to the new page due to the popstate event).
// This ensures the current HTML has the latest snapshot.
With this in place, snapshots only ever contain pristine <template> tags, so Alpine renders exactly one button per restore. A regression browser test was added alongside the fix — test_browser_history_navigation_does_not_recapture_alpine_if_output — which navigates to a second page, goes back, forward, and back again, and asserts the conditional button's count stays at 1 each time. The change fixes issue #10335 and a corresponding Maestro report.
What this means for your upgrade
There is nothing to migrate here. The change is confined to the JavaScript inside the navigate plugin; there are no PHP API changes, no configuration changes, and no template changes.
A few practical notes:
- If you hit this bug on v3.8.10, the fix is included in v4 — upgrading resolves it.
- If you worked around it by avoiding
x-ifon navigable pages or switching tox-show, you can revisit those choices. (x-showremains perfectly fine when you only want to toggle visibility.) - If you serve Livewire's JavaScript yourself — for example a published or CDN-hosted
livewire.jsfrom a build pipeline — make sure you redeploy the updated bundle so clients pick up the patch. wire:persistbehavior is unchanged: the cleanup still skips elements inside persisted containers, so persistent UI such as dialogs or third-party widget state is left intact.
Takeaways
- Fixes duplicated Alpine
x-ifoutput after back/forward navigation withwire:navigate(issue #10335). - Root cause: the browser-history path refreshed the page snapshot cache without first cleaning up Alpine-rendered template output, so restored pages could re-render duplicates.
- The fix reuses the existing
cleanupAlpineElementsOnThePageThatArentInsideAPersistedElement()call before recapturing the current page. - No breaking changes, configuration, or code changes are needed when upgrading from v3.8.10.
- A new browser test guards against the regression, and
wire:persistcontent remains untouched.