livewire/livewire: Fix: a broken JS expression no longer wedges the component
Contributed by calebporzio
Part of the livewire v4.4.7 release
If you have ever clicked a button in a Livewire component and watched the loading indicator spin forever while the component silently refused to recover, this release is for you. PR #10500 fixes a failure mode where a single JavaScript expression throwing in the browser — most often triggered by calling a PHP action through $this->js() — would permanently wedge the component: wire:loading never cleared, and awaited action promises never settled. The fix hardens both the expression evaluator and the response pipeline, so the error is reported to the console and the component carries on.
The bug: one bad expression froze the component
The easiest way to trigger it was a very natural mistake. Calling $this->js('someAction', 'foo') is the documented shape for invoking a registered $js action by name with parameters, so reaching for it with a PHP action name feels right:
public function send()
{
// `record` is a regular PHP action, not a registered `$js` action
$this->js('record', 'hi');
}
The name resolves against $js actions only. Since record is a PHP method, nothing matches, and Livewire falls through to evaluating the bare identifier record in the browser — which throws ReferenceError: record is not defined. Until this patch, that throw was fatal in practice: every subsequent interaction still round-tripped to the server, but the stuck loading state never cleared and the component appeared dead.
Why the component stayed wedged
The root cause was an asymmetry in the evaluator. evaluateExpression() — the path used for expressions queued from PHP, including everything sent through $this->js() — had no try/catch, unlike its sibling evaluateActionExpression(). Because Alpine.evaluateRaw() throws synchronously, the error escaped processEffects() and rejected the Alpine.transaction() promise in the response handler. That promise's .then() is where resolveActionPromises() and invokeOnFinish() live, so neither ever ran. Net effect: the action's promise never settles, data-loading never clears, and nothing downstream of the failed transaction executes.
What the fix changes
Two internal files were hardened.
In js/evaluator.js, evaluateExpression() now wraps evaluation in a try/catch and reports failures through a new reportExpressionError() helper shared with evaluateActionExpression(), so both paths log identically:
Livewire Expression Error: record is not defined
Expression: "record"
In js/request/index.js, the response pipeline's cleanup moved from a .then() into a .finally(), so promise resolution and onFinish always run even when the Alpine.transaction() promise rejects. A morphed flag tracks whether the morph actually completed; if it did not, the requestAnimationFrame render-hook block is skipped the same way a skipped message is, so onRender hooks never fire for content that was never painted. The rejection itself still propagates, so any error-handling hooks you registered still observe it.
Importantly, this makes the pipeline resilient to any throwing effect handler, not just this one expression — the behavior was verified separately with a third-party Livewire.hook('effect') that throws.
How it behaves now
Replaying the scenario above: clicking the button still triggers the client-side error, and the console still warns you loudly — the fix is about recovery, not silencing mistakes. But the loading state clears, awaited promises settle, and the component keeps responding. A new browser test, test_a_broken_js_expression_does_not_wedge_the_component, locks this in by asserting that Loading... disappears after the failed response and a subsequent wire:click="increment" still updates the count. A companion test verifies that a throwing effect hook still runs onFinish (documented to run on error) but never fires onRender, since the morph never completed.
The change is well covered: all 18 SupportJsEvaluation browser tests, 50 tests across SupportWireLoading, SupportDataLoading, SupportEvents, and SupportRedirects, and the full suite of 101 JS unit tests pass.
Upgrade impact
Coming from v3.8.10, there is nothing to migrate: no public API changed, no configuration was added, and no markup needs updating. The fix is purely internal JavaScript hardening that ships with the v4 line. If you were already on 4.x and hit the wedged-component symptom, upgrading removes it outright. Plugin and hook authors should note the refined contract: onFinish is now guaranteed to run even when a transaction rejects, while onRender only fires when the morph genuinely completed. And if you hit the underlying bug in your own code, the console error will point you at the offending expression — the real fix is still to point $this->js() at a registered $js action name rather than a PHP method.
Takeaways
- A throwing JS expression from
$this->js()(e.g., passing a PHP action name instead of a registered$jsaction) previously wedged the component with a stuck loading state and promises that never settled. evaluateExpression()now catches and reports errors identically toevaluateActionExpression().- Response cleanup runs in a
.finally(), so action promises resolve andonFinishfires even when effects throw;onRenderis skipped if the morph never completed. - No breaking changes or migrations for apps upgrading from v3.8.10 — behavior is strictly more resilient.