diff --git a/.changeset/petite-bobcats-travel.md b/.changeset/petite-bobcats-travel.md
new file mode 100644
index 0000000000..1899806897
--- /dev/null
+++ b/.changeset/petite-bobcats-travel.md
@@ -0,0 +1,7 @@
+---
+"@lynx-js/react": patch
+---
+
+fix: ensure ref lifecycle events run after firstScreen in the background thread
+
+This patch fixes an issue where ref lifecycle events were running before firstScreen events in the background thread async render mode, which could cause refs to be undefined when components try to access them.
diff --git a/packages/react/runtime/__test__/lifecycle/reload.test.jsx b/packages/react/runtime/__test__/lifecycle/reload.test.jsx
index b1d6cfd8f9..c29f4dc52d 100644
--- a/packages/react/runtime/__test__/lifecycle/reload.test.jsx
+++ b/packages/react/runtime/__test__/lifecycle/reload.test.jsx
@@ -9,8 +9,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { BasicBG, ListBG, ListConditionalBG, ViewBG, setObj, setStr } from './reloadBG';
import { BasicMT, ListConditionalMT, ListMT, ViewMT } from './reloadMT';
import { root } from '../../src/index';
+import { delayedEvents, delayedPublishEvent } from '../../src/lifecycle/event/delayEvents';
import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread';
+import { reloadBackground } from '../../src/lifecycle/reload';
import { __root } from '../../src/root';
import { setupPage } from '../../src/snapshot';
import { globalEnvManager } from '../utils/envManager';
@@ -1882,4 +1884,11 @@ describe('firstScreenSyncTiming - jsReady', () => {
lynx.getNativeApp().callLepusMethod.mockClear();
}
});
+
+ it('should clear cached events before reload when js not ready', async function() {
+ delayedPublishEvent('bindEvent:tap', 'test');
+ expect(delayedEvents.length).toBe(1);
+ reloadBackground({});
+ expect(delayedEvents.length).toBe(0);
+ });
});
diff --git a/packages/react/runtime/__test__/snapshot/ref.test.jsx b/packages/react/runtime/__test__/snapshot/ref.test.jsx
index 4c499e6bc6..138904a2a5 100644
--- a/packages/react/runtime/__test__/snapshot/ref.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/ref.test.jsx
@@ -5,7 +5,8 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { Component, createRef, useState } from '../../src/index';
+import { Component, createRef, root, useState } from '../../src/index';
+import { delayedLifecycleEvents } from '../../src/lifecycle/event/delayLifecycleEvents';
import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread';
import { renderBackground as render } from '../../src/lifecycle/render';
@@ -1650,4 +1651,105 @@ describe('element ref in list', () => {
`);
}
});
+
+ it('when __FIRST_SCREEN_SYNC_TIMING__ is jsReady', async function() {
+ globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'jsReady';
+ const refs = [createRef(), createRef(), createRef()];
+ const signs = [0, 0, 0];
+
+ class ListItem extends Component {
+ render() {
+ return ;
+ }
+ }
+
+ class Comp extends Component {
+ render() {
+ return (
+
+ {[0, 1, 2].map((index) => {
+ return (
+
+
+
+ );
+ })}
+
+ );
+ }
+ }
+
+ // main thread render
+ {
+ __root.__jsx = ;
+ renderPage();
+ }
+
+ // list render item 1 & 2
+ {
+ signs[0] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 0);
+ expect(globalThis.__OnLifecycleEvent).toHaveBeenCalledTimes(1);
+
+ globalEnvManager.switchToBackground();
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+ globalThis.__OnLifecycleEvent.mockClear();
+ expect(delayedLifecycleEvents).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxRef",
+ {
+ "commitTaskId": undefined,
+ "refPatch": "{"-4:0:":62}",
+ },
+ ],
+ ]
+ `);
+ }
+
+ // background render
+ {
+ globalEnvManager.switchToBackground();
+ root.render(, __root);
+ expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
+ expect(lynx.getNativeApp().callLepusMethod.mock.calls[0]).toMatchInlineSnapshot(`
+ [
+ "rLynxJSReady",
+ {},
+ ]
+ `);
+ globalEnvManager.switchToMainThread();
+ const rLynxJSReady = lynx.getNativeApp().callLepusMethod.mock.calls[0];
+ globalThis[rLynxJSReady[0]](rLynxJSReady[1]);
+ lynx.getNativeApp().callLepusMethod.mockClear();
+ expect(globalThis.__OnLifecycleEvent).toHaveBeenCalledTimes(1);
+ expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ [
+ "rLynxFirstScreen",
+ {
+ "jsReadyEventIdSwap": {},
+ "refPatch": "{}",
+ "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_24","children":[{"id":-3,"type":"__Card__:__snapshot_a94a8_test_25","values":[{"item-key":0}],"children":[{"id":-4,"type":"__Card__:__snapshot_a94a8_test_23","values":["-4:0:"]}]},{"id":-5,"type":"__Card__:__snapshot_a94a8_test_25","values":[{"item-key":1}],"children":[{"id":-6,"type":"__Card__:__snapshot_a94a8_test_23","values":["-6:0:"]}]},{"id":-7,"type":"__Card__:__snapshot_a94a8_test_25","values":[{"item-key":2}],"children":[{"id":-8,"type":"__Card__:__snapshot_a94a8_test_23","values":["-8:0:"]}]}]}]}",
+ },
+ ],
+ ],
+ ]
+ `);
+ }
+
+ {
+ globalEnvManager.switchToBackground();
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+ globalThis.__OnLifecycleEvent.mockClear();
+
+ expect(refs[0].current).toMatchInlineSnapshot(`
+ {
+ "selectUniqueID": [Function],
+ "uid": 62,
+ }
+ `);
+ }
+ globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately';
+ });
});
diff --git a/packages/react/runtime/src/lifecycle/destroy.ts b/packages/react/runtime/src/lifecycle/destroy.ts
index 7ca26d6b72..bdbf4fa6c9 100644
--- a/packages/react/runtime/src/lifecycle/destroy.ts
+++ b/packages/react/runtime/src/lifecycle/destroy.ts
@@ -2,6 +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 { __root } from '../root.js';
+import { delayedEvents } from './event/delayEvents.js';
+import { delayedLifecycleEvents } from './event/delayLifecycleEvents.js';
import { globalCommitTaskMap } from './patch/commit.js';
import { renderBackground } from './render.js';
@@ -16,7 +18,12 @@ function destroyBackground(): void {
task();
});
globalCommitTaskMap.clear();
-
+ // Clear delayed events which should not be executed after destroyed.
+ // This is important when the page is performing a reload.
+ delayedLifecycleEvents.length = 0;
+ if (delayedEvents) {
+ delayedEvents.length = 0;
+ }
if (__PROFILE__) {
console.profileEnd();
}
diff --git a/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts b/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts
index 4df1b7bb80..1b0c61a6da 100644
--- a/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts
+++ b/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts
@@ -1,7 +1,18 @@
+import { LifecycleConstant } from '../../lifecycleConstant.js';
+
const delayedLifecycleEvents: [type: string, data: any][] = [];
function delayLifecycleEvent(type: string, data: any): void {
- delayedLifecycleEvents.push([type, data]);
+ // We need to ensure that firstScreen events are executed before other events.
+ // This is because firstScreen events are used to initialize the dom tree,
+ // and other events depend on the dom tree being fully constructed.
+ // There might be some edge cases where ctx cannot be found in `ref` lifecycle event,
+ // and they should be ignored safely.
+ if (type === LifecycleConstant.firstScreen) {
+ delayedLifecycleEvents.unshift([type, data]);
+ } else {
+ delayedLifecycleEvents.push([type, data]);
+ }
}
/**
diff --git a/packages/react/runtime/src/lynx-api.ts b/packages/react/runtime/src/lynx-api.ts
index c9262b12f8..25b274c0bd 100644
--- a/packages/react/runtime/src/lynx-api.ts
+++ b/packages/react/runtime/src/lynx-api.ts
@@ -87,12 +87,13 @@ export const root: Root = {
} else {
__root.__jsx = jsx;
renderBackground(jsx, __root as any);
- if (__FIRST_SCREEN_SYNC_TIMING__ === 'immediately') {}
- else {
+ if (__FIRST_SCREEN_SYNC_TIMING__ === 'immediately') {
+ // This is for cases where `root.render()` is called asynchronously,
+ // `firstScreen` message might have been reached.
+ flushDelayedLifecycleEvents();
+ } else {
lynx.getNativeApp().callLepusMethod(LifecycleConstant.jsReady, {});
}
-
- flushDelayedLifecycleEvents();
}
},
registerDataProcessors: (dataProcessorDefinition: DataProcessorDefinition): void => {
diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts
index 90873fa037..b6b971a17f 100644
--- a/packages/react/runtime/src/lynx/tt.ts
+++ b/packages/react/runtime/src/lynx/tt.ts
@@ -131,6 +131,8 @@ async function onLifecycleEventImpl(type: string, data: any): Promise {
}
markTiming(PerformanceTimingKeys.diff_vdom_end);
+ // TODO: It seems `delayedEvents` and `delayedLifecycleEvents` should be merged into one array to ensure the proper order of events.
+ flushDelayedLifecycleEvents();
if (delayedEvents) {
delayedEvents.forEach((args) => {
const [handlerName, data] = args;
@@ -144,6 +146,7 @@ async function onLifecycleEventImpl(type: string, data: any): Promise {
});
delayedEvents.length = 0;
}
+
lynxCoreInject.tt.publishEvent = publishEvent;
lynxCoreInject.tt.publicComponentEvent = publicComponentEvent;