From f9c68703596ad1043d983885664c1ca66a8d624c Mon Sep 17 00:00:00 2001 From: Yradex <11014207+Yradex@users.noreply.github.com> Date: Wed, 20 May 2026 20:23:17 +0800 Subject: [PATCH 1/5] feat(react): align ET typed element protocol --- .../element-template/debug/alog.test.ts | 22 ++ .../hydration/hydration-listener.test.ts | 44 ++- .../patch/element-template-patch.test.tsx | 187 ++++++++++ .../test-utils/debug/updateRunner.test.tsx | 22 ++ .../test-utils/debug/updateRunner.ts | 26 +- .../test-utils/mock/mockNativePapi.ts | 47 +++ .../mock/mockNativePapi/templateTree.test.ts | 70 +++- .../mock/mockNativePapi/templateTree.ts | 326 ++++++++++++++---- .../background/hydration-listener.ts | 15 +- .../src/element-template/debug/alog.ts | 17 + .../src/element-template/protocol/opcodes.ts | 1 + .../src/element-template/protocol/types.ts | 65 +++- .../src/element-template/runtime/patch.ts | 68 +++- .../runtime/render/render-main-thread.ts | 4 +- .../runtime/src/element-template/types.d.ts | 21 +- 15 files changed, 849 insertions(+), 86 deletions(-) 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 d36dee785b..2bbc148524 100644 --- a/packages/react/runtime/__test__/element-template/debug/alog.test.ts +++ b/packages/react/runtime/__test__/element-template/debug/alog.test.ts @@ -26,6 +26,15 @@ describe('ElementTemplate alog helpers', () => { 11, 0, 'updated', + ElementTemplateUpdateOps.createTypedElement, + 13, + 'list', + [[12]], + { listChildren: [{ __etHandleRef: 12 }] }, + ElementTemplateUpdateOps.setAttribute, + 13, + 0, + { insertAction: [] }, ElementTemplateUpdateOps.insertNode, 11, 1, @@ -51,6 +60,19 @@ describe('ElementTemplate alog helpers', () => { attrSlotIndex: 0, value: 'updated', }, + { + op: 'createTypedElement', + handleId: 13, + type: 'list', + elementSlots: [[12]], + options: { listChildren: [{ __etHandleRef: 12 }] }, + }, + { + op: 'setAttribute', + targetId: 13, + attrSlotIndex: 0, + value: { insertAction: [] }, + }, { op: 'insertNode', targetId: 11, 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 577ecb283b..4833cff22a 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 @@ -20,7 +20,10 @@ import { flushPendingRefs, } from '../../../../src/element-template/prop-adapters/ref.js'; import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; -import type { SerializedElementTemplate } from '../../../../src/element-template/protocol/types.js'; +import type { + SerializedElementTemplate, + SerializedTypedNode, +} from '../../../../src/element-template/protocol/types.js'; import { __root } from '../../../../src/element-template/runtime/page/root-instance.js'; import { __etAttrPlanMap, @@ -101,6 +104,45 @@ describe('ElementTemplate hydration listener', () => { expect(backgroundElementTemplateInstanceManager.get(-2)).toBeUndefined(); }); + it('drops typed roots before typed hydrate support lands', () => { + const oldReportError = lynx.reportError; + const reportError = vi.fn(); + lynx.reportError = reportError; + + try { + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_test'); + backgroundRoot.appendChild(after); + const oldId = after.instanceId; + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + type: 'view', + elementSlots: [], + uid: -1, + } satisfies SerializedTypedNode, + ], + }); + + envManager.switchToBackground(); + + expect(reportError).toHaveBeenCalledTimes(1); + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain( + 'does not support serialized typed root', + ); + expect(backgroundElementTemplateInstanceManager.get(oldId)).toBe(after); + expect(backgroundElementTemplateInstanceManager.get(-1)).toBeUndefined(); + } finally { + lynx.reportError = oldReportError; + } + }); + it('schedules delayed cleanup for removed subtrees produced during hydration', () => { vi.useFakeTimers(); try { diff --git a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx index 1af18dbff4..8941de3e8e 100644 --- a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx @@ -64,6 +64,8 @@ describe('ElementTemplate patch stream (apply)', () => { let hydrationData: SerializedElementTemplate[] = []; let onHydrate: (event: { data: unknown }) => void; + let mockCreateTypedElementTemplate: ReportErrorMock; + let mockSetAttribute: ReportErrorMock; let mockSetAttributeOfElementTemplate: ReportErrorMock; let mockInsertNodeToElementTemplate: ReportErrorMock; let mockRemoveNodeFromElementTemplate: ReportErrorMock; @@ -72,6 +74,8 @@ describe('ElementTemplate patch stream (apply)', () => { beforeEach(() => { vi.clearAllMocks(); // mocks are already installed by setup.js beforeEach + mockCreateTypedElementTemplate = lastMock!.mockCreateTypedElementTemplate as unknown as ReportErrorMock; + mockSetAttribute = lastMock!.mockSetAttribute as unknown as ReportErrorMock; mockSetAttributeOfElementTemplate = lastMock!.mockSetAttributeOfElementTemplate as unknown as ReportErrorMock; mockInsertNodeToElementTemplate = lastMock!.mockInsertNodeToElementTemplate as unknown as ReportErrorMock; mockRemoveNodeFromElementTemplate = lastMock!.mockRemoveNodeFromElementTemplate as unknown as ReportErrorMock; @@ -358,6 +362,189 @@ describe('ElementTemplate patch stream (apply)', () => { resetReportedErrors(); }); + it('creates typed elements with resolved slots and command options', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + + const slotChildRef = { __isNativeRef: true, id: 'slot-child' } as unknown as ElementRef; + const optionChildRef = { __isNativeRef: true, id: 'option-child' } as unknown as ElementRef; + elementTemplateRegistry.set(11, slotChildRef); + elementTemplateRegistry.set(12, optionChildRef); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 21, + 'list', + [[11]], + { + listChildren: [{ __etHandleRef: 12 }], + estimatedHeight: 80, + }, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(1); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[0]).toBe('list'); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[1]).toEqual([[slotChildRef]]); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[2]).toBe(21); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toEqual({ + listChildren: [optionChildRef], + estimatedHeight: 80, + }); + expect(elementTemplateRegistry.has(21)).toBe(true); + }); + + it('creates typed elements with no command options', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 23, + 'list', + [], + null, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toBe(null); + expect(elementTemplateRegistry.has(23)).toBe(true); + }); + + it('passes serializable typed options unchanged when listChildren is absent', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockCreateTypedElementTemplate.mockClear(); + + const options = { + metadata: { itemCount: 1 }, + estimatedHeight: 80, + }; + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 24, + 'list', + [], + options, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toEqual(options); + expect(elementTemplateRegistry.has(24)).toBe(true); + }); + + it('skips typed create when element slot handles are unresolved', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 25, + 'list', + [[404]], + null, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(0); + expect(elementTemplateRegistry.has(25)).toBe(false); + const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError; + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain('child handle 404 not found'); + resetReportedErrors(); + }); + + it('skips typed create when command option handles are unresolved', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 22, + 'list', + [], + { listChildren: [{ __etHandleRef: 404 }] }, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(0); + expect(elementTemplateRegistry.has(22)).toBe(false); + const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError; + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain( + 'options.listChildren[0] handle 404 not found', + ); + resetReportedErrors(); + }); + + it('sets typed slot 0 attributes through the standard attr-slot PAPI', () => { + envManager.switchToMainThread(); + const targetRef = { __isNativeRef: true, id: 'typed-target' } as unknown as ElementRef; + elementTemplateRegistry.set(31, targetRef); + mockSetAttribute.mockClear(); + mockSetAttributeOfElementTemplate.mockClear(); + + const updateListInfo = { + insertAction: [], + removeAction: [], + updateAction: [], + }; + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.setAttribute, + 31, + 0, + { 'update-list-info': updateListInfo }, + ]); + + expect(mockSetAttributeOfElementTemplate.mock.calls).toEqual([[ + targetRef, + 0, + { 'update-list-info': updateListInfo }, + null, + ]]); + expect(mockSetAttribute.mock.calls).toHaveLength(0); + }); + + it('clears typed slot 0 attributes through the standard attr-slot PAPI', () => { + envManager.switchToMainThread(); + const targetRef = { __isNativeRef: true, id: 'typed-target' } as unknown as ElementRef; + elementTemplateRegistry.set(32, targetRef); + mockSetAttribute.mockClear(); + mockSetAttributeOfElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.setAttribute, + 32, + 0, + null, + ]); + + expect(mockSetAttributeOfElementTemplate.mock.calls).toEqual([[ + targetRef, + 0, + null, + null, + ]]); + expect(mockSetAttribute.mock.calls).toHaveLength(0); + }); + + it('skips typed slot 0 attributes when the target handle is unresolved', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockSetAttribute.mockClear(); + mockSetAttributeOfElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.setAttribute, + 404, + 0, + null, + ]); + + expect(mockSetAttribute.mock.calls).toHaveLength(0); + expect(mockSetAttributeOfElementTemplate.mock.calls).toHaveLength(0); + const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError; + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain('target handle 404 not found'); + resetReportedErrors(); + }); + it('reports missing handle when resolving references', () => { const jsx = ; root.render(jsx); diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx index bc17a3cdc2..b6482424f9 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx @@ -18,6 +18,15 @@ describe('element-template update runner', () => { 2, 3, 'next', + ElementTemplateUpdateOps.createTypedElement, + 8, + 'list', + [[1]], + { listChildren: [{ __etHandleRef: 1 }] }, + ElementTemplateUpdateOps.setAttribute, + 8, + 0, + { insertAction: [] }, ElementTemplateUpdateOps.removeNode, 4, 5, @@ -39,6 +48,19 @@ describe('element-template update runner', () => { attrSlotIndex: 3, value: 'next', }, + { + type: 'createTypedElement', + id: 8, + elementType: 'list', + elementSlots: [[1]], + options: { listChildren: [{ __etHandleRef: 1 }] }, + }, + { + type: 'setAttribute', + id: 8, + attrSlotIndex: 0, + value: { insertAction: [] }, + }, { type: 'removeNode', id: 4, diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts index 0703636050..28f85f9c8e 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts @@ -5,6 +5,7 @@ import { hydrate as hydrateBackground } from '../../../../src/element-template/background/hydrate.js'; import type { BackgroundElementTemplateInstance } from '../../../../src/element-template/background/instance.js'; import { root } from '../../../../src/element-template/index.js'; +import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; import type { ElementTemplateUpdateCommandStream, @@ -19,7 +20,6 @@ import { __page } from '../../../../src/element-template/runtime/page/page.js'; import { __root } from '../../../../src/element-template/runtime/page/root-instance.js'; import { ElementTemplateEnvManager } from './envManager.js'; import { lastMock } from '../mock/mockNativePapi.js'; -import { formatUpdateCommands } from '../mock/mockNativePapi/templateTree.js'; import { serializeBackgroundTree, serializeToJSX } from './serializer.js'; declare const renderPage: () => void; @@ -32,6 +32,13 @@ type FormattedUpdateEntry = attributeSlots: unknown; elementSlots: unknown; } + | { + type: 'createTypedElement'; + id: number; + elementType: string; + elementSlots: unknown; + options: unknown; + } | { type: 'setAttribute'; id: number; @@ -73,7 +80,7 @@ export function formatUpdateStream(stream: ElementTemplateUpdateCommandStream): let index = 0; while (index < stream.length) { const opcode = stream[index++] as number; - if (opcode === 1) { + if (opcode === ElementTemplateUpdateOps.createTemplate) { formatted.push({ type: 'create', id: stream[index++] as number, @@ -85,7 +92,7 @@ export function formatUpdateStream(stream: ElementTemplateUpdateCommandStream): continue; } - if (opcode === 2) { + if (opcode === ElementTemplateUpdateOps.setAttribute) { formatted.push({ type: 'setAttribute', id: stream[index++] as number, @@ -95,7 +102,18 @@ export function formatUpdateStream(stream: ElementTemplateUpdateCommandStream): continue; } - if (opcode === 3) { + if (opcode === ElementTemplateUpdateOps.createTypedElement) { + formatted.push({ + type: 'createTypedElement', + id: stream[index++] as number, + elementType: stream[index++] as string, + elementSlots: stream[index++] as unknown, + options: stream[index++] as unknown, + }); + continue; + } + + if (opcode === ElementTemplateUpdateOps.insertNode) { formatted.push({ type: 'insertNode', id: stream[index++] as number, diff --git a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts index 9670162498..bad6666fe5 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts @@ -24,6 +24,7 @@ const isUnknownArray = isUnknownArrayForMock; export interface MockNativePapi { nativeLog: any[]; mockCreateElementTemplate: any; + mockCreateTypedElementTemplate: any; mockSetClasses: any; mockSetInlineStyles: any; mockSetID: any; @@ -115,6 +116,50 @@ export function installMockNativePapi( return element; }); + const mockCreateTypedElementTemplate = vi.fn().mockImplementation(( + type: string, + elementSlots: unknown[][] | null | undefined, + handleId: unknown, + options: unknown, + ) => { + nativeLog.push(['__CreateTypedElementTemplate', type, elementSlots, handleId, options]); + const element: CompiledTemplateNode = { + tag: type, + type, + attributes: {}, + children: [...(elementSlots?.[0] ?? [])], + }; + attachMockNativeId(element); + if (typeof handleId === 'number') { + Object.defineProperty(element, '__handleId', { + value: handleId, + writable: true, + configurable: true, + }); + } + Object.defineProperty(element, '__typedElementType', { + value: type, + writable: true, + configurable: true, + }); + Object.defineProperty(element, '__attributeSlots', { + value: null, + writable: true, + configurable: true, + }); + Object.defineProperty(element, '__elementSlots', { + value: elementSlots ?? null, + writable: true, + configurable: true, + }); + Object.defineProperty(element, '__options', { + value: options ?? null, + writable: true, + configurable: true, + }); + return element; + }); + const mockSerializeElementTemplate = vi.fn().mockImplementation((templateInstance: unknown) => { return serializeTemplateInstance(templateInstance); }); @@ -311,6 +356,7 @@ export function installMockNativePapi( }); vi.stubGlobal('__CreateElementTemplate', mockCreateElementTemplate); + vi.stubGlobal('__CreateTypedElementTemplate', mockCreateTypedElementTemplate); vi.stubGlobal('__CreatePage', mockCreatePage); vi.stubGlobal('__AppendElement', mockAppendElement); vi.stubGlobal('__AddDataset', mockAddDataset); @@ -335,6 +381,7 @@ export function installMockNativePapi( const result: MockNativePapi = { nativeLog: nativeLog, mockCreateElementTemplate: mockCreateElementTemplate, + mockCreateTypedElementTemplate: mockCreateTypedElementTemplate, mockSetClasses: mockSetClasses, mockSetInlineStyles: mockSetInlineStyles, mockSetID: mockSetID, diff --git a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.test.ts b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.test.ts index 9dbfa1f7ba..4afd9e5223 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.test.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { instantiateCompiledTemplate, setAttributeSlotOnTemplateInstance } from './templateTree.js'; +import { + instantiateCompiledTemplate, + insertNodeIntoTemplateInstance, + serializeTemplateInstance, + setAttributeSlotOnTemplateInstance, +} from './templateTree.js'; import type { CompiledTemplateNode } from './templateTree.js'; type CompiledElementTemplate = NonNullable; @@ -49,4 +54,67 @@ describe('mock native template tree spread slots', () => { expect(root.__attributeSlots).toEqual([{ id: 'cta-next' }]); expect(root.attributes).toEqual({ id: 'cta-next' }); }); + + it('serializes typed nodes with attributeSlots and omits function fields', () => { + const child = { + templateId: '_et_builtin_raw_text', + attributes: { text: 'row' }, + children: [], + __handleId: 2, + } satisfies CompiledTemplateNode; + const componentAtIndex = () => child; + const root = { + tag: 'list', + type: 'list', + attributes: {}, + children: [child], + __typedElementType: 'list', + __handleId: 1, + __attributeSlots: [{ + 'component-at-index': componentAtIndex, + 'update-list-info': { insertAction: [] }, + }], + __elementSlots: [[child]], + __options: { + listChildren: [child], + callback: componentAtIndex, + }, + } satisfies CompiledTemplateNode; + + const serializedChild = { + templateKey: '_et_builtin_raw_text', + attributeSlots: ['row'], + elementSlots: [], + uid: 2, + }; + + expect(serializeTemplateInstance(root)).toEqual({ + type: 'list', + attributeSlots: [{ 'update-list-info': { insertAction: [] } }], + elementSlots: [[serializedChild]], + uid: 1, + options: { + listChildren: [serializedChild], + }, + }); + }); + + it('moves typed children between element slots instead of duplicating them', () => { + const child = { tag: 'view' }; + const root = { + tag: 'list', + type: 'list', + attributes: {}, + children: [child], + __typedElementType: 'list', + __handleId: 1, + __attributeSlots: null, + __elementSlots: [[child], []], + } satisfies CompiledTemplateNode; + + insertNodeIntoTemplateInstance(root, 1, child, null); + + expect(root.__elementSlots).toEqual([[], [child]]); + expect(root.children).toEqual([]); + }); }); diff --git a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts index d997cdca02..088d130ad8 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts @@ -16,7 +16,9 @@ export interface CompiledTemplateNode { text?: string; __handleId?: number; __compiledTemplate?: CompiledElementNode; + __typedElementType?: string; __attributeSlots?: unknown[] | null; + __elementSlots?: unknown[][] | null; __options?: Record; } @@ -177,28 +179,7 @@ function rebuildTemplateInstance(root: CompiledTemplateNode): void { assignTemplateInstance(root, next); } -export function setAttributeSlotOnTemplateInstance( - root: CompiledTemplateNode, - attrSlotIndex: number, - value: unknown, -): void { - const attributeSlots = normalizeAttributeSlots(root.__attributeSlots); - attributeSlots[attrSlotIndex] = value; - root.__attributeSlots = attributeSlots; - rebuildTemplateInstance(root); -} - -export function insertNodeIntoTemplateInstance( - root: CompiledTemplateNode, - elementSlotIndex: number, - node: unknown, - referenceNode?: unknown, -): void { - if (!root.__compiledTemplate) { - return; - } - const attributeSlots = normalizeAttributeSlots(root.__attributeSlots); - const elementSlots = collectElementSlotsFromInstance(root); +function detachNodeFromElementSlots(elementSlots: unknown[][], node: unknown): void { for (let slotIndex = 0; slotIndex < elementSlots.length; slotIndex += 1) { const children = elementSlots[slotIndex]; if (!children) { @@ -212,7 +193,14 @@ export function insertNodeIntoTemplateInstance( ]; } } +} +function insertNodeIntoElementSlot( + elementSlots: unknown[][], + elementSlotIndex: number, + node: unknown, + referenceNode?: unknown, +): void { const targetChildren = [...(elementSlots[elementSlotIndex] ?? [])]; if (referenceNode == null) { targetChildren.push(node); @@ -224,8 +212,68 @@ export function insertNodeIntoTemplateInstance( targetChildren.push(node); } } + elementSlots[elementSlotIndex] = targetChildren; +} +function removeNodeFromElementSlot( + elementSlots: unknown[][], + elementSlotIndex: number, + node: unknown, +): void { + const targetChildren = [...(elementSlots[elementSlotIndex] ?? [])]; + const index = targetChildren.indexOf(node); + if (index >= 0) { + targetChildren.splice(index, 1); + } elementSlots[elementSlotIndex] = targetChildren; +} + +function commitTypedElementSlots( + root: CompiledTemplateNode, + elementSlots: unknown[][], +): void { + root.__elementSlots = elementSlots; + root.children = elementSlots[0] ?? []; +} + +export function setAttributeSlotOnTemplateInstance( + root: CompiledTemplateNode, + attrSlotIndex: number, + value: unknown, +): void { + const attributeSlots = normalizeAttributeSlots(root.__attributeSlots); + attributeSlots[attrSlotIndex] = value; + root.__attributeSlots = attributeSlots; + + if (root.__typedElementType) { + root.attributes = attrSlotIndex === 0 && isRecord(value) ? { ...value } : {}; + return; + } + + rebuildTemplateInstance(root); +} + +export function insertNodeIntoTemplateInstance( + root: CompiledTemplateNode, + elementSlotIndex: number, + node: unknown, + referenceNode?: unknown, +): void { + if (root.__typedElementType) { + const elementSlots = isUnknownArray(root.__elementSlots) ? [...root.__elementSlots] : []; + detachNodeFromElementSlots(elementSlots, node); + insertNodeIntoElementSlot(elementSlots, elementSlotIndex, node, referenceNode); + commitTypedElementSlots(root, elementSlots); + return; + } + + if (!root.__compiledTemplate) { + return; + } + const attributeSlots = normalizeAttributeSlots(root.__attributeSlots); + const elementSlots = collectElementSlotsFromInstance(root); + detachNodeFromElementSlots(elementSlots, node); + insertNodeIntoElementSlot(elementSlots, elementSlotIndex, node, referenceNode); root.__attributeSlots = attributeSlots; const next = instantiateCompiledTemplate(root.__compiledTemplate, attributeSlots, elementSlots); assignTemplateInstance(root, next); @@ -236,17 +284,19 @@ export function removeNodeFromTemplateInstance( elementSlotIndex: number, node: unknown, ): void { + if (root.__typedElementType) { + const elementSlots = isUnknownArray(root.__elementSlots) ? [...root.__elementSlots] : []; + removeNodeFromElementSlot(elementSlots, elementSlotIndex, node); + commitTypedElementSlots(root, elementSlots); + return; + } + if (!root.__compiledTemplate) { return; } const attributeSlots = normalizeAttributeSlots(root.__attributeSlots); const elementSlots = collectElementSlotsFromInstance(root); - const targetChildren = [...(elementSlots[elementSlotIndex] ?? [])]; - const index = targetChildren.indexOf(node); - if (index >= 0) { - targetChildren.splice(index, 1); - } - elementSlots[elementSlotIndex] = targetChildren; + removeNodeFromElementSlot(elementSlots, elementSlotIndex, node); root.__attributeSlots = attributeSlots; const next = instantiateCompiledTemplate(root.__compiledTemplate, attributeSlots, elementSlots); assignTemplateInstance(root, next); @@ -260,14 +310,30 @@ type SerializableValueForMock = | SerializableValueForMock[] | { [key: string]: SerializableValueForMock }; -interface SerializedElementTemplateForMock { - templateKey: string; - bundleUrl?: string; +type SerializedRuntimeOptionValueForMock = + | SerializableValueForMock + | SerializedEtNodeForMock + | SerializedRuntimeOptionValueForMock[] + | { [key: string]: SerializedRuntimeOptionValueForMock }; + +interface SerializedEtNodeBaseForMock { attributeSlots?: SerializableValueForMock[] | null; - elementSlots?: SerializedElementTemplateForMock[][] | null; + elementSlots?: SerializedEtNodeForMock[][] | null; uid: number | string; + options?: Record | null; +} + +interface SerializedCompiledNodeForMock extends SerializedEtNodeBaseForMock { + templateKey: string; + bundleUrl?: string; } +interface SerializedTypedNodeForMock extends SerializedEtNodeBaseForMock { + type: string; +} + +type SerializedEtNodeForMock = SerializedCompiledNodeForMock | SerializedTypedNodeForMock; + function getSlotId(node: Record): number | undefined { const attrs = node['attributes']; if (!isRecord(attrs)) { @@ -345,67 +411,182 @@ function decodeDynamicAttrsFromTemplate( return Object.keys(attrsByPartId).length > 0 ? attrsByPartId : undefined; } +function isTemplateInstanceForSerialization(value: unknown): value is Record { + return isRecord(value) + && (typeof value['templateId'] === 'string' || typeof value['__typedElementType'] === 'string'); +} + +function serializePlainSerializableValue(value: unknown): SerializableValueForMock | undefined { + if ( + typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + || value === null + ) { + return value; + } + + if (isUnknownArray(value)) { + return value.map((item) => serializePlainSerializableValue(item) ?? null); + } + + if (isTemplateInstanceForSerialization(value) || !isRecord(value)) { + return undefined; + } + + const serialized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const nextValue = serializePlainSerializableValue(nestedValue); + if (nextValue !== undefined) { + serialized[key] = nextValue; + } + } + return serialized; +} + +function serializeRuntimeOptionValue(value: unknown): SerializedRuntimeOptionValueForMock | undefined { + if (isTemplateInstanceForSerialization(value)) { + return serializeTemplateNode(value); + } + + if (isUnknownArray(value)) { + return value.map((item) => serializeRuntimeOptionValue(item) ?? null); + } + + if (!isRecord(value)) { + return serializePlainSerializableValue(value); + } + + const serialized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const nextValue = serializeRuntimeOptionValue(nestedValue); + if (nextValue !== undefined) { + serialized[key] = nextValue; + } + } + return serialized; +} + +function serializeRuntimeOptions( + value: unknown, +): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const serialized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const nextValue = serializeRuntimeOptionValue(nestedValue); + if (nextValue !== undefined) { + serialized[key] = nextValue; + } + } + return serialized; +} + function normalizeAttributeSlots( value: unknown, ): SerializableValueForMock[] { - return isUnknownArray(value) ? (value as SerializableValueForMock[]) : []; + return isUnknownArray(value) + ? value.map((slotValue) => serializePlainSerializableValue(slotValue) ?? null) + : []; } -function serializeTemplateNode( - root: unknown, -): SerializedElementTemplateForMock { - if (!isRecord(root) || typeof root['templateId'] !== 'string') { - throw new Error('ElementTemplate: __SerializeElementTemplate expects a template instance.'); +function serializeElementSlotsFromSlots( + elementSlots: unknown[][] | null | undefined, +): SerializedEtNodeForMock[][] { + const serializedSlots: SerializedEtNodeForMock[][] = []; + if (!isUnknownArray(elementSlots)) { + return serializedSlots; } - const handleId = root['__handleId']; - if (typeof handleId !== 'number') { - throw new Error('ElementTemplate: __SerializeElementTemplate expects a template instance handleId.'); + for (let slotId = 0; slotId < elementSlots.length; slotId += 1) { + const slotChildren = elementSlots[slotId]; + if (!isUnknownArray(slotChildren)) { + continue; + } + + const serializedChildren: SerializedEtNodeForMock[] = []; + for (const childNode of slotChildren) { + if (isTemplateInstanceForSerialization(childNode)) { + serializedChildren.push(serializeTemplateNode(childNode)); + } + } + serializedSlots[slotId] = serializedChildren; } - const templateId = root['templateId']; - const serializedSlots: SerializedElementTemplateForMock[][] = []; + return serializedSlots; +} + +function serializeCompiledElementSlotsFromChildren(root: Record): SerializedEtNodeForMock[][] { + const serializedSlots: SerializedEtNodeForMock[][] = []; const children = root['children']; - if (isUnknownArray(children)) { - for (const child of children) { - if (!isRecord(child)) { - continue; - } + if (!isUnknownArray(children)) { + return serializedSlots; + } - const slotId = getSlotId(child); - if (slotId === undefined) { - continue; - } + for (const child of children) { + if (!isRecord(child)) { + continue; + } - const slotChildren: SerializedElementTemplateForMock[] = []; - const childNodes = child['children']; - if (isUnknownArray(childNodes)) { - for (const childNode of childNodes) { - if (!isRecord(childNode) || typeof childNode['templateId'] !== 'string') { - continue; - } - slotChildren.push( - serializeTemplateNode(childNode) as SerializedElementTemplateForMock, - ); + const slotId = getSlotId(child); + if (slotId === undefined) { + continue; + } + + const slotChildren: SerializedEtNodeForMock[] = []; + const childNodes = child['children']; + if (isUnknownArray(childNodes)) { + for (const childNode of childNodes) { + if (isTemplateInstanceForSerialization(childNode)) { + slotChildren.push(serializeTemplateNode(childNode)); } } - - serializedSlots[slotId] = slotChildren; } + + serializedSlots[slotId] = slotChildren; + } + + return serializedSlots; +} + +function serializeTemplateNode( + root: unknown, +): SerializedEtNodeForMock { + if (!isRecord(root) || !isTemplateInstanceForSerialization(root)) { + throw new Error('ElementTemplate: __SerializeElementTemplate expects a template instance.'); + } + + const handleId = root['__handleId']; + if (typeof handleId !== 'number') { + throw new Error('ElementTemplate: __SerializeElementTemplate expects a template instance handleId.'); + } + + if (typeof root['__typedElementType'] === 'string') { + const options = serializeRuntimeOptions(root['__options']); + return { + type: root['__typedElementType'], + attributeSlots: normalizeAttributeSlots(root['__attributeSlots']), + elementSlots: serializeElementSlotsFromSlots(root['__elementSlots']), + uid: handleId, + ...(options ? { options } : {}), + }; } + const templateId = root['templateId']; return { - templateKey: templateId === '_et_builtin_raw_text' ? '_et_builtin_raw_text' : templateId, + templateKey: templateId === '_et_builtin_raw_text' ? '_et_builtin_raw_text' : String(templateId), attributeSlots: templateId === '_et_builtin_raw_text' ? [String((isRecord(root['attributes']) ? root['attributes']?.['text'] : '') ?? '')] : normalizeAttributeSlots(root['__attributeSlots']), - elementSlots: serializedSlots, + elementSlots: serializeCompiledElementSlotsFromChildren(root), uid: handleId, }; } -export function serializeTemplateInstance(root: unknown): SerializedElementTemplateForMock { - return serializeTemplateNode(root) as SerializedElementTemplateForMock; +export function serializeTemplateInstance(root: unknown): SerializedEtNodeForMock { + return serializeTemplateNode(root); } export function formatUpdateCommands(ops: unknown): unknown { @@ -451,6 +632,15 @@ export function formatUpdateCommands(ops: unknown): unknown { child: ops[i + 3], }); i += 4; + } else if (opcode === 5) { + res.push({ + type: 'createTypedElement', + id: ops[i + 1], + elementType: ops[i + 2], + elementSlots: ops[i + 3], + options: ops[i + 4], + }); + i += 5; } else { res.push(opcode); i += 1; diff --git a/packages/react/runtime/src/element-template/background/hydration-listener.ts b/packages/react/runtime/src/element-template/background/hydration-listener.ts index a8d789f0aa..d301d6a32e 100644 --- a/packages/react/runtime/src/element-template/background/hydration-listener.ts +++ b/packages/react/runtime/src/element-template/background/hydration-listener.ts @@ -19,7 +19,7 @@ import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } fr import { clearPendingEvents, flushPendingEvents } from '../prop-adapters/event.js'; import { clearDelayedRefUiOps, clearPendingRefs, flushDelayedRefUiOps } from '../prop-adapters/ref.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; -import type { SerializedElementTemplate } from '../protocol/types.js'; +import type { SerializedElementTemplate, SerializedEtNode } from '../protocol/types.js'; import { __root } from '../runtime/page/root-instance.js'; let listener: @@ -37,7 +37,7 @@ export function installElementTemplateHydrationListener(): void { } beginPipeline(true, PipelineOrigins.reactLynxHydrate, PerformanceTimingFlags.reactLynxHydrate); markTiming('hydrateParsePayloadStart'); - const instances = data as SerializedElementTemplate[]; + const instances = data as SerializedEtNode[]; markTiming('hydrateParsePayloadEnd'); markTiming('diffVdomStart'); @@ -61,7 +61,16 @@ export function installElementTemplateHydrationListener(): void { if (!after) { break; } - if (!hydrateIntoContext(before, after)) { + if (!('templateKey' in before)) { + if (__DEV__) { + lynx.reportError( + new Error(`ElementTemplate hydrate does not support serialized typed root '${before.type}'.`), + ); + } + didHydrateMatchedInstances = false; + break; + } + if (!hydrateIntoContext(before as SerializedElementTemplate, after)) { didHydrateMatchedInstances = false; break; } diff --git a/packages/react/runtime/src/element-template/debug/alog.ts b/packages/react/runtime/src/element-template/debug/alog.ts index 11a6e2d46e..9e650496d3 100644 --- a/packages/react/runtime/src/element-template/debug/alog.ts +++ b/packages/react/runtime/src/element-template/debug/alog.ts @@ -16,6 +16,13 @@ export type FormattedElementTemplateUpdateCommand = attributeSlots: unknown; elementSlots: unknown; } + | { + op: 'createTypedElement'; + handleId: number; + type: string; + elementSlots: unknown; + options: unknown; + } | { op: 'setAttribute'; targetId: number; @@ -76,6 +83,16 @@ export function formatElementTemplateUpdateCommands( }); break; + case ElementTemplateUpdateOps.createTypedElement: + result.push({ + op: 'createTypedElement', + handleId: stream[index++] as number, + type: stream[index++] as string, + elementSlots: stream[index++], + options: stream[index++], + }); + break; + case ElementTemplateUpdateOps.insertNode: result.push({ op: 'insertNode', diff --git a/packages/react/runtime/src/element-template/protocol/opcodes.ts b/packages/react/runtime/src/element-template/protocol/opcodes.ts index 3ce122c3bd..492e861d03 100644 --- a/packages/react/runtime/src/element-template/protocol/opcodes.ts +++ b/packages/react/runtime/src/element-template/protocol/opcodes.ts @@ -7,6 +7,7 @@ export const ElementTemplateUpdateOps = { setAttribute: 2, insertNode: 3, removeNode: 4, + createTypedElement: 5, } as const; export type ElementTemplateUpdateOp = typeof ElementTemplateUpdateOps[keyof typeof ElementTemplateUpdateOps]; diff --git a/packages/react/runtime/src/element-template/protocol/types.ts b/packages/react/runtime/src/element-template/protocol/types.ts index 45a377a1a6..51cf1ee884 100644 --- a/packages/react/runtime/src/element-template/protocol/types.ts +++ b/packages/react/runtime/src/element-template/protocol/types.ts @@ -12,14 +12,66 @@ export type SerializableValue = | SerializableValue[] | { [key: string]: SerializableValue }; -export type RuntimeOptions = Record; +export type RuntimeOptionValue = + | SerializableValue + | ElementRef + | RuntimeOptionValue[] + | { [key: string]: RuntimeOptionValue }; +export type RuntimeOptions = Record; + +export type RuntimeAttributeSlotValue = + | SerializableValue + | ((...args: unknown[]) => unknown) + | RuntimeAttributeSlotValue[] + | { [key: string]: RuntimeAttributeSlotValue }; + +export interface ElementTemplateHandleRefCommandValue { + __etHandleRef: number; + [key: string]: SerializableValue; +} + +// Phase 1 keeps handle refs list-specific: only `listChildren` is resolved to +// ElementRef[] by MTS before native create. Generic nested refs are deferred. +export interface RuntimeOptionsCommand extends Record { + listChildren?: ElementTemplateHandleRefCommandValue[]; +} + +export type SerializedRuntimeOptionValue = + | SerializableValue + | SerializedEtNode + | SerializedRuntimeOptionValue[] + | { [key: string]: SerializedRuntimeOptionValue }; + +export type SerializedRuntimeOptions = Record; + +export interface SerializedEtNodeBase { + attributeSlots?: SerializableValue[] | null; + elementSlots?: SerializedEtNode[][] | null; + uid: number | string; + options?: SerializedRuntimeOptions | null; +} + +export interface SerializedCompiledNode extends SerializedEtNodeBase { + templateKey: string; + bundleUrl?: string; +} + +export interface SerializedTypedNode extends SerializedEtNodeBase { + type: string; +} + +export type SerializedEtNode = SerializedCompiledNode | SerializedTypedNode; + +// Current hydrate/update code is still compiled-node only. Keep this recursive +// shape narrow while the RFC-level SerializedEtNode already models typed nodes. export interface SerializedElementTemplate { templateKey: string; bundleUrl?: string; attributeSlots?: SerializableValue[] | null; elementSlots?: SerializedElementTemplate[][] | null; uid: number | string; + options?: SerializedRuntimeOptions | null; } export type CreateTemplateCommand = [ @@ -54,11 +106,20 @@ export type RemoveNodeCommand = [ removedSubtreeHandleIds: number[], ]; +export type CreateTypedElementCommand = [ + typeof ElementTemplateUpdateOps.createTypedElement, + handleId: number, + type: string, + elementSlots: number[][] | null | undefined, + options: RuntimeOptionsCommand | null | undefined, +]; + export type ElementTemplateUpdateCommand = | CreateTemplateCommand | SetAttributeCommand | InsertNodeCommand - | RemoveNodeCommand; + | RemoveNodeCommand + | CreateTypedElementCommand; // Commands are transported as a flat stream to match the native update payload. // Tuple aliases above define each opcode's shape; this item union preserves the diff --git a/packages/react/runtime/src/element-template/runtime/patch.ts b/packages/react/runtime/src/element-template/runtime/patch.ts index d5da37b174..1f3669013a 100644 --- a/packages/react/runtime/src/element-template/runtime/patch.ts +++ b/packages/react/runtime/src/element-template/runtime/patch.ts @@ -5,7 +5,12 @@ import { elementTemplateRegistry } from './template/registry.js'; import { ElementTemplateUpdateOps } from '../protocol/opcodes.js'; import type { ElementTemplateUpdateOp } from '../protocol/opcodes.js'; -import type { ElementTemplateUpdateCommandStream, SerializableValue } from '../protocol/types.js'; +import type { + ElementTemplateUpdateCommandStream, + RuntimeOptions, + RuntimeOptionsCommand, + SerializableValue, +} from '../protocol/types.js'; export type { ElementTemplateUpdateCommandStream } from '../protocol/types.js'; @@ -67,6 +72,31 @@ export function applyElementTemplateUpdateCommands( break; } + case ElementTemplateUpdateOps.createTypedElement: { + const handleId = stream[i++] as number; + const type = stream[i++] as string; + const elementSlots = stream[i++] as number[][] | null | undefined; + const options = stream[i++] as RuntimeOptionsCommand | null | undefined; + + const resolvedElementSlots = resolveElementSlots(elementSlots); + const resolvedOptions = resolveRuntimeOptions(options); + if ((__DEV__ && resolvedElementSlots.hasError) || resolvedOptions.hasError) { + continue; + } + + const nativeRef = __CreateTypedElementTemplate( + type, + resolvedElementSlots.value, + handleId, + resolvedOptions.value, + ); + + if (nativeRef) { + elementTemplateRegistry.set(handleId, nativeRef); + } + break; + } + case ElementTemplateUpdateOps.insertNode: { const targetId = stream[i++] as number; const elementSlotIndex = stream[i++] as number; @@ -145,6 +175,42 @@ function resolveElementSlots( return { hasError, value }; } +function resolveRuntimeOptions( + options: RuntimeOptionsCommand | null | undefined, +): { hasError: boolean; value: RuntimeOptions | null | undefined } { + if (options == null) { + return { hasError: false, value: options }; + } + + const listChildren = options.listChildren; + if (!Array.isArray(listChildren)) { + return { hasError: false, value: options as RuntimeOptions }; + } + + const resolvedListChildren: ElementRef[] = []; + for (let index = 0; index < listChildren.length; index++) { + const ref = resolveHandle( + (listChildren[index]!).__etHandleRef, + `options.listChildren[${index}]`, + ); + if (ref === null) { + return { + hasError: true, + value: null, + }; + } + resolvedListChildren.push(ref); + } + + return { + hasError: false, + value: ({ + ...options, + listChildren: resolvedListChildren, + }) as RuntimeOptions, + }; +} + function resolveHandle(id: number, role: string): ElementRef | null { const nativeRef = elementTemplateRegistry.get(id); if (!nativeRef) { diff --git a/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts b/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts index de490f6592..00e461c788 100644 --- a/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts +++ b/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts @@ -10,7 +10,7 @@ import { renderOpcodesIntoElementTemplate } from './render-opcodes.js'; import { render as renderToString } from './render-to-opcodes.js'; import { profileEnd, profileStart } from '../../debug/profile.js'; import { ElementTemplateLifecycleConstant } from '../../protocol/lifecycle-constant.js'; -import type { SerializedElementTemplate } from '../../protocol/types.js'; +import type { SerializedEtNode } from '../../protocol/types.js'; import { __page } from '../page/page.js'; import { __root } from '../page/root-instance.js'; @@ -39,7 +39,7 @@ function renderMainThread(): void { profileStart('ReactLynx::packSerializedETInstance'); try { - const instances: SerializedElementTemplate[] = []; + const instances: SerializedEtNode[] = []; for (const rootRef of rootRefs) { instances.push(__SerializeElementTemplate(rootRef)); } diff --git a/packages/react/runtime/src/element-template/types.d.ts b/packages/react/runtime/src/element-template/types.d.ts index 6fe8ab5287..186cf44721 100644 --- a/packages/react/runtime/src/element-template/types.d.ts +++ b/packages/react/runtime/src/element-template/types.d.ts @@ -2,7 +2,12 @@ // 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 type { RuntimeOptions, SerializableValue, SerializedElementTemplate } from './protocol/types.js'; +import type { + RuntimeAttributeSlotValue, + RuntimeOptions, + SerializableValue, + SerializedEtNode, +} from './protocol/types.js'; export {}; @@ -17,13 +22,21 @@ declare global { attributeSlots: SerializableValue[] | null | undefined, elementSlots: ElementRef[][] | null | undefined, uid: number | string, + options?: RuntimeOptions | null, + ): ElementRef; + + function __CreateTypedElementTemplate( + type: string, + elementSlots: ElementRef[][] | null | undefined, + uid: number | string, + options?: RuntimeOptions | null, ): ElementRef; function __SetAttributeOfElementTemplate( element: ElementRef, attrSlotIndex: number, - value: SerializableValue | null, - oldValue?: SerializableValue | null, + value: RuntimeAttributeSlotValue | null, + options?: RuntimeOptions | null, ): void; function __InsertNodeToElementTemplate( @@ -42,5 +55,5 @@ declare global { function __SerializeElementTemplate( templateInstance: ElementRef, options?: RuntimeOptions | null, - ): SerializedElementTemplate; + ): SerializedEtNode; } From 3fd99ba1ff7416766493309edc34328cd0437b8f Mon Sep 17 00:00:00 2001 From: Yradex <11014207+Yradex@users.noreply.github.com> Date: Thu, 21 May 2026 14:16:43 +0800 Subject: [PATCH 2/5] feat(react): support ET immediate updatePage --- .../__test__/core/lynx-page-data.test.ts | 62 ++++++++++++ .../native/main-thread-api.test.ts | 99 ++++++++++++++++++- .../snapshot/lifecycle/updateData.test.jsx | 23 +++++ .../react/runtime/src/core/lynx-page-data.ts | 19 ++++ .../native/main-thread-api.ts | 14 ++- .../src/snapshot/lynx/calledByNative.ts | 11 +-- 6 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 packages/react/runtime/__test__/core/lynx-page-data.test.ts create mode 100644 packages/react/runtime/src/core/lynx-page-data.ts diff --git a/packages/react/runtime/__test__/core/lynx-page-data.test.ts b/packages/react/runtime/__test__/core/lynx-page-data.test.ts new file mode 100644 index 0000000000..5abb7b2b25 --- /dev/null +++ b/packages/react/runtime/__test__/core/lynx-page-data.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { applyUpdatePageData } from '../../src/core/lynx-page-data.js'; + +describe('applyUpdatePageData', () => { + let originalInitData: typeof lynx.__initData; + + beforeEach(() => { + originalInitData = lynx.__initData; + }); + + afterEach(() => { + lynx.__initData = originalInitData; + }); + + it('merges non-empty object data into the existing initData object', () => { + const previousInitData = { msg: 'init', stable: true }; + lynx.__initData = previousInitData; + + applyUpdatePageData({ msg: 'update', next: 1 }); + + expect(lynx.__initData).toBe(previousInitData); + expect(lynx.__initData).toEqual({ msg: 'update', stable: true, next: 1 }); + }); + + it('creates initData when merging into an empty main-thread state', () => { + lynx.__initData = undefined; + + applyUpdatePageData({ msg: 'update' }); + + expect(lynx.__initData).toEqual({ msg: 'update' }); + }); + + it('clears existing initData before resetPageData merge', () => { + lynx.__initData = { stale: true, msg: 'init' }; + + applyUpdatePageData({ msg: 'reset' }, { resetPageData: true }); + + expect(lynx.__initData).toEqual({ msg: 'reset' }); + }); + + it('supports resetPageData without update data', () => { + lynx.__initData = { stale: true }; + + applyUpdatePageData(undefined, { resetPageData: true }); + + expect(lynx.__initData).toEqual({}); + }); + + it('does not change initData for empty or non-object data', () => { + const previousInitData = { msg: 'init' }; + lynx.__initData = previousInitData; + + applyUpdatePageData({}); + applyUpdatePageData(null); + applyUpdatePageData(undefined); + applyUpdatePageData('ignored'); + + expect(lynx.__initData).toBe(previousInitData); + expect(lynx.__initData).toEqual({ msg: 'init' }); + }); +}); diff --git a/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts b/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts index 4ff91b21a3..89b3bfaf42 100644 --- a/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts +++ b/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts @@ -4,8 +4,17 @@ import { injectCalledByNative } from '../../../src/element-template/native/main- import { setupPage } from '../../../src/element-template/runtime/page/page.js'; import { renderMainThread } from '../../../src/element-template/runtime/render/render-main-thread.js'; +const mockedPageModuleState = vi.hoisted(() => ({ + page: undefined as unknown, +})); + vi.mock('../../../src/element-template/runtime/page/page.js', () => ({ - setupPage: vi.fn(), + get __page() { + return mockedPageModuleState.page; + }, + setupPage: vi.fn((page: unknown) => { + mockedPageModuleState.page = page; + }), })); vi.mock('../../../src/element-template/runtime/render/render-main-thread.js', () => ({ @@ -14,7 +23,10 @@ vi.mock('../../../src/element-template/runtime/render/render-main-thread.js', () describe('injectCalledByNative', () => { beforeEach(() => { + mockedPageModuleState.page = undefined; + globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; vi.stubGlobal('__CreatePage', vi.fn(() => ({ type: 'page', id: '0', children: [] }))); + vi.stubGlobal('__FlushElementTree', vi.fn()); (globalThis as typeof globalThis & { lynx: typeof lynx & { __initData?: unknown } }).lynx = { ...(globalThis.lynx ?? {}), __initData: undefined, @@ -51,4 +63,89 @@ describe('injectCalledByNative', () => { expect(vi.mocked(setupPage)).toHaveBeenCalledWith({ type: 'page', id: '0', children: [] }); expect(vi.mocked(renderMainThread)).toHaveBeenCalledTimes(1); }); + + it('merges updatePage data into initData and flushes the current page', () => { + injectCalledByNative(); + const globalAny = globalThis as typeof globalThis & { + renderPage: (data?: Record) => void; + updatePage: (data?: Record, options?: UpdatePageOption) => void; + }; + const page = { type: 'page', id: '0', children: [] }; + vi.mocked(__CreatePage).mockReturnValue(page); + globalAny.renderPage({ msg: 'init', stable: true }); + vi.mocked(renderMainThread).mockClear(); + + const options = { pipelineOptions: { pipelineID: 'pipeline-1' } }; + globalAny.updatePage({ msg: 'update', next: 1 }, options); + + expect(globalThis.lynx.__initData).toEqual({ msg: 'update', stable: true, next: 1 }); + expect(__FlushElementTree).toHaveBeenCalledWith(page, options); + expect(vi.mocked(renderMainThread)).not.toHaveBeenCalled(); + expect(options).not.toHaveProperty('triggerDataUpdated'); + }); + + it('clears initData before resetPageData updates', () => { + injectCalledByNative(); + const globalAny = globalThis as typeof globalThis & { + renderPage: (data?: Record) => void; + updatePage: (data?: Record, options?: UpdatePageOption) => void; + }; + const page = { type: 'page', id: '0', children: [] }; + vi.mocked(__CreatePage).mockReturnValue(page); + globalAny.renderPage({ stale: true, msg: 'init' }); + + globalAny.updatePage({ msg: 'reset' }, { resetPageData: true }); + + expect(globalThis.lynx.__initData).toEqual({ msg: 'reset' }); + expect(__FlushElementTree).toHaveBeenLastCalledWith(page, { resetPageData: true }); + }); + + it('keeps initData unchanged for empty or non-object updatePage data', () => { + injectCalledByNative(); + const globalAny = globalThis as typeof globalThis & { + renderPage: (data?: Record) => void; + updatePage: (data?: Record, options?: UpdatePageOption) => void; + }; + const page = { type: 'page', id: '0', children: [] }; + vi.mocked(__CreatePage).mockReturnValue(page); + globalAny.renderPage({ msg: 'init' }); + + globalAny.updatePage({}); + globalAny.updatePage(null as unknown as Record); + globalAny.updatePage('ignored' as unknown as Record); + + expect(globalThis.lynx.__initData).toEqual({ msg: 'init' }); + expect(__FlushElementTree).toHaveBeenLastCalledWith(page, {}); + }); + + it('does not route reloadTemplate through the Phase 1 ordinary updatePage path', () => { + injectCalledByNative(); + const globalAny = globalThis as typeof globalThis & { + renderPage: (data?: Record) => void; + updatePage: (data?: Record, options?: UpdatePageOption) => void; + }; + globalAny.renderPage({ msg: 'init' }); + vi.mocked(__FlushElementTree).mockClear(); + + globalAny.updatePage({ msg: 'reload' }, { reloadTemplate: true }); + + expect(globalThis.lynx.__initData).toEqual({ msg: 'init' }); + expect(__FlushElementTree).not.toHaveBeenCalled(); + }); + + it('keeps non-immediately timing outside the Phase 1 ordinary updatePage path', () => { + injectCalledByNative(); + const globalAny = globalThis as typeof globalThis & { + renderPage: (data?: Record) => void; + updatePage: (data?: Record, options?: UpdatePageOption) => void; + }; + globalAny.renderPage({ msg: 'init' }); + globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'jsReady'; + vi.mocked(__FlushElementTree).mockClear(); + + globalAny.updatePage({ msg: 'update' }); + + expect(globalThis.lynx.__initData).toEqual({ msg: 'init' }); + expect(__FlushElementTree).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react/runtime/__test__/snapshot/lifecycle/updateData.test.jsx b/packages/react/runtime/__test__/snapshot/lifecycle/updateData.test.jsx index b20751d3e6..8c40614d8a 100644 --- a/packages/react/runtime/__test__/snapshot/lifecycle/updateData.test.jsx +++ b/packages/react/runtime/__test__/snapshot/lifecycle/updateData.test.jsx @@ -28,6 +28,29 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('main-thread updatePage initData', () => { + it('clears initData before resetPageData updates', () => { + renderPage({ stale: true, msg: 'init' }); + + updatePage({ msg: 'reset' }, { resetPageData: true }); + + expect(lynx.__initData).toEqual({ msg: 'reset' }); + }); + + it('keeps initData unchanged for empty or non-object updatePage data', () => { + renderPage({ msg: 'init' }); + const previousInitData = lynx.__initData; + + updatePage({}); + updatePage(null); + updatePage(undefined); + updatePage('ignored'); + + expect(lynx.__initData).toBe(previousInitData); + expect(lynx.__initData).toEqual({ msg: 'init' }); + }); +}); + describe('triggerDataUpdated', () => { /** * This test verifies that updates initiated by `updateCardData` include the `"flushOptions":{"triggerDataUpdated":true}` property. diff --git a/packages/react/runtime/src/core/lynx-page-data.ts b/packages/react/runtime/src/core/lynx-page-data.ts new file mode 100644 index 0000000000..d93beb9208 --- /dev/null +++ b/packages/react/runtime/src/core/lynx-page-data.ts @@ -0,0 +1,19 @@ +// Copyright 2026 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 { isEmptyObject } from '../utils.js'; + +function isNonEmptyObjectData(data: unknown): data is Record { + return typeof data == 'object' && data !== null && !isEmptyObject(data); +} + +export function applyUpdatePageData(data: unknown, options?: Pick): void { + if (options?.resetPageData) { + lynx.__initData = {}; + } + + if (isNonEmptyObjectData(data)) { + lynx.__initData ??= {}; + Object.assign(lynx.__initData, data); + } +} diff --git a/packages/react/runtime/src/element-template/native/main-thread-api.ts b/packages/react/runtime/src/element-template/native/main-thread-api.ts index dbc43b7cfc..dacf8a3f87 100644 --- a/packages/react/runtime/src/element-template/native/main-thread-api.ts +++ b/packages/react/runtime/src/element-template/native/main-thread-api.ts @@ -2,13 +2,14 @@ // 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 { setupPage } from '../runtime/page/page.js'; +import { applyUpdatePageData } from '../../core/lynx-page-data.js'; +import { __page, setupPage } from '../runtime/page/page.js'; import { renderMainThread } from '../runtime/render/render-main-thread.js'; function injectCalledByNative(): void { const calledByNative: LynxCallByNative = { renderPage, - updatePage: function(): void {}, + updatePage, updateGlobalProps: function(): void {}, getPageData: function() { return null; @@ -25,6 +26,15 @@ function renderPage(data: Record | undefined): void { renderMainThread(); } +function updatePage(data: Record | undefined, options?: UpdatePageOption): void { + if (__FIRST_SCREEN_SYNC_TIMING__ !== 'immediately' || options?.reloadTemplate) { + return; + } + + applyUpdatePageData(data, options); + __FlushElementTree(__page, options ?? {}); +} + /** * @internal */ diff --git a/packages/react/runtime/src/snapshot/lynx/calledByNative.ts b/packages/react/runtime/src/snapshot/lynx/calledByNative.ts index a1dc37780d..e93ab22d42 100644 --- a/packages/react/runtime/src/snapshot/lynx/calledByNative.ts +++ b/packages/react/runtime/src/snapshot/lynx/calledByNative.ts @@ -2,8 +2,8 @@ // 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 { markTiming, setPipeline } from './performance.js'; +import { applyUpdatePageData } from '../../core/lynx-page-data.js'; import { __root, setRoot } from '../../root.js'; -import { isEmptyObject } from '../../utils.js'; import { LifecycleConstant } from '../lifecycle/constant.js'; import { isJSReady, jsReady, jsReadyEventIdSwap, resetJSReady } from '../lifecycle/event/jsReady.js'; import { reloadMainThread } from '../lifecycle/reload.js'; @@ -114,14 +114,7 @@ function updatePage(data: Record | undefined, options?: UpdateP return; } - if (options?.resetPageData) { - lynx.__initData = {}; - } - - if (typeof data == 'object' && !isEmptyObject(data)) { - lynx.__initData ??= {}; - Object.assign(lynx.__initData, data); - } + applyUpdatePageData(data, options); const flushOptions = options ?? {}; if (!isJSReady) { From 3d4f745ea846a0d679b3b71032eda82eb3f71531 Mon Sep 17 00:00:00 2001 From: Yradex <11014207+Yradex@users.noreply.github.com> Date: Thu, 21 May 2026 16:28:57 +0800 Subject: [PATCH 3/5] feat(react): support ET immediate reload --- .../__test__/core/reload-version.test.ts | 14 + .../hydrate/background-hydrate/_shared.tsx | 8 +- .../hydrate/hydration-data/_shared.tsx | 8 +- .../fixtures/patch/_shared.tsx | 15 +- .../element-template/native/index.test.ts | 10 + .../native/main-thread-api.test.ts | 17 +- .../element-template/native/reload.test.ts | 269 ++++++++++++++++++ .../runtime/background/commit-hook.test.ts | 7 + .../runtime/background/reload.test.ts | 69 +++++ .../hydration/hydration-listener.test.ts | 33 +++ .../patch/element-template-patch.test.tsx | 8 +- .../runtime/patch/update-timing.test.ts | 65 +++++ .../render-main-thread.contract.test.ts | 11 +- .../runtime/render/render-main-thread.test.ts | 6 +- .../debug/compiledHydrationScenario.ts | 6 +- .../test-utils/debug/hydratePayload.ts | 16 ++ .../test-utils/debug/updateRunner.ts | 8 +- .../react/runtime/src/core/lynx-page-data.ts | 6 +- .../pass.ts => core/reload-version.ts} | 9 +- .../background/commit-hook.ts | 2 + .../background/hydration-listener.ts | 20 +- .../src/element-template/native/index.ts | 2 + .../native/main-thread-api.ts | 11 +- .../element-template/native/patch-listener.ts | 5 + .../src/element-template/native/reload.ts | 73 +++++ .../src/element-template/protocol/types.ts | 6 + .../runtime/render/render-main-thread.ts | 33 ++- .../runtime/template/handle.ts | 1 - .../src/snapshot/lifecycle/patch/commit.ts | 2 +- .../lifecycle/patch/updateMainThread.ts | 2 +- .../runtime/src/snapshot/lifecycle/reload.ts | 4 +- 31 files changed, 674 insertions(+), 72 deletions(-) create mode 100644 packages/react/runtime/__test__/core/reload-version.test.ts create mode 100644 packages/react/runtime/__test__/element-template/native/reload.test.ts create mode 100644 packages/react/runtime/__test__/element-template/runtime/background/reload.test.ts create mode 100644 packages/react/runtime/__test__/element-template/test-utils/debug/hydratePayload.ts rename packages/react/runtime/src/{snapshot/lifecycle/pass.ts => core/reload-version.ts} (52%) create mode 100644 packages/react/runtime/src/element-template/native/reload.ts diff --git a/packages/react/runtime/__test__/core/reload-version.test.ts b/packages/react/runtime/__test__/core/reload-version.test.ts new file mode 100644 index 0000000000..ddd166ec96 --- /dev/null +++ b/packages/react/runtime/__test__/core/reload-version.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { getReloadVersion, increaseReloadVersion } from '../../src/core/reload-version.js'; + +describe('reload version', () => { + it('increments monotonically and exposes the current version', () => { + const initial = getReloadVersion(); + + expect(increaseReloadVersion()).toBe(initial + 1); + expect(getReloadVersion()).toBe(initial + 1); + expect(increaseReloadVersion()).toBe(initial + 2); + expect(getReloadVersion()).toBe(initial + 2); + }); +}); 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 04209f01fb..6a779b9f77 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 @@ -29,6 +29,7 @@ import type { SerializedElementTemplate } from '../../../../../src/element-templ import { __page } from '../../../../../src/element-template/runtime/page/page.js'; import { __root } from '../../../../../src/element-template/runtime/page/root-instance.js'; import { ElementTemplateEnvManager } from '../../../test-utils/debug/envManager.js'; +import { extractSerializedHydrateInstances } from '../../../test-utils/debug/hydratePayload.js'; import { installMockNativePapi } from '../../../test-utils/mock/mockNativePapi.js'; import { serializeToJSX } from '../../../test-utils/debug/serializer.js'; @@ -100,12 +101,7 @@ function setup(): CaseContext { envManager.setUseElementTemplate(true); const onHydrate = vi.fn().mockImplementation((event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - for (const item of data) { - hydrationData.push(item as SerializedElementTemplate); - } - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }); lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); diff --git a/packages/react/runtime/__test__/element-template/fixtures/hydrate/hydration-data/_shared.tsx b/packages/react/runtime/__test__/element-template/fixtures/hydrate/hydration-data/_shared.tsx index 7ae25de3b8..523078dbf2 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/hydrate/hydration-data/_shared.tsx +++ b/packages/react/runtime/__test__/element-template/fixtures/hydrate/hydration-data/_shared.tsx @@ -9,6 +9,7 @@ import { resetTemplateId } from '../../../../../src/element-template/runtime/tem import { elementTemplateRegistry } from '../../../../../src/element-template/runtime/template/registry.js'; import { loadCompiledFixtureApp } from '../../../test-utils/debug/compiledFixtureApp.js'; import { ElementTemplateEnvManager } from '../../../test-utils/debug/envManager.js'; +import { extractSerializedHydrateInstances } from '../../../test-utils/debug/hydratePayload.js'; import { installMockNativePapi } from '../../../test-utils/mock/mockNativePapi.js'; declare const renderPage: () => void; @@ -32,12 +33,7 @@ function setup(): HydrationContext { const hydrationData: unknown[] = []; const onHydrate = vi.fn().mockImplementation((event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - for (const item of data) { - hydrationData.push(item); - } - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }); lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); diff --git a/packages/react/runtime/__test__/element-template/fixtures/patch/_shared.tsx b/packages/react/runtime/__test__/element-template/fixtures/patch/_shared.tsx index 0bd4cf399e..f388873033 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/patch/_shared.tsx +++ b/packages/react/runtime/__test__/element-template/fixtures/patch/_shared.tsx @@ -30,6 +30,7 @@ import { resetTemplateId } from '../../../../src/element-template/runtime/templa import { elementTemplateRegistry } from '../../../../src/element-template/runtime/template/registry.js'; import { registerBuiltinRawTextTemplate } from '../../test-utils/debug/registry.js'; import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; +import { extractSerializedHydrateInstances } from '../../test-utils/debug/hydratePayload.js'; import { compileFixtureSource } from '../../test-utils/debug/compiledFixtureCompiler.js'; import { loadCompiledFixtureModule, @@ -87,12 +88,7 @@ export function setupPatchContext(): PatchContext { envManager.switchToBackground(); const onHydrate = vi.fn().mockImplementation((event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - for (const item of data) { - hydrationData.push(item as SerializedElementTemplate); - } - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }); lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); @@ -124,12 +120,7 @@ export function setupUpdateFixtureContext(): UpdateFixtureContext { installElementTemplateHydrationListener(); installElementTemplateCommitHook(); const onHydrate = (event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - for (const item of data) { - hydrationData.push(item as SerializedElementTemplate); - } - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }; lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); diff --git a/packages/react/runtime/__test__/element-template/native/index.test.ts b/packages/react/runtime/__test__/element-template/native/index.test.ts index 0c8d4b6fd4..d4e65949c8 100644 --- a/packages/react/runtime/__test__/element-template/native/index.test.ts +++ b/packages/react/runtime/__test__/element-template/native/index.test.ts @@ -21,6 +21,7 @@ describe('element-template native index wiring', () => { vi.doUnmock('../../../src/element-template/native/patch-listener.js'); vi.doUnmock('../../../src/element-template/native/mts-destroy.js'); vi.doUnmock('../../../src/element-template/native/callDestroyLifetimeFun.js'); + vi.doUnmock('../../../src/element-template/native/reload.js'); vi.doUnmock('../../../src/element-template/prop-adapters/event.js'); vi.doUnmock('../../../src/element-template/background/document.js'); vi.doUnmock('../../../src/element-template/background/hydration-listener.js'); @@ -49,6 +50,7 @@ describe('element-template native index wiring', () => { const installElementTemplateHydrationListener = vi.fn(); const setRoot = vi.fn(); const initTimingAPI = vi.fn(); + const reloadBackground = vi.fn(); vi.doMock('../../../src/element-template/native/main-thread-api.js', () => ({ injectCalledByNative, @@ -86,6 +88,9 @@ describe('element-template native index wiring', () => { vi.doMock('../../../src/element-template/background/instance.js', () => ({ BackgroundElementTemplateInstance: class BackgroundElementTemplateInstance {}, })); + vi.doMock('../../../src/element-template/native/reload.js', () => ({ + reloadBackground, + })); await import('../../../src/element-template/native/index.js'); @@ -123,6 +128,7 @@ describe('element-template native index wiring', () => { const publicComponentEvent = vi.fn(); const resetEventStateForRuntime = vi.fn(); const updateCardData = vi.fn(); + const reloadBackground = vi.fn(); vi.doMock('../../../src/element-template/native/main-thread-api.js', () => ({ injectCalledByNative, @@ -170,6 +176,9 @@ describe('element-template native index wiring', () => { constructor(public type: string) {} }, })); + vi.doMock('../../../src/element-template/native/reload.js', () => ({ + reloadBackground, + })); await import('../../../src/element-template/native/index.js'); @@ -185,6 +194,7 @@ describe('element-template native index wiring', () => { expect(globalThis.lynxCoreInject.tt.publishEvent).toBe(publishEvent); expect(globalThis.lynxCoreInject.tt.publicComponentEvent).toBe(publicComponentEvent); expect(globalThis.lynxCoreInject.tt.updateCardData).toBe(updateCardData); + expect(globalThis.lynxCoreInject.tt.onAppReload).toBe(reloadBackground); expect(injectCalledByNative).not.toHaveBeenCalled(); expect(installElementTemplatePatchListener).not.toHaveBeenCalled(); diff --git a/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts b/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts index 89b3bfaf42..17621e1497 100644 --- a/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts +++ b/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { injectCalledByNative } from '../../../src/element-template/native/main-thread-api.js'; +import { reloadMainThread } from '../../../src/element-template/native/reload.js'; import { setupPage } from '../../../src/element-template/runtime/page/page.js'; -import { renderMainThread } from '../../../src/element-template/runtime/render/render-main-thread.js'; +import { + renderMainThread, + resetMainThreadRootRefs, +} from '../../../src/element-template/runtime/render/render-main-thread.js'; const mockedPageModuleState = vi.hoisted(() => ({ page: undefined as unknown, @@ -19,6 +23,11 @@ vi.mock('../../../src/element-template/runtime/page/page.js', () => ({ vi.mock('../../../src/element-template/runtime/render/render-main-thread.js', () => ({ renderMainThread: vi.fn(), + resetMainThreadRootRefs: vi.fn(), +})); + +vi.mock('../../../src/element-template/native/reload.js', () => ({ + reloadMainThread: vi.fn(), })); describe('injectCalledByNative', () => { @@ -61,6 +70,7 @@ describe('injectCalledByNative', () => { expect(globalThis.lynx.__initData).toEqual({ answer: 42 }); expect(__CreatePage).toHaveBeenCalledWith('0', 0); expect(vi.mocked(setupPage)).toHaveBeenCalledWith({ type: 'page', id: '0', children: [] }); + expect(vi.mocked(resetMainThreadRootRefs)).toHaveBeenCalledTimes(1); expect(vi.mocked(renderMainThread)).toHaveBeenCalledTimes(1); }); @@ -118,7 +128,7 @@ describe('injectCalledByNative', () => { expect(__FlushElementTree).toHaveBeenLastCalledWith(page, {}); }); - it('does not route reloadTemplate through the Phase 1 ordinary updatePage path', () => { + it('routes reloadTemplate through the reload main-thread path', () => { injectCalledByNative(); const globalAny = globalThis as typeof globalThis & { renderPage: (data?: Record) => void; @@ -129,6 +139,7 @@ describe('injectCalledByNative', () => { globalAny.updatePage({ msg: 'reload' }, { reloadTemplate: true }); + expect(vi.mocked(reloadMainThread)).toHaveBeenCalledWith({ msg: 'reload' }, { reloadTemplate: true }); expect(globalThis.lynx.__initData).toEqual({ msg: 'init' }); expect(__FlushElementTree).not.toHaveBeenCalled(); }); @@ -144,8 +155,10 @@ describe('injectCalledByNative', () => { vi.mocked(__FlushElementTree).mockClear(); globalAny.updatePage({ msg: 'update' }); + globalAny.updatePage({ msg: 'reload' }, { reloadTemplate: true }); expect(globalThis.lynx.__initData).toEqual({ msg: 'init' }); expect(__FlushElementTree).not.toHaveBeenCalled(); + expect(vi.mocked(reloadMainThread)).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/runtime/__test__/element-template/native/reload.test.ts b/packages/react/runtime/__test__/element-template/native/reload.test.ts new file mode 100644 index 0000000000..2731d9a3b5 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/native/reload.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { increaseReloadVersion } from '../../../src/core/reload-version.js'; +import { setupBackgroundElementTemplateDocument } from '../../../src/element-template/background/document.js'; +import { destroyElementTemplateBackgroundRuntime } from '../../../src/element-template/background/destroy.js'; +import { installElementTemplateHydrationListener } from '../../../src/element-template/background/hydration-listener.js'; +import { BackgroundElementTemplateInstance } from '../../../src/element-template/background/instance.js'; +import { profileEnd, profileStart } from '../../../src/element-template/debug/profile.js'; +import { reloadBackground, reloadMainThread } from '../../../src/element-template/native/reload.js'; +import { resetEventStateForRuntime } from '../../../src/element-template/prop-adapters/event.js'; +import { setupPage } from '../../../src/element-template/runtime/page/page.js'; +import { __root, setRoot } from '../../../src/element-template/runtime/page/root-instance.js'; +import { elementTemplateRegistry } from '../../../src/element-template/runtime/template/registry.js'; +import { resetTemplateId } from '../../../src/element-template/runtime/template/handle.js'; +import { + renderMainThread, + resetMainThreadRootRefs, +} from '../../../src/element-template/runtime/render/render-main-thread.js'; +import { render as mockRender } from '../../../src/element-template/runtime/render/render-to-opcodes.js'; +import { renderOpcodesIntoElementTemplate as mockRenderOpcodesIntoElementTemplate } from '../../../src/element-template/runtime/render/render-opcodes.js'; +import { render as preactRender } from 'preact'; + +const mockedState = vi.hoisted(() => ({ + page: undefined as unknown, + root: {} as { __jsx?: unknown; stale?: boolean }, +})); + +vi.mock('../../../src/core/reload-version.js', () => ({ + getReloadVersion: vi.fn(() => 1), + increaseReloadVersion: vi.fn(), +})); + +vi.mock('../../../src/element-template/runtime/page/page.js', () => ({ + get __page() { + return mockedState.page; + }, + setupPage: vi.fn((page: unknown) => { + mockedState.page = page; + }), +})); + +vi.mock('../../../src/element-template/runtime/page/root-instance.js', () => ({ + get __root() { + return mockedState.root; + }, + setRoot: vi.fn((root: typeof mockedState.root) => { + mockedState.root = root; + }), +})); + +vi.mock('../../../src/element-template/runtime/render/render-to-opcodes.js', () => ({ + render: vi.fn(), + registerSlot: vi.fn(), +})); + +vi.mock('../../../src/element-template/runtime/render/render-opcodes.js', () => ({ + renderOpcodesIntoElementTemplate: vi.fn(), +})); + +vi.mock('../../../src/element-template/runtime/template/registry.js', () => ({ + elementTemplateRegistry: { + clear: vi.fn(), + }, +})); + +vi.mock('../../../src/element-template/runtime/template/handle.js', () => ({ + resetTemplateId: vi.fn(), +})); + +vi.mock('../../../src/element-template/background/destroy.js', () => ({ + destroyElementTemplateBackgroundRuntime: vi.fn(), +})); + +vi.mock('../../../src/element-template/background/document.js', () => ({ + setupBackgroundElementTemplateDocument: vi.fn(), +})); + +vi.mock('../../../src/element-template/background/hydration-listener.js', () => ({ + installElementTemplateHydrationListener: vi.fn(), +})); + +vi.mock('../../../src/element-template/prop-adapters/event.js', () => ({ + resetEventStateForRuntime: vi.fn(), +})); + +vi.mock('../../../src/element-template/background/instance.js', () => ({ + BackgroundElementTemplateInstance: class BackgroundElementTemplateInstance { + constructor(public type: string) {} + }, +})); + +vi.mock('../../../src/element-template/debug/profile.js', () => ({ + profileEnd: vi.fn(), + profileStart: vi.fn(), +})); + +vi.mock('preact', () => ({ + render: vi.fn(), +})); + +describe('ElementTemplate reloadMainThread', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMainThreadRootRefs(); + mockedState.page = undefined; + mockedState.root = {}; + vi.stubGlobal('__PROFILE__', false); + vi.stubGlobal('__CreatePage', vi.fn(() => ({ type: 'page', id: '0', children: [] }))); + vi.stubGlobal('__FlushElementTree', vi.fn()); + vi.stubGlobal('__AppendElement', vi.fn()); + vi.stubGlobal('__RemoveElement', vi.fn()); + vi.stubGlobal('__SerializeElementTemplate', vi.fn()); + globalThis.lynx = { + ...(globalThis.lynx ?? {}), + __initData: {}, + reportError: vi.fn(), + getJSContext: vi.fn(() => ({ + dispatchEvent: vi.fn(), + })), + } as typeof lynx; + }); + + afterEach(() => { + resetMainThreadRootRefs(); + vi.unstubAllGlobals(); + }); + + it('rebuilds main-thread ET state and flushes the current page', () => { + const jsx = { type: 'App' }; + const oldRoot = { __jsx: jsx, stale: true }; + mockedState.root = oldRoot; + const initData = { msg: 'init', stable: true }; + lynx.__initData = initData; + const data = { msg: 'reload' }; + const options = { reloadTemplate: true, pipelineOptions: { pipelineID: 'reload-1' } }; + const page = { type: 'page', id: '0', children: [] }; + mockedState.page = page; + const oldRootRef = { type: 'old-ref' } as unknown as ElementRef; + const oldSerializedRoot = { + templateKey: '_et_old', + attributeSlots: [], + elementSlots: [], + uid: -1, + }; + const opcodes = [0, 'opcode']; + const rootRef = { type: 'ref-a' } as unknown as ElementRef; + const serializedRoot = { + templateKey: '_et_reload', + attributeSlots: [], + elementSlots: [], + uid: -1, + }; + const dispatchEvent = vi.fn(); + vi.mocked(mockRender).mockReturnValueOnce(['old-opcode']); + vi.mocked(mockRenderOpcodesIntoElementTemplate).mockReturnValueOnce({ rootRefs: [oldRootRef] }); + vi.mocked(__SerializeElementTemplate).mockReturnValueOnce( + oldSerializedRoot as ReturnType, + ); + (globalThis.lynx as typeof lynx & { getJSContext?: () => { dispatchEvent: typeof dispatchEvent } }) + .getJSContext = vi.fn(() => ({ + dispatchEvent, + })); + renderMainThread(); + + vi.mocked(__AppendElement).mockClear(); + vi.mocked(__SerializeElementTemplate).mockClear(); + vi.mocked(mockRender).mockClear(); + vi.mocked(mockRenderOpcodesIntoElementTemplate).mockClear(); + dispatchEvent.mockClear(); + vi.mocked(mockRender).mockReturnValue(opcodes); + vi.mocked(mockRenderOpcodesIntoElementTemplate).mockReturnValue({ rootRefs: [rootRef] }); + vi.mocked(__SerializeElementTemplate).mockReturnValue( + serializedRoot as ReturnType, + ); + + reloadMainThread(data, options); + + expect(increaseReloadVersion).toHaveBeenCalledTimes(1); + expect(lynx.__initData).toBe(initData); + expect(lynx.__initData).toEqual({ msg: 'reload', stable: true }); + expect(elementTemplateRegistry.clear).toHaveBeenCalledTimes(1); + expect(resetTemplateId).toHaveBeenCalledTimes(1); + expect(__CreatePage).not.toHaveBeenCalled(); + expect(vi.mocked(setupPage)).not.toHaveBeenCalled(); + expect(__RemoveElement).toHaveBeenCalledWith(page, oldRootRef); + expect(vi.mocked(setRoot)).toHaveBeenCalledTimes(1); + expect(__root).not.toBe(oldRoot); + expect(__root.__jsx).toBe(jsx); + expect(__root).not.toHaveProperty('stale'); + expect(mockRender).toHaveBeenCalledWith(jsx, undefined); + expect(mockRenderOpcodesIntoElementTemplate).toHaveBeenCalledWith(opcodes); + expect(__AppendElement).toHaveBeenCalledWith(page, rootRef); + expect(__SerializeElementTemplate).toHaveBeenCalledWith(rootRef); + expect(dispatchEvent).toHaveBeenCalledWith({ + type: 'rLynxElementTemplateHydrate', + data: { + instances: [serializedRoot], + reloadVersion: expect.any(Number), + }, + }); + expect(__FlushElementTree).toHaveBeenCalledWith(page, options); + }); + + it('profiles main-thread reload when profiling is enabled', () => { + vi.stubGlobal('__PROFILE__', true); + mockedState.root = { __jsx: null }; + vi.mocked(__CreatePage).mockReturnValue({ type: 'page', id: '0', children: [] }); + vi.mocked(mockRender).mockReturnValue([]); + vi.mocked(mockRenderOpcodesIntoElementTemplate).mockReturnValue({ rootRefs: [] }); + + reloadMainThread(undefined, { reloadTemplate: true }); + + expect(profileStart).toHaveBeenCalledWith('ReactLynx::reloadMainThread'); + expect(__FlushElementTree).toHaveBeenCalledTimes(1); + }); +}); + +describe('ElementTemplate reloadBackground', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedState.root = {}; + globalThis.lynx = { + ...(globalThis.lynx ?? {}), + __initData: {}, + reportError: vi.fn(), + getJSContext: vi.fn(), + } as typeof lynx; + vi.stubGlobal('__PROFILE__', false); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('destroys old background state, rebuilds root state and renders saved JSX', () => { + const jsx = { type: 'App' }; + const oldRoot = { __jsx: jsx, stale: true }; + mockedState.root = oldRoot; + const initData = { msg: 'init', stable: true }; + lynx.__initData = initData; + const updateData = { msg: 'reload' }; + + reloadBackground(updateData); + + expect(destroyElementTemplateBackgroundRuntime).toHaveBeenCalledTimes(1); + expect(increaseReloadVersion).toHaveBeenCalledTimes(1); + expect(lynx.__initData).not.toBe(initData); + expect(lynx.__initData).toEqual({ msg: 'reload', stable: true }); + expect(vi.mocked(setRoot)).toHaveBeenCalledWith(expect.any(BackgroundElementTemplateInstance)); + expect(__root).toBeInstanceOf(BackgroundElementTemplateInstance); + expect(__root).not.toBe(oldRoot); + expect(__root.__jsx).toBe(jsx); + expect(__root).not.toHaveProperty('stale'); + expect(setupBackgroundElementTemplateDocument).toHaveBeenCalledTimes(1); + expect(installElementTemplateHydrationListener).toHaveBeenCalledTimes(1); + expect(resetEventStateForRuntime).toHaveBeenCalledTimes(1); + expect(preactRender).toHaveBeenCalledWith(jsx, __root); + }); + + it('profiles background reload with the Snapshot reload label', () => { + vi.stubGlobal('__PROFILE__', true); + mockedState.root = { __jsx: null }; + + reloadBackground(undefined); + + expect(profileStart).toHaveBeenCalledWith('ReactLynx::reloadBackground'); + expect(profileEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts index 7a456c24d4..5dce1f6d46 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { options } from 'preact'; import { Component, createElement } from 'preact/compat'; +import { getReloadVersion } from '../../../../src/core/reload-version.js'; import * as elementTemplateAlog from '../../../../src/element-template/debug/alog.js'; import { installElementTemplateCommitHook, @@ -139,6 +140,8 @@ describe('ElementTemplate commit hook', () => { expect(updateEvents[0]).toEqual({ ops: createRawTextOps(1, 'hello'), flushOptions: { nativeUpdateDataOrder: 7 }, + flowIds: undefined, + reloadVersion: getReloadVersion(), }); envManager.switchToBackground(); expect(globalCommitContext.ops).toEqual([]); @@ -155,6 +158,8 @@ describe('ElementTemplate commit hook', () => { expect(updateEvents[0]).toEqual({ ops: [], flushOptions: { triggerDataUpdated: true }, + flowIds: undefined, + reloadVersion: getReloadVersion(), }); envManager.switchToBackground(); expect(globalCommitContext.flushOptions).toEqual({}); @@ -457,6 +462,8 @@ describe('ElementTemplate commit hook', () => { { ops: [], flushOptions: { triggerDataUpdated: true }, + flowIds: undefined, + reloadVersion: getReloadVersion(), }, ]); envManager.switchToBackground(); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/reload.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/reload.test.ts new file mode 100644 index 0000000000..ba9c18ee2e --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/background/reload.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getReloadVersion } from '../../../../src/core/reload-version.js'; +import { setupBackgroundElementTemplateDocument } from '../../../../src/element-template/background/document.js'; +import { + installElementTemplateHydrationListener, + resetElementTemplateHydrationListener, +} from '../../../../src/element-template/background/hydration-listener.js'; +import { BackgroundElementTemplateInstance } from '../../../../src/element-template/background/instance.js'; +import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; +import { reloadBackground } from '../../../../src/element-template/native/reload.js'; +import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; +import type { SerializedElementTemplate } from '../../../../src/element-template/protocol/types.js'; +import { __root, setRoot } from '../../../../src/element-template/runtime/page/root-instance.js'; +import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; + +function createSerializedTemplate(handleId: number, templateKey: string): SerializedElementTemplate { + return { + templateKey, + attributeSlots: [], + elementSlots: [], + uid: handleId, + }; +} + +describe('ElementTemplate background reload', () => { + const envManager = new ElementTemplateEnvManager(); + + beforeEach(() => { + vi.clearAllMocks(); + envManager.resetEnv('background'); + resetElementTemplateHydrationListener(); + backgroundElementTemplateInstanceManager.clear(); + setRoot(new BackgroundElementTemplateInstance('root')); + setupBackgroundElementTemplateDocument(); + installElementTemplateHydrationListener(); + }); + + afterEach(() => { + resetElementTemplateHydrationListener(); + backgroundElementTemplateInstanceManager.clear(); + }); + + it('installs a new hydration listener that consumes post-reload hydrate payloads', () => { + const oldRoot = __root; + oldRoot.__jsx = null; + + reloadBackground({ msg: 'after' }); + + const reloadedRoot = __root as BackgroundElementTemplateInstance; + expect(reloadedRoot).not.toBe(oldRoot); + const after = new BackgroundElementTemplateInstance('_et_test'); + reloadedRoot.appendChild(after); + const oldId = after.instanceId; + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: { + instances: [createSerializedTemplate(-1, '_et_test')], + reloadVersion: getReloadVersion(), + }, + }); + + envManager.switchToBackground(); + expect(backgroundElementTemplateInstanceManager.get(oldId)).toBeUndefined(); + expect(backgroundElementTemplateInstanceManager.get(-1)).toBe(after); + }); +}); 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 4833cff22a..7f57ec339d 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 @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getReloadVersion, increaseReloadVersion } from '../../../../src/core/reload-version.js'; import * as elementTemplateAlog from '../../../../src/element-template/debug/alog.js'; import { globalCommitContext } from '../../../../src/element-template/background/commit-context.js'; import { @@ -104,6 +105,31 @@ describe('ElementTemplate hydration listener', () => { expect(backgroundElementTemplateInstanceManager.get(-2)).toBeUndefined(); }); + it('ignores stale hydrate payloads from before reload', () => { + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_test'); + backgroundRoot.appendChild(after); + const oldId = after.instanceId; + const staleReloadVersion = getReloadVersion(); + increaseReloadVersion(); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: { + instances: [createSerializedTemplate(-1, '_et_test')], + reloadVersion: staleReloadVersion, + }, + }); + + envManager.switchToBackground(); + expect(backgroundElementTemplateInstanceManager.get(oldId)).toBe(after); + expect(backgroundElementTemplateInstanceManager.get(-1)).toBeUndefined(); + }); + it('drops typed roots before typed hydrate support lands', () => { const oldReportError = lynx.reportError; const reportError = vi.fn(); @@ -148,6 +174,7 @@ describe('ElementTemplate hydration listener', () => { try { envManager.switchToBackground(); installElementTemplateHydrationListener(); + const dispatchSpy = vi.spyOn(lynx.getCoreContext(), 'dispatchEvent'); const backgroundRoot = __root as BackgroundElementTemplateInstance; const host = new BackgroundElementTemplateInstance('_et_test'); @@ -166,6 +193,12 @@ describe('ElementTemplate hydration listener', () => { }); envManager.switchToBackground(); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: ElementTemplateLifecycleConstant.update, + data: expect.objectContaining({ + reloadVersion: getReloadVersion(), + }), + }); vi.advanceTimersByTime(9999); expect(backgroundElementTemplateInstanceManager.get(stale.instanceId)).toBe(stale); diff --git a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx index 8941de3e8e..81f436a3f2 100644 --- a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx @@ -22,6 +22,7 @@ import { __root } from '../../../../src/element-template/runtime/page/root-insta import { applyElementTemplateUpdateCommands } from '../../../../src/element-template/runtime/patch.js'; import { elementTemplateRegistry } from '../../../../src/element-template/runtime/template/registry.js'; import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; +import { extractSerializedHydrateInstances } from '../../test-utils/debug/hydratePayload.js'; import { registerBuiltinRawTextTemplate, registerTemplates } from '../../test-utils/debug/registry.js'; import { lastMock } from '../../test-utils/mock/mockNativePapi.js'; import { serializeToJSX } from '../../test-utils/debug/serializer.js'; @@ -87,12 +88,7 @@ describe('ElementTemplate patch stream (apply)', () => { envManager.setUseElementTemplate(true); onHydrate = vi.fn().mockImplementation((event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - for (const item of data) { - hydrationData.push(item as SerializedElementTemplate); - } - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }); lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/patch/update-timing.test.ts b/packages/react/runtime/__test__/element-template/runtime/patch/update-timing.test.ts index 636bec7e40..041a3f2c37 100644 --- a/packages/react/runtime/__test__/element-template/runtime/patch/update-timing.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/patch/update-timing.test.ts @@ -7,6 +7,7 @@ import { import { setPipeline } from '../../../../src/element-template/lynx/performance.js'; import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; +import { getReloadVersion, increaseReloadVersion } from '../../../../src/core/reload-version.js'; import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; import { BUILTIN_RAW_TEXT_TEMPLATE_ID, registerBuiltinRawTextTemplate } from '../../test-utils/debug/registry.js'; @@ -88,4 +89,68 @@ describe('ElementTemplate update timing (main thread patch)', () => { .calls; expect(flushCalls.length).toBeGreaterThan(0); }); + + it('ignores update payloads from an older reload version', () => { + const staleReloadVersion = getReloadVersion(); + increaseReloadVersion(); + const payload = { + ops: createRawTextOps(1, 'stale'), + flushOptions: { pipelineOptions }, + reloadVersion: staleReloadVersion, + }; + + envManager.switchToBackground(() => { + lynx.getCoreContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.update, + data: payload, + }); + }); + envManager.switchToMainThread(); + + const flushCalls = (__FlushElementTree as unknown as { mock: { calls: unknown[][] } }).mock + .calls; + expect(flushCalls).toHaveLength(0); + expect(lynx.performance._markTiming.mock.calls).toEqual([]); + }); + + it('accepts update payloads from the current reload version', () => { + increaseReloadVersion(); + const payload = { + ops: createRawTextOps(1, 'fresh'), + flushOptions: { pipelineOptions }, + reloadVersion: getReloadVersion(), + }; + + envManager.switchToBackground(() => { + lynx.getCoreContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.update, + data: payload, + }); + }); + envManager.switchToMainThread(); + + const flushCalls = (__FlushElementTree as unknown as { mock: { calls: unknown[][] } }).mock + .calls; + expect(flushCalls.length).toBeGreaterThan(0); + expect(flushCalls[0]?.[1]).toMatchObject({ pipelineOptions }); + }); + + it('accepts update payloads without reloadVersion for compatibility', () => { + const payload = { + ops: createRawTextOps(1, 'legacy'), + flushOptions: { pipelineOptions }, + }; + + envManager.switchToBackground(() => { + lynx.getCoreContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.update, + data: payload, + }); + }); + envManager.switchToMainThread(); + + const flushCalls = (__FlushElementTree as unknown as { mock: { calls: unknown[][] } }).mock + .calls; + expect(flushCalls.length).toBeGreaterThan(0); + }); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts index 76d26318e3..1e59d99172 100644 --- a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts @@ -105,12 +105,15 @@ describe('renderMainThread contract', () => { -2, ]); - const dispatched = dispatchEvent.mock.calls[0]?.[0] as { type: string; data: unknown[] } | undefined; + const dispatched = dispatchEvent.mock.calls[0]?.[0] as + | { type: string; data: { instances?: unknown[]; reloadVersion?: unknown } } + | undefined; expect(dispatched?.type).toBe('rLynxElementTemplateHydrate'); - expect(Array.isArray(dispatched?.data)).toBe(true); - expect(dispatched?.data).toHaveLength(1); + expect(Array.isArray(dispatched?.data.instances)).toBe(true); + expect(dispatched?.data.instances).toHaveLength(1); + expect(typeof dispatched?.data.reloadVersion).toBe('number'); - const [rootSerialized] = dispatched!.data as Array>; + const [rootSerialized] = dispatched!.data.instances as Array>; expect(rootSerialized).toMatchObject({ templateKey: '_et_contract_root', attributeSlots: ['main', 'lazy-entry'], diff --git a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts index e4e15ea94a..89f9357152 100644 --- a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { renderMainThread } from '../../../../src/element-template/runtime/render/render-main-thread.js'; +import { getReloadVersion } from '../../../../src/core/reload-version.js'; import { setupPage } from '../../../../src/element-template/runtime/page/page.js'; import { setRoot } from '../../../../src/element-template/runtime/page/root-instance.js'; @@ -98,7 +99,10 @@ describe('renderMainThread', () => { expect(__SerializeElementTemplate).toHaveBeenNthCalledWith(2, rootRefB); expect(dispatchEvent).toHaveBeenCalledWith({ type: 'rLynxElementTemplateHydrate', - data: [serializedA, serializedB], + data: { + instances: [serializedA, serializedB], + reloadVersion: getReloadVersion(), + }, }); }); }); diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/compiledHydrationScenario.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/compiledHydrationScenario.ts index c57be20c49..5f75b458ee 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/compiledHydrationScenario.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/compiledHydrationScenario.ts @@ -11,6 +11,7 @@ import { installMockNativePapi } from '../mock/mockNativePapi.js'; import { compileFixtureSource, type CompiledFixtureTarget } from './compiledFixtureCompiler.js'; import { loadCompiledFixtureModule } from './compiledFixtureModule.js'; import { ElementTemplateEnvManager } from './envManager.js'; +import { extractSerializedHydrateInstances } from './hydratePayload.js'; import { primeCompiledFixtureTemplates } from './compiledFixtureRegistry.js'; import { renderCompiledFixtureOnBackground, renderCompiledFixtureOnMainThread } from './compiledThreadRunner.js'; @@ -47,10 +48,7 @@ export async function runCompiledHydrationScenario( const hydrationData: SerializedElementTemplate[] = []; const onHydrate = vi.fn().mockImplementation((event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - hydrationData.push(...data as SerializedElementTemplate[]); - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }); lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/hydratePayload.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/hydratePayload.ts new file mode 100644 index 0000000000..fa6abeb3f1 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/hydratePayload.ts @@ -0,0 +1,16 @@ +import type { SerializedElementTemplate } from '../../../../src/element-template/protocol/types.js'; + +export function extractSerializedHydrateInstances(data: unknown): SerializedElementTemplate[] { + if (Array.isArray(data)) { + return data as SerializedElementTemplate[]; + } + + if (data !== null && typeof data === 'object') { + const payload = data as { instances?: unknown }; + if (Array.isArray(payload.instances)) { + return payload.instances as SerializedElementTemplate[]; + } + } + + return []; +} diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts index 28f85f9c8e..53c6ff6086 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts @@ -19,6 +19,7 @@ import { import { __page } from '../../../../src/element-template/runtime/page/page.js'; import { __root } from '../../../../src/element-template/runtime/page/root-instance.js'; import { ElementTemplateEnvManager } from './envManager.js'; +import { extractSerializedHydrateInstances } from './hydratePayload.js'; import { lastMock } from '../mock/mockNativePapi.js'; import { serializeBackgroundTree, serializeToJSX } from './serializer.js'; @@ -158,12 +159,7 @@ export function runElementTemplateUpdate(options: UpdateRunOptions): UpdateRunRe envManager.resetEnv('background'); envManager.setUseElementTemplate(true); const onHydrate = (event: { data: unknown }) => { - const data = event.data; - if (Array.isArray(data)) { - for (const item of data) { - hydrationData.push(item as SerializedElementTemplate); - } - } + hydrationData.push(...extractSerializedHydrateInstances(event.data)); }; lynx.getCoreContext().addEventListener(ElementTemplateLifecycleConstant.hydrate, onHydrate); diff --git a/packages/react/runtime/src/core/lynx-page-data.ts b/packages/react/runtime/src/core/lynx-page-data.ts index d93beb9208..14773e2a9f 100644 --- a/packages/react/runtime/src/core/lynx-page-data.ts +++ b/packages/react/runtime/src/core/lynx-page-data.ts @@ -3,16 +3,12 @@ // LICENSE file in the root directory of this source tree. import { isEmptyObject } from '../utils.js'; -function isNonEmptyObjectData(data: unknown): data is Record { - return typeof data == 'object' && data !== null && !isEmptyObject(data); -} - export function applyUpdatePageData(data: unknown, options?: Pick): void { if (options?.resetPageData) { lynx.__initData = {}; } - if (isNonEmptyObjectData(data)) { + if (typeof data == 'object' && data !== null && !isEmptyObject(data)) { lynx.__initData ??= {}; Object.assign(lynx.__initData, data); } diff --git a/packages/react/runtime/src/snapshot/lifecycle/pass.ts b/packages/react/runtime/src/core/reload-version.ts similarity index 52% rename from packages/react/runtime/src/snapshot/lifecycle/pass.ts rename to packages/react/runtime/src/core/reload-version.ts index 310bd6ce07..f478564a7b 100644 --- a/packages/react/runtime/src/snapshot/lifecycle/pass.ts +++ b/packages/react/runtime/src/core/reload-version.ts @@ -1,14 +1,13 @@ -// Copyright 2024 The Lynx Authors. All rights reserved. +// Copyright 2026 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. + let reloadVersion = 0; -function getReloadVersion(): number { +export function getReloadVersion(): number { return reloadVersion; } -function increaseReloadVersion(): number { +export function increaseReloadVersion(): number { return ++reloadVersion; } - -export { getReloadVersion, increaseReloadVersion }; diff --git a/packages/react/runtime/src/element-template/background/commit-hook.ts b/packages/react/runtime/src/element-template/background/commit-hook.ts index 7760413a7f..e756643622 100644 --- a/packages/react/runtime/src/element-template/background/commit-hook.ts +++ b/packages/react/runtime/src/element-template/background/commit-hook.ts @@ -10,6 +10,7 @@ import { takeRemovedSubtreesForPostDispatchTeardown, } from './commit-context.js'; import type { BackgroundElementTemplateInstance } from './instance.js'; +import { getReloadVersion } from '../../core/reload-version.js'; import { COMMIT } from '../../shared/render-constants.js'; import { hook, isEmptyObject } from '../../utils.js'; import { formatElementTemplateUpdateCommands } from '../debug/alog.js'; @@ -122,6 +123,7 @@ export function installElementTemplateCommitHook(): void { ops: globalCommitContext.ops, flushOptions: globalCommitContext.flushOptions, flowIds: globalCommitContext.flowIds, + reloadVersion: getReloadVersion(), }, }); } diff --git a/packages/react/runtime/src/element-template/background/hydration-listener.ts b/packages/react/runtime/src/element-template/background/hydration-listener.ts index d301d6a32e..287ac12de3 100644 --- a/packages/react/runtime/src/element-template/background/hydration-listener.ts +++ b/packages/react/runtime/src/element-template/background/hydration-listener.ts @@ -13,13 +13,18 @@ import { } from './commit-hook.js'; import { hydrateIntoContext } from './hydrate.js'; import { BackgroundElementTemplateInstance } from './instance.js'; +import { getReloadVersion } from '../../core/reload-version.js'; import { formatElementTemplateUpdateCommands, printElementTemplateTreeToString } from '../debug/alog.js'; import { profileEnd, profileStart } from '../debug/profile.js'; import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } from '../lynx/performance.js'; import { clearPendingEvents, flushPendingEvents } from '../prop-adapters/event.js'; import { clearDelayedRefUiOps, clearPendingRefs, flushDelayedRefUiOps } from '../prop-adapters/ref.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; -import type { SerializedElementTemplate, SerializedEtNode } from '../protocol/types.js'; +import type { + ElementTemplateHydrateCommitContext, + SerializedElementTemplate, + SerializedEtNode, +} from '../protocol/types.js'; import { __root } from '../runtime/page/root-instance.js'; let listener: @@ -32,12 +37,22 @@ export function installElementTemplateHydrationListener(): void { listener = (event: { data: unknown }) => { const { data } = event; + let instances: SerializedEtNode[]; + if (Array.isArray(data)) { + instances = data as SerializedEtNode[]; + } else { + const payload = data as ElementTemplateHydrateCommitContext; + if (typeof payload.reloadVersion === 'number' && payload.reloadVersion < getReloadVersion()) { + return; + } + instances = payload.instances; + } + if (__PROFILE__) { profileStart('ReactLynx::hydrate'); } beginPipeline(true, PipelineOrigins.reactLynxHydrate, PerformanceTimingFlags.reactLynxHydrate); markTiming('hydrateParsePayloadStart'); - const instances = data as SerializedEtNode[]; markTiming('hydrateParsePayloadEnd'); markTiming('diffVdomStart'); @@ -125,6 +140,7 @@ export function installElementTemplateHydrationListener(): void { ops: globalCommitContext.ops, flushOptions: globalCommitContext.flushOptions, flowIds: globalCommitContext.flowIds, + reloadVersion: getReloadVersion(), }, }); didDispatchHydrateUpdate = true; diff --git a/packages/react/runtime/src/element-template/native/index.ts b/packages/react/runtime/src/element-template/native/index.ts index fc62dc06c8..3114144bf5 100644 --- a/packages/react/runtime/src/element-template/native/index.ts +++ b/packages/react/runtime/src/element-template/native/index.ts @@ -6,6 +6,7 @@ import { callDestroyLifetimeFun } from './callDestroyLifetimeFun.js'; import { injectCalledByNative } from './main-thread-api.js'; import { installOnMtsDestruction } from './mts-destroy.js'; import { installElementTemplatePatchListener } from './patch-listener.js'; +import { reloadBackground } from './reload.js'; import { installMainThreadHooks } from '../../core/hooks/mainThreadImpl.js'; import { updateCardData } from '../../core/lynx-update-data.js'; import { installElementTemplateCommitHook } from '../background/commit-hook.js'; @@ -44,6 +45,7 @@ function init(): void { lynxCoreInject.tt.publishEvent = publishEvent; lynxCoreInject.tt.publicComponentEvent = publicComponentEvent; lynxCoreInject.tt.updateCardData = updateCardData; + lynxCoreInject.tt.onAppReload = reloadBackground; installElementTemplateCommitHook(); if (process.env['NODE_ENV'] !== 'test') { initTimingAPI(); diff --git a/packages/react/runtime/src/element-template/native/main-thread-api.ts b/packages/react/runtime/src/element-template/native/main-thread-api.ts index dacf8a3f87..09b15cb527 100644 --- a/packages/react/runtime/src/element-template/native/main-thread-api.ts +++ b/packages/react/runtime/src/element-template/native/main-thread-api.ts @@ -2,9 +2,10 @@ // 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 { reloadMainThread } from './reload.js'; import { applyUpdatePageData } from '../../core/lynx-page-data.js'; import { __page, setupPage } from '../runtime/page/page.js'; -import { renderMainThread } from '../runtime/render/render-main-thread.js'; +import { renderMainThread, resetMainThreadRootRefs } from '../runtime/render/render-main-thread.js'; function injectCalledByNative(): void { const calledByNative: LynxCallByNative = { @@ -23,11 +24,17 @@ function injectCalledByNative(): void { function renderPage(data: Record | undefined): void { lynx.__initData = data ?? {}; setupPage(__CreatePage('0', 0)); + resetMainThreadRootRefs(); renderMainThread(); } function updatePage(data: Record | undefined, options?: UpdatePageOption): void { - if (__FIRST_SCREEN_SYNC_TIMING__ !== 'immediately' || options?.reloadTemplate) { + if (__FIRST_SCREEN_SYNC_TIMING__ !== 'immediately') { + return; + } + + if (options?.reloadTemplate) { + reloadMainThread(data, options); return; } diff --git a/packages/react/runtime/src/element-template/native/patch-listener.ts b/packages/react/runtime/src/element-template/native/patch-listener.ts index 643de97d38..3234a2672c 100644 --- a/packages/react/runtime/src/element-template/native/patch-listener.ts +++ b/packages/react/runtime/src/element-template/native/patch-listener.ts @@ -2,6 +2,7 @@ // 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 { getReloadVersion } from '../../core/reload-version.js'; import { formatElementTemplateUpdateCommands } from '../debug/alog.js'; import { markTiming, setPipeline } from '../lynx/performance.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; @@ -19,6 +20,10 @@ export function installElementTemplatePatchListener(): void { listener = (event: { data: unknown }) => { const { data } = event; const payload = data as ElementTemplateUpdateCommitContext; + if (typeof payload?.reloadVersion === 'number' && payload.reloadVersion < getReloadVersion()) { + return; + } + const hasOps = Array.isArray(payload?.ops) && payload.ops.length > 0; const flushOptions = payload?.flushOptions ?? {}; const pipelineOptions = flushOptions.pipelineOptions; diff --git a/packages/react/runtime/src/element-template/native/reload.ts b/packages/react/runtime/src/element-template/native/reload.ts new file mode 100644 index 0000000000..71cddc4678 --- /dev/null +++ b/packages/react/runtime/src/element-template/native/reload.ts @@ -0,0 +1,73 @@ +// Copyright 2026 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 type { ComponentChild, ContainerNode } from 'preact'; +import { render } from 'preact'; + +import { increaseReloadVersion } from '../../core/reload-version.js'; +import { isEmptyObject } from '../../utils.js'; +import { destroyElementTemplateBackgroundRuntime } from '../background/destroy.js'; +import { setupBackgroundElementTemplateDocument } from '../background/document.js'; +import { installElementTemplateHydrationListener } from '../background/hydration-listener.js'; +import { BackgroundElementTemplateInstance } from '../background/instance.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; +import { resetEventStateForRuntime } from '../prop-adapters/event.js'; +import { __page } from '../runtime/page/page.js'; +import { __root, setRoot } from '../runtime/page/root-instance.js'; +import { removeMainThreadRootRefs, renderMainThread } from '../runtime/render/render-main-thread.js'; +import { resetTemplateId } from '../runtime/template/handle.js'; +import { elementTemplateRegistry } from '../runtime/template/registry.js'; + +export function reloadMainThread(data: unknown, options: UpdatePageOption): void { + if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { + profileStart('ReactLynx::reloadMainThread'); + } + + try { + increaseReloadVersion(); + if (typeof data == 'object' && data !== null && !isEmptyObject(data)) { + Object.assign(lynx.__initData, data); + } + + elementTemplateRegistry.clear(); + resetTemplateId(); + + const oldRoot = __root; + removeMainThreadRootRefs(); + setRoot({ __jsx: oldRoot.__jsx }); + renderMainThread(); + + __FlushElementTree(__page, options); + } finally { + if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { + profileEnd(); + } + } +} + +export function reloadBackground(updateData: unknown): void { + if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { + profileStart('ReactLynx::reloadBackground'); + } + + try { + const jsx = __root.__jsx; + destroyElementTemplateBackgroundRuntime(); + increaseReloadVersion(); + // Reload creates a new object so InitData Provider / Consumer observers do + // not retain the pre-reload object identity. + lynx.__initData = Object.assign({}, lynx.__initData, updateData); + + setRoot(new BackgroundElementTemplateInstance('root')); + __root.__jsx = jsx; + setupBackgroundElementTemplateDocument(); + installElementTemplateHydrationListener(); + resetEventStateForRuntime(); + render(jsx as ComponentChild, __root as unknown as ContainerNode); + } finally { + if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { + profileEnd(); + } + } +} diff --git a/packages/react/runtime/src/element-template/protocol/types.ts b/packages/react/runtime/src/element-template/protocol/types.ts index 51cf1ee884..8059826b71 100644 --- a/packages/react/runtime/src/element-template/protocol/types.ts +++ b/packages/react/runtime/src/element-template/protocol/types.ts @@ -63,6 +63,11 @@ export interface SerializedTypedNode extends SerializedEtNodeBase { export type SerializedEtNode = SerializedCompiledNode | SerializedTypedNode; +export interface ElementTemplateHydrateCommitContext { + instances: SerializedEtNode[]; + reloadVersion?: number; +} + // Current hydrate/update code is still compiled-node only. Keep this recursive // shape narrow while the RFC-level SerializedEtNode already models typed nodes. export interface SerializedElementTemplate { @@ -148,4 +153,5 @@ export interface ElementTemplateUpdateCommitContext { ops: ElementTemplateUpdateCommandStream; flushOptions: ElementTemplateFlushOptions; flowIds?: number[]; + reloadVersion?: number; } diff --git a/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts b/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts index 00e461c788..05ee9953f6 100644 --- a/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts +++ b/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts @@ -8,15 +8,31 @@ import { renderOpcodesIntoElementTemplate } from './render-opcodes.js'; import { render as renderToString } from './render-to-opcodes.js'; +import { getReloadVersion } from '../../../core/reload-version.js'; import { profileEnd, profileStart } from '../../debug/profile.js'; import { ElementTemplateLifecycleConstant } from '../../protocol/lifecycle-constant.js'; -import type { SerializedEtNode } from '../../protocol/types.js'; +import type { ElementTemplateHydrateCommitContext, SerializedEtNode } from '../../protocol/types.js'; import { __page } from '../page/page.js'; import { __root } from '../page/root-instance.js'; +// ET reload reuses the native page, so the main-thread render path owns the +// root refs it appended and can remove only those roots before rebuilding. +let mainThreadRootRefs: ElementRef[] = []; + +function resetMainThreadRootRefs(): void { + mainThreadRootRefs = []; +} + +function removeMainThreadRootRefs(): void { + const rootRefs = mainThreadRootRefs; + mainThreadRootRefs = []; + for (const rootRef of rootRefs) { + __RemoveElement(__page, rootRef); + } +} + function renderMainThread(): void { let opcodes; - let rootRefs: ElementRef[] = []; profileStart('ReactLynx::renderMainThread'); try { opcodes = renderToString(__root.__jsx, undefined); @@ -29,10 +45,11 @@ function renderMainThread(): void { profileStart('ReactLynx::renderOpcodes'); try { - rootRefs = renderOpcodesIntoElementTemplate(opcodes).rootRefs; + const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); for (const rootRef of rootRefs) { __AppendElement(__page, rootRef); } + mainThreadRootRefs = rootRefs; } finally { profileEnd(); } @@ -40,17 +57,21 @@ function renderMainThread(): void { profileStart('ReactLynx::packSerializedETInstance'); try { const instances: SerializedEtNode[] = []; - for (const rootRef of rootRefs) { + for (const rootRef of mainThreadRootRefs) { instances.push(__SerializeElementTemplate(rootRef)); } + const payload: ElementTemplateHydrateCommitContext = { + instances, + reloadVersion: getReloadVersion(), + }; lynx.getJSContext().dispatchEvent({ type: ElementTemplateLifecycleConstant.hydrate, - data: instances, + data: payload, }); } finally { profileEnd(); } } -export { renderMainThread }; +export { removeMainThreadRootRefs, renderMainThread, resetMainThreadRootRefs }; diff --git a/packages/react/runtime/src/element-template/runtime/template/handle.ts b/packages/react/runtime/src/element-template/runtime/template/handle.ts index bc1c50932f..3bef45f3a0 100644 --- a/packages/react/runtime/src/element-template/runtime/template/handle.ts +++ b/packages/react/runtime/src/element-template/runtime/template/handle.ts @@ -37,5 +37,4 @@ export function resetTemplateId(): void { export function destroyElementTemplateId(id: number): void { deleteElementTemplateNativeRef(id); - // __ReleaseElement(nativeRef); } diff --git a/packages/react/runtime/src/snapshot/lifecycle/patch/commit.ts b/packages/react/runtime/src/snapshot/lifecycle/patch/commit.ts index ba20e6d3a6..322be47b55 100644 --- a/packages/react/runtime/src/snapshot/lifecycle/patch/commit.ts +++ b/packages/react/runtime/src/snapshot/lifecycle/patch/commit.ts @@ -30,6 +30,7 @@ import { import { takeGlobalSnapshotPatch } from './snapshotPatch.js'; import type { SnapshotPatch } from './snapshotPatch.js'; import { takeGlobalFlushOptions } from '../../../core/commit-context.js'; +import { getReloadVersion } from '../../../core/reload-version.js'; import { profileEnd, profileStart } from '../../../shared/profile.js'; import { COMMIT } from '../../../shared/render-constants.js'; import { hook, isEmptyObject } from '../../../utils.js'; @@ -43,7 +44,6 @@ import { } from '../../worklet/call/delayedRunOnMainThreadData.js'; import { sendMTRefInitValueToMainThread } from '../../worklet/ref/updateInitValue.js'; import { isRendering } from '../isRendering.js'; -import { getReloadVersion } from '../pass.js'; const globalCommitTaskMap: Map void> = /*@__PURE__*/ new Map void>(); let nextCommitTaskId = 1; diff --git a/packages/react/runtime/src/snapshot/lifecycle/patch/updateMainThread.ts b/packages/react/runtime/src/snapshot/lifecycle/patch/updateMainThread.ts index 52ec94d6ef..408ad328b2 100644 --- a/packages/react/runtime/src/snapshot/lifecycle/patch/updateMainThread.ts +++ b/packages/react/runtime/src/snapshot/lifecycle/patch/updateMainThread.ts @@ -8,6 +8,7 @@ import { runRunOnMainThreadTask, setEomShouldFlushElementTree } from '@lynx-js/r import type { PatchList, PatchOptions } from './commit.js'; import { setMainThreadHydrating } from './isMainThreadHydrating.js'; import { snapshotPatchApply } from './snapshotPatchApply.js'; +import { getReloadVersion } from '../../../core/reload-version.js'; import { prettyFormatSnapshotPatch } from '../../debug/formatPatch.js'; import { LifecycleConstant } from '../../lifecycle/constant.js'; import { __pendingListUpdates } from '../../list/pendingListUpdates.js'; @@ -15,7 +16,6 @@ import { markTiming, setPipeline } from '../../lynx/performance.js'; import { __page } from '../../snapshot/definition.js'; import { applyRefQueue } from '../../snapshot/workletRef.js'; import { isMtsEnabled } from '../../worklet/functionality.js'; -import { getReloadVersion } from '../pass.js'; function updateMainThread( { data, patchOptions }: { diff --git a/packages/react/runtime/src/snapshot/lifecycle/reload.ts b/packages/react/runtime/src/snapshot/lifecycle/reload.ts index 5d8b1bbafe..fe5eb83e57 100644 --- a/packages/react/runtime/src/snapshot/lifecycle/reload.ts +++ b/packages/react/runtime/src/snapshot/lifecycle/reload.ts @@ -10,8 +10,8 @@ import { render } from 'preact'; import { destroyBackground } from './destroy.js'; -import { increaseReloadVersion } from './pass.js'; import { renderMainThread } from './render.js'; +import { increaseReloadVersion } from '../../core/reload-version.js'; import { __root, setRoot } from '../../root.js'; import { profileEnd, profileStart } from '../../shared/profile.js'; import { isEmptyObject } from '../../utils.js'; @@ -32,7 +32,7 @@ function reloadMainThread(data: unknown, options: UpdatePageOption): void { increaseReloadVersion(); - if (typeof data == 'object' && !isEmptyObject(data as Record)) { + if (typeof data == 'object' && data !== null && !isEmptyObject(data)) { Object.assign(lynx.__initData, data); } From cb6a0bf71f08134ef7b82252b3c91a02d8c59bc6 Mon Sep 17 00:00:00 2001 From: Yradex <11014207+Yradex@users.noreply.github.com> Date: Thu, 21 May 2026 17:38:29 +0800 Subject: [PATCH 4/5] fix(runtime): align ET typed page contract --- .../element-template/debug/alog.test.ts | 2 + .../debug/elementPAPICall.test.ts | 14 ++++++- .../fixtures/page/render-page/native-log.txt | 13 +++++-- .../fixtures/render/child-siblings/papi.txt | 16 ++++++-- .../render/component-slot-content/papi.txt | 16 ++++++-- .../fixtures/render/component/papi.txt | 16 ++++++-- .../render/mapped-view-children/papi.txt | 16 ++++++-- .../fixtures/render/mixed-children/papi.txt | 16 ++++++-- .../fixtures/render/multiple-text/papi.txt | 16 ++++++-- .../fixtures/render/nested-templates/papi.txt | 16 ++++++-- .../opcodes-into-element-template/_shared.ts | 4 +- .../case.ts | 2 +- .../native-log.txt | 14 ++++++- .../case.ts | 2 +- .../native-log.txt | 14 ++++++- .../case.ts | 2 +- .../native-log.txt | 14 ++++++- .../case.ts | 2 +- .../native-log.txt | 14 ++++++- .../ignores-non-attrs-opcode-payloads/case.ts | 2 +- .../native-log.txt | 14 ++++++- .../case.ts | 2 +- .../native-log.txt | 14 ++++++- .../case.ts | 2 +- .../native-log.txt | 14 ++++++- .../fixtures/render/react-example/papi.txt | 16 ++++++-- .../native/main-thread-api.test.ts | 15 +++++--- .../element-template/native/reload.test.ts | 19 ++++++---- .../runtime/page/page.test.ts | 37 +++++++++++++++++++ .../patch/element-template-patch.test.tsx | 17 ++++++--- .../render-main-thread.contract.test.ts | 2 +- .../runtime/render/render-main-thread.test.ts | 20 ++++++++-- .../test-utils/debug/renderFixtureRunner.ts | 9 ++--- .../test-utils/debug/updateRunner.test.tsx | 2 + .../test-utils/debug/updateRunner.ts | 2 + .../test-utils/mock/mockNativePapi.ts | 36 ++---------------- .../mock/mockNativePapi/templateTree.ts | 7 ++-- .../src/element-template/debug/alog.ts | 2 + .../element-template/debug/elementPAPICall.ts | 3 ++ .../native/main-thread-api.ts | 4 +- .../src/element-template/protocol/types.ts | 5 +++ .../src/element-template/runtime/page/page.ts | 20 +++++++++- .../src/element-template/runtime/patch.ts | 3 ++ .../runtime/render/render-main-thread.ts | 6 +-- .../runtime/src/element-template/types.d.ts | 2 + 45 files changed, 360 insertions(+), 124 deletions(-) create mode 100644 packages/react/runtime/__test__/element-template/runtime/page/page.test.ts 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 2bbc148524..01270c4336 100644 --- a/packages/react/runtime/__test__/element-template/debug/alog.test.ts +++ b/packages/react/runtime/__test__/element-template/debug/alog.test.ts @@ -29,6 +29,7 @@ describe('ElementTemplate alog helpers', () => { ElementTemplateUpdateOps.createTypedElement, 13, 'list', + { id: 'typed-list' }, [[12]], { listChildren: [{ __etHandleRef: 12 }] }, ElementTemplateUpdateOps.setAttribute, @@ -64,6 +65,7 @@ describe('ElementTemplate alog helpers', () => { op: 'createTypedElement', handleId: 13, type: 'list', + attributes: { id: 'typed-list' }, elementSlots: [[12]], options: { listChildren: [{ __etHandleRef: 12 }] }, }, diff --git a/packages/react/runtime/__test__/element-template/debug/elementPAPICall.test.ts b/packages/react/runtime/__test__/element-template/debug/elementPAPICall.test.ts index 0fc9be43a8..db7338f075 100644 --- a/packages/react/runtime/__test__/element-template/debug/elementPAPICall.test.ts +++ b/packages/react/runtime/__test__/element-template/debug/elementPAPICall.test.ts @@ -21,6 +21,7 @@ describe('ElementTemplate PAPI alog wrapper', () => { it('wraps ET PAPI calls and formats native refs', () => { const templateRef = { id: 1 }; const childRef = { id: 2 }; + const typedRef = { id: 'page' }; const circular: Record = {}; circular['self'] = circular; const jsonUndefined = { toJSON: () => undefined }; @@ -30,6 +31,7 @@ describe('ElementTemplate PAPI alog wrapper', () => { const target = { __CreateElementTemplate: vi.fn(() => templateRef), + __CreateTypedElementTemplate: vi.fn(() => typedRef), __SetAttributeOfElementTemplate: vi.fn(), __InsertNodeToElementTemplate: vi.fn(() => childRef), __RemoveNodeFromElementTemplate: vi.fn(() => null), @@ -45,6 +47,13 @@ describe('ElementTemplate PAPI alog wrapper', () => { [], 17, )).toBe(templateRef); + expect((target.__CreateTypedElementTemplate as (...args: unknown[]) => unknown)( + 'page', + null, + null, + '0', + null, + )).toBe(typedRef); (target.__SetAttributeOfElementTemplate as (...args: unknown[]) => unknown)( templateRef, 0, @@ -81,6 +90,7 @@ describe('ElementTemplate PAPI alog wrapper', () => { expect(logs).toContain( '__CreateElementTemplate("_et_card", null, ["title"], [], 17) => _et_card#17', ); + expect(logs).toContain('__CreateTypedElementTemplate("page", null, null, "0", null) => page#0'); expect(logs).toContain( '__SetAttributeOfElementTemplate(_et_card#17, 0, [_et_card#17, undefined, null, [Function namedHandler], [Function], Symbol(slot), [object Object], [object Object]], null)', ); @@ -89,8 +99,8 @@ describe('ElementTemplate PAPI alog wrapper', () => { ); expect(logs).toContain('__RemoveNodeFromElementTemplate(_et_card#17, 1, {"id":2})'); expect(logs).toContain('__SerializeElementTemplate(_et_card#17) => Symbol(serialized)'); - expect(globalThis.lynx.performance.profileStart).toHaveBeenCalledTimes(5); - expect(globalThis.lynx.performance.profileEnd).toHaveBeenCalledTimes(5); + expect(globalThis.lynx.performance.profileStart).toHaveBeenCalledTimes(6); + expect(globalThis.lynx.performance.profileEnd).toHaveBeenCalledTimes(6); }); it('skips missing APIs and keeps logging optional', () => { diff --git a/packages/react/runtime/__test__/element-template/fixtures/page/render-page/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/page/render-page/native-log.txt index cc5ce5d91a..21c752ff1c 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/page/render-page/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/page/render-page/native-log.txt @@ -1,8 +1,11 @@ Array [ Array [ - "__CreatePage", + "__CreateTypedElementTemplate", + "page", + null, + null, "0", - 0, + null, ], Array [ "__CreateElementTemplate", @@ -13,8 +16,10 @@ Array [ -1, ], Array [ - "__AppendElement", - "0", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_a94a8_test_1 />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/papi.txt index 1b3de5b6aa..169b8ba0b9 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/child-siblings/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_7a8c6_test_2", @@ -111,8 +119,10 @@ -5 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/papi.txt index 731fdd9b09..757600cbfc 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/component-slot-content/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_7a8c6_test_3", @@ -68,8 +76,10 @@ -3 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_2 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_2 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/component/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/component/papi.txt index 65aa26d3f2..991efc9a27 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/component/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/component/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -50,8 +58,10 @@ -3 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/papi.txt index 834a50bab2..ecdb16b735 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/mapped-view-children/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -197,8 +205,10 @@ -7 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/papi.txt index 9c60d3234c..2b082931b6 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/mixed-children/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -69,8 +77,10 @@ -4 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/papi.txt index f7855d1706..267d8f001d 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/multiple-text/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -437,8 +445,10 @@ -19 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/papi.txt index 2f6c169c25..32cb2dba43 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/nested-templates/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -63,8 +71,10 @@ -3 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_2 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_2 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/_shared.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/_shared.ts index be68656234..39368090ac 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/_shared.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/_shared.ts @@ -14,7 +14,7 @@ import { installMockNativePapi } from '../../../test-utils/mock/mockNativePapi.j import { registerBuiltinRawTextTemplate, registerTemplates } from '../../../test-utils/debug/registry.js'; export interface RootNode { - type: 'root'; + type: 'page'; children?: unknown[]; } @@ -114,7 +114,7 @@ function setup(): CaseContext { registerTemplates(templates); return { - root: { type: 'root' }, + root: __CreateTypedElementTemplate('page', null, null, '0', null) as unknown as RootNode, nativeLog: installed.nativeLog, }; } diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/case.ts index 097b882590..ef0be80c3b 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/case.ts @@ -5,7 +5,7 @@ export function run() { const opcodes = [__OpText, 'root']; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); return { output: { diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/native-log.txt index 9414e0f9d4..65665d3c6d 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/appends-root-text-via-append-element/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -10,8 +18,10 @@ Array [ -1, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_builtin_raw_text />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/case.ts index 9c1098bcf9..98fec56247 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/case.ts @@ -24,7 +24,7 @@ export function run() { ]; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); const rootChild = root.children?.[0]; diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/native-log.txt index 237719e25c..0024b18f59 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/creates-template-from-attrs-and-slot-text/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -32,8 +40,10 @@ Array [ -2, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_foo />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/case.ts index aa149b118b..aa6fee2ba4 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/case.ts @@ -17,7 +17,7 @@ export function run() { ]; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); const slotChildren = root.children?.[0]?.children?.[0]?.children ?? []; return { diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/native-log.txt index 09070bbf85..e71e1d3b4e 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-template-children-in-the-same-slot/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_child_a", @@ -39,8 +47,10 @@ Array [ -3, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_parent />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/case.ts index 98a9c8b66d..5e374e5ca8 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/case.ts @@ -15,7 +15,7 @@ export function run() { ]; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); return { output: { diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/native-log.txt index e45fdf9f86..a2b2d5ca2a 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/handles-multiple-text-nodes-in-the-same-slot/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -47,8 +55,10 @@ Array [ -3, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_parent />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/case.ts index 83c578f8d6..65dc4e486e 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/case.ts @@ -12,7 +12,7 @@ export function run() { ]; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); return { output: { diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/native-log.txt index 5fbfd98075..be8eec4cd6 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/ignores-non-attrs-opcode-payloads/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_foo", @@ -8,8 +16,10 @@ Array [ -1, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_foo />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/case.ts index ea4cc94111..33abb6c92d 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/case.ts @@ -29,7 +29,7 @@ export function run() { ]; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); return { output: { diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/native-log.txt index c234c6ca14..0407bd0c1e 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/inserts-nested-templates-into-parent-slots/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -67,8 +75,10 @@ Array [ -3, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_outer />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/case.ts b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/case.ts index f4787977e2..39875fe2ce 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/case.ts +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/case.ts @@ -17,7 +17,7 @@ export function run() { ]; const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); - rootRefs.forEach(rootRef => __AppendElement(root as FiberElement, rootRef)); + rootRefs.forEach(rootRef => __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null)); return { output: { diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/native-log.txt b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/native-log.txt index b818ce1f12..fe4b9209a6 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/native-log.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/opcodes-into-element-template/keeps-slot-children-separated-and-ordered/native-log.txt @@ -1,4 +1,12 @@ Array [ + Array [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null, + ], Array [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -49,8 +57,10 @@ Array [ -3, ], Array [ - "__AppendElement", - "root", + "__InsertNodeToElementTemplate", + "", + 0, "<_et_foo />", + null, ], ] diff --git a/packages/react/runtime/__test__/element-template/fixtures/render/react-example/papi.txt b/packages/react/runtime/__test__/element-template/fixtures/render/react-example/papi.txt index 72c5d6be9c..1b39abdace 100644 --- a/packages/react/runtime/__test__/element-template/fixtures/render/react-example/papi.txt +++ b/packages/react/runtime/__test__/element-template/fixtures/render/react-example/papi.txt @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_7a8c6_test_3", @@ -53,8 +61,10 @@ -3 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts b/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts index 17621e1497..ec5e4beac5 100644 --- a/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts +++ b/packages/react/runtime/__test__/element-template/native/main-thread-api.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { injectCalledByNative } from '../../../src/element-template/native/main-thread-api.js'; import { reloadMainThread } from '../../../src/element-template/native/reload.js'; -import { setupPage } from '../../../src/element-template/runtime/page/page.js'; +import { createElementTemplatePage, setupPage } from '../../../src/element-template/runtime/page/page.js'; import { renderMainThread, resetMainThreadRootRefs, @@ -16,6 +16,7 @@ vi.mock('../../../src/element-template/runtime/page/page.js', () => ({ get __page() { return mockedPageModuleState.page; }, + createElementTemplatePage: vi.fn(() => ({ type: 'page', id: '0', children: [] })), setupPage: vi.fn((page: unknown) => { mockedPageModuleState.page = page; }), @@ -34,7 +35,9 @@ describe('injectCalledByNative', () => { beforeEach(() => { mockedPageModuleState.page = undefined; globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; - vi.stubGlobal('__CreatePage', vi.fn(() => ({ type: 'page', id: '0', children: [] }))); + vi.mocked(createElementTemplatePage).mockReturnValue( + { type: 'page', id: '0', children: [] } as unknown as ElementRef, + ); vi.stubGlobal('__FlushElementTree', vi.fn()); (globalThis as typeof globalThis & { lynx: typeof lynx & { __initData?: unknown } }).lynx = { ...(globalThis.lynx ?? {}), @@ -68,7 +71,7 @@ describe('injectCalledByNative', () => { globalAny.renderPage({ answer: 42 }); expect(globalThis.lynx.__initData).toEqual({ answer: 42 }); - expect(__CreatePage).toHaveBeenCalledWith('0', 0); + expect(vi.mocked(createElementTemplatePage)).toHaveBeenCalledTimes(1); expect(vi.mocked(setupPage)).toHaveBeenCalledWith({ type: 'page', id: '0', children: [] }); expect(vi.mocked(resetMainThreadRootRefs)).toHaveBeenCalledTimes(1); expect(vi.mocked(renderMainThread)).toHaveBeenCalledTimes(1); @@ -81,7 +84,7 @@ describe('injectCalledByNative', () => { updatePage: (data?: Record, options?: UpdatePageOption) => void; }; const page = { type: 'page', id: '0', children: [] }; - vi.mocked(__CreatePage).mockReturnValue(page); + vi.mocked(createElementTemplatePage).mockReturnValue(page as unknown as ElementRef); globalAny.renderPage({ msg: 'init', stable: true }); vi.mocked(renderMainThread).mockClear(); @@ -101,7 +104,7 @@ describe('injectCalledByNative', () => { updatePage: (data?: Record, options?: UpdatePageOption) => void; }; const page = { type: 'page', id: '0', children: [] }; - vi.mocked(__CreatePage).mockReturnValue(page); + vi.mocked(createElementTemplatePage).mockReturnValue(page as unknown as ElementRef); globalAny.renderPage({ stale: true, msg: 'init' }); globalAny.updatePage({ msg: 'reset' }, { resetPageData: true }); @@ -117,7 +120,7 @@ describe('injectCalledByNative', () => { updatePage: (data?: Record, options?: UpdatePageOption) => void; }; const page = { type: 'page', id: '0', children: [] }; - vi.mocked(__CreatePage).mockReturnValue(page); + vi.mocked(createElementTemplatePage).mockReturnValue(page as unknown as ElementRef); globalAny.renderPage({ msg: 'init' }); globalAny.updatePage({}); diff --git a/packages/react/runtime/__test__/element-template/native/reload.test.ts b/packages/react/runtime/__test__/element-template/native/reload.test.ts index 2731d9a3b5..55f2b0ff85 100644 --- a/packages/react/runtime/__test__/element-template/native/reload.test.ts +++ b/packages/react/runtime/__test__/element-template/native/reload.test.ts @@ -37,6 +37,12 @@ vi.mock('../../../src/element-template/runtime/page/page.js', () => ({ setupPage: vi.fn((page: unknown) => { mockedState.page = page; }), + insertRootIntoPage: vi.fn((rootRef: ElementRef) => { + __InsertNodeToElementTemplate(mockedState.page as ElementRef, 0, rootRef, null); + }), + removeRootFromPage: vi.fn((rootRef: ElementRef) => { + __RemoveNodeFromElementTemplate(mockedState.page as ElementRef, 0, rootRef); + }), })); vi.mock('../../../src/element-template/runtime/page/root-instance.js', () => ({ @@ -105,10 +111,9 @@ describe('ElementTemplate reloadMainThread', () => { mockedState.page = undefined; mockedState.root = {}; vi.stubGlobal('__PROFILE__', false); - vi.stubGlobal('__CreatePage', vi.fn(() => ({ type: 'page', id: '0', children: [] }))); vi.stubGlobal('__FlushElementTree', vi.fn()); - vi.stubGlobal('__AppendElement', vi.fn()); - vi.stubGlobal('__RemoveElement', vi.fn()); + vi.stubGlobal('__InsertNodeToElementTemplate', vi.fn()); + vi.stubGlobal('__RemoveNodeFromElementTemplate', vi.fn()); vi.stubGlobal('__SerializeElementTemplate', vi.fn()); globalThis.lynx = { ...(globalThis.lynx ?? {}), @@ -162,7 +167,7 @@ describe('ElementTemplate reloadMainThread', () => { })); renderMainThread(); - vi.mocked(__AppendElement).mockClear(); + vi.mocked(__InsertNodeToElementTemplate).mockClear(); vi.mocked(__SerializeElementTemplate).mockClear(); vi.mocked(mockRender).mockClear(); vi.mocked(mockRenderOpcodesIntoElementTemplate).mockClear(); @@ -180,16 +185,15 @@ describe('ElementTemplate reloadMainThread', () => { expect(lynx.__initData).toEqual({ msg: 'reload', stable: true }); expect(elementTemplateRegistry.clear).toHaveBeenCalledTimes(1); expect(resetTemplateId).toHaveBeenCalledTimes(1); - expect(__CreatePage).not.toHaveBeenCalled(); expect(vi.mocked(setupPage)).not.toHaveBeenCalled(); - expect(__RemoveElement).toHaveBeenCalledWith(page, oldRootRef); + expect(__RemoveNodeFromElementTemplate).toHaveBeenCalledWith(page, 0, oldRootRef); expect(vi.mocked(setRoot)).toHaveBeenCalledTimes(1); expect(__root).not.toBe(oldRoot); expect(__root.__jsx).toBe(jsx); expect(__root).not.toHaveProperty('stale'); expect(mockRender).toHaveBeenCalledWith(jsx, undefined); expect(mockRenderOpcodesIntoElementTemplate).toHaveBeenCalledWith(opcodes); - expect(__AppendElement).toHaveBeenCalledWith(page, rootRef); + expect(__InsertNodeToElementTemplate).toHaveBeenCalledWith(page, 0, rootRef, null); expect(__SerializeElementTemplate).toHaveBeenCalledWith(rootRef); expect(dispatchEvent).toHaveBeenCalledWith({ type: 'rLynxElementTemplateHydrate', @@ -204,7 +208,6 @@ describe('ElementTemplate reloadMainThread', () => { it('profiles main-thread reload when profiling is enabled', () => { vi.stubGlobal('__PROFILE__', true); mockedState.root = { __jsx: null }; - vi.mocked(__CreatePage).mockReturnValue({ type: 'page', id: '0', children: [] }); vi.mocked(mockRender).mockReturnValue([]); vi.mocked(mockRenderOpcodesIntoElementTemplate).mockReturnValue({ rootRefs: [] }); diff --git a/packages/react/runtime/__test__/element-template/runtime/page/page.test.ts b/packages/react/runtime/__test__/element-template/runtime/page/page.test.ts new file mode 100644 index 0000000000..e3582106ba --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/page/page.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('ElementTemplate page root helpers', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('__CreateTypedElementTemplate', vi.fn()); + vi.stubGlobal('__InsertNodeToElementTemplate', vi.fn()); + vi.stubGlobal('__RemoveNodeFromElementTemplate', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('creates typed page and updates its root slot', async () => { + const page = { type: 'page' } as unknown as ElementRef; + const rootRef = { type: 'root' } as unknown as ElementRef; + vi.mocked(__CreateTypedElementTemplate).mockReturnValue(page); + + const { + createElementTemplatePage, + insertRootIntoPage, + removeRootFromPage, + setupPage, + } = await import('../../../../src/element-template/runtime/page/page.js'); + + expect(createElementTemplatePage()).toBe(page); + expect(__CreateTypedElementTemplate).toHaveBeenCalledWith('page', null, null, '0', null); + + setupPage(page); + insertRootIntoPage(rootRef); + removeRootFromPage(rootRef); + + expect(__InsertNodeToElementTemplate).toHaveBeenCalledWith(page, 0, rootRef, null); + expect(__RemoveNodeFromElementTemplate).toHaveBeenCalledWith(page, 0, rootRef); + }); +}); diff --git a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx index 81f436a3f2..78d37c231f 100644 --- a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx @@ -372,6 +372,7 @@ describe('ElementTemplate patch stream (apply)', () => { ElementTemplateUpdateOps.createTypedElement, 21, 'list', + { id: 'typed-list' }, [[11]], { listChildren: [{ __etHandleRef: 12 }], @@ -381,9 +382,10 @@ describe('ElementTemplate patch stream (apply)', () => { expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(1); expect(mockCreateTypedElementTemplate.mock.calls[0]?.[0]).toBe('list'); - expect(mockCreateTypedElementTemplate.mock.calls[0]?.[1]).toEqual([[slotChildRef]]); - expect(mockCreateTypedElementTemplate.mock.calls[0]?.[2]).toBe(21); - expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toEqual({ + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[1]).toEqual({ id: 'typed-list' }); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[2]).toEqual([[slotChildRef]]); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toBe(21); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[4]).toEqual({ listChildren: [optionChildRef], estimatedHeight: 80, }); @@ -399,11 +401,13 @@ describe('ElementTemplate patch stream (apply)', () => { ElementTemplateUpdateOps.createTypedElement, 23, 'list', + null, [], null, ]); - expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toBe(null); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[1]).toBe(null); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[4]).toBe(null); expect(elementTemplateRegistry.has(23)).toBe(true); }); @@ -420,11 +424,12 @@ describe('ElementTemplate patch stream (apply)', () => { ElementTemplateUpdateOps.createTypedElement, 24, 'list', + null, [], options, ]); - expect(mockCreateTypedElementTemplate.mock.calls[0]?.[3]).toEqual(options); + expect(mockCreateTypedElementTemplate.mock.calls[0]?.[4]).toEqual(options); expect(elementTemplateRegistry.has(24)).toBe(true); }); @@ -437,6 +442,7 @@ describe('ElementTemplate patch stream (apply)', () => { ElementTemplateUpdateOps.createTypedElement, 25, 'list', + null, [[404]], null, ]); @@ -457,6 +463,7 @@ describe('ElementTemplate patch stream (apply)', () => { ElementTemplateUpdateOps.createTypedElement, 22, 'list', + null, [], { listChildren: [{ __etHandleRef: 404 }] }, ]); diff --git a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts index 1e59d99172..146c2fcc85 100644 --- a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.contract.test.ts @@ -53,7 +53,7 @@ describe('renderMainThread contract', () => { resetTemplateId(); elementTemplateRegistry.clear(); setRoot({ __jsx: { type: 'test-root' } }); - setupPage({ type: 'page', children: [] } as unknown as FiberElement); + setupPage({ type: 'page', children: [] } as unknown as ElementRef); globalThis.__MAIN_THREAD__ = true; globalThis.__BACKGROUND__ = false; }); diff --git a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts index 89f9357152..2f7923e808 100644 --- a/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/render/render-main-thread.test.ts @@ -20,7 +20,7 @@ import { renderOpcodesIntoElementTemplate as mockRenderOpcodesIntoElementTemplat describe('renderMainThread', () => { beforeEach(() => { setRoot({ __jsx: { type: 'test-root' } }); - setupPage({ type: 'page', children: [] } as unknown as FiberElement); + setupPage({ type: 'page', children: [] } as unknown as ElementRef); globalThis.__MAIN_THREAD__ = true; globalThis.__BACKGROUND__ = false; const dispatchEvent = vi.fn(); @@ -31,7 +31,7 @@ describe('renderMainThread', () => { dispatchEvent, })), } as typeof lynx; - vi.stubGlobal('__AppendElement', vi.fn()); + vi.stubGlobal('__InsertNodeToElementTemplate', vi.fn()); vi.stubGlobal('__SerializeElementTemplate', vi.fn()); vi.mocked(mockRenderOpcodesIntoElementTemplate).mockReturnValue({ rootRefs: [] }); }); @@ -93,8 +93,20 @@ describe('renderMainThread', () => { expect(mockRenderOpcodesIntoElementTemplate).toHaveBeenCalledWith( opcodes, ); - expect(__AppendElement).toHaveBeenNthCalledWith(1, expect.objectContaining({ type: 'page' }), rootRefA); - expect(__AppendElement).toHaveBeenNthCalledWith(2, expect.objectContaining({ type: 'page' }), rootRefB); + expect(__InsertNodeToElementTemplate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ type: 'page' }), + 0, + rootRefA, + null, + ); + expect(__InsertNodeToElementTemplate).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: 'page' }), + 0, + rootRefB, + null, + ); expect(__SerializeElementTemplate).toHaveBeenNthCalledWith(1, rootRefA); expect(__SerializeElementTemplate).toHaveBeenNthCalledWith(2, rootRefB); expect(dispatchEvent).toHaveBeenCalledWith({ diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts index 27e63a5719..dd213afd33 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts @@ -26,8 +26,7 @@ declare global { interface RootNode { type: 'page'; - id: string; - children: unknown[]; + children?: unknown[]; } interface TransformResult { @@ -161,7 +160,7 @@ async function runCompiledRenderFixture(options: { const installed = installMockNativePapi({ clearTemplatesOnCleanup: true }); const nativeLog = installed.nativeLog as unknown[]; const cleanup = installed.cleanup; - const root: RootNode = { type: 'page', id: '0', children: [] }; + const root = __CreateTypedElementTemplate('page', null, null, '0', null) as unknown as RootNode; try { const code = fs.readFileSync(sourcePath, 'utf8'); @@ -233,12 +232,12 @@ async function runCompiledRenderFixture(options: { const opcodes = renderToString(vnode, null); const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); for (const rootRef of rootRefs) { - __AppendElement(root as FiberElement, rootRef); + __InsertNodeToElementTemplate(root as FiberElement, 0, rootRef, null); } assertOrUpdateTextFile({ path: expectedPath, - actual: serializeToJSX(root.children[0]), + actual: serializeToJSX(root.children?.[0]), update, fixtureName, label: 'jsx output', diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx index b6482424f9..9ab360413b 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.test.tsx @@ -21,6 +21,7 @@ describe('element-template update runner', () => { ElementTemplateUpdateOps.createTypedElement, 8, 'list', + { id: 'typed-list' }, [[1]], { listChildren: [{ __etHandleRef: 1 }] }, ElementTemplateUpdateOps.setAttribute, @@ -52,6 +53,7 @@ describe('element-template update runner', () => { type: 'createTypedElement', id: 8, elementType: 'list', + attributes: { id: 'typed-list' }, elementSlots: [[1]], options: { listChildren: [{ __etHandleRef: 1 }] }, }, diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts index 53c6ff6086..ce310d36bc 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/updateRunner.ts @@ -37,6 +37,7 @@ type FormattedUpdateEntry = type: 'createTypedElement'; id: number; elementType: string; + attributes: unknown; elementSlots: unknown; options: unknown; } @@ -108,6 +109,7 @@ export function formatUpdateStream(stream: ElementTemplateUpdateCommandStream): type: 'createTypedElement', id: stream[index++] as number, elementType: stream[index++] as string, + attributes: stream[index++] as unknown, elementSlots: stream[index++] as unknown, options: stream[index++] as unknown, }); diff --git a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts index bad6666fe5..bf0a2a447c 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi.ts @@ -9,7 +9,6 @@ import { formatNode, instantiateCompiledTemplate, isRecordForMock, - isUnknownArrayForMock, insertNodeIntoTemplateInstance, removeNodeFromTemplateInstance, serializeTemplateInstance, @@ -19,7 +18,6 @@ import type { CompiledTemplateNode } from './mockNativePapi/templateTree.js'; import { clearTemplates, templateRepo } from '../debug/registry.js'; const isRecord = isRecordForMock; -const isUnknownArray = isUnknownArrayForMock; export interface MockNativePapi { nativeLog: any[]; @@ -37,8 +35,6 @@ export interface MockNativePapi { mockRemoveNodeFromElementTemplate: any; mockReportError: any; mockFlushElementTree: any; - mockCreatePage: any; - mockAppendElement: any; cleanup: () => void; } @@ -118,15 +114,16 @@ export function installMockNativePapi( const mockCreateTypedElementTemplate = vi.fn().mockImplementation(( type: string, + attributes: unknown, elementSlots: unknown[][] | null | undefined, handleId: unknown, options: unknown, ) => { - nativeLog.push(['__CreateTypedElementTemplate', type, elementSlots, handleId, options]); + nativeLog.push(['__CreateTypedElementTemplate', type, attributes, elementSlots, handleId, options]); const element: CompiledTemplateNode = { tag: type, type, - attributes: {}, + attributes: isRecord(attributes) ? { ...attributes } : {}, children: [...(elementSlots?.[0] ?? [])], }; attachMockNativeId(element); @@ -143,7 +140,7 @@ export function installMockNativePapi( configurable: true, }); Object.defineProperty(element, '__attributeSlots', { - value: null, + value: attributes == null ? null : [attributes], writable: true, configurable: true, }); @@ -171,27 +168,6 @@ export function installMockNativePapi( nativeLog.push(['lynx.reportError', error]); }); - const mockCreatePage = vi.fn().mockImplementation((id: string, cssId: number) => { - nativeLog.push(['__CreatePage', id, cssId]); - const page = { type: 'page', id, cssId }; - attachMockNativeId(page); - return page; - }); - - const mockAppendElement = vi.fn().mockImplementation((parent: unknown, child: unknown) => { - const parentId = formatNode(parent); - const childId = formatNode(child); - nativeLog.push(['__AppendElement', parentId, childId]); - if (isRecord(parent)) { - const children = parent['children']; - if (isUnknownArray(children)) { - children.push(child); - } else { - parent['children'] = [child]; - } - } - }); - const mockSetAttribute = vi.fn().mockImplementation((element: unknown, name: string, value: unknown) => { nativeLog.push(['__SetAttribute', formatNode(element), name, value]); if (!isRecord(element)) { @@ -357,8 +333,6 @@ export function installMockNativePapi( vi.stubGlobal('__CreateElementTemplate', mockCreateElementTemplate); vi.stubGlobal('__CreateTypedElementTemplate', mockCreateTypedElementTemplate); - vi.stubGlobal('__CreatePage', mockCreatePage); - vi.stubGlobal('__AppendElement', mockAppendElement); vi.stubGlobal('__AddDataset', mockAddDataset); vi.stubGlobal('__SetDataset', mockSetDataset); vi.stubGlobal('__SetAttribute', mockSetAttribute); @@ -394,8 +368,6 @@ export function installMockNativePapi( mockRemoveNodeFromElementTemplate: mockRemoveNodeFromElementTemplate, mockReportError: mockReportError, mockFlushElementTree: mockFlushElementTree, - mockCreatePage: mockCreatePage, - mockAppendElement: mockAppendElement, cleanup: (): void => { const errorCalls = mockReportError.mock.calls; if (clearTemplatesOnCleanup) { diff --git a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts index 088d130ad8..22f7014dbe 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/mock/mockNativePapi/templateTree.ts @@ -637,10 +637,11 @@ export function formatUpdateCommands(ops: unknown): unknown { type: 'createTypedElement', id: ops[i + 1], elementType: ops[i + 2], - elementSlots: ops[i + 3], - options: ops[i + 4], + attributes: ops[i + 3], + elementSlots: ops[i + 4], + options: ops[i + 5], }); - i += 5; + i += 6; } else { res.push(opcode); i += 1; diff --git a/packages/react/runtime/src/element-template/debug/alog.ts b/packages/react/runtime/src/element-template/debug/alog.ts index 9e650496d3..a92bd4fa65 100644 --- a/packages/react/runtime/src/element-template/debug/alog.ts +++ b/packages/react/runtime/src/element-template/debug/alog.ts @@ -20,6 +20,7 @@ export type FormattedElementTemplateUpdateCommand = op: 'createTypedElement'; handleId: number; type: string; + attributes: unknown; elementSlots: unknown; options: unknown; } @@ -88,6 +89,7 @@ export function formatElementTemplateUpdateCommands( op: 'createTypedElement', handleId: stream[index++] as number, type: stream[index++] as string, + attributes: stream[index++], elementSlots: stream[index++], options: stream[index++], }); diff --git a/packages/react/runtime/src/element-template/debug/elementPAPICall.ts b/packages/react/runtime/src/element-template/debug/elementPAPICall.ts index 5024d27d27..47ba3a7ddd 100644 --- a/packages/react/runtime/src/element-template/debug/elementPAPICall.ts +++ b/packages/react/runtime/src/element-template/debug/elementPAPICall.ts @@ -6,6 +6,7 @@ import { profileEnd, profileStart } from '../../shared/profile.js'; const elementTemplatePAPINameList = [ '__CreateElementTemplate', + '__CreateTypedElementTemplate', '__SetAttributeOfElementTemplate', '__InsertNodeToElementTemplate', '__RemoveNodeFromElementTemplate', @@ -44,6 +45,8 @@ export function initElementTemplatePAPICallAlog(globalWithIndex: Record | undefined): void { lynx.__initData = data ?? {}; - setupPage(__CreatePage('0', 0)); + setupPage(createElementTemplatePage()); resetMainThreadRootRefs(); renderMainThread(); } diff --git a/packages/react/runtime/src/element-template/protocol/types.ts b/packages/react/runtime/src/element-template/protocol/types.ts index 8059826b71..acc4de1913 100644 --- a/packages/react/runtime/src/element-template/protocol/types.ts +++ b/packages/react/runtime/src/element-template/protocol/types.ts @@ -26,6 +26,10 @@ export type RuntimeAttributeSlotValue = | RuntimeAttributeSlotValue[] | { [key: string]: RuntimeAttributeSlotValue }; +export type RuntimeTypedElementAttributes = Record; + +export type TypedElementAttributesCommand = Record; + export interface ElementTemplateHandleRefCommandValue { __etHandleRef: number; [key: string]: SerializableValue; @@ -115,6 +119,7 @@ export type CreateTypedElementCommand = [ typeof ElementTemplateUpdateOps.createTypedElement, handleId: number, type: string, + attributes: TypedElementAttributesCommand | null | undefined, elementSlots: number[][] | null | undefined, options: RuntimeOptionsCommand | null | undefined, ]; diff --git a/packages/react/runtime/src/element-template/runtime/page/page.ts b/packages/react/runtime/src/element-template/runtime/page/page.ts index 719644fe4f..ad93e578cc 100644 --- a/packages/react/runtime/src/element-template/runtime/page/page.ts +++ b/packages/react/runtime/src/element-template/runtime/page/page.ts @@ -2,8 +2,24 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -export let __page: FiberElement; +export let __page: ElementRef; -export function setupPage(page: FiberElement): void { +const ELEMENT_TEMPLATE_PAGE_TYPE = 'page'; +const ELEMENT_TEMPLATE_PAGE_UID = '0'; +const ELEMENT_TEMPLATE_PAGE_ROOT_SLOT = 0; + +export function createElementTemplatePage(): ElementRef { + return __CreateTypedElementTemplate(ELEMENT_TEMPLATE_PAGE_TYPE, null, null, ELEMENT_TEMPLATE_PAGE_UID, null); +} + +export function setupPage(page: ElementRef): void { __page = page; } + +export function insertRootIntoPage(rootRef: ElementRef): void { + __InsertNodeToElementTemplate(__page, ELEMENT_TEMPLATE_PAGE_ROOT_SLOT, rootRef, null); +} + +export function removeRootFromPage(rootRef: ElementRef): void { + __RemoveNodeFromElementTemplate(__page, ELEMENT_TEMPLATE_PAGE_ROOT_SLOT, rootRef); +} diff --git a/packages/react/runtime/src/element-template/runtime/patch.ts b/packages/react/runtime/src/element-template/runtime/patch.ts index 1f3669013a..ad15cf2545 100644 --- a/packages/react/runtime/src/element-template/runtime/patch.ts +++ b/packages/react/runtime/src/element-template/runtime/patch.ts @@ -10,6 +10,7 @@ import type { RuntimeOptions, RuntimeOptionsCommand, SerializableValue, + TypedElementAttributesCommand, } from '../protocol/types.js'; export type { ElementTemplateUpdateCommandStream } from '../protocol/types.js'; @@ -75,6 +76,7 @@ export function applyElementTemplateUpdateCommands( case ElementTemplateUpdateOps.createTypedElement: { const handleId = stream[i++] as number; const type = stream[i++] as string; + const attributes = stream[i++] as TypedElementAttributesCommand | null | undefined; const elementSlots = stream[i++] as number[][] | null | undefined; const options = stream[i++] as RuntimeOptionsCommand | null | undefined; @@ -86,6 +88,7 @@ export function applyElementTemplateUpdateCommands( const nativeRef = __CreateTypedElementTemplate( type, + attributes, resolvedElementSlots.value, handleId, resolvedOptions.value, diff --git a/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts b/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts index 05ee9953f6..548953e9eb 100644 --- a/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts +++ b/packages/react/runtime/src/element-template/runtime/render/render-main-thread.ts @@ -12,7 +12,7 @@ import { getReloadVersion } from '../../../core/reload-version.js'; import { profileEnd, profileStart } from '../../debug/profile.js'; import { ElementTemplateLifecycleConstant } from '../../protocol/lifecycle-constant.js'; import type { ElementTemplateHydrateCommitContext, SerializedEtNode } from '../../protocol/types.js'; -import { __page } from '../page/page.js'; +import { insertRootIntoPage, removeRootFromPage } from '../page/page.js'; import { __root } from '../page/root-instance.js'; // ET reload reuses the native page, so the main-thread render path owns the @@ -27,7 +27,7 @@ function removeMainThreadRootRefs(): void { const rootRefs = mainThreadRootRefs; mainThreadRootRefs = []; for (const rootRef of rootRefs) { - __RemoveElement(__page, rootRef); + removeRootFromPage(rootRef); } } @@ -47,7 +47,7 @@ function renderMainThread(): void { try { const { rootRefs } = renderOpcodesIntoElementTemplate(opcodes); for (const rootRef of rootRefs) { - __AppendElement(__page, rootRef); + insertRootIntoPage(rootRef); } mainThreadRootRefs = rootRefs; } finally { diff --git a/packages/react/runtime/src/element-template/types.d.ts b/packages/react/runtime/src/element-template/types.d.ts index 186cf44721..39cb522ce7 100644 --- a/packages/react/runtime/src/element-template/types.d.ts +++ b/packages/react/runtime/src/element-template/types.d.ts @@ -5,6 +5,7 @@ import type { RuntimeAttributeSlotValue, RuntimeOptions, + RuntimeTypedElementAttributes, SerializableValue, SerializedEtNode, } from './protocol/types.js'; @@ -27,6 +28,7 @@ declare global { function __CreateTypedElementTemplate( type: string, + attributes: RuntimeTypedElementAttributes | null | undefined, elementSlots: ElementRef[][] | null | undefined, uid: number | string, options?: RuntimeOptions | null, From 2f20f3c18c7fe4d6975a58eee6450453f3813034 Mon Sep 17 00:00:00 2001 From: Yradex <11014207+Yradex@users.noreply.github.com> Date: Fri, 22 May 2026 16:50:47 +0800 Subject: [PATCH 5/5] fix(react): address ET reload review comments --- .../render/sparse-element-slot/papi.txt | 20 ++++-- .../element-template/native/reload.test.ts | 23 +++++++ .../patch/element-template-patch.test.tsx | 64 +++++++++++++++++++ .../test-utils/debug/renderFixtureRunner.ts | 2 + .../src/element-template/native/reload.ts | 9 ++- .../src/element-template/runtime/patch.ts | 44 +++++++++++-- 6 files changed, 145 insertions(+), 17 deletions(-) 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 index e280455f9f..dab438dca6 100644 --- 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 @@ -1,4 +1,12 @@ [ + [ + "__CreateTypedElementTemplate", + "page", + null, + null, + "0", + null + ], [ "__CreateElementTemplate", "_et_builtin_raw_text", @@ -32,9 +40,7 @@ "__CreateElementTemplate", "_et_7a8c6_test_1", null, - [ - null - ], + null, [ null, [ @@ -66,8 +72,10 @@ -3 ], [ - "__AppendElement", - "0", - "<_et_7a8c6_test_1 />" + "__InsertNodeToElementTemplate", + "", + 0, + "<_et_7a8c6_test_1 />", + null ] ] \ No newline at end of file diff --git a/packages/react/runtime/__test__/element-template/native/reload.test.ts b/packages/react/runtime/__test__/element-template/native/reload.test.ts index 55f2b0ff85..00d9110556 100644 --- a/packages/react/runtime/__test__/element-template/native/reload.test.ts +++ b/packages/react/runtime/__test__/element-template/native/reload.test.ts @@ -205,6 +205,18 @@ describe('ElementTemplate reloadMainThread', () => { expect(__FlushElementTree).toHaveBeenCalledWith(page, options); }); + it('clears initData before resetPageData main-thread reloads', () => { + mockedState.root = { __jsx: { type: 'App' } }; + lynx.__initData = { stale: true, msg: 'init' }; + mockedState.page = { type: 'page', id: '0', children: [] }; + vi.mocked(mockRender).mockReturnValue([]); + vi.mocked(mockRenderOpcodesIntoElementTemplate).mockReturnValue({ rootRefs: [] }); + + reloadMainThread({ msg: 'reset' }, { reloadTemplate: true, resetPageData: true }); + + expect(lynx.__initData).toEqual({ msg: 'reset' }); + }); + it('profiles main-thread reload when profiling is enabled', () => { vi.stubGlobal('__PROFILE__', true); mockedState.root = { __jsx: null }; @@ -269,4 +281,15 @@ describe('ElementTemplate reloadBackground', () => { expect(profileStart).toHaveBeenCalledWith('ReactLynx::reloadBackground'); expect(profileEnd).toHaveBeenCalledTimes(1); }); + + it('keeps background reload initData object fresh without merging non-object update data', () => { + mockedState.root = { __jsx: null }; + const initData = { stable: true }; + lynx.__initData = initData; + + reloadBackground('ignored'); + + expect(lynx.__initData).not.toBe(initData); + expect(lynx.__initData).toEqual({ stable: true }); + }); }); diff --git a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx index 78d37c231f..fd539f1df7 100644 --- a/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx +++ b/packages/react/runtime/__test__/element-template/runtime/patch/element-template-patch.test.tsx @@ -433,6 +433,47 @@ describe('ElementTemplate patch stream (apply)', () => { expect(elementTemplateRegistry.has(24)).toBe(true); }); + it('reports invalid typed create handleId', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 0, + 'list', + null, + [], + null, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(0); + const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError; + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain('invalid handleId 0'); + resetReportedErrors(); + }); + + it('reports duplicate typed create handleId', () => { + envManager.switchToMainThread(); + const existingRef = { __isNativeRef: true, id: 'existing' } as unknown as ElementRef; + elementTemplateRegistry.set(26, existingRef); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 26, + 'list', + null, + [], + null, + ]); + + expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(0); + const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError; + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain('duplicate handleId 26'); + resetReportedErrors(); + }); + it('skips typed create when element slot handles are unresolved', () => { envManager.switchToMainThread(); elementTemplateRegistry.clear(); @@ -477,6 +518,29 @@ describe('ElementTemplate patch stream (apply)', () => { resetReportedErrors(); }); + it('skips typed create when command option handle refs are malformed', () => { + envManager.switchToMainThread(); + elementTemplateRegistry.clear(); + mockCreateTypedElementTemplate.mockClear(); + + applyElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTypedElement, + 27, + 'list', + null, + [], + { listChildren: [null] } as unknown as ElementTemplateUpdateCommandStream[number], + ]); + + expect(mockCreateTypedElementTemplate.mock.calls).toHaveLength(0); + expect(elementTemplateRegistry.has(27)).toBe(false); + const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError; + expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain( + 'options.listChildren[0] must contain a valid __etHandleRef', + ); + resetReportedErrors(); + }); + it('sets typed slot 0 attributes through the standard attr-slot PAPI', () => { envManager.switchToMainThread(); const targetRef = { __isNativeRef: true, id: 'typed-target' } as unknown as ElementRef; diff --git a/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts b/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts index dd213afd33..fc2b1869e3 100644 --- a/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts +++ b/packages/react/runtime/__test__/element-template/test-utils/debug/renderFixtureRunner.ts @@ -6,6 +6,7 @@ import { vi } from 'vitest'; import { resetElementTemplateHydrationListener } from '../../../../src/element-template/background/hydration-listener.js'; import { renderOpcodesIntoElementTemplate } from '../../../../src/element-template/runtime/render/render-opcodes.js'; +import { clearEtAttrPlanMap } from '../../../../src/element-template/runtime/template/attr-slot-plan.js'; import { resetTemplateId } from '../../../../src/element-template/runtime/template/handle.js'; import { elementTemplateRegistry } from '../../../../src/element-template/runtime/template/registry.js'; import { renderToString } from '../../../../src/element-template/runtime/render/render-to-opcodes.js'; @@ -135,6 +136,7 @@ async function runCompiledRenderFixture(options: { vi.resetAllMocks(); elementTemplateRegistry.clear(); + clearEtAttrPlanMap(); resetTemplateId(); globalThis.__USE_ELEMENT_TEMPLATE__ = true; diff --git a/packages/react/runtime/src/element-template/native/reload.ts b/packages/react/runtime/src/element-template/native/reload.ts index 71cddc4678..8cd2eead4f 100644 --- a/packages/react/runtime/src/element-template/native/reload.ts +++ b/packages/react/runtime/src/element-template/native/reload.ts @@ -5,8 +5,8 @@ import type { ComponentChild, ContainerNode } from 'preact'; import { render } from 'preact'; +import { applyUpdatePageData } from '../../core/lynx-page-data.js'; import { increaseReloadVersion } from '../../core/reload-version.js'; -import { isEmptyObject } from '../../utils.js'; import { destroyElementTemplateBackgroundRuntime } from '../background/destroy.js'; import { setupBackgroundElementTemplateDocument } from '../background/document.js'; import { installElementTemplateHydrationListener } from '../background/hydration-listener.js'; @@ -26,9 +26,7 @@ export function reloadMainThread(data: unknown, options: UpdatePageOption): void try { increaseReloadVersion(); - if (typeof data == 'object' && data !== null && !isEmptyObject(data)) { - Object.assign(lynx.__initData, data); - } + applyUpdatePageData(data, options); elementTemplateRegistry.clear(); resetTemplateId(); @@ -57,7 +55,8 @@ export function reloadBackground(updateData: unknown): void { increaseReloadVersion(); // Reload creates a new object so InitData Provider / Consumer observers do // not retain the pre-reload object identity. - lynx.__initData = Object.assign({}, lynx.__initData, updateData); + lynx.__initData = Object.assign({}, lynx.__initData); + applyUpdatePageData(updateData); setRoot(new BackgroundElementTemplateInstance('root')); __root.__jsx = jsx; diff --git a/packages/react/runtime/src/element-template/runtime/patch.ts b/packages/react/runtime/src/element-template/runtime/patch.ts index ad15cf2545..35931eece3 100644 --- a/packages/react/runtime/src/element-template/runtime/patch.ts +++ b/packages/react/runtime/src/element-template/runtime/patch.ts @@ -80,6 +80,14 @@ export function applyElementTemplateUpdateCommands( const elementSlots = stream[i++] as number[][] | null | undefined; const options = stream[i++] as RuntimeOptionsCommand | null | undefined; + if (__DEV__) { + const createError = validateCreateHandleId(handleId); + if (createError) { + lynx.reportError(createError); + continue; + } + } + const resolvedElementSlots = resolveElementSlots(elementSlots); const resolvedOptions = resolveRuntimeOptions(options); if ((__DEV__ && resolvedElementSlots.hasError) || resolvedOptions.hasError) { @@ -192,8 +200,24 @@ function resolveRuntimeOptions( const resolvedListChildren: ElementRef[] = []; for (let index = 0; index < listChildren.length; index++) { + const child = listChildren[index]; + if ( + child == null + || typeof child !== 'object' + || !('__etHandleRef' in child) + || !Number.isInteger((child as { __etHandleRef?: unknown }).__etHandleRef) + ) { + lynx.reportError( + new Error(`ElementTemplate update options.listChildren[${index}] must contain a valid __etHandleRef.`), + ); + return { + hasError: true, + value: null, + }; + } + const ref = resolveHandle( - (listChildren[index]!).__etHandleRef, + child.__etHandleRef, `options.listChildren[${index}]`, ); if (ref === null) { @@ -227,17 +251,25 @@ function isValidHandleId(handleId: number): boolean { return Number.isInteger(handleId) && handleId !== 0; } -function validateCreateTemplatePayload( - handleId: number, - attributeSlots: SerializableValue[] | null | undefined, - elementSlots: number[][] | null | undefined, -): Error | null { +function validateCreateHandleId(handleId: number): Error | null { if (!isValidHandleId(handleId)) { return new Error(`ElementTemplate update has invalid handleId ${String(handleId)}.`); } if (elementTemplateRegistry.get(handleId)) { return new Error(`ElementTemplate update received duplicate handleId ${handleId}.`); } + return null; +} + +function validateCreateTemplatePayload( + handleId: number, + attributeSlots: SerializableValue[] | null | undefined, + elementSlots: number[][] | null | undefined, +): Error | null { + const handleError = validateCreateHandleId(handleId); + if (handleError) { + return handleError; + } if (attributeSlots != null && !Array.isArray(attributeSlots)) { return new Error('ElementTemplate update create attributeSlots must be an array, null, or undefined.'); }