Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ae93f08
rename profileHooks.ts
Yradex Feb 11, 2026
8156bcb
rename profile.ts
Yradex Feb 11, 2026
55731a6
feat: Implement profiling for React `useEffect` and `useLayoutEffect`…
Yradex Feb 11, 2026
ba2a73e
feat: Initialize global `__PROFILE__` flag when profiling is active
Yradex Feb 11, 2026
4efc9fc
feat: Add profiling to background snapshot hydration process.
Yradex Feb 11, 2026
ef66fda
feat: Introduce VNode source tracking for debugging and profiling by …
Yradex Feb 11, 2026
d675e25
feat: Add profiling for `useState`
Yradex Feb 11, 2026
a7d99b3
feat: Enable profiling via a global `__PROFILE__` flag
Yradex Feb 12, 2026
00eb27c
feat: Add new profiling tests for background snapshot hydration, vnod…
Yradex Feb 13, 2026
f60166e
changeset
Yradex Feb 13, 2026
e45c557
chore(react): update API report for hooks profiling exports
Yradex Feb 13, 2026
1105c34
fix(react): restore explicit hook API signatures in docs
Yradex Feb 13, 2026
e54714e
chore(react): address code review feedback on profiling and performance
Yradex Feb 24, 2026
1954845
perf(react): optimize profiling overhead and ensure test isolation
Yradex Feb 24, 2026
74157f1
fix(react): eliminate hook index shift and improve tree-shaking for p…
Yradex Feb 24, 2026
76236dd
fix(react): remove runtime __PROFILE__ injection as per PR review
Yradex Feb 24, 2026
627f1c2
test(react): remove outdated __PROFILE__ injection check in profile m…
Yradex Feb 24, 2026
8a6b4df
fix(react): restore hook setState profiling details
Yradex Feb 26, 2026
0ffa31f
make usestate stable
Yradex Feb 26, 2026
b96b59a
fix snapshotType arg name
Yradex Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/free-dragons-mate.md
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.
4 changes: 2 additions & 2 deletions packages/react/etc/react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export { useContext }
export { useDebugValue }

// @public
export function useEffect(effect: EffectCallback, deps?: DependencyList): void;
export const useEffect: (effect: EffectCallback, deps?: DependencyList) => void;

export { useErrorBoundary }

Expand All @@ -145,7 +145,7 @@ export const useInitData: () => InitData;
export const useInitDataChanged: (callback: (data: InitData) => void) => void;

// @public @deprecated
export function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void;
export const useLayoutEffect: (effect: EffectCallback, deps?: DependencyList) => void;

// @public
export function useLynxGlobalEventListener<T extends (...args: any[]) => void>(eventName: string, listener: T): void;
Expand Down
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;
});
Comment thread
Yradex marked this conversation as resolved.

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 packages/react/runtime/__test__/debug/profile-module.test.ts
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);
});
});
Loading
Loading