diff --git a/.changeset/free-dragons-mate.md b/.changeset/free-dragons-mate.md new file mode 100644 index 0000000000..7b98efb1c0 --- /dev/null +++ b/.changeset/free-dragons-mate.md @@ -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. diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md index c38cb84129..d8aa00c05f 100644 --- a/packages/react/etc/react.api.md +++ b/packages/react/etc/react.api.md @@ -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 } @@ -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 void>(eventName: string, listener: T): void; diff --git a/packages/react/runtime/__test__/debug/backgroundSnapshot-profile.test.jsx b/packages/react/runtime/__test__/debug/backgroundSnapshot-profile.test.jsx new file mode 100644 index 0000000000..16e16130e5 --- /dev/null +++ b/packages/react/runtime/__test__/debug/backgroundSnapshot-profile.test.jsx @@ -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__( + + root + {HOLE} + , +); +const ITEM_A = __SNAPSHOT__(A); +const ITEM_B = __SNAPSHOT__(); +const ITEM_C = __SNAPSHOT__(); + +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 ; + } + + render(, __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(); + }); + }); +}); diff --git a/packages/react/runtime/__test__/debug/profile-module.test.ts b/packages/react/runtime/__test__/debug/profile-module.test.ts new file mode 100644 index 0000000000..fe81b19b90 --- /dev/null +++ b/packages/react/runtime/__test__/debug/profile-module.test.ts @@ -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); + }); +}); diff --git a/packages/react/runtime/__test__/debug/profile.test.jsx b/packages/react/runtime/__test__/debug/profile.test.jsx index 53e9e79015..7dd8647083 100644 --- a/packages/react/runtime/__test__/debug/profile.test.jsx +++ b/packages/react/runtime/__test__/debug/profile.test.jsx @@ -8,7 +8,7 @@ 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 { initProfileHook } from '../../src/debug/profileHooks'; import { useState } from '../../src/index'; import { COMPONENT, DIFF, DIFF2, DIFFED, HOOKS, LIST, RENDER, VNODE } from '../../src/renderToOpcodes/constants'; diff --git a/packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx b/packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx new file mode 100644 index 0000000000..2f7b8ac06e --- /dev/null +++ b/packages/react/runtime/__test__/debug/react-hooks-profile.test.jsx @@ -0,0 +1,268 @@ +/* +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +*/ +import { render } from 'preact'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setupDocument } from '../../src/document'; +import { setupPage, snapshotInstanceManager } from '../../src/snapshot'; +import { elementTree } from '../utils/nativeMethod'; + +async function importHooksWithProfileRecording(isRecording) { + const original = lynx.performance.isProfileRecording; + lynx.performance.isProfileRecording = vi.fn(() => isRecording); + vi.resetModules(); + try { + return await import('../../src/hooks/react'); + } finally { + lynx.performance.isProfileRecording = original; + } +} + +describe('react hooks profile', () => { + let scratch; + + beforeAll(() => { + setupDocument(); + setupPage(__CreatePage('0', 0)); + }); + + beforeEach(() => { + snapshotInstanceManager.clear(); + scratch = document.createElement('root'); + }); + + afterEach(() => { + render(null, scratch); + elementTree.clear(); + vi.restoreAllMocks(); + }); + + it('should profile useEffect and useLayoutEffect with flowId and cleanup', async () => { + const { useEffect, useLayoutEffect } = await importHooksWithProfileRecording(true); + + function App() { + useEffect(() => { + return () => {}; + }, []); + useLayoutEffect(() => { + return () => {}; + }, []); + return ; + } + + lynx.performance.profileStart.mockClear(); + lynx.performance.profileEnd.mockClear(); + lynx.performance.profileFlowId.mockClear(); + + render(, scratch); + await Promise.resolve(); + render(null, scratch); + await Promise.resolve(); + + const starts = lynx.performance.profileStart.mock.calls; + const effectCalls = starts.filter(([traceName]) => ( + traceName.startsWith('ReactLynx::hooks::useEffect') + )); + const layoutCalls = starts.filter(([traceName]) => ( + traceName.startsWith('ReactLynx::hooks::useLayoutEffect') + )); + + expect(effectCalls.some(([traceName]) => traceName === 'ReactLynx::hooks::useEffect')).toBe(true); + expect(effectCalls.some(([traceName]) => traceName === 'ReactLynx::hooks::useEffect::callback')).toBe(true); + expect(effectCalls.some(([traceName]) => traceName === 'ReactLynx::hooks::useEffect::callback::cleanup')).toBe( + true, + ); + expect(layoutCalls.some(([traceName]) => traceName === 'ReactLynx::hooks::useLayoutEffect')).toBe(true); + expect(layoutCalls.some(([traceName]) => traceName === 'ReactLynx::hooks::useLayoutEffect::callback')).toBe(true); + expect(layoutCalls.some(([traceName]) => traceName === 'ReactLynx::hooks::useLayoutEffect::callback::cleanup')) + .toBe(true); + expect(lynx.performance.profileFlowId).toBeCalledTimes(2); + + [...effectCalls, ...layoutCalls].forEach(([, option]) => { + expect(option).toEqual( + expect.objectContaining({ + flowId: 666, + args: expect.objectContaining({ + stack: expect.any(String), + }), + }), + ); + expect(option.args.stack.length).toBeGreaterThan(0); + }); + }); + + it('should skip stack payload when Error.stack is missing in useState setter profile', async () => { + const { useState } = await importHooksWithProfileRecording(true); + let setValue; + + function App() { + const [value, _setValue] = useState(0); + setValue = _setValue; + return ; + } + + render(, scratch); + + const OriginalError = globalThis.Error; + class ErrorWithoutStack extends OriginalError { + constructor(...args) { + super(...args); + this.stack = undefined; + } + } + + lynx.performance.profileStart.mockClear(); + lynx.performance.profileEnd.mockClear(); + + try { + vi.stubGlobal('Error', ErrorWithoutStack); + setValue(v => v + 1); + } finally { + vi.stubGlobal('Error', OriginalError); + } + + const setterProfileCalls = lynx.performance.profileStart.mock.calls.filter( + ([traceName]) => traceName === 'ReactLynx::hooks::useState::setter', + ); + + expect(setterProfileCalls).toHaveLength(1); + expect(setterProfileCalls[0][1]).toBeUndefined(); + }); + + it('should use flowId-only trace option when stack is missing in effect profile', async () => { + const { useEffect } = await importHooksWithProfileRecording(true); + + function App() { + useEffect(() => undefined, []); + return ; + } + + const OriginalError = globalThis.Error; + class ErrorWithoutStack extends OriginalError { + constructor(...args) { + super(...args); + this.stack = undefined; + } + } + + lynx.performance.profileStart.mockClear(); + lynx.performance.profileEnd.mockClear(); + + try { + vi.stubGlobal('Error', ErrorWithoutStack); + render(, scratch); + await Promise.resolve(); + } finally { + vi.stubGlobal('Error', OriginalError); + } + + const effectRootCall = lynx.performance.profileStart.mock.calls.find( + ([traceName]) => traceName === 'ReactLynx::hooks::useEffect', + ); + const effectCallbackCall = lynx.performance.profileStart.mock.calls.find( + ([traceName]) => traceName === 'ReactLynx::hooks::useEffect::callback', + ); + + expect(effectRootCall?.[1]).toEqual({ flowId: 666 }); + expect(effectCallbackCall?.[1]).toEqual({ flowId: 666 }); + }); + + it('should support useState without initial value in profiling mode', async () => { + const { useState } = await importHooksWithProfileRecording(true); + let setValue; + + function App() { + const [value, _setValue] = useState(); + setValue = _setValue; + return ; + } + + render(, scratch); + + lynx.performance.profileStart.mockClear(); + lynx.performance.profileEnd.mockClear(); + + setValue(1); + + const setterProfileCalls = lynx.performance.profileStart.mock.calls.filter( + ([traceName]) => traceName === 'ReactLynx::hooks::useState::setter', + ); + + expect(setterProfileCalls).toHaveLength(1); + expect(setterProfileCalls[0][1]).toEqual( + expect.objectContaining({ + args: expect.objectContaining({ + stack: expect.any(String), + }), + }), + ); + }); + + it('should profile useState setter with realtime stack', async () => { + const { useState } = await importHooksWithProfileRecording(true); + let setValue; + + function App() { + const [value, _setValue] = useState(0); + setValue = _setValue; + return ; + } + + render(, scratch); + + lynx.performance.profileStart.mockClear(); + lynx.performance.profileEnd.mockClear(); + + setValue(v => v); + + const setterProfileCalls = lynx.performance.profileStart.mock.calls.filter( + ([traceName]) => traceName === 'ReactLynx::hooks::useState::setter', + ); + + expect(setterProfileCalls).toHaveLength(1); + expect(setterProfileCalls[0][1]).toEqual( + expect.objectContaining({ + args: expect.objectContaining({ + stack: expect.any(String), + }), + }), + ); + expect(setterProfileCalls[0][1].args.stack.length).toBeGreaterThan(0); + }); + + it('should not profile hooks when profiling is disabled', async () => { + const { useEffect, useLayoutEffect, useState } = await importHooksWithProfileRecording(false); + const preactHooks = await import('preact/hooks'); + let setValue; + + function App() { + const [value, _setValue] = useState(0); + setValue = _setValue; + useEffect(() => undefined, [value]); + useLayoutEffect(() => undefined, [value]); + return ; + } + + lynx.performance.profileStart.mockClear(); + lynx.performance.profileEnd.mockClear(); + lynx.performance.profileFlowId.mockClear(); + + expect(useState).toBe(preactHooks.useState); + expect(useEffect).toBe(preactHooks.useEffect); + expect(useLayoutEffect).toBe(preactHooks.useEffect); + + render(, scratch); + await Promise.resolve(); + setValue(v => v + 1); + await Promise.resolve(); + + const hookTraceCalls = lynx.performance.profileStart.mock.calls.filter(([traceName]) => ( + traceName.startsWith('ReactLynx::hooks::') + )); + + expect(hookTraceCalls).toHaveLength(0); + }); +}); diff --git a/packages/react/runtime/__test__/debug/vnodeSource.test.ts b/packages/react/runtime/__test__/debug/vnodeSource.test.ts new file mode 100644 index 0000000000..7d8557786e --- /dev/null +++ b/packages/react/runtime/__test__/debug/vnodeSource.test.ts @@ -0,0 +1,120 @@ +/* +// 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 { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { + clearSnapshotVNodeSource, + getSnapshotVNodeSource, + moveSnapshotVNodeSource, + setupVNodeSourceHook, +} from '../../src/debug/vnodeSource'; +import { DIFFED, DOM } from '../../src/renderToOpcodes/constants'; + +describe('vnodeSource', () => { + let originalDiffed: typeof options[typeof DIFFED]; + let oldDiffedHook: ReturnType; + + beforeAll(() => { + originalDiffed = options[DIFFED]; + oldDiffedHook = vi.fn(); + options[DIFFED] = oldDiffedHook; + setupVNodeSourceHook(); + }); + + afterEach(() => { + clearSnapshotVNodeSource(); + oldDiffedHook.mockClear(); + }); + + afterAll(() => { + options[DIFFED] = originalDiffed; + clearSnapshotVNodeSource(); + }); + + it('should install hook once and call previous DIFFED hook', () => { + const installedHook = options[DIFFED]; + setupVNodeSourceHook(); + expect(options[DIFFED]).toBe(installedHook); + + const vnode = { + type: 'view', + __source: { + fileName: 'component.tsx', + lineNumber: 10, + columnNumber: 20, + }, + [DOM]: { __id: 101 }, + }; + + options[DIFFED]?.(vnode as never); + + expect(oldDiffedHook).toBeCalledWith(vnode); + expect(getSnapshotVNodeSource(101)).toBe('component.tsx:10:20'); + }); + + it('should format source with file:line and file only', () => { + options[DIFFED]?.({ + type: 'view', + __source: { fileName: 'line-only.tsx', lineNumber: 7 }, + [DOM]: { __id: 201 }, + } as never); + options[DIFFED]?.({ + type: 'view', + __source: { fileName: 'file-only.tsx' }, + [DOM]: { __id: 202 }, + } as never); + + expect(getSnapshotVNodeSource(201)).toBe('line-only.tsx:7'); + expect(getSnapshotVNodeSource(202)).toBe('file-only.tsx'); + }); + + it('should ignore vnode without valid type/source/id', () => { + options[DIFFED]?.({ + type: () => null, + __source: { fileName: 'ignored.tsx', lineNumber: 1, columnNumber: 1 }, + [DOM]: { __id: 301 }, + } as never); + options[DIFFED]?.({ + type: 'view', + [DOM]: { __id: 302 }, + } as never); + options[DIFFED]?.({ + type: 'view', + __source: { lineNumber: 1, columnNumber: 1 }, + [DOM]: { __id: 303 }, + } as never); + options[DIFFED]?.({ + type: 'view', + __source: { fileName: 'no-id.tsx', lineNumber: 1, columnNumber: 1 }, + [DOM]: {}, + } as never); + + expect(getSnapshotVNodeSource(301)).toBeUndefined(); + expect(getSnapshotVNodeSource(302)).toBeUndefined(); + expect(getSnapshotVNodeSource(303)).toBeUndefined(); + }); + + it('should move and clear source entries', () => { + options[DIFFED]?.({ + type: 'view', + __source: { fileName: 'move.tsx', lineNumber: 9, columnNumber: 8 }, + [DOM]: { __id: 401 }, + } as never); + + expect(getSnapshotVNodeSource(401)).toBe('move.tsx:9:8'); + + moveSnapshotVNodeSource(401, 402); + expect(getSnapshotVNodeSource(401)).toBeUndefined(); + expect(getSnapshotVNodeSource(402)).toBe('move.tsx:9:8'); + + moveSnapshotVNodeSource(402, 402); + expect(getSnapshotVNodeSource(402)).toBe('move.tsx:9:8'); + + clearSnapshotVNodeSource(); + expect(getSnapshotVNodeSource(402)).toBeUndefined(); + }); +}); diff --git a/packages/react/runtime/src/alog/elementPAPICall.ts b/packages/react/runtime/src/alog/elementPAPICall.ts index 892919b554..6288e589c1 100644 --- a/packages/react/runtime/src/alog/elementPAPICall.ts +++ b/packages/react/runtime/src/alog/elementPAPICall.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 { profileEnd, profileStart } from '../debug/utils.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; const fiberElementPAPINameList = [ '__CreatePage', diff --git a/packages/react/runtime/src/backgroundSnapshot.ts b/packages/react/runtime/src/backgroundSnapshot.ts index dbe3b4d417..bf134d5915 100644 --- a/packages/react/runtime/src/backgroundSnapshot.ts +++ b/packages/react/runtime/src/backgroundSnapshot.ts @@ -10,7 +10,8 @@ import type { Worklet } from '@lynx-js/react/worklet-runtime/bindings'; -import { profileEnd, profileStart } from './debug/utils.js'; +import { profileEnd, profileStart } from './debug/profile.js'; +import { getSnapshotVNodeSource } from './debug/vnodeSource.js'; import { processGestureBackground } from './gesture/processGestureBagkround.js'; import type { GestureKind } from './gesture/types.js'; import { diffArrayAction, diffArrayLepus } from './hydrate.js'; @@ -391,149 +392,260 @@ export function hydrate( before: SerializedSnapshotInstance, after: BackgroundSnapshotInstance, ): SnapshotPatch { - initGlobalSnapshotPatch(); - - const helper = ( - before: SerializedSnapshotInstance, - after: BackgroundSnapshotInstance, - ) => { - hydrationMap.set(after.__id, before.id); - backgroundSnapshotInstanceManager.updateId(after.__id, before.id); - after.__values?.forEach((value: unknown, index) => { - const old: unknown = before.values![index]; - - if (value) { - if (typeof value === 'object') { - if ('__spread' in value) { - // `value.__spread` my contain event ids using snapshot ids before hydration. Remove it. - delete value.__spread; - const __spread = transformSpread(after, index, value); - for (const key in __spread) { - const v = __spread[key]; - if (v && typeof v === 'object') { - if ('_wkltId' in v) { - onPostWorkletCtx(v as Worklet); - } else if ('__isGesture' in v) { - processGestureBackground(v as GestureKind); + const shouldProfile = typeof __PROFILE__ !== 'undefined' && __PROFILE__; + if (shouldProfile) { + profileStart('ReactLynx::BSI::hydrate'); + } + try { + initGlobalSnapshotPatch(); + + const helper = ( + before: SerializedSnapshotInstance, + after: BackgroundSnapshotInstance, + ) => { + hydrationMap.set(after.__id, before.id); + backgroundSnapshotInstanceManager.updateId(after.__id, before.id); + after.__values?.forEach((value: unknown, index) => { + const old: unknown = before.values![index]; + + if (value) { + if (typeof value === 'object') { + if ('__spread' in value) { + // `value.__spread` my contain event ids using snapshot ids before hydration. Remove it. + delete value.__spread; + const __spread = transformSpread(after, index, value); + for (const key in __spread) { + const v = __spread[key]; + if (v && typeof v === 'object') { + if ('_wkltId' in v) { + onPostWorkletCtx(v as Worklet); + } else if ('__isGesture' in v) { + processGestureBackground(v as GestureKind); + } } } + (after.__values![index]! as Record)['__spread'] = __spread; + value = __spread; + } else if ('__ref' in value) { + // skip patch + value = old; + } else if ('_wkltId' in value) { + onPostWorkletCtx(value as Worklet); + } else if ('__isGesture' in value) { + processGestureBackground(value as GestureKind); + } + } else if (typeof value === 'function') { + if ('__ref' in value) { + // skip patch + value = old; + } else { + value = `${after.__id}:${index}:`; } - (after.__values![index]! as Record)['__spread'] = __spread; - value = __spread; - } else if ('__ref' in value) { - // skip patch - value = old; - } else if ('_wkltId' in value) { - onPostWorkletCtx(value as Worklet); - } else if ('__isGesture' in value) { - processGestureBackground(value as GestureKind); - } - } else if (typeof value === 'function') { - if ('__ref' in value) { - // skip patch - value = old; - } else { - value = `${after.__id}:${index}:`; } } - } - if (!isDirectOrDeepEqual(value, old)) { - if (value === undefined && old === null) { - // This is a workaround for the case where we set an attribute to `undefined` in the main thread, - // but the old value becomes `null` during JSON serialization. - // In this case, we should not patch the value. - } else { - __globalSnapshotPatch!.push( - SnapshotOperation.SetAttribute, - after.__id, - index, - value, - ); - } - } - }); - - if (after.__extraProps) { - for (const key in after.__extraProps) { - const value = after.__extraProps[key]; - const old = before.extraProps?.[key]; if (!isDirectOrDeepEqual(value, old)) { - __globalSnapshotPatch!.push( - SnapshotOperation.SetAttribute, - after.__id, - key, - value, - ); - } - } - } - - const { slot } = after.__snapshot_def; - - const beforeChildNodes = before.children ?? []; - const afterChildNodes = after.childNodes; - - if (!slot) { - return; - } - - slot.forEach(([type], index) => { - switch (type) { - case DynamicPartType.Slot: - case DynamicPartType.MultiChildren: { - // TODO: the following null assertions are not 100% safe - const v1 = beforeChildNodes[index]!; - const v2 = afterChildNodes[index]!; - helper(v1, v2); - break; - } - case DynamicPartType.Children: - case DynamicPartType.ListChildren: { - const diffResult = diffArrayLepus( - beforeChildNodes, - afterChildNodes, - (a, b) => a.type === b.type, - (a, b) => { - helper(a, b); - }, - // Should be `false` in hydrate as SerializedSnapshotInstance has no item-key - false, - ); - diffArrayAction( - beforeChildNodes, - diffResult, - (node, target) => { - reconstructInstanceTree([node], before.id, target?.id); - return undefined as unknown as SerializedSnapshotInstance; - }, - node => { + if (value === undefined && old === null) { + // This is a workaround for the case where we set an attribute to `undefined` in the main thread, + // but the old value becomes `null` during JSON serialization. + // In this case, we should not patch the value. + } else { + if (shouldProfile) { + profileStart('ReactLynx::hydrate::setAttribute', { + args: { + id: String(after.__id), + snapshotType: String(after.type), + source: getSnapshotVNodeSource(after.__id) ?? '', + dynamicPartIndex: String(index), + valueType: value === null ? 'null' : typeof value, + }, + }); + try { + __globalSnapshotPatch!.push( + SnapshotOperation.SetAttribute, + after.__id, + index, + value, + ); + } finally { + profileEnd(); + } + } else { __globalSnapshotPatch!.push( - SnapshotOperation.RemoveChild, - before.id, - node.id, + SnapshotOperation.SetAttribute, + after.__id, + index, + value, ); - }, - (node, target) => { - // changedList.push([SnapshotOperation.RemoveChild, before.id, node.id]); + } + } + } + }); + + if (after.__extraProps) { + for (const key in after.__extraProps) { + const value = after.__extraProps[key]; + const old = before.extraProps?.[key]; + if (!isDirectOrDeepEqual(value, old)) { + if (shouldProfile) { + profileStart('ReactLynx::hydrate::setAttribute', { + args: { + id: String(after.__id), + snapshotType: String(after.type), + source: getSnapshotVNodeSource(after.__id) ?? '', + dynamicPartIndex: key, + valueType: value === null ? 'null' : typeof value, + }, + }); + try { + __globalSnapshotPatch!.push( + SnapshotOperation.SetAttribute, + after.__id, + key, + value, + ); + } finally { + profileEnd(); + } + } else { __globalSnapshotPatch!.push( - SnapshotOperation.InsertBefore, - before.id, - node.id, - target?.id, + SnapshotOperation.SetAttribute, + after.__id, + key, + value, ); - }, - ); - break; + } + } } } - }); - }; - helper(before, after); - // Hydration should not trigger ref updates. They were incorrectly triggered when using `setAttribute` to add values to the patch list. - clearQueuedRefs(); - return takeGlobalSnapshotPatch()!; + const { slot } = after.__snapshot_def; + + const beforeChildNodes = before.children ?? []; + const afterChildNodes = after.childNodes; + + if (!slot) { + return; + } + + slot.forEach(([type], index) => { + switch (type) { + case DynamicPartType.Slot: + case DynamicPartType.MultiChildren: { + // TODO: the following null assertions are not 100% safe + const v1 = beforeChildNodes[index]!; + const v2 = afterChildNodes[index]!; + helper(v1, v2); + break; + } + case DynamicPartType.Children: + case DynamicPartType.ListChildren: { + const diffResult = diffArrayLepus( + beforeChildNodes, + afterChildNodes, + (a, b) => a.type === b.type, + (a, b) => { + helper(a, b); + }, + // Should be `false` in hydrate as SerializedSnapshotInstance has no item-key + false, + ); + diffArrayAction( + beforeChildNodes, + diffResult, + (node, target) => { + if (shouldProfile) { + profileStart('ReactLynx::BSI::reconstructInstanceTree', { + args: { + id: String(node.__id), + snapshotType: String(node.type), + source: getSnapshotVNodeSource(node.__id) ?? '', + }, + }); + } + try { + reconstructInstanceTree([node], before.id, target?.id); + } finally { + if (shouldProfile) { + profileEnd(); + } + } + return undefined as unknown as SerializedSnapshotInstance; + }, + node => { + if (shouldProfile) { + profileStart('ReactLynx::hydrate::removeChild', { + args: { + id: String(node.id), + snapshotType: String(node.type), + source: getSnapshotVNodeSource(node.id) ?? '', + parentId: String(before.id), + }, + }); + try { + __globalSnapshotPatch!.push( + SnapshotOperation.RemoveChild, + before.id, + node.id, + ); + } finally { + profileEnd(); + } + } else { + __globalSnapshotPatch!.push( + SnapshotOperation.RemoveChild, + before.id, + node.id, + ); + } + }, + (node, target) => { + // changedList.push([SnapshotOperation.RemoveChild, before.id, node.id]); + if (shouldProfile) { + profileStart('ReactLynx::hydrate::insertBefore', { + args: { + id: String(node.id), + snapshotType: String(node.type), + source: getSnapshotVNodeSource(node.id) ?? '', + parentId: String(before.id), + targetId: String(target?.id ?? ''), + }, + }); + try { + __globalSnapshotPatch!.push( + SnapshotOperation.InsertBefore, + before.id, + node.id, + target?.id, + ); + } finally { + profileEnd(); + } + } else { + __globalSnapshotPatch!.push( + SnapshotOperation.InsertBefore, + before.id, + node.id, + target?.id, + ); + } + }, + ); + break; + } + } + }); + }; + + helper(before, after); + // Hydration should not trigger ref updates. They were incorrectly triggered when using `setAttribute` to add values to the patch list. + clearQueuedRefs(); + return takeGlobalSnapshotPatch()!; + } finally { + if (shouldProfile) { + profileEnd(); + } + } } function reconstructInstanceTree(afters: BackgroundSnapshotInstance[], parentId: number, targetId?: number): void { diff --git a/packages/react/runtime/src/debug/profile.ts b/packages/react/runtime/src/debug/profile.ts index 9b39fa713f..0fca5a70d0 100644 --- a/packages/react/runtime/src/debug/profile.ts +++ b/packages/react/runtime/src/debug/profile.ts @@ -1,269 +1,36 @@ // 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 { Component, options } from 'preact'; -import type { ComponentClass, ComponentType, VNode } from 'preact'; -import type { TraceOption } from '@lynx-js/types'; +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ -import { globalPatchOptions } from '../lifecycle/patch/commit.js'; -import { __globalSnapshotPatch } from '../lifecycle/patch/snapshotPatch.js'; -import { - COMMIT, - COMPONENT, - DIFF, - DIFF2, - DIFFED, - DIRTY, - HOOKS, - LIST, - NEXT_STATE, - NEXT_VALUE, - RENDER, - VALUE, - VNODE, -} from '../renderToOpcodes/constants.js'; -import { getDisplayName, hook } from '../utils.js'; +export const isProfiling: boolean = /* @__PURE__ */ Boolean( + lynx.performance?.isProfileRecording?.(), +); -const format = (val: unknown) => { - if (typeof val === 'function') { - return val.toString(); - } - return val; -}; - -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( - type: string | ComponentType | undefined, - currentState: unknown, - nextState: unknown, -): Record { - const EMPTY_OBJ = {}; - - const currentStateObj = (currentState ?? EMPTY_OBJ) as Record; - 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( - // the setState is in assign manner, we assume nextState is a superset of currentState - Object.keys(nextStateObj).filter( - key => currentStateObj[key] !== nextStateObj[key], - ), - ), - currentValue: safeJsonStringify(format(currentState)), - nextValue: safeJsonStringify(format(nextState)), - }; -} +const noop = () => {}; +const noopFlowId = () => 0; -export function initProfileHook(): void { - // early-exit if required profiling APIs are unavailable +export const profileStart = /* @__PURE__ */ ((() => { 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; + if (!(p = lynx.performance) || typeof p.profileStart !== 'function') { + return noop; } - /* 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('FLOW_ID'); - type PatchedComponent = Component & { [sFlowID]?: number }; - - if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { - hook( - Component.prototype, - 'setState', - function(this: PatchedComponent & { [NEXT_STATE]: unknown }, old, state, callback) { - old?.call(this, state, callback); - - if (this[DIRTY]) { - const type = this[VNODE]!.type; - const isClassComponent = typeof type === 'function' && ('prototype' in type) - && ('render' in type.prototype); - - if (isClassComponent) { - profileMark('ReactLynx::setState', { - flowId: this[sFlowID] ??= profileFlowId(), - args: buildSetStateProfileMarkArgs( - type, - this.state, - this[NEXT_STATE], - ), - }); - } - } - }, - ); - } - - hook(options, DIFF2, (old, vnode, oldVNode) => { - // We only add profiling trace for Component - if (typeof vnode.type === 'function') { - const profileOptions: TraceOption = {}; - - if (typeof __BACKGROUND__ !== 'undefined' && __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; - } - } - } + return p.profileStart.bind(p); +})()) as typeof lynx.performance.profileStart; - profileStart( - `ReactLynx::diff::${/* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass)}`, - profileOptions, - ); - } - old?.(vnode, oldVNode); - }); - - hook(options, DIFFED, (old, vnode) => { - if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { - const hooks = vnode[COMPONENT]?.[HOOKS]; - const hookList = hooks?.[LIST]; - - if (Array.isArray(hookList)) { - hookList.forEach((hookState, hookIdx: number) => { - 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 type = component[VNODE]!.type; - const flowId = component[sFlowID] ??= profileFlowId(); - - profileMark('ReactLynx::hooks::setState', { - flowId, - args: { - hookIdx: String(hookIdx), - ...buildSetStateProfileMarkArgs( - type, - currentValue, - nextValue, - ), - }, - }); - } - hookState['internalNextValue'] = value; - }, - configurable: true, - }); - }); - } - } - - if (typeof vnode.type === 'function') { - profileEnd(); // for options[DIFF] - } - old?.(vnode); - }); - - if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { - hook(options, COMMIT, (old, vnode, commitQueue) => { - profileStart('ReactLynx::commit', { - ...globalPatchOptions.flowIds - ? { - flowId: globalPatchOptions.flowIds[0], - flowIds: globalPatchOptions.flowIds, - } - : {}, - }); - old?.(vnode, commitQueue); - profileEnd(); - }); - } +export const profileEnd = /* @__PURE__ */ ((() => { + let p; + if (!(p = lynx.performance) || typeof p.profileEnd !== 'function') { + return noop; } + return p.profileEnd.bind(p); +})()) as typeof lynx.performance.profileEnd; - // Profile the user-provided `render`. - 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) { - profileStart(`ReactLynx::render::${/* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass)}`); - try { - return originalRender.call(this, props, state, context); - } finally { - profileEnd(); - vnode[COMPONENT]!.render = originalRender; - } - }; - old?.(vnode); - }); - - if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { - const sPatchLength = Symbol('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); - }); +export const profileFlowId = /* @__PURE__ */ ((() => { + let p; + if (!(p = lynx.performance) || typeof p.profileFlowId !== 'function') { + return noopFlowId; } -} + return p.profileFlowId.bind(p); +})()) as typeof lynx.performance.profileFlowId; diff --git a/packages/react/runtime/src/debug/profileHooks.ts b/packages/react/runtime/src/debug/profileHooks.ts new file mode 100644 index 0000000000..6814da0ed2 --- /dev/null +++ b/packages/react/runtime/src/debug/profileHooks.ts @@ -0,0 +1,270 @@ +// 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 { Component, options } from 'preact'; +import type { ComponentClass, ComponentType, VNode } from 'preact'; + +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, + HOOKS, + LIST, + NEXT_STATE, + NEXT_VALUE, + RENDER, + VALUE, + VNODE, +} from '../renderToOpcodes/constants.js'; +import { getDisplayName, hook } from '../utils.js'; + +const format = (val: unknown) => { + if (typeof val === 'function') { + return val.toString(); + } + return val; +}; + +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( + type: string | ComponentType | undefined, + currentState: unknown, + nextState: unknown, +): Record { + const EMPTY_OBJ = {}; + + const currentStateObj = (currentState ?? EMPTY_OBJ) as Record; + 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( + // the setState is in assign manner, we assume nextState is a superset of currentState + Object.keys(nextStateObj).filter( + key => currentStateObj[key] !== nextStateObj[key], + ), + ), + currentValue: safeJsonStringify(format(currentState)), + nextValue: safeJsonStringify(format(nextState)), + }; +} + +export function initProfileHook(): void { + // 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('FLOW_ID'); + type PatchedComponent = Component & { [sFlowID]?: number }; + + if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { + hook( + Component.prototype, + 'setState', + function(this: PatchedComponent & { [NEXT_STATE]: unknown }, old, state, callback) { + old?.call(this, state, callback); + + if (this[DIRTY]) { + const type = this[VNODE]!.type; + const isClassComponent = typeof type === 'function' && ('prototype' in type) + && ('render' in type.prototype); + + if (isClassComponent) { + profileMark('ReactLynx::setState', { + flowId: this[sFlowID] ??= profileFlowId(), + args: buildSetStateProfileMarkArgs( + type, + this.state, + this[NEXT_STATE], + ), + }); + } + } + }, + ); + } + + hook(options, DIFF2, (old, vnode, oldVNode) => { + // We only add profiling trace for Component + if (typeof vnode.type === 'function') { + const profileOptions: TraceOption = {}; + + if (typeof __BACKGROUND__ !== 'undefined' && __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 __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { + const hooks = vnode[COMPONENT]?.[HOOKS]; + const hookList = hooks?.[LIST]; + + if (Array.isArray(hookList)) { + hookList.forEach((hookState, hookIdx: number) => { + 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 type = component[VNODE]!.type; + const flowId = component[sFlowID] ??= profileFlowId(); + + profileMark('ReactLynx::hooks::setState', { + flowId, + args: { + hookIdx: String(hookIdx), + ...buildSetStateProfileMarkArgs( + type, + currentValue, + nextValue, + ), + }, + }); + } + hookState['internalNextValue'] = value; + }, + configurable: true, + }); + }); + } + } + + if (typeof vnode.type === 'function') { + profileEnd(); // for options[DIFF2] + } + old?.(vnode); + }); + + if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { + hook(options, COMMIT, (old, vnode, commitQueue) => { + profileStart('ReactLynx::commit', { + ...globalPatchOptions.flowIds + ? { + flowId: globalPatchOptions.flowIds[0], + flowIds: globalPatchOptions.flowIds, + } + : {}, + }); + old?.(vnode, commitQueue); + profileEnd(); + }); + } + } + + // Profile the user-provided `render`. + 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) { + profileStart(`ReactLynx::render::${/* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass)}`); + try { + return originalRender.call(this, props, state, context); + } finally { + profileEnd(); + vnode[COMPONENT]!.render = originalRender; + } + }; + old?.(vnode); + }); + + if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { + const sPatchLength = Symbol('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') { + const patchLength = vnode[sPatchLength]; + delete vnode[sPatchLength]; + if (__globalSnapshotPatch && patchLength === __globalSnapshotPatch.length) { + // "NoPatch" is a conventional name in Lynx + profileMark('ReactLynx::diffFinishNoPatch', { + args: { + componentName: /* #__INLINE__ */ getDisplayName(vnode.type as ComponentClass), + }, + }); + } + } + old?.(vnode); + }); + } +} diff --git a/packages/react/runtime/src/debug/utils.ts b/packages/react/runtime/src/debug/utils.ts deleted file mode 100644 index 8fe37662f9..0000000000 --- a/packages/react/runtime/src/debug/utils.ts +++ /dev/null @@ -1,23 +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. - -/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ - -const noop = () => {}; - -export const profileStart = /* @__PURE__ */ ((() => { - let p; - if (!(p = lynx.performance) || typeof p.profileStart !== 'function') { - return noop; - } - return p.profileStart.bind(p); -})()) as typeof lynx.performance.profileStart; - -export const profileEnd = /* @__PURE__ */ ((() => { - let p; - if (!(p = lynx.performance) || typeof p.profileEnd !== 'function') { - return noop; - } - return p.profileEnd.bind(p); -})()) as typeof lynx.performance.profileEnd; diff --git a/packages/react/runtime/src/debug/vnodeSource.ts b/packages/react/runtime/src/debug/vnodeSource.ts new file mode 100644 index 0000000000..aa8e9f8843 --- /dev/null +++ b/packages/react/runtime/src/debug/vnodeSource.ts @@ -0,0 +1,81 @@ +// 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 { VNode } from 'preact'; + +import { DIFFED, DOM } from '../renderToOpcodes/constants.js'; + +interface SourceInfo { + fileName?: string; + lineNumber?: number; + columnNumber?: number; +} + +interface PatchedVNode extends VNode { + __source?: SourceInfo; + [DOM]?: { __id?: number } | null; +} + +const snapshotVNodeSourceMap: Map = new Map(); + +let hookInstalled = false; + +function formatSource(source: SourceInfo): string | undefined { + if (!source.fileName) { + return undefined; + } + if (typeof source.lineNumber === 'number' && typeof source.columnNumber === 'number') { + return `${source.fileName}:${source.lineNumber}:${source.columnNumber}`; + } + if (typeof source.lineNumber === 'number') { + return `${source.fileName}:${source.lineNumber}`; + } + return source.fileName; +} + +function captureVNodeSource(vnode: PatchedVNode): void { + if (typeof vnode.type !== 'string') { + return; + } + const source = vnode.__source; + const id = vnode[DOM]?.__id; + if (!source || typeof id !== 'number') { + return; + } + const formattedSource = formatSource(source); + if (formattedSource) { + snapshotVNodeSourceMap.set(id, formattedSource); + } +} + +export function setupVNodeSourceHook(): void { + if (hookInstalled) { + return; + } + hookInstalled = true; + const oldDiffed = options[DIFFED]; + options[DIFFED] = (vnode) => { + captureVNodeSource(vnode as PatchedVNode); + oldDiffed?.(vnode); + }; +} + +export function moveSnapshotVNodeSource(oldId: number, newId: number): void { + if (oldId === newId) { + return; + } + const source = snapshotVNodeSourceMap.get(oldId); + if (source) { + snapshotVNodeSourceMap.set(newId, source); + snapshotVNodeSourceMap.delete(oldId); + } +} + +export function getSnapshotVNodeSource(id: number): string | undefined { + return snapshotVNodeSourceMap.get(id); +} + +export function clearSnapshotVNodeSource(): void { + snapshotVNodeSourceMap.clear(); +} diff --git a/packages/react/runtime/src/hooks/react.ts b/packages/react/runtime/src/hooks/react.ts index 95d4665884..91fb6dfc4a 100644 --- a/packages/react/runtime/src/hooks/react.ts +++ b/packages/react/runtime/src/hooks/react.ts @@ -10,40 +10,150 @@ import { useImperativeHandle, useMemo, useEffect as usePreactEffect, + useState as usePreactState, useReducer, useRef, - useState, } from 'preact/hooks'; +import type { Dispatch, StateUpdater } from 'preact/hooks'; import type { DependencyList, EffectCallback } from 'react'; +import type { TraceOption } from '@lynx-js/types'; + +import { isProfiling, profileEnd, profileFlowId, profileStart } from '../debug/profile.js'; + +type GenericSetState = Dispatch>; + +// Cache profiled wrappers by the original preact setter to preserve stable +// identity without introducing extra hooks in component render flow. +const stateSetterTraceCache: WeakMap | undefined = /* @__PURE__ */ isProfiling + ? new WeakMap() + : undefined; + +function buildTraceOption(flowId: number, stack: string | undefined): TraceOption { + if (!stack) { + return { flowId }; + } + return { + flowId, + args: { + stack, + }, + }; +} + +function withEffectProfile( + effect: EffectCallback, + traceName: string, + flowId: number, + stack: string | undefined, +): EffectCallback { + const traceOption = buildTraceOption(flowId, stack); + return () => { + profileStart(traceName, traceOption); + try { + const cleanup = effect(); + if (typeof cleanup !== 'function') { + return cleanup; + } + return () => { + profileStart(`${traceName}::cleanup`, traceOption); + try { + cleanup(); + } finally { + profileEnd(); + } + }; + } finally { + profileEnd(); + } + }; +} + +function useEffectWithProfile(effect: EffectCallback, deps: DependencyList | undefined, traceName: string): void { + const flowId = profileFlowId(); + const stack = new Error().stack; + const traceOption = buildTraceOption(flowId, stack); + profileStart(traceName, traceOption); + try { + return usePreactEffect(withEffectProfile(effect, `${traceName}::callback`, flowId, stack), deps); + } finally { + profileEnd(); + } +} + +function useLayoutEffectProfiled(effect: EffectCallback, deps?: DependencyList): void { + return useEffectWithProfile(effect, deps, 'ReactLynx::hooks::useLayoutEffect'); +} + +function useEffectProfiled(effect: EffectCallback, deps?: DependencyList): void { + return useEffectWithProfile(effect, deps, 'ReactLynx::hooks::useEffect'); +} + +function useStateWithProfile(initialState: S | (() => S)): [S, Dispatch>]; +function useStateWithProfile(): [S | undefined, Dispatch>]; +function useStateWithProfile( + initialState?: S | (() => S), +): [S | undefined, Dispatch>] { + const [state, setState] = ( + arguments.length === 0 + ? usePreactState() + : usePreactState(initialState as S | (() => S)) + ) as [S | undefined, Dispatch>]; + const genericSetState = setState as unknown as GenericSetState; + const cachedTracedSetState = stateSetterTraceCache?.get(genericSetState); + if (cachedTracedSetState) { + return [state, cachedTracedSetState as Dispatch>]; + } + + const tracedSetState = ((nextState: StateUpdater) => { + const stack = new Error().stack; + const traceOption = stack + ? ({ args: { stack } } as TraceOption) + : undefined; + profileStart('ReactLynx::hooks::useState::setter', traceOption); + try { + return setState(nextState); + } finally { + profileEnd(); + } + }) as Dispatch>; + stateSetterTraceCache?.set(genericSetState, tracedSetState as unknown as GenericSetState); + + return [state, tracedSetState]; +} + +const useState: typeof usePreactState = isProfiling + ? useStateWithProfile as typeof usePreactState + : usePreactState; + /** - * `useLayoutEffect` is now an alias of `useEffect`. Use `useEffect` instead. - * - * Accepts a function that contains imperative, possibly effectful code. The effects run after main thread dom update without blocking it. + * Accepts a function that contains imperative, possibly effectful code. + * The effects run after main thread dom update without blocking it. * * @param effect - Imperative function that can return a cleanup function * @param deps - If present, effect will only activate if the values in the list change (using ===). * * @public - * - * @deprecated `useLayoutEffect` in the background thread cannot offer the precise timing for reading layout information and synchronously re-render, which is different from React. */ -function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void { - return usePreactEffect(effect, deps); -} +const useEffect: (effect: EffectCallback, deps?: DependencyList) => void = isProfiling + ? useEffectProfiled + : usePreactEffect; /** - * Accepts a function that contains imperative, possibly effectful code. - * The effects run after main thread dom update without blocking it. + * `useLayoutEffect` is now an alias of `useEffect`. Use `useEffect` instead. + * + * Accepts a function that contains imperative, possibly effectful code. The effects run after main thread dom update without blocking it. * * @param effect - Imperative function that can return a cleanup function * @param deps - If present, effect will only activate if the values in the list change (using ===). * * @public + * + * @deprecated `useLayoutEffect` in the background thread cannot offer the precise timing for reading layout information and synchronously re-render, which is different from React. */ -function useEffect(effect: EffectCallback, deps?: DependencyList): void { - return usePreactEffect(effect, deps); -} +const useLayoutEffect: (effect: EffectCallback, deps?: DependencyList) => void = isProfiling + ? useLayoutEffectProfiled + : usePreactEffect; export { // preact diff --git a/packages/react/runtime/src/hydrate.ts b/packages/react/runtime/src/hydrate.ts index fed966a180..0aa2f7ed20 100644 --- a/packages/react/runtime/src/hydrate.ts +++ b/packages/react/runtime/src/hydrate.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 { profileEnd, profileStart } from './debug/utils.js'; +import { profileEnd, profileStart } from './debug/profile.js'; import { componentAtIndexFactory, enqueueComponentFactory, gRecycleMap, gSignMap } from './list.js'; import { __pendingListUpdates } from './pendingListUpdates.js'; import { DynamicPartType } from './snapshot/dynamicPartType.js'; diff --git a/packages/react/runtime/src/lifecycle/destroy.ts b/packages/react/runtime/src/lifecycle/destroy.ts index c2ffa75027..ee5835cedf 100644 --- a/packages/react/runtime/src/lifecycle/destroy.ts +++ b/packages/react/runtime/src/lifecycle/destroy.ts @@ -7,7 +7,7 @@ import { __root } from '../root.js'; import { delayedEvents } from './event/delayEvents.js'; import { delayedLifecycleEvents } from './event/delayLifecycleEvents.js'; import { globalCommitTaskMap } from './patch/commit.js'; -import { profileEnd, profileStart } from '../debug/utils.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; function destroyBackground(): void { if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { diff --git a/packages/react/runtime/src/lifecycle/event/jsReady.ts b/packages/react/runtime/src/lifecycle/event/jsReady.ts index 51ce9b5265..ad01c39d7b 100644 --- a/packages/react/runtime/src/lifecycle/event/jsReady.ts +++ b/packages/react/runtime/src/lifecycle/event/jsReady.ts @@ -1,7 +1,7 @@ // Copyright 2025 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 { profileEnd, profileStart } from '../../debug/utils.js'; +import { profileEnd, profileStart } from '../../debug/profile.js'; import { LifecycleConstant } from '../../lifecycleConstant.js'; import { __root } from '../../root.js'; diff --git a/packages/react/runtime/src/lifecycle/patch/commit.ts b/packages/react/runtime/src/lifecycle/patch/commit.ts index c1cb8f2ef1..54e0783131 100644 --- a/packages/react/runtime/src/lifecycle/patch/commit.ts +++ b/packages/react/runtime/src/lifecycle/patch/commit.ts @@ -33,7 +33,7 @@ import { sendMTRefInitValueToMainThread } from '../../worklet/ref/updateInitValu import { getReloadVersion } from '../pass.js'; import type { SnapshotPatch } from './snapshotPatch.js'; import { takeGlobalSnapshotPatch } from './snapshotPatch.js'; -import { profileEnd, profileStart } from '../../debug/utils.js'; +import { profileEnd, profileStart } from '../../debug/profile.js'; import { delayedRunOnMainThreadData, takeDelayedRunOnMainThreadData, diff --git a/packages/react/runtime/src/lifecycle/reload.ts b/packages/react/runtime/src/lifecycle/reload.ts index 7d5e52b24c..2d97a20f97 100644 --- a/packages/react/runtime/src/lifecycle/reload.ts +++ b/packages/react/runtime/src/lifecycle/reload.ts @@ -22,7 +22,7 @@ import { increaseReloadVersion } from './pass.js'; import { deinitGlobalSnapshotPatch } from './patch/snapshotPatch.js'; import { shouldDelayUiOps } from './ref/delay.js'; import { renderMainThread } from './render.js'; -import { profileEnd, profileStart } from '../debug/utils.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; function reloadMainThread(data: unknown, options: UpdatePageOption): void { if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { diff --git a/packages/react/runtime/src/lifecycle/render.ts b/packages/react/runtime/src/lifecycle/render.ts index ff95c8b598..111e9e0af7 100644 --- a/packages/react/runtime/src/lifecycle/render.ts +++ b/packages/react/runtime/src/lifecycle/render.ts @@ -8,7 +8,7 @@ import { isValidElement } from 'preact'; -import { profileEnd, profileStart } from '../debug/utils.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; import { renderOpcodesInto } from '../opcodes.js'; import { render as renderToString } from '../renderToOpcodes/index.js'; import { __root } from '../root.js'; diff --git a/packages/react/runtime/src/listUpdateInfo.ts b/packages/react/runtime/src/listUpdateInfo.ts index aee6b5e726..bbf3a0ee64 100644 --- a/packages/react/runtime/src/listUpdateInfo.ts +++ b/packages/react/runtime/src/listUpdateInfo.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 { profileEnd, profileStart } from './debug/utils.js'; +import { profileEnd, profileStart } from './debug/profile.js'; import { hydrate } from './hydrate.js'; import { componentAtIndexFactory, enqueueComponentFactory } from './list.js'; import type { SnapshotInstance } from './snapshot.js'; diff --git a/packages/react/runtime/src/lynx-api.ts b/packages/react/runtime/src/lynx-api.ts index 28b465de03..076a6f2933 100644 --- a/packages/react/runtime/src/lynx-api.ts +++ b/packages/react/runtime/src/lynx-api.ts @@ -7,7 +7,7 @@ import { useState } from 'preact/hooks'; import type { Consumer, FC, ReactNode } from 'react'; import { factory, withInitDataInState } from './compat/initData.js'; -import { profileEnd, profileStart } from './debug/utils.js'; +import { profileEnd, profileStart } from './debug/profile.js'; import { useLynxGlobalEventListener } from './hooks/useLynxGlobalEventListener.js'; import { LifecycleConstant } from './lifecycleConstant.js'; import { flushDelayedLifecycleEvents } from './lynx/tt.js'; diff --git a/packages/react/runtime/src/lynx.ts b/packages/react/runtime/src/lynx.ts index c636fe7760..da3a1d0bf2 100644 --- a/packages/react/runtime/src/lynx.ts +++ b/packages/react/runtime/src/lynx.ts @@ -8,7 +8,9 @@ import './hooks/react.js'; import { initElementPAPICallAlog } from './alog/elementPAPICall.js'; import { initAlog } from './alog/index.js'; import { setupComponentStack } from './debug/component-stack.js'; -import { initProfileHook } from './debug/profile.js'; +import { isProfiling } from './debug/profile.js'; +import { initProfileHook } from './debug/profileHooks.js'; +import { setupVNodeSourceHook } from './debug/vnodeSource.js'; import { document, setupBackgroundDocument } from './document.js'; import { replaceCommitHook } from './lifecycle/patch/commit.js'; import { addCtxNotFoundEventListener } from './lifecycle/patch/error.js'; @@ -69,7 +71,10 @@ if (typeof __BACKGROUND__ !== 'undefined' && __BACKGROUND__) { else { replaceCommitHook(); initTimingAPI(); - if (lynx.performance?.isProfileRecording?.()) { + if (__DEV__ && isProfiling) { + setupVNodeSourceHook(); + } + if (isProfiling) { initProfileHook(); } } diff --git a/packages/react/runtime/src/lynx/env.ts b/packages/react/runtime/src/lynx/env.ts index 2dd3fdba1a..73a670cc7f 100644 --- a/packages/react/runtime/src/lynx/env.ts +++ b/packages/react/runtime/src/lynx/env.ts @@ -1,7 +1,7 @@ // Copyright 2024 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { profileEnd, profileStart } from '../debug/utils.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; import type { DataProcessorDefinition, InitData, InitDataRaw } from '../lynx-api.js'; export function setupLynxEnv(): void { diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts index d3b967a2d0..03aec47cb7 100644 --- a/packages/react/runtime/src/lynx/tt.ts +++ b/packages/react/runtime/src/lynx/tt.ts @@ -9,7 +9,8 @@ import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } fr import { BackgroundSnapshotInstance, hydrate } from '../backgroundSnapshot.js'; import { runWithForce } from './runWithForce.js'; import { printSnapshotInstanceToString } from '../debug/printSnapshot.js'; -import { profileEnd, profileStart } from '../debug/utils.js'; +import { profileEnd, profileStart } from '../debug/profile.js'; +import { getSnapshotVNodeSource } from '../debug/vnodeSource.js'; import { destroyBackground } from '../lifecycle/destroy.js'; import { delayedEvents, delayedPublishEvent } from '../lifecycle/event/delayEvents.js'; import { delayLifecycleEvent, delayedLifecycleEvents } from '../lifecycle/event/delayLifecycleEvents.js'; @@ -203,30 +204,35 @@ function flushDelayedLifecycleEvents(): void { function publishEvent(handlerName: string, data: EventDataType) { lynxCoreInject.tt.callBeforePublishEvent?.(data); + let snapshotId: number | undefined; + const getSnapshotId = () => snapshotId ??= Number(handlerName.split(':')[0]); const eventHandler = backgroundSnapshotInstanceManager.getValueBySign( handlerName, ) as ((data: unknown) => void) | undefined; if (typeof __PROFILE__ !== 'undefined' && __PROFILE__) { + const currentSnapshotId = getSnapshotId(); profileStart(`ReactLynx::publishEvent`, { args: { handlerName, type: data.type, - snapshotInstanceType: backgroundSnapshotInstanceManager.values.get( - Number(handlerName.split(':')[0]), + snapshotType: backgroundSnapshotInstanceManager.values.get( + currentSnapshotId, )?.type ?? '', + source: getSnapshotVNodeSource(currentSnapshotId) ?? '', jsFunctionName: eventHandler?.name ?? '', }, }); } if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + const currentSnapshotId = getSnapshotId(); console.alog?.( `[ReactLynxDebug] BTS received event:\n` + JSON.stringify( { handlerName, type: data.type, - snapshotInstanceType: backgroundSnapshotInstanceManager.values.get( - Number(handlerName.split(':')[0]), + snapshotType: backgroundSnapshotInstanceManager.values.get( + currentSnapshotId, )?.type ?? '', jsFunctionName: eventHandler?.name ?? '', }, diff --git a/packages/react/runtime/src/snapshot.ts b/packages/react/runtime/src/snapshot.ts index 53d9bc6778..71295e9c3a 100644 --- a/packages/react/runtime/src/snapshot.ts +++ b/packages/react/runtime/src/snapshot.ts @@ -17,6 +17,7 @@ import type { Worklet, WorkletRefImpl } from '@lynx-js/react/worklet-runtime/bindings'; import type { BackgroundSnapshotInstance } from './backgroundSnapshot.js'; +import { clearSnapshotVNodeSource, moveSnapshotVNodeSource } from './debug/vnodeSource.js'; import { SnapshotOperation, __globalSnapshotPatch } from './lifecycle/patch/snapshotPatch.js'; import { ListUpdateInfoRecording } from './listUpdateInfo.js'; import { __pendingListUpdates } from './pendingListUpdates.js'; @@ -132,6 +133,9 @@ export const snapshotInstanceManager: { clear() { // not resetting `nextId` to prevent id collision this.values.clear(); + if (__DEV__) { + clearSnapshotVNodeSource(); + } }, }; @@ -173,6 +177,9 @@ export const backgroundSnapshotInstanceManager: { clear() { // not resetting `nextId` to prevent id collision this.values.clear(); + if (__DEV__) { + clearSnapshotVNodeSource(); + } }, updateId(id: number, newId: number) { const values = this.values; @@ -191,6 +198,9 @@ export const backgroundSnapshotInstanceManager: { values.delete(id); values.set(newId, si); si.__id = newId; + if (__DEV__) { + moveSnapshotVNodeSource(id, newId); + } }, getValueBySign(str: string): unknown { const res = str?.split(':'); diff --git a/packages/react/testing-library/src/__tests__/alog.test.jsx b/packages/react/testing-library/src/__tests__/alog.test.jsx index 0bd9eb0994..00a34ac41f 100644 --- a/packages/react/testing-library/src/__tests__/alog.test.jsx +++ b/packages/react/testing-library/src/__tests__/alog.test.jsx @@ -319,7 +319,7 @@ describe('alog', () => { { "handlerName": "-2:0:", "type": "bindEvent:tap", - "snapshotInstanceType": "__snapshot_426db_test_1", + "snapshotType": "__snapshot_426db_test_1", "jsFunctionName": "" }", ], @@ -328,7 +328,7 @@ describe('alog', () => { { "handlerName": "-2:1:", "type": "catchEvent:focus", - "snapshotInstanceType": "__snapshot_426db_test_1", + "snapshotType": "__snapshot_426db_test_1", "jsFunctionName": "handleFocus" }", ],