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}>`; + }; + 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': {}