Skip to content

fix: place cross-slot keyed moves at cursor instead of appending#18

Merged
upupming merged 2 commits intov10.xfrom
fix/slot-branch-cross-slot-keyed-move
Apr 24, 2026
Merged

fix: place cross-slot keyed moves at cursor instead of appending#18
upupming merged 2 commits intov10.xfrom
fix/slot-branch-cross-slot-keyed-move

Conversation

@upupming
Copy link
Copy Markdown
Collaborator

@upupming upupming commented Apr 23, 2026

Problem

When a keyed node moves to a different $N slot across a re-render, preact's insert() slot-branch was using null as the insertBefore reference, which appended the node past any stable siblings that followed — wrong DOM order.

A second edge case: when the cross-slot node happens to already sit exactly at the diff cursor (oldDom === parentVNode._dom), calling insertBefore(node, node) corrupts BackgroundSnapshotInstance's linked list by creating a self-cycle (H.__nextSibling = H), eventually causing OOM on the main thread.

Fix

Commit 1 (40912aae): use oldDom (the diff cursor) as the insertBefore reference, placing the moved node at the correct slot position rather than appending it.

Commit 2 (995ae4de): when the node is already at the cursor, use oldDom.nextSibling instead — same effective DOM position, no self-insert, and the patch carries a valid beforeId so the main-thread renderer correctly receives the updated slotIndex.

Tests

Browser tests in test/browser/render.test.jsx:

  • deterministic repro: cross-slot keyed move lands before stable tail siblings
  • 10k-iteration fuzz: slot placement matches slot order under random layouts

…lot mismatch

`insert()`'s slot-branch was using

    parentVNode._dom.__slotIndex === oldDom?.__slotIndex ? oldDom : null

to pick the `insertBefore` reference. The `null` fallback (= append to
parent's end) was assumed to self-correct via subsequent slot
processing, but it doesn't: `constructNewChildrenArray` only flags
nodes with `INSERT_VNODE` when their skewed index actually shifts, so
keyed siblings that stay in the same slot do not call back into
`insert()` and never re-insert themselves to rebuild the tail.

Concretely, when a keyed node moves to an earlier slot while later
slots remain stable, the moved node gets appended past the stable tail
and stays there, producing wrong DOM order. Property-based fuzzing
over $N slot layouts hits this within the first iteration; minimized
repro is `[X, A, E, D]` → `[E, A, N, D]` (E moves slot 2 → slot 0,
A and D stable).

Drop the conditional and always pass `oldDom` as the reference
(falling back to `getDomSibling` if it's been detached mid-diff,
mirroring the plain-branch's recovery). Inserting before the cursor is
exactly what the cursor-driven loop expects: the moved node lands at
the current cursor position and the cursor advances one slot.

Tests:

- Add `should place cross-slot keyed move before stable tail siblings`,
  the minimized 4-slot deterministic repro.
- Add `fuzz: $N slot placement matches slot order under random
  layouts`: 10k iterations of random keyed permutations under a
  fixed-size $N slot set, deterministic xorshift32 seed, prints prev
  and next layouts on mismatch for easy minimization. Runs in ~90ms.

Local: 1090 passed / 170 skipped / 0 failed in both source and
MINIFY modes.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

Size Change: +1.61 kB (+0.74%)

Total Size: 221 kB

📦 View Changed
Filename Size Change
dist/preact.js 17.2 kB +225 B (+1.33%)
dist/preact.min.js 17.3 kB +237 B (+1.39%)
dist/preact.min.module.js 17.2 kB +226 B (+1.33%)
dist/preact.min.umd.js 17.4 kB +238 B (+1.39%)
dist/preact.mjs 17.1 kB +225 B (+1.33%)
dist/preact.module.js 17.1 kB +225 B (+1.33%)
dist/preact.umd.js 17.4 kB +236 B (+1.38%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.js 9.31 kB
compat/dist/compat.mjs 9.11 kB
compat/dist/compat.module.js 9.11 kB
compat/dist/compat.umd.js 9.49 kB
debug/dist/debug.js 7.08 kB
debug/dist/debug.mjs 7.06 kB
debug/dist/debug.module.js 7.06 kB
debug/dist/debug.umd.js 7.23 kB
devtools/dist/devtools.js 407 B
devtools/dist/devtools.mjs 409 B
devtools/dist/devtools.module.js 409 B
devtools/dist/devtools.umd.js 527 B
hooks/dist/hooks.js 4.53 kB
hooks/dist/hooks.mjs 4.49 kB
hooks/dist/hooks.module.js 4.49 kB
hooks/dist/hooks.umd.js 4.68 kB
jsx-runtime/dist/jsxRuntime.js 2.38 kB
jsx-runtime/dist/jsxRuntime.mjs 2.32 kB
jsx-runtime/dist/jsxRuntime.module.js 2.32 kB
jsx-runtime/dist/jsxRuntime.umd.js 2.5 kB
test-utils/dist/testUtils.js 1.26 kB
test-utils/dist/testUtils.mjs 1.25 kB
test-utils/dist/testUtils.module.js 1.25 kB
test-utils/dist/testUtils.umd.js 1.4 kB

compressed-size-action

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

npm i https://pkg.pr.new/preact@18

commit: 995ae4d

When a cross-slot keyed node happens to sit exactly at the diff loop
cursor (oldDom === parentVNode._dom), inserting it before itself is a
no-op in real DOM but corrupts the BackgroundSnapshotInstance linked
list by creating a self-cycle (H.__nextSibling = H).

Use oldDom.nextSibling as the reference instead: the effective DOM
position is unchanged, the BSI linked list stays intact, and the patch
carries a valid (non-self) beforeId so the main-thread renderer
correctly receives the updated slotIndex for the moved node.
@github-actions
Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

A summary of the benchmark results will show here once they finish.

Results

The full results of your benchmarks will show here once they finish.

tachometer-reporter-action v2 for CI

@upupming upupming changed the title fix: insert cross-slot keyed move at cursor instead of appending on slot mismatch fix: place cross-slot keyed moves at cursor instead of appending Apr 23, 2026
@upupming
Copy link
Copy Markdown
Collaborator Author

upupming commented Apr 23, 2026

@codex review

@upupming upupming merged commit 12b794f into v10.x Apr 24, 2026
6 checks passed
upupming added a commit to lynx-family/lynx-stack that referenced this pull request Apr 24, 2026
…r InsertBefore (#2512)

## Summary

Two fixes that together restore correct cross-slot keyed move behavior
in `SlotV2` snapshots.

### 1. Bump `@lynx-js/internal-preact` for [PR
#18](lynx-family/internal-preact#18)

When a keyed child moved to a different `$N` slot across a re-render,
preact's `insert()` slot-branch:

- (commit 1, `40912aae`) used `null` as the `insertBefore` reference
when the diff cursor wasn't in the moved node's old slot, appending the
moved node past stable siblings — wrong DOM order.
- (commit 2, `995ae4de`) when the moved node *was* exactly at the diff
cursor, called `insertBefore(node, node)` which corrupted
`BackgroundSnapshotInstance`'s linked list with a self-cycle (eventually
OOM on main thread).

Both are fixed in PR #18 (merged → `10.29.1-20260424024911-12b794f`). We
pin via the pkg.pr.new URL because the published npm package is named
`@lynx-js/internal-preact`, which can't satisfy `@preact/signals`'s
`preact` peer-dep — that creates a dual-preact graph and breaks hooks
(see commit message of `d214d521`).

### 2. Fix `SnapshotInstance.insertBefore` for cross-wrapper case

For SlotV2 each `$N` has its own wrapper element. The original code only
fell back to `__AppendElement` when `newNode.__slotIndex <
existingNode.__slotIndex`; the `>` direction fell through to
`__InsertElementBefore(newWrapper, node, ref)` which throws
`NotFoundError: The child can not be found in the parent.` because `ref`
lives in another wrapper.

The minimum repro is a 3-key cross-slot rearrange (`[F,H,E,G] →
[H,A,D,F]`) — `A` is new at slot 1, but its `existingNode` cursor is `F`
(still at slot 0 since F's slot update happens later). Widened the check
to `!==` so any cross-wrapper insert falls back to `__AppendElement`,
relying on DOM `appendChild`'s auto-detach to move the node out of its
old wrapper.

## Tests added
(`packages/react/testing-library/src/__tests__/slot-jsx.test.jsx`)

- single-key cross-slot move (`E moves $2 → $0`) with patch + tree
assertions
- multi-key cross-slot move (`H moves $0 → $1, G moves $2 → $3`)
- three-key cross-slot move (the regression that triggered the runtime
fix)
- two-slot keyed swap
- array slot diff-context isolation — documents that arrays-in-slots
break cross-slot keyed reuse, so the unreachable edge case stays
unreachable
- 10000-step / 6-slot fuzz, **keyed and unkeyed variants** end-to-end
through the main-thread renderer, mirroring the property-based fuzz in
internal-preact

Each cross-slot test snapshots the exact element-PAPI sequence (`create
/ append / insertBefore / remove`) so any regression that adds a
redundant DOM op or accidentally re-creates a reused keyed node shows up
as a snapshot diff.

## Test plan
- [x] `slot-jsx.test.jsx` passes (8 tests)
- [x] full `react/runtime` + `react/testing-library` suites pass locally
(657 tests)
- [ ] CI green
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant