livewire/livewire:
Contributed by cyppe
Part of the livewire v4.4.7 release
Livewire fires a lot of requests on its own behalf — #[On] event listeners, wire:model.live updates, wire:click="$set(...)", file uploads — and none of them has application code awaiting the result. When one of those requests failed, the browser logged an Uncaught (in promise) error on top of the error modal Livewire had already shown, and each one fired an unhandledrejection event that error trackers dutifully recorded as a page error. PR #10696 fixes this at the source: internally-fired requests that fail no longer surface as unhandled promise rejections, while code that explicitly awaits or .catch()es a $wire call still receives the identical rejection. If you are upgrading from v3.8.10, this arrives in 4.x with nothing to change in your app — just a quieter console and cleaner tracker signal.
The noise: fire-and-forget requests that fail
Consider this component:
#[On('foo')]
public function onFoo()
{
throw new \Exception('The listener failed.');
}
<button @click="$dispatch('foo')">Dispatch</button>
When the dispatched request fails, Livewire shows its error modal — and then the console reports:
Uncaught (in promise) {status: 500, body: '…', json: {…}, errors: null}
The action promise rejects with that { status, body, json, errors } shape after Livewire has already surfaced the failure. Because nobody holds a reference to the promise, JavaScript treats it as unhandled. The same applies to wire:model.live commits, wire:click="$set('open', true)", and file uploads — every internal request path, in other words.
Why it matters
Every unhandled rejection fires the browser's unhandledrejection event, which Sentry, Bugsnag, and in-house reporters log as a page error. The worst case is routine: after a deployment that rotates the release token, every stale open tab produces one rejection per #[On] listener or live input the moment it tries to reach the server — potentially dozens of "errors" that are really just expired sessions.
v3 already had a partial patch: the expression evaluator silently caught promises carrying an internal _livewireAction marker, covering wire:click, wire:poll, wire:init, and wire:intersect. But paths outside the evaluator leaked — event dispatches, wire:model commits, uploads — and even evaluator-driven calls leaked when the $wire method was an async wrapper, because the wrapper handed back a copy of the promise without the marker. $set and $call were still async, so $set and $toggle clicks produced noise (PR #10428 fixed exactly this for $refresh and $commit).
The fix: mark the promise handled at the source
Two coordinated changes:
- The
Actionclass now attaches a no-op rejection handler to its own promise when it is constructed:
// js/request/action.js
this.promise.catch(() => {})
Attaching a handler marks a promise as handled without changing its state or what other consumers observe. A discarded promise therefore never becomes an unhandledrejection, while an awaiting or .catch()ing caller still receives the identical rejection object.
- For that one line to cover everything, every
$wireentry point must hand back the action's own promise rather than an async wrapper's copy.$setand$calldrop theirasynckeyword accordingly — the same treatment #10428 gave$refreshand$commit.
With all paths covered, the _livewireAction marker and the evaluator's special-case branch became dead code and were removed; supportListeners.js reverts to its original two lines.
Explicit callers still get the rejection
Deliberately handling a failure is unchanged:
<button x-on:click="$wire.explode().catch(error => console.log(error.status))">
Explode
</button>
Here error.status is still 500, exactly as before. The new browser tests assert both sides of the contract: a failed wire:model.live update and a failed wire:click="$set(...)" each show the error modal while unhandledrejection fires zero times (both leaked before this PR), and $wire.explode().catch(...) still captures status: 500.
Upgrading from v3.8.10
- No application changes required.
await $wire.$set('open', true)and friends behave exactly as before —$setand$callstill return promises; they just return the action's promise directly instead of anasynccopy. - You can drop workarounds. If you added global
unhandledrejectionhandlers or tracker filtering specifically to swallow Livewire'sUncaught (in promise)noise, that code is now redundant. - One deliberate quieting. A
#[Json]method that is fired and forgotten — noawait, no.catch()— and then fails is now silent, where it previously loggedUncaught (in promise) {status: 403, …}. This matches whatwire:click="jsonMethod"already did, and#[Json]methods are meant to be awaited, but it is the one place the console gets quieter without the error modal taking its place. To observe a JSON call's failure, await it or attach a.catch().
Takeaways
- Failed fire-and-forget requests —
#[On]listeners,wire:model.live,$set/$toggleclicks, file uploads — no longer produceUncaught (in promise)errors orunhandledrejectionevents. - Livewire still surfaces failures itself via the error modal and expired-page dialog; nothing is hidden from callers that
awaitor.catch(). $setand$callnow return the action promise directly rather than anasyncwrapper's copy; promise-based usage is unaffected.- Expect cleaner error-tracker signal after release-token rotations — no more one rejection per stale listener or live input.
- Fire-and-forget
#[Json]method failures are now console-silent; await or.catch()them if you need visibility.