From 09c37cdba89d4642d87f9a2cc91ed40b5ade3bb2 Mon Sep 17 00:00:00 2001
From: Yradex <11014207+Yradex@users.noreply.github.com>
Date: Wed, 20 May 2026 11:37:18 +0800
Subject: [PATCH 1/2] feat(react): support ET updateCardData bridge
---
.../__test__/core/lynx-update-data.test.ts | 77 +++++++++
.../background/init-data-update/index.tsx | 11 ++
.../element-template/lynx/update-data.test.ts | 108 ++++++++++++
.../element-template/native/index.test.ts | 6 +
.../runtime/background/commit-hook.test.ts | 71 ++++++++
.../init-data-compiled-fixtures.test.tsx | 159 ++++++++++++++++++
.../patch/element-template-patch.test.tsx | 11 +-
.../snapshot/compat/initData.test.jsx | 38 +++++
.../runtime/src/core/lynx-update-data.ts | 40 +++++
.../background/commit-hook.ts | 7 +-
.../src/element-template/lynx/update-data.ts | 18 ++
.../src/element-template/native/index.ts | 2 +
.../react/runtime/src/snapshot/lynx/tt.ts | 19 +--
13 files changed, 545 insertions(+), 22 deletions(-)
create mode 100644 packages/react/runtime/__test__/core/lynx-update-data.test.ts
create mode 100644 packages/react/runtime/__test__/element-template/fixtures/background/init-data-update/index.tsx
create mode 100644 packages/react/runtime/__test__/element-template/lynx/update-data.test.ts
create mode 100644 packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx
create mode 100644 packages/react/runtime/src/core/lynx-update-data.ts
create mode 100644 packages/react/runtime/src/element-template/lynx/update-data.ts
diff --git a/packages/react/runtime/__test__/core/lynx-update-data.test.ts b/packages/react/runtime/__test__/core/lynx-update-data.test.ts
new file mode 100644
index 0000000000..8a39b7d243
--- /dev/null
+++ b/packages/react/runtime/__test__/core/lynx-update-data.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { applyInitDataUpdateFromNative, NativeUpdateDataType } from '../../src/core/lynx-update-data.js';
+
+describe('applyInitDataUpdateFromNative', () => {
+ let originalInitData: typeof lynx.__initData;
+ let originalReportError: typeof lynx.reportError;
+
+ beforeEach(() => {
+ originalInitData = lynx.__initData;
+ originalReportError = lynx.reportError;
+ lynx.__initData = {};
+ lynx.reportError = vi.fn();
+ });
+
+ afterEach(() => {
+ lynx.__initData = originalInitData;
+ lynx.reportError = originalReportError;
+ });
+
+ it('COW merges update data and returns the current patch data', () => {
+ const previousInitData = { msg: 'init', stable: true };
+ lynx.__initData = previousInitData;
+
+ const restNewData = applyInitDataUpdateFromNative({
+ msg: 'update',
+ next: 1,
+ });
+
+ expect(restNewData).toEqual({ msg: 'update', next: 1 });
+ expect(lynx.__initData).toEqual({ msg: 'update', stable: true, next: 1 });
+ expect(lynx.__initData).not.toBe(previousInitData);
+ expect(lynx.reportError).not.toHaveBeenCalled();
+ });
+
+ it('clears existing initData before RESET updates', () => {
+ lynx.__initData = { stale: true, msg: 'init' };
+
+ const restNewData = applyInitDataUpdateFromNative(
+ { msg: 'reset' },
+ { type: NativeUpdateDataType.RESET },
+ );
+
+ expect(restNewData).toEqual({ msg: 'reset' });
+ expect(lynx.__initData).toEqual({ msg: 'reset' });
+ });
+
+ it('keeps Snapshot-compatible loose RESET matching', () => {
+ lynx.__initData = { stale: true, msg: 'init' };
+
+ const restNewData = applyInitDataUpdateFromNative(
+ { msg: 'reset' },
+ { type: '1' as unknown as NativeUpdateDataType },
+ );
+
+ expect(restNewData).toEqual({ msg: 'reset' });
+ expect(lynx.__initData).toEqual({ msg: 'reset' });
+ });
+
+ it('reports and strips __lynx_timing_flag from the merged data and return value', () => {
+ lynx.__initData = { msg: 'init' };
+
+ const restNewData = applyInitDataUpdateFromNative({
+ msg: 'update',
+ __lynx_timing_flag: '__lynx_timing_actual_fmp',
+ });
+
+ expect(restNewData).toEqual({ msg: 'update' });
+ expect(lynx.__initData).toEqual({ msg: 'update' });
+ expect(lynx.reportError).toHaveBeenCalledTimes(1);
+ expect(lynx.reportError).toHaveBeenCalledWith(
+ new Error(
+ 'Received unsupported updateData with `__lynx_timing_flag` (value "__lynx_timing_actual_fmp"), the timing flag is ignored',
+ ),
+ );
+ });
+});
diff --git a/packages/react/runtime/__test__/element-template/fixtures/background/init-data-update/index.tsx b/packages/react/runtime/__test__/element-template/fixtures/background/init-data-update/index.tsx
new file mode 100644
index 0000000000..bcfb01a129
--- /dev/null
+++ b/packages/react/runtime/__test__/element-template/fixtures/background/init-data-update/index.tsx
@@ -0,0 +1,11 @@
+import { useInitData } from '@lynx-js/react/element-template';
+
+export function App(): JSX.Element {
+ const data = useInitData() as { msg?: string };
+
+ return (
+
+ {data.msg}
+
+ );
+}
diff --git a/packages/react/runtime/__test__/element-template/lynx/update-data.test.ts b/packages/react/runtime/__test__/element-template/lynx/update-data.test.ts
new file mode 100644
index 0000000000..9d7d2c72b1
--- /dev/null
+++ b/packages/react/runtime/__test__/element-template/lynx/update-data.test.ts
@@ -0,0 +1,108 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { NativeUpdateDataType } from '../../../src/core/lynx-update-data.js';
+import { updateCardData } from '../../../src/element-template/lynx/update-data.js';
+import { ElementTemplateEnvManager } from '../test-utils/debug/envManager.js';
+
+type Listener = (...args: unknown[]) => void;
+
+class LynxGlobalEventEmitter {
+ private listeners = new Map>();
+
+ addListener(eventName: string, listener: Listener): void {
+ const listeners = this.listeners.get(eventName);
+ if (listeners) {
+ listeners.add(listener);
+ return;
+ }
+ this.listeners.set(eventName, new Set([listener]));
+ }
+
+ removeListener(eventName: string, listener: Listener): void {
+ const listeners = this.listeners.get(eventName);
+ listeners?.delete(listener);
+ }
+
+ emit(eventName: string, args?: unknown[]): void {
+ for (const listener of this.listeners.get(eventName) ?? []) {
+ listener(...(args ?? []));
+ }
+ }
+}
+
+describe('ElementTemplate updateCardData', () => {
+ const envManager = new ElementTemplateEnvManager();
+ let originalLynx: typeof lynx;
+ let emitter: LynxGlobalEventEmitter;
+ let reportError: ReturnType;
+
+ function installLynx(initData: Record): void {
+ const baseLynx = globalThis.lynx;
+ vi.stubGlobal('lynx', {
+ ...baseLynx,
+ __initData: initData,
+ reportError,
+ getJSModule(moduleName: string) {
+ if (moduleName === 'GlobalEventEmitter') {
+ return emitter;
+ }
+ return baseLynx.getJSModule?.(moduleName);
+ },
+ });
+ }
+
+ beforeEach(() => {
+ originalLynx = globalThis.lynx;
+ emitter = new LynxGlobalEventEmitter();
+ reportError = vi.fn();
+ envManager.resetEnv('background');
+ });
+
+ afterEach(() => {
+ vi.stubGlobal('lynx', originalLynx);
+ });
+
+ it('updates initData and emits restNewData through the ET listener channel', () => {
+ installLynx({ msg: 'init', stable: true });
+ const listener = vi.fn();
+ emitter.addListener('onDataChanged', listener);
+
+ updateCardData({ msg: 'update', next: 1 });
+
+ expect(lynx.__initData).toEqual({ msg: 'update', stable: true, next: 1 });
+ expect(listener).toHaveBeenCalledWith({ msg: 'update', next: 1 });
+ expect(reportError).not.toHaveBeenCalled();
+ });
+
+ it('clears previous initData when RESET is requested', () => {
+ installLynx({ stale: true, msg: 'init' });
+ const listener = vi.fn();
+ emitter.addListener('onDataChanged', listener);
+
+ updateCardData(
+ { msg: 'reset' },
+ { type: NativeUpdateDataType.RESET },
+ );
+
+ expect(lynx.__initData).toEqual({ msg: 'reset' });
+ expect(listener).toHaveBeenCalledWith({ msg: 'reset' });
+ });
+
+ it('reports and strips __lynx_timing_flag before emitting onDataChanged', () => {
+ installLynx({ msg: 'init' });
+ const listener = vi.fn();
+ emitter.addListener('onDataChanged', listener);
+
+ updateCardData({
+ msg: 'update',
+ __lynx_timing_flag: '__lynx_timing_actual_fmp',
+ });
+
+ expect(lynx.__initData).toEqual({ msg: 'update' });
+ expect(listener).toHaveBeenCalledWith({ msg: 'update' });
+ expect(reportError).toHaveBeenCalledTimes(1);
+ expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toBe(
+ 'Received unsupported updateData with `__lynx_timing_flag` (value "__lynx_timing_actual_fmp"), the timing flag is ignored',
+ );
+ });
+});
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 201bb44eb6..08d4825538 100644
--- a/packages/react/runtime/__test__/element-template/native/index.test.ts
+++ b/packages/react/runtime/__test__/element-template/native/index.test.ts
@@ -30,6 +30,7 @@ describe('element-template native index wiring', () => {
vi.doUnmock('../../../src/element-template/debug/profile.js');
vi.doUnmock('../../../src/element-template/lynx/env.js');
vi.doUnmock('../../../src/element-template/lynx/performance.js');
+ vi.doUnmock('../../../src/element-template/lynx/update-data.js');
vi.doUnmock('../../../src/element-template/runtime/page/root-instance.js');
});
@@ -121,6 +122,7 @@ describe('element-template native index wiring', () => {
const publishEvent = vi.fn();
const publicComponentEvent = vi.fn();
const resetEventStateForRuntime = vi.fn();
+ const updateCardData = vi.fn();
vi.doMock('../../../src/element-template/native/main-thread-api.js', () => ({
injectCalledByNative,
@@ -149,6 +151,9 @@ describe('element-template native index wiring', () => {
vi.doMock('../../../src/element-template/lynx/performance.js', () => ({
initTimingAPI,
}));
+ vi.doMock('../../../src/element-template/lynx/update-data.js', () => ({
+ updateCardData,
+ }));
vi.doMock('../../../src/element-template/runtime/page/root-instance.js', () => ({
setRoot,
}));
@@ -179,6 +184,7 @@ describe('element-template native index wiring', () => {
expect(globalThis.lynxCoreInject.tt.callDestroyLifetimeFun).toBe(callDestroyLifetimeFun);
expect(globalThis.lynxCoreInject.tt.publishEvent).toBe(publishEvent);
expect(globalThis.lynxCoreInject.tt.publicComponentEvent).toBe(publicComponentEvent);
+ expect(globalThis.lynxCoreInject.tt.updateCardData).toBe(updateCardData);
expect(injectCalledByNative).not.toHaveBeenCalled();
expect(installElementTemplatePatchListener).not.toHaveBeenCalled();
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 83adff7f45..7a456c24d4 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
@@ -158,6 +158,11 @@ describe('ElementTemplate commit hook', () => {
});
envManager.switchToBackground();
expect(globalCommitContext.flushOptions).toEqual({});
+
+ options.__c?.({} as unknown as object, []);
+ envManager.switchToMainThread();
+ expect(updateEvents).toHaveLength(1);
+ envManager.switchToBackground();
});
it('dispatches triggerDataUpdated when useInitData observes a data change', () => {
@@ -279,6 +284,51 @@ describe('ElementTemplate commit hook', () => {
}
});
+ it('dispatches one data-updated payload for multiple initData readers', () => {
+ const dataChange = installDataChangeHarness();
+ let first: unknown;
+ let second: unknown;
+ let consumed: unknown;
+
+ try {
+ function App() {
+ first = useInitData();
+ second = useInitData();
+ return createElement(
+ InitDataProvider,
+ null,
+ createElement(InitDataConsumer, null, (initData: { msg?: string }) => {
+ consumed = initData;
+ return createElement('view');
+ }),
+ );
+ }
+
+ lynx.__initData = { msg: 'before' };
+ root.render(createElement(App, null));
+ expect(first).toEqual({ msg: 'before' });
+ expect(second).toEqual({ msg: 'before' });
+ expect(consumed).toEqual({ msg: 'before' });
+
+ markElementTemplateHydrated();
+ lynx.__initData = { msg: 'after' };
+ dataChange.emitDataChanged();
+ dataChange.flushScheduledRenders();
+
+ expect(first).toEqual({ msg: 'after' });
+ expect(second).toEqual({ msg: 'after' });
+ expect(consumed).toEqual({ msg: 'after' });
+ envManager.switchToMainThread();
+ expect(updateEvents).toHaveLength(1);
+ expect(updateEvents[0]).toMatchObject({
+ ops: [],
+ flushOptions: { triggerDataUpdated: true },
+ });
+ } finally {
+ dataChange.restore();
+ }
+ });
+
it('skips dispatch before hydration', () => {
globalCommitContext.ops = createRawTextOps(1, 'hello');
@@ -394,6 +444,27 @@ describe('ElementTemplate commit hook', () => {
}));
});
+ it('dispatches data-updated payload while flushing ref-only effects', () => {
+ const ref = vi.fn();
+ markElementTemplateHydrated();
+ globalCommitContext.flushOptions = { triggerDataUpdated: true };
+ queueRefAttrUpdate(null, ref, -2, 0);
+
+ options.__c?.({} as unknown as object, []);
+
+ envManager.switchToMainThread();
+ expect(updateEvents).toEqual([
+ {
+ ops: [],
+ flushOptions: { triggerDataUpdated: true },
+ },
+ ]);
+ envManager.switchToBackground();
+ expect(ref).toHaveBeenCalledWith(expect.objectContaining({
+ selector: '[ref=-2-0]',
+ }));
+ });
+
it('flushes pre-hydration ref effects on commit without dispatching native ops', () => {
const ref = vi.fn();
queueRefAttrUpdate(null, ref, 1, 0);
diff --git a/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx
new file mode 100644
index 0000000000..e97001808b
--- /dev/null
+++ b/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx
@@ -0,0 +1,159 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { createElement } from 'preact';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ installElementTemplateCommitHook,
+ resetElementTemplateCommitState,
+} from '../../../../src/element-template/background/commit-hook.js';
+import {
+ installElementTemplateHydrationListener,
+ resetElementTemplateHydrationListener,
+} from '../../../../src/element-template/background/hydration-listener.js';
+import { root } from '../../../../src/element-template/index.js';
+import { updateCardData } from '../../../../src/element-template/lynx/update-data.js';
+import {
+ installElementTemplatePatchListener,
+ resetElementTemplatePatchListener,
+} from '../../../../src/element-template/native/patch-listener.js';
+import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js';
+import type { ElementTemplateUpdateCommitContext } from '../../../../src/element-template/protocol/types.js';
+import { __page } from '../../../../src/element-template/runtime/page/page.js';
+import { clearEtAttrPlanMap } from '../../../../src/element-template/runtime/template/attr-slot-plan.js';
+import { compileFixtureSource } from '../../test-utils/debug/compiledFixtureCompiler.js';
+import { loadCompiledFixtureModule } from '../../test-utils/debug/compiledFixtureModule.js';
+import type { CompiledFixtureModuleExports } from '../../test-utils/debug/compiledFixtureModule.js';
+import { primeCompiledFixtureTemplates } from '../../test-utils/debug/compiledFixtureRegistry.js';
+import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js';
+import { serializeToJSX } from '../../test-utils/debug/serializer.js';
+
+declare const renderPage: (data?: Record) => void;
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const FIXTURE = path.resolve(__dirname, '../../fixtures/background/init-data-update/index.tsx');
+
+type Listener = (...args: unknown[]) => void;
+
+class LynxTestEventEmitter {
+ private listeners = new Map>();
+
+ addListener(eventName: string, listener: Listener): void {
+ const listeners = this.listeners.get(eventName);
+ if (listeners) {
+ listeners.add(listener);
+ return;
+ }
+ this.listeners.set(eventName, new Set([listener]));
+ }
+
+ removeListener(eventName: string, listener: Listener): void {
+ this.listeners.get(eventName)?.delete(listener);
+ }
+
+ emit(eventName: string, args?: unknown[]): void {
+ for (const listener of this.listeners.get(eventName) ?? []) {
+ listener(...(args ?? []));
+ }
+ }
+}
+
+function waitForRender(): Promise {
+ return new Promise(resolve => setTimeout(resolve, 0));
+}
+
+async function loadCompiledInitDataFixture(): Promise<{
+ backgroundModule: CompiledFixtureModuleExports;
+ mainModule: CompiledFixtureModuleExports;
+}> {
+ const mainArtifact = await compileFixtureSource(FIXTURE, { target: 'LEPUS' });
+ primeCompiledFixtureTemplates(mainArtifact);
+ const mainModule = await loadCompiledFixtureModule(mainArtifact);
+
+ const backgroundArtifact = await compileFixtureSource(FIXTURE, { target: 'JS' });
+ const backgroundModule = await loadCompiledFixtureModule(backgroundArtifact);
+
+ return { backgroundModule, mainModule };
+}
+
+describe('Compiled ET InitData updateData fixture', () => {
+ const envManager = new ElementTemplateEnvManager();
+ let originalLynx: typeof lynx;
+ let emitter: LynxTestEventEmitter;
+ let updateEvents: ElementTemplateUpdateCommitContext[] = [];
+
+ const onUpdate = (event: { data: unknown }) => {
+ updateEvents.push(event.data as ElementTemplateUpdateCommitContext);
+ };
+
+ function installInitData(initData: Record): void {
+ const baseLynx = globalThis.lynx;
+ vi.stubGlobal('lynx', {
+ ...baseLynx,
+ __initData: initData,
+ getJSModule(moduleName: string) {
+ if (moduleName === 'GlobalEventEmitter') {
+ return emitter;
+ }
+ return baseLynx.getJSModule?.(moduleName);
+ },
+ });
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ originalLynx = globalThis.lynx;
+ emitter = new LynxTestEventEmitter();
+ updateEvents = [];
+ resetElementTemplateCommitState();
+ clearEtAttrPlanMap();
+ envManager.resetEnv('background');
+ envManager.setUseElementTemplate(true);
+ installInitData({ msg: 'init' });
+ installElementTemplateCommitHook();
+ installElementTemplateHydrationListener();
+
+ envManager.switchToMainThread();
+ installElementTemplatePatchListener();
+ lynx.getJSContext().addEventListener(ElementTemplateLifecycleConstant.update, onUpdate);
+ envManager.switchToBackground();
+ });
+
+ afterEach(() => {
+ envManager.switchToMainThread();
+ lynx.getJSContext().removeEventListener(ElementTemplateLifecycleConstant.update, onUpdate);
+ resetElementTemplatePatchListener();
+ envManager.switchToBackground();
+ resetElementTemplateHydrationListener();
+ resetElementTemplateCommitState();
+ envManager.setUseElementTemplate(false);
+ vi.stubGlobal('lynx', originalLynx);
+ });
+
+ it('updates raw text after hydrated background updateCardData', async () => {
+ const { backgroundModule, mainModule } = await loadCompiledInitDataFixture();
+
+ envManager.switchToBackground();
+ root.render(createElement(backgroundModule.App));
+
+ envManager.switchToMainThread();
+ root.render(createElement(mainModule.App));
+ renderPage({ msg: 'init' });
+ expect(serializeToJSX(__page)).toContain('init');
+
+ envManager.switchToBackground();
+ updateEvents = [];
+ updateCardData({ msg: 'update' });
+ await waitForRender();
+
+ envManager.switchToMainThread();
+ expect(updateEvents.at(-1)?.flushOptions).toMatchObject({ triggerDataUpdated: true });
+ expect(updateEvents.at(-1)?.ops.length).toBeGreaterThan(0);
+ expect(serializeToJSX(__page)).toContain('update');
+ expect((__FlushElementTree as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(-1)?.[1]).toMatchObject({
+ triggerDataUpdated: 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 61c4b98909..1af18dbff4 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
@@ -579,18 +579,22 @@ describe('ElementTemplate patch stream (apply)', () => {
resetReportedErrors();
});
- it('still flushes update payloads without ops so flushOptions can reach native', () => {
+ it('still flushes update payloads with empty ops so flushOptions can reach native', () => {
envManager.switchToMainThread();
installElementTemplatePatchListener();
mockSetAttributeOfElementTemplate.mockClear();
mockInsertNodeToElementTemplate.mockClear();
mockRemoveNodeFromElementTemplate.mockClear();
mockFlushElementTree.mockClear();
+ lynx.performance._markTiming.mockClear();
envManager.switchToBackground();
lynx.getCoreContext().dispatchEvent({
type: ElementTemplateLifecycleConstant.update,
- data: { flushOptions: {} },
+ data: {
+ ops: [],
+ flushOptions: { triggerDataUpdated: true },
+ },
});
envManager.switchToMainThread();
@@ -598,6 +602,7 @@ describe('ElementTemplate patch stream (apply)', () => {
expect(mockInsertNodeToElementTemplate.mock.calls).toHaveLength(0);
expect(mockRemoveNodeFromElementTemplate.mock.calls).toHaveLength(0);
expect(mockFlushElementTree.mock.calls).toHaveLength(1);
- expect(mockFlushElementTree.mock.calls[0]?.[1]).toEqual({});
+ expect(mockFlushElementTree.mock.calls[0]?.[1]).toEqual({ triggerDataUpdated: true });
+ expect(lynx.performance._markTiming.mock.calls).toEqual([]);
});
});
diff --git a/packages/react/runtime/__test__/snapshot/compat/initData.test.jsx b/packages/react/runtime/__test__/snapshot/compat/initData.test.jsx
index eb22fe8cad..7cdcf6054d 100644
--- a/packages/react/runtime/__test__/snapshot/compat/initData.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/compat/initData.test.jsx
@@ -11,6 +11,7 @@ import {
import { backgroundSnapshotInstanceToJSON } from '../utils/debug';
import { useState } from 'preact/compat';
import { useInitData, withInitDataInState } from '../../../src/lynx-api';
+import { NativeUpdateDataType } from '../../../src/snapshot/lifecycle/constant';
import { globalEnvManager } from '../utils/envManager';
/** @type {SnapshotInstance} */
@@ -148,4 +149,41 @@ describe('withInitDataInState', () => {
}
`);
});
+
+ it('resets initData and strips timing flag before emitting data changes', () => {
+ const tt = lynxCoreInject.tt;
+ const emitter = lynx.getJSModule('GlobalEventEmitter');
+ const listener = vi.fn();
+ const originalReportError = lynx.reportError;
+ lynx.reportError = vi.fn();
+ lynx.__initData = {
+ stale: true,
+ key4: 'old',
+ };
+ emitter.addListener('onDataChanged', listener);
+
+ try {
+ tt.updateCardData(
+ {
+ key4: 'reset',
+ __lynx_timing_flag: '__lynx_timing_actual_fmp',
+ },
+ { type: NativeUpdateDataType.RESET },
+ );
+
+ expect(lynx.__initData).toEqual({
+ key4: 'reset',
+ });
+ expect(listener).toHaveBeenCalledWith({
+ key4: 'reset',
+ });
+ expect(lynx.reportError).toHaveBeenCalledTimes(1);
+ expect(String(lynx.reportError.mock.calls[0]?.[0]?.message ?? '')).toBe(
+ 'Received unsupported updateData with `__lynx_timing_flag` (value "__lynx_timing_actual_fmp"), the timing flag is ignored',
+ );
+ } finally {
+ emitter.removeListener('onDataChanged', listener);
+ lynx.reportError = originalReportError;
+ }
+ });
});
diff --git a/packages/react/runtime/src/core/lynx-update-data.ts b/packages/react/runtime/src/core/lynx-update-data.ts
new file mode 100644
index 0000000000..f358394f0d
--- /dev/null
+++ b/packages/react/runtime/src/core/lynx-update-data.ts
@@ -0,0 +1,40 @@
+// 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.
+
+export const NativeUpdateDataType = {
+ UPDATE: 0,
+ RESET: 1,
+} as const;
+
+export type NativeUpdateDataType = (typeof NativeUpdateDataType)[keyof typeof NativeUpdateDataType];
+
+export interface NativeUpdateDataOptions {
+ type?: NativeUpdateDataType | undefined;
+}
+
+type InitDataPatch = Record;
+
+export function applyInitDataUpdateFromNative(
+ newData: InitDataPatch,
+ options?: NativeUpdateDataOptions,
+): InitDataPatch {
+ const { ['__lynx_timing_flag']: performanceTimingFlag, ...restNewData } = newData;
+ if (performanceTimingFlag) {
+ lynx.reportError(
+ new Error(
+ `Received unsupported updateData with \`__lynx_timing_flag\` (value "${performanceTimingFlag}"), the timing flag is ignored`,
+ ),
+ );
+ }
+
+ const { type = NativeUpdateDataType.UPDATE } = options ?? {};
+ if (type == NativeUpdateDataType.RESET) {
+ lynx.__initData = {};
+ }
+
+ // COW keeps provider/consumer readers aligned with Snapshot updateData behavior.
+ lynx.__initData = Object.assign({}, lynx.__initData, restNewData);
+
+ return restNewData;
+}
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 ac5aef480f..7760413a7f 100644
--- a/packages/react/runtime/src/element-template/background/commit-hook.ts
+++ b/packages/react/runtime/src/element-template/background/commit-hook.ts
@@ -77,11 +77,11 @@ export function installElementTemplateCommitHook(): void {
)
) {
const hasNativeOps = globalCommitContext.ops.length > 0;
- const shouldDispatchUpdate = hasNativeOps || !isEmptyObject(globalCommitContext.flushOptions);
+ const hasUpdatePayload = hasNativeOps || !isEmptyObject(globalCommitContext.flushOptions);
const removedSubtreesAwaitingTeardown = hasNativeOps ? takeRemovedSubtreesForPostDispatchTeardown() : [];
let didFlushRefs = false;
try {
- if (shouldDispatchUpdate) {
+ if (hasUpdatePayload) {
markTimingLegacy('updateDiffVdomEnd');
markTiming('diffVdomEnd');
@@ -114,7 +114,8 @@ export function installElementTemplateCommitHook(): void {
),
);
}
-
+ }
+ if (hasUpdatePayload) {
lynx.getCoreContext().dispatchEvent({
type: ElementTemplateLifecycleConstant.update,
data: {
diff --git a/packages/react/runtime/src/element-template/lynx/update-data.ts b/packages/react/runtime/src/element-template/lynx/update-data.ts
new file mode 100644
index 0000000000..a60c17d9d1
--- /dev/null
+++ b/packages/react/runtime/src/element-template/lynx/update-data.ts
@@ -0,0 +1,18 @@
+// 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 { applyInitDataUpdateFromNative } from '../../core/lynx-update-data.js';
+import type { NativeUpdateDataOptions } from '../../core/lynx-update-data.js';
+
+interface LynxGlobalEventEmitter {
+ emit: (eventName: string, args?: unknown[]) => void;
+}
+
+export function updateCardData(
+ newData: Record,
+ options?: NativeUpdateDataOptions,
+): void {
+ const restNewData = applyInitDataUpdateFromNative(newData, options);
+ (lynx.getJSModule('GlobalEventEmitter') as LynxGlobalEventEmitter).emit('onDataChanged', [restNewData]);
+}
diff --git a/packages/react/runtime/src/element-template/native/index.ts b/packages/react/runtime/src/element-template/native/index.ts
index 6f780c0ecd..b94042dada 100644
--- a/packages/react/runtime/src/element-template/native/index.ts
+++ b/packages/react/runtime/src/element-template/native/index.ts
@@ -15,6 +15,7 @@ import { initElementTemplatePAPICallAlog } from '../debug/elementPAPICall.js';
import { initProfileHook } from '../debug/profile.js';
import { setupLynxEnv } from '../lynx/env.js';
import { initTimingAPI } from '../lynx/performance.js';
+import { updateCardData } from '../lynx/update-data.js';
import { publicComponentEvent, publishEvent, resetEventStateForRuntime } from '../prop-adapters/event.js';
import { setRoot } from '../runtime/page/root-instance.js';
@@ -42,6 +43,7 @@ function init(): void {
lynxCoreInject.tt.callDestroyLifetimeFun = callDestroyLifetimeFun;
lynxCoreInject.tt.publishEvent = publishEvent;
lynxCoreInject.tt.publicComponentEvent = publicComponentEvent;
+ lynxCoreInject.tt.updateCardData = updateCardData;
installElementTemplateCommitHook();
if (process.env['NODE_ENV'] !== 'test') {
initTimingAPI();
diff --git a/packages/react/runtime/src/snapshot/lynx/tt.ts b/packages/react/runtime/src/snapshot/lynx/tt.ts
index 83f141a44a..604efb686d 100644
--- a/packages/react/runtime/src/snapshot/lynx/tt.ts
+++ b/packages/react/runtime/src/snapshot/lynx/tt.ts
@@ -5,12 +5,13 @@ import { process, render } from 'preact';
import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } from './performance.js';
import { runWithForce } from './runWithForce.js';
+import { applyInitDataUpdateFromNative } from '../../core/lynx-update-data.js';
import { __root } from '../../root.js';
import { profileEnd, profileStart } from '../../shared/profile.js';
import { CHILDREN } from '../../shared/render-constants.js';
import { printSnapshotInstanceToString } from '../debug/printSnapshot.js';
import { getSnapshotVNodeSource } from '../debug/vnodeSource.js';
-import { LifecycleConstant, NativeUpdateDataType } from '../lifecycle/constant.js';
+import { LifecycleConstant } from '../lifecycle/constant.js';
import type { FirstScreenData } from '../lifecycle/constant.js';
import { destroyBackground } from '../lifecycle/destroy.js';
import { delayedEvents, delayedPublishEvent } from '../lifecycle/event/delayEvents.js';
@@ -285,21 +286,7 @@ function updateGlobalProps(newData: Record): void {
}
function updateCardData(newData: Record, options?: Record): void {
- const { ['__lynx_timing_flag']: performanceTimingFlag, ...restNewData } = newData;
- if (performanceTimingFlag) {
- lynx.reportError(
- new Error(
- `Received unsupported updateData with \`__lynx_timing_flag\` (value "${performanceTimingFlag}"), the timing flag is ignored`,
- ),
- );
- }
- const { type = NativeUpdateDataType.UPDATE } = options ?? {};
- if (type == NativeUpdateDataType.RESET) {
- lynx.__initData = {};
- }
-
- // COW when modify `lynx.__initData` to make sure Provider & Consumer works
- lynx.__initData = Object.assign({}, lynx.__initData, restNewData);
+ const restNewData = applyInitDataUpdateFromNative(newData, options);
lynxCoreInject.tt.GlobalEventEmitter.emit('onDataChanged', [restNewData]);
}
From 7856cbedbe5b52ccd4897fa25f6a802a4b600dfe Mon Sep 17 00:00:00 2001
From: Yradex <11014207+Yradex@users.noreply.github.com>
Date: Wed, 20 May 2026 18:02:12 +0800
Subject: [PATCH 2/2] refactor(react): share updateCardData bridge
---
.../__test__/core/lynx-update-data.test.ts | 44 +++++--
.../element-template/lynx/update-data.test.ts | 108 ------------------
.../element-template/native/index.test.ts | 4 +-
.../init-data-compiled-fixtures.test.tsx | 28 +----
.../__test__/test-utils/lynx-event-emitter.ts | 63 ++++++++++
.../runtime/src/core/lynx-update-data.ts | 6 +-
.../src/element-template/lynx/update-data.ts | 18 ---
.../src/element-template/native/index.ts | 2 +-
.../react/runtime/src/snapshot/lynx/tt.ts | 7 +-
packages/react/runtime/vitest.config.ts | 1 +
10 files changed, 105 insertions(+), 176 deletions(-)
delete mode 100644 packages/react/runtime/__test__/element-template/lynx/update-data.test.ts
create mode 100644 packages/react/runtime/__test__/test-utils/lynx-event-emitter.ts
delete mode 100644 packages/react/runtime/src/element-template/lynx/update-data.ts
diff --git a/packages/react/runtime/__test__/core/lynx-update-data.test.ts b/packages/react/runtime/__test__/core/lynx-update-data.test.ts
index 8a39b7d243..50669a3e7c 100644
--- a/packages/react/runtime/__test__/core/lynx-update-data.test.ts
+++ b/packages/react/runtime/__test__/core/lynx-update-data.test.ts
@@ -1,72 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { applyInitDataUpdateFromNative, NativeUpdateDataType } from '../../src/core/lynx-update-data.js';
+import { NativeUpdateDataType, updateCardData } from '../../src/core/lynx-update-data.js';
+import { LynxTestEventEmitter } from '../test-utils/lynx-event-emitter.js';
-describe('applyInitDataUpdateFromNative', () => {
+describe('updateCardData', () => {
let originalInitData: typeof lynx.__initData;
let originalReportError: typeof lynx.reportError;
+ let originalGetJSModule: typeof lynx.getJSModule;
+ let emitter: LynxTestEventEmitter;
beforeEach(() => {
originalInitData = lynx.__initData;
originalReportError = lynx.reportError;
+ originalGetJSModule = lynx.getJSModule;
+ emitter = new LynxTestEventEmitter();
lynx.__initData = {};
lynx.reportError = vi.fn();
+ lynx.getJSModule = vi.fn((moduleName: string) => {
+ if (moduleName === 'GlobalEventEmitter') {
+ return emitter;
+ }
+ return originalGetJSModule(moduleName);
+ }) as typeof lynx.getJSModule;
});
afterEach(() => {
lynx.__initData = originalInitData;
lynx.reportError = originalReportError;
+ lynx.getJSModule = originalGetJSModule;
});
- it('COW merges update data and returns the current patch data', () => {
+ it('COW merges update data and emits the current patch data', () => {
const previousInitData = { msg: 'init', stable: true };
+ const listener = vi.fn();
lynx.__initData = previousInitData;
+ emitter.addListener('onDataChanged', listener);
- const restNewData = applyInitDataUpdateFromNative({
+ updateCardData({
msg: 'update',
next: 1,
});
- expect(restNewData).toEqual({ msg: 'update', next: 1 });
expect(lynx.__initData).toEqual({ msg: 'update', stable: true, next: 1 });
expect(lynx.__initData).not.toBe(previousInitData);
+ expect(listener).toHaveBeenCalledWith({ msg: 'update', next: 1 });
expect(lynx.reportError).not.toHaveBeenCalled();
});
it('clears existing initData before RESET updates', () => {
+ const listener = vi.fn();
lynx.__initData = { stale: true, msg: 'init' };
+ emitter.addListener('onDataChanged', listener);
- const restNewData = applyInitDataUpdateFromNative(
+ updateCardData(
{ msg: 'reset' },
{ type: NativeUpdateDataType.RESET },
);
- expect(restNewData).toEqual({ msg: 'reset' });
expect(lynx.__initData).toEqual({ msg: 'reset' });
+ expect(listener).toHaveBeenCalledWith({ msg: 'reset' });
});
it('keeps Snapshot-compatible loose RESET matching', () => {
+ const listener = vi.fn();
lynx.__initData = { stale: true, msg: 'init' };
+ emitter.addListener('onDataChanged', listener);
- const restNewData = applyInitDataUpdateFromNative(
+ updateCardData(
{ msg: 'reset' },
{ type: '1' as unknown as NativeUpdateDataType },
);
- expect(restNewData).toEqual({ msg: 'reset' });
expect(lynx.__initData).toEqual({ msg: 'reset' });
+ expect(listener).toHaveBeenCalledWith({ msg: 'reset' });
});
- it('reports and strips __lynx_timing_flag from the merged data and return value', () => {
+ it('reports and strips __lynx_timing_flag from the merged data and emitted patch', () => {
+ const listener = vi.fn();
lynx.__initData = { msg: 'init' };
+ emitter.addListener('onDataChanged', listener);
- const restNewData = applyInitDataUpdateFromNative({
+ updateCardData({
msg: 'update',
__lynx_timing_flag: '__lynx_timing_actual_fmp',
});
- expect(restNewData).toEqual({ msg: 'update' });
expect(lynx.__initData).toEqual({ msg: 'update' });
+ expect(listener).toHaveBeenCalledWith({ msg: 'update' });
expect(lynx.reportError).toHaveBeenCalledTimes(1);
expect(lynx.reportError).toHaveBeenCalledWith(
new Error(
diff --git a/packages/react/runtime/__test__/element-template/lynx/update-data.test.ts b/packages/react/runtime/__test__/element-template/lynx/update-data.test.ts
deleted file mode 100644
index 9d7d2c72b1..0000000000
--- a/packages/react/runtime/__test__/element-template/lynx/update-data.test.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { NativeUpdateDataType } from '../../../src/core/lynx-update-data.js';
-import { updateCardData } from '../../../src/element-template/lynx/update-data.js';
-import { ElementTemplateEnvManager } from '../test-utils/debug/envManager.js';
-
-type Listener = (...args: unknown[]) => void;
-
-class LynxGlobalEventEmitter {
- private listeners = new Map>();
-
- addListener(eventName: string, listener: Listener): void {
- const listeners = this.listeners.get(eventName);
- if (listeners) {
- listeners.add(listener);
- return;
- }
- this.listeners.set(eventName, new Set([listener]));
- }
-
- removeListener(eventName: string, listener: Listener): void {
- const listeners = this.listeners.get(eventName);
- listeners?.delete(listener);
- }
-
- emit(eventName: string, args?: unknown[]): void {
- for (const listener of this.listeners.get(eventName) ?? []) {
- listener(...(args ?? []));
- }
- }
-}
-
-describe('ElementTemplate updateCardData', () => {
- const envManager = new ElementTemplateEnvManager();
- let originalLynx: typeof lynx;
- let emitter: LynxGlobalEventEmitter;
- let reportError: ReturnType;
-
- function installLynx(initData: Record): void {
- const baseLynx = globalThis.lynx;
- vi.stubGlobal('lynx', {
- ...baseLynx,
- __initData: initData,
- reportError,
- getJSModule(moduleName: string) {
- if (moduleName === 'GlobalEventEmitter') {
- return emitter;
- }
- return baseLynx.getJSModule?.(moduleName);
- },
- });
- }
-
- beforeEach(() => {
- originalLynx = globalThis.lynx;
- emitter = new LynxGlobalEventEmitter();
- reportError = vi.fn();
- envManager.resetEnv('background');
- });
-
- afterEach(() => {
- vi.stubGlobal('lynx', originalLynx);
- });
-
- it('updates initData and emits restNewData through the ET listener channel', () => {
- installLynx({ msg: 'init', stable: true });
- const listener = vi.fn();
- emitter.addListener('onDataChanged', listener);
-
- updateCardData({ msg: 'update', next: 1 });
-
- expect(lynx.__initData).toEqual({ msg: 'update', stable: true, next: 1 });
- expect(listener).toHaveBeenCalledWith({ msg: 'update', next: 1 });
- expect(reportError).not.toHaveBeenCalled();
- });
-
- it('clears previous initData when RESET is requested', () => {
- installLynx({ stale: true, msg: 'init' });
- const listener = vi.fn();
- emitter.addListener('onDataChanged', listener);
-
- updateCardData(
- { msg: 'reset' },
- { type: NativeUpdateDataType.RESET },
- );
-
- expect(lynx.__initData).toEqual({ msg: 'reset' });
- expect(listener).toHaveBeenCalledWith({ msg: 'reset' });
- });
-
- it('reports and strips __lynx_timing_flag before emitting onDataChanged', () => {
- installLynx({ msg: 'init' });
- const listener = vi.fn();
- emitter.addListener('onDataChanged', listener);
-
- updateCardData({
- msg: 'update',
- __lynx_timing_flag: '__lynx_timing_actual_fmp',
- });
-
- expect(lynx.__initData).toEqual({ msg: 'update' });
- expect(listener).toHaveBeenCalledWith({ msg: 'update' });
- expect(reportError).toHaveBeenCalledTimes(1);
- expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toBe(
- 'Received unsupported updateData with `__lynx_timing_flag` (value "__lynx_timing_actual_fmp"), the timing flag is ignored',
- );
- });
-});
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 08d4825538..0c8d4b6fd4 100644
--- a/packages/react/runtime/__test__/element-template/native/index.test.ts
+++ b/packages/react/runtime/__test__/element-template/native/index.test.ts
@@ -30,7 +30,7 @@ describe('element-template native index wiring', () => {
vi.doUnmock('../../../src/element-template/debug/profile.js');
vi.doUnmock('../../../src/element-template/lynx/env.js');
vi.doUnmock('../../../src/element-template/lynx/performance.js');
- vi.doUnmock('../../../src/element-template/lynx/update-data.js');
+ vi.doUnmock('../../../src/core/lynx-update-data.js');
vi.doUnmock('../../../src/element-template/runtime/page/root-instance.js');
});
@@ -151,7 +151,7 @@ describe('element-template native index wiring', () => {
vi.doMock('../../../src/element-template/lynx/performance.js', () => ({
initTimingAPI,
}));
- vi.doMock('../../../src/element-template/lynx/update-data.js', () => ({
+ vi.doMock('../../../src/core/lynx-update-data.js', () => ({
updateCardData,
}));
vi.doMock('../../../src/element-template/runtime/page/root-instance.js', () => ({
diff --git a/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx
index e97001808b..0623362616 100644
--- a/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx
+++ b/packages/react/runtime/__test__/element-template/runtime/background/init-data-compiled-fixtures.test.tsx
@@ -13,7 +13,7 @@ import {
resetElementTemplateHydrationListener,
} from '../../../../src/element-template/background/hydration-listener.js';
import { root } from '../../../../src/element-template/index.js';
-import { updateCardData } from '../../../../src/element-template/lynx/update-data.js';
+import { updateCardData } from '../../../../src/core/lynx-update-data.js';
import {
installElementTemplatePatchListener,
resetElementTemplatePatchListener,
@@ -22,6 +22,7 @@ import { ElementTemplateLifecycleConstant } from '../../../../src/element-templa
import type { ElementTemplateUpdateCommitContext } from '../../../../src/element-template/protocol/types.js';
import { __page } from '../../../../src/element-template/runtime/page/page.js';
import { clearEtAttrPlanMap } from '../../../../src/element-template/runtime/template/attr-slot-plan.js';
+import { LynxTestEventEmitter } from '../../../test-utils/lynx-event-emitter.js';
import { compileFixtureSource } from '../../test-utils/debug/compiledFixtureCompiler.js';
import { loadCompiledFixtureModule } from '../../test-utils/debug/compiledFixtureModule.js';
import type { CompiledFixtureModuleExports } from '../../test-utils/debug/compiledFixtureModule.js';
@@ -35,31 +36,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const FIXTURE = path.resolve(__dirname, '../../fixtures/background/init-data-update/index.tsx');
-type Listener = (...args: unknown[]) => void;
-
-class LynxTestEventEmitter {
- private listeners = new Map>();
-
- addListener(eventName: string, listener: Listener): void {
- const listeners = this.listeners.get(eventName);
- if (listeners) {
- listeners.add(listener);
- return;
- }
- this.listeners.set(eventName, new Set([listener]));
- }
-
- removeListener(eventName: string, listener: Listener): void {
- this.listeners.get(eventName)?.delete(listener);
- }
-
- emit(eventName: string, args?: unknown[]): void {
- for (const listener of this.listeners.get(eventName) ?? []) {
- listener(...(args ?? []));
- }
- }
-}
-
function waitForRender(): Promise {
return new Promise(resolve => setTimeout(resolve, 0));
}
diff --git a/packages/react/runtime/__test__/test-utils/lynx-event-emitter.ts b/packages/react/runtime/__test__/test-utils/lynx-event-emitter.ts
new file mode 100644
index 0000000000..ab84fb7d5f
--- /dev/null
+++ b/packages/react/runtime/__test__/test-utils/lynx-event-emitter.ts
@@ -0,0 +1,63 @@
+// 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.
+
+export type LynxTestEventListener = (...args: unknown[]) => void;
+
+export class LynxTestEventEmitter {
+ readonly listeners = new Map();
+
+ addListener(eventName: string, listener: LynxTestEventListener): void {
+ const listeners = this.listeners.get(eventName);
+ if (listeners) {
+ listeners.push(listener);
+ return;
+ }
+ this.listeners.set(eventName, [listener]);
+ }
+
+ removeListener(eventName: string, listener: LynxTestEventListener): void {
+ const listeners = this.listeners.get(eventName);
+ if (!listeners) {
+ return;
+ }
+ const index = listeners.indexOf(listener);
+ if (index === -1) {
+ return;
+ }
+ listeners.splice(index, 1);
+ if (listeners.length === 0) {
+ this.listeners.delete(eventName);
+ }
+ }
+
+ removeAllListeners(eventName?: string): void {
+ if (eventName) {
+ this.listeners.delete(eventName);
+ return;
+ }
+ this.clear();
+ }
+
+ emit(eventName: string, args?: unknown[]): void {
+ for (const listener of [...(this.listeners.get(eventName) ?? [])]) {
+ listener(...(args ?? []));
+ }
+ }
+
+ trigger(eventName: string, params: string | Record): void {
+ this.emit(eventName, [params]);
+ }
+
+ toggle(eventName: string, ...data: unknown[]): void {
+ this.emit(eventName, data);
+ }
+
+ clear(): void {
+ this.listeners.clear();
+ }
+
+ listenerCount(eventName: string): number {
+ return this.listeners.get(eventName)?.length ?? 0;
+ }
+}
diff --git a/packages/react/runtime/src/core/lynx-update-data.ts b/packages/react/runtime/src/core/lynx-update-data.ts
index f358394f0d..c3a9be1504 100644
--- a/packages/react/runtime/src/core/lynx-update-data.ts
+++ b/packages/react/runtime/src/core/lynx-update-data.ts
@@ -15,10 +15,10 @@ export interface NativeUpdateDataOptions {
type InitDataPatch = Record;
-export function applyInitDataUpdateFromNative(
+export function updateCardData(
newData: InitDataPatch,
options?: NativeUpdateDataOptions,
-): InitDataPatch {
+): void {
const { ['__lynx_timing_flag']: performanceTimingFlag, ...restNewData } = newData;
if (performanceTimingFlag) {
lynx.reportError(
@@ -36,5 +36,5 @@ export function applyInitDataUpdateFromNative(
// COW keeps provider/consumer readers aligned with Snapshot updateData behavior.
lynx.__initData = Object.assign({}, lynx.__initData, restNewData);
- return restNewData;
+ lynx.getJSModule('GlobalEventEmitter').emit('onDataChanged', [restNewData]);
}
diff --git a/packages/react/runtime/src/element-template/lynx/update-data.ts b/packages/react/runtime/src/element-template/lynx/update-data.ts
deleted file mode 100644
index a60c17d9d1..0000000000
--- a/packages/react/runtime/src/element-template/lynx/update-data.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// 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 { applyInitDataUpdateFromNative } from '../../core/lynx-update-data.js';
-import type { NativeUpdateDataOptions } from '../../core/lynx-update-data.js';
-
-interface LynxGlobalEventEmitter {
- emit: (eventName: string, args?: unknown[]) => void;
-}
-
-export function updateCardData(
- newData: Record,
- options?: NativeUpdateDataOptions,
-): void {
- const restNewData = applyInitDataUpdateFromNative(newData, options);
- (lynx.getJSModule('GlobalEventEmitter') as LynxGlobalEventEmitter).emit('onDataChanged', [restNewData]);
-}
diff --git a/packages/react/runtime/src/element-template/native/index.ts b/packages/react/runtime/src/element-template/native/index.ts
index b94042dada..fc62dc06c8 100644
--- a/packages/react/runtime/src/element-template/native/index.ts
+++ b/packages/react/runtime/src/element-template/native/index.ts
@@ -7,6 +7,7 @@ import { injectCalledByNative } from './main-thread-api.js';
import { installOnMtsDestruction } from './mts-destroy.js';
import { installElementTemplatePatchListener } from './patch-listener.js';
import { installMainThreadHooks } from '../../core/hooks/mainThreadImpl.js';
+import { updateCardData } from '../../core/lynx-update-data.js';
import { installElementTemplateCommitHook } from '../background/commit-hook.js';
import { setupBackgroundElementTemplateDocument } from '../background/document.js';
import { installElementTemplateHydrationListener } from '../background/hydration-listener.js';
@@ -15,7 +16,6 @@ import { initElementTemplatePAPICallAlog } from '../debug/elementPAPICall.js';
import { initProfileHook } from '../debug/profile.js';
import { setupLynxEnv } from '../lynx/env.js';
import { initTimingAPI } from '../lynx/performance.js';
-import { updateCardData } from '../lynx/update-data.js';
import { publicComponentEvent, publishEvent, resetEventStateForRuntime } from '../prop-adapters/event.js';
import { setRoot } from '../runtime/page/root-instance.js';
diff --git a/packages/react/runtime/src/snapshot/lynx/tt.ts b/packages/react/runtime/src/snapshot/lynx/tt.ts
index 604efb686d..942efd5aa9 100644
--- a/packages/react/runtime/src/snapshot/lynx/tt.ts
+++ b/packages/react/runtime/src/snapshot/lynx/tt.ts
@@ -5,7 +5,7 @@ import { process, render } from 'preact';
import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } from './performance.js';
import { runWithForce } from './runWithForce.js';
-import { applyInitDataUpdateFromNative } from '../../core/lynx-update-data.js';
+import { updateCardData } from '../../core/lynx-update-data.js';
import { __root } from '../../root.js';
import { profileEnd, profileStart } from '../../shared/profile.js';
import { CHILDREN } from '../../shared/render-constants.js';
@@ -285,9 +285,4 @@ function updateGlobalProps(newData: Record): void {
lynxCoreInject.tt.GlobalEventEmitter.emit('onGlobalPropsChanged', [lynx.__globalProps]);
}
-function updateCardData(newData: Record, options?: Record): void {
- const restNewData = applyInitDataUpdateFromNative(newData, options);
- lynxCoreInject.tt.GlobalEventEmitter.emit('onDataChanged', [restNewData]);
-}
-
export { injectTt, flushDelayedLifecycleEvents };
diff --git a/packages/react/runtime/vitest.config.ts b/packages/react/runtime/vitest.config.ts
index dd481c0aa0..9ce5995aa6 100644
--- a/packages/react/runtime/vitest.config.ts
+++ b/packages/react/runtime/vitest.config.ts
@@ -116,6 +116,7 @@ export default defineConfig({
'vitest.config.ts',
'__test__/element-template/**',
'__test__/snapshot/utils/**',
+ '__test__/test-utils/**',
'lib/**',
'worklet-runtime/**',
'src/element-template/**',