From 8d705f3171362f5b24acd23a59c1f8d2b0c02ff0 Mon Sep 17 00:00:00 2001 From: hzy <28915578+hzy@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:38:30 +0800 Subject: [PATCH] fix(react): run all pending `renderComponent` before hydrate Run all pending `renderComponent` before hydrate, which ensures some immediate update can be applied in `hydrate`. As background info, ReactLynx will use tree in background-thread as the source-of-truth, so this PR is helpful if main-thread renders more than background-thread's `root.render` by avoiding unwanted node removals. --- .../__test__/lifecycle/updateData.test.jsx | 228 +++++++++++++ .../runtime/__test__/lynx/suspense.test.jsx | 320 ++---------------- .../react/runtime/__test__/render.test.jsx | 3 +- packages/react/runtime/src/lynx-api.ts | 27 +- packages/react/runtime/src/lynx/tt.ts | 12 +- 5 files changed, 279 insertions(+), 311 deletions(-) diff --git a/packages/react/runtime/__test__/lifecycle/updateData.test.jsx b/packages/react/runtime/__test__/lifecycle/updateData.test.jsx index 1e5a0b021b..6ec533412f 100644 --- a/packages/react/runtime/__test__/lifecycle/updateData.test.jsx +++ b/packages/react/runtime/__test__/lifecycle/updateData.test.jsx @@ -8,6 +8,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { replaceCommitHook } from '../../src/lifecycle/patch/commit'; import { deinitGlobalSnapshotPatch } from '../../src/lifecycle/patch/snapshotPatch'; import { InitDataConsumer, InitDataProvider, useInitData, withInitDataInState } from '../../src/lynx-api'; +import { useState } from '../../src/index'; import { __root } from '../../src/root'; import { globalEnvManager } from '../utils/envManager'; import { elementTree, waitSchedule } from '../utils/nativeMethod'; @@ -864,3 +865,230 @@ describe('triggerDataUpdated when jsReady is enabled', () => { } }); }); + +describe('flush pending `renderComponent` before hydrate', () => { + beforeEach(() => { + globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'jsReady'; + }); + + afterEach(() => { + globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; + }); + + it('`updateCardData` before hydrate should take effects', async function() { + function Comp() { + const initData = useInitData(); + + return {initData.msg}; + } + + // main thread render + { + __root.__jsx = ; + renderPage({ msg: 'init' }); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // main thread updatePage + { + __root.__jsx = ; + updatePage({ msg: 'update' }); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // reset back + // lynx.__initData is shared between main thread and background IN TEST + // so we should reset it + lynx.__initData.msg = 'init'; + + // background render + { + globalEnvManager.switchToBackground(); + render(, __root); + } + + // LifecycleConstant.jsReady + { + globalEnvManager.switchToMainThread(); + rLynxJSReady(); + } + + // background updateCardData + { + globalEnvManager.switchToBackground(); + + const spy = vi.spyOn(Component.prototype, 'setState'); + lynxCoreInject.tt.updateCardData({ msg: 'update' }); + expect(spy).toBeCalled(); + spy.mockRestore(); + } + + // hydrate + { + globalEnvManager.switchToBackground(); + // LifecycleConstant.firstScreen + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + expect(rLynxChange[1]).toMatchInlineSnapshot(` + { + "data": "{"patchList":[{"snapshotPatch":[],"id":24}]}", + "patchOptions": { + "isHydration": true, + "pipelineOptions": { + "dsl": "reactLynx", + "needTimestamps": true, + "pipelineID": "pipelineID", + "pipelineOrigin": "reactLynxHydrate", + "stage": "hydrate", + }, + "reloadVersion": 0, + }, + } + `); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + }); + + it('throw in process will not prevent hydrate', async function() { + let _setShouldThrow; + function Comp({ isBackground }) { + const [shouldThrow, setShouldThrow] = useState(); + + _setShouldThrow = setShouldThrow; + + if (shouldThrow) { + throw new Error('initData.shouldThrow is true'); + } + + return isBackground: {`${isBackground}`}; + } + + // main thread render + { + __root.__jsx = ; + renderPage({}); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + + + + `); + } + + // background render + { + globalEnvManager.switchToBackground(); + render(, __root); + _setShouldThrow(true); + } + + // LifecycleConstant.jsReady + { + globalEnvManager.switchToMainThread(); + rLynxJSReady(); + } + + // hydrate + { + globalEnvManager.switchToBackground(); + // LifecycleConstant.firstScreen + const spy = vi.spyOn(lynx, 'reportError'); + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + [Error: initData.shouldThrow is true], + ], + ] + `); + spy.mockRestore(); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + expect(rLynxChange[1]).toMatchInlineSnapshot(` + { + "data": "{"patchList":[{"snapshotPatch":[3,-3,0,"true"],"id":26}]}", + "patchOptions": { + "isHydration": true, + "pipelineOptions": { + "dsl": "reactLynx", + "needTimestamps": true, + "pipelineID": "pipelineID", + "pipelineOrigin": "reactLynxHydrate", + "stage": "hydrate", + }, + "reloadVersion": 0, + }, + } + `); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + + + + `); + } + }); +}); diff --git a/packages/react/runtime/__test__/lynx/suspense.test.jsx b/packages/react/runtime/__test__/lynx/suspense.test.jsx index 26021fb8e7..eaea03d1e0 100644 --- a/packages/react/runtime/__test__/lynx/suspense.test.jsx +++ b/packages/react/runtime/__test__/lynx/suspense.test.jsx @@ -91,33 +91,6 @@ describe('suspense', () => { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - `); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange globalEnvManager.switchToMainThread(); globalThis.__OnLifecycleEvent.mockClear(); @@ -240,76 +213,6 @@ describe('suspense', () => { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - `); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - const data = JSON.parse(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).patchList[0].snapshotPatch; - // A `PreventDestroy` op is inserted to keep the wrapper element alive - expect(prettyFormatSnapshotPatch(data)).toMatchInlineSnapshot(` - [ - { - "id": 4, - "op": "CreateElement", - "type": "div", - }, - { - "childId": -3, - "op": "RemoveChild", - "parentId": -1, - }, - { - "id": 5, - "op": "CreateElement", - "type": "wrapper", - }, - { - "id": 6, - "op": "CreateElement", - "type": "__Card__:__snapshot_a94a8_test_3", - }, - { - "beforeId": null, - "childId": 6, - "op": "InsertBefore", - "parentId": 5, - }, - { - "beforeId": null, - "childId": 5, - "op": "InsertBefore", - "parentId": -1, - }, - ] - `); - vi.runAllTimers(); - // rLynxChange globalEnvManager.switchToMainThread(); globalThis.__OnLifecycleEvent.mockClear(); @@ -347,7 +250,7 @@ describe('suspense', () => { expect(prettyFormatSnapshotPatch(data)).toMatchInlineSnapshot(` [ { - "id": -3, + "id": 2, "op": "CreateElement", "type": "wrapper", }, @@ -367,16 +270,16 @@ describe('suspense', () => { "beforeId": null, "childId": 3, "op": "InsertBefore", - "parentId": -3, + "parentId": 2, }, { "beforeId": null, - "childId": -3, + "childId": 2, "op": "InsertBefore", "parentId": -1, }, { - "childId": 5, + "childId": -3, "op": "RemoveChild", "parentId": -1, }, @@ -393,7 +296,7 @@ describe('suspense', () => { }, { "beforeId": null, - "childId": -3, + "childId": 2, "op": "InsertBefore", "parentId": -1, }, @@ -453,14 +356,16 @@ describe('suspense', () => { const { Suspender, suspended } = createSuspender(); function Comp({ show }) { - return show && ( - loading}> - - - foo - - - + return ( + show && ( + loading}> + + + foo + + + + ) ); } @@ -505,26 +410,6 @@ describe('suspense', () => { vi.runAllTimers(); } - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - // render children { globalEnvManager.switchToBackground(); @@ -564,10 +449,10 @@ describe('suspense', () => { vi.runAllTimers(); expect([...backgroundSnapshotInstanceManager.values.keys()]).toMatchInlineSnapshot(` [ + 2, 3, - -1, - -3, 4, + -1, 7, ] `); @@ -596,7 +481,6 @@ describe('suspense', () => { [ -1, -2, - 4, ] `); @@ -606,8 +490,8 @@ describe('suspense', () => { vi.runAllTimers(); expect([...backgroundSnapshotInstanceManager.values.keys()]).toMatchInlineSnapshot(` [ - -1, 4, + -1, ] `); expect(backgroundSnapshotInstanceManager.values.get(4).type).toBe('div'); @@ -634,14 +518,16 @@ describe('suspense', () => { const { Suspender, suspended } = createSuspender(); function Comp({ show }) { - return show && ( - loading}> - - - foo - - - + return ( + show && ( + loading}> + + + foo + + + + ) ); } @@ -676,26 +562,6 @@ describe('suspense', () => { vi.runAllTimers(); } - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - // render children { globalEnvManager.switchToBackground(); @@ -857,39 +723,6 @@ describe('suspense', () => { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - `); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - const data = JSON.parse(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).patchList[0].snapshotPatch; - vi.runAllTimers(); - // rLynxChange globalEnvManager.switchToMainThread(); globalThis.__OnLifecycleEvent.mockClear(); @@ -1034,38 +867,6 @@ describe('suspense', () => { const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - - `); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(2); - - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange1 = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange1[0]](rLynxChange1[1]); - const rLynxChange2 = lynx.getNativeApp().callLepusMethod.mock.calls[1]; - globalThis[rLynxChange2[0]](rLynxChange2[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); expect(__root.__element_root).toMatchInlineSnapshot(` { // rLynxChange callback globalEnvManager.switchToBackground(); - rLynxChange1[2](); - rLynxChange2[2](); + rLynxChange[2](); vi.runAllTimers(); } @@ -1247,37 +1047,6 @@ describe('suspense', () => { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - - - `); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange globalEnvManager.switchToMainThread(); globalThis.__OnLifecycleEvent.mockClear(); @@ -1534,9 +1303,7 @@ describe('suspense', () => { function Comp({ content }) { return ( loading}> - - {content} - + {content} ); } @@ -1572,33 +1339,6 @@ describe('suspense', () => { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - `); - - // rLynxChange callback - globalEnvManager.switchToBackground(); - rLynxChange[2](); - vi.runAllTimers(); - } - - // render fallback - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - await Promise.resolve().then(() => {}); - expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); - // rLynxChange globalEnvManager.switchToMainThread(); globalThis.__OnLifecycleEvent.mockClear(); diff --git a/packages/react/runtime/__test__/render.test.jsx b/packages/react/runtime/__test__/render.test.jsx index 9ba94bd30e..933ef9ccb2 100644 --- a/packages/react/runtime/__test__/render.test.jsx +++ b/packages/react/runtime/__test__/render.test.jsx @@ -2,7 +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 { render, Component } from 'preact'; +import { render, Component, process } from 'preact'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { replaceCommitHook } from '../src/lifecycle/patch/commit'; @@ -61,6 +61,7 @@ describe('background render', () => { globalEnvManager.switchToBackground(); root.render(); + process(); expect(__root.__firstChild.__firstChild.__values).toEqual([88]); }); }); diff --git a/packages/react/runtime/src/lynx-api.ts b/packages/react/runtime/src/lynx-api.ts index 185e116b01..d1f74694ec 100644 --- a/packages/react/runtime/src/lynx-api.ts +++ b/packages/react/runtime/src/lynx-api.ts @@ -1,7 +1,7 @@ // Copyright 2024 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 { options, render } from 'preact'; +import { render } from 'preact'; import { createContext, createElement } from 'preact/compat'; import { useState } from 'preact/hooks'; import type { Consumer, FC, ReactNode } from 'react'; @@ -88,24 +88,13 @@ export const root: Root = { __root.__jsx = jsx; } else { __root.__jsx = jsx; - let preactProcess: (() => void) | undefined = undefined; - // eslint-disable-next-line @typescript-eslint/unbound-method - const oldDebounceRendering = options.debounceRendering; - options.debounceRendering = (cb) => { - preactProcess = cb; - }; - try { - if (__PROFILE__) { - profileStart('ReactLynx::renderBackground'); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - render(jsx, __root as any); - if (__PROFILE__) { - profileEnd(); - } - (preactProcess as (() => void) | undefined)?.(); - } finally { - options.debounceRendering = oldDebounceRendering!; + if (__PROFILE__) { + profileStart('ReactLynx::renderBackground'); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + render(jsx, __root as any); + if (__PROFILE__) { + profileEnd(); } if (__FIRST_SCREEN_SYNC_TIMING__ === 'immediately') { // This is for cases where `root.render()` is called asynchronously, diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts index 1cc0f84e94..f29c5ad262 100644 --- a/packages/react/runtime/src/lynx/tt.ts +++ b/packages/react/runtime/src/lynx/tt.ts @@ -1,7 +1,7 @@ // Copyright 2024 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 { render } from 'preact'; +import { process, render } from 'preact'; import { LifecycleConstant, NativeUpdateDataType } from '../lifecycleConstant.js'; import type { FirstScreenData } from '../lifecycleConstant.js'; @@ -70,6 +70,12 @@ function onLifecycleEvent([type, data]: [LifecycleConstant, unknown]) { function onLifecycleEventImpl(type: LifecycleConstant, data: unknown): void { switch (type) { case LifecycleConstant.firstScreen: { + let processErr; + try { + process(); + } catch (e) { + processErr = e; + } const { root: lepusSide, jsReadyEventIdSwap } = data as FirstScreenData; if (__PROFILE__) { profileStart('ReactLynx::hydrate'); @@ -126,6 +132,10 @@ function onLifecycleEventImpl(type: LifecycleConstant, data: unknown): void { }); }); runDelayedUiOps(); + + if (processErr) { + throw processErr; + } break; } case LifecycleConstant.globalEventFromLepus: {