livewire/livewire: Fix stale reactive snapshots for overlapping `wire:model.live` requests
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.liveparent 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 propcrashes and silent state loss caused by overlappingwire:model.liverequests on parents with reactive/modelable children. - Overlapping requests now bundle fresh parent snapshots (
[0, 1], not[0, 0]), so server-side accumulation inupdated*()hooks is no longer lost. - Parallel
model.livebehavior 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.