Skip to content

Commit c8c849d

Browse files
calebporzioNightFurySL2001claude
committed
Support multiple root elements in x-for templates
x-for previously used .firstElementChild to clone exactly one element per iteration, silently discarding any siblings. This made it impossible to render multiple <td> elements per iteration inside a table row — a common need with no clean workaround. When the template has multiple children, x-for now clones the full fragment and uses comment-node boundary markers to track each iteration group. Single-element templates are unchanged (no markers, no overhead). Co-Authored-By: NFSL2001 <NightFurySL2001@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b357068 commit c8c849d

File tree

3 files changed

+311
-41
lines changed

3 files changed

+311
-41
lines changed

packages/alpinejs/src/directives/x-for.js

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,19 @@ directive('for', (el, { expression }, { effect, cleanup }) => {
2121
effect(() => loop(el, iteratorNames, evaluateItems, evaluateKey))
2222

2323
cleanup(() => {
24-
el._x_lookup.forEach(el =>
24+
el._x_lookup.forEach(entry =>
2525
mutateDom(() => {
26-
destroyTree(el)
27-
28-
el.remove()
26+
if (entry.startComment) {
27+
entry.elements.forEach(child => {
28+
destroyTree(child)
29+
child.remove()
30+
})
31+
entry.startComment.remove()
32+
entry.endComment.remove()
33+
} else {
34+
destroyTree(entry)
35+
entry.remove()
36+
}
2937
})
3038
)
3139

@@ -42,6 +50,8 @@ function refreshScope(scope) {
4250
}
4351

4452
function loop(templateEl, iteratorNames, evaluateItems, evaluateKey) {
53+
let isFragment = templateEl.content.children.length > 1
54+
4555
evaluateItems(items => {
4656
// Prepare yourself. There's a lot going on here. Take heart,
4757
// every bit of complexity in this function was added for
@@ -83,46 +93,101 @@ function loop(templateEl, iteratorNames, evaluateItems, evaluateKey) {
8393
})
8494

8595
mutateDom(() => {
86-
oldLookup.forEach((el) => {
87-
destroyTree(el)
88-
el.remove()
96+
// Cleanup removed items
97+
oldLookup.forEach((entry) => {
98+
if (isFragment) {
99+
entry.elements.forEach(el => {
100+
destroyTree(el)
101+
el.remove()
102+
})
103+
entry.startComment.remove()
104+
entry.endComment.remove()
105+
} else {
106+
destroyTree(entry)
107+
entry.remove()
108+
}
89109
})
90110

91111
let added = new Set()
92112

93113
let prev = templateEl
94114
scopeEntries.forEach(([key, scope]) => {
95115
if (lookup.has(key)) {
96-
let el = lookup.get(key)
97-
el._x_refreshXForScope(scope)
98-
99-
if (prev.nextElementSibling !== el) {
100-
if (prev.nextElementSibling)
101-
el.replaceWith(prev.nextElementSibling)
102-
prev.after(el)
103-
}
104-
prev = el
105-
106-
if (el._x_currentIfEl) {
107-
if (el.nextElementSibling !== el._x_currentIfEl)
108-
prev.after(el._x_currentIfEl)
109-
prev = el._x_currentIfEl
116+
let entry = lookup.get(key)
117+
entry._x_refreshXForScope(scope)
118+
119+
if (isFragment) {
120+
let { startComment, endComment, elements } = entry
121+
122+
// Check if group is already in correct position
123+
if (prev.nextSibling !== startComment) {
124+
prev.after(startComment)
125+
let cursor = startComment
126+
elements.forEach(el => {
127+
cursor.after(el)
128+
cursor = el
129+
})
130+
cursor.after(endComment)
131+
}
132+
prev = endComment
133+
} else {
134+
if (prev.nextElementSibling !== entry) {
135+
if (prev.nextElementSibling)
136+
entry.replaceWith(prev.nextElementSibling)
137+
prev.after(entry)
138+
}
139+
prev = entry
140+
141+
if (entry._x_currentIfEl) {
142+
if (entry.nextElementSibling !== entry._x_currentIfEl)
143+
prev.after(entry._x_currentIfEl)
144+
prev = entry._x_currentIfEl
145+
}
110146
}
111147
return
112148
}
113149

114-
let clone = document.importNode(templateEl.content, true).firstElementChild
115-
let reactiveScope = reactive(scope)
116-
addScopeToNode(clone, reactiveScope, templateEl)
117-
clone._x_refreshXForScope = refreshScope(reactiveScope)
118-
119-
lookup.set(key, clone)
120-
added.add(clone)
121-
122-
prev.after(clone)
123-
prev = clone
150+
// New item — clone and insert
151+
if (isFragment) {
152+
let startComment = document.createComment(' [x-for] ')
153+
let endComment = document.createComment(' [/x-for] ')
154+
let fragment = document.importNode(templateEl.content, true)
155+
let elements = Array.from(fragment.children)
156+
157+
let reactiveScope = reactive(scope)
158+
let group = { startComment, endComment, elements }
159+
160+
elements.forEach(el => {
161+
addScopeToNode(el, reactiveScope, templateEl)
162+
})
163+
164+
group._x_refreshXForScope = refreshScope(reactiveScope)
165+
166+
lookup.set(key, group)
167+
168+
prev.after(startComment)
169+
let cursor = startComment
170+
elements.forEach(el => {
171+
cursor.after(el)
172+
cursor = el
173+
added.add(el)
174+
})
175+
cursor.after(endComment)
176+
prev = endComment
177+
} else {
178+
let clone = document.importNode(templateEl.content, true).firstElementChild
179+
let reactiveScope = reactive(scope)
180+
addScopeToNode(clone, reactiveScope, templateEl)
181+
clone._x_refreshXForScope = refreshScope(reactiveScope)
182+
183+
lookup.set(key, clone)
184+
added.add(clone)
185+
186+
prev.after(clone)
187+
prev = clone
188+
}
124189
})
125-
skipDuringClone(() => added.forEach(clone => initTree(clone)))()
190+
skipDuringClone(() => added.forEach(el => initTree(el)))()
126191
})
127192
})
128193
}

packages/docs/src/en/directives/for.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,9 @@ You may also pass objects to `x-for`.
4949
</div>
5050
<!-- END_VERBATIM -->
5151

52-
There are two rules worth noting about `x-for`:
52+
There is one rule worth noting about `x-for`:
5353

5454
> `x-for` MUST be declared on a `<template>` element.
55-
> That `<template>` element MUST contain only one root element
5655
5756
<a name="keys"></a>
5857
## Keys
@@ -115,21 +114,40 @@ If you need to simply loop `n` number of times, rather than iterate through an a
115114
<a name="contents-of-a-template"></a>
116115
## Contents of a `<template>`
117116

118-
As mentioned above, an `<template>` tag must contain only one root element.
119-
120-
For example, the following code will not work:
117+
Alpine supports multiple root elements inside an `x-for` template. Each iteration will render all children:
121118

122119
```alpine
123-
<template x-for="color in colors">
124-
<span>The next color is </span><span x-text="color">
125-
</template>
120+
<table x-data="{ colors: ['Red', 'Orange', 'Yellow'] }">
121+
<tr>
122+
<template x-for="(color, index) in colors">
123+
<td x-text="index"></td>
124+
<td x-text="color"></td>
125+
</template>
126+
</tr>
127+
</table>
126128
```
127129

128-
but this code will work:
130+
If you only have one root element, that works too — it's just not required:
131+
129132
```alpine
130133
<template x-for="color in colors">
131134
<p>
132135
<span>The next color is </span><span x-text="color">
133136
</p>
134137
</template>
135138
```
139+
140+
### Limitations with `<table>` elements
141+
142+
HTML tables have strict parsing rules — a `<div>` is not valid inside `<tr>`, so the browser will strip it before Alpine runs. If you need to iterate table cells, either place multiple `<td>` elements directly in the template (as shown above) or use `<tr>` as the root element:
143+
144+
```alpine
145+
<table x-data="{ colors: ['Red', 'Orange', 'Yellow'] }">
146+
<template x-for="(color, index) in colors">
147+
<tr>
148+
<td x-text="index"></td>
149+
<td x-text="color"></td>
150+
</tr>
151+
</template>
152+
</table>
153+
```

0 commit comments

Comments
 (0)