From ef38675002a59a17d3c995628c03b0d0eca4f999 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Fri, 6 Feb 2026 13:06:48 +0800 Subject: [PATCH 1/7] fix(react): improve profile hook coverage and robustness - Fix functional component hook interception logic. - Ensure correct access to hook list (legacy vs ). - Fix constant mismatch. - Add comprehensive tests covering edge cases (manual vnodes, non-array values, error handling). - improve code coverage to 100% for profile.ts. --- .../runtime/__test__/debug/trace.test.jsx | 336 ++++++++++++++++++ packages/react/runtime/src/debug/profile.ts | 143 ++++++-- .../runtime/src/renderToOpcodes/constants.ts | 4 + .../react/runtime/types/internal-preact.d.ts | 12 + 4 files changed, 466 insertions(+), 29 deletions(-) create mode 100644 packages/react/runtime/__test__/debug/trace.test.jsx diff --git a/packages/react/runtime/__test__/debug/trace.test.jsx b/packages/react/runtime/__test__/debug/trace.test.jsx new file mode 100644 index 0000000000..887e64ada5 --- /dev/null +++ b/packages/react/runtime/__test__/debug/trace.test.jsx @@ -0,0 +1,336 @@ +// Copyright 2026 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Component, options, render } from 'preact'; +import { useState } from '../../src/index'; +import { initProfileHook } from '../../src/debug/profile'; +import { __root } from '../../src/root'; +import { globalEnvManager } from '../utils/envManager'; +import { elementTree, waitSchedule } from '../utils/nativeMethod'; +import { NEXT_VALUE, VALUE, COMPONENT } from '../../src/renderToOpcodes/constants'; + +describe('Trace', () => { + let originalDiffed; + + beforeEach(() => { + globalEnvManager.resetEnv(); + // Enable background mode where profiling hooks are active + globalEnvManager.switchToBackground(); + + // Mock performance API if not already mocked by globals.js + if (!lynx.performance) { + lynx.performance = { + profileStart: vi.fn(), + profileEnd: vi.fn(), + profileMark: vi.fn(), + profileFlowId: vi.fn(() => 666), + isProfileRecording: vi.fn(() => true), + }; + } + + // Capture original options.diffed + originalDiffed = options.diffed; + + // Initialize profile hook for every test + initProfileHook(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Restore options.diffed to prevent hook stacking + options.diffed = originalDiffed; + }); + + it('should trace useState updates in functional components', async () => { + // Use the existing mock + const profileMarkSpy = lynx.performance.profileMark; + + function App() { + const [count, setCount] = useState(0); + globalThis.triggerUpdate = () => setCount((c) => c + 1); + + return ( + + {count} + + + ); + } + + // Initial render + render(, __root); + await waitSchedule(); + + // Verify initial render didn't trigger setState trace (it shouldn't) + expect(profileMarkSpy).not.toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.anything(), + ); + + // Trigger update + if (globalThis.triggerUpdate) { + globalThis.triggerUpdate(); + } else { + throw new Error('triggerUpdate not exposed'); + } + + // Wait for update to process (the setter interceptor runs synchronously during execution, but let's wait) + await waitSchedule(); + + // Verify trace + // The setter interceptor calls profileMark synchronously when setCount is called. + expect(profileMarkSpy).toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.objectContaining({ + flowId: expect.any(Number), + args: expect.objectContaining({ + componentName: 'App', + hookIdx: '0', + currentValue: '0', + nextValue: '1', + }), + }), + ); + }); + + it('should handle function values in useState', async () => { + const profileMarkSpy = lynx.performance.profileMark; + + const funcA = () => 'A'; + const funcB = () => 'B'; + + function App() { + // Use function as initial state (lazy init), but result is a function? + // No, to store a function, we must use `() => func`. + const [func, setFunc] = useState(() => funcA); + + globalThis.updateFunc = () => setFunc(() => funcB); + + return {func()}; + } + + render(, __root); + await waitSchedule(); + + if (globalThis.updateFunc) { + globalThis.updateFunc(); + } + await waitSchedule(); + + expect(profileMarkSpy).toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.objectContaining({ + args: expect.objectContaining({ + // The format function converts functions to string + currentValue: expect.stringContaining('() =>'), + nextValue: expect.stringContaining('() =>'), + }), + }), + ); + }); + + it('should handle unserializable values in useState', async () => { + const profileMarkSpy = lynx.performance.profileMark; + + function App() { + const [val, setVal] = useState({ id: 1 }); + + globalThis.updateCircular = () => { + const circular = { id: 2 }; + circular.self = circular; + setVal(circular); + }; + + return {val.id}; + } + + render(, __root); + await waitSchedule(); + + if (globalThis.updateCircular) { + globalThis.updateCircular(); + } + await waitSchedule(); + + expect(profileMarkSpy).toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.objectContaining({ + args: expect.objectContaining({ + nextValue: '"Unserializable"', + }), + }), + ); + }); + + it('should handle missing component instance', async () => { + let capturedComponent; + + const profileWrapper = options.diffed; + options.diffed = (vnode) => { + if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { + capturedComponent = vnode.__c; + } + profileWrapper?.(vnode); + }; + + function App() { + const [val, setVal] = useState(0); + globalThis.updateMissing = () => setVal(1); + return {val}; + } + render(, __root); + await waitSchedule(); + + expect(capturedComponent).toBeDefined(); + + if (capturedComponent && capturedComponent.__H && capturedComponent.__H.__) { + // Sabotage: remove __c from the hook state + capturedComponent.__H.__[0].__c = undefined; + } else { + throw new Error('Failed to access hook state for sabotage'); + } + + // Trigger update, expect Preact to crash (but our interceptor runs first) + expect(() => { + globalThis.updateMissing(); + }).toThrow(); + }); + + it('should handle unknown component name', async () => { + const profileMarkSpy = lynx.performance.profileMark; + let capturedComponent; + + const profileWrapper = options.diffed; + options.diffed = (vnode) => { + if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { + capturedComponent = vnode.__c; + } + profileWrapper?.(vnode); + }; + + function App() { + const [val, setVal] = useState(0); + globalThis.updateUnknown = () => setVal(1); + return {val}; + } + + render(, __root); + await waitSchedule(); + + expect(capturedComponent).toBeDefined(); + + if (capturedComponent && capturedComponent.__v) { + // Sabotage: set type to something not a function + capturedComponent.__v.type = {}; + } else { + throw new Error('Failed to access vnode for sabotage'); + } + + if (globalThis.updateUnknown) { + globalThis.updateUnknown(); + } + await waitSchedule(); + + expect(profileMarkSpy).toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.objectContaining({ + args: expect.objectContaining({ + componentName: 'Unknown', + }), + }), + ); + }); + + it('should support manual vnode construction', () => { + // Isolate from Preact internals by mocking the "old" hook + options.diffed = vi.fn(); + initProfileHook(); + const profileHook = options.diffed; + + const vnode = { + type: () => {}, + __c: { + __H: { + __: [ + { + [VALUE]: [0, () => {}], + [NEXT_VALUE]: 0, + }, + ], + }, + }, + }; + + // Should not throw and should inspect the list + expect(() => profileHook(vnode)).not.toThrow(); + + // Check if property was defined + const hookState = vnode.__c.__H.__[0]; + const desc = Object.getOwnPropertyDescriptor(hookState, NEXT_VALUE); + expect(desc.set).toBeDefined(); + }); + + it('should handle non-array hook values', () => { + options.diffed = vi.fn(); + initProfileHook(); + const profileHook = options.diffed; + + const hookState = { + [VALUE]: 'not-an-array', + [NEXT_VALUE]: 'next', + [COMPONENT]: { __v: { type: () => {} } }, + }; + + const vnode = { + type: () => {}, + __c: { + __H: { + __: [hookState], + }, + }, + }; + + profileHook(vnode); + + // Trigger setter + hookState[NEXT_VALUE] = ['new-value']; // Must be array to enter the block where currentValue is resolved + // The setter logic has: const currentValue = Array.isArray(...) ? ... : ... + // Coverage should be hit. + }); + + it('should catch errors in hook iteration', () => { + options.diffed = vi.fn(); + initProfileHook(); + const profileHook = options.diffed; + + const hookState = { + [VALUE]: [0, null], + [NEXT_VALUE]: 0, + }; + // Make it impossible to define property (e.g. freeze or non-configurable) + Object.defineProperty(hookState, NEXT_VALUE, { + value: 0, + configurable: false, + writable: true, + }); + + const vnode = { + type: () => {}, + __c: { + __H: { + __: [hookState], + }, + }, + }; + + // profileHook attempts `Object.defineProperty(hookState, NEXT_VALUE, ...)` + // It should throw TypeError because it's non-configurable. + // The loop wraps in try-catch. + expect(() => profileHook(vnode)).not.toThrow(); + }); +}); diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index e079a45c0f..14463883f4 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -8,9 +8,41 @@ 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 { + COMMIT, + COMPONENT, + DIFF, + DIFF2, + DIFFED, + DIRTY, + NEXT_STATE, + NEXT_VALUE, + RENDER, + VALUE, +} from '../renderToOpcodes/constants.js'; import { getDisplayName, hook } from '../utils.js'; +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], + ), + ), + }; +} + export function initProfileHook(): void { // early-exit if required profiling APIs are unavailable let p; @@ -39,27 +71,6 @@ export function initProfileHook(): void { type PatchedComponent = Component & { [sFlowID]?: number }; if (typeof __BACKGROUND__ !== 'undefined' && __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', @@ -67,13 +78,20 @@ export function initProfileHook(): void { 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, - ), - }); + const type = this.__v!.type; + const isClassComponent = typeof type === 'function' && ('prototype' in type) + && ('render' in type.prototype); + console.log('isClassComponent', isClassComponent, type); + + if (isClassComponent) { + profileMark('ReactLynx::setState', { + flowId: this[sFlowID] ??= profileFlowId(), + args: buildSetStateProfileMarkArgs( + this.state as Record, + this[NEXT_STATE] as Record, + ), + }); + } else {} } }, ); @@ -106,6 +124,73 @@ export function initProfileHook(): void { }); hook(options, DIFFED, (old, vnode) => { + if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { + const hooks = vnode.__c?.__H; + const hookList = hooks?.__; + + if (Array.isArray(hookList)) { + hookList.forEach((hookState, hookIdx: number) => { + try { + hookState['internalNextValue'] = hookState[NEXT_VALUE]; + // define a setter for __N to track the next value of the hook + Object.defineProperty(hookState, NEXT_VALUE, { + get: () => hookState['internalNextValue'], + set: (value) => { + if (Array.isArray(value)) { + // hookState[VALUE] is [state, dispatch] + const currentValueTuple = hookState[VALUE] as unknown[]; + const currentValue = Array.isArray(currentValueTuple) ? currentValueTuple[0] : currentValueTuple; + const [nextValue] = value as unknown[]; + + const component = hookState[COMPONENT] as PatchedComponent | undefined; + if (!component) { + hookState['internalNextValue'] = value; + return; + } + + const format = (val: unknown) => { + if (typeof val === 'function') { + return val.toString(); + } + return val; + }; + + const safeJsonStringify = (val: unknown) => { + try { + return JSON.stringify(val); + } catch { + return '"Unserializable"'; + } + }; + + const type = component.__v?.type; + const flowId = component[sFlowID] ??= profileFlowId(); + + profileMark('ReactLynx::hooks::setState', { + flowId, + args: { + componentName: (type && typeof type === 'function') + ? getDisplayName(type as ComponentClass) + : 'Unknown', + hookIdx: String(hookIdx), + currentValue: safeJsonStringify(format(currentValue)), + nextValue: safeJsonStringify(format(nextValue)), + ...buildSetStateProfileMarkArgs( + currentValue as Record, + nextValue as Record, + ), + }, + }); + } + hookState['internalNextValue'] = value; + }, + configurable: true, + }); + } catch (e) {} + }); + } + } + if (typeof vnode.type === 'function') { profileEnd(); // for options[DIFF] } diff --git a/packages/react/runtime/src/renderToOpcodes/constants.ts b/packages/react/runtime/src/renderToOpcodes/constants.ts index 1c22748bd2..48bb342fb6 100644 --- a/packages/react/runtime/src/renderToOpcodes/constants.ts +++ b/packages/react/runtime/src/renderToOpcodes/constants.ts @@ -27,3 +27,7 @@ export const NEXT_STATE = '__s'; export const CHILD_DID_SUSPEND = '__c'; export const RENDER_CALLBACKS = '__h'; export const HOOK = '__h'; + +// Hooks properties +export const VALUE = '__'; +export const NEXT_VALUE = '__N'; diff --git a/packages/react/runtime/types/internal-preact.d.ts b/packages/react/runtime/types/internal-preact.d.ts index f1efa67045..0092d84666 100644 --- a/packages/react/runtime/types/internal-preact.d.ts +++ b/packages/react/runtime/types/internal-preact.d.ts @@ -22,6 +22,16 @@ declare module 'preact' { __?(vnode: VNode, parentDom: any): void; } + export type HookState = Record; + + // Hook tracking + interface ComponentHooks { + /** The list of hooks a component uses */ + __?: HookState[]; + /** List of Effects to be invoked after the next frame is rendered */ + _pendingEffects: EffectHookState[]; + } + interface VNode { /** _component */ __c?: Component | null; @@ -38,5 +48,7 @@ declare module 'preact' { __e?: boolean; /** dirty */ __d?: boolean; + /** __hooks */ + __H?: ComponentHooks; } } From 1edb713521c2c22a37bb122b2c06809d64203e0a Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Fri, 6 Feb 2026 13:10:45 +0800 Subject: [PATCH 2/7] fix(react): refactor profile logic and add changeset --- .changeset/profile-hooks-coverage.md | 5 ++ packages/react/runtime/src/debug/profile.ts | 58 ++++++++++----------- packages/react/runtime/src/lynx/tt.ts | 4 +- 3 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 .changeset/profile-hooks-coverage.md diff --git a/.changeset/profile-hooks-coverage.md b/.changeset/profile-hooks-coverage.md new file mode 100644 index 0000000000..bf04363a43 --- /dev/null +++ b/.changeset/profile-hooks-coverage.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +fix(react): improve profile hook coverage and robustness diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index 14463883f4..bba2ba2452 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -22,24 +22,41 @@ import { } from '../renderToOpcodes/constants.js'; import { getDisplayName, hook } from '../utils.js'; +const format = (val: unknown) => { + if (typeof val === 'function') { + return val.toString(); + } + return val; +}; + +const safeJsonStringify = (val: unknown) => { + try { + return JSON.stringify(val); + } catch { + return '"Unserializable"'; + } +}; + function buildSetStateProfileMarkArgs( - currentState: Record, - nextState: Record, + currentState: unknown, + nextState: unknown, ): Record { const EMPTY_OBJ = {}; - currentState ??= EMPTY_OBJ; - nextState ??= EMPTY_OBJ; + const currentStateObj = (currentState ?? EMPTY_OBJ) as Record; + const nextStateObj = (nextState ?? EMPTY_OBJ) as Record; return { - 'current state keys': JSON.stringify(Object.keys(currentState)), - 'next state keys': JSON.stringify(Object.keys(nextState)), + 'current state keys': JSON.stringify(Object.keys(currentStateObj)), + 'next state keys': JSON.stringify(Object.keys(nextStateObj)), '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], + Object.keys(nextStateObj).filter( + key => currentStateObj[key] !== nextStateObj[key], ), ), + currentValue: safeJsonStringify(format(currentState)), + nextValue: safeJsonStringify(format(nextState)), }; } @@ -87,8 +104,8 @@ export function initProfileHook(): void { profileMark('ReactLynx::setState', { flowId: this[sFlowID] ??= profileFlowId(), args: buildSetStateProfileMarkArgs( - this.state as Record, - this[NEXT_STATE] as Record, + this.state, + this[NEXT_STATE], ), }); } else {} @@ -148,21 +165,6 @@ export function initProfileHook(): void { return; } - const format = (val: unknown) => { - if (typeof val === 'function') { - return val.toString(); - } - return val; - }; - - const safeJsonStringify = (val: unknown) => { - try { - return JSON.stringify(val); - } catch { - return '"Unserializable"'; - } - }; - const type = component.__v?.type; const flowId = component[sFlowID] ??= profileFlowId(); @@ -173,11 +175,9 @@ export function initProfileHook(): void { ? getDisplayName(type as ComponentClass) : 'Unknown', hookIdx: String(hookIdx), - currentValue: safeJsonStringify(format(currentValue)), - nextValue: safeJsonStringify(format(nextValue)), ...buildSetStateProfileMarkArgs( - currentValue as Record, - nextValue as Record, + currentValue, + nextValue, ), }, }); diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts index 33bb09f50b..d3b967a2d0 100644 --- a/packages/react/runtime/src/lynx/tt.ts +++ b/packages/react/runtime/src/lynx/tt.ts @@ -207,7 +207,7 @@ function publishEvent(handlerName: string, data: EventDataType) { handlerName, ) as ((data: unknown) => void) | undefined; - if (__PROFILE__) { + if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { profileStart(`ReactLynx::publishEvent`, { args: { handlerName, @@ -243,7 +243,7 @@ function publishEvent(handlerName: string, data: EventDataType) { lynx.reportError(e as Error); } } - if (__PROFILE__) { + if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { profileEnd(); } } From fa7942853eb74a4694f9d66933b7326c36763c5d Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Tue, 24 Feb 2026 12:40:17 +0800 Subject: [PATCH 3/7] test: update test cases --- .changeset/profile-hooks-coverage.md | 2 +- packages/react/runtime/__test__/debug/hook.js | 14 - .../runtime/__test__/debug/profile.test.jsx | 278 ++++++++++++++- .../runtime/__test__/debug/trace.test.jsx | 336 ------------------ packages/react/runtime/src/debug/profile.ts | 100 +++--- .../runtime/src/renderToOpcodes/constants.ts | 2 + 6 files changed, 330 insertions(+), 402 deletions(-) delete mode 100644 packages/react/runtime/__test__/debug/hook.js delete mode 100644 packages/react/runtime/__test__/debug/trace.test.jsx diff --git a/.changeset/profile-hooks-coverage.md b/.changeset/profile-hooks-coverage.md index bf04363a43..30a25aba5e 100644 --- a/.changeset/profile-hooks-coverage.md +++ b/.changeset/profile-hooks-coverage.md @@ -2,4 +2,4 @@ "@lynx-js/react": patch --- -fix(react): improve profile hook coverage and robustness +Support `ReactLynx::hooks::setState` trace for function components. diff --git a/packages/react/runtime/__test__/debug/hook.js b/packages/react/runtime/__test__/debug/hook.js deleted file mode 100644 index 571edf04b3..0000000000 --- a/packages/react/runtime/__test__/debug/hook.js +++ /dev/null @@ -1,14 +0,0 @@ -// 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 { vi } from 'vitest'; - -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 19e076d7b1..6c0115a423 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -3,19 +3,18 @@ // 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 { noop } from './hook'; - -import { render } from 'preact'; +import { render, options, Component } from 'preact'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { setupDocument } from '../../src/document'; import { setupPage, snapshotInstanceManager } from '../../src/snapshot'; import { initProfileHook } from '../../src/debug/profile'; +import { useState } from '../../src/index'; +import { COMPONENT, DIFF, DIFF2, DIFFED, HOOKS, LIST, RENDER, VNODE } from '../../src/renderToOpcodes/constants'; describe('profile', () => { let scratch; beforeAll(() => { - initProfileHook(); setupDocument(); setupPage(__CreatePage('0', 0)); }); @@ -23,15 +22,33 @@ describe('profile', () => { beforeEach(() => { snapshotInstanceManager.clear(); scratch = document.createElement('root'); + lynx.performance.profileMark.mockClear(); }); test('original options hooks should be called', async () => { + const noop = vi.fn(); + + const oldDiff = options[DIFF]; + const oldDiff2 = options[DIFF2]; + const oldRender = options[RENDER]; + const oldDiffed = options[DIFFED]; + + options[DIFF] = noop; + options[DIFF2] = noop; + options[RENDER] = noop; + options[DIFFED] = noop; + render( null, scratch, ); expect(noop).toBeCalledTimes(4); + + options[DIFF] = oldDiff; + options[DIFF2] = oldDiff2; + options[RENDER] = oldRender; + options[DIFFED] = oldDiffed; }); test('diff and render should be profiled', async () => { @@ -71,4 +88,257 @@ describe('profile', () => { expect(lynx.performance.profileStart).not.toBeCalledWith(`ReactLynx::diff::ClassComponent`); expect(lynx.performance.profileStart).toBeCalledWith(`ReactLynx::diff::Clazz`, {}); }); + + test('should trace setState updates in class components', async () => { + const profileMarkSpy = lynx.performance.profileMark; + let triggerUpdate; + class ClassComponent extends Component { + state = { + count: 0, + }; + + render() { + triggerUpdate = () => this.setState({ count: this.state.count + 1 }); + return ( + + {this.state.count} + + + ); + } + } + + render( + , + scratch, + ); + triggerUpdate(); + + expect(profileMarkSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "ReactLynx::setState", + { + "args": { + "changed (shallow diff) state keys": "["count"]", + "current state keys": "["count"]", + "currentValue": "{"count":0}", + "next state keys": "["count"]", + "nextValue": "{"count":1}", + }, + "flowId": 666, + }, + ], + ] + `); + }); + + test('should trace useState updates in functional components', async () => { + const profileMarkSpy = lynx.performance.profileMark; + + let triggerUpdatePrimitive; + let triggerUpdateObject; + let triggerUpdateObjectCircular; + function App() { + const [count, setCount] = useState(0); + const [obj, setObj] = useState({ count: 0, unchanged: 'unchanged' }); + let tmp = { count: 0 }; + tmp.circularKey = tmp; + let [circularObj, setCircularObj] = useState(tmp); + triggerUpdatePrimitive = () => setCount(count + 1); + triggerUpdateObject = () => setObj({ count: obj.count + 1, unchanged: obj.unchanged, newKey: 'newValue' }); + triggerUpdateObjectCircular = () => + setCircularObj({ + ...circularObj, + count: circularObj.count + 1, + }); + + return ( + + {count} + + + + + ); + } + + render(, scratch); + triggerUpdatePrimitive(); + triggerUpdateObject(); + triggerUpdateObjectCircular(); + + expect(profileMarkSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "ReactLynx::hooks::setState", + { + "args": { + "changed (shallow diff) state keys": "[]", + "componentName": "App", + "current state keys": "[]", + "currentValue": "0", + "hookIdx": "0", + "next state keys": "[]", + "nextValue": "1", + }, + "flowId": 666, + }, + ], + [ + "ReactLynx::hooks::setState", + { + "args": { + "changed (shallow diff) state keys": "["count","newKey"]", + "componentName": "App", + "current state keys": "["count","unchanged"]", + "currentValue": "{"count":0,"unchanged":"unchanged"}", + "hookIdx": "1", + "next state keys": "["count","unchanged","newKey"]", + "nextValue": "{"count":1,"unchanged":"unchanged","newKey":"newValue"}", + }, + "flowId": 666, + }, + ], + [ + "ReactLynx::hooks::setState", + { + "args": { + "changed (shallow diff) state keys": "["count"]", + "componentName": "App", + "current state keys": "["count","circularKey"]", + "currentValue": "{"count":0,"circularKey":"[Unserializable: Circular]"}", + "hookIdx": "2", + "next state keys": "["count","circularKey"]", + "nextValue": "{"count":1,"circularKey":{"count":0,"circularKey":"[Unserializable: Circular]"}}", + }, + "flowId": 666, + }, + ], + ] + `); + }); + + test('should handle function values in useState', async () => { + const profileMarkSpy = lynx.performance.profileMark; + + const funcA = () => 'A'; + const funcB = () => 'B'; + + let updateFunc; + function App() { + const [func, setFunc] = useState(() => funcA); + + updateFunc = () => setFunc(() => funcB); + + return {func()}; + } + + render(, scratch); + updateFunc(); + + expect(profileMarkSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "ReactLynx::hooks::setState", + { + "args": { + "changed (shallow diff) state keys": "[]", + "componentName": "App", + "current state keys": "[]", + "currentValue": ""() => \\"A\\""", + "hookIdx": "0", + "next state keys": "[]", + "nextValue": ""() => \\"B\\""", + }, + "flowId": 666, + }, + ], + ] + `); + }); + + test('should handle missing component instance', async () => { + let capturedComponent; + + const profileWrapper = options.diffed; + options.diffed = (vnode) => { + if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { + capturedComponent = vnode.__c; + } + profileWrapper?.(vnode); + }; + + let updateMissing; + function App() { + const [val, setVal] = useState(0); + updateMissing = () => setVal(1); + return {val}; + } + render(, scratch); + expect(capturedComponent).toBeDefined(); + + if (capturedComponent && capturedComponent[HOOKS] && capturedComponent[HOOKS][LIST]) { + capturedComponent[HOOKS][LIST][0][COMPONENT] = undefined; + } else { + throw new Error('Failed to access hook state for sabotage'); + } + + // Trigger update, expect Preact to crash (but our interceptor runs first) + expect(() => { + updateMissing(); + }).toThrow(); + }); + + test('should handle unknown component name', async () => { + const profileMarkSpy = lynx.performance.profileMark; + let capturedComponent; + + const profileWrapper = options.diffed; + options.diffed = (vnode) => { + if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { + capturedComponent = vnode.__c; + } + profileWrapper?.(vnode); + }; + + let updateUnknown; + function App() { + const [val, setVal] = useState(0); + updateUnknown = () => setVal(1); + return {val}; + } + + render(, scratch); + expect(capturedComponent).toBeDefined(); + + if (capturedComponent && capturedComponent[VNODE]) { + // Sabotage: set type to something not a function + capturedComponent[VNODE].type = {}; + } else { + throw new Error('Failed to access vnode for sabotage'); + } + updateUnknown(); + + expect(profileMarkSpy).toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.objectContaining({ + args: expect.objectContaining({ + componentName: 'Unknown', + }), + }), + ); + }); }); diff --git a/packages/react/runtime/__test__/debug/trace.test.jsx b/packages/react/runtime/__test__/debug/trace.test.jsx deleted file mode 100644 index 887e64ada5..0000000000 --- a/packages/react/runtime/__test__/debug/trace.test.jsx +++ /dev/null @@ -1,336 +0,0 @@ -// Copyright 2026 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Component, options, render } from 'preact'; -import { useState } from '../../src/index'; -import { initProfileHook } from '../../src/debug/profile'; -import { __root } from '../../src/root'; -import { globalEnvManager } from '../utils/envManager'; -import { elementTree, waitSchedule } from '../utils/nativeMethod'; -import { NEXT_VALUE, VALUE, COMPONENT } from '../../src/renderToOpcodes/constants'; - -describe('Trace', () => { - let originalDiffed; - - beforeEach(() => { - globalEnvManager.resetEnv(); - // Enable background mode where profiling hooks are active - globalEnvManager.switchToBackground(); - - // Mock performance API if not already mocked by globals.js - if (!lynx.performance) { - lynx.performance = { - profileStart: vi.fn(), - profileEnd: vi.fn(), - profileMark: vi.fn(), - profileFlowId: vi.fn(() => 666), - isProfileRecording: vi.fn(() => true), - }; - } - - // Capture original options.diffed - originalDiffed = options.diffed; - - // Initialize profile hook for every test - initProfileHook(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - // Restore options.diffed to prevent hook stacking - options.diffed = originalDiffed; - }); - - it('should trace useState updates in functional components', async () => { - // Use the existing mock - const profileMarkSpy = lynx.performance.profileMark; - - function App() { - const [count, setCount] = useState(0); - globalThis.triggerUpdate = () => setCount((c) => c + 1); - - return ( - - {count} - - - ); - } - - // Initial render - render(, __root); - await waitSchedule(); - - // Verify initial render didn't trigger setState trace (it shouldn't) - expect(profileMarkSpy).not.toHaveBeenCalledWith( - 'ReactLynx::hooks::setState', - expect.anything(), - ); - - // Trigger update - if (globalThis.triggerUpdate) { - globalThis.triggerUpdate(); - } else { - throw new Error('triggerUpdate not exposed'); - } - - // Wait for update to process (the setter interceptor runs synchronously during execution, but let's wait) - await waitSchedule(); - - // Verify trace - // The setter interceptor calls profileMark synchronously when setCount is called. - expect(profileMarkSpy).toHaveBeenCalledWith( - 'ReactLynx::hooks::setState', - expect.objectContaining({ - flowId: expect.any(Number), - args: expect.objectContaining({ - componentName: 'App', - hookIdx: '0', - currentValue: '0', - nextValue: '1', - }), - }), - ); - }); - - it('should handle function values in useState', async () => { - const profileMarkSpy = lynx.performance.profileMark; - - const funcA = () => 'A'; - const funcB = () => 'B'; - - function App() { - // Use function as initial state (lazy init), but result is a function? - // No, to store a function, we must use `() => func`. - const [func, setFunc] = useState(() => funcA); - - globalThis.updateFunc = () => setFunc(() => funcB); - - return {func()}; - } - - render(, __root); - await waitSchedule(); - - if (globalThis.updateFunc) { - globalThis.updateFunc(); - } - await waitSchedule(); - - expect(profileMarkSpy).toHaveBeenCalledWith( - 'ReactLynx::hooks::setState', - expect.objectContaining({ - args: expect.objectContaining({ - // The format function converts functions to string - currentValue: expect.stringContaining('() =>'), - nextValue: expect.stringContaining('() =>'), - }), - }), - ); - }); - - it('should handle unserializable values in useState', async () => { - const profileMarkSpy = lynx.performance.profileMark; - - function App() { - const [val, setVal] = useState({ id: 1 }); - - globalThis.updateCircular = () => { - const circular = { id: 2 }; - circular.self = circular; - setVal(circular); - }; - - return {val.id}; - } - - render(, __root); - await waitSchedule(); - - if (globalThis.updateCircular) { - globalThis.updateCircular(); - } - await waitSchedule(); - - expect(profileMarkSpy).toHaveBeenCalledWith( - 'ReactLynx::hooks::setState', - expect.objectContaining({ - args: expect.objectContaining({ - nextValue: '"Unserializable"', - }), - }), - ); - }); - - it('should handle missing component instance', async () => { - let capturedComponent; - - const profileWrapper = options.diffed; - options.diffed = (vnode) => { - if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { - capturedComponent = vnode.__c; - } - profileWrapper?.(vnode); - }; - - function App() { - const [val, setVal] = useState(0); - globalThis.updateMissing = () => setVal(1); - return {val}; - } - render(, __root); - await waitSchedule(); - - expect(capturedComponent).toBeDefined(); - - if (capturedComponent && capturedComponent.__H && capturedComponent.__H.__) { - // Sabotage: remove __c from the hook state - capturedComponent.__H.__[0].__c = undefined; - } else { - throw new Error('Failed to access hook state for sabotage'); - } - - // Trigger update, expect Preact to crash (but our interceptor runs first) - expect(() => { - globalThis.updateMissing(); - }).toThrow(); - }); - - it('should handle unknown component name', async () => { - const profileMarkSpy = lynx.performance.profileMark; - let capturedComponent; - - const profileWrapper = options.diffed; - options.diffed = (vnode) => { - if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { - capturedComponent = vnode.__c; - } - profileWrapper?.(vnode); - }; - - function App() { - const [val, setVal] = useState(0); - globalThis.updateUnknown = () => setVal(1); - return {val}; - } - - render(, __root); - await waitSchedule(); - - expect(capturedComponent).toBeDefined(); - - if (capturedComponent && capturedComponent.__v) { - // Sabotage: set type to something not a function - capturedComponent.__v.type = {}; - } else { - throw new Error('Failed to access vnode for sabotage'); - } - - if (globalThis.updateUnknown) { - globalThis.updateUnknown(); - } - await waitSchedule(); - - expect(profileMarkSpy).toHaveBeenCalledWith( - 'ReactLynx::hooks::setState', - expect.objectContaining({ - args: expect.objectContaining({ - componentName: 'Unknown', - }), - }), - ); - }); - - it('should support manual vnode construction', () => { - // Isolate from Preact internals by mocking the "old" hook - options.diffed = vi.fn(); - initProfileHook(); - const profileHook = options.diffed; - - const vnode = { - type: () => {}, - __c: { - __H: { - __: [ - { - [VALUE]: [0, () => {}], - [NEXT_VALUE]: 0, - }, - ], - }, - }, - }; - - // Should not throw and should inspect the list - expect(() => profileHook(vnode)).not.toThrow(); - - // Check if property was defined - const hookState = vnode.__c.__H.__[0]; - const desc = Object.getOwnPropertyDescriptor(hookState, NEXT_VALUE); - expect(desc.set).toBeDefined(); - }); - - it('should handle non-array hook values', () => { - options.diffed = vi.fn(); - initProfileHook(); - const profileHook = options.diffed; - - const hookState = { - [VALUE]: 'not-an-array', - [NEXT_VALUE]: 'next', - [COMPONENT]: { __v: { type: () => {} } }, - }; - - const vnode = { - type: () => {}, - __c: { - __H: { - __: [hookState], - }, - }, - }; - - profileHook(vnode); - - // Trigger setter - hookState[NEXT_VALUE] = ['new-value']; // Must be array to enter the block where currentValue is resolved - // The setter logic has: const currentValue = Array.isArray(...) ? ... : ... - // Coverage should be hit. - }); - - it('should catch errors in hook iteration', () => { - options.diffed = vi.fn(); - initProfileHook(); - const profileHook = options.diffed; - - const hookState = { - [VALUE]: [0, null], - [NEXT_VALUE]: 0, - }; - // Make it impossible to define property (e.g. freeze or non-configurable) - Object.defineProperty(hookState, NEXT_VALUE, { - value: 0, - configurable: false, - writable: true, - }); - - const vnode = { - type: () => {}, - __c: { - __H: { - __: [hookState], - }, - }, - }; - - // profileHook attempts `Object.defineProperty(hookState, NEXT_VALUE, ...)` - // It should throw TypeError because it's non-configurable. - // The loop wraps in try-catch. - expect(() => profileHook(vnode)).not.toThrow(); - }); -}); diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index bba2ba2452..e3ee31d243 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -15,6 +15,8 @@ import { DIFF2, DIFFED, DIRTY, + HOOKS, + LIST, NEXT_STATE, NEXT_VALUE, RENDER, @@ -29,13 +31,20 @@ const format = (val: unknown) => { return val; }; -const safeJsonStringify = (val: unknown) => { - try { - return JSON.stringify(val); - } catch { - return '"Unserializable"'; - } -}; +function safeJsonStringify(val: unknown) { + const seen = new WeakSet(); + + return JSON.stringify(val, function(_key, value: unknown) { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Unserializable: Circular]'; + } + seen.add(value); + } + + return value; + }); +} function buildSetStateProfileMarkArgs( currentState: unknown, @@ -98,7 +107,6 @@ export function initProfileHook(): void { const type = this.__v!.type; const isClassComponent = typeof type === 'function' && ('prototype' in type) && ('render' in type.prototype); - console.log('isClassComponent', isClassComponent, type); if (isClassComponent) { profileMark('ReactLynx::setState', { @@ -142,51 +150,49 @@ export function initProfileHook(): void { hook(options, DIFFED, (old, vnode) => { if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { - const hooks = vnode.__c?.__H; - const hookList = hooks?.__; + const hooks = vnode[COMPONENT]?.[HOOKS]; + const hookList = hooks?.[LIST]; if (Array.isArray(hookList)) { hookList.forEach((hookState, hookIdx: number) => { - try { - hookState['internalNextValue'] = hookState[NEXT_VALUE]; - // define a setter for __N to track the next value of the hook - Object.defineProperty(hookState, NEXT_VALUE, { - get: () => hookState['internalNextValue'], - set: (value) => { - if (Array.isArray(value)) { - // hookState[VALUE] is [state, dispatch] - const currentValueTuple = hookState[VALUE] as unknown[]; - const currentValue = Array.isArray(currentValueTuple) ? currentValueTuple[0] : currentValueTuple; - const [nextValue] = value as unknown[]; + hookState['internalNextValue'] = hookState[NEXT_VALUE]; + // define a setter for __N to track the next value of the hook + Object.defineProperty(hookState, NEXT_VALUE, { + get: () => hookState['internalNextValue'], + set: (value) => { + if (Array.isArray(value)) { + // hookState[VALUE] is [state, dispatch] + const currentValueTuple = hookState[VALUE] as unknown[]; + const currentValue = currentValueTuple[0]; + const [nextValue] = value as unknown[]; - const component = hookState[COMPONENT] as PatchedComponent | undefined; - if (!component) { - hookState['internalNextValue'] = value; - return; - } + const component = hookState[COMPONENT] as PatchedComponent | undefined; + if (!component) { + hookState['internalNextValue'] = value; + return; + } - const type = component.__v?.type; - const flowId = component[sFlowID] ??= profileFlowId(); + const type = component.__v?.type; + const flowId = component[sFlowID] ??= profileFlowId(); - profileMark('ReactLynx::hooks::setState', { - flowId, - args: { - componentName: (type && typeof type === 'function') - ? getDisplayName(type as ComponentClass) - : 'Unknown', - hookIdx: String(hookIdx), - ...buildSetStateProfileMarkArgs( - currentValue, - nextValue, - ), - }, - }); - } - hookState['internalNextValue'] = value; - }, - configurable: true, - }); - } catch (e) {} + profileMark('ReactLynx::hooks::setState', { + flowId, + args: { + componentName: (type && typeof type === 'function') + ? getDisplayName(type as ComponentClass) + : 'Unknown', + hookIdx: String(hookIdx), + ...buildSetStateProfileMarkArgs( + currentValue, + nextValue, + ), + }, + }); + } + hookState['internalNextValue'] = value; + }, + configurable: true, + }); }); } } diff --git a/packages/react/runtime/src/renderToOpcodes/constants.ts b/packages/react/runtime/src/renderToOpcodes/constants.ts index 48bb342fb6..b3e89c66aa 100644 --- a/packages/react/runtime/src/renderToOpcodes/constants.ts +++ b/packages/react/runtime/src/renderToOpcodes/constants.ts @@ -29,5 +29,7 @@ export const RENDER_CALLBACKS = '__h'; export const HOOK = '__h'; // Hooks properties +export const HOOKS = '__H'; +export const LIST = '__'; export const VALUE = '__'; export const NEXT_VALUE = '__N'; From 30f2ccce5841dfb292ad121496db8e6983fdbdcd Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Tue, 24 Feb 2026 14:03:06 +0800 Subject: [PATCH 4/7] Update packages/react/runtime/__test__/debug/profile.test.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yiming Li --- .../runtime/__test__/debug/profile.test.jsx | 73 +++++-------------- packages/react/runtime/src/debug/profile.ts | 7 +- 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/packages/react/runtime/__test__/debug/profile.test.jsx b/packages/react/runtime/__test__/debug/profile.test.jsx index 6c0115a423..bf994aab8b 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -281,64 +281,27 @@ describe('profile', () => { profileWrapper?.(vnode); }; - let updateMissing; - function App() { - const [val, setVal] = useState(0); - updateMissing = () => setVal(1); - return {val}; - } - render(, scratch); - expect(capturedComponent).toBeDefined(); - - if (capturedComponent && capturedComponent[HOOKS] && capturedComponent[HOOKS][LIST]) { - capturedComponent[HOOKS][LIST][0][COMPONENT] = undefined; - } else { - throw new Error('Failed to access hook state for sabotage'); - } - - // Trigger update, expect Preact to crash (but our interceptor runs first) - expect(() => { - updateMissing(); - }).toThrow(); - }); - - test('should handle unknown component name', async () => { - const profileMarkSpy = lynx.performance.profileMark; - let capturedComponent; - - const profileWrapper = options.diffed; - options.diffed = (vnode) => { - if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { - capturedComponent = vnode.__c; + try { + let updateMissing; + function App() { + const [val, setVal] = useState(0); + updateMissing = () => setVal(1); + return {val}; } - profileWrapper?.(vnode); - }; + render(, scratch); + expect(capturedComponent).toBeDefined(); - let updateUnknown; - function App() { - const [val, setVal] = useState(0); - updateUnknown = () => setVal(1); - return {val}; - } - - render(, scratch); - expect(capturedComponent).toBeDefined(); + if (capturedComponent && capturedComponent[HOOKS] && capturedComponent[HOOKS][LIST]) { + capturedComponent[HOOKS][LIST][0][COMPONENT] = undefined; + } else { + throw new Error('Failed to access hook state for sabotage'); + } - if (capturedComponent && capturedComponent[VNODE]) { - // Sabotage: set type to something not a function - capturedComponent[VNODE].type = {}; - } else { - throw new Error('Failed to access vnode for sabotage'); + expect(() => { + updateMissing(); + }).toThrow(); + } finally { + options.diffed = profileWrapper; } - updateUnknown(); - - expect(profileMarkSpy).toHaveBeenCalledWith( - 'ReactLynx::hooks::setState', - expect.objectContaining({ - args: expect.objectContaining({ - componentName: 'Unknown', - }), - }), - ); }); }); diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index e3ee31d243..77ad894b97 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -21,6 +21,7 @@ import { NEXT_VALUE, RENDER, VALUE, + VNODE, } from '../renderToOpcodes/constants.js'; import { getDisplayName, hook } from '../utils.js'; @@ -104,7 +105,7 @@ export function initProfileHook(): void { old?.call(this, state, callback); if (this[DIRTY]) { - const type = this.__v!.type; + const type = this[VNODE]!.type; const isClassComponent = typeof type === 'function' && ('prototype' in type) && ('render' in type.prototype); @@ -116,7 +117,7 @@ export function initProfileHook(): void { this[NEXT_STATE], ), }); - } else {} + } } }, ); @@ -172,7 +173,7 @@ export function initProfileHook(): void { return; } - const type = component.__v?.type; + const type = component[VNODE]!.type; const flowId = component[sFlowID] ??= profileFlowId(); profileMark('ReactLynx::hooks::setState', { From c797c4e4790721ba57f9c4014fc94de8b9fda949 Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Tue, 24 Feb 2026 14:08:56 +0800 Subject: [PATCH 5/7] chore: __c -> COMPONENT --- packages/react/runtime/__test__/debug/profile.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/runtime/__test__/debug/profile.test.jsx b/packages/react/runtime/__test__/debug/profile.test.jsx index bf994aab8b..698d09b360 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -275,8 +275,8 @@ describe('profile', () => { const profileWrapper = options.diffed; options.diffed = (vnode) => { - if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { - capturedComponent = vnode.__c; + if (vnode[COMPONENT] && typeof vnode.type === 'function' && vnode.type.name === 'App') { + capturedComponent = vnode[COMPONENT]; } profileWrapper?.(vnode); }; From 4aea138769203d625109e9c5e791e6ab27ac35fb Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Tue, 24 Feb 2026 14:25:12 +0800 Subject: [PATCH 6/7] fix: add unknown component name case --- .../runtime/__test__/debug/profile.test.jsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/react/runtime/__test__/debug/profile.test.jsx b/packages/react/runtime/__test__/debug/profile.test.jsx index 698d09b360..8b8f691344 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -304,4 +304,44 @@ describe('profile', () => { options.diffed = profileWrapper; } }); + + test('should handle unknown component name', async () => { + const profileMarkSpy = lynx.performance.profileMark; + let capturedComponent; + + const profileWrapper = options.diffed; + options.diffed = (vnode) => { + if (vnode.__c && typeof vnode.type === 'function' && vnode.type.name === 'App') { + capturedComponent = vnode.__c; + } + profileWrapper?.(vnode); + }; + + let updateUnknown; + function App() { + const [val, setVal] = useState(0); + updateUnknown = () => setVal(1); + return {val}; + } + + render(, scratch); + expect(capturedComponent).toBeDefined(); + + if (capturedComponent && capturedComponent[VNODE]) { + // Sabotage: set type to something not a function + capturedComponent[VNODE].type = {}; + } else { + throw new Error('Failed to access vnode for sabotage'); + } + updateUnknown(); + + expect(profileMarkSpy).toHaveBeenCalledWith( + 'ReactLynx::hooks::setState', + expect.objectContaining({ + args: expect.objectContaining({ + componentName: 'Unknown', + }), + }), + ); + }); }); From a4ea38a0cf2871da71d45f296689ac579d9e7cdb Mon Sep 17 00:00:00 2001 From: Yiming Li Date: Tue, 24 Feb 2026 14:45:03 +0800 Subject: [PATCH 7/7] feat: add componentName for class setState --- .../react/runtime/__test__/debug/profile.test.jsx | 1 + packages/react/runtime/src/debug/profile.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react/runtime/__test__/debug/profile.test.jsx b/packages/react/runtime/__test__/debug/profile.test.jsx index 8b8f691344..53e9e79015 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -124,6 +124,7 @@ describe('profile', () => { { "args": { "changed (shallow diff) state keys": "["count"]", + "componentName": "ClassComponent", "current state keys": "["count"]", "currentValue": "{"count":0}", "next state keys": "["count"]", diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index 77ad894b97..9b39fa713f 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -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 { Component, options } from 'preact'; -import type { ComponentClass, VNode } from 'preact'; +import type { ComponentClass, ComponentType, VNode } from 'preact'; import type { TraceOption } from '@lynx-js/types'; @@ -48,6 +48,7 @@ function safeJsonStringify(val: unknown) { } function buildSetStateProfileMarkArgs( + type: string | ComponentType | undefined, currentState: unknown, nextState: unknown, ): Record { @@ -57,6 +58,9 @@ function buildSetStateProfileMarkArgs( const nextStateObj = (nextState ?? EMPTY_OBJ) as Record; return { + componentName: (type && typeof type === 'function') + ? getDisplayName(type as ComponentClass) + : 'Unknown', 'current state keys': JSON.stringify(Object.keys(currentStateObj)), 'next state keys': JSON.stringify(Object.keys(nextStateObj)), 'changed (shallow diff) state keys': JSON.stringify( @@ -113,6 +117,7 @@ export function initProfileHook(): void { profileMark('ReactLynx::setState', { flowId: this[sFlowID] ??= profileFlowId(), args: buildSetStateProfileMarkArgs( + type, this.state, this[NEXT_STATE], ), @@ -179,11 +184,9 @@ export function initProfileHook(): void { profileMark('ReactLynx::hooks::setState', { flowId, args: { - componentName: (type && typeof type === 'function') - ? getDisplayName(type as ComponentClass) - : 'Unknown', hookIdx: String(hookIdx), ...buildSetStateProfileMarkArgs( + type, currentValue, nextValue, ),