livewire/livewire: Fix $wire.$watch teardown crash caused by lazy placeholder node conflicts
Contributed by MaxencePaulin
Part of the livewire v4.4.7 release
Pull request #10656 fixes a teardown crash and a slow memory leak at the intersection of Livewire's lazy loading feature and $wire.$watch. When a lazy component's placeholder markup shared DOM node positions with its real output, Alpine's morph process could instantiate an x-data component on a throwaway clone node with its reactivity disabled. If that clone's init() called $wire.$watch, the resulting broken watcher got stranded in the component's cleanup queue and threw when the component was destroyed, aborting the rest of the cleanup and leaving the component registered in window.Livewire.all() forever. The fix ties watcher cleanup to the calling element whenever one is available, mirroring how $wire.$entangle already works, and keeps the old component-lifetime behavior everywhere else.
What went wrong
The old $wire.$watch implementation created an Alpine watcher and registered its teardown via component.addCleanup(). That scoped the watcher's lifetime to the component, not to whatever element happened to call it.
Lazy loading set the trap. When a lazy component's placeholder is replaced with real content, Alpine's morph runs a "clone" pass: it boots x-data components on a disposable copy of the node before diffing, to inspect them safely. That clone runs with its reactivity neutered. If init() there calls $wire.$watch, the watcher is created in a state where it can never track anything, but its unwatch function still lands in the component's cleanups array.
The bomb detonates later. When the component is finally destroyed, Livewire iterates its cleanups, hits the broken watcher, throws, and stops. Every cleanup after it never runs, and the component lingers in window.Livewire.all() indefinitely — a genuine leak in long-lived, SPA-style sessions.
The pattern that triggered it
The reproduction from issue #10655 makes it concrete. A lazy child whose placeholder and real markup share node positions:
public function placeholder() { return <<<'HTML'
<div id="child"><div>Loading...</div></div>
HTML; }
public function render() { return <<<'HTML'
<div id="child">
<div x-data="{ init() { $wire.$watch('search', () => {}) } }">
Children component
</div>
</div>
HTML; }
Because both fragments start with the same <div id="child"><div> shape, the morph's clone pass boots the real markup's x-data — and its $wire.$watch call — on a dead node before the diff even happens.
What changed
$wire.$watch cleanup now lives on the calling element, not the component. The watch logic was extracted into a new js/features/supportWatch.js:
export function generateWatchFunction(component, cleanup) {
return (path, callback) => {
let getter = () => dataGet(component.reactive, path)
let unwatch = Alpine.watch(getter, callback)
if (cleanup) {
cleanup(unwatch) // tied to the calling element
return unwatch
}
// fallback: component lifetime, unchanged
let removeCleanup = component.addCleanup(unwatch)
return () => {
removeCleanup()
unwatch()
}
}
}
When $wire is used from inside an element — an x-data init, a wire: expression — Alpine hands the magic an element-scoped cleanup hook, and the watcher's teardown registers there. If that element is discarded mid-morph (like the throwaway clone), its watcher is discarded with it and never pollutes the component's cleanup queue.
When there is no element context, nothing changes: direct calls from @script, and internal consumers like the file upload feature, still register cleanup against the component and still receive an unwatch wrapper that detaches itself.
What this means for your upgrade
There is no API change to migrate. $wire.$watch(path, callback) still returns an unwatch function, and manually calling it — including from x-data — works exactly as before, which the JavascriptUnwatchBrowserTest suite confirms.
If you were hitting issue #10655, the visible symptoms disappear: no more exceptions when a lazy component is removed, and no more zombie components stuck in window.Livewire.all().
One subtle lifecycle shift is worth knowing: a watcher created inside an element now dies with that element rather than surviving until the whole component tears down. That matches $wire.$entangle, and for virtually all real-world code the difference is unobservable. Only if you deliberately depended on a watcher inside a removed element continuing to fire would you notice anything at all.
Coverage is solid: a new regression test in SupportLazyLoading reproduces the placeholder conflict exactly (all 24 tests in that suite pass), SupportFileUploads passes all 10 tests with no collision with its own watcher cleanup, and the SupportJsModules/SupportWireIntersect failures on the branch were confirmed pre-existing and unrelated to this change.
Takeaways
$wire.$watchcleanup is now scoped to the calling element when one is available, aligning it with$wire.$entangle; direct usage from@scriptand internal features like file uploads keep the component-lifetime fallback.- Fixes the crash and memory leak from issue #10655, where lazy components whose placeholder mirrored their real markup left broken watchers that aborted teardown and leaked components into
window.Livewire.all(). - No code changes are required to upgrade: the method signature and the returned unwatch function are unchanged, and manual unwatch still works.
- The fix is guarded by a dedicated lazy-loading regression test, with the full
SupportLazyLoadingandSupportFileUploadssuites passing.
Category: Fix