livewire/livewire: Fix: wire:navigate no longer breaks when a teleport targets an element inside @persist
Contributed by lazerg
Part of the livewire v4.4.7 release
Pull request #10653 fixes a wire:navigate failure that could leave the destination page completely inert. The new page's HTML renders, but nothing responds — no Livewire hydration, no Alpine, no click handlers — until a manual refresh. The trigger is a specific combination of @persist and x-teleport, and the fix is a small but important reordering inside the navigate plugin's persist restore logic. If you have ever clicked a link and landed on a page that looks fine but never "wakes up," this was the bug.
The symptom
A navigation goes dead when a persisted element contains a teleport whose target lives inside it, and the page you are navigating to ships no matching target in its HTML. The classic pattern:
@persist('sidebar')
<div id="labels"></div>
<template x-teleport="#labels">
<span>Label</span>
</template>
@endpersist
The missing ingredient is usually a #[Lazy] component inside the persisted block: on the destination page the server response contains only the loading placeholder, so the teleport's target never appears in the fetched markup. During the swap an exception fires, and in the console you would see a TypeError like Cannot read properties of null (reading 'appendChild'). The visible result is the inert page.
The root cause: callbacks ran while the element was detached
During a swap, Livewire calls putPersistantElementsBack() to graft the stored [x-persist] elements into the incoming document. The old code processed each persisted element in this order:
callback(old, i)— which drivesunPackPersistedTeleports(), walking the persisted element for nodes carrying_x_teleportand placing their content into their targets.Alpine.mutateDom(() => i.replaceWith(old))— which actually re-attaches the element to the document.
That order is the problem. In step 1 the element is still detached, so document.querySelector() cannot find #labels — it only exists inside the not-yet-attached element. placeInDom then throws on null.appendChild(...). Because that exception escapes swapCurrentPageWithNewHtml, nowInitializeAlpineOnTheNewPage never runs, and the entire page is left un-booted. One missing teleport target was enough to take down every component on the destination page.
The fix: attach everything first, then unpack
The order is now inverted. putPersistantElementsBack() first replaces every placeholder with its stored element, collecting [old, i] pairs as it goes. Only after every persisted element is back in the document does it run the put-back callbacks, so unPackPersistedTeleports() resolves targets against a fully assembled document.
A welcome side effect: since all persists are attached before any teleport is unpacked, a teleport in one @persist can now legally target an element inside a different one:
@persist('header')
<div x-data="{ count: 0 }">
<template x-teleport="#labels">
<button x-on:click="count++" x-text="count"></button>
</template>
</div>
@endpersist
@persist('sidebar')
<div id="labels"></div>
@endpersist
The restore path now also mirrors storePersistantElementsForLater(), which already packs elements up while they are still attached, so saving and restoring are symmetric. The one-line diff in teleport.js is just a comment update documenting the new invariant.
Proof from the new tests
Two Dusk browser tests lock the behavior in: test_navigate_works_with_teleports_targeting_inside_a_persist and test_navigate_works_with_teleports_targeting_inside_another_persist (the cross-persist case above). Both click a button to increment a counter inside teleported content, navigate to a second page whose persists contain only placeholders, then assert the counter survived the trip and — crucially — that clicking still works afterward. Before the fix, that final click silently did nothing, because Alpine never initialized on the destination page.
Upgrade impact
None beyond pulling the fix. This is a contained change to js/plugins/navigate/persist.js: no public API, no new directives, no configuration, and nothing to migrate. Anyone upgrading from v3.8.10 or an earlier 4.x build gets the corrected behavior automatically. If you worked around the bug — by keeping teleports out of persisted blocks, removing #[Lazy] from persisted components, or avoiding wire:navigate on affected links — you can undo those workarounds after upgrading. Nothing else about how @persist or x-teleport behaves changes.
Takeaways
wire:navigatecould leave pages inert when anx-teleportinside@persisttargeted an element missing from the fetched HTML.- Root cause: teleport unpacking ran while persisted elements were detached, so
querySelector()missed,placeInDomthrew, and Alpine never initialized. - The fix re-attaches every persisted element before running put-back callbacks; teleports can even target other persists now.
- Covered by two new browser tests; no API, config, or migration changes — just upgrade.