livewire/livewire: Recover from failed server responses by reverting rejected updates
Contributed by calebporzio
Part of the livewire v4.4.7 release
This patch fixes a failure mode where a single rejected request could permanently break a Livewire component until a full page refresh. Previously, if the server errored while applying a property update — for example a stray dd() or an abort(500) inside an updated hook — the update was never "accepted", so the client kept re-sending it with every subsequent request. Dismissing the error modal only meant the next click or keystroke failed the same way. This release reverts server-rejected property updates on the client so the component recovers on its own, and it cleans up the unhandled promise rejections those failures produced. There are no public API changes, so if you're on v3.8.10 this is a drop-in bugfix upgrade.
The bug: one 500 could wedge a component
The reproduction is disarmingly simple. Throw, abort, or dump from an updated hook:
public $name = '';
public function updatedName($value)
{
dd($value); // simulates an unhandled server error
}
Bind an input with wire:model.live and type into it: the request 500s and the error modal appears. Dismiss the modal, and the component is now broken for good — an unrelated wire:click elsewhere in the same component fails with the identical error and re-opens the modal, over and over.
Root cause: the rejected update was replayed forever
Livewire tracks pending changes as a diff between two pieces of client state: the canonical snapshot the server last accepted, and the ephemeral state the user is currently interacting with. When a request succeeds, the diff drains. When the server rejects an update, nothing drains it — getUpdates() keeps producing the same diff, so every subsequent message re-sends the failing property update. The server then dies while applying updates, before it ever reaches the action you actually called, which is why even unrelated interactions failed.
A second, smaller symptom: fire-and-forget actions like wire:model.live and wire:click never await their promises, so the rejection had no listener and surfaced in the console as an unhandled promise rejection.
The fix: revert rejected updates instead of retrying them
Three coordinated changes in the JavaScript layer:
- Rejected updates are rolled back.
Message.invokeOnErrornow calls a newComponent.revertUpdates(), which resets each rejected path to its last server-accepted (canonical) value, or removes it if the server never had it. Because client state matches the server again, the diff disappears and the failing update is no longer re-sent. - In-flight edits are respected. If the user changed a property again while the failed request was in flight, that path is left alone — the newer local edit wins instead of being clobbered by the rollback.
- Fire-and-forget promises are tamed.
Actionattaches an internal no-op rejection handler so unawaited actions no longer produce unhandled rejections, and$set,$commit, and$refreshreturn the action's own promise rather than wrapping it in anasyncfunction that re-wrapped (and leaked) the rejection.
Network failures are deliberately untouched: changes made while offline still queue and retry exactly as before. Only server-rejected updates are reverted.
What recovery looks like after the upgrade
Back to the dd() example: you type, the modal appears, you press Escape — and the input reverts to its last synced value. The component is immediately interactive again: unrelated actions succeed, and typing again re-triggers the updated hook normally.
If you await $wire actions from Alpine or your own JS, error handling keeps working as you'd expect:
// Fire-and-forget: no more unhandled promise rejection in the console
$wire.set('name', 'Taylor')
// Awaited: rejections still reach your catch block
try {
await $wire.set('name', 'Taylor')
} catch (error) {
console.error('The server rejected this update', error)
}
The fix is covered by a new browser test in SupportErrorResponses that triggers a 500 from an updated hook, dismisses the dialog, asserts the failed update was reverted, and confirms an unrelated action then succeeds.
Upgrade impact
Coming from v3.8.10, there is nothing to migrate: no config, template, or API changes. The only observable behavior change is the desired one — after a server error, client-side property values the server rejected now snap back to their last synced value instead of lingering and poisoning every future request. If you happened to rely on the old behavior of keeping rejected values rendered locally (unlikely, given it wedged the component), you'll now see the canonical value until the user edits that property again.
Takeaways
- A failed request no longer permanently breaks a component; recovery is automatic after dismissing the error modal.
- Server-rejected property updates revert to their last synced value and stop being re-sent.
- Edits made while the failed request was in flight are preserved.
- Offline/network-failure retries are unchanged.
- No public API changes — upgrade from v3.8.10 by simply pulling the patched release.