Describe the bug
If the same DOM node moves from one JSX expression slot to an adjacent sibling slot, the slot it left removes it from the document while cleaning up — the node is destroyed instead of migrated. Two adjacent <Show>s trading a pair of hoisted elements is the everyday shape:
<Show when={swap()} fallback={el1}>{el2}</Show><Show when={swap()} fallback={el2}>{el1}</Show> renders 12 initially.
- After
setSwap(true) the container shows only 1 — el2 has been removed from the DOM entirely.
- Swapping back shows only
2 — each further swap destroys one of the elements.
The same happens with plain adjacent expressions ({swap() ? el2 : el1}{swap() ? el1 : el2}) — components and raw expressions compile to the same adjacent-slot shape.
Probing the rest of the failure class (runtime-level tests, all failing on next, controls green):
- A single node migrating forward to the adjacent slot vanishes entirely —
{right() ? null : el}{right() ? el : null} goes from [X] to []. This is the simplest possible migration shape. The backward direction (into the preceding slot) vanishes the same way.
- Arrays exchanging members across adjacent slots throw
NotFoundError — a hard crash from the reconcile path mid-update, not just wrong output. (Under 2.0's error semantics an uncaught flush error halts the reactive system, so a layout swap can take down the app.)
- A rotation across three adjacent slots destroys two of the three elements (
abc → a).
- An interleaved text expression does not separate the slots —
{a}{label()}{b} fails like the directly-adjacent case (the text slot shares the same marker).
- Related same-root positional bug, present even without migration: a null-marker slot emptied via
[] loses its anchor, so refilled content appends at the parent's end, after later slots' content ({cond ? [x] : []}{y} refills as yx instead of xy).
- Controls: the identical exchange with a static element between the slots, and single-node migration between slots in different parents, both work.
- Post-hydration, the same shapes work correctly — hydratable compilation emits a dedicated
<!--$-->/<!--/--> marker pair per slot, so adjacent slots have distinct markers and ownership discriminates. So this is also a CSR/SSR behavioral divergence: a view that works on first load (hydrated) silently destroys nodes when the same JSX is client-rendered (e.g. after navigation). It also suggests the fix is already half-shipped: the compiler knows how to emit per-slot markers (<!$><!/> in hydratable DOM templates) and only strips them in plain CSR mode.
2.0's slot-ownership tagging ($$SLOT) was added expressly to support nodes migrating between slots (the ownership comments in dom-expressions reconcile.js:12-16 describe exactly this "node migrated to another slot — same parent or otherwise" case), and it works in every other geometry — verified as controls: the identical swap succeeds when a static element separates the two slots (distinct markers), and a hoisted element migrating between <Show>s in different parents (the #2030 shape) works too. It breaks exactly and only when the compiler gives adjacent slots the same insertion marker, so the ownership check cannot tell them apart.
Scope note, to distinguish this from the externally-moved-nodes class (#2515, #1898 — closed as unsupported): every move here happens through JSX expression values — no code touches the DOM behind the runtime's back. This is exactly the "Solid controls both sides" case the #2515 closing comment carved out as the supported one (#2030), not the dnd-style external mutation it declined.
For context: 1.x fails this exact case the same way (verified on 1.9.14, both the tail-position and static-follower shapes) — it has no slot ownership at all. So this is not a regression; it's a gap in a shipped, advertised 2.0 feature:
Adjacent slots are arguably the most common shape for migration — two sibling positions trading a kept-alive element on a layout flip — and currently the one geometry where the $$SLOT model can't deliver it.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-rfxcawsd?file=src%2FApp.tsx
import { createSignal, flush, Show } from "solid-js";
export default function App() {
// two elements created once, then moved between the two sibling slots
const el1 = <span>1</span>;
const el2 = <b>2</b>;
const [swap, setSwap] = createSignal(false);
const [report, setReport] = createSignal("");
let out!: HTMLDivElement;
const read = () => setReport(`textContent: "${out.textContent}" — innerHTML: ${out.innerHTML}`);
return (
<div>
<div ref={out}>
<Show when={swap()} fallback={el1}>
{el2}
</Show>
<Show when={swap()} fallback={el2}>
{el1}
</Show>
</div>
<button
onClick={() => {
setSwap(!swap());
flush();
read();
}}
>
swap slots
</button>
<pre>{report()}</pre>
</div>
);
}
Steps to Reproduce the Bug or Issue
- On load the div shows
12.
- Click "swap slots". Actual —
el2 is gone from the document:
textContent: "1" — innerHTML: <span>1</span>
- Click "swap slots" again. Actual — now
el1 is destroyed too; the swap never recovers:
textContent: "2" — innerHTML: <b>2</b>
Expected behavior
The two nodes trade places on every click:
after swap: textContent: "21" — innerHTML: <b>2</b><span>1</span>
after swap back: textContent: "12" — innerHTML: <span>1</span><b>2</b>
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chrome
- Version: 2.0.0-beta.15 (verified at
next HEAD bad66625; 1.x comparison on solid-js 1.9.14)
Additional context
The compiler emits the same marker for adjacent expression slots. For the repro above (@dom-expressions/babel-plugin-jsx 0.50.0-next.15):
_$insert(_el$, _$createComponent(Show, { …, fallback: el1, children: el2 }), null); // ← same marker (null: both at the tail)
_$insert(_el$, _$createComponent(Show, { …, fallback: el2, children: el1 }), null); // ←
Plain adjacent expressions compile identically (_$insert(_el$, () => swap() ? el2 : el1, null) twice). With a static element following the slots, both get that element as marker instead (_$insert(_el$, …, _el$2) twice) — same failure, reproduced all ways (verified with <Show> and raw ternaries, tail and static-follower positions).
The runtime's slot-ownership check treats "untagged" and "tagged with my marker" as owned, e.g. cleanChildren in node_modules/@dom-expressions/runtime/src/client.js:866 (source repo: ryansolid/dom-expressions, packages/runtime/src/client.js; the same predicate appears in reconcile.js at lines 18, 78 and 145):
const owns = el.parentNode === parent && (!tag || tag === marker);
- Tail-position slots (marker
null): nodes are never tagged at all (if (marker) n[$$SLOT] = marker in appendNodes), so both slots consider every node "fair game" — when slot 1 updates from el1 to el2, its cleanup removes el1… which slot 2 just claimed.
- Marker-sharing slots: both slots tag nodes with the same marker node, so
tag === marker is true across slots — identical outcome.
Suggested fix direction (working patch with tests in hand, PR to dom-expressions to follow): key $$SLOT on a per-insert()-call identity token so ownership distinguishes adjacent slots even when they share a physical anchor, keeping the marker for positioning — gated so tokens only engage when a parent actually hosts more than one slot. Single-slot parents (the For-list hot path) keep the exact current instruction stream and marker-tag semantics; ownership accepts token-or-marker so content inserted before a parent becomes multi-slot stays owned across the transition. A per-parent slot registry (consulted only when a slot's entire content migrated away in one flush) restores lost positions from the next slot's region start — which also fixes the []-refill mis-order above. Benchmarked through built dists on js-framework-benchmark-shaped operations: all nine ops within ±2.3% with overlapping spreads; residual cost is ~0.02µs/update confined to multi-slot parents. The alternative — compiler-emitted per-slot markers, which hydratable output already produces (and is why hydrated pages don't have this bug) — also fixes everything but costs permanent extra template/DOM nodes on every adjacent-expression site.
Does this exist in Solid 1.x?
Broken identically in 1.x. Verified against solid-js 1.9.14 with the identical two-adjacent-<Show> element-swap shape: the swap destroys a node the same way — "<span>1</span><b>2</b>" becomes "<span>1</span>" (and "1end" in the static-follower shape). Not a regression; it's a long-standing gap that the 2.0 slot-ownership model (the shipped #2030 fix) was positioned to close but doesn't for adjacent slots sharing a marker.
(Harness note for anyone re-running the 1.x check: perform the signal writes outside the createRoot body — 1.x batches updates inside the root callback, which makes the swap look like a harmless no-op if you assert inside it.)
Describe the bug
If the same DOM node moves from one JSX expression slot to an adjacent sibling slot, the slot it left removes it from the document while cleaning up — the node is destroyed instead of migrated. Two adjacent
<Show>s trading a pair of hoisted elements is the everyday shape:<Show when={swap()} fallback={el1}>{el2}</Show><Show when={swap()} fallback={el2}>{el1}</Show>renders12initially.setSwap(true)the container shows only1—el2has been removed from the DOM entirely.2— each further swap destroys one of the elements.The same happens with plain adjacent expressions (
{swap() ? el2 : el1}{swap() ? el1 : el2}) — components and raw expressions compile to the same adjacent-slot shape.Probing the rest of the failure class (runtime-level tests, all failing on
next, controls green):{right() ? null : el}{right() ? el : null}goes from[X]to[]. This is the simplest possible migration shape. The backward direction (into the preceding slot) vanishes the same way.NotFoundError— a hard crash from the reconcile path mid-update, not just wrong output. (Under 2.0's error semantics an uncaught flush error halts the reactive system, so a layout swap can take down the app.)abc→a).{a}{label()}{b}fails like the directly-adjacent case (the text slot shares the same marker).[]loses its anchor, so refilled content appends at the parent's end, after later slots' content ({cond ? [x] : []}{y}refills asyxinstead ofxy).<!--$-->/<!--/-->marker pair per slot, so adjacent slots have distinct markers and ownership discriminates. So this is also a CSR/SSR behavioral divergence: a view that works on first load (hydrated) silently destroys nodes when the same JSX is client-rendered (e.g. after navigation). It also suggests the fix is already half-shipped: the compiler knows how to emit per-slot markers (<!$><!/>in hydratable DOM templates) and only strips them in plain CSR mode.2.0's slot-ownership tagging (
$$SLOT) was added expressly to support nodes migrating between slots (the ownership comments in dom-expressionsreconcile.js:12-16describe exactly this "node migrated to another slot — same parent or otherwise" case), and it works in every other geometry — verified as controls: the identical swap succeeds when a static element separates the two slots (distinct markers), and a hoisted element migrating between<Show>s in different parents (the #2030 shape) works too. It breaks exactly and only when the compiler gives adjacent slots the same insertion marker, so the ownership check cannot tell them apart.Scope note, to distinguish this from the externally-moved-nodes class (#2515, #1898 — closed as unsupported): every move here happens through JSX expression values — no code touches the DOM behind the runtime's back. This is exactly the "Solid controls both sides" case the #2515 closing comment carved out as the supported one (#2030), not the dnd-style external mutation it declined.
For context: 1.x fails this exact case the same way (verified on 1.9.14, both the tail-position and static-follower shapes) — it has no slot ownership at all. So this is not a regression; it's a gap in a shipped, advertised 2.0 feature:
<div>makes it work — that wrapper changes the geometry from adjacent slots (shared marker) to nested containers, which is exactly the boundary of this bug.examples/migrating-elementworks for the same reason: each<Show>wraps the hoisted canvas in its own<div class="slot-…">, so the canvas migrates between distinct parents rather than between sibling slots. Remove the wrappers — the natural way to write it — and the node is destroyed.Adjacent slots are arguably the most common shape for migration — two sibling positions trading a kept-alive element on a layout flip — and currently the one geometry where the
$$SLOTmodel can't deliver it.Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-rfxcawsd?file=src%2FApp.tsx
Steps to Reproduce the Bug or Issue
12.el2is gone from the document:el1is destroyed too; the swap never recovers:Expected behavior
The two nodes trade places on every click:
Screenshots or Videos
No response
Platform
nextHEADbad66625; 1.x comparison on solid-js 1.9.14)Additional context
The compiler emits the same marker for adjacent expression slots. For the repro above (
@dom-expressions/babel-plugin-jsx0.50.0-next.15):Plain adjacent expressions compile identically (
_$insert(_el$, () => swap() ? el2 : el1, null)twice). With a static element following the slots, both get that element as marker instead (_$insert(_el$, …, _el$2)twice) — same failure, reproduced all ways (verified with<Show>and raw ternaries, tail and static-follower positions).The runtime's slot-ownership check treats "untagged" and "tagged with my marker" as owned, e.g.
cleanChildreninnode_modules/@dom-expressions/runtime/src/client.js:866(source repo: ryansolid/dom-expressions,packages/runtime/src/client.js; the same predicate appears inreconcile.jsat lines 18, 78 and 145):null): nodes are never tagged at all (if (marker) n[$$SLOT] = markerinappendNodes), so both slots consider every node "fair game" — when slot 1 updates fromel1toel2, its cleanup removesel1… which slot 2 just claimed.tag === markeris true across slots — identical outcome.Suggested fix direction (working patch with tests in hand, PR to dom-expressions to follow): key
$$SLOTon a per-insert()-call identity token so ownership distinguishes adjacent slots even when they share a physical anchor, keeping the marker for positioning — gated so tokens only engage when a parent actually hosts more than one slot. Single-slot parents (the For-list hot path) keep the exact current instruction stream and marker-tag semantics; ownership accepts token-or-marker so content inserted before a parent becomes multi-slot stays owned across the transition. A per-parent slot registry (consulted only when a slot's entire content migrated away in one flush) restores lost positions from the next slot's region start — which also fixes the[]-refill mis-order above. Benchmarked through built dists on js-framework-benchmark-shaped operations: all nine ops within ±2.3% with overlapping spreads; residual cost is ~0.02µs/update confined to multi-slot parents. The alternative — compiler-emitted per-slot markers, which hydratable output already produces (and is why hydrated pages don't have this bug) — also fixes everything but costs permanent extra template/DOM nodes on every adjacent-expression site.Does this exist in Solid 1.x?
Broken identically in 1.x. Verified against solid-js 1.9.14 with the identical two-adjacent-
<Show>element-swap shape: the swap destroys a node the same way —"<span>1</span><b>2</b>"becomes"<span>1</span>"(and"1end"in the static-follower shape). Not a regression; it's a long-standing gap that the 2.0 slot-ownership model (the shipped #2030 fix) was positioned to close but doesn't for adjacent slots sharing a marker.(Harness note for anyone re-running the 1.x check: perform the signal writes outside the
createRootbody — 1.x batches updates inside the root callback, which makes the swap look like a harmless no-op if you assert inside it.)