diff --git a/.changeset/bump-internal-preact-10-29-1-12b794f.md b/.changeset/bump-internal-preact-10-29-1-12b794f.md
new file mode 100644
index 0000000000..c8f56d52e0
--- /dev/null
+++ b/.changeset/bump-internal-preact-10-29-1-12b794f.md
@@ -0,0 +1,7 @@
+---
+"@lynx-js/react": minor
+---
+
+Bump `@lynx-js/internal-preact` from `10.28.4-dfff9aa` to `10.29.1-20260424024911-12b794f` ([diff](https://github.com/lynx-family/internal-preact/compare/10.28.4-dfff9aa...10.29.1-20260424024911-12b794f)).
+
+Fixes wrong DOM order when a keyed child moves to a different `$N` slot across a re-render. Cross-slot moves now land at the correct slot position instead of being appended past stable siblings.
diff --git a/packages/react/package.json b/packages/react/package.json
index 4b97eb33dc..df2e157c99 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -196,7 +196,7 @@
"build": "rslib build"
},
"dependencies": {
- "preact": "npm:@lynx-js/internal-preact@10.28.4-dfff9aa"
+ "preact": "npm:@lynx-js/internal-preact@10.29.1-20260424024911-12b794f"
},
"devDependencies": {
"@lynx-js/types": "3.7.0",
diff --git a/packages/react/runtime/src/snapshot/snapshot/snapshot.ts b/packages/react/runtime/src/snapshot/snapshot/snapshot.ts
index a73c7a9c73..c924670867 100644
--- a/packages/react/runtime/src/snapshot/snapshot/snapshot.ts
+++ b/packages/react/runtime/src/snapshot/snapshot/snapshot.ts
@@ -372,7 +372,13 @@ export class SnapshotInstance {
__RemoveElement(parent, newNode.__element_root!);
}
if (existingNode) {
- if (__snapshot_def.isSlotV2 && newNode.__slotIndex < existingNode.__slotIndex) {
+ // SlotV2: each slot has its own wrapper. `existingNode` may live in a
+ // different wrapper — `insertBefore(node, ref)` across wrappers throws,
+ // so fall back to `append` (DOM auto-detaches the node from old parent).
+ if (
+ __snapshot_def.isSlotV2
+ && newNode.__slotIndex !== existingNode.__slotIndex
+ ) {
__AppendElement(parent, newNode.__element_root!);
} else {
__InsertElementBefore(
diff --git a/packages/react/testing-library/src/__tests__/setState-jsx.test.jsx b/packages/react/testing-library/src/__tests__/setState-jsx.test.jsx
deleted file mode 100644
index d4d902d889..0000000000
--- a/packages/react/testing-library/src/__tests__/setState-jsx.test.jsx
+++ /dev/null
@@ -1,276 +0,0 @@
-import { expect } from 'vitest';
-import { Component, useState } from '@lynx-js/react';
-
-import { fireEvent, render, act } from '..';
-import { prettyFormatSnapshotPatch } from '../../../runtime/lib/snapshot/debug/formatPatch';
-
-test('setState changes jsx', async () => {
- vi.spyOn(lynxTestingEnv.backgroundThread.lynxCoreInject.tt, 'OnLifecycleEvent');
- const onLifecycleEventCalls = lynxTestingEnv.backgroundThread.lynxCoreInject.tt.OnLifecycleEvent.mock.calls;
- vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
- const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
-
- const jsx0 = Hello 0;
- const jsx1 = Hello 1;
- const jsx2 = Hello 2;
-
- const Comp = () => {
- const [text0, setText0] = useState(jsx0);
- const [text1, setText1] = useState(jsx1);
- const handleTap = () => {
- setText0(jsx1);
- setText1(jsx0);
- };
- return (
-
- {text0}
- ---
- {[0, 1, 2].map((i) => text1)}
- ---
- {jsx2}
-
- );
- };
-
- const { container, findByTestId } = render();
-
- expect(container).toMatchInlineSnapshot(`
-
-
-
-
- Hello 0
-
-
-
- ---
-
-
-
- Hello 1
-
-
- Hello 1
-
-
- Hello 1
-
-
-
- ---
-
-
-
- Hello 2
-
-
-
-
- `);
-
- const view = await findByTestId('view');
- fireEvent.tap(view);
-
- {
- const snapshotPatch = JSON.parse(callLepusMethodCalls[0][1]['data']).patchList[0].snapshotPatch;
- const formattedSnapshotPatch = prettyFormatSnapshotPatch(snapshotPatch);
- expect(formattedSnapshotPatch).toMatchInlineSnapshot(`
- [
- {
- "id": 2,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_4",
- },
- {
- "id": 2,
- "op": "SetAttributes",
- "values": [
- 1,
- ],
- },
- {
- "id": 3,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_1",
- },
- {
- "beforeId": null,
- "childId": 3,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 0,
- },
- {
- "id": 4,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_2",
- },
- {
- "beforeId": null,
- "childId": 4,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 1,
- },
- {
- "id": 5,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_2",
- },
- {
- "beforeId": null,
- "childId": 5,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 1,
- },
- {
- "id": 6,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_2",
- },
- {
- "beforeId": null,
- "childId": 6,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 1,
- },
- {
- "id": 7,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_3",
- },
- {
- "beforeId": null,
- "childId": 7,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 2,
- },
- {
- "beforeId": null,
- "childId": 2,
- "op": "InsertBefore",
- "parentId": -1,
- "slotIndex": 0,
- },
- ]
- `);
- }
-
- {
- const snapshotPatch = JSON.parse(callLepusMethodCalls[1][1]['data']).patchList[0].snapshotPatch;
- const formattedSnapshotPatch = prettyFormatSnapshotPatch(snapshotPatch);
- expect(formattedSnapshotPatch).toMatchInlineSnapshot(`
- [
- {
- "childId": 3,
- "op": "RemoveChild",
- "parentId": 2,
- },
- {
- "id": 8,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_2",
- },
- {
- "beforeId": 4,
- "childId": 8,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 0,
- },
- {
- "childId": 4,
- "op": "RemoveChild",
- "parentId": 2,
- },
- {
- "childId": 5,
- "op": "RemoveChild",
- "parentId": 2,
- },
- {
- "childId": 6,
- "op": "RemoveChild",
- "parentId": 2,
- },
- {
- "id": 9,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_1",
- },
- {
- "beforeId": null,
- "childId": 9,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 1,
- },
- {
- "id": 10,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_1",
- },
- {
- "beforeId": null,
- "childId": 10,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 1,
- },
- {
- "id": 11,
- "op": "CreateElement",
- "type": "__snapshot_c1928_test_1",
- },
- {
- "beforeId": null,
- "childId": 11,
- "op": "InsertBefore",
- "parentId": 2,
- "slotIndex": 1,
- },
- ]
- `);
- }
-
- expect(container).toMatchInlineSnapshot(`
-
-
-
-
- Hello 1
-
-
-
- ---
-
-
-
- Hello 0
-
-
- Hello 0
-
-
- Hello 0
-
-
-
- ---
-
-
-
- Hello 2
-
-
-
-
- `);
-});
diff --git a/packages/react/testing-library/src/__tests__/slot-jsx.test.jsx b/packages/react/testing-library/src/__tests__/slot-jsx.test.jsx
new file mode 100644
index 0000000000..e462a1a20f
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/slot-jsx.test.jsx
@@ -0,0 +1,934 @@
+import { expect } from 'vitest';
+import { useState } from '@lynx-js/react';
+
+import { fireEvent, render, act } from '..';
+import { prettyFormatSnapshotPatch } from '../../../runtime/lib/snapshot/debug/formatPatch';
+import { printSnapshotInstanceToString } from '../../../runtime/lib/snapshot/debug/printSnapshot';
+import { __root } from '../../../runtime/lib/root';
+
+// Spy on the main-thread element-PAPI mutation calls and return a snapshot-friendly
+// trace of `op(parent, child[, ref])` lines. Each element is shown as `text`.
+// Used to lock in the exact DOM operations a render produces so accidental redundant
+// ops show up as a diff. (We spy directly on the PAPI globals rather than going
+// through the existing `console.alog` trace, which would require dual-thread render
+// + careful ordering against the runtime alog wrap.)
+function spyElementApi() {
+ const g = lynxTestingEnv.mainThread.globalThis;
+ const desc = el => {
+ if (el == null) return 'null';
+ const tag = (el.tagName || 'raw').toLowerCase();
+ const txt = (el.textContent || '').trim().replace(/\s+/g, ' ');
+ return txt && txt.length <= 12 ? `<${tag}>${txt}${tag}>` : `<${tag}>`;
+ };
+ const ops = [];
+ const wrap = (name, formatter) => {
+ const original = g[name];
+ vi.spyOn(g, name).mockImplementation((...args) => {
+ ops.push(formatter(args));
+ return original.apply(g, args);
+ });
+ };
+ wrap('__AppendElement', ([parent, child]) => `append(${desc(parent)} <- ${desc(child)})`);
+ wrap(
+ '__InsertElementBefore',
+ ([parent, child, ref]) => `insertBefore(${desc(parent)}: ${desc(child)} before ${desc(ref)})`,
+ );
+ wrap('__RemoveElement', ([parent, child]) => `remove(${desc(parent)} -x ${desc(child)})`);
+ wrap('__CreateElement', ([type]) => `create(${type})`);
+ wrap('__CreateView', () => `create(view)`);
+ wrap('__CreateText', () => `create(text)`);
+ wrap('__CreateRawText', ([text]) => `create(raw-text "${text}")`);
+ wrap('__CreateWrapperElement', () => `create(wrapper)`);
+ let mark = 0;
+ return {
+ mark() {
+ mark = ops.length;
+ },
+ trace() {
+ return ops.slice(mark).join('\n');
+ },
+ };
+}
+
+test('setState changes jsx', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+
+ const jsx0 = Hello 0;
+ const jsx1 = Hello 1;
+ const jsx2 = Hello 2;
+
+ const Comp = () => {
+ const [text0, setText0] = useState(jsx0);
+ const [text1, setText1] = useState(jsx1);
+ const handleTap = () => {
+ setText0(jsx1);
+ setText1(jsx0);
+ };
+ return (
+
+ {text0}
+ ---
+ {[0, 1, 2].map((i) => text1)}
+ ---
+ {jsx2}
+
+ );
+ };
+
+ const { container, findByTestId } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ Hello 0
+
+
+
+ ---
+
+
+
+ Hello 1
+
+
+ Hello 1
+
+
+ Hello 1
+
+
+
+ ---
+
+
+
+ Hello 2
+
+
+
+
+ `);
+
+ {
+ expect(callLepusMethodCalls.length).toBe(1);
+ const snapshotPatch = JSON.parse(callLepusMethodCalls[0][1]['data']).patchList[0].snapshotPatch;
+ const formattedSnapshotPatch = prettyFormatSnapshotPatch(snapshotPatch);
+ expect(formattedSnapshotPatch).toMatchInlineSnapshot(`
+ [
+ {
+ "id": 2,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_4",
+ },
+ {
+ "id": 2,
+ "op": "SetAttributes",
+ "values": [
+ 1,
+ ],
+ },
+ {
+ "id": 3,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_1",
+ },
+ {
+ "beforeId": null,
+ "childId": 3,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 0,
+ },
+ {
+ "id": 4,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_2",
+ },
+ {
+ "beforeId": null,
+ "childId": 4,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 1,
+ },
+ {
+ "id": 5,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_2",
+ },
+ {
+ "beforeId": null,
+ "childId": 5,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 1,
+ },
+ {
+ "id": 6,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_2",
+ },
+ {
+ "beforeId": null,
+ "childId": 6,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 1,
+ },
+ {
+ "id": 7,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_3",
+ },
+ {
+ "beforeId": null,
+ "childId": 7,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 2,
+ },
+ {
+ "beforeId": null,
+ "childId": 2,
+ "op": "InsertBefore",
+ "parentId": -1,
+ "slotIndex": 0,
+ },
+ ]
+ `);
+ }
+
+ expect(__root.constructor.name).toMatchInlineSnapshot(`"BackgroundSnapshotInstance"`);
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_4): [null]
+ | 3(__snapshot_c1db7_test_1): undefined
+ | 4(__snapshot_c1db7_test_2): undefined
+ | 5(__snapshot_c1db7_test_2): undefined
+ | 6(__snapshot_c1db7_test_2): undefined
+ | 7(__snapshot_c1db7_test_3): undefined"
+ `);
+ lynxTestingEnv.switchToMainThread();
+ try {
+ expect(__root.constructor.name).toMatchInlineSnapshot(`"SnapshotInstance"`);
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_4): ["2:0:"]
+ | 3(__snapshot_c1db7_test_1): undefined
+ | 4(__snapshot_c1db7_test_2): undefined
+ | 5(__snapshot_c1db7_test_2): undefined
+ | 6(__snapshot_c1db7_test_2): undefined
+ | 7(__snapshot_c1db7_test_3): undefined"
+ `);
+ } finally {
+ lynxTestingEnv.switchToBackgroundThread();
+ }
+
+ const view = await findByTestId('view');
+ fireEvent.tap(view);
+
+ {
+ expect(callLepusMethodCalls.length).toBe(2);
+ const snapshotPatch = JSON.parse(callLepusMethodCalls[1][1]['data']).patchList[0].snapshotPatch;
+ const formattedSnapshotPatch = prettyFormatSnapshotPatch(snapshotPatch);
+ expect(formattedSnapshotPatch).toMatchInlineSnapshot(`
+ [
+ {
+ "childId": 3,
+ "op": "RemoveChild",
+ "parentId": 2,
+ },
+ {
+ "id": 8,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_2",
+ },
+ {
+ "beforeId": 4,
+ "childId": 8,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 0,
+ },
+ {
+ "childId": 4,
+ "op": "RemoveChild",
+ "parentId": 2,
+ },
+ {
+ "childId": 5,
+ "op": "RemoveChild",
+ "parentId": 2,
+ },
+ {
+ "childId": 6,
+ "op": "RemoveChild",
+ "parentId": 2,
+ },
+ {
+ "id": 9,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_1",
+ },
+ {
+ "beforeId": 7,
+ "childId": 9,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 1,
+ },
+ {
+ "id": 10,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_1",
+ },
+ {
+ "beforeId": 7,
+ "childId": 10,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 1,
+ },
+ {
+ "id": 11,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_1",
+ },
+ {
+ "beforeId": 7,
+ "childId": 11,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 1,
+ },
+ ]
+ `);
+ }
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ Hello 1
+
+
+
+ ---
+
+
+
+ Hello 0
+
+
+ Hello 0
+
+
+ Hello 0
+
+
+
+ ---
+
+
+
+ Hello 2
+
+
+
+
+ `);
+
+ expect(__root.constructor.name).toMatchInlineSnapshot(`"BackgroundSnapshotInstance"`);
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_4): [null]
+ | 8(__snapshot_c1db7_test_2): undefined
+ | 9(__snapshot_c1db7_test_1): undefined
+ | 10(__snapshot_c1db7_test_1): undefined
+ | 11(__snapshot_c1db7_test_1): undefined
+ | 7(__snapshot_c1db7_test_3): undefined"
+ `);
+ lynxTestingEnv.switchToMainThread();
+ try {
+ expect(__root.constructor.name).toMatchInlineSnapshot(`"SnapshotInstance"`);
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_4): ["2:0:"]
+ | 8(__snapshot_c1db7_test_2): undefined
+ | 9(__snapshot_c1db7_test_1): undefined
+ | 10(__snapshot_c1db7_test_1): undefined
+ | 11(__snapshot_c1db7_test_1): undefined
+ | 7(__snapshot_c1db7_test_3): undefined"
+ `);
+ } finally {
+ lynxTestingEnv.switchToBackgroundThread();
+ }
+});
+
+test('cross-slot keyed move: E is placed before A with correct beforeId in patch', async () => {
+ const tX = X;
+ const tA = A;
+ const tE = E;
+ const tD = D;
+ const tN = N;
+ vi.spyOn(lynxTestingEnv.backgroundThread.lynxCoreInject.tt, 'OnLifecycleEvent');
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+
+ const Comp = () => {
+ const [moved, setMoved] = useState(false);
+ return (
+ setMoved(true)} data-testid='view'>
+ {moved ? tE : tX}
+ -
+ {tA}
+ -
+ {moved ? tN : tE}
+ -
+ {tD}
+
+ );
+ };
+
+ const trace = spyElementApi();
+ const { container, findByTestId } = render();
+ const view = await findByTestId('view');
+ trace.mark();
+ fireEvent.tap(view);
+
+ // Lock in the exact element-PAPI sequence so an extra DOM op shows up as a diff.
+ expect(trace.trace()).toMatchInlineSnapshot(`
+ "remove(X -x X)
+ remove( -x E)
+ append( <- E)
+ create(text)
+ create(raw-text "N")
+ append( <- N)
+ append( <- N)"
+ `);
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ E
+
+
+
+ -
+
+
+
+ A
+
+
+
+ -
+
+
+
+ N
+
+
+
+ -
+
+
+
+ D
+
+
+
+
+ `);
+
+ expect(callLepusMethodCalls.length).toBe(2);
+ const patch = JSON.parse(callLepusMethodCalls[1][1]['data']).patchList[0].snapshotPatch;
+ expect(prettyFormatSnapshotPatch(patch)).toMatchInlineSnapshot(`
+ [
+ {
+ "childId": 3,
+ "op": "RemoveChild",
+ "parentId": 2,
+ },
+ {
+ "beforeId": 4,
+ "childId": 5,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 0,
+ },
+ {
+ "id": 7,
+ "op": "CreateElement",
+ "type": "__snapshot_c1db7_test_9",
+ },
+ {
+ "beforeId": 6,
+ "childId": 7,
+ "op": "InsertBefore",
+ "parentId": 2,
+ "slotIndex": 2,
+ },
+ ]
+ `);
+
+ // Background tree: [E, A, N, D] in slot order.
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_10): [null]
+ | 5(__snapshot_c1db7_test_7): undefined
+ | 4(__snapshot_c1db7_test_6): undefined
+ | 7(__snapshot_c1db7_test_9): undefined
+ | 6(__snapshot_c1db7_test_8): undefined"
+ `);
+});
+
+// Two keys move across slots simultaneously: H moves $0→$1, G moves $2→$3.
+// Verifies background tree stays in slot order and main-thread container is correct.
+test('multi-key cross-slot moves keep background snapshot tree in slot order', async () => {
+ const ITEMS = {
+ A: A,
+ B: B,
+ C: C,
+ D: D,
+ E: E,
+ F: F,
+ G: G,
+ H: H,
+ };
+
+ const before = ['H', 'A', 'G', 'B'];
+ const after = ['F', 'H', 'E', 'G'];
+
+ let setMoved;
+ const Comp = () => {
+ const [moved, set] = useState(false);
+ setMoved = set;
+ const layout = moved ? after : before;
+ return (
+
+ {ITEMS[layout[0]]}
+ -
+ {ITEMS[layout[1]]}
+ -
+ {ITEMS[layout[2]]}
+ -
+ {ITEMS[layout[3]]}
+
+ );
+ };
+
+ const trace = spyElementApi();
+ const { container } = render();
+
+ const getViewChildCount = () => {
+ const tree = printSnapshotInstanceToString(__root);
+ return (tree.match(/^ {4}\| \d+\(/gm) ?? []).length;
+ };
+
+ expect(getViewChildCount()).toBe(4);
+
+ trace.mark();
+ act(() => setMoved(true));
+
+ expect(trace.trace()).toMatchInlineSnapshot(`
+ "remove(A -x A)
+ remove(B -x B)
+ create(text)
+ create(raw-text "F")
+ append( <- F)
+ insertBefore(H: F before H)
+ remove( -x H)
+ append( <- H)
+ create(text)
+ create(raw-text "E")
+ append( <- E)
+ insertBefore(G: E before G)
+ remove( -x G)
+ append( <- G)"
+ `);
+
+ expect(getViewChildCount()).toBe(4);
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_19): undefined
+ | 7(__snapshot_c1db7_test_16): undefined
+ | 3(__snapshot_c1db7_test_18): undefined
+ | 8(__snapshot_c1db7_test_15): undefined
+ | 5(__snapshot_c1db7_test_17): undefined"
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ F
+
+
+
+ -
+
+
+
+ H
+
+
+
+ -
+
+
+
+ E
+
+
+
+ -
+
+
+
+ G
+
+
+
+
+ `);
+});
+
+// Three keys cross slots in one render: F $0→$3, H $1→$0, plus E/G removed and A/D created.
+// Each $N has its own wrapper element; an InsertBefore where `newNode` and the
+// `existingNode` reference live in different slot wrappers can't go through
+// `parent.insertBefore(node, ref)` (ref isn't a child of parent) and must fall
+// back to appending into the new slot's wrapper.
+test('three-key cross-slot move applies cleanly on main thread', async () => {
+ const ITEMS = {
+ A: A,
+ D: D,
+ E: E,
+ F: F,
+ G: G,
+ H: H,
+ };
+
+ const before = ['F', 'H', 'E', 'G'];
+ const after = ['H', 'A', 'D', 'F'];
+
+ let setMoved;
+ const Comp = () => {
+ const [moved, set] = useState(false);
+ setMoved = set;
+ const layout = moved ? after : before;
+ return (
+
+ {ITEMS[layout[0]]}
+ -
+ {ITEMS[layout[1]]}
+ -
+ {ITEMS[layout[2]]}
+ -
+ {ITEMS[layout[3]]}
+
+ );
+ };
+
+ const trace = spyElementApi();
+ const { container } = render();
+
+ // Spy on element-API calls during the *update* (after initial render) so any
+ // regression that adds redundant DOM ops to a cross-slot move is caught.
+ trace.mark();
+ act(() => setMoved(true));
+ expect(trace.trace()).toMatchInlineSnapshot(`
+ "remove(E -x E)
+ remove(G -x G)
+ remove(F -x H)
+ insertBefore(F: H before F)
+ create(text)
+ create(raw-text "A")
+ append( <- A)
+ append( <- A)
+ create(text)
+ create(raw-text "D")
+ append( <- D)
+ append( <- D)
+ remove( -x F)
+ append( <- F)"
+ `);
+
+ // Container reflects the new layout end-to-end through the main-thread renderer.
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ H
+
+
+
+ -
+
+
+
+ A
+
+
+
+ -
+
+
+
+ D
+
+
+
+ -
+
+
+
+ F
+
+
+
+
+ `);
+ expect(printSnapshotInstanceToString(__root)).toMatchInlineSnapshot(`
+ "| -1(root): undefined
+ | 2(__snapshot_c1db7_test_26): undefined
+ | 4(__snapshot_c1db7_test_25): undefined
+ | 7(__snapshot_c1db7_test_20): undefined
+ | 8(__snapshot_c1db7_test_21): undefined
+ | 3(__snapshot_c1db7_test_23): undefined"
+ `);
+});
+
+// Mixing a single-VNode slot with an array slot: cross-slot keyed move from a
+// view-level VNode (slot 0) into an array slot (slot 1) does NOT happen because
+// arrays in JSX become Fragments with their own diff context. preact unmounts the
+// view-level `b` and creates a fresh one inside the Fragment — verified here so
+// the assumption is recorded.
+test('array slot isolates diff context: keyed VNode is recreated, not cross-slot reused', async () => {
+ const tA = A;
+ const tb = b;
+ const tx = x;
+ let setMoved;
+ const Comp = () => {
+ const [moved, set] = useState(false);
+ setMoved = set;
+ return (
+
+ {moved ? null : tb}
+ -
+ {moved ? [tA, tb, tx] : [tx]}
+
+ );
+ };
+
+ const trace = spyElementApi();
+ const { container } = render();
+ trace.mark();
+ act(() => setMoved(true));
+
+ // No cross-wrapper insertBefore: b is unmounted from slot 0 and a fresh b is
+ // created and inserted via same-slot insertBefore inside slot 1's Fragment.
+ expect(trace.trace()).toMatchInlineSnapshot(`
+ "remove(b -x b)
+ create(text)
+ create(raw-text "A")
+ append( <- A)
+ insertBefore(x: A before x)
+ create(text)
+ create(raw-text "b")
+ append( <- b)
+ insertBefore(Ax: b before x)"
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ -
+
+
+
+ A
+
+
+ b
+
+
+ x
+
+
+
+
+ `);
+});
+
+// Two view-level single-VNode slots swap their content via cross-slot keyed reuse.
+// This is the *only* shape where a cross-slot keyed move can happen in SlotV2;
+// each slot still ends up with at most one child, so the cross-wrapper insertBefore
+// fallback (`__AppendElement`) is correct (no intra-slot ordering to violate).
+test('cross-slot keyed swap between two single-VNode slots', async () => {
+ const tA = A;
+ const tB = B;
+ let setSwap;
+ const Comp = () => {
+ const [swap, set] = useState(false);
+ setSwap = set;
+ return (
+
+ {swap ? tB : tA}
+ -
+ {swap ? tA : tB}
+
+ );
+ };
+
+ const trace = spyElementApi();
+ const { container } = render();
+ trace.mark();
+ act(() => setSwap(true));
+
+ expect(trace.trace()).toMatchInlineSnapshot(`
+ "remove(A -x B)
+ insertBefore(A: B before A)
+ remove( -x A)
+ append( <- A)"
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ B
+
+
+
+ -
+
+
+
+ A
+
+
+
+
+ `);
+});
+
+// Random slot-layout transitions driven end-to-end through the main-thread renderer.
+// Variants:
+// - keyed permutation: each slot always filled, keys reshuffled (cross-slot reuse)
+// - unkeyed permutation: each slot always filled, no keys (positional diff path)
+// - keyed sparse: slots independently null/filled (add/remove + cross-slot)
+// - unkeyed sparse: slots independently null/filled, no keys (add/remove only)
+// Sparse layouts: per slot ~70% chance to fill, picking a not-yet-used key. Empty
+// slots produce empty ``.
+function runSlotFuzz({ withKey, sparse, seed: initialSeed }) {
+ const ALL = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ const ITEMS = withKey
+ ? Object.fromEntries(ALL.map(k => [k, {k}]))
+ : Object.fromEntries(ALL.map(k => [k, {k}]));
+
+ const SLOT_COUNT = 6;
+ const STEPS = 10000;
+ let seed = initialSeed >>> 0;
+ const rand = () => {
+ seed ^= seed << 13;
+ seed >>>= 0;
+ seed ^= seed >>> 17;
+ seed ^= seed << 5;
+ seed >>>= 0;
+ return seed / 0x100000000;
+ };
+ const shuffled = () => {
+ const pool = ALL.slice();
+ for (let i = pool.length - 1; i > 0; i--) {
+ const j = Math.floor(rand() * (i + 1));
+ [pool[i], pool[j]] = [pool[j], pool[i]];
+ }
+ return pool;
+ };
+ const pickLayout = () => {
+ if (!sparse) return shuffled().slice(0, SLOT_COUNT);
+ const pool = shuffled();
+ let next = 0;
+ return Array.from({ length: SLOT_COUNT }, () => (rand() < 0.7 ? pool[next++] : null));
+ };
+
+ const layouts = [];
+ for (let i = 0; i < STEPS; i++) layouts.push(pickLayout());
+
+ let step = 0;
+ let setStep = null;
+ const Comp = () => {
+ const [s, setS] = useState(0);
+ setStep = setS;
+ const layout = layouts[s];
+ return (
+
+ {layout[0] != null ? ITEMS[layout[0]] : null}
+ -
+ {layout[1] != null ? ITEMS[layout[1]] : null}
+ -
+ {layout[2] != null ? ITEMS[layout[2]] : null}
+ -
+ {layout[3] != null ? ITEMS[layout[3]] : null}
+ -
+ {layout[4] != null ? ITEMS[layout[4]] : null}
+ -
+ {layout[5] != null ? ITEMS[layout[5]] : null}
+
+ );
+ };
+
+ const { container } = render();
+ // Read each $N wrapper's textContent (empty string for empty slots).
+ const slotOrder = () =>
+ Array.from(container.querySelectorAll('view > wrapper'))
+ .map(w => w.textContent.trim() || null);
+
+ expect(slotOrder()).toEqual(layouts[0]);
+ for (step = 1; step < STEPS; step++) {
+ expect(slotOrder()).toEqual(layouts[step - 1]);
+ act(() => {
+ setStep(step);
+ });
+ expect(slotOrder()).toEqual(layouts[step]);
+ }
+}
+
+test('fuzz (keyed): cross-slot keyed moves keep slot order', { timeout: 30000 }, () => {
+ runSlotFuzz({ withKey: true, sparse: false, seed: 0xDEADBEEF });
+});
+
+test('fuzz (unkeyed): positional slot updates keep slot order', { timeout: 30000 }, () => {
+ runSlotFuzz({ withKey: false, sparse: false, seed: 0x1337C0DE });
+});
+
+test('fuzz (keyed, sparse): random null/filled slots stay correct', { timeout: 30000 }, () => {
+ runSlotFuzz({ withKey: true, sparse: true, seed: 0xCAFEBABE });
+});
+
+test('fuzz (unkeyed, sparse): random null/filled slots stay correct', { timeout: 30000 }, () => {
+ runSlotFuzz({ withKey: false, sparse: true, seed: 0xFEEDFACE });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23c7a4207e..5b0f2a1d62 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -678,8 +678,8 @@ importers:
packages/react:
dependencies:
preact:
- specifier: npm:@lynx-js/internal-preact@10.28.4-dfff9aa
- version: '@lynx-js/internal-preact@10.28.4-dfff9aa'
+ specifier: npm:@lynx-js/internal-preact@10.29.1-20260424024911-12b794f
+ version: '@lynx-js/internal-preact@10.29.1-20260424024911-12b794f'
devDependencies:
'@lynx-js/types':
specifier: 3.7.0
@@ -3494,8 +3494,8 @@ packages:
'@lynx-js/react': '*'
'@lynx-js/types': '*'
- '@lynx-js/internal-preact@10.28.4-dfff9aa':
- resolution: {integrity: sha512-i2xFwaWsmfODVfb9oYurZWCBDlnOdXdP/IsfW/R8i9vLtV6eo7I73U4smBINSNtQfSwpdQHooslEFD94RHVqSw==}
+ '@lynx-js/internal-preact@10.29.1-20260424024911-12b794f':
+ resolution: {integrity: sha512-MQ+xjPL2f1P9/eCAdkT2h9cJRXl1qqhSJDX9GkPETt3UAepLo5N+HbGB8Qr3IUzqGDWMr3Mj76my1P0pLkKVDg==}
'@lynx-js/lynx-core@0.1.3':
resolution: {integrity: sha512-uWzKKYJUK4Q09ZRZxWSAFINnmZb9piWPbvWF9SkLn+3snBl9u/BJa4ekPRcKWAhBmpbtxWH1x27fxe3Q3p5l3Q==}
@@ -12436,7 +12436,7 @@ snapshots:
'@lynx-js/react': link:packages/react
'@lynx-js/types': 3.7.0
- '@lynx-js/internal-preact@10.28.4-dfff9aa': {}
+ '@lynx-js/internal-preact@10.29.1-20260424024911-12b794f': {}
'@lynx-js/lynx-core@0.1.3': {}