Skip to content

2.0.0-beta.15: adjacent expression slots destroy each other's DOM nodes when a node migrates between them #2830

Description

@yumemi-thomas

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:

  1. <Show when={swap()} fallback={el1}>{el2}</Show><Show when={swap()} fallback={el2}>{el1}</Show> renders 12 initially.
  2. After setSwap(true) the container shows only 1el2 has been removed from the DOM entirely.
  3. 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 (abca).
  • 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

  1. On load the div shows 12.
  2. Click "swap slots". Actual — el2 is gone from the document:
textContent: "1" — innerHTML: <span>1</span>
  1. 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.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions