livewire/livewire: Keep wire:dirty subscribed to every target
Contributed by calebporzio
Part of the livewire v4.4.7 release
This release includes a small but user-visible fix to how Livewire's wire:dirty directive watches multiple targets. If you use a dirty-state indicator that listens to more than one wire:target, you may have seen it go permanently quiet after a commit: it would flag changes on the first target, clear after saving, and then never light up again when a later target changed. This PR ports the 3.x fix (#10616) into the 4.x branch so both release lines behave identically. There are no API, syntax, or configuration changes — only corrected behavior, plus a new browser regression test to lock it in.
What changed
One function was touched: checkDirty() in js/directives/wire-dirty.js, the helper that compares the component's canonical (server-known) state against its reactive (in-browser) state on every reactive pass. In the multi-target branch, an early-exit break was removed, and the comparison now accumulates dirtiness instead of assigning it:
// Before
for (let i = 0; i < targets.length; i++) {
if (isDirty) break;
let target = targets[i]
isDirty = JSON.stringify(dataGet(component.canonical, target)) !== JSON.stringify(dataGet(component.reactive, target))
}
// After
for (let i = 0; i < targets.length; i++) {
let target = targets[i]
let canonical = JSON.stringify(dataGet(component.canonical, target))
let reactive = JSON.stringify(dataGet(component.reactive, target))
if (canonical !== reactive) {
isDirty = true
}
}
Deleting a two-line break looks trivial, but it changes which work the loop skips — and that skipped work was the whole bug. Note also why the "accumulate, don't assign" style matters: once the break is gone, a plain assignment would let a later clean target overwrite an earlier dirty result.
The bug you would have hit
Consider a typical unsaved-changes indicator watching two bound inputs:
<div>
<input type="checkbox" wire:model="first">
<input type="text" wire:model="second">
<div wire:dirty.class="dirty" wire:target="first, second">
Unsaved changes…
</div>
<button type="button" wire:click="$commit">Save</button>
</div>
The failure sequence, as reproduced in the new browser test:
- Check the
firstcheckbox — thedirtyclass appears. Correct. - Click the commit button — canonical state catches up and the indicator clears. Correct.
- Type into the
secondinput — with the bug, the indicator never reappears. The element looks subscribed but is effectively dead for that target until a full reload.
For users, this looks like a randomly unreliable "unsaved changes" warning — the worst kind of bug to debug, because the markup is technically correct.
Why it happened
checkDirty() runs inside a reactive effect that Livewire re-runs whenever watched state changes. Reading a reactive value via dataGet(component.reactive, target) is exactly what registers that target as a dependency with Alpine's effect tracker. The old if (isDirty) break; short-circuit meant that once an earlier target was dirty, the loop stopped before reading the remaining targets — so Alpine never subscribed to them in the first place. When a commit later made the earlier target clean, nothing re-triggered the check for the skipped targets: no dependency, no re-run, no indicator.
The fix makes the loop evaluate every target on every reactive pass, so all targets stay subscribed. That's the meaning of the PR title: keep wire:dirty subscribed to every target, not just the ones visited before the first dirty match.
Upgrade impact
Coming from v3.8.10, this is a pure behavior fix:
- Your
wire:dirty/wire:target="a, b"markup keeps working exactly as written — no template, config, or API changes needed. - The equivalent 3.x fix (#10616) landed after 3.8.10, so upgrading to 4.x brings you the corrected behavior in one step.
- If you worked around the bug — for example by splitting one multi-target directive into several single-target
wire:dirtyelements, or by adding custom JS — you can consolidate back to a single directive now. - Single-target and no-target (
wire:dirtyon the whole component) paths are unchanged. - Performance impact is negligible: every target is serialized on each pass instead of stopping at the first dirty one, which is trivial for realistic target counts.
- If you publish or bundle Livewire's JavaScript yourself, rebuild/refresh that asset so the updated
wire-dirty.jsships to your users.
How it was verified
The PR ships with three layers of confidence: a production asset build, the full Vitest suite (110 passed, 1 skipped), and a focused browser regression test (test_wire_dirty_keeps_tracking_every_target_after_a_commit in SupportDataBinding/BrowserTest.php). That test walks the exact failing sequence above — assert clean, check the first input, assert dirty, commit, wait until clean, type into the second input, assert dirty again — across 5 assertions.
Takeaways
- Fixes
wire:dirtysilently losing tracking on laterwire:targets after one target becomes clean following a commit. - Root cause: an early
breakskipped reading later targets, so Alpine's reactive effect was never subscribed to them. - Ported from 3.x (#10616) into 4.x for release parity — upgrading from v3.8.10 picks this up automatically.
- No syntax or configuration changes; existing multi-target
wire:dirtymarkup is now simply correct. - Workarounds (duplicate directives or custom JS) can be removed.
Category: Fix (id 3)