diff --git a/codecov.yml b/codecov.yml index 89e92302b8..f2b01de702 100644 --- a/codecov.yml +++ b/codecov.yml @@ -30,6 +30,7 @@ ignore: - "packages/genui/**" - "pnpm-lock.yaml" - "rstest.config.ts" + - "**/__test__/**/fixtures/**" fixes: - "/home/runner/_work/lynx-stack::" diff --git a/packages/react/runtime/__test__/element-template/debug/alog.test.ts b/packages/react/runtime/__test__/element-template/debug/alog.test.ts index 0c6b7c6c45..d36dee785b 100644 --- a/packages/react/runtime/__test__/element-template/debug/alog.test.ts +++ b/packages/react/runtime/__test__/element-template/debug/alog.test.ts @@ -89,21 +89,33 @@ describe('ElementTemplate alog helpers', () => { const text = new BackgroundElementTemplateInstance('_et_builtin_raw_text', ['hello']); root.appendChild(card); + text.__slotIndex = 2; card.appendChild(text); - card.elementSlots[0] = [text]; - card.elementSlots[1] = []; const output = printElementTemplateTreeToString(root); expect(output).toContain('root#1'); expect(output).toContain('_et_card#2'); expect(output).toContain('attributeSlots: ["title"]'); - expect(output).toContain('elementSlots[0]: [3]'); + expect(output).toContain('elementSlots[2]: [3]'); expect(output).not.toContain('elementSlots[1]'); expect(output).toContain('_et_builtin_raw_text#3'); expect(output).toContain('attributeSlots: ["hello"]'); }); + it('skips sparse element slots when printing the background tree', () => { + const root = new BackgroundElementTemplateInstance('root'); + const child = new BackgroundElementTemplateInstance('view'); + child.__slotIndex = 1; + root.appendChild(child); + + const output = printElementTemplateTreeToString(root); + + expect(output).toContain(`view#${child.instanceId}`); + expect(output).toContain(`elementSlots[1]: [${child.instanceId}]`); + expect(output).not.toMatch(/elementSlots\[0\]/); + }); + it('prints an empty marker for missing roots', () => { expect(printElementTemplateTreeToString(null)).toBe(''); }); diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/instance/_shared.ts b/packages/react/runtime/__test__/element-template/fixtures/background/instance/_shared.ts index e259a8fe67..9631d3f9df 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/background/instance/_shared.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/background/instance/_shared.ts @@ -6,10 +6,7 @@ import { markElementTemplateHydrated, resetElementTemplateCommitState, } from '../../../../../src/element-template/background/commit-hook.js'; -import { - BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, -} from '../../../../../src/element-template/background/instance.js'; +import { BackgroundElementTemplateInstance } from '../../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../../src/element-template/background/manager.js'; export function runCase(runner: () => T): T { @@ -27,7 +24,6 @@ export function runCase(runner: () => T): T { export { BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, globalCommitContext, markElementTemplateHydrated, resetGlobalCommitContext, diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/supports-silent-insert-before/case.ts b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/supports-silent-insert-before/case.ts index 6fe379c6f3..56125ac143 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/supports-silent-insert-before/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/background/instance/ops/supports-silent-insert-before/case.ts @@ -1,19 +1,10 @@ -import { - BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, - globalCommitContext, - runCase, -} from '../../_shared.js'; +import { BackgroundElementTemplateInstance, globalCommitContext, runCase } from '../../_shared.js'; export function run() { return runCase(() => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); - const child = new BackgroundElementTemplateInstance('view'); - slot.insertBefore(child, null, true); + root.insertBefore(child, null, true); return globalCommitContext.ops; }); diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/render/supports-slot-component-materiality/output.txt b/packages/react/runtime/__test__/element-template/fixtures/background/render/supports-slot-component-materiality/output.txt index d755935f47..a3b8e819e1 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/background/render/supports-slot-component-materiality/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/background/render/supports-slot-component-materiality/output.txt @@ -1,18 +1,12 @@ Object { "tree": " <_et_a94a8_test_3> - - <_et_a94a8_test_2> - - <_et_a94a8_test_4 /> - - - <_et_a94a8_test_2> - - <_et_a94a8_test_5 /> - - - + <_et_a94a8_test_2> + <_et_a94a8_test_4 /> + + <_et_a94a8_test_2> + <_et_a94a8_test_5 /> + ", diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/update-sparse/mount-sparse-template/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/background/update-sparse/mount-sparse-template/index.tsx new file mode 100644 index 0000000000..0f18231f70 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/background/update-sparse/mount-sparse-template/index.tsx @@ -0,0 +1,26 @@ +interface AppProps { + showCard?: boolean; + showHeader?: boolean; + items?: string[]; +} + +function SparseCard({ showHeader, items }: { showHeader: boolean; items: string[] }) { + return ( + + + {showHeader && header} + + + {items.map(item => {item})} + + + ); +} + +export function App({ showCard = false, showHeader = false, items = ['body'] }: AppProps) { + return ( + + {showCard && } + + ); +} diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.creates-and-inserts-new/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.creates-and-inserts-new/output.txt index 8f737af5f6..425a5c230b 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.creates-and-inserts-new/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.creates-and-inserts-new/output.txt @@ -1,7 +1,7 @@ Object { "stream": Array [ 1, - 4, + 3, "_et_77307_test_2", null, Array [], @@ -9,7 +9,7 @@ Object { 3, -1, 0, - 4, + 3, 0, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.inserts-before-existing-sibling/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.inserts-before-existing-sibling/output.txt index 27dc92a194..9c533daae5 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.inserts-before-existing-sibling/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.inserts-before-existing-sibling/output.txt @@ -1,7 +1,7 @@ Object { "stream": Array [ 1, - 4, + 3, "_et_f9aab_test_2", null, Array [], @@ -9,7 +9,7 @@ Object { 3, -3, 0, - 4, + 3, -1, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.mixed-operations/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.mixed-operations/output.txt index 1b19623ee0..80cb62b7b8 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.mixed-operations/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.mixed-operations/output.txt @@ -8,7 +8,7 @@ Object { -1, ], 1, - 5, + 4, "_et_04991_test_3", null, Array [], @@ -16,7 +16,7 @@ Object { 3, -3, 0, - 5, + 4, 0, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.non-string-raw-text-key-on-main/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.non-string-raw-text-key-on-main/output.txt index 486a16f3dc..a6195536aa 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.non-string-raw-text-key-on-main/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/children.non-string-raw-text-key-on-main/output.txt @@ -1,7 +1,7 @@ Object { "stream": Array [ 1, - 4, + 3, "_et_builtin_raw_text", null, Array [ @@ -11,7 +11,7 @@ Object { 3, -2, 0, - 4, + 3, 0, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/complex-trees.deeply-nested-dynamic-content/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/complex-trees.deeply-nested-dynamic-content/output.txt index e415ce8bb9..1b1a01ce5b 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/complex-trees.deeply-nested-dynamic-content/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate-compiled/complex-trees.deeply-nested-dynamic-content/output.txt @@ -5,7 +5,7 @@ Object { 0, "BG", 1, - 10, + 7, "_et_c2579_test_5", null, Array [], @@ -13,7 +13,7 @@ Object { 3, -4, 0, - 10, + 7, 0, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/_shared.tsx b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/_shared.tsx index dfc41db8d8..04209f01fb 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/_shared.tsx +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/_shared.tsx @@ -15,7 +15,6 @@ import { import '../../../../../src/element-template/native/index.js'; import { BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, BUILTIN_RAW_TEXT_TEMPLATE_KEY, } from '../../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../../src/element-template/background/manager.js'; @@ -378,11 +377,8 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.nextId = 0; const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); const child = new BackgroundElementTemplateInstance('child'); - slot0.appendChild(child); - rootInstance.appendChild(slot0); + rootInstance.appendChild(child); const before = createHydrationTemplate(-1, 'root'); const stream = hydrateBackground(before, rootInstance); @@ -410,21 +406,15 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.nextId = 0; const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - rootInstance.appendChild(slot0); const existing = new BackgroundElementTemplateInstance('existing'); - slot0.appendChild(existing); + rootInstance.appendChild(existing); const card = new BackgroundElementTemplateInstance('card'); card.setAttribute('attributeSlots', [{ id: 'card' }]); - const cardSlot = new BackgroundElementTemplateSlot(); - cardSlot.setAttribute('id', 0); const text = createTextNode('NEW'); - cardSlot.appendChild(text); - card.appendChild(cardSlot); - slot0.insertBefore(card, existing); + card.appendChild(text); + rootInstance.insertBefore(card, existing); const beforeExisting = createHydrationChild(-2, 'existing'); const beforeRemoved = createHydrationChild(-3, 'removed'); @@ -443,11 +433,8 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.nextId = 0; const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - rootInstance.appendChild(slot0); const rawText = createTextNode(''); - slot0.appendChild(rawText); + rootInstance.appendChild(rawText); const before = createHydrationTemplate(-1, 'root', { elementSlots: [[createHydrationRawTextChild(3, '')]], @@ -467,14 +454,11 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.nextId = 0; const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - rootInstance.appendChild(slot0); const rawTextText = createTextNode('bg'); const rawTextInstance = new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY); - slot0.appendChild(rawTextText); - slot0.appendChild(rawTextInstance); + rootInstance.appendChild(rawTextText); + rootInstance.appendChild(rawTextInstance); const beforeExistingString = createHydrationRawTextChild(rawTextText.instanceId, 'bg'); const beforeExistingNonString = createHydrationRawTextChild(rawTextInstance.instanceId, 123); @@ -500,15 +484,16 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.clear(); backgroundElementTemplateInstanceManager.nextId = 0; + // Background side has a child living at slot 1 that the serialized payload + // never mentions. The hydrate loop in `hydrate.ts` extends its slot count + // via `Math.max(serializedSlots.length, instance.elementSlots.length)` and + // synthesizes a create + insert for the background-only child at slot 1. const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - rootInstance.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - rootInstance.appendChild(slot1); - - const before = createHydrationTemplate(-1, 'root', { elementSlots: [[], []] }); + const slot1Child = new BackgroundElementTemplateInstance('child'); + slot1Child.__slotIndex = 1; + rootInstance.appendChild(slot1Child); + + const before = createHydrationTemplate(-1, 'root', { elementSlots: [[]] }); const stream = hydrateBackground(before, rootInstance); return { stream }; }); @@ -520,9 +505,6 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.nextId = 0; const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - rootInstance.appendChild(slot0); const before = createHydrationTemplate(-1, 'root', { elementSlots: [[]] }); const stream = hydrateBackground(before, rootInstance); @@ -536,16 +518,13 @@ export function runCaseByName(name: string): unknown { backgroundElementTemplateInstanceManager.nextId = 0; const rootInstance = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - rootInstance.appendChild(slot0); const childA = new BackgroundElementTemplateInstance('a'); const childB = new BackgroundElementTemplateInstance('b'); const childC = new BackgroundElementTemplateInstance('c'); - slot0.appendChild(childB); - slot0.appendChild(childA); - slot0.appendChild(childC); + rootInstance.appendChild(childB); + rootInstance.appendChild(childA); + rootInstance.appendChild(childC); const beforeChildA = createHydrationChild(childA.instanceId, 'a'); const beforeChildB = createHydrationChild(childB.instanceId, 'b'); diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.creates-missing-nodes-recursively/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.creates-missing-nodes-recursively/output.txt index d9b87195d7..ceaa1a335b 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.creates-missing-nodes-recursively/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.creates-missing-nodes-recursively/output.txt @@ -1,7 +1,7 @@ Object { "stream": Array [ 1, - 6, + 4, "_et_builtin_raw_text", null, Array [ @@ -9,7 +9,7 @@ Object { ], Array [], 1, - 4, + 3, "card", null, Array [ @@ -19,13 +19,13 @@ Object { ], Array [ Array [ - 6, + 4, ], ], 3, -1, 0, - 4, + 3, -2, 4, -1, diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.iterates-existing-slots/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.iterates-existing-slots/output.txt index b2f705ad33..63ba1e3a9e 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.iterates-existing-slots/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.iterates-existing-slots/output.txt @@ -1,3 +1,15 @@ Object { - "stream": Array [], + "stream": Array [ + 1, + 2, + "child", + null, + Array [], + Array [], + 3, + -1, + 1, + 2, + 0, + ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.missing-slot-record-on-main/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.missing-slot-record-on-main/output.txt index a9c3d9ce1d..3f41b9b863 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.missing-slot-record-on-main/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/children.missing-slot-record-on-main/output.txt @@ -1,7 +1,7 @@ Object { "stream": Array [ 1, - 3, + 2, "child", null, Array [], @@ -9,7 +9,7 @@ Object { 3, -1, 0, - 3, + 2, 0, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.move-before-child/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.move-before-child/output.txt index 40a9c796bf..11501ad613 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.move-before-child/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.move-before-child/output.txt @@ -3,7 +3,7 @@ Object { 3, 1, 0, - 3, - 5, + 2, + 4, ], } diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.raw-text-key-branches/output.txt b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.raw-text-key-branches/output.txt index 6745642c57..5a74fc1d64 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.raw-text-key-branches/output.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/background-hydrate/coverage.raw-text-key-branches/output.txt @@ -1,7 +1,7 @@ Object { "stream": Array [ 2, - 4, + 3, 0, null, 4, diff --git a/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/applies-insert-before-with-reference/ops.txt b/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/applies-insert-before-with-reference/ops.txt index 8718a6e77a..a9781ab792 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/applies-insert-before-with-reference/ops.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/applies-insert-before-with-reference/ops.txt @@ -1,6 +1,6 @@ Array [ 1, - 4, + 3, "_et_3634f_test_1", null, Array [], @@ -8,6 +8,6 @@ Array [ 3, -3, 0, - 4, + 3, -1, ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/apply-hydration-ops/ops.txt b/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/apply-hydration-ops/ops.txt index e588f6e2c3..9b922eb725 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/apply-hydration-ops/ops.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/patch-compiled/apply-hydration-ops/ops.txt @@ -18,7 +18,7 @@ Array [ -1, 0, 1, - 9, + 7, "_et_b81b7_test_4", null, Array [], @@ -26,6 +26,6 @@ Array [ 3, -5, 0, - 9, + 7, 0, ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/index.js.txt index a81a482dcd..fccbbbeae3 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/index.js.txt @@ -4,23 +4,19 @@ const _et_7a8c6_test_3 = "_et_7a8c6_test_3"; const _et_7a8c6_test_1 = "_et_7a8c6_test_1"; export function App() { return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - [ - /*#__PURE__*/ _jsx(Sub, { - children: /*#__PURE__*/ _jsx(_et_7a8c6_test_2, {}) - }), - /*#__PURE__*/ _jsx(Sub, { - children: /*#__PURE__*/ _jsx(_et_7a8c6_test_3, {}) - }) - ] + $0: [ + /*#__PURE__*/ _jsx(Sub, { + children: /*#__PURE__*/ _jsx(_et_7a8c6_test_2, {}) + }), + /*#__PURE__*/ _jsx(Sub, { + children: /*#__PURE__*/ _jsx(_et_7a8c6_test_3, {}) + }) ] }); } const _et_7a8c6_test_4 = "_et_7a8c6_test_4"; function Sub(props) { return /*#__PURE__*/ _jsx(_et_7a8c6_test_4, { - children: [ - props.children - ] + $0: props.children }); } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/index.js.txt index edcc9215de..21d53e9c9e 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/index.js.txt @@ -2,19 +2,15 @@ import { jsx as _jsx } from "@lynx-js/react/jsx-runtime"; const _et_7a8c6_test_1 = "_et_7a8c6_test_1"; function CustomComponent(props) { return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - props.children - ] + $0: props.children }); } const _et_7a8c6_test_3 = "_et_7a8c6_test_3"; const _et_7a8c6_test_2 = "_et_7a8c6_test_2"; export function App() { return /*#__PURE__*/ _jsx(_et_7a8c6_test_2, { - children: [ - /*#__PURE__*/ _jsx(CustomComponent, { - children: /*#__PURE__*/ _jsx(_et_7a8c6_test_3, {}) - }) - ] + $0: /*#__PURE__*/ _jsx(CustomComponent, { + children: /*#__PURE__*/ _jsx(_et_7a8c6_test_3, {}) + }) }); } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/component/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/component/index.js.txt index 3d0506cef0..328a16c36c 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/component/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/component/index.js.txt @@ -5,10 +5,8 @@ export function App() { const y = 2; const z = 3; return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - x, - y, - z - ] + $0: x, + $1: y, + $2: z }); } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/index.js.txt index d01b5c8836..5146e2c260 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/index.js.txt @@ -8,15 +8,11 @@ export function App() { 'C' ]; return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_2, { - attributeSlots: [ - item - ], - children: [ - item - ] - })) - ] + $0: items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_2, { + attributeSlots: [ + item + ], + $0: item + })) }); } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/index.js.txt index b3b55d859c..b55cb20e1f 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/index.js.txt @@ -7,11 +7,9 @@ export class App extends Component { const innerText = 'B'; const trailing = 'C'; return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - leading, - innerText, - trailing - ] + $0: leading, + $1: innerText, + $2: trailing }); } } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/index.js.txt index 32c6844f17..01df92fbb1 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/index.js.txt @@ -11,20 +11,14 @@ export class App extends Component { 'C' ]; return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - items, - items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_2, { - children: [ - item - ] - })), - items, - items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_3, { - children: [ - item - ] - })) - ] + $0: items, + $1: items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_2, { + $0: item + })), + $2: items, + $3: items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_3, { + $0: item + })) }); } } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/index.js.txt index 3d8dfc673a..4fa51229d3 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/index.js.txt @@ -4,9 +4,7 @@ const _et_7a8c6_test_1 = "_et_7a8c6_test_1"; class Inner extends Component { render() { return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { - children: [ - this.props.message - ] + $0: this.props.message }); } } @@ -14,11 +12,9 @@ const _et_7a8c6_test_2 = "_et_7a8c6_test_2"; export class App extends Component { render() { return /*#__PURE__*/ _jsx(_et_7a8c6_test_2, { - children: [ - /*#__PURE__*/ _jsx(Inner, { - message: "X" - }) - ] + $0: /*#__PURE__*/ _jsx(Inner, { + message: "X" + }) }); } } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/react-example/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/react-example/index.js.txt index 953cd283ec..6ab1587ea2 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/react-example/index.js.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/react-example/index.js.txt @@ -25,17 +25,15 @@ export function App() { 1, arrow ], - children: [ - alterLogo ? /*#__PURE__*/ _jsx(_et_7a8c6_test_2, { - attributeSlots: [ - reactLynxLogo - ] - }) : /*#__PURE__*/ _jsx(_et_7a8c6_test_3, { - attributeSlots: [ - lynxLogo - ] - }), - ' src/App.tsx ' - ] + $0: alterLogo ? /*#__PURE__*/ _jsx(_et_7a8c6_test_2, { + attributeSlots: [ + reactLynxLogo + ] + }) : /*#__PURE__*/ _jsx(_et_7a8c6_test_3, { + attributeSlots: [ + lynxLogo + ] + }), + $1: ' src/App.tsx ' }); } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/index.js.txt b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/index.js.txt new file mode 100644 index 0000000000..731235c076 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/index.js.txt @@ -0,0 +1,14 @@ +import { jsx as _jsx } from "@lynx-js/react/jsx-runtime"; +const _et_7a8c6_test_2 = "_et_7a8c6_test_2"; +const _et_7a8c6_test_3 = "_et_7a8c6_test_3"; +const _et_7a8c6_test_1 = "_et_7a8c6_test_1"; +export function App({ showHeader = false, items = [ + 'body' +] }) { + return /*#__PURE__*/ _jsx(_et_7a8c6_test_1, { + $0: showHeader && /*#__PURE__*/ _jsx(_et_7a8c6_test_2, {}), + $1: items.map((item)=>/*#__PURE__*/ _jsx(_et_7a8c6_test_3, { + $0: item + }, item)) + }); +} diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/index.tsx new file mode 100644 index 0000000000..1a2980c9e8 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/index.tsx @@ -0,0 +1,17 @@ +interface AppProps { + showHeader?: boolean; + items?: string[]; +} + +export function App({ showHeader = false, items = ['body'] }: AppProps) { + return ( + + + {showHeader && header} + + + {items.map(item => {item})} + + + ); +} diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/output.txt b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/output.txt new file mode 100644 index 0000000000..a01d78d419 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/output.txt @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/papi.txt new file mode 100644 index 0000000000..e280455f9f --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/papi.txt @@ -0,0 +1,73 @@ +[ + [ + "__CreateElementTemplate", + "_et_builtin_raw_text", + null, + [ + "body" + ], + [], + -1 + ], + [ + "__CreateElementTemplate", + "_et_7a8c6_test_3", + null, + null, + [ + [ + { + "tag": "raw-text", + "attributes": { + "text": "body" + }, + "children": [], + "templateId": "_et_builtin_raw_text" + } + ] + ], + -2 + ], + [ + "__CreateElementTemplate", + "_et_7a8c6_test_1", + null, + [ + null + ], + [ + null, + [ + { + "tag": "text", + "attributes": {}, + "children": [ + { + "tag": "slot", + "attributes": { + "slot-id": 0 + }, + "children": [ + { + "tag": "raw-text", + "attributes": { + "text": "body" + }, + "children": [], + "templateId": "_et_builtin_raw_text" + } + ] + } + ], + "templateId": "_et_7a8c6_test_3" + } + ] + ], + -3 + ], + [ + "__AppendElement", + "0", + "<_et_7a8c6_test_1 />" + ] +] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/templates.json.txt b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/templates.json.txt new file mode 100644 index 0000000000..175929fcf1 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/fixtures/render/sparse-element-slot/templates.json.txt @@ -0,0 +1,97 @@ +[ + { + "templateId": "_et_7a8c6_test_2", + "compiledTemplate": { + "kind": "element", + "type": "text", + "attributesArray": [ + { + "kind": "static", + "key": "text", + "value": "header" + } + ], + "children": [] + }, + "sourceFile": "index.tsx" + }, + { + "templateId": "_et_7a8c6_test_3", + "compiledTemplate": { + "kind": "element", + "type": "text", + "attributesArray": [], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0 + } + ] + }, + "sourceFile": "index.tsx" + }, + { + "templateId": "_et_7a8c6_test_1", + "compiledTemplate": { + "kind": "element", + "type": "view", + "attributesArray": [], + "children": [ + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "static", + "key": "id", + "value": "header" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 0 + } + ] + }, + { + "kind": "element", + "type": "view", + "attributesArray": [ + { + "kind": "static", + "key": "id", + "value": "body" + } + ], + "children": [ + { + "kind": "elementSlot", + "type": "slot", + "elementSlotIndex": 1 + } + ] + } + ] + }, + "sourceFile": "index.tsx" + }, + { + "templateId": "_et_builtin_raw_text", + "compiledTemplate": { + "kind": "element", + "type": "raw-text", + "attributesArray": [ + { + "kind": "slot", + "key": "text", + "attrSlotIndex": 0 + } + ], + "children": [] + }, + "sourceFile": "" + } +] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/runtime/background/adapter/background-adapter.et.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/adapter/background-adapter.et.test.tsx index 10599bc753..286e720186 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/adapter/background-adapter.et.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/background/adapter/background-adapter.et.test.tsx @@ -6,7 +6,6 @@ import { BackgroundElementTemplateInstance, BUILTIN_RAW_TEXT_TEMPLATE_KEY, } from '../../../../../src/element-template/background/instance.js'; -import { __etSlot } from '../../../../../src/element-template/runtime/components/slot.js'; describe('Background Element Template Adapter', () => { let doc: BackgroundElementTemplateDocument; @@ -24,12 +23,6 @@ describe('Background Element Template Adapter', () => { expect(el.nodeType).toBe(1); }); - it('creates BackgroundElementTemplateSlot for "slot" type', () => { - const el = doc.createElement('slot'); - expect(el).toBeInstanceOf(BackgroundElementTemplateInstance); - expect(el.type).toBe('slot'); - }); - it('creates builtin raw-text template instances for text nodes', () => { const node = doc.createTextNode('hello'); expect(node).toBeInstanceOf(BackgroundElementTemplateInstance); @@ -69,28 +62,4 @@ describe('Background Element Template Adapter', () => { expect(child2.parent).toBeNull(); }); }); - - describe('__etSlot', () => { - it('returns element in background', () => { - vi.stubGlobal('__BACKGROUND__', true); - const vnode = __etSlot(10, 'content') as unknown as { - type: string; - props: { id: number; children: unknown }; - }; - expect(vnode).not.toBe('content'); - expect(vnode.type).toBe('slot'); - expect(vnode.props.id).toBe(10); - expect(vnode.props.children).toBe('content'); - - vi.unstubAllGlobals(); - }); - - it('throws in main thread (default)', () => { - vi.stubGlobal('__BACKGROUND__', false); - expect(() => __etSlot(10, 'content')).toThrow( - '__etSlot() should not run on the main thread. LEPUS ET children are lowered to slot arrays at compile time.', - ); - vi.unstubAllGlobals(); - }); - }); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts index 3e37c57012..f75c2ab55e 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/commit-context.test.ts @@ -12,7 +12,6 @@ import { } from '../../../../src/element-template/background/commit-context.js'; import { BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, BUILTIN_RAW_TEXT_TEMPLATE_KEY, collectElementTemplateSubtreeHandleIds, } from '../../../../src/element-template/background/instance.js'; @@ -58,16 +57,10 @@ describe('ElementTemplate commit context', () => { it('collects only handles that are registered in the main-thread registry', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child'); - const childSlot = new BackgroundElementTemplateSlot(); - childSlot.setAttribute('id', 0); - child.appendChild(childSlot); const rawText = new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, ['text']); - childSlot.appendChild(rawText); - slot.appendChild(child); + child.appendChild(rawText); + root.appendChild(child); expect(collectElementTemplateSubtreeHandleIds(root)).toEqual([ root.instanceId, diff --git a/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts index ec8a175bf5..bc360c843b 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts @@ -8,7 +8,6 @@ import { import { hydrate } from '../../../../src/element-template/background/hydrate.js'; import { BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, BUILTIN_RAW_TEXT_TEMPLATE_KEY, } from '../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; @@ -71,11 +70,8 @@ describe('hydrate', () => { it('returns an empty stream when background slot children already match', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child'); - slot.appendChild(child); + root.appendChild(child); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -90,20 +86,14 @@ describe('hydrate', () => { it('patches attribute slots while creating and inserting background-only children', () => { const root = new BackgroundElementTemplateInstance('root', ['background-root']); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const existing = new BackgroundElementTemplateInstance('item', ['background-existing']); - slot.appendChild(existing); + root.appendChild(existing); const card = new BackgroundElementTemplateInstance('card', ['background-card']); - const cardSlot = new BackgroundElementTemplateSlot(); - cardSlot.setAttribute('id', 0); - card.appendChild(cardSlot); const rawText = new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, ['NEW']); - cardSlot.appendChild(rawText); - slot.appendChild(card); + card.appendChild(rawText); + root.appendChild(card); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -150,9 +140,6 @@ describe('hydrate', () => { it('removes serialized children that are missing from the background slot', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const stale = new BackgroundElementTemplateInstance('stale'); @@ -170,14 +157,11 @@ describe('hydrate', () => { stale.instanceId, [stale.instanceId], ]); - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); }); it('includes nested serialized subtree handles from sparse slots when hydrate removes a stale child', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const stale = createHydrationChild(101, 'stale', { elementSlots: [ @@ -206,7 +190,7 @@ describe('hydrate', () => { 101, [101, 102, 103], ]); - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(101)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(102)).toBeUndefined(); @@ -215,13 +199,10 @@ describe('hydrate', () => { it('keeps existing background stale instances on the pending cleanup path during hydrate remove', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const stale = new BackgroundElementTemplateInstance('stale'); const keep = new BackgroundElementTemplateInstance('keep'); - slot.appendChild(keep); + root.appendChild(keep); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -246,16 +227,13 @@ describe('hydrate', () => { it('moves serialized children to match the background slot order', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const a = new BackgroundElementTemplateInstance('a'); const b = new BackgroundElementTemplateInstance('b'); const c = new BackgroundElementTemplateInstance('c'); - slot.appendChild(b); - slot.appendChild(a); - slot.appendChild(c); + root.appendChild(b); + root.appendChild(a); + root.appendChild(c); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -281,17 +259,12 @@ describe('hydrate', () => { it('treats a source-before-target cross-slot hydrate candidate as remove and recreate', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - root.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - root.appendChild(slot1); const moved = new BackgroundElementTemplateInstance('moved', ['after']); const localId = moved.instanceId; const mainThreadId = -2; - slot1.appendChild(moved); + moved.__slotIndex = 1; + root.appendChild(moved); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -321,7 +294,7 @@ describe('hydrate', () => { localId, 0, ]); - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); expect(root.elementSlots[1]).toEqual([moved]); expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(mainThreadId)).toBeUndefined(); @@ -330,17 +303,12 @@ describe('hydrate', () => { it('does not pull cross-slot hydrate recreate candidates back into a non-empty source slot', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - root.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - root.appendChild(slot1); const keep = new BackgroundElementTemplateInstance('keep'); const moved = new BackgroundElementTemplateInstance('moved', ['after']); - slot0.appendChild(keep); - slot1.appendChild(moved); + root.appendChild(keep); + moved.__slotIndex = 1; + root.appendChild(moved); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -381,17 +349,11 @@ describe('hydrate', () => { it('treats a target-before-source cross-slot hydrate candidate as recreate then remove', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - root.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - root.appendChild(slot1); const moved = new BackgroundElementTemplateInstance('moved', ['after']); const localId = moved.instanceId; const mainThreadId = -3; - slot0.appendChild(moved); + root.appendChild(moved); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -422,7 +384,7 @@ describe('hydrate', () => { [mainThreadId], ]); expect(root.elementSlots[0]).toEqual([moved]); - expect(root.elementSlots[1]).toEqual([]); + expect(root.elementSlots[1]).toBeUndefined(); expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(mainThreadId)).toBeUndefined(); expect(backgroundElementTemplateInstanceManager.get(localId)).toBe(moved); @@ -430,19 +392,15 @@ describe('hydrate', () => { it('recreates repeated cross-slot hydrate candidates in their target slot order', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - root.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - root.appendChild(slot1); const first = new BackgroundElementTemplateInstance('item'); const second = new BackgroundElementTemplateInstance('item'); const firstLocalId = first.instanceId; const secondLocalId = second.instanceId; - slot1.appendChild(first); - slot1.appendChild(second); + first.__slotIndex = 1; + root.appendChild(first); + second.__slotIndex = 1; + root.appendChild(second); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -488,7 +446,7 @@ describe('hydrate', () => { secondLocalId, 0, ]); - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); expect(root.elementSlots[1]).toEqual([first, second]); expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(-2)).toBeUndefined(); @@ -499,17 +457,11 @@ describe('hydrate', () => { it('recreates background children when serialized root has no child slots', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child', ['background-child']); - const childSlot = new BackgroundElementTemplateSlot(); - childSlot.setAttribute('id', 0); - child.appendChild(childSlot); const rawText = new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, ['NEW']); - childSlot.appendChild(rawText); - slot.appendChild(child); + child.appendChild(rawText); + root.appendChild(child); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root'), @@ -542,13 +494,10 @@ describe('hydrate', () => { it('registers event handlers for background-only insertion subtrees during hydrate', () => { __etAttrPlanMap.child = [0, adaptEventAttrSlot]; const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child'); const handler = vi.fn(); child.setAttribute('attributeSlots', [handler]); - slot.appendChild(child); + root.appendChild(child); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -576,20 +525,16 @@ describe('hydrate', () => { it('diffs multiple dynamic children slots independently during hydrate', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - root.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - root.appendChild(slot1); const newA = new BackgroundElementTemplateInstance('new-a'); - slot0.appendChild(newA); + root.appendChild(newA); const b0 = new BackgroundElementTemplateInstance('b0'); + b0.__slotIndex = 1; const b1 = new BackgroundElementTemplateInstance('b1'); - slot1.appendChild(b1); - slot1.appendChild(b0); + b1.__slotIndex = 1; + root.appendChild(b1); + root.appendChild(b0); const oldAId = -11; const stream = hydrate( @@ -634,17 +579,13 @@ describe('hydrate', () => { it('does not match same-type children across element slot indexes', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot0 = new BackgroundElementTemplateSlot(); - slot0.setAttribute('id', 0); - root.appendChild(slot0); - const slot1 = new BackgroundElementTemplateSlot(); - slot1.setAttribute('id', 1); - root.appendChild(slot1); const slot0Item = new BackgroundElementTemplateInstance('item', ['B']); + slot0Item.__slotIndex = 0; const slot1Item = new BackgroundElementTemplateInstance('item', ['A']); - slot0.appendChild(slot0Item); - slot1.appendChild(slot1Item); + slot1Item.__slotIndex = 1; + root.appendChild(slot0Item); + root.appendChild(slot1Item); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { @@ -672,11 +613,8 @@ describe('hydrate', () => { it('keeps hydrated handles for later background attribute updates', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child', ['before']); - slot.appendChild(child); + root.appendChild(child); const childHandleId = -2; hydrate( @@ -804,11 +742,8 @@ describe('hydrate', () => { try { const root = new BackgroundElementTemplateInstance('root', ['after-root']); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child'); - slot.appendChild(child); + root.appendChild(child); const oldRootId = root.instanceId; const oldChildId = child.instanceId; @@ -851,11 +786,8 @@ describe('hydrate', () => { it('treats omitted serialized slots as empty arrays', () => { const root = new BackgroundElementTemplateInstance('root', ['background-root']); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child'); - slot.appendChild(child); + root.appendChild(child); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root'), @@ -1071,42 +1003,13 @@ describe('hydrate', () => { expect(ref).not.toHaveBeenCalled(); }); - it('skips sparse background slot indexes when checking trailing slots', () => { - const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 2); - root.appendChild(slot); - - const stream = hydrate( - createHydrationTemplate(root.instanceId, 'root', { - elementSlots: [ - undefined as unknown as SerializedElementTemplate[], - undefined as unknown as SerializedElementTemplate[], - [], - ], - }), - root, - ); - - expect(stream).toEqual([]); - expect(root.elementSlots[0]).toBeUndefined(); - expect(root.elementSlots[1]).toBeUndefined(); - expect(root.elementSlots[2]).toEqual([]); - }); - it('emits create recursively for inserted nested children', () => { const root = new BackgroundElementTemplateInstance('root'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - root.appendChild(slot); const child = new BackgroundElementTemplateInstance('child'); - const childSlot = new BackgroundElementTemplateSlot(); - childSlot.setAttribute('id', 0); - child.appendChild(childSlot); const grandchild = new BackgroundElementTemplateInstance('grandchild'); - childSlot.appendChild(grandchild); - slot.appendChild(child); + child.appendChild(grandchild); + root.appendChild(child); const stream = hydrate( createHydrationTemplate(root.instanceId, 'root', { diff --git a/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts index e584a8b2bf..6b7fd432ab 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts @@ -12,7 +12,6 @@ import { import { destroyElementTemplateBackgroundRuntime } from '../../../../src/element-template/background/destroy.js'; import { BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, BUILTIN_RAW_TEXT_TEMPLATE_KEY, } from '../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; @@ -64,25 +63,12 @@ describe('BackgroundElementTemplateInstance', () => { expect(globalCommitContext.ops).toEqual([]); }); - it('does not emit create for synthetic slot containers after hydration', () => { - markElementTemplateHydrated(); - globalCommitContext.ops = []; - - new BackgroundElementTemplateSlot(); - - expect(globalCommitContext.ops).toEqual([]); - }); - it('exposes DOM-compatible tree accessors for Preact removal paths', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('image'); - slot.appendChild(child); + parent.appendChild(child); - expect(parent.childNodes).toEqual([slot]); - expect(slot.childNodes).toEqual([child]); + expect(parent.childNodes).toEqual([child]); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -90,9 +76,9 @@ describe('BackgroundElementTemplateInstance', () => { globalCommitContext.ops = []; child.parentNode?.removeChild(child); - expect(slot.childNodes).toEqual([]); + expect(parent.childNodes).toEqual([]); expect(child.parentNode).toBeNull(); - expect(parent.elementSlots[0]).toEqual([]); + expect(parent.elementSlots[0]).toBeUndefined(); expect(globalCommitContext.ops).toEqual([ 4, parent.instanceId, @@ -180,9 +166,6 @@ describe('BackgroundElementTemplateInstance', () => { it('emits create with initialized attrs before inserting a post-hydration template', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); parent.emitCreate(); markElementTemplateHydrated(); @@ -193,7 +176,7 @@ describe('BackgroundElementTemplateInstance', () => { expect(globalCommitContext.ops).toEqual([]); - slot.appendChild(child); + parent.appendChild(child); expect(globalCommitContext.ops).toEqual([ 1, @@ -214,9 +197,6 @@ describe('BackgroundElementTemplateInstance', () => { const ref = vi.fn(); __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); parent.emitCreate(); markElementTemplateHydrated(); @@ -224,7 +204,7 @@ describe('BackgroundElementTemplateInstance', () => { const child = new BackgroundElementTemplateInstance('view'); child.setAttribute('attributeSlots', [ref]); - slot.appendChild(child); + parent.appendChild(child); flushPendingRefs(); expect(globalCommitContext.ops).toEqual([ @@ -245,18 +225,36 @@ describe('BackgroundElementTemplateInstance', () => { })); }); + it('does not detach refs that never attached on a post-hydration unmaterialized subtree', () => { + // Regression: post-hydration `setAttribute` runs with + // `queueRefEffects=false` on unmaterialized children (attach is deferred + // to `emitCreate`). If `removeChild` unconditionally queues a cleanup, + // the ref observes a spurious detach for an attach that never fired. + const ref = vi.fn(); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; + markElementTemplateHydrated(); + const parent = new BackgroundElementTemplateInstance('view'); + const child = new BackgroundElementTemplateInstance('view'); + child.setAttribute('attributeSlots', [ref]); + parent.appendChild(child); + flushPendingRefs(); + expect(ref).not.toHaveBeenCalled(); + + parent.removeChild(child); + flushPendingRefs(); + + expect(ref).not.toHaveBeenCalled(); + }); + it('does not re-attach stable direct refs when moving an existing hydrated child', () => { const ref = vi.fn(); __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const before = new BackgroundElementTemplateInstance('view'); const child = new BackgroundElementTemplateInstance('view'); child.setAttribute('attributeSlots', [ref]); - slot.appendChild(before); - slot.appendChild(child); + parent.appendChild(before); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -267,7 +265,7 @@ describe('BackgroundElementTemplateInstance', () => { ref.mockClear(); globalCommitContext.ops = []; - slot.insertBefore(child, before); + parent.insertBefore(child, before); flushPendingRefs(); expect(globalCommitContext.ops).toEqual([ @@ -282,24 +280,18 @@ describe('BackgroundElementTemplateInstance', () => { it('defers nested slot inserts until the owner template is created', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); parent.emitCreate(); markElementTemplateHydrated(); globalCommitContext.ops = []; const owner = new BackgroundElementTemplateInstance('view'); - const ownerSlot = new BackgroundElementTemplateSlot(); - ownerSlot.setAttribute('id', 0); - owner.appendChild(ownerSlot); const nested = createTextNode('nested'); - ownerSlot.appendChild(nested); + owner.appendChild(nested); expect(globalCommitContext.ops).toEqual([]); - slot.appendChild(owner); + parent.appendChild(owner); expect(globalCommitContext.ops).toEqual([ 1, @@ -324,24 +316,26 @@ describe('BackgroundElementTemplateInstance', () => { it('skips sparse child slots when creating a post-hydration template recursively', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); parent.emitCreate(); markElementTemplateHydrated(); globalCommitContext.ops = []; + const grandchild = new BackgroundElementTemplateInstance('view'); + grandchild.__slotIndex = 1; const child = new BackgroundElementTemplateInstance('view'); - child.elementSlots.length = 1; - slot.appendChild(child); + child.appendChild(grandchild); - const serializedSlots = globalCommitContext.ops[5] as unknown[]; - expect(globalCommitContext.ops[0]).toBe(1); - expect(globalCommitContext.ops[1]).toBe(child.instanceId); - expect(serializedSlots).toHaveLength(1); + parent.appendChild(child); + + const serializedSlots = globalCommitContext.ops[11] as unknown[]; + expect(globalCommitContext.ops[6]).toBe(1); + expect(globalCommitContext.ops[7]).toBe(child.instanceId); + expect(serializedSlots).toHaveLength(2); expect(0 in serializedSlots).toBe(false); - expect(globalCommitContext.ops.slice(6)).toEqual([ + expect(1 in serializedSlots).toBe(true); + expect(serializedSlots[1]).toEqual([grandchild.instanceId]); + expect(globalCommitContext.ops.slice(12)).toEqual([ 3, parent.instanceId, 0, @@ -430,6 +424,71 @@ describe('BackgroundElementTemplateInstance', () => { 'Cannot insert a node before itself', ); }); + + it('emits beforeId=0 when the reference child lives in a different slot', () => { + // Slot ordering is per-slot, so a beforeChild from another slot has no + // meaning for the new child's position. Falling back to 0 keeps the + // main-thread insert as an append within the destination slot. + const parent = new BackgroundElementTemplateInstance('view'); + const slot0Anchor = new BackgroundElementTemplateInstance('view'); + slot0Anchor.__slotIndex = 0; + parent.appendChild(slot0Anchor); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + slot0Anchor.markMaterializedByHydration(); + globalCommitContext.ops = []; + + const newChild = new BackgroundElementTemplateInstance('text'); + newChild.__slotIndex = 1; + + parent.insertBefore(newChild, slot0Anchor); + + expect(globalCommitContext.ops).toEqual([ + ElementTemplateUpdateOps.createTemplate, + newChild.instanceId, + 'text', + null, + [], + [], + ElementTemplateUpdateOps.insertNode, + parent.instanceId, + 1, + newChild.instanceId, + 0, + ]); + }); + + it('keeps beforeId pointing at the reference child when slots match', () => { + const parent = new BackgroundElementTemplateInstance('view'); + const anchor = new BackgroundElementTemplateInstance('view'); + anchor.__slotIndex = 1; + parent.appendChild(anchor); + + markElementTemplateHydrated(); + parent.markMaterializedByHydration(); + anchor.markMaterializedByHydration(); + globalCommitContext.ops = []; + + const newChild = new BackgroundElementTemplateInstance('text'); + newChild.__slotIndex = 1; + + parent.insertBefore(newChild, anchor); + + expect(globalCommitContext.ops).toEqual([ + ElementTemplateUpdateOps.createTemplate, + newChild.instanceId, + 'text', + null, + [], + [], + ElementTemplateUpdateOps.insertNode, + parent.instanceId, + 1, + newChild.instanceId, + anchor.instanceId, + ]); + }); }); describe('removeChild', () => { @@ -500,25 +559,19 @@ describe('BackgroundElementTemplateInstance', () => { it('emits remove ops when removing from a slot container', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('text'); - const childSlot = new BackgroundElementTemplateSlot(); - childSlot.setAttribute('id', 0); const grandchild = new BackgroundElementTemplateInstance('text'); - child.appendChild(childSlot); - childSlot.appendChild(grandchild); - slot.appendChild(child); + child.appendChild(grandchild); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); child.markMaterializedByHydration(); grandchild.markMaterializedByHydration(); globalCommitContext.ops = []; - slot.removeChild(child); + parent.removeChild(child); - expect(parent.elementSlots[0]).toEqual([]); + expect(parent.elementSlots[0]).toBeUndefined(); expect(globalCommitContext.ops).toEqual([ 4, parent.instanceId, @@ -534,11 +587,8 @@ describe('BackgroundElementTemplateInstance', () => { const ref = vi.fn(() => cleanup); __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('view'); - slot.appendChild(child); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -548,7 +598,7 @@ describe('BackgroundElementTemplateInstance', () => { ref.mockClear(); globalCommitContext.ops = []; - slot.removeChild(child); + parent.removeChild(child); flushPendingRefs(); expect(globalCommitContext.ops).toEqual([ @@ -566,11 +616,8 @@ describe('BackgroundElementTemplateInstance', () => { const ref = { current: null }; __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('view'); - slot.appendChild(child); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -580,7 +627,7 @@ describe('BackgroundElementTemplateInstance', () => { expect(ref.current).toMatchObject({ selector: `[ref=${child.instanceId}-0]` }); globalCommitContext.ops = []; - slot.removeChild(child); + parent.removeChild(child); flushPendingRefs(); expect(ref.current).toBeNull(); @@ -592,11 +639,8 @@ describe('BackgroundElementTemplateInstance', () => { const spreadRef = vi.fn(() => cleanup); __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('view'); - slot.appendChild(child); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -608,7 +652,7 @@ describe('BackgroundElementTemplateInstance', () => { directRef.mockClear(); spreadRef.mockClear(); - slot.removeChild(child); + parent.removeChild(child); flushPendingRefs(); expect(cleanup).toHaveBeenCalledTimes(1); @@ -624,16 +668,10 @@ describe('BackgroundElementTemplateInstance', () => { const grandchildSpreadRef = vi.fn(() => grandchildCleanup); __etAttrPlanMap.view = [0, adaptRefAttrSlot, 1, adaptSpreadAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('view'); - const childSlot = new BackgroundElementTemplateSlot(); - childSlot.setAttribute('id', 0); const grandchild = new BackgroundElementTemplateInstance('view'); - child.appendChild(childSlot); - childSlot.appendChild(grandchild); - slot.appendChild(child); + child.appendChild(grandchild); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -653,7 +691,7 @@ describe('BackgroundElementTemplateInstance', () => { grandchildSpreadRef.mockClear(); globalCommitContext.ops = []; - slot.removeChild(child); + parent.removeChild(child); flushPendingRefs(); expect(globalCommitContext.ops).toEqual([ @@ -675,11 +713,8 @@ describe('BackgroundElementTemplateInstance', () => { const ref = vi.fn(() => cleanup); __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('view'); - slot.appendChild(child); + parent.appendChild(child); markElementTemplateHydrated(); parent.markMaterializedByHydration(); @@ -688,7 +723,7 @@ describe('BackgroundElementTemplateInstance', () => { flushPendingRefs(); expect(ref).toHaveBeenCalledTimes(1); - slot.removeChild(child); + parent.removeChild(child); flushPendingRefs(); expect(cleanup).toHaveBeenCalledTimes(1); @@ -700,18 +735,15 @@ describe('BackgroundElementTemplateInstance', () => { it('does not emit patches for pre-hydration slot mutations', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('text'); const childId = child.instanceId; globalCommitContext.ops = []; child.setAttribute('attributeSlots', ['pending']); - slot.appendChild(child); - slot.removeChild(child); + parent.appendChild(child); + parent.removeChild(child); - expect(parent.elementSlots[0]).toEqual([]); + expect(parent.elementSlots[0]).toBeUndefined(); expect(globalCommitContext.ops).toEqual([]); expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(childId)).toBeUndefined(); @@ -721,54 +753,56 @@ describe('BackgroundElementTemplateInstance', () => { const ref = { current: null }; __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const child = new BackgroundElementTemplateInstance('view'); const childId = child.instanceId; - slot.appendChild(child); + parent.appendChild(child); child.setAttribute('attributeSlots', [ref]); flushPendingRefs(); expect(ref.current).toMatchObject({ selector: `[ref=${child.instanceId}-0]` }); globalCommitContext.ops = []; - slot.removeChild(child); + parent.removeChild(child); flushPendingRefs(); expect(ref.current).toBeNull(); - expect(parent.elementSlots[0]).toEqual([]); + expect(parent.elementSlots[0]).toBeUndefined(); expect(globalCommitContext.ops).toEqual([]); expect(backgroundElementTemplateInstanceManager.get(childId)).toBeUndefined(); }); - it('supports silent removal from a slot container', () => { + it('invokes a callback ref cleanup exactly once on pre-hydration removal', () => { + // Regression: an earlier rewrite left a redundant `queueRefCleanupForSubtree` + // inside the pre-hydration branch in addition to the unconditional one + // emitted at the end of `removeChild`, so callback ref cleanups fired twice. + const cleanup = vi.fn(); + const ref = vi.fn(() => cleanup); + __etAttrPlanMap.view = [0, adaptRefAttrSlot]; const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); - const child = new BackgroundElementTemplateInstance('text'); - slot.appendChild(child); + const child = new BackgroundElementTemplateInstance('view'); + parent.appendChild(child); + child.setAttribute('attributeSlots', [ref]); + flushPendingRefs(); + ref.mockClear(); - globalCommitContext.ops = []; - slot.removeChild(child, true); + parent.removeChild(child); + flushPendingRefs(); - expect(parent.elementSlots[0]).toEqual([]); - expect(globalCommitContext.ops).toEqual([]); - expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(ref).not.toHaveBeenCalled(); }); - it('clears cached elementSlots when removing a slot child', () => { + it('supports silent removal from a slot container', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 1); - parent.appendChild(slot); - - expect(parent.elementSlots[1]).toEqual([]); + const child = new BackgroundElementTemplateInstance('text'); + parent.appendChild(child); - parent.removeChild(slot, true); + globalCommitContext.ops = []; + parent.removeChild(child, true); - expect(parent.elementSlots[1]).toEqual([]); + expect(parent.elementSlots[0]).toBeUndefined(); + expect(globalCommitContext.ops).toEqual([]); + expect(globalCommitContext.nonPayload.removedSubtreesAwaitingTeardown).toEqual([]); }); }); @@ -1079,9 +1113,6 @@ describe('BackgroundElementTemplateInstance', () => { it('defers raw-text patches until inserting a post-hydration text node', () => { const parent = new BackgroundElementTemplateInstance('view'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); parent.emitCreate(); markElementTemplateHydrated(); @@ -1092,7 +1123,7 @@ describe('BackgroundElementTemplateInstance', () => { expect(globalCommitContext.ops).toEqual([]); - slot.appendChild(textNode); + parent.appendChild(textNode); expect(globalCommitContext.ops).toEqual([ 1, @@ -1120,13 +1151,6 @@ describe('BackgroundElementTemplateInstance', () => { }); }); -describe('BackgroundElementTemplateSlot', () => { - it('should have correct type', () => { - const slot = new BackgroundElementTemplateSlot(); - expect(slot.type).toBe('slot'); - }); -}); - describe('Background raw-text instance', () => { it('should have correct type and text', () => { const textNode = createTextNode('hello'); @@ -1521,108 +1545,83 @@ describe('BackgroundElementTemplateInstance Shadow State', () => { }); }); -describe('BackgroundElementTemplateSlot Children', () => { - it('should update partId for slot when setAttribute is called', () => { - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 10); - expect(slot.partId).toBe(10); - }); - +describe('BackgroundElementTemplateInstance slot-index children', () => { it('should clear the previous slot index when partId changes after attachment', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); - const slot = new BackgroundElementTemplateSlot(); const text = createTextNode('move'); - slot.setAttribute('id', 0); - slot.appendChild(text); - root.appendChild(slot); - slot.setAttribute('id', 1); + root.appendChild(text); + + text.__slotIndex = 1; - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); expect(root.elementSlots[1]).toEqual([text]); }); it('should keep elementSlots in sync when slot is attached after children exist', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); - const slot = new BackgroundElementTemplateSlot(); const text = createTextNode('late'); - slot.setAttribute('id', 2); - slot.appendChild(text); - root.appendChild(slot); + text.__slotIndex = 2; + root.appendChild(text); expect(root.elementSlots[2]).toEqual([text]); }); it('should move slot children to the new slot index when partId changes', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); - const slot = new BackgroundElementTemplateSlot(); const text = createTextNode('move'); - slot.setAttribute('id', 0); - slot.appendChild(text); - root.appendChild(slot); - slot.setAttribute('id', 3); + text.__slotIndex = 0; + root.appendChild(text); + text.__slotIndex = 3; - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); expect(root.elementSlots[3]).toEqual([text]); }); it('should detach a moved child from the old slot shadow state when silent reparenting', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); - const slotA = new BackgroundElementTemplateSlot(); - const slotB = new BackgroundElementTemplateSlot(); const text = createTextNode('move'); - slotA.setAttribute('id', 0); - slotB.setAttribute('id', 1); - root.appendChild(slotA); - root.appendChild(slotB); - slotA.appendChild(text); + root.appendChild(text); - slotB.insertBefore(text, null, true); + text.__slotIndex = 1; + root.insertBefore(text, null, true); - expect(root.elementSlots[0]).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); expect(root.elementSlots[1]).toEqual([text]); - expect(slotA.firstChild).toBeNull(); - expect(slotB.firstChild).toBe(text); + expect(root.firstChild).toBe(text); }); it('should detach a moved child from the old parent slot shadow state', () => { const rootA = new BackgroundElementTemplateInstance('element-template-view'); const rootB = new BackgroundElementTemplateInstance('element-template-view'); - const slotA = new BackgroundElementTemplateSlot(); - const slotB = new BackgroundElementTemplateSlot(); const text = createTextNode('move'); - slotA.setAttribute('id', 0); - slotB.setAttribute('id', 0); - rootA.appendChild(slotA); - rootB.appendChild(slotB); - slotA.appendChild(text); + rootA.appendChild(text); - slotB.insertBefore(text, null, true); + rootB.insertBefore(text, null, true); - expect(rootA.elementSlots[0]).toEqual([]); - expect(rootB.elementSlots[0]).toEqual([text]); - expect(slotA.firstChild).toBeNull(); - expect(slotB.firstChild).toBe(text); + expect(rootA.elementSlots).toEqual([]); + expect(rootB.firstChild).toBe(text); }); - it('does not create an element slot entry for non-slot direct children', () => { + it('should append to elementSlots', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); const view = new BackgroundElementTemplateInstance('view'); root.appendChild(view); - expect(root.elementSlots).toEqual([]); + expect(root.elementSlots[0]).toEqual([view]); }); - it('does not create an element slot entry for slot with default partId', () => { + it('should append to elementSlots with custom slot index', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); - const slot = new BackgroundElementTemplateSlot(); - slot.appendChild(createTextNode('Hello')); - root.appendChild(slot); + const text = createTextNode('Hello'); + text.__slotIndex = 1; + root.appendChild(text); - expect(root.elementSlots).toEqual([]); + expect(root.elementSlots[0]).toBeUndefined(); + expect(root.elementSlots[1]).toEqual([text]); }); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx index 4066b9e2ab..dbd0a95131 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createElement } from 'preact'; import { markElementTemplateHydrated, @@ -79,4 +80,34 @@ describe('Background Preact render', () => { ).toEqual(next); } }); + + // The Lynx Preact fork's `findMatchingIndex` requires + // `oldVNode._slotIndex === newSlot` before keyed reuse (introduced by #2664), + // so a same-key child cannot migrate across `$N` indices via `root.render`. + // The old slot must end up empty because the previous child is fully + // unmounted (not moved) and a fresh instance is mounted at the new slot. + for ( + const [from, to] of [ + [2, 0], + [1, 0], + ] as const + ) { + it(`unmounts the old child when a same-key host child moves from $${from} to $${to}`, () => { + const moved = createElement('_et_child', { key: 'moved' }); + root.render(createElement('_et_host', { [`$${from}`]: moved })); + markElementTemplateHydrated(); + + const initialHost = (__root as BackgroundElementTemplateInstance).firstChild!; + const initialChild = initialHost.elementSlots[from]?.[0]; + expect(initialChild?.type).toBe('_et_child'); + + root.render(createElement('_et_host', { [`$${to}`]: moved })); + + const host = (__root as BackgroundElementTemplateInstance).firstChild!; + const movedChild = host.elementSlots[to]?.[0]; + expect(movedChild?.type).toBe('_et_child'); + expect(movedChild).not.toBe(initialChild); + expect(host.elementSlots[from] ?? []).toEqual([]); + }); + } }); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/update/sparse-fixtures.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/update/sparse-fixtures.test.tsx new file mode 100644 index 0000000000..bb7d277313 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/background/update/sparse-fixtures.test.tsx @@ -0,0 +1,172 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createElement } from 'preact'; + +import { + installElementTemplateCommitHook, + resetElementTemplateCommitState, +} from '../../../../../src/element-template/background/commit-hook.js'; +import { + installElementTemplateHydrationListener, + resetElementTemplateHydrationListener, +} from '../../../../../src/element-template/background/hydration-listener.js'; +import { BackgroundElementTemplateInstance } from '../../../../../src/element-template/background/instance.js'; +import { root } from '../../../../../src/element-template/index.js'; +import { ElementTemplateLifecycleConstant } from '../../../../../src/element-template/protocol/lifecycle-constant.js'; +import { ElementTemplateUpdateOps } from '../../../../../src/element-template/protocol/opcodes.js'; +import type { + ElementTemplateUpdateCommandStream, + ElementTemplateUpdateCommitContext, +} from '../../../../../src/element-template/protocol/types.js'; +import { clearEtAttrPlanMap } from '../../../../../src/element-template/runtime/template/attr-slot-plan.js'; +import { __root } from '../../../../../src/element-template/runtime/page/root-instance.js'; +import { compileFixtureSource } from '../../../test-utils/debug/compiledFixtureCompiler.js'; +import { + loadCompiledFixtureModule, + type CompiledFixtureModuleExports, +} from '../../../test-utils/debug/compiledFixtureModule.js'; +import { primeCompiledFixtureTemplates } from '../../../test-utils/debug/compiledFixtureRegistry.js'; +import { ElementTemplateEnvManager } from '../../../test-utils/debug/envManager.js'; + +declare const renderPage: () => void; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const FIXTURE_DIR = path.resolve( + __dirname, + '../../../fixtures/background/update-sparse/mount-sparse-template', +); + +interface SparseTemplateAppProps { + mounted?: boolean; + showHeader?: boolean; + items?: string[]; +} + +interface SparseTemplateModule extends CompiledFixtureModuleExports { + App: (props: SparseTemplateAppProps) => JSX.Element; +} + +function getRenderedHost(): BackgroundElementTemplateInstance { + const host = (__root as BackgroundElementTemplateInstance).firstChild; + if (!host) { + throw new Error('Missing rendered host.'); + } + return host; +} + +async function loadFixture(): Promise<{ + backgroundModule: SparseTemplateModule; + mainModule: SparseTemplateModule; +}> { + const sourcePath = path.join(FIXTURE_DIR, 'index.tsx'); + const mainArtifact = await compileFixtureSource(sourcePath, { target: 'LEPUS' }); + primeCompiledFixtureTemplates(mainArtifact); + const mainModule = await loadCompiledFixtureModule(mainArtifact); + + const backgroundArtifact = await compileFixtureSource(sourcePath, { target: 'JS' }); + const backgroundModule = await loadCompiledFixtureModule(backgroundArtifact); + + return { backgroundModule, mainModule }; +} + +describe('Sparse element slot updates', () => { + const envManager = new ElementTemplateEnvManager(); + let updateEvents: ElementTemplateUpdateCommitContext[] = []; + const onUpdate = (event: { data: unknown }) => { + updateEvents.push(event.data as ElementTemplateUpdateCommitContext); + }; + + function renderOnBackground( + moduleExports: SparseTemplateModule, + props: SparseTemplateAppProps, + ): BackgroundElementTemplateInstance { + envManager.switchToBackground(); + root.render(createElement(moduleExports.App, props)); + return getRenderedHost(); + } + + function hydrateFromMainThread( + moduleExports: SparseTemplateModule, + props: SparseTemplateAppProps, + ): void { + envManager.switchToMainThread(); + root.render(createElement(moduleExports.App, props)); + renderPage(); + envManager.switchToBackground(); + } + + beforeEach(() => { + vi.clearAllMocks(); + resetElementTemplateCommitState(); + clearEtAttrPlanMap(); + updateEvents = []; + envManager.resetEnv('background'); + envManager.setUseElementTemplate(true); + installElementTemplateCommitHook(); + installElementTemplateHydrationListener(); + + envManager.switchToMainThread(); + lynx.getJSContext().addEventListener(ElementTemplateLifecycleConstant.update, onUpdate); + envManager.switchToBackground(); + }); + + afterEach(() => { + envManager.switchToMainThread(); + lynx.getJSContext().removeEventListener(ElementTemplateLifecycleConstant.update, onUpdate); + envManager.switchToBackground(); + resetElementTemplateHydrationListener(); + envManager.setUseElementTemplate(false); + globalThis.__ALOG__ = false; + }); + + it('emits a create patch with sparse element slots when mounting post-hydration', async () => { + const { backgroundModule, mainModule } = await loadFixture(); + + renderOnBackground(backgroundModule, { showCard: false }); + hydrateFromMainThread(mainModule, { showCard: false }); + updateEvents = []; + + renderOnBackground(backgroundModule, { showCard: true, showHeader: false, items: ['body'] }); + + envManager.switchToMainThread(); + const ops = updateEvents.at(-1)?.ops as ElementTemplateUpdateCommandStream; + expect(ops).toBeDefined(); + + const sparseCreateIndex = ops.findIndex((value, index) => + value === ElementTemplateUpdateOps.createTemplate + && Array.isArray(ops[index + 5]) + && (ops[index + 5] as unknown[]).length === 2 + && !(0 in (ops[index + 5] as unknown[])) + && (1 in (ops[index + 5] as unknown[])) + ); + expect(sparseCreateIndex).toBeGreaterThanOrEqual(0); + const serializedSlots = ops[sparseCreateIndex + 5] as unknown[]; + expect(serializedSlots).toHaveLength(2); + expect(0 in serializedSlots).toBe(false); + expect(Array.isArray(serializedSlots[1])).toBe(true); + expect((serializedSlots[1] as unknown[]).length).toBe(1); + envManager.switchToBackground(); + }); + + it('walks sparse element slots when alog prints the background tree during hydration', async () => { + globalThis.__ALOG__ = true; + const alogSpy = vi.fn(); + (console as { alog?: (message: string) => void }).alog = alogSpy; + + const { backgroundModule, mainModule } = await loadFixture(); + + renderOnBackground(backgroundModule, { showCard: true, showHeader: false, items: ['body'] }); + hydrateFromMainThread(mainModule, { showCard: true, showHeader: false, items: ['body'] }); + + const treeLog = alogSpy.mock.calls.map(args => args[0] as string).find(message => + message.includes('BackgroundElementTemplate tree before hydration') + ); + expect(treeLog).toBeDefined(); + // SparseCard's root template lists only `elementSlots[1]:` because slot 0 + // (the header view's conditional child) is the hole the print loop must skip. + expect(treeLog!).toMatch(/elementSlots\[1\]:/); + }); +}); diff --git a/packages/react/runtime/__test__/element-template/runtime/components/slot.test.ts b/packages/react/runtime/__test__/element-template/runtime/components/slot.test.ts deleted file mode 100644 index b7a109c2fc..0000000000 --- a/packages/react/runtime/__test__/element-template/runtime/components/slot.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2024 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { __etSlot } from '../../../../src/element-template/internal.js'; - -describe('__etSlot', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('should throw in main thread', () => { - vi.stubGlobal('__BACKGROUND__', false); - const children = 'test children'; - expect(() => __etSlot(0, children)).toThrow( - '__etSlot() should not run on the main thread. LEPUS ET children are lowered to slot arrays at compile time.', - ); - }); -}); diff --git a/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts b/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts index ee7876314c..577ecb283b 100644 --- a/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts @@ -6,10 +6,7 @@ import { installElementTemplateHydrationListener, resetElementTemplateHydrationListener, } from '../../../../src/element-template/background/hydration-listener.js'; -import { - BackgroundElementTemplateInstance, - BackgroundElementTemplateSlot, -} from '../../../../src/element-template/background/instance.js'; +import { BackgroundElementTemplateInstance } from '../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; import { PerformanceTimingFlags, PipelineOrigins } from '../../../../src/element-template/lynx/performance.js'; import { @@ -112,9 +109,6 @@ describe('ElementTemplate hydration listener', () => { const backgroundRoot = __root as BackgroundElementTemplateInstance; const host = new BackgroundElementTemplateInstance('_et_test'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - host.appendChild(slot); backgroundRoot.appendChild(host); const stale = new BackgroundElementTemplateInstance('_et_stale'); @@ -154,9 +148,6 @@ describe('ElementTemplate hydration listener', () => { const backgroundRoot = __root as BackgroundElementTemplateInstance; const host = new BackgroundElementTemplateInstance('_et_test'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - host.appendChild(slot); backgroundRoot.appendChild(host); const stale = new BackgroundElementTemplateInstance('_et_stale'); @@ -198,12 +189,9 @@ describe('ElementTemplate hydration listener', () => { const backgroundRoot = __root as BackgroundElementTemplateInstance; const parent = new BackgroundElementTemplateInstance('_et_parent'); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const inserted = new BackgroundElementTemplateInstance('_et_ref_parent'); inserted.setAttribute('attributeSlots', [ref]); - slot.appendChild(inserted); + parent.appendChild(inserted); backgroundRoot.appendChild(parent); envManager.switchToMainThread(); @@ -384,11 +372,8 @@ describe('ElementTemplate hydration listener', () => { const backgroundRoot = __root as BackgroundElementTemplateInstance; const parent = new BackgroundElementTemplateInstance('_et_event_parent'); parent.setAttribute('attributeSlots', [handler]); - const slot = new BackgroundElementTemplateSlot(); - slot.setAttribute('id', 0); - parent.appendChild(slot); const stale = new BackgroundElementTemplateInstance('_et_stale'); - slot.appendChild(stale); + parent.appendChild(stale); backgroundRoot.appendChild(parent); publishEvent('-1:0:', eventData); diff --git a/packages/react/runtime/__test__/element-template/runtime/render/render-to-opcodes.et.test.jsx b/packages/react/runtime/__test__/element-template/runtime/render/render-to-opcodes.et.test.jsx index 467224625d..8cefc94aba 100644 --- a/packages/react/runtime/__test__/element-template/runtime/render/render-to-opcodes.et.test.jsx +++ b/packages/react/runtime/__test__/element-template/runtime/render/render-to-opcodes.et.test.jsx @@ -25,11 +25,9 @@ describe('Element Template renderToOpcodes', () => { expect(__OpSlot).toBe(4); }); - it('should emit slot opcodes for ET host slot arrays', () => { + it('emits slot opcodes for ET host nodes using $N named props', () => { const Template = '_et_test_root'; - const opcodes = renderToString( -