livewire/livewire: Fix wire action collisions with Alpine scope variables
Contributed by calebporzio
Part of the livewire v4.4.7 release
Upgrading from v3.8.10, you'll pick up a targeted fix for how Livewire evaluates wire:* action expressions when the element — or one of its parents — defines an Alpine scope containing a variable that shares the action's name. In affected versions, wire:click="open(1)" inside <div x-data="{ open: false }"> resolved open to the Alpine boolean instead of the Livewire component method, so the click either threw a JavaScript error or silently did nothing. Pull request #10561 changes the evaluator so the leading target of an action expression always refers to the Livewire component, while everything else — reactive directives, action parameters, assignments — continues to resolve against the Alpine scope exactly as before.
The bug: when Alpine shadows your action
Livewire's contextual evaluator rewrites shorthand action expressions by prefixing "unknown" identifiers with $wire., which is what makes wire:click="open(1)" work as a shortcut for the component's open() method. To avoid stomping on Alpine state, the evaluator also collected every Alpine scope key between the element and the component root and deliberately skipped those identifiers.
That courtesy backfired when the names collided:
<div x-data="{ open: false }">
<button wire:click="open(1)">Open</button>
</div>
Here open is both a Livewire method and an Alpine scope key. The evaluator skipped it, Alpine tried to evaluate open(1) against the boolean false, and the result was a "not a function" error or a silent no-op, depending on the directive. The same applied to bare statements such as wire:submit="save;" inside x-data="{ save: false }". That was issue #10558.
What changed in the evaluator
The fix in js/evaluator.js introduces a "prefer wire action" mode that is enabled automatically whenever an action expression is evaluated in a non-reactive context — wire:click, wire:submit, wire:keydown, and friends, as opposed to bindings like wire:text or wire:show.
The mechanics:
- A new helper,
getActionTargetOffset(), locates the leading identifier of the expression and verifies it is genuinely being invoked or executed as a statement: everything after it (ignoring whitespace and comments) must be empty, start with(, or start with;. - When that identifier also appears in the collected Alpine scope keys, it is prefixed with
$wire.anyway — the Livewire method wins for the target of the action. - String literals and JavaScript comments are now shielded before rewriting (comments previously weren't protected), so expressions like
open /* toggle */ (user)are handled correctly.
Anything that isn't a plain action invocation is left untouched. wire:click="open = !open" still evaluates as an Alpine assignment, because the leading identifier is followed by =, not a call or semicolon.
What's deliberately preserved
The fix is scoped narrowly on purpose. Reactive bindings still resolve against the Alpine scope first, so this behavior is unchanged:
<div x-data="{ open: 'alpine' }">
<span wire:text="open"></span> <!-- still renders "alpine" -->
</div>
Likewise, only the leading target of an action is forced onto $wire. — parameters keep their Alpine scope resolution:
<div x-data="{ user: { id: 7 } }">
<button wire:click="open(user.id)">Open</button>
</div>
open becomes $wire.open (the Livewire method), while user still resolves to the Alpine scope object. The new unit tests assert exactly this: open(user) rewrites to $wire.open(user).
Upgrade impact
For the vast majority of components this is a pure bug fix: actions that previously collided with Alpine scope variables now call your Livewire methods, with no template changes required.
There is one edge case worth checking. If you relied on the old behavior — a wire:* action whose name matched an Alpine-scope function you intended to call — that expression now targets the Livewire component instead. The clean solution is to use x-on:click for pure Alpine handlers and reserve wire:click for Livewire actions; alternatively, rename one of the colliding identifiers.
As a bonus, target detection now tolerates trailing semicolons and inline comments (wire:submit="save;"), so multi-statement-style expressions no longer trip the detection and fall through to the wrong scope.
How it was verified
The PR adds unit coverage in evaluator.spec.js asserting the new precedence rules (open(user) → $wire.open(user), save; → $wire.save;, comments preserved, assignments untouched), plus a full browser test in SupportJsEvaluation/BrowserTest.php that clicks through both collision scenarios and confirms a wire:text binding still reads the Alpine value. The JavaScript suite ran with 110 passing tests (1 skipped), and the PHPUnit browser suite passed with 20 tests and 29 assertions.
Takeaways
wire:click="open(1)"insidex-data="{ open: false }"now calls the Livewire method instead of erroring on the Alpine boolean — no code changes needed.- Reactive directives (
wire:text,wire:show) and action parameters still resolve against the Alpine scope exactly as before. - Assignments like
wire:click="open = !open"remain untouched; only invoked or executed leading identifiers are redirected to$wire. - If you intentionally called an Alpine-scope function from a
wire:*attribute, switch tox-on:clickor rename one of the colliding names to keep that behavior.