From 9d0d09ca524ea4427a50e4ef9c0cb0cbde272e85 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Sat, 16 May 2026 16:31:20 +0800 Subject: [PATCH 01/12] feat(react): route element-template background tree via multi-slot Element-template's background runtime now consumes Preact's __slotIndex directly instead of going through a BackgroundElementTemplateSlot wrapper. The SWC swc_plugin_element_template lowering emits $0, $1, ... named-slot JSX props in place of __etSlot(N, ...) wrapper-call children, so the main-thread renderToOpcodes and the background insertBefore/removeChild both key insert and remove ops off the child's own slot index. The legacy BackgroundElementTemplateSlot is kept for bundles compiled before this change. --- .changeset/et-multi-slots.md | 7 + .../runtime/background/instance.test.ts | 130 +++++++++++++++++- .../render/render-to-opcodes.et.test.jsx | 40 ++++++ .../element-template/background/document.ts | 14 +- .../element-template/background/instance.ts | 85 ++++++++++++ .../runtime/render/render-to-opcodes.ts | 31 ++++- .../crates/swc_plugin_element_template/lib.rs | 32 ----- .../swc_plugin_element_template/lowering.rs | 67 ++------- .../swc_plugin_element_template/slot.rs | 99 ------------- ..._handle_deeply_nested_user_components.snap | 2 +- ...le_interpolated_text_with_siblings_js.snap | 2 +- ...interpolated_text_with_siblings_lepus.snap | 2 +- .../should_handle_mixed_content.snap | 2 +- ..._nested_structure_and_dynamic_content.snap | 2 +- ...should_handle_sibling_user_components.snap | 2 +- .../should_handle_user_component.snap | 2 +- ..._arrays_with_element_slot_placeholder.snap | 2 +- ...y_text_attribute_and_child_text_slots.snap | 2 +- .../tests/element_template.rs | 53 +++++++ 19 files changed, 374 insertions(+), 202 deletions(-) create mode 100644 .changeset/et-multi-slots.md delete mode 100644 packages/react/transform/crates/swc_plugin_element_template/slot.rs diff --git a/.changeset/et-multi-slots.md b/.changeset/et-multi-slots.md new file mode 100644 index 0000000000..20187cb3dd --- /dev/null +++ b/.changeset/et-multi-slots.md @@ -0,0 +1,7 @@ +--- +"@lynx-js/react": patch +--- + +feat(react): apply multi-slot routing to element-template background tree + +Element-template's background runtime now consumes Preact's `__slotIndex` directly instead of going through a `BackgroundElementTemplateSlot` wrapper. The SWC `swc_plugin_element_template` lowering emits `$0`, `$1`, ... named-slot JSX props in place of `__etSlot(N, ...)` wrapper-call children, so the main-thread `renderToOpcodes` and the background `insertBefore`/`removeChild` both key insert and remove ops off the child's own slot index. The legacy `BackgroundElementTemplateSlot` is kept for bundles compiled before this change. 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 2b9681aac3..e9b267e124 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 @@ -1144,12 +1144,26 @@ describe('BackgroundElementTemplateSlot Children', () => { expect(slotB.firstChild).toBe(text); }); - it('does not create an element slot entry for non-slot direct children', () => { + it('routes non-slot direct children into elementSlots keyed by __slotIndex', () => { const root = new BackgroundElementTemplateInstance('element-template-view'); const view = new BackgroundElementTemplateInstance('view'); + // Default __slotIndex is 0, matching Preact's baseline assignment in + // diffElementNodes; multi-slot output reuses this without a slot wrapper. root.appendChild(view); - expect(root.elementSlots).toEqual([]); + expect(root.elementSlots).toEqual([[view]]); + }); + + it('groups non-slot direct children by __slotIndex without a slot wrapper', () => { + const root = new BackgroundElementTemplateInstance('element-template-view'); + const slot0Child = new BackgroundElementTemplateInstance('view'); + const slot1Child = new BackgroundElementTemplateInstance('text'); + slot0Child.__slotIndex = 0; + slot1Child.__slotIndex = 1; + root.appendChild(slot0Child); + root.appendChild(slot1Child); + + expect(root.elementSlots).toEqual([[slot0Child], [slot1Child]]); }); it('does not create an element slot entry for slot with default partId', () => { @@ -1161,3 +1175,115 @@ describe('BackgroundElementTemplateSlot Children', () => { expect(root.elementSlots).toEqual([]); }); }); + +describe('Multi-slot inserts and removes without a slot wrapper', () => { + beforeEach(() => { + globalThis.__MAIN_THREAD__ = false; + globalThis.__BACKGROUND__ = true; + backgroundElementTemplateInstanceManager.clear(); + backgroundElementTemplateInstanceManager.nextId = 0; + clearEtAttrPlanMap(); + clearEventState(); + resetElementTemplateCommitState(); + }); + + it('emits insertNode keyed by child.__slotIndex for direct attachments', () => { + const parent = new BackgroundElementTemplateInstance('view'); + parent.emitCreate(); + + markElementTemplateHydrated(); + globalCommitContext.ops = []; + + const child = new BackgroundElementTemplateInstance('image'); + child.setAttribute('attributeSlots', ['logo.png']); + child.__slotIndex = 1; + parent.appendChild(child); + + // 1=create, 3=insertNode — both go to slot index 1 of the parent without a + // BackgroundElementTemplateSlot wrapper in between. + expect(globalCommitContext.ops).toEqual([ + 1, + child.instanceId, + 'image', + null, + ['logo.png'], + [], + 3, + parent.instanceId, + 1, + child.instanceId, + 0, + ]); + }); + + it('emits removeNode keyed by child.__slotIndex for direct removals', () => { + const parent = new BackgroundElementTemplateInstance('view'); + const child = new BackgroundElementTemplateInstance('text'); + child.__slotIndex = 2; + parent.appendChild(child); + parent.emitCreate(); + + markElementTemplateHydrated(); + globalCommitContext.ops = []; + + parent.removeChild(child); + + expect(globalCommitContext.ops).toEqual([ + 4, + parent.instanceId, + 2, + child.instanceId, + [child.instanceId], + ]); + expect(parent.elementSlots[2]).toEqual([]); + }); + + it('only uses beforeChild as anchor when it lives in the same slot', () => { + const parent = new BackgroundElementTemplateInstance('view'); + const sameSlotAnchor = new BackgroundElementTemplateInstance('text'); + sameSlotAnchor.__slotIndex = 0; + parent.appendChild(sameSlotAnchor); + parent.emitCreate(); + + markElementTemplateHydrated(); + globalCommitContext.ops = []; + + const incoming = new BackgroundElementTemplateInstance('text'); + incoming.__slotIndex = 0; + parent.insertBefore(incoming, sameSlotAnchor); + + // Same-slot anchor is honored: insertNode references it as beforeId. + expect(globalCommitContext.ops.slice(-5)).toEqual([ + 3, + parent.instanceId, + 0, + incoming.instanceId, + sameSlotAnchor.instanceId, + ]); + }); + + it('drops cross-slot beforeChild anchors back to append-tail', () => { + const parent = new BackgroundElementTemplateInstance('view'); + const crossSlotAnchor = new BackgroundElementTemplateInstance('text'); + crossSlotAnchor.__slotIndex = 1; + parent.appendChild(crossSlotAnchor); + parent.emitCreate(); + + markElementTemplateHydrated(); + globalCommitContext.ops = []; + + const incoming = new BackgroundElementTemplateInstance('text'); + incoming.__slotIndex = 0; + parent.insertBefore(incoming, crossSlotAnchor); + + // Cross-slot anchor would corrupt the slot's order — drop to 0 so the + // insert appends at the slot's tail in the main-thread template. + expect(globalCommitContext.ops.slice(-5)).toEqual([ + 3, + parent.instanceId, + 0, + incoming.instanceId, + 0, + ]); + }); +}); 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..68557096fc 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 @@ -59,6 +59,46 @@ describe('Element Template renderToOpcodes', () => { ]); }); + it('emits slot opcodes for ET host nodes using $N named props', () => { + const Template = '_et_test_root'; + const opcodes = renderToString( +