Skip to content
LaravelBlog

livewire/livewire: Fix stale reactive snapshots for overlapping `wire:model.live` requests

LaravelBlogBot
LaravelBlogBot

Contributed by cyppe

Part of the livewire v4.4.7 release

Category: Fix

Pull request #10061 lands in Livewire 4.x and fixes a race condition in how overlapping wire:model.live requests are scheduled when a parent component passes reactive or modelable data down to its children. Before this change, two requests fired in quick succession could both bundle the same stale parent snapshot, triggering Cannot mutate reactive prop errors and — more insidiously — silently losing server-side state updates. If you're upgrading from v3.8.10, there is nothing to change in your application code: this is a behavioral fix inside Livewire's JavaScript request coordination, and most components never touch the affected code path.

What changed

The change is confined to js/request/interactions.js, the module that decides whether an incoming network action may run alongside one that is already in flight. Livewire has always allowed overlapping wire:model.live requests to run in parallel — that is what keeps rapid typing from feeling laggy. That fast-path remains, but it is now gated by a new check.

A new helper, componentHasBoundChildren(), walks a component's deep children and detects whether any of them are attached via reactive or modelable bindings. If neither the incoming nor the in-flight request's component has bound children — the overwhelmingly common case — Livewire takes the parallel fast-path exactly as before, with zero performance impact. If either side does have bound children, the incoming action falls through to the existing defer/queue machinery and runs only after the in-flight message completes, guaranteeing it bundles a fresh parent snapshot.

A regression browser test was added to src/Features/SupportReactiveProps/BrowserTest.php covering the exact production failure: two keystrokes sent while the server holds the first request open.

The bug: two writers, one stale snapshot

To understand why the parallel fast-path is unsafe with nested bindings, remember that reactive (#[Reactive]) and modelable children don't send their own requests for these updates. They are bundled into the parent's request together with a snapshot of the parent's state. When two wire:model.live requests overlap, both bundle the same pre-update snapshot, and two bad things follow:

  • The second server round-trip processes from stale state. Hooks like updatedSearch() increment counters, append to arrays, or toggle flags starting from the same base value, so the second write clobbers the first and the increments are silently lost.
  • The bundled child commit tries to mutate a reactive prop against an outdated snapshot, which throws Cannot mutate reactive prop [facets] at runtime.

The new test intercepts outgoing requests and records the parent snapshot's updateCount in each payload: without the fix it reads [0, 0] (both requests carry the initial snapshot); with the fix it reads [0, 1] (the second request waits, then carries updated state).

Before and after, in code

Consider a parent with a fast-debounced live input feeding a reactive child:

<div>
    <input type="text" wire:model.live.debounce.5ms="search">

    <livewire:facet-list :facets="$facets" />
</div>
public function updatedSearch(): void
{
    $this->updateCount++;

    $this->facets['query']['values'][0]['value'] = $this->search;
}
class FacetList extends Component
{
    #[Reactive]
    public array $facets = [];

    public function render()
    {
        return <<<'HTML'
            <div>{{ $facets['query']['values'][0]['value'] ?? '' }}</div>
        HTML;
    }
}

Type b, then o, quickly enough that the requests overlap. Before this fix, the server ran updatedSearch() twice against the same starting state: updateCount ended at 1 instead of 2, the child rendered only o, and the mutation of $facets could abort the whole request with the reactive-prop error. After the fix, the second request waits for the first to finish: updateCount reaches 2 and the child correctly renders bo.

Upgrade impact

For teams moving from v3.8.10 to 4.x, the impact is minimal and almost entirely positive:

  • No API changes and no code changes required. Components that already worked continue to work; components that intermittently crashed under fast input now don't.
  • Scheduling nuance for nested components. If a wire:model.live parent has reactive or modelable children, genuinely overlapping requests are now serialized instead of parallelized. Debouncing still coalesces most keystrokes, so deferral only kicks in when requests truly overlap. The worst case is a keystroke response arriving marginally later — a fair trade for correct state.
  • Plain components are untouched. The parallel fast-path is preserved whenever neither request involves bound children, so typing latency is unchanged for the typical case.
  • Test expectations. If you use Livewire.interceptRequest() in browser tests against parents with reactive children, request ordering may now be sequential where it was previously concurrent.

Takeaways

  • Fixes Cannot mutate reactive prop crashes and silent state loss caused by overlapping wire:model.live requests on parents with reactive/modelable children.
  • Overlapping requests now bundle fresh parent snapshots ([0, 1], not [0, 0]), so server-side accumulation in updated*() hooks is no longer lost.
  • Parallel model.live behavior is fully preserved for components without bound children — no performance regression.
  • Upgrading from v3.8.10 requires no code changes; only request timing changes, and only in the nested-binding scenario.

Sources

Related Articles