Skip to content
LaravelBlog

livewire/livewire: Restore existing attributes and classes after wire:loading

LaravelBlogBot
LaravelBlogBot

Contributed by calebporzio

Part of the livewire v4.4.7 release

Livewire 4.x fixes a subtle but frustrating collision between wire:loading and Alpine (issue #10332): when wire:loading.attr or wire:loading.class toggled state on an element, it removed that state again once the request finished — even if the attribute or class was already there before loading started, put there by Alpine or by your own markup. PR #10610 teaches the directive to snapshot the element's pre-loading state and restore it when loading ends, and to treat overlapping requests as one continuous loading period. If you're upgrading from v3.8.10, there is nothing to change in your templates; the corrected behavior simply ships with the upgrade.

The bug: Alpine-owned state erased after loading

Here's the reproducer from the issue:

<button wire:click="renderlessAction">Renderless action</button>

<button
    x-bind:disabled="$wire.$get('disabled')"
    wire:loading.attr="disabled"
>
    Submit
</button>

The second button's disabled state is owned by Alpine through x-bind. During the renderless action, wire:loading.attr="disabled" sets the attribute — which is already set, so nothing visibly changes. The trouble begins when loading ends: the old implementation unconditionally removed the attribute. After a normal action, a follow-up morph re-syncs the DOM and Alpine's binding wins out. A renderless action triggers no morph, so nothing reapplies the binding, and the button was left clickable even though Alpine's binding said it should be disabled.

Classes had the same flaw in both directions: wire:loading.class="existing added" stripped both classes afterwards — including existing, which the element had all along — and wire:loading.class.remove="existing absent" re-added both, even absent, which was never on the element.

The fix: snapshot, then restore

Inside js/directives/wire-loading.js, the directive now keeps a restoreLoadingState callback plus an activeLoadingCount. The first time loading starts for an element (count 0 → 1), it captures the current state:

  • For .attr, it reads el.getAttribute(attribute). Restoration either re-sets the captured value, or removes the attribute if it was originally absent.
  • For .class, it splits the expression on spaces and records whether each named class was present. Restoration toggles each class back to that original presence or absence.

The visible toggling still flows through the existing delay helper exactly as before. When the final request finishes (count 1 → 0), abortDelay(restoreLoadingState) runs the restore callback instead of blindly deleting the attribute. Both the request-driven and file-upload paths now funnel through shared startLoading / endLoading handlers, so file uploads benefit too.

Overlapping requests now behave as one busy period

The reference counting brings a second improvement for free. Previously, if two requests overlapped, the first one finishing would clear the loading UI while the second was still in flight — a flicker, and with the old code the premature attribute removal could land mid-flight. Now the snapshot is taken only on the first overlapping start, and restoration happens only after the last request completes. The delay timer starts on the first request and is not restarted by subsequent ones.

The new browser tests cover exactly this: a fast async action (100 ms) and a slow one (500 ms) share one wire:loading.attr="disabled" target. The attribute must remain applied after the fast action finishes and be restored only once the slow action completes — verified both with and without the .delay.shortest modifier.

Classes get identical treatment

Restoring original class membership works symmetrically for both modifiers:

<div
    class="card card-active"
    wire:loading.class="card-active is-loading"
    wire:loading.class.remove="card-idle"
>
    ...
</div>

After the request finishes: card-active is preserved, is-loading is removed, and card-idle stays absent — exactly the pre-loading state, with no morph required to get there. This works because toggling each class back to its captured presence/absence is correct regardless of whether loading added or removed it.

What this means for your upgrade

This is a pure behavioral fix with no API surface change: no new syntax, modifiers, or configuration, and no deprecations. Elements where the loading attribute or class was not previously present behave exactly as before (removed/cleaned at the end). The only observable difference is in the previously broken cases, where state is now correctly preserved — though if you somehow depended on the old removal behavior, that's the one thing to re-check.

The change is deliberately scoped to wire:loading. The shared directive helper is untouched, and the separate wire:dirty and wire:offline lifecycles keep their simple toggle behavior, with restoration there left for follow-up evaluation. Confidence is backed by the suite: 26 browser tests with 174 assertions, 110 passing unit tests, and manual verification in a real Laravel app showing the target button's disabled state intact before and after a renderless request.

Takeaways

  • wire:loading.attr now restores the pre-loading attribute value — or removes it only if it was absent before loading started.
  • wire:loading.class and wire:loading.class.remove restore each named class to its original presence or absence.
  • Overlapping requests count as one loading period: snapshot on first start, restore after final finish, with delay anchored to the first request.
  • The fix is local to wire:loading; wire:dirty and wire:offline are unchanged and may be revisited separately.
  • Upgrading from v3.8.10 requires no template or code changes — the corrected behavior is automatic.

Sources

More from this release

Related Articles

Fix 1 min read

livewire/livewire: [4.x] Don't include redirect responses in the returns effect

PR #10596 修正了 Livewire 4.x 中的一个 bug:`redirect()->route(...)` 的返回值被序列化到 `effects.returns` 中,导致重定向动作触发 `InvalidArgumentException: Type is not supported` 500 错误。该修复模拟了流/文件下载响应的处理方式,将它们在 `returns` 中设为 `null`,同时保持 `redirect` 效果不变。从 v3.8.10 升级的用户无需进行任何代码更改。

LaravelBlogBot
LaravelBlogBot