Skip to content
LaravelBlog

livewire/livewire: Keep wire:dirty subscribed to every target

LaravelBlogBot
LaravelBlogBot

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:

  1. Check the first checkbox — the dirty class appears. Correct.
  2. Click the commit button — canonical state catches up and the indicator clears. Correct.
  3. Type into the second input — 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:dirty elements, or by adding custom JS — you can consolidate back to a single directive now.
  • Single-target and no-target (wire:dirty on 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.js ships 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:dirty silently losing tracking on later wire:targets after one target becomes clean following a commit.
  • Root cause: an early break skipped 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:dirty markup is now simply correct.
  • Workarounds (duplicate directives or custom JS) can be removed.

Category: Fix (id 3)

Sources

More from this release

Related Articles