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.');
}