Skip to content
Open
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
42 changes: 30 additions & 12 deletions notes/Inserters.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,22 @@ Everything reachable through the `com.raquo.laminar.inserters` package: `child <

## 1. The north star: the plain-element analogy

The single most important principle, cited throughout the code (e.g. `NestedGroup.moveToParent` scaladoc: _"mirroring how a plain element can be moved between two parents"_):
The single most important principle, cited throughout the code (e.g. `NestedGroup.moveTo` scaladoc: _"mirroring how a plain element can be moved"_):

> **A dynamic inserter should behave like a plain element wherever possible.**

A plain Laminar element (`val el = div(...)`) can be referenced by multiple parents over time. You can move it from one parent to another; it takes its current children with it; it is not re-created; and if it is moved seamlessly (while it always has an active parent) it is not even re-mounted. A dynamic inserter (`val dyn = children <-- signal`) is likewise a _value_ that can be placed, moved, and re-placed, and should exhibit the same relocation semantics.

Concretely, the analogy dictates:

- **Identity is stable.** The same inserter `val` placed in a new location is _moved_, not rebuilt. (`DynamicInserter.apply` / `addToDynamicList` detect an already-placed group and call `moveToParent` instead of constructing.)
- **Identity is stable.** The same inserter `val` placed in a new location is _moved_, not rebuilt. (`DynamicInserter.apply` / `addToDynamicList` detect an already-placed group and call `NestedGroup.moveTo` instead of constructing.)
- **A move carries current content, and only current content.** Moving a `div` relocates the children it currently has — it does not reclaim children that were previously removed or stolen from it. Moving a group relocates exactly the nodes currently in its span. See §5.
- **A seamless move does not re-mount.** When an element/group always has an active parent throughout the transition, its subscriptions/owner are _transferred_, not torn down and rebuilt, so no unmount/mount fires. See §6.
- **Last write wins for a contested node.** If two hosts both want the same node, whichever acts last owns it — exactly as re-parenting a plain element to B removes it from A. See §4.

Two operations must not be conflated (they answer different questions):

1. **Moving a group** (`moveToParent`) — triggered by a re-emission whose payload _is_ the group (a list re-emitting `[G]`, or `G` applied to an element). It relocates the group's span. It says nothing about the group's _inner_ membership.
1. **Moving a group** (`moveTo`) — triggered by a re-emission whose payload _is_ the group (a list re-emitting `[G]`, or `G` applied to an element). It relocates the group's span. It says nothing about the group's _inner_ membership.
2. **Stealing / re-stealing a node** — governed by whichever list re-emits _that node_; last write wins.

Blurring these is a recurring source of bugs (see §5).
Expand Down Expand Up @@ -78,7 +78,7 @@ Therefore any operation that must act on the _current_ span (relocate it, re-slo
The two correct DOM-walk helpers are `InsertContext.removeContentMapNodesFromDom` (the destructive walk) and `currentContentInsertersFromDom` (its read-only twin). Any code that iterates `contentMap.forEach` to touch _live_ content is suspect (see §11).

### NestedGroup
The rendering vehicle for a `DynamicInserter` — used both when it is applied plainly and when it is a `children <--` item, to keep one consistent code path (state + subscription management in one place, and moveability between arbitrary contexts). It owns the leading sentinel, the `InsertContext`, a `DynamicOwner` + `TransferableSubscription` pair (the "pilot" that mounts/unmounts the inner inserter with the group), and implements `moveToParent` / `removeFromParent`.
The rendering vehicle for a `DynamicInserter` — used both when it is applied plainly and when it is a `children <--` item, to keep one consistent code path (state + subscription management in one place, and moveability between arbitrary contexts). It owns the leading sentinel, the `InsertContext`, a `DynamicOwner` + `TransferableSubscription` pair (the "pilot" that mounts/unmounts the inner inserter with the group), and implements `moveTo` / `removeFromParent`.

### Stealing
When node/group X is tracked by inserter A but gets placed by inserter B, we say B _stole_ X from A. Steals happen because the observables feeding A and B propagate in some order, and the add (into B) can be processed before the remove (from A). Laminar's contract: **inserter code must never fail on the resulting stale state, and must self-correct on the next emission** (`InsertContext` scaladoc, lines 31–51). `removeFromDynamicList` is a no-op on parent mismatch precisely so a stale removal after a steal does nothing (`DynamicInserter.removeFromDynamicList`, `DomApi.removeChild` no-op on parent mismatch).
Expand Down Expand Up @@ -128,10 +128,16 @@ Last-write-wins must survive a group **move**: relocating a group leaves a sibli

Four distinct relocation scenarios, all of which should be **seamless (no re-mount)** because the moved thing always has an active parent throughout:

1. **Reorder within one list** — `moveWithinDynamicList`, same-parent branch: a raw DOM reposition of the span, then a slot re-affirm. No owner transfer needed.
2. **Transfer between two lists** (add-first steal) — `addToDynamicList` sees an already-placed group and calls `moveToParent`: relocate the span, transfer the pilot subscription to the new parent's owner, update `currentParentNode` + `currentSlotName` so future emissions target the new home.
3. **Promote** a plainly-applied dynamic inserter INTO a `children <--` list, and **demote** a list item back onto a plain element (`element.amend(inserter)`) — both routed through `moveToParent` / `apply`. Seamless. The trailing sentinel is added on promote and stickily retained on demote (§2).
4. **Steal-back / re-steal** — a group stolen into a sibling, then re-emitted by its original list. Same-parent layout takes `moveWithinDynamicList`'s raw-reposition branch; cross-parent takes `moveToParent`.
1. **Reorder within one list**.
2. **Transfer between two lists** (add-first steal) — `addToDynamicList` sees an already-placed group.
3. **Promote** a plainly-applied dynamic inserter INTO a `children <--` list, and **demote** a list item back onto a plain element (`element.amend(inserter)`, via `apply`). The trailing sentinel is added on promote and stickily retained on demote (§2).
4. **Steal-back / re-steal** — a group stolen into a sibling (or applied plainly to the list's parent), then re-emitted by its original list.

In all four, the list (or `apply`) calls `addToDynamicList` / `apply` on an already-placed inserter, just like it would to add a new one – the same way `setParent` both adds and moves a plain element. For a dynamic inserter, all four go through one entry point, `NestedGroup.moveTo`, so that the group always ends up in the same shape and lifecycle order as if it was created at its new place, regardless of how it was placed before:

- **Shape first.** If the destination is a `children <--` list, `moveTo` ensures the trailing sentinel before moving. Without it, the list would treat the group's content as its own neighbouring items, and the group would treat the list's next items as its own content (`NestedGroupReStealSpec`).
- **Same parent element** → a raw DOM reposition of the span, then a slot re-affirm on its live content (§9). The group and its content keep their Laminar parent and dynamic owner, so there is no lifecycle to update.
- **Different parent element** → `moveToParent`: transfer the pilot subscription, relocate the span, update `currentParentNode` + `currentSlotName` so future emissions target the new home.

### The governing rule for `moveToParent`

Expand All @@ -141,13 +147,24 @@ This follows directly from the plain-element analogy: a moved `div` takes the ch

- **DOM order, not map order.** `children.command <--` builds its DOM out of insertion order, so the map lists nodes differently than the DOM. The move must preserve DOM order (`InserterMoveSpec` "a stolen `children.command <--` span preserves its DOM order").
- **Live membership, not map membership.** A node stolen out of the span by a sibling, or absorbed into the group's own nested `child <--`, has a stale map entry that the move must NOT drag back (the ④/⑤ bug class).
- **Nested spans move as a unit.** The DOM walk jumps by each inserter's `lastNode.nextSibling`, so an inner group (with its own sentinels) is stepped over whole and relocated by its own recursive `moveToParent` (handles depth-2/3, ⑤).
- **Nested spans move as a unit.** The DOM walk jumps by each inserter's `lastNode.nextSibling`, so an inner group (with its own sentinels) is stepped over whole and relocated by its own recursive `moveTo` (handles depth-2/3, ⑤).

Accordingly, `moveToParent` **snapshots the live span (via `currentContentInsertersFromDom`) BEFORE moving the sentinels** (the walk starts at the leading sentinel, which is about to move), then re-adds each collected inserter in DOM order, then commits the new parent/slot. The move deliberately does **not** touch `contentMap` — stale inner tracking is tolerated and self-corrects on the inner inserter's next emission, matching existing behaviour.

### Lifecycle first: the pilot transfer precedes the content

A group has no element of its own: its content's pilot subscriptions are owned directly by the parent element's `DynamicOwner`, alongside the group's own pilot. The group's logical ownership of its content is encoded only by **registration order** in that shared owner: the group's pilot must come first, so that on activation, the inner inserter updates its content _before_ that content mounts. This is what a plain `div(child <-- signal.map(render))` gets for free, and what a never-moved group gets by construction (its content only arrives once it activates).

So `moveToParent` transfers the pilot subscription **before** snapshotting and moving the content:

Accordingly, `moveToParent` **snapshots the live span (via `currentContentInsertersFromDom`) BEFORE moving the sentinels** (the walk starts at the leading sentinel, which is about to move), then re-adds each collected inserter in DOM order, then commits the new parent/slot, then transfers the subscription. The move deliberately does **not** touch `contentMap` — stale inner tracking is tolerated and self-corrects on the inner inserter's next emission, matching existing behaviour.
- **Inactive → active** (e.g. stolen out of an unmounted host into a mounted list): the inner inserter re-renders while still in the old, inactive parent, so stale content is dropped without ever mounting, and only fresh content is moved (and mounted). This matters beyond wasted events: stale content that mounts runs its own bindings, which can steal nodes from unrelated live lists and take them down with it when it's dropped (`NestedGroupActivationOrderSpec`).
- **Active → inactive**: the inner inserter stops before its content unmounts, mirroring `removeFromParent`.
- **Active → active**: a live transfer, seamless as before.
- **Any move**: the group's pilot re-registers in the new owner ahead of its content's, so later unmount / remount cycles of the new host behave like a never-moved group. This recurses: a nested group is moved by its own `moveTo`, after its outer group has already re-rendered and dropped it if it was stale.

### Re-placing an inserter whose group was already torn down

If the previous host genuinely _removed_ the group (set `nestedGroupOpt = js.undefined`) and then the original list re-emits it, there is no group to move — it must be **placed afresh** (re-inserted + re-mounted), like a plain element that was removed and re-added. `DynamicInserter.moveWithinDynamicList` detects the missing group and falls back to `addToDynamicList`; the list's item count already counted this inserter (it was in the previous map), so the rebuild changes no count. This is the counterpart to "move the live span": when there is no live span, rebuild. Covered by `InserterMoveSpec` section 4d.
If the previous host genuinely _removed_ the group (set `nestedGroupOpt = js.undefined`) and then the original list re-emits it, there is no group to move — it must be **placed afresh** (re-inserted + re-mounted), like a plain element that was removed and re-added. `addToDynamicList` builds a new group when there is none; the list's item count already counted this inserter (it was in the previous map), so the rebuild changes no count. This is the counterpart to "move the live span": when there is no live span, rebuild. Covered by `InserterMoveSpec` section 4d.

---

Expand Down Expand Up @@ -230,7 +247,8 @@ Distinct from the last-write-wins contest above: when a slot could come from an
### Persistence and live-span re-slotting

- A dynamic inserter's slot is **persistent**, not just applied to current content: it is stored on the context (`currentSlotName`) so future emissions are slotted the same way. A `NestedGroup` move resolves the destination list's slot against its own and stores the result, redirecting the inner inserter's future emissions.
- Re-slotting on a move/diff reads the **live DOM span**, never `contentMap` — so a node that has left the span is never re-slotted out from under its new host. `NestedGroup.applySlot` iterates `currentContentInsertersFromDom` and skips departed nodes; `updateChildren`'s same-place branch also re-affirms slot, because a different wrapper may now slot the same node. Covered by `SlotSpec` "re-slotting a moved group re-slots only its live span, leaving a sibling-stolen node's slot with its new host" (a re-steal between two sibling `Slot`s hits `NestedGroup.applySlot` via `moveWithinDynamicList`'s same-parent branch).
- Re-slotting on a move/diff reads the **live DOM span**, never `contentMap` — so a node that has left the span is never re-slotted out from under its new host. `NestedGroup.applySlot` iterates `currentContentInsertersFromDom` and skips departed nodes; `updateChildren`'s same-place branch also re-affirms slot, because a different wrapper may now slot the same node. Covered by `SlotSpec` "re-slotting a moved group re-slots only its live span, leaving a sibling-stolen node's slot with its new host" (a re-steal between two sibling `Slot`s hits `NestedGroup.applySlot` via `NestedGroup.moveTo`'s same-parent branch).
- **A group's content is re-affirmed like a plain item.** Whenever a list places, re-places, or reconciles in place a dynamic inserter item (and whenever such a group is moved, within or across parents), `NestedGroup.applySlot` re-affirms the effective slot on every node of the group's live span, even if the slot name didn't change. So the last-write-wins contest above behaves the same whether an element is a direct list item or sits inside a nested group, and regardless of which move path was taken. There is deliberately no "slot name unchanged" shortcut: it would let a manual `slot :=` on a group's content survive a reconcile that restores it on a plain sibling. Protecting stolen nodes is the live-span walk's job, not the shortcut's. Pinned by `SlotAttributeStealingSpec` "a slotted-list reconcile re-asserts the Slot's slot over a manual override on a nested group's content".

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,12 @@ object ChildrenInserter {

if (index >= currentItemCount) {
// Overflow – we've consumed all previous items:
// Just insert nextInserter at the cursor (or move it there if this inserter it was previously in the list)
if (foundInserterInPrevMap) {
// @Note: DOM update
nextInserter.moveWithinDynamicList(listParentNode, afterRef, slotName)
} else {
// Just insert nextInserter at the cursor
if (!foundInserterInPrevMap) {
currentItemCount += 1
// @Note: DOM update
nextInserter.addToDynamicList(listParentNode, afterRef, slotName)
}
// @Note: DOM update
nextInserter.addToDynamicList(listParentNode, afterRef, slotName)
} else {
if (foundInserterInPrevMap) {
if (nextInserter.stableFirstNode == prevItemRef) {
Expand Down Expand Up @@ -158,7 +155,7 @@ object ChildrenInserter {
} else {
// Still not in place – this is a MOVE, so we do NOT change the count.
// @Note: DOM update
nextInserter.moveWithinDynamicList(listParentNode, afterRef, slotName)
nextInserter.addToDynamicList(listParentNode, afterRef, slotName)
}
}
} else {
Expand Down
Loading