Skip to content

plugin/fragment: keep blocks in order under keyed x-for - #69

Merged
rameel merged 5 commits into
mainfrom
fragment/x-for-reorder
Oct 25, 2026
Merged

rameel merged 5 commits into
mainfrom
fragment/x-for-reorder

Conversation

@rameel

@rameel rameel commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Fixes #62

rameel and others added 2 commits October 18, 2026 22:13
Since Alpine 3.15.9, keyed x-for moves only the item element and,
for x-if, its single rendered element. The content of x-fragment, x-when
and x-match was rendered before the template and was left behind, so
reordering mixed up the blocks.

Render the content after the template, like x-if, and expose its last
node through _x_currentIfEl and _x_lastRenderedEl, so x-for treats the
whole block as the item. The shared mutation observer then restores
the content of every block in the affected parent right after its
template, following nested blocks and x-if.

The observer also handles removed nodes and the records produced by
deleting nested blocks, and works with Alpine versions that do not
expose the last rendered item of x-for or destroy x-if content.

Fixes #62
Cover reordering, adding, removing and toggling the items of a keyed
x-for for x-fragment, x-when and x-match, checking the exact node
layout and that the existing nodes are moved rather than recreated.
For x-fragment, check all transitions between the orders of four items.

Also cover nested blocks, x-if inside a block and vice versa, a block
containing a keyed x-for of blocks, restoring a moved node of a block,
keeping the initial content of nested x-if and x-for, and that the
observer settles without a feedback loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

📦 Bundle size comparison

Name Size Gzip Brotli Δ
alpinegear-bound.esm.min.js 3.91 KiB 1.80 KiB 1.56 KiB
alpinegear-bound.min.js 3.95 KiB 1.82 KiB 1.58 KiB
alpinegear-destroy.esm.min.js 128 B 123 B 91 B
alpinegear-destroy.min.js 191 B 167 B 123 B
alpinegear-dialog.esm.min.js 1.75 KiB 951 B 811 B
alpinegear-dialog.min.js 1.79 KiB 967 B 822 B
alpinegear-format.esm.min.js 1.18 KiB 645 B 579 B
alpinegear-format.min.js 1.25 KiB 680 B 587 B
alpinegear-fragment.esm.min.js 1.52 KiB (+511 B) 787 B (+248 B) 667 B (+205 B) 🔴
alpinegear-fragment.min.js 1.58 KiB (+511 B) 819 B (+248 B) 700 B (+212 B) 🔴
alpinegear-hotkey.esm.min.js 1.97 KiB 1.08 KiB 964 B
alpinegear-hotkey.min.js 2.03 KiB 1.10 KiB 996 B
alpinegear-main.esm.min.js 7.72 KiB (+511 B) 3.16 KiB (+236 B) 2.79 KiB (+216 B) 🔴
alpinegear-main.min.js 7.69 KiB (+511 B) 3.11 KiB (+229 B) 2.73 KiB (+197 B) 🔴
alpinegear-markdown.esm.min.js 19.27 KiB 7.26 KiB 6.61 KiB
alpinegear-markdown.min.js 19.32 KiB 7.27 KiB 6.63 KiB
alpinegear-match.esm.min.js 1.99 KiB (+511 B) 1005 B (+239 B) 867 B (+197 B) 🔴
alpinegear-match.min.js 2.05 KiB (+511 B) 1.01 KiB (+239 B) 898 B (+208 B) 🔴
alpinegear-router.esm.min.js 9.40 KiB 4.03 KiB 3.59 KiB
alpinegear-router.min.js 9.42 KiB 4.04 KiB 3.60 KiB
alpinegear-safehtml.esm.min.js 806 B 543 B 457 B
alpinegear-safehtml.min.js 853 B 569 B 485 B
alpinegear-template.esm.min.js 421 B 294 B 219 B
alpinegear-template.min.js 483 B 326 B 246 B
alpinegear-typegrab.esm.min.js 471 B 333 B 277 B
alpinegear-typegrab.min.js 509 B 348 B 294 B
alpinegear-when.esm.min.js 1.71 KiB (+511 B) 883 B (+238 B) 759 B (+203 B) 🔴
alpinegear-when.min.js 1.78 KiB (+511 B) 915 B (+240 B) 786 B (+206 B) 🔴

@rameel rameel changed the title Fragment/x for reorder plugin/fragment: keep blocks in order under keyed x-for Oct 21, 2026
@rameel

rameel commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

This fix fails specific tests:

<div x-data="{ outer: [1, 2], inner: [1, 2] }">
  <button @click="outer = [2, 1]">outer = [2, 1]</button>

  <div id="target">
    <template x-for="o in outer" :key="o">
      <template x-fragment>
        <h2 x-text="o"></h2>
        <template x-for="n in inner" :key="n">
          <b x-text="o + '-' + n"></b>
        </template>
        <footer>F</footer>
      </template>
    </template>
  </div>
</div>

After outer = [2, 1], the expected output is 22-12-2F11-11-2F, but on

  • 3.16.2: 2F1F1-11-22-12-2
  • 3.14.1: 2F1-11-21F2-12-2

the inner loop's <b> elements end up after all the outer blocks, and <footer> appears right after <h2>.

@rameel
rameel marked this pull request as draft October 23, 2026 20:02
@rameel rameel self-assigned this Oct 23, 2026
…blocks

Alpine before 3.14.2 removes x-if content and x-for items without
destroying them, leaving that to its mutation observer. Deleting
a block within mutateDom paused the observer, so the items of x-for
inside x-if, and the content of x-if items of x-for, stayed in the DOM.

Delete the block outside of mutateDom, which also makes deleting
the blocks left behind from the shared observer unnecessary.
@rameel
rameel marked this pull request as ready for review October 24, 2026 16:18
@rameel

rameel commented Oct 24, 2026

Copy link
Copy Markdown
Owner Author

I'd rather not fix in x-fragment what Alpine itself can't do.

Keyed x-for doesn't keep the order when an item renders sibling content through nested templates: x-for > x-for, x-for > x-if > x-if, x-for > x-if > x-for (alpinejs/alpine#4911). This happens in every version from 3.14.1 to 3.17.5. A block with a nested x-for or x-if at its top level is the same case. Most of the code added here only works around that - it rebuilds the order of nested x-for items from _x_lookup and intercepts _x_lookup to flush pending mutations.

So I'd keep only what Alpine supports and the unsupported cases fail the same way as in Alpine. With alpinejs/alpine#4912 they work without any code on our side.

Alpine's keyed x-for does not keep nested templates of its items in
order when the list changes (alpinejs/alpine#4911). The previous changes
worked around this for templates placed directly in a block, which took
most of anchor_block: tracking the items of nested x-for lists and
intercepting their lookups. This is a limitation of Alpine itself, and
alpinejs/alpine#4912 is expected to fix it, so we no longer work around
it.

anchor_block now restores only the block's own nodes, and only when they
are out of order. Content that a nested x-if, x-for or block renders
between them is left as is. It is never lost or duplicated, but it can
end up out of order, as with nested templates in plain Alpine x-for
items.

Mark the nested template reordering cases as test.fail, so they start
failing once Alpine includes alpinejs/alpine#4912
@rameel
rameel merged commit 2da5960 into main Oct 25, 2026
2 checks passed
@rameel
rameel deleted the fragment/x-for-reorder branch October 25, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin/fragment: node order breaks after updating Alpine.js

1 participant