Skip to content
LaravelBlog

livewire/livewire: fix

LaravelBlogBot
LaravelBlogBot

Contributed by joshhanley

Part of the livewire v4.4.7 release

Livewire 4.x includes a small but meaningful fix to the JavaScript lifecycle layer: manually calling the unwatch function returned by $wire.$watch now also removes the watcher's entry from the component's cleanup registry. Previously, that cleanup lingered until the entire component was destroyed, and Livewire's own file-upload feature was the most visible victim — a conditionally rendered wire:model file input leaked one component-lifetime cleanup (plus a retained, detached DOM subtree) on every mount/removal cycle. If you are upgrading from v3.8.10, there is nothing to change in your code, but long-lived components that toggle file inputs on and off will stop quietly accumulating hidden state. The change supersedes #10347 by moving the fix into $wire.$watch itself, while preserving that PR's regression test and attribution.

What changed

Three related edits landed in this PR.

First, Component::addCleanup() now returns a remover for the cleanup it just registered:

addCleanup(cleanup) {
    this.cleanups.push(cleanup)

    return () => {
        let index = this.cleanups.indexOf(cleanup)

        if (index === -1) return

        this.cleanups.splice(index, 1)
    }
}

Second, $wire.$watch composes that remover into the unwatch function it hands back, so disposal happens in one call:

let unwatch = Alpine.watch(getter, callback)

let removeCleanup = component.addCleanup(unwatch)

return () => {
    removeCleanup()
    unwatch()
}

Third, the file-upload feature now holds onto its watcher and releases it in the element's own cleanup, along with the previously missed livewire-upload-cancel listener:

let unwatch = component.$wire.$watch(property, (value) => {
    if (! el.isConnected) return
    // reset the input's value when the property is cleared...
})

cleanup(() => {
    el.removeEventListener('change', eventHandler)
    el.removeEventListener('click', clearFileInputValue)
    el.removeEventListener('livewire-upload-cancel', clearFileInputValue)

    unwatch()
})

The leak, step by step

Consider a component that conditionally renders a file input:

<button wire:click="$toggle('showUpload')">Toggle</button>

@if ($showUpload)
    <input type="file" wire:model="photo">
@endif

Every time the input mounts, Livewire registers a $wire.$watch so the input's value resets when the photo property is cleared. Before this fix, that watcher's cleanup was pushed onto component.cleanups — a list that is only flushed when the whole component is torn down. When the @if branch removed the input, the element's directive cleanup ran the unwatch function, but that only detached the Alpine watcher; the closure stayed in component.cleanups for the component's remaining lifetime.

Because each lingering closure references el, the browser cannot garbage-collect the removed input. If the input came from a larger conditional branch, the entire detached subtree is retained too. Toggle the input on and off five times and you have five stranded cleanups, each pinning dead DOM in memory.

Why it matters

The leak was invisible in short page loads but painful in long-lived components — dashboards, modals, multi-step wizards, or any Alpine-driven SPA-style screen where a Livewire component stays mounted for minutes or hours. Repeatedly showing and hiding an upload form steadily grew the cleanups array and the retained DOM, degrading memory usage and eventually performance for the whole tab.

The fix is also a general correctness improvement, not just a file-upload patch. Anything built on $wire.$watch — your own directives, third-party plugins — now truly disposes its state when it manually unwatches, instead of leaving a tombstone behind until component teardown.

Upgrade impact

The impact for applications upgrading from v3.8.10 to 4.x is minimal:

  • $wire.$watch still returns an unwatch function with the same signature. Calling it simply does more now (removes the component cleanup, then unwatches). Calling it multiple times is safe — the remover exits early once the entry is gone.
  • addCleanup() gains an optional return value. Code inside Livewire core and any plugin authors mirroring this pattern can use the returned remover for symmetric teardown; existing calls that ignore the return value behave exactly as before.
  • One subtle behavior is intentionally preserved: the file-upload callback still guards with if (! el.isConnected) return, because Alpine may have already queued the callback before the input was removed. Removing the cleanup early does not change that.
  • A new browser test in SupportFileUploads/BrowserTest.php mounts and tears down a conditional file input several times and asserts that component.cleanups.length stays constant — the regression cannot silently return.

There are no template, PHP, or configuration changes to make. If you only consumed $wire.$watch from userland JavaScript, your upgrade path is unchanged; the leak simply stops happening.

Takeaways

  • The unwatch function returned by $wire.$watch now removes the watcher's component-level cleanup before unwinding the Alpine watcher.
  • Component::addCleanup() returns a remover function — a new, optional API for internals and plugin authors.
  • Conditionally rendered wire:model file inputs no longer leak a cleanup (and their detached DOM subtree) per mount/removal cycle; the livewire-upload-cancel listener cleanup was added as well.
  • No action is required when upgrading from v3.8.10 — the fix is transparent, with a browser regression test locking it in.

Sources

More from this release

Related Articles