Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 97 additions & 32 deletions packages/alpinejs/src/directives/x-for.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@ directive('for', (el, { expression }, { effect, cleanup }) => {
effect(() => loop(el, iteratorNames, evaluateItems, evaluateKey))

cleanup(() => {
el._x_lookup.forEach(el =>
el._x_lookup.forEach(entry =>
mutateDom(() => {
destroyTree(el)

el.remove()
if (entry.startComment) {
entry.elements.forEach(child => {
destroyTree(child)
child.remove()
})
entry.startComment.remove()
entry.endComment.remove()
} else {
destroyTree(entry)
entry.remove()
}
})
)

Expand All @@ -42,6 +50,8 @@ function refreshScope(scope) {
}

function loop(templateEl, iteratorNames, evaluateItems, evaluateKey) {
let isFragment = templateEl.content.children.length > 1

evaluateItems(items => {
// Prepare yourself. There's a lot going on here. Take heart,
// every bit of complexity in this function was added for
Expand Down Expand Up @@ -83,46 +93,101 @@ function loop(templateEl, iteratorNames, evaluateItems, evaluateKey) {
})

mutateDom(() => {
oldLookup.forEach((el) => {
destroyTree(el)
el.remove()
// Cleanup removed items
oldLookup.forEach((entry) => {
if (isFragment) {
entry.elements.forEach(el => {
destroyTree(el)
el.remove()
})
entry.startComment.remove()
entry.endComment.remove()
} else {
destroyTree(entry)
entry.remove()
}
})

let added = new Set()

let prev = templateEl
scopeEntries.forEach(([key, scope]) => {
if (lookup.has(key)) {
let el = lookup.get(key)
el._x_refreshXForScope(scope)

if (prev.nextElementSibling !== el) {
if (prev.nextElementSibling)
el.replaceWith(prev.nextElementSibling)
prev.after(el)
}
prev = el

if (el._x_currentIfEl) {
if (el.nextElementSibling !== el._x_currentIfEl)
prev.after(el._x_currentIfEl)
prev = el._x_currentIfEl
let entry = lookup.get(key)
entry._x_refreshXForScope(scope)

if (isFragment) {
let { startComment, endComment, elements } = entry

// Check if group is already in correct position
if (prev.nextSibling !== startComment) {
prev.after(startComment)
let cursor = startComment
elements.forEach(el => {
cursor.after(el)
cursor = el
})
cursor.after(endComment)
}
prev = endComment
} else {
if (prev.nextElementSibling !== entry) {
if (prev.nextElementSibling)
entry.replaceWith(prev.nextElementSibling)
prev.after(entry)
}
prev = entry

if (entry._x_currentIfEl) {
if (entry.nextElementSibling !== entry._x_currentIfEl)
prev.after(entry._x_currentIfEl)
prev = entry._x_currentIfEl
}
}
return
}

let clone = document.importNode(templateEl.content, true).firstElementChild
let reactiveScope = reactive(scope)
addScopeToNode(clone, reactiveScope, templateEl)
clone._x_refreshXForScope = refreshScope(reactiveScope)

lookup.set(key, clone)
added.add(clone)

prev.after(clone)
prev = clone
// New item — clone and insert
if (isFragment) {
let startComment = document.createComment(' [x-for] ')
let endComment = document.createComment(' [/x-for] ')
let fragment = document.importNode(templateEl.content, true)
let elements = Array.from(fragment.children)

let reactiveScope = reactive(scope)
let group = { startComment, endComment, elements }

elements.forEach(el => {
addScopeToNode(el, reactiveScope, templateEl)
})

group._x_refreshXForScope = refreshScope(reactiveScope)

lookup.set(key, group)

prev.after(startComment)
let cursor = startComment
elements.forEach(el => {
cursor.after(el)
cursor = el
added.add(el)
})
cursor.after(endComment)
prev = endComment
} else {
let clone = document.importNode(templateEl.content, true).firstElementChild
let reactiveScope = reactive(scope)
addScopeToNode(clone, reactiveScope, templateEl)
clone._x_refreshXForScope = refreshScope(reactiveScope)

lookup.set(key, clone)
added.add(clone)

prev.after(clone)
prev = clone
}
})
skipDuringClone(() => added.forEach(clone => initTree(clone)))()
skipDuringClone(() => added.forEach(el => initTree(el)))()
})
})
}
Expand Down
36 changes: 27 additions & 9 deletions packages/docs/src/en/directives/for.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,9 @@ You may also pass objects to `x-for`.
</div>
<!-- END_VERBATIM -->

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

> `x-for` MUST be declared on a `<template>` element.
> That `<template>` element MUST contain only one root element

<a name="keys"></a>
## Keys
Expand Down Expand Up @@ -115,21 +114,40 @@ If you need to simply loop `n` number of times, rather than iterate through an a
<a name="contents-of-a-template"></a>
## Contents of a `<template>`

As mentioned above, an `<template>` tag must contain only one root element.

For example, the following code will not work:
Alpine supports multiple root elements inside an `x-for` template. Each iteration will render all children:

```alpine
<template x-for="color in colors">
<span>The next color is </span><span x-text="color">
</template>
<table x-data="{ colors: ['Red', 'Orange', 'Yellow'] }">
<tr>
<template x-for="(color, index) in colors">
<td x-text="index"></td>
<td x-text="color"></td>
</template>
</tr>
</table>
```

but this code will work:
If you only have one root element, that works too — it's just not required:

```alpine
<template x-for="color in colors">
<p>
<span>The next color is </span><span x-text="color">
</p>
</template>
```

### Limitations with `<table>` elements

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:
Copy link
Copy Markdown
Contributor

@NightFurySL2001 NightFurySL2001 Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be "td is not valid inside div" ? And should be "strict template parsing rule", not "strict table parsing rule"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsure? it might go both ways

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah seems to be both


```alpine
<table x-data="{ colors: ['Red', 'Orange', 'Yellow'] }">
<template x-for="(color, index) in colors">
<tr>
<td x-text="index"></td>
<td x-text="color"></td>
</tr>
</template>
</table>
```
Loading