Skip to content
LaravelBlog

livewire/livewire: Fix late-added wire:sort:item attributes

LaravelBlogBot
LaravelBlogBot

Contributed by joshhanley

Part of the livewire v4.4.7 release

Pull request #10391 fixes a timing bug in Livewire's wire:sort feature. When a wire:sort:item attribute reaches an element after Alpine has already initialized it — most commonly on the root of a lazy child component, but also when the attribute is only rendered conditionally — the element was never registered as a sortable item, so dragging it sent a missing item ID to your handler. The fix adds a narrowly scoped hook that initializes late-added wire:sort:item directives during morphs, while leaving the normal boot path untouched. It merges into the 4.x branch, so anyone upgrading from v3.8.10 picks it up as part of v4 with no migration steps.

The bug in practice

wire:sort pairs a container directive with per-item IDs:

<ul wire:sort="sortItems">
    <livewire:child wire:key="item-1" wire:sort:item="item-1" lazy>
        Item 1
    </livewire:child>

    <livewire:child wire:key="item-2" wire:sort:item="item-2" lazy>
        Item 2
    </livewire:child>
</ul>

The same shape appears when you toggle the attribute on plain elements, like <li wire:key="item-1" @if ($enabled) wire:sort:item="item-1" @endif>. With lazy children, Livewire keeps the placeholder root in the DOM and morphs the rendered child in afterward — forwarding wire:sort:item onto an element Alpine already initialized. From that point, dragging the item dispatched the sort action without a usable item ID, so a handler like sortItems($item, $position) received nothing to persist.

Why it only broke after boot

supportWireSort.js translated wire:sort:item into Alpine's x-sort:item inside Alpine.interceptInit. That hook fires once, when Alpine first walks the DOM — perfect for the initial page load, silent for anything that happens later. Livewire's morph, however, routinely attaches attributes to elements that already exist, and interceptInit does not run again for those. Livewire's own directive.init hook fires both during the initial directive pass and for directives added during later morphs — exactly the gap this PR closes.

What the fix does

Rather than replace the entire sort initialization path — which would unnecessarily change the lifecycle of wire:sort, group directives, and Alpine's clone handling — the PR keeps interceptInit intact and supplements it with a listener for late-added item directives:

on('directive.init', ({ el, directive }) => {
    if (! directive.rawName.startsWith('wire:sort:item')) return

    // Already initialized by interceptInit — skip.
    if (el._x_sort_key !== undefined) return

    bindSortItem(el, directive)
})

The binding logic itself moved into a shared bindSortItem() function used by both paths. Two details make this safe: the guard on el._x_sort_key means items initialized through the normal boot path are never bound twice, and the strict wire:sort:item name check means wire:sort and wire:sort:group-id behavior is untouched.

Upgrade impact for v3.8.10 users

There is no public API change: wire:sort, wire:sort:item, and group directives keep their signatures, and no configuration is involved. The impact is purely behavioral:

  • Sort handlers now always receive the item ID, whether the attribute was present at boot or added by a later morph.
  • The classic failure — a missing item ID when sorting the roots of lazy child components — is resolved (fixes #10325).
  • If you worked around the bug by avoiding lazy, forcing eager loads, or re-rendering whole lists to re-trigger initialization, you can likely simplify that code.

The PR also hardens teardown: removing wire:sort from a container and then removing the container no longer produces browser errors.

New browser test coverage

Three browser tests were added, and they exercise the behavior through real WebDriver drag interactions rather than calling SortableJS internals:

  1. A conditionally added wire:sort:item on an already-rendered <li> — after clicking "Enable", dragging item 2 above item 1 reports item:item-2,position:0.
  2. Lazy child components with a Loading... placeholder — the test waits for the real child to render, drags, and asserts the correct item ID and position.
  3. A lifecycle check that removes wire:sort, removes the sortable element, and asserts assertConsoleLogHasNoErrors() to guard the new hook against teardown regressions.

Takeaways

  • Late-added wire:sort:item attributes now initialize correctly during morphs; sort handlers always receive the item ID.
  • Fixes missing/undefined item IDs when sorting lazy child component roots (issue #10325).
  • Implemented as a shared bindSortItem() plus a narrow directive.init hook, deduplicated via el._x_sort_key; wire:sort and group directives are unchanged.
  • No upgrade steps, API changes, or config changes — ships in the 4.x series for anyone coming from v3.8.10.
  • Covered by real-drag browser tests, including a teardown scenario.

Sources

More from this release

Related Articles