-
Notifications
You must be signed in to change notification settings - Fork 122
feat(react): add richer profiling traces for hooks and hydrate #2235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
ae93f08
rename profileHooks.ts
Yradex 8156bcb
rename profile.ts
Yradex 55731a6
feat: Implement profiling for React `useEffect` and `useLayoutEffect`…
Yradex ba2a73e
feat: Initialize global `__PROFILE__` flag when profiling is active
Yradex 4efc9fc
feat: Add profiling to background snapshot hydration process.
Yradex ef66fda
feat: Introduce VNode source tracking for debugging and profiling by …
Yradex d675e25
feat: Add profiling for `useState`
Yradex a7d99b3
feat: Enable profiling via a global `__PROFILE__` flag
Yradex 00eb27c
feat: Add new profiling tests for background snapshot hydration, vnod…
Yradex f60166e
changeset
Yradex e45c557
chore(react): update API report for hooks profiling exports
Yradex 1105c34
fix(react): restore explicit hook API signatures in docs
Yradex e54714e
chore(react): address code review feedback on profiling and performance
Yradex 1954845
perf(react): optimize profiling overhead and ensure test isolation
Yradex 74157f1
fix(react): eliminate hook index shift and improve tree-shaking for p…
Yradex 76236dd
fix(react): remove runtime __PROFILE__ injection as per PR review
Yradex 627f1c2
test(react): remove outdated __PROFILE__ injection check in profile m…
Yradex 8a6b4df
fix(react): restore hook setState profiling details
Yradex 0ffa31f
make usestate stable
Yradex b96b59a
fix snapshotType arg name
Yradex File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| --- | ||
| "@lynx-js/react": patch | ||
| --- | ||
|
|
||
| Improve React runtime hook profiling. | ||
| Enable Profiling recording first, then enter the target page so the trace includes full render/hydrate phases. | ||
|
|
||
| - Record trace events for `useEffect` / `useLayoutEffect` hook entry, callback, and cleanup phases. | ||
| - Log trace events for `useState` setter calls. | ||
| - Wire `profileFlowId` support in debug profile utilities and attach flow IDs to related hook traces. | ||
| - Instrument hydrate/background snapshot profiling around patch operations with richer args (e.g. snapshot id/type, dynamic part index, value type, and source when available). | ||
| - Capture vnode source mapping in dev and use it in profiling args to improve trace attribution. | ||
| - Expand debug test coverage for profile utilities, hook profiling behavior, vnode source mapping, and hydrate profiling branches. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
277 changes: 277 additions & 0 deletions
277
packages/react/runtime/__test__/debug/backgroundSnapshot-profile.test.jsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,277 @@ | ||
| /* | ||
| // 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 { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; | ||
|
|
||
| import { BackgroundSnapshotInstance, hydrate } from '../../src/backgroundSnapshot'; | ||
| import { setupDocument } from '../../src/document'; | ||
| import { setupVNodeSourceHook } from '../../src/debug/vnodeSource'; | ||
| import { SnapshotOperation, SnapshotOperationParams } from '../../src/lifecycle/patch/snapshotPatch'; | ||
| import { DIFFED, DOM } from '../../src/renderToOpcodes/constants'; | ||
| import { __root } from '../../src/root'; | ||
| import { | ||
| backgroundSnapshotInstanceManager, | ||
| setupPage, | ||
| SnapshotInstance, | ||
| snapshotInstanceManager, | ||
| } from '../../src/snapshot'; | ||
| import { elementTree } from '../utils/nativeMethod'; | ||
|
|
||
| const HOLE = null; | ||
| const ROOT = __SNAPSHOT__( | ||
| <view> | ||
| <text>root</text> | ||
| {HOLE} | ||
| </view>, | ||
| ); | ||
| const ITEM_A = __SNAPSHOT__(<text id={HOLE}>A</text>); | ||
| const ITEM_B = __SNAPSHOT__(<image />); | ||
| const ITEM_C = __SNAPSHOT__(<view />); | ||
|
|
||
| function decodePatch(patch) { | ||
| const operations = []; | ||
| let index = 0; | ||
|
|
||
| while (index < patch.length) { | ||
| const op = patch[index]; | ||
| const params = SnapshotOperationParams[op]?.params; | ||
| if (!params) { | ||
| throw new Error(`Invalid patch operation at index ${index}, op: ${String(op)}`); | ||
| } | ||
| const paramCount = params.length; | ||
| const args = patch.slice(index + 1, index + 1 + paramCount); | ||
| operations.push({ op, args }); | ||
| index += 1 + paramCount; | ||
| } | ||
|
|
||
| return operations; | ||
| } | ||
|
|
||
| function createBeforeTree() { | ||
| const root = new SnapshotInstance(ROOT); | ||
|
|
||
| const a = new SnapshotInstance(ITEM_A); | ||
| a.setAttribute(0, 'a-old'); | ||
| a.setAttribute('meta', 'meta-old'); | ||
|
|
||
| const b = new SnapshotInstance(ITEM_B); | ||
| const c = new SnapshotInstance(ITEM_C); | ||
|
|
||
| root.insertBefore(a); | ||
| root.insertBefore(b); | ||
| root.insertBefore(c); | ||
|
|
||
| return JSON.parse(JSON.stringify(root)); | ||
| } | ||
|
|
||
| function createAfterTree(metaValue) { | ||
| const root = new BackgroundSnapshotInstance(ROOT); | ||
|
|
||
| const b = new BackgroundSnapshotInstance(ITEM_B); | ||
| const a = new BackgroundSnapshotInstance(ITEM_A); | ||
| a.setAttribute(0, 'a-new'); | ||
| a.setAttribute('meta', metaValue); | ||
|
|
||
| root.insertBefore(b); | ||
| root.insertBefore(a); | ||
|
|
||
| return root; | ||
| } | ||
|
|
||
| function createBeforeTreeWithDefinedTargetMove() { | ||
| const root = new SnapshotInstance(ROOT); | ||
| const a = new SnapshotInstance(ITEM_A); | ||
| const b = new SnapshotInstance(ITEM_B); | ||
| const c = new SnapshotInstance(ITEM_C); | ||
|
|
||
| root.insertBefore(a); | ||
| root.insertBefore(b); | ||
| root.insertBefore(c); | ||
|
|
||
| return JSON.parse(JSON.stringify(root)); | ||
| } | ||
|
|
||
| function createAfterTreeWithDefinedTargetMove() { | ||
| const root = new BackgroundSnapshotInstance(ROOT); | ||
| const b = new BackgroundSnapshotInstance(ITEM_B); | ||
| const a = new BackgroundSnapshotInstance(ITEM_A); | ||
| const c = new BackgroundSnapshotInstance(ITEM_C); | ||
|
|
||
| root.insertBefore(b); | ||
| root.insertBefore(a); | ||
| root.insertBefore(c); | ||
|
|
||
| return root; | ||
| } | ||
|
|
||
| describe('backgroundSnapshot profile', () => { | ||
| beforeAll(() => { | ||
| setupDocument(); | ||
| setupPage(__CreatePage('0', 0)); | ||
| setupVNodeSourceHook(); | ||
| }); | ||
|
|
||
| describe('hydrate source', () => { | ||
| beforeEach(() => { | ||
| render(null, __root); | ||
| snapshotInstanceManager.clear(); | ||
| snapshotInstanceManager.nextId = 0; | ||
| backgroundSnapshotInstanceManager.clear(); | ||
| backgroundSnapshotInstanceManager.nextId = 0; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| render(null, __root); | ||
| elementTree.clear(); | ||
| }); | ||
|
|
||
| it('should include source in hydrate setAttribute profile args', () => { | ||
| function App({ text }) { | ||
| return <view id={text} />; | ||
| } | ||
|
|
||
| render(<App text='main-thread-value' />, __root); | ||
| const serializedRoot = JSON.parse(JSON.stringify(__root)); | ||
| const mainThreadChild = serializedRoot.children?.[0]; | ||
|
|
||
| expect(mainThreadChild).toBeDefined(); | ||
| options[DIFFED]?.({ | ||
| type: 'view', | ||
| __source: { | ||
| fileName: 'backgroundSnapshot-profile.test.jsx', | ||
| lineNumber: 128, | ||
| columnNumber: 18, | ||
| }, | ||
| [DOM]: { | ||
| __id: mainThreadChild.id, | ||
| }, | ||
| }); | ||
|
|
||
| const backgroundRoot = new BackgroundSnapshotInstance('root'); | ||
| const backgroundChild = new BackgroundSnapshotInstance(mainThreadChild.type); | ||
| backgroundChild.setAttribute(0, 'background-value'); | ||
| backgroundRoot.insertBefore(backgroundChild); | ||
|
|
||
| lynx.performance.profileStart.mockClear(); | ||
| lynx.performance.profileEnd.mockClear(); | ||
|
|
||
| hydrate(serializedRoot, backgroundRoot); | ||
|
|
||
| const setAttributeProfileCalls = lynx.performance.profileStart.mock.calls.filter( | ||
| ([traceName]) => traceName === 'ReactLynx::hydrate::setAttribute', | ||
| ); | ||
|
|
||
| expect(setAttributeProfileCalls).toHaveLength(1); | ||
| expect(setAttributeProfileCalls[0][1]).toEqual( | ||
| expect.objectContaining({ | ||
| args: expect.objectContaining({ | ||
| id: String(mainThreadChild.id), | ||
| snapshotType: mainThreadChild.type, | ||
| dynamicPartIndex: '0', | ||
| source: 'backgroundSnapshot-profile.test.jsx:128:18', | ||
| }), | ||
| }), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('hydrate branches', () => { | ||
| let originalProfileFlag; | ||
|
|
||
| beforeEach(() => { | ||
| originalProfileFlag = globalThis.__PROFILE__; | ||
| snapshotInstanceManager.clear(); | ||
| snapshotInstanceManager.nextId = 0; | ||
| backgroundSnapshotInstanceManager.clear(); | ||
| backgroundSnapshotInstanceManager.nextId = 0; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| globalThis.__PROFILE__ = originalProfileFlag; | ||
| }); | ||
|
|
||
| it('should apply non-profile hydrate branches for setAttribute/remove/move', () => { | ||
| globalThis.__PROFILE__ = false; | ||
|
|
||
| const before = createBeforeTree(); | ||
| const after = createAfterTree('meta-new'); | ||
|
|
||
| lynx.performance.profileStart.mockClear(); | ||
| lynx.performance.profileEnd.mockClear(); | ||
|
|
||
| const patch = hydrate(before, after); | ||
| const operations = decodePatch(patch); | ||
|
|
||
| expect(lynx.performance.profileStart).not.toBeCalled(); | ||
| expect(lynx.performance.profileEnd).not.toBeCalled(); | ||
| expect(operations).toEqual( | ||
| expect.arrayContaining([ | ||
| expect.objectContaining({ | ||
| op: SnapshotOperation.SetAttribute, | ||
| args: expect.arrayContaining([before.children[0].id, 0, 'a-new']), | ||
| }), | ||
| expect.objectContaining({ | ||
| op: SnapshotOperation.SetAttribute, | ||
| args: expect.arrayContaining([before.children[0].id, 'meta', 'meta-new']), | ||
| }), | ||
| expect.objectContaining({ | ||
| op: SnapshotOperation.RemoveChild, | ||
| args: [before.id, before.children[2].id], | ||
| }), | ||
| expect.objectContaining({ | ||
| op: SnapshotOperation.InsertBefore, | ||
| args: [before.id, before.children[0].id, undefined], | ||
| }), | ||
| ]), | ||
| ); | ||
| }); | ||
|
|
||
| it('should profile hydrate extraProps null valueType and empty move targetId', () => { | ||
| globalThis.__PROFILE__ = true; | ||
|
|
||
| const before = createBeforeTree(); | ||
| const after = createAfterTree(null); | ||
|
|
||
| lynx.performance.profileStart.mockClear(); | ||
| lynx.performance.profileEnd.mockClear(); | ||
|
|
||
| hydrate(before, after); | ||
|
|
||
| const setAttributeCalls = lynx.performance.profileStart.mock.calls.filter( | ||
| ([traceName]) => traceName === 'ReactLynx::hydrate::setAttribute', | ||
| ); | ||
| const insertBeforeCalls = lynx.performance.profileStart.mock.calls.filter( | ||
| ([traceName]) => traceName === 'ReactLynx::hydrate::insertBefore', | ||
| ); | ||
|
|
||
| expect( | ||
| setAttributeCalls.some(([, option]) => ( | ||
| option?.args?.dynamicPartIndex === 'meta' && option?.args?.valueType === 'null' | ||
| )), | ||
| ).toBe(true); | ||
| expect( | ||
| insertBeforeCalls.some(([, option]) => option?.args?.targetId === ''), | ||
| ).toBe(true); | ||
| }); | ||
|
|
||
| it('should apply non-profile move branch with defined target id', () => { | ||
| globalThis.__PROFILE__ = false; | ||
|
|
||
| const before = createBeforeTreeWithDefinedTargetMove(); | ||
| const after = createAfterTreeWithDefinedTargetMove(); | ||
|
|
||
| const patch = hydrate(before, after); | ||
| const operations = decodePatch(patch); | ||
| const moveWithDefinedTarget = operations.find(({ op, args }) => ( | ||
| op === SnapshotOperation.InsertBefore | ||
| && args[0] === before.id | ||
| && args[2] !== undefined | ||
| )); | ||
|
|
||
| expect(moveWithDefinedTarget).toBeDefined(); | ||
| }); | ||
| }); | ||
| }); | ||
88 changes: 88 additions & 0 deletions
88
packages/react/runtime/__test__/debug/profile-module.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| type PerformanceLike = { | ||
| isProfileRecording?: () => boolean; | ||
| profileStart?: (traceName: string, option?: unknown) => void; | ||
| profileEnd?: () => void; | ||
| profileFlowId?: () => number; | ||
| }; | ||
|
|
||
| type LynxLike = { | ||
| performance?: PerformanceLike; | ||
| }; | ||
|
|
||
| describe('debug/profile module', () => { | ||
| let originalLynx: LynxLike; | ||
| let originalProfileFlag: unknown; | ||
|
|
||
| beforeEach(() => { | ||
| vi.resetModules(); | ||
| originalLynx = globalThis.lynx as LynxLike; | ||
| // eslint-disable-next-line no-undef | ||
| originalProfileFlag = typeof __PROFILE__ === 'undefined' ? undefined : __PROFILE__; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| globalThis.lynx = originalLynx as typeof globalThis.lynx; | ||
| // eslint-disable-next-line no-undef | ||
| globalThis.__PROFILE__ = originalProfileFlag as boolean | undefined; | ||
| }); | ||
|
|
||
| it('should indicate profiling is enabled when recording is active', async () => { | ||
| const perf: PerformanceLike = { | ||
| isProfileRecording: vi.fn(() => true), | ||
| }; | ||
| globalThis.lynx = { | ||
| ...globalThis.lynx, | ||
| performance: perf, | ||
| }; | ||
|
|
||
| const profile = await import('../../src/debug/profile'); | ||
|
|
||
| expect(profile.isProfiling).toBe(true); | ||
| }); | ||
|
|
||
| it('should fallback to no-op APIs when profile functions are unavailable', async () => { | ||
| const perf: PerformanceLike = { | ||
| isProfileRecording: vi.fn(() => false), | ||
| }; | ||
| // eslint-disable-next-line no-undef | ||
| globalThis.__PROFILE__ = false; | ||
| globalThis.lynx = { | ||
| ...globalThis.lynx, | ||
| performance: perf, | ||
| }; | ||
|
|
||
| const profile = await import('../../src/debug/profile'); | ||
|
|
||
| expect(profile.isProfiling).toBe(false); | ||
| expect(() => profile.profileStart('trace')).not.toThrow(); | ||
| expect(() => profile.profileEnd()).not.toThrow(); | ||
| expect(profile.profileFlowId()).toBe(0); | ||
| }); | ||
|
|
||
| it('should bind and call native profile APIs when available', async () => { | ||
| const perf = { | ||
| isProfileRecording: vi.fn(() => true), | ||
| profileStart: vi.fn(), | ||
| profileEnd: vi.fn(), | ||
| profileFlowId: vi.fn(() => 123), | ||
| }; | ||
| // eslint-disable-next-line no-undef | ||
| globalThis.__PROFILE__ = true; | ||
| globalThis.lynx = { | ||
| ...globalThis.lynx, | ||
| performance: perf, | ||
| }; | ||
|
|
||
| const profile = await import('../../src/debug/profile'); | ||
|
|
||
| profile.profileStart('trace-name', { args: { foo: 'bar' } }); | ||
| profile.profileEnd(); | ||
| expect(profile.profileFlowId()).toBe(123); | ||
|
|
||
| expect(perf.profileStart).toBeCalledWith('trace-name', { args: { foo: 'bar' } }); | ||
| expect(perf.profileEnd).toBeCalledTimes(1); | ||
| expect(perf.profileFlowId).toBeCalledTimes(1); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.