From 592be5fe7f8bdc16a0f1d8860b0da60232c2c190 Mon Sep 17 00:00:00 2001 From: hzy <28915578+hzy@users.noreply.github.com> Date: Wed, 23 Jul 2025 21:20:48 +0800 Subject: [PATCH 1/4] fix(react): support profile in production build --- .changeset/forty-aliens-play.md | 11 + packages/react/runtime/__test__/debug/hook.js | 3 +- .../runtime/__test__/debug/profile.test.jsx | 28 ++- .../__test__/lifecycle/reload.test.jsx | 21 ++ .../__test__/lifecycle/updateData.test.jsx | 24 +++ .../runtime/__test__/lynx/timing.test.jsx | 18 ++ .../react/runtime/__test__/utils/globals.js | 27 +++ packages/react/runtime/src/debug/profile.ts | 192 ++++++++++++++---- .../runtime/src/lifecycle/patch/commit.ts | 167 ++++++++------- .../src/lifecycle/patch/updateMainThread.ts | 13 ++ packages/react/runtime/src/lynx.ts | 8 +- .../runtime/src/renderToOpcodes/index.ts | 5 +- .../web-constants/src/types/NativeApp.ts | 15 ++ .../background-apis/createPerformanceApis.ts | 14 ++ 14 files changed, 418 insertions(+), 128 deletions(-) create mode 100644 .changeset/forty-aliens-play.md diff --git a/.changeset/forty-aliens-play.md b/.changeset/forty-aliens-play.md new file mode 100644 index 0000000000..a8687b9e26 --- /dev/null +++ b/.changeset/forty-aliens-play.md @@ -0,0 +1,11 @@ +--- +"@lynx-js/react": patch +--- + +Add profile in production build: + +1. `diff:__COMPONENT_NAME__`: how long ReactLynx diff took. +2. `render:__COMPONENT_NAME__`: how long your render function took. +3. `setState`: an instant trace event, indicate when your setState was called. + +NOTE: `__COMPONENT_NAME__` may be unreadable when minified, setting `displayName` may help. diff --git a/packages/react/runtime/__test__/debug/hook.js b/packages/react/runtime/__test__/debug/hook.js index ff6b51abe6..571edf04b3 100644 --- a/packages/react/runtime/__test__/debug/hook.js +++ b/packages/react/runtime/__test__/debug/hook.js @@ -4,10 +4,11 @@ import { options } from 'preact'; import { vi } from 'vitest'; -import { DIFF, DIFFED, RENDER } from '../../src/renderToOpcodes/constants'; +import { DIFF, DIFF2, DIFFED, RENDER } from '../../src/renderToOpcodes/constants'; export const noop = vi.fn(); options[DIFF] = noop; +options[DIFF2] = noop; options[RENDER] = noop; options[DIFFED] = noop; diff --git a/packages/react/runtime/__test__/debug/profile.test.jsx b/packages/react/runtime/__test__/debug/profile.test.jsx index 9f4418c05c..19e076d7b1 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -26,14 +26,12 @@ describe('profile', () => { }); test('original options hooks should be called', async () => { - vi.stubGlobal('__JS__', true); - render( null, scratch, ); - expect(noop).toBeCalledTimes(3); + expect(noop).toBeCalledTimes(4); }); test('diff and render should be profiled', async () => { @@ -59,18 +57,18 @@ describe('profile', () => { scratch, ); - // render:: - expect(console.profile).toBeCalledWith(`render::Foo`); - expect(console.profile).not.toBeCalledWith(`render::Bar`); - expect(console.profile).toBeCalledWith(`render::Baz`); - expect(console.profile).not.toBeCalledWith(`render::ClassComponent`); - expect(console.profile).toBeCalledWith(`render::Clazz`); + // // ReactLynx::render:: + expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::render::Foo`); + expect(lynx.performance.profileStart).not.toBeCalledWith(`ReactLynx::render::Bar`); + expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::render::Baz`); + expect(lynx.performance.profileStart).not.toBeCalledWith(`ReactLynx::render::ClassComponent`); + expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::render::Clazz`); - // diff:: - expect(console.profile).toBeCalledWith(`diff::Foo`); - expect(console.profile).not.toBeCalledWith(`diff::Bar`); - expect(console.profile).toBeCalledWith(`diff::Baz`); - expect(console.profile).not.toBeCalledWith(`diff::ClassComponent`); - expect(console.profile).toBeCalledWith(`diff::Clazz`); + // // ReactLynx::diff:: + expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::diff::Foo`, {}); + expect(lynx.performance.profileStart).not.toBeCalledWith(`ReactLynx::diff::Bar`); + expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::diff::Baz`, {}); + expect(lynx.performance.profileStart).not.toBeCalledWith(`ReactLynx::diff::ClassComponent`); + expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::diff::Clazz`, {}); }); }); diff --git a/packages/react/runtime/__test__/lifecycle/reload.test.jsx b/packages/react/runtime/__test__/lifecycle/reload.test.jsx index f811d42173..0982b3482b 100644 --- a/packages/react/runtime/__test__/lifecycle/reload.test.jsx +++ b/packages/react/runtime/__test__/lifecycle/reload.test.jsx @@ -127,6 +127,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":3,"snapshotPatch":[3,-5,0,{"dataX2":"WorldX2"},3,-8,0,"update",3,-6,0,{"attr":{"dataX2":"WorldX2"}}]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, } @@ -235,6 +238,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":4,"snapshotPatch":[3,-8,0,"???"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, } @@ -384,6 +390,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":8,"snapshotPatch":[3,-16,0,"update"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 2, }, } @@ -530,6 +539,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":11,"snapshotPatch":[3,-5,0,{"dataX2":"WorldX2"},3,-8,0,"update",3,-6,0,{"attr":{"dataX2":"WorldX2"}}]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 2, }, } @@ -642,6 +654,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":12,"snapshotPatch":[3,-8,0,"???"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 2, }, } @@ -795,6 +810,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":16,"snapshotPatch":[3,-17,0,"update"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 4, }, } @@ -980,6 +998,9 @@ describe('reload', () => { { "data": "{"patchList":[{"id":19,"snapshotPatch":[3,-6,0,"d",3,-7,0,"e",3,-8,0,"f"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 4, }, }, diff --git a/packages/react/runtime/__test__/lifecycle/updateData.test.jsx b/packages/react/runtime/__test__/lifecycle/updateData.test.jsx index e04b373756..4fa040fff4 100644 --- a/packages/react/runtime/__test__/lifecycle/updateData.test.jsx +++ b/packages/react/runtime/__test__/lifecycle/updateData.test.jsx @@ -140,6 +140,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":3,"snapshotPatch":[3,-3,0,"update"]}],"flushOptions":{"triggerDataUpdated":true}}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -277,6 +280,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":6}],"flushOptions":{"triggerDataUpdated":true}}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -287,6 +293,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":7,"snapshotPatch":[3,-6,0,"update"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -297,6 +306,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":8,"snapshotPatch":[3,-8,0,"update"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -467,6 +479,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":11}],"flushOptions":{"triggerDataUpdated":true}}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -477,6 +492,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":12,"snapshotPatch":[3,-5,0,"update"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -487,6 +505,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":13,"snapshotPatch":[3,-7,0,"update"]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, @@ -628,6 +649,9 @@ describe('triggerDataUpdated', () => { { "data": "{"patchList":[{"id":16,"snapshotPatch":[3,-3,0,"update"]}],"flushOptions":{"triggerDataUpdated":true}}", "patchOptions": { + "flowIds": [ + 666, + ], "reloadVersion": 0, }, }, diff --git a/packages/react/runtime/__test__/lynx/timing.test.jsx b/packages/react/runtime/__test__/lynx/timing.test.jsx index 1151641770..50b9740e90 100644 --- a/packages/react/runtime/__test__/lynx/timing.test.jsx +++ b/packages/react/runtime/__test__/lynx/timing.test.jsx @@ -100,6 +100,9 @@ describe('setState timing api', () => { { "data": "{"patchList":[{"id":3,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_2",3,0,null,4,3,4,0,1,1,3,4,null,1,-2,3,null]}],"flushOptions":{"__lynx_timing_flag":"__lynx_timing_actual_fmp"}}", "patchOptions": { + "flowIds": [ + 666, + ], "pipelineOptions": { "dsl": "reactLynx", "needTimestamps": false, @@ -179,6 +182,9 @@ describe('attribute timing api', () => { { "data": "{"patchList":[{"id":6,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",3,4,3,[{"__ltf":"__lynx_timing_actual_fmp"}],0,null,4,3,4,0,1,1,3,4,null,1,-2,3,null]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "pipelineOptions": { "dsl": "reactLynx", "needTimestamps": true, @@ -368,6 +374,9 @@ describe('attribute timing api', () => { { "data": "{"patchList":[{"id":9,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_6",3,4,3,[{"__ltf":"__lynx_timing_actual_fmp"}],0,null,4,3,4,0,1,1,3,4,null,1,-2,3,null]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "pipelineOptions": { "dsl": "reactLynx", "needTimestamps": true, @@ -540,6 +549,9 @@ describe('attribute timing api', () => { { "data": "{"patchList":[{"id":14,"snapshotPatch":[3,-2,1,444]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "pipelineOptions": { "dsl": "reactLynx", "needTimestamps": false, @@ -706,6 +718,9 @@ describe('attribute timing api', () => { { "data": "{"patchList":[{"id":17,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_15",3,4,3,[{"xxx":333,"__lynx_timing_flag":"__lynx_timing_actual_fmp"}],0,null,4,3,4,0,1,1,3,4,null,1,-2,3,null]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "pipelineOptions": { "dsl": "reactLynx", "needTimestamps": true, @@ -819,6 +834,9 @@ describe('attribute timing api', () => { { "data": "{"patchList":[{"id":18,"snapshotPatch":[3,3,0,{"xxx":666,"__lynx_timing_flag":"__lynx_timing_actual_fmp"}]}]}", "patchOptions": { + "flowIds": [ + 666, + ], "pipelineOptions": { "dsl": "reactLynx", "needTimestamps": false, diff --git a/packages/react/runtime/__test__/utils/globals.js b/packages/react/runtime/__test__/utils/globals.js index ed51a9a17f..a1f26bd87b 100644 --- a/packages/react/runtime/__test__/utils/globals.js +++ b/packages/react/runtime/__test__/utils/globals.js @@ -4,6 +4,7 @@ import { vi } from 'vitest'; import { getJSModule } from './jsModule.ts'; +import { beforeEach, afterEach, expect } from 'vitest'; const app = { callLepusMethod: vi.fn(), @@ -35,6 +36,12 @@ const performance = { _bindPipelineIdWithTimingFlag: vi.fn((id, flag) => { performance.__functionCallHistory.push(['_bindPipelineIdWithTimingFlag', id, flag]); }), + + profileStart: vi.fn(), + profileEnd: vi.fn(), + profileMark: vi.fn(), + profileFlowId: vi.fn(() => 666), + isProfileRecording: vi.fn(() => true), }; class SelectorQuery { @@ -133,4 +140,24 @@ function injectGlobals() { console.alog = vi.fn(); } +beforeEach(() => { + performance.profileStart.mockClear(); + performance.profileEnd.mockClear(); +}); + +afterEach((context) => { + const skippedTasks = [ + // Skip preact/debug tests since it would throw errors and abort the rendering process + 'preact/debug', + 'should remove event listener when throw in cleanup', + ]; + if (skippedTasks.some(task => context.task.name.includes(task))) { + return; + } + + expect(performance.profileStart.mock.calls.length).toBe( + performance.profileEnd.mock.calls.length, + ); +}); + injectGlobals(); diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index 8b9f9c12b1..f6a8e1882f 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -1,52 +1,174 @@ // 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 } from 'preact'; -import type { Component, ComponentClass, VNode } from 'preact'; +import { Component, options } from 'preact'; +import type { ComponentClass, VNode } from 'preact'; -import { COMPONENT, DIFF, DIFFED, RENDER } from '../renderToOpcodes/constants.js'; -import { getDisplayName } from '../utils.js'; +import type { TraceOption } from '@lynx-js/types'; + +import { globalPatchOptions } from '../lifecycle/patch/commit.js'; +import { __globalSnapshotPatch } from '../lifecycle/patch/snapshotPatch.js'; +import { COMMIT, COMPONENT, DIFF, DIFF2, DIFFED, DIRTY, NEXT_STATE, RENDER } from '../renderToOpcodes/constants.js'; +import { getDisplayName, hook } from '../utils.js'; export function initProfileHook(): void { - const oldDiff = options[DIFF]; - options[DIFF] = function(vnode: VNode) { - // This __PROFILE__ is used for DCE testing - if (__PROFILE__ && typeof vnode.type === 'function') { - // We only add profiling trace for Component - console.profile(`diff::${getDisplayName(vnode.type as ComponentClass)}`); + // early-exit if required profiling APIs are unavailable + let p; + /* v8 ignore start */ + if ( + !(p = lynx.performance) + || typeof p.profileStart !== 'function' + || typeof p.profileEnd !== 'function' + || typeof p.profileMark !== 'function' + || typeof p.profileFlowId !== 'function' + ) { + return; + } + /* v8 ignore stop */ + + const profileStart = p.profileStart.bind(p); + const profileEnd = p.profileEnd.bind(p); + const profileMark = p.profileMark.bind(p); + const profileFlowId = p.profileFlowId.bind(p); + + // for each setState call, we will add a profiling trace and + // attach a flowId to the component instance. + // This allows us to trace the flow of its diffing, committing and patching. + { + const sFlowID = Symbol.for('FLOW_ID'); + type PatchedComponent = Component & { [sFlowID]?: number }; + + if (__BACKGROUND__) { + function buildSetStateProfileMarkArgs( + currentState: Record, + nextState: Record, + ): Record { + const EMPTY_OBJ = {}; + + currentState ??= EMPTY_OBJ; + nextState ??= EMPTY_OBJ; + + return { + 'current state keys': JSON.stringify(Object.keys(currentState)), + 'next state keys': JSON.stringify(Object.keys(nextState)), + 'changed (shallow diff) state keys': JSON.stringify( + // the setState is in assign manner, we assume nextState is a superset of currentState + Object.keys(nextState).filter( + key => currentState[key] !== nextState[key], + ), + ), + }; + } + + hook( + Component.prototype, + 'setState', + function(this: PatchedComponent & { [NEXT_STATE]: unknown }, old, state, callback) { + old?.call(this, state, callback); + + if (this[DIRTY]) { + profileMark('ReactLynx::setState', { + flowId: this[sFlowID] ??= profileFlowId(), + args: buildSetStateProfileMarkArgs( + this.state as Record, + this[NEXT_STATE] as Record, + ), + }); + } + }, + ); } - oldDiff?.(vnode); - }; - const oldDiffed = options[DIFFED]; - options[DIFFED] = function(vnode) { - // This __PROFILE__ is used for DCE testing - if (__PROFILE__ && typeof vnode.type === 'function') { - console.profileEnd(); // for options[DIFF] + + hook(options, DIFF2, (old, vnode, oldVNode) => { + // We only add profiling trace for Component + if (typeof vnode.type === 'function') { + const profileOptions: TraceOption = {}; + + if (__BACKGROUND__) { + const c: PatchedComponent = oldVNode[COMPONENT]!; + if (c) { + const flowId = c[sFlowID]; + delete c[sFlowID]; + if (flowId) { + globalPatchOptions.flowIds ??= []; + globalPatchOptions.flowIds.push(flowId); + profileOptions.flowId = flowId; + } + } + } + + profileStart( + `ReactLynx::diff::${/* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass)}`, + profileOptions, + ); + } + old?.(vnode, oldVNode); + }); + + hook(options, DIFFED, (old, vnode) => { + if (typeof vnode.type === 'function') { + profileEnd(); // for options[DIFF] + } + old?.(vnode); + }); + + if (__BACKGROUND__) { + hook(options, COMMIT, (old, vnode, commitQueue) => { + profileStart('ReactLynx::commit', { + ...globalPatchOptions.flowIds + ? { + flowId: globalPatchOptions.flowIds[0], + flowIds: globalPatchOptions.flowIds, + } + : {}, + }); + old?.(vnode, commitQueue); + profileEnd(); + }); } - oldDiffed?.(vnode); - }; + } // Profile the user-provided `render`. - const oldRender = options[RENDER]; - options[RENDER] = function(vnode: VNode & { [COMPONENT]: Component }) { - const displayName = getDisplayName(vnode.type as ComponentClass); + hook(options, RENDER, (old, vnode: VNode) => { // eslint-disable-next-line @typescript-eslint/unbound-method - const originalRender = vnode[COMPONENT].render; - vnode[COMPONENT].render = function render(this, props, state, context) { - // This __PROFILE__ is used for DCE testing - if (__PROFILE__) { - console.profile(`render::${displayName}`); - } + const originalRender = vnode[COMPONENT]!.render; + vnode[COMPONENT]!.render = function render(this, props, state, context) { + profileStart(`ReactLynx::render::${/* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass)}`); try { return originalRender.call(this, props, state, context); } finally { - // This __PROFILE__ is used for DCE testing - if (__PROFILE__) { - console.profileEnd(); - } - vnode[COMPONENT].render = originalRender; + profileEnd(); + vnode[COMPONENT]!.render = originalRender; } }; - oldRender?.(vnode); - }; + old?.(vnode); + }); + + if (__BACKGROUND__) { + const sPatchLength = Symbol.for('PATCH_LENGTH'); + + type PatchedVNode = VNode & { [sPatchLength]?: number }; + + hook(options, DIFF, (old, vnode: PatchedVNode) => { + if (typeof vnode.type === 'function' && __globalSnapshotPatch) { + vnode[sPatchLength] = __globalSnapshotPatch.length; + } + old?.(vnode); + }); + + hook(options, DIFFED, (old, vnode: PatchedVNode) => { + if (typeof vnode.type === 'function' && __globalSnapshotPatch) { + if (vnode[sPatchLength] === __globalSnapshotPatch.length) { + // "NoPatch" is a conventional name in Lynx + profileMark('ReactLynx::diffFinishNoPatch', { + args: { + componentName: /* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass), + }, + }); + } + delete vnode[sPatchLength]; + } + old?.(vnode); + }); + } } diff --git a/packages/react/runtime/src/lifecycle/patch/commit.ts b/packages/react/runtime/src/lifecycle/patch/commit.ts index f5ced7172a..b85df21440 100644 --- a/packages/react/runtime/src/lifecycle/patch/commit.ts +++ b/packages/react/runtime/src/lifecycle/patch/commit.ts @@ -19,7 +19,6 @@ * its [options](https://preactjs.com/guide/v10/options/) API */ -import type { VNode } from 'preact'; import { options } from 'preact'; import { LifecycleConstant } from '../../lifecycleConstant.js'; @@ -27,13 +26,18 @@ import { globalPipelineOptions, markTiming, markTimingLegacy, setPipeline } from import { COMMIT } from '../../renderToOpcodes/constants.js'; import { applyQueuedRefs } from '../../snapshot/ref.js'; import { backgroundSnapshotInstanceManager } from '../../snapshot.js'; -import { isEmptyObject } from '../../utils.js'; +import { hook, isEmptyObject } from '../../utils.js'; import { takeWorkletRefInitValuePatch } from '../../worklet/workletRefPool.js'; import { getReloadVersion } from '../pass.js'; import type { SnapshotPatch } from './snapshotPatch.js'; import { takeGlobalSnapshotPatch } from './snapshotPatch.js'; let globalFlushOptions: FlushOptions = {}; +function takeGlobalFlushOptions() { + const res = globalFlushOptions; + globalFlushOptions = {}; + return res; +} const globalCommitTaskMap: Map void> = /*@__PURE__*/ new Map void>(); let nextCommitTaskId = 1; @@ -65,91 +69,108 @@ interface PatchOptions { pipelineOptions?: PipelineOptions; reloadVersion: number; isHydration?: boolean; + flowIds?: number[]; +} + +/** + * Allow to pass options to the patch operation + */ +export type GlobalPatchOptions = Omit; +export let globalPatchOptions: GlobalPatchOptions = {}; +function takeGlobalPatchOptions(): GlobalPatchOptions { + const res = globalPatchOptions; + globalPatchOptions = {}; + return res; } /** * Replaces Preact's default commit hook with our custom implementation */ function replaceCommitHook(): void { - // This is actually not used since Preact use `hooks._commit` for callbacks of `useLayoutEffect`. - const originalPreactCommit = options[COMMIT]; - const commit = async (vnode: VNode, commitQueue: any[]) => { - // Skip commit phase for MT runtime - if (__MAIN_THREAD__) { - // for testing only - commitQueue.length = 0; - return; - } - - // Mark the end of virtual DOM diffing phase for performance tracking - markTimingLegacy('updateDiffVdomEnd'); - markTiming('diffVdomEnd'); - - const backgroundSnapshotInstancesToRemove = globalBackgroundSnapshotInstancesToRemove; - globalBackgroundSnapshotInstancesToRemove = []; - - const commitTaskId = genCommitTaskId(); - - // Register the commit task - globalCommitTaskMap.set(commitTaskId, () => { - if (backgroundSnapshotInstancesToRemove.length) { - setTimeout(() => { - backgroundSnapshotInstancesToRemove.forEach(id => { - backgroundSnapshotInstanceManager.values.get(id)?.tearDown(); - }); - }, 10000); + hook( + options, + COMMIT, + ( + originalPreactCommit, // This is actually not used since Preact use `hooks._commit` for callbacks of `useLayoutEffect`. + vnode, + commitQueue, + ) => { + // Skip commit phase for MT runtime + if (__MAIN_THREAD__) { + // for testing only + commitQueue.length = 0; + return; } - }); - - // Collect patches for this update - const snapshotPatch = takeGlobalSnapshotPatch(); - const flushOptions = globalFlushOptions; - const workletRefInitValuePatch = takeWorkletRefInitValuePatch(); - globalFlushOptions = {}; - if (!snapshotPatch && workletRefInitValuePatch.length === 0) { - // before hydration, skip patch - applyQueuedRefs(); - originalPreactCommit?.(vnode, commitQueue); - return; - } - - const patch: Patch = { - id: commitTaskId, - }; - // TODO: check all fields in `flushOptions` from runtime3 - if (snapshotPatch?.length) { - patch.snapshotPatch = snapshotPatch; - } - if (workletRefInitValuePatch.length) { - patch.workletRefInitValuePatch = workletRefInitValuePatch; - } - const patchList: PatchList = { - patchList: [patch], - }; - if (!isEmptyObject(flushOptions)) { - patchList.flushOptions = flushOptions; - } - const obj = commitPatchUpdate(patchList, {}); - - // Send the update to the native layer - lynx.getNativeApp().callLepusMethod(LifecycleConstant.patchUpdate, obj, () => { - const commitTask = globalCommitTaskMap.get(commitTaskId); - if (commitTask) { - commitTask(); - globalCommitTaskMap.delete(commitTaskId); + + // Mark the end of virtual DOM diffing phase for performance tracking + markTimingLegacy('updateDiffVdomEnd'); + markTiming('diffVdomEnd'); + + const backgroundSnapshotInstancesToRemove = globalBackgroundSnapshotInstancesToRemove; + globalBackgroundSnapshotInstancesToRemove = []; + + const commitTaskId = genCommitTaskId(); + + // Register the commit task + globalCommitTaskMap.set(commitTaskId, () => { + if (backgroundSnapshotInstancesToRemove.length) { + setTimeout(() => { + backgroundSnapshotInstancesToRemove.forEach(id => { + backgroundSnapshotInstanceManager.values.get(id)?.tearDown(); + }); + }, 10000); + } + }); + + // Collect patches for this update + const snapshotPatch = takeGlobalSnapshotPatch(); + const flushOptions = takeGlobalFlushOptions(); + const patchOptions = takeGlobalPatchOptions(); + const workletRefInitValuePatch = takeWorkletRefInitValuePatch(); + if (!snapshotPatch && workletRefInitValuePatch.length === 0) { + // before hydration, skip patch + applyQueuedRefs(); + originalPreactCommit?.(vnode, commitQueue); + return; } - }); - applyQueuedRefs(); - originalPreactCommit?.(vnode, commitQueue); - }; - options[COMMIT] = commit as ((...args: Parameters) => void); + const patch: Patch = { + id: commitTaskId, + }; + // TODO: check all fields in `flushOptions` from runtime3 + if (snapshotPatch?.length) { + patch.snapshotPatch = snapshotPatch; + } + if (workletRefInitValuePatch.length) { + patch.workletRefInitValuePatch = workletRefInitValuePatch; + } + const patchList: PatchList = { + patchList: [patch], + }; + if (!isEmptyObject(flushOptions)) { + patchList.flushOptions = flushOptions; + } + const obj = commitPatchUpdate(patchList, patchOptions); + + // Send the update to the native layer + lynx.getNativeApp().callLepusMethod(LifecycleConstant.patchUpdate, obj, () => { + const commitTask = globalCommitTaskMap.get(commitTaskId); + if (commitTask) { + commitTask(); + globalCommitTaskMap.delete(commitTaskId); + } + }); + + applyQueuedRefs(); + originalPreactCommit?.(vnode, commitQueue); + }, + ); } /** * Prepares the patch update for transmission to the native layer */ -function commitPatchUpdate(patchList: PatchList, patchOptions: Omit): { +function commitPatchUpdate(patchList: PatchList, patchOptions: GlobalPatchOptions): { data: string; patchOptions: PatchOptions; } { diff --git a/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts b/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts index 9de985a63b..5adbcb2b37 100644 --- a/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts +++ b/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts @@ -24,6 +24,15 @@ function updateMainThread( return; } + const flowIds = patchOptions.flowIds; + if (flowIds) { + lynx.performance.profileStart('ReactLynx::patch', { + flowId: flowIds[0], + // @ts-expect-error flowIds is not defined in the type, for now + flowIds, + }); + } + setPipeline(patchOptions.pipelineOptions); markTiming('mtsRenderStart'); markTiming('parseChangesStart'); @@ -57,6 +66,10 @@ function updateMainThread( flushOptions.pipelineOptions = patchOptions.pipelineOptions; } __FlushElementTree(__page, flushOptions); + + if (flowIds) { + lynx.performance.profileEnd(); + } } function injectUpdateMainThread(): void { diff --git a/packages/react/runtime/src/lynx.ts b/packages/react/runtime/src/lynx.ts index ad62e3b2da..1f71c156dc 100644 --- a/packages/react/runtime/src/lynx.ts +++ b/packages/react/runtime/src/lynx.ts @@ -40,9 +40,8 @@ if (__DEV__) { setupComponentStack(); } -// TODO: replace this with __PROFILE__ -if (__PROFILE__) { - // We are profiling both main-thread and background. +// We are profiling both main-thread and background. +if (__MAIN_THREAD__ && __PROFILE__) { initProfileHook(); } @@ -68,6 +67,9 @@ if (__BACKGROUND__) { else { replaceCommitHook(); initTimingAPI(); + if (lynx.performance?.isProfileRecording?.()) { + initProfileHook(); + } } } diff --git a/packages/react/runtime/src/renderToOpcodes/index.ts b/packages/react/runtime/src/renderToOpcodes/index.ts index 18f7f67d2b..8ba08da869 100644 --- a/packages/react/runtime/src/renderToOpcodes/index.ts +++ b/packages/react/runtime/src/renderToOpcodes/index.ts @@ -17,6 +17,7 @@ import { COMMIT, COMPONENT, DIFF, + DIFF2, DIFFED, DIRTY, NEXT_STATE, @@ -33,7 +34,7 @@ const isArray = /* @__PURE__ */ Array.isArray; const assign = /* @__PURE__ */ Object.assign; // Global state for the current render pass -let beforeDiff, afterDiff, renderHook, ummountHook; +let beforeDiff, beforeDiff2, afterDiff, renderHook, ummountHook; /** * Render Preact JSX + Components to an HTML string. @@ -51,6 +52,7 @@ export function renderToString(vnode: any, context: any): any[] { // store options hooks once before each synchronous render call beforeDiff = options[DIFF]; + beforeDiff2 = options[DIFF2]; afterDiff = options[DIFFED]; renderHook = options[RENDER]; ummountHook = options.unmount; @@ -183,6 +185,7 @@ function _renderToString( vnode[PARENT] = parent; if (beforeDiff) beforeDiff(vnode); + if (beforeDiff2) beforeDiff2(vnode, EMPTY_OBJ); let type = vnode.type, props = vnode.props, diff --git a/packages/web-platform/web-constants/src/types/NativeApp.ts b/packages/web-platform/web-constants/src/types/NativeApp.ts index 431f84bfb1..6eba140132 100644 --- a/packages/web-platform/web-constants/src/types/NativeApp.ts +++ b/packages/web-platform/web-constants/src/types/NativeApp.ts @@ -180,6 +180,21 @@ export interface NativeApp { */ profileEnd: () => void; + /*** + * Support from Lynx 3.0 + */ + profileMark: () => void; + + /** + * Support from Lynx 3.0 + */ + profileFlowId: () => number; + + /** + * Support from Lynx 2.18 + */ + isProfileRecording: () => boolean; + triggerComponentEvent(id: string, params: { eventDetail: CloneableObject; eventOption: CloneableObject; diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createPerformanceApis.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createPerformanceApis.ts index 33e2eb7ad7..0a28b7d704 100644 --- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createPerformanceApis.ts +++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createPerformanceApis.ts @@ -13,6 +13,9 @@ export function createPerformanceApis(timingSystem: TimingSystem): Pick< | 'bindPipelineIdWithTimingFlag' | 'profileStart' | 'profileEnd' + | 'profileMark' + | 'profileFlowId' + | 'isProfileRecording' > { let inc = 0; const performanceApis = { @@ -48,6 +51,17 @@ export function createPerformanceApis(timingSystem: TimingSystem): Pick< profileEnd: () => { console.error('NYI: profileEnd. This is an issue of lynx-core.'); }, + profileMark: () => { + console.error('NYI: profileMark. This is an issue of lynx-core.'); + }, + profileFlowId: () => { + console.error('NYI: profileFlowId. This is an issue of lynx-core.'); + return 0; + }, + isProfileRecording: () => { + console.error('NYI: isProfileRecording. This is an issue of lynx-core.'); + return false; + }, }; return performanceApis; } From 6542fdd3073f0c64a3d0ba847705b85e79c0a904 Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:51:20 +0800 Subject: [PATCH 2/4] Apply suggestion from @colinaaa Co-authored-by: Qingyu Wang <40660121+colinaaa@users.noreply.github.com> Signed-off-by: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> --- packages/react/runtime/src/debug/profile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index f6a8e1882f..8319b838d9 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -35,7 +35,7 @@ export function initProfileHook(): void { // attach a flowId to the component instance. // This allows us to trace the flow of its diffing, committing and patching. { - const sFlowID = Symbol.for('FLOW_ID'); + const sFlowID = Symbol('FLOW_ID'); type PatchedComponent = Component & { [sFlowID]?: number }; if (__BACKGROUND__) { From 2a18129e29e86492f53fb89e483b54458df4f4e1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:51:35 +0800 Subject: [PATCH 3/4] Apply suggestion from @colinaaa Co-authored-by: Qingyu Wang <40660121+colinaaa@users.noreply.github.com> Signed-off-by: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> --- packages/react/runtime/__test__/utils/globals.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react/runtime/__test__/utils/globals.js b/packages/react/runtime/__test__/utils/globals.js index a1f26bd87b..0e8abf878c 100644 --- a/packages/react/runtime/__test__/utils/globals.js +++ b/packages/react/runtime/__test__/utils/globals.js @@ -1,10 +1,9 @@ // 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 { vi } from 'vitest'; +import { afterEach, beforeEach, expect, vi } from 'vitest'; import { getJSModule } from './jsModule.ts'; -import { beforeEach, afterEach, expect } from 'vitest'; const app = { callLepusMethod: vi.fn(), From 45f2597548a71b0a94f663d0e043ef2576e8d8a1 Mon Sep 17 00:00:00 2001 From: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:51:46 +0800 Subject: [PATCH 4/4] Apply suggestion from @colinaaa Co-authored-by: Qingyu Wang <40660121+colinaaa@users.noreply.github.com> Signed-off-by: Zhiyuan Hong <28915578+hzy@users.noreply.github.com> --- packages/react/runtime/src/debug/profile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index 8319b838d9..16d85cad83 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -145,7 +145,7 @@ export function initProfileHook(): void { }); if (__BACKGROUND__) { - const sPatchLength = Symbol.for('PATCH_LENGTH'); + const sPatchLength = Symbol('PATCH_LENGTH'); type PatchedVNode = VNode & { [sPatchLength]?: number };