diff --git a/packages/react-devtools-facade/src/DevToolsFacade.js b/packages/react-devtools-facade/src/DevToolsFacade.js index 0c329bc1a5d1..b4fe09ac1176 100644 --- a/packages/react-devtools-facade/src/DevToolsFacade.js +++ b/packages/react-devtools-facade/src/DevToolsFacade.js @@ -20,6 +20,11 @@ import type { import {getInternalReactConstants} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants'; +// Re-export the tools assembler so the full building-block API is available +// from the package entry point (index.js re-exports this module). +export {createTools} from './DevToolsFacadeTools'; +export type {Tools} from './DevToolsFacadeTools'; + // Per-renderer internal constants, initialized at inject() time. Building // blocks read these to translate fibers into human-readable output. export type RendererInternals = { @@ -55,33 +60,162 @@ export type Facade = { profilingState: ProfilingState, }; +// Initialize per-renderer internal constants for a renderer registered with the +// hook. Shared by the installed hook's inject() and the attach path. +function initializeRendererInternals( + rendererInternals: Map, + id: number, + renderer: any, +): void { + const version = renderer.reconcilerVersion || renderer.version; + if (version == null) { + console.error( + 'react-devtools-facade: Renderer %s has no version, internals not initialized.', + id, + ); + return; + } + const {getDisplayNameForFiber, ReactTypeOfWork, ReactPriorityLevels} = + getInternalReactConstants(version); + rendererInternals.set(id, { + getDisplayNameForFiber, + ReactTypeOfWork, + ReactPriorityLevels, + currentDispatcherRef: renderer.currentDispatcherRef, + }); +} + +// Record a commit: keep fiberRoots in sync (add new roots, drop unmounted ones) +// and drive a profiling session when one is active. Shared by the installed +// hook's onCommitFiberRoot and the attach path's wrapper. +function recordCommitFiberRoot( + fiberRoots: Map>, + profilingState: ProfilingState, + rendererID: number, + root: any, + schedulerPriority?: number, +): void { + let mountedRoots = fiberRoots.get(rendererID); + if (mountedRoots == null) { + mountedRoots = new Set(); + fiberRoots.set(rendererID, mountedRoots); + } + const current = root.current; + const isKnownRoot = mountedRoots.has(root); + const isUnmounting = + current.memoizedState == null || current.memoizedState.element == null; + if (!isKnownRoot && !isUnmounting) { + mountedRoots.add(root); + } else if (isKnownRoot && isUnmounting) { + mountedRoots.delete(root); + } + + if (profilingState.isActive && profilingState.onCommit != null) { + profilingState.onCommit(rendererID, root, schedulerPriority); + } +} + +// Attach to a DevTools hook that is already installed on the page — for example +// the React DevTools browser extension. Rather than replacing it (React would +// ignore a second hook), read the renderers and fiber roots it is already +// tracking, then wrap inject / onCommitFiberRoot / onPostCommitFiberRoot so +// future renderers, commits, and passive passes also feed the facade's state. +// The existing hook's own bookkeeping is preserved — we always call through to +// it first. +function attachToExistingHook( + hook: any, + fiberRoots: Map>, + rendererInternals: Map, + profilingState: ProfilingState, +): void { + // Back-fill renderers and roots registered before we attached (React may have + // initialized first). + if (hook.renderers instanceof Map) { + hook.renderers.forEach((renderer: any, id: number) => { + if (!rendererInternals.has(id)) { + initializeRendererInternals(rendererInternals, id, renderer); + } + if (typeof hook.getFiberRoots === 'function') { + let roots = fiberRoots.get(id); + if (roots == null) { + roots = new Set(); + fiberRoots.set(id, roots); + } + // Alias to a const so the non-null refinement survives into the closure. + const mountedRoots = roots; + hook.getFiberRoots(id).forEach((root: FiberRoot) => { + mountedRoots.add(root); + }); + } + }); + } + + const originalInject = hook.inject; + hook.inject = function inject(renderer: any, ...rest: Array): number { + const id = originalInject.call(hook, renderer, ...rest); + if (typeof id === 'number') { + initializeRendererInternals(rendererInternals, id, renderer); + } + return id; + }; + + const originalOnCommitFiberRoot = hook.onCommitFiberRoot; + hook.onCommitFiberRoot = function onCommitFiberRoot( + rendererID: number, + root: any, + schedulerPriority?: number, + ...rest: Array + ) { + if (typeof originalOnCommitFiberRoot === 'function') { + originalOnCommitFiberRoot.call( + hook, + rendererID, + root, + schedulerPriority, + ...rest, + ); + } + recordCommitFiberRoot( + fiberRoots, + profilingState, + rendererID, + root, + schedulerPriority, + ); + }; + + const originalOnPostCommitFiberRoot = hook.onPostCommitFiberRoot; + hook.onPostCommitFiberRoot = function onPostCommitFiberRoot( + rendererID: number, + root: any, + ...rest: Array + ) { + if (typeof originalOnPostCommitFiberRoot === 'function') { + originalOnPostCommitFiberRoot.call(hook, rendererID, root, ...rest); + } + if (profilingState.isActive && profilingState.onPostCommit != null) { + profilingState.onPostCommit(root); + } + }; +} + /** - * Install the React DevTools facade: install `__REACT_DEVTOOLS_GLOBAL_HOOK__` - * on `target` (defaults to globalThis) and return a Facade handle. + * Install the React DevTools facade and return a Facade handle. * - * This installs ONLY `__REACT_DEVTOOLS_GLOBAL_HOOK__` — the global React looks - * for at initialization time. It does not install any tool globals: the - * returned Facade is passed to building blocks such as `createTools(facade)`, - * and the integrator decides whether to expose the resulting tools on globals. + * If `__REACT_DEVTOOLS_GLOBAL_HOOK__` is not yet present, this installs the + * facade's own minimal hook (the global React looks for at init). If a hook is + * already installed — e.g. the user has the React DevTools browser extension — + * the facade attaches to that hook instead of installing a second one. * - * Must run BEFORE React initializes so the hook captures the first commit. + * Either way the returned Facade exposes the same `{hook, fiberRoots, + * rendererInternals, profilingState}` that building blocks such as + * `createTools(facade)` read from. Install before React initializes so the first + * commit is captured; when attaching, roots committed before attach are + * back-filled from the existing hook. */ export function installFacade(target?: any = globalThis): Facade { - // Guard against double-install (e.g. bundled twice or mixed with full DevTools). - if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) { - throw new Error( - 'React DevTools global hook is already installed. ' + - 'react-devtools-facade should not be used with any other React DevTools package.', - ); - } - - // Fiber root tracking — the only runtime state the hook maintains. - // onCommitFiberRoot adds/removes entries so that unmounted roots are - // garbage-collected. Building blocks walk from these roots on demand. const fiberRoots: Map> = new Map(); - const rendererInternals: Map = new Map(); - const profilingState: ProfilingState = { isActive: false, currentTraceName: null, @@ -90,6 +224,19 @@ export function installFacade(target?: any = globalThis): Facade { onPostCommit: null, }; + // A hook is already installed (e.g. the React DevTools extension). Attach to + // it rather than replacing it. + const existingHook = target.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (existingHook != null) { + attachToExistingHook( + existingHook, + fiberRoots, + rendererInternals, + profilingState, + ); + return {hook: existingHook, fiberRoots, rendererInternals, profilingState}; + } + let registeredRenderersCount = 0; // $FlowFixMe[incompatible-type] the facade provides a minimal subset of DevToolsHook @@ -111,23 +258,7 @@ export function installFacade(target?: any = globalThis): Facade { inject(renderer: any): number { const id = registeredRenderersCount++; hook.renderers.set(id, renderer); - // Initialize internal constants for this renderer's React version. - const version = renderer.reconcilerVersion || renderer.version; - if (version == null) { - console.error( - 'react-devtools-facade: Renderer %s has no version, internals not initialized.', - id, - ); - } else { - const {getDisplayNameForFiber, ReactTypeOfWork, ReactPriorityLevels} = - getInternalReactConstants(version); - rendererInternals.set(id, { - getDisplayNameForFiber, - ReactTypeOfWork, - ReactPriorityLevels, - currentDispatcherRef: renderer.currentDispatcherRef, - }); - } + initializeRendererInternals(rendererInternals, id, renderer); return id; }, on() {}, @@ -143,23 +274,13 @@ export function installFacade(target?: any = globalThis): Facade { root: any, schedulerPriority?: number, ) { - // Hot path — called on every React commit. Keep minimal: just - // add or remove the root so building blocks can find it later. - const mountedRoots = hook.getFiberRoots(rendererID); - const current = root.current; - const isKnownRoot = mountedRoots.has(root); - const isUnmounting = - current.memoizedState == null || current.memoizedState.element == null; - if (!isKnownRoot && !isUnmounting) { - mountedRoots.add(root); - } else if (isKnownRoot && isUnmounting) { - mountedRoots.delete(root); - } - - // Profiling: record commit durations when a session is active. - if (profilingState.isActive && profilingState.onCommit != null) { - profilingState.onCommit(rendererID, root, schedulerPriority); - } + recordCommitFiberRoot( + fiberRoots, + profilingState, + rendererID, + root, + schedulerPriority, + ); }, onCommitFiberUnmount() {}, onPostCommitFiberRoot(rendererID: number, root: any) { diff --git a/packages/react-devtools-facade/src/DevToolsFacadeProfilerTools.js b/packages/react-devtools-facade/src/DevToolsFacadeProfilerTools.js new file mode 100644 index 000000000000..2c5a7de8320a --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacadeProfilerTools.js @@ -0,0 +1,327 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {didFiberRender} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberChangeDetection'; + +import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; +import type {RendererInternals, ProfilingState} from './DevToolsFacade'; +import type {ToolError} from './DevToolsFacadeTreeTools'; + +import {getTypeTag} from './DevToolsFacadeTreeTools'; + +// Per-component render timing within a single commit. Durations are null when +// the build does not collect profiler timing. +export type CommitComponent = { + uid: string, + name: string, + type: string, + actualDuration: number | null, + selfDuration: number | null, +}; + +// One row of a trace overview — a per-commit timing summary. +export type TraceOverviewRow = { + commit: number, + committedAt: number, + renderDuration: number | null, + layoutDuration: number | null, + passiveDuration: number | null, + componentsChanged: number, +}; + +// A detailed report for a single commit. +export type CommitReport = { + committedAt: number, + priority: string, + renderDuration: number | null, + layoutDuration: number | null, + passiveDuration: number | null, + components: Array, +}; + +export type StartProfilingResult = {status: 'started', traceName: string}; +export type StopProfilingResult = { + status: 'stopped', + traceName: string, + commits: number, +}; + +export type ProfilerTools = { + startProfiling: (traceName?: string) => StartProfilingResult | ToolError, + stopProfiling: () => StopProfilingResult | ToolError, + getTraceOverview: (traceName: string) => Array | ToolError, + getCommitReport: ( + traceName: string, + commitIndex: number, + ) => CommitReport | ToolError, +}; + +// Internal per-commit record (durations captured at commit time). +type CommitRecord = { + timestamp: number, + priority: string, + renderDuration: number | null, + layoutDuration: number | null, + passiveDuration: number | null, + durations: Array, +}; + +type TraceData = { + startTime: number, + commits: Array, +}; + +function priorityToString( + internals: RendererInternals, + schedulerPriority: number | void, +): string { + const { + ImmediatePriority, + UserBlockingPriority, + NormalPriority, + IdlePriority, + } = internals.ReactPriorityLevels; + switch (schedulerPriority) { + case ImmediatePriority: + return 'Sync'; + case UserBlockingPriority: + return 'UserBlocking'; + case NormalPriority: + return 'Normal'; + case IdlePriority: + return 'Idle'; + default: + return 'Normal'; + } +} + +/** + * Build the profiler tools from a renderer-internals map, the shared profiling + * state, and the tree tools' getUid (so component uids are consistent with + * getComponentTree/getComponentByUid). The hook installed by installFacade + * invokes profilingState.onCommit/onPostCommit while a session is active. + */ +export function createProfilerTools( + rendererInternals: Map, + profilingState: ProfilingState, + getUid: (fiber: Fiber) => string, +): ProfilerTools { + // Walk the fiber tree collecting timing for fibers that actually rendered. + // Matches the same didFiberRender check and display-name filtering as the + // DevTools Profiler — only fibers with a non-null display name are recorded, + // which filters out internal types (HostRoot, Fragment, Mode, HostText, etc.). + function collectDurations( + internals: RendererInternals, + fiber: Fiber, + durations: Array, + ): void { + const {ReactTypeOfWork, getDisplayNameForFiber} = internals; + const displayName = getDisplayNameForFiber(fiber); + if (displayName != null) { + const prevFiber = fiber.alternate; + if ( + prevFiber == null || + didFiberRender(ReactTypeOfWork, prevFiber, fiber) + ) { + const actual = + fiber.actualDuration != null ? fiber.actualDuration : null; + let self: number | null = actual; + if (actual != null) { + let selfDuration: number = actual; + let child = fiber.child; + while (child !== null) { + selfDuration -= child.actualDuration || 0; + child = child.sibling; + } + self = selfDuration; + } + durations.push({ + uid: getUid(fiber), + name: displayName, + type: getTypeTag(ReactTypeOfWork, fiber.tag), + actualDuration: actual, + selfDuration: self, + }); + } + } + // Recurse into children regardless of whether this node rendered. + let child = fiber.child; + while (child !== null) { + collectDurations(internals, child, durations); + child = child.sibling; + } + } + + // Commits awaiting their passive-effect pass, keyed by root so that a late + // onPostCommit attributes passiveDuration to the right commit even when + // multiple roots commit before their passive passes run. + const pendingPassive: Map = new Map(); + + /** + * Start a named profiling session that captures per-commit render timing. + * While active, every React commit records timing for components that + * rendered. Errors if a session is already active. + * + * @param traceName - Optional trace name (auto-generated if omitted). + */ + function startProfiling( + traceName?: string, + ): StartProfilingResult | ToolError { + if (profilingState.isActive) { + return { + error: + 'Already profiling trace "' + + (profilingState.currentTraceName || '') + + '"', + }; + } + const resolvedTraceName = traceName || 'trace-' + Date.now(); + const trace: TraceData = {startTime: Date.now(), commits: []}; + profilingState.traces.set(resolvedTraceName, trace); + profilingState.isActive = true; + profilingState.currentTraceName = resolvedTraceName; + + profilingState.onCommit = function onCommit( + rendererID: number, + root: FiberRoot, + schedulerPriority: number | void, + ) { + const internals = rendererInternals.get(rendererID); + if (internals == null) { + console.error( + 'react-devtools-facade: Missing internals for renderer %s, commit not recorded.', + rendererID, + ); + return; + } + const durations: Array = []; + collectDurations(internals, root.current, durations); + const rootFiber = root.current; + const record: CommitRecord = { + timestamp: Date.now(), + priority: priorityToString(internals, schedulerPriority), + renderDuration: + rootFiber.actualDuration != null ? rootFiber.actualDuration : null, + layoutDuration: + root.effectDuration != null ? root.effectDuration : null, + passiveDuration: null, + durations, + }; + trace.commits.push(record); + pendingPassive.set(root, record); + }; + + profilingState.onPostCommit = function onPostCommit(root: FiberRoot) { + const record = pendingPassive.get(root); + if (record != null) { + record.passiveDuration = + root.passiveEffectDuration != null + ? root.passiveEffectDuration + : null; + pendingPassive.delete(root); + } + }; + + return {status: 'started', traceName: resolvedTraceName}; + } + + /** + * Stop the active profiling session. Errors if no session is active. + */ + function stopProfiling(): StopProfilingResult | ToolError { + if (!profilingState.isActive) { + return {error: 'Not currently profiling'}; + } + const traceName = profilingState.currentTraceName; + if (traceName == null) { + return {error: 'No active trace'}; + } + const trace = profilingState.traces.get(traceName); + const commitCount = trace ? trace.commits.length : 0; + profilingState.isActive = false; + profilingState.currentTraceName = null; + profilingState.onCommit = null; + profilingState.onPostCommit = null; + pendingPassive.clear(); + return {status: 'stopped', traceName, commits: commitCount}; + } + + function getTrace(traceName: string): TraceData | null { + return profilingState.traces.get(traceName) || null; + } + + /** + * Return an overview of a trace — one row per commit with a timing breakdown + * (render, layout effects, passive effects) and the number of components that + * changed. + * + * @param traceName - The name of the trace to query. + */ + function getTraceOverview( + traceName: string, + ): Array | ToolError { + const trace = getTrace(traceName); + if (trace == null) { + return {error: 'Unknown trace "' + traceName + '"'}; + } + const rows: Array = []; + for (let i = 0; i < trace.commits.length; i++) { + const commit = trace.commits[i]; + rows.push({ + commit: i, + committedAt: commit.timestamp - trace.startTime, + renderDuration: commit.renderDuration, + layoutDuration: commit.layoutDuration, + passiveDuration: commit.passiveDuration, + componentsChanged: commit.durations.length, + }); + } + return rows; + } + + /** + * Return a detailed report for a single commit — timing metadata + * (committedAt, priority, duration breakdown) and per-component render + * durations sorted by actualDuration descending. + * + * @param traceName - The name of the trace. + * @param commitIndex - Zero-based index of the commit within the trace. + */ + function getCommitReport( + traceName: string, + commitIndex: number, + ): CommitReport | ToolError { + const trace = getTrace(traceName); + if (trace == null) { + return {error: 'Unknown trace "' + traceName + '"'}; + } + if (commitIndex < 0 || commitIndex >= trace.commits.length) { + return {error: 'Commit index out of range'}; + } + const commit = trace.commits[commitIndex]; + const components = commit.durations + .slice() + .sort((a, b) => (b.actualDuration || 0) - (a.actualDuration || 0)); + return { + committedAt: commit.timestamp - trace.startTime, + priority: commit.priority, + renderDuration: commit.renderDuration, + layoutDuration: commit.layoutDuration, + passiveDuration: commit.passiveDuration, + components, + }; + } + + return { + startProfiling, + stopProfiling, + getTraceOverview, + getCommitReport, + }; +} diff --git a/packages/react-devtools-facade/src/DevToolsFacadeTools.js b/packages/react-devtools-facade/src/DevToolsFacadeTools.js new file mode 100644 index 000000000000..7f42bc73bb17 --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacadeTools.js @@ -0,0 +1,106 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Facade} from './DevToolsFacade'; +import type { + TreeNode, + NodeInfo, + ComponentSource, + OwnersStack, + OwnerEntry, + FindComponentsResult, + ToolError, +} from './DevToolsFacadeTreeTools'; +import type { + StartProfilingResult, + StopProfilingResult, + TraceOverviewRow, + CommitReport, +} from './DevToolsFacadeProfilerTools'; + +import {createTreeTools} from './DevToolsFacadeTreeTools'; +import {createProfilerTools} from './DevToolsFacadeProfilerTools'; + +export type { + TreeNode, + NodeInfo, + HookNode, + ComponentSource, + SourceLocation, + OwnersStack, + OwnerEntry, + FindComponentsResult, + ToolError, +} from './DevToolsFacadeTreeTools'; +export type { + CommitComponent, + TraceOverviewRow, + CommitReport, + StartProfilingResult, + StopProfilingResult, +} from './DevToolsFacadeProfilerTools'; + +// The set of tools assembled from a Facade. Each tool returns a plain +// JavaScript value (see the types in ./DevToolsFacadeTreeTools and +// ./DevToolsFacadeProfilerTools); serialization is the integrator's responsibility. +// Integrators decide whether to expose these on globals or call them directly. +export type Tools = { + getComponentTree: ( + depth?: number, + rootUid?: string, + ) => Array | ToolError, + getComponentByUid: (uid: string) => NodeInfo | ToolError, + findComponents: ( + name: string, + rootUid?: string, + page?: number, + pageSize?: number, + ) => FindComponentsResult | ToolError, + getComponentSource: (uid: string) => ComponentSource | ToolError, + getOwnersStack: (uid: string) => OwnersStack | ToolError, + getOwnersBranch: (uid: string) => Array | ToolError, + startProfiling: (traceName?: string) => StartProfilingResult | ToolError, + stopProfiling: () => StopProfilingResult | ToolError, + getTraceOverview: (traceName: string) => Array | ToolError, + getCommitReport: ( + traceName: string, + commitIndex: number, + ) => CommitReport | ToolError, +}; + +/** + * Assemble the set of tools from a Facade. The tools read the facade's tracked + * runtime state (fiber roots, per-renderer internals, profiling state) lazily + * on each call and never touch globals, so the integrator fully owns both the + * facade and the returned tools. Profiler tools share the tree tools' getUid + * so component labels are consistent across all tools. + * + * @param facade - A Facade returned by installFacade(). + */ +export function createTools(facade: Facade): Tools { + const tree = createTreeTools(facade.fiberRoots, facade.rendererInternals); + const profiler = createProfilerTools( + facade.rendererInternals, + facade.profilingState, + tree.getUid, + ); + + return { + getComponentTree: tree.getComponentTree, + getComponentByUid: tree.getComponentByUid, + findComponents: tree.findComponents, + getComponentSource: tree.getComponentSource, + getOwnersStack: tree.getOwnersStack, + getOwnersBranch: tree.getOwnersBranch, + startProfiling: profiler.startProfiling, + stopProfiling: profiler.stopProfiling, + getTraceOverview: profiler.getTraceOverview, + getCommitReport: profiler.getCommitReport, + }; +} diff --git a/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js b/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js new file mode 100644 index 000000000000..3a76fe27bef0 --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js @@ -0,0 +1,674 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {extractLocationFromComponentStack} from 'react-devtools-shared/src/backend/utils/parseStackTrace'; +import { + getOwnerStackByFiberInDev, + getSourceLocationByFiber, +} from 'react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack'; +import {getDispatcherRef} from 'react-devtools-shared/src/backend/shared/DevToolsReactDispatcher'; +import {inspectHooksOfFiberWithoutDefaultDispatcher} from 'react-debug-tools'; + +import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; +import type {WorkTagMap} from 'react-devtools-shared/src/backend/types'; +import type {HooksTree, HooksNode} from 'react-debug-tools/src/ReactDebugHooks'; +import type {RendererInternals} from './DevToolsFacade'; + +// Tools return plain JavaScript values with the types below. Serialization +// (to TOON, JSON, etc.) is the integrator's responsibility. + +// Returned by any tool when the requested component/root cannot be resolved. +export type ToolError = {error: string}; + +// A single component in a tree snapshot. firstChild/nextSibling reference other +// nodes by their uid, forming an adjacency list the integrator can rebuild. +export type TreeNode = { + uid: string, + type: string, + name: string, + key: string | null, + firstChild: string | null, + nextSibling: string | null, +}; + +// One inspected hook. value is normalized (serialization-safe); subHooks holds +// the hooks called by a custom hook, recursively. +export type HookNode = { + id: number | null, + name: string, + value: mixed, + subHooks: Array, +}; + +export type NodeInfo = { + uid: string, + type: string, + name: string, + key?: string, + props?: {[string]: mixed}, + hooks?: Array, +}; + +export type SourceLocation = { + name: string, + fileName: string, + line: number, + column: number, +}; + +export type ComponentSource = {source: SourceLocation | null}; + +export type OwnersStack = {stack: string}; + +export type OwnerEntry = {uid: string, name: string, type: string}; + +export type FindComponentsResult = { + page: number, + pageSize: number, + totalCount: number, + totalPages: number, + results: Array, +}; + +export type TreeTools = { + getComponentTree: ( + depth?: number, + rootUid?: string, + ) => Array | ToolError, + getComponentByUid: (uid: string) => NodeInfo | ToolError, + findComponents: ( + name: string, + rootUid?: string, + page?: number, + pageSize?: number, + ) => FindComponentsResult | ToolError, + getComponentSource: (uid: string) => ComponentSource | ToolError, + getOwnersStack: (uid: string) => OwnersStack | ToolError, + getOwnersBranch: (uid: string) => Array | ToolError, + // Shared with the profiler tools so component uids are consistent across all + // tools. Maps a fiber to its stable uid (assigning one on first encounter). + getUid: (fiber: Fiber) => string, +}; + +/** + * Map a fiber work tag number to a human-readable type string. + * Every tag maps to a descriptive string; unknown tags return 'unknown'. + */ +export function getTypeTag(workTagMap: WorkTagMap, tag: number): string { + const { + FunctionComponent, + IncompleteFunctionComponent, + ClassComponent, + IncompleteClassComponent, + HostComponent, + HostHoistable, + HostSingleton, + HostRoot, + ForwardRef, + MemoComponent, + SimpleMemoComponent, + ContextConsumer, + ContextProvider, + SuspenseComponent, + SuspenseListComponent, + LazyComponent, + Profiler, + HostPortal, + ActivityComponent, + ViewTransitionComponent, + CacheComponent, + ScopeComponent, + OffscreenComponent, + LegacyHiddenComponent, + Throw, + HostText, + Fragment, + DehydratedSuspenseComponent, + Mode, + } = workTagMap; + + switch (tag) { + case FunctionComponent: + case IncompleteFunctionComponent: + return 'function'; + case ClassComponent: + case IncompleteClassComponent: + return 'class'; + case HostComponent: + case HostHoistable: + case HostSingleton: + return 'host'; + case HostRoot: + return 'root'; + case ForwardRef: + return 'forwardRef'; + case MemoComponent: + case SimpleMemoComponent: + return 'memo'; + case ContextConsumer: + case ContextProvider: + return 'context'; + case SuspenseComponent: + return 'suspense'; + case SuspenseListComponent: + return 'suspenseList'; + case LazyComponent: + return 'lazy'; + case Profiler: + return 'profiler'; + case HostPortal: + return 'portal'; + case ActivityComponent: + return 'activity'; + case ViewTransitionComponent: + return 'viewTransition'; + case CacheComponent: + return 'cache'; + case ScopeComponent: + return 'scope'; + case OffscreenComponent: + case LegacyHiddenComponent: + return 'offscreen'; + case Throw: + return 'throw'; + case HostText: + return 'text'; + case Fragment: + return 'fragment'; + case Mode: + return 'mode'; + case DehydratedSuspenseComponent: + return 'dehydrated'; + default: + return 'unknown'; + } +} + +const MAX_NORMALIZE_DEPTH = 3; + +// Normalize a value to a plain, serialization-safe shape. Tracks seen objects +// to break circular references and limits depth to avoid stack overflow on +// deeply nested structures. Functions/symbols/elements become descriptive +// strings so the result can be safely serialized downstream. +function normalizeValue(val: mixed, seen?: Set, depth?: number): mixed { + if (val === undefined) return null; + if (typeof val === 'function') + return val.name ? '[fn ' + val.name + ']' : '[fn]'; + if (typeof val === 'symbol') return '[symbol]'; + if (typeof val === 'object' && val !== null) { + if ((val as any).$$typeof != null) return '[React element]'; + const currentDepth = depth || 0; + if (currentDepth >= MAX_NORMALIZE_DEPTH) return '[max depth]'; + const currentSeen = seen || new Set(); + if (currentSeen.has(val)) return '[circular]'; + currentSeen.add(val); + if (Array.isArray(val)) { + const mapped = val.map((v: mixed) => + normalizeValue(v, currentSeen, currentDepth + 1), + ); + currentSeen.delete(val); + return mapped; + } + const result: {[string]: mixed} = {}; + const keys = Object.keys(val); + for (let i = 0; i < keys.length; i++) { + result[keys[i]] = normalizeValue( + (val as any)[keys[i]], + currentSeen, + currentDepth + 1, + ); + } + currentSeen.delete(val); + return result; + } + return val; +} + +// Normalize props for output: skip children, normalize values. +function normalizeProps(props: mixed): {[string]: mixed} | null { + if (props == null || typeof props !== 'object') return null; + const result: {[string]: mixed} = {}; + const keys = Object.keys(props); + let hasProps = false; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === 'children') continue; + result[key] = normalizeValue((props as any)[key]); + hasProps = true; + } + return hasProps ? result : null; +} + +// Normalize an inspected hooks tree into a serialization-safe shape. +function normalizeHooks(hooks: HooksTree): Array { + return hooks.map((hook: HooksNode) => ({ + id: hook.id, + name: hook.name, + value: normalizeValue(hook.value), + subHooks: normalizeHooks(hook.subHooks), + })); +} + +export function createTreeTools( + fiberRoots: Map>, + rendererInternals: Map, +): TreeTools { + function getTypeTagForFiber( + internals: RendererInternals, + fiber: Fiber, + ): string { + return getTypeTag(internals.ReactTypeOfWork, fiber.tag); + } + + function getDisplayName(internals: RendererInternals, fiber: Fiber): string { + return internals.getDisplayNameForFiber(fiber) || 'Unknown'; + } + + // Persistent uid state — survives across calls so the same fiber + // always maps to the same uid, even after React re-renders (which + // swap fiber objects via double-buffering / alternates). + const fiberToUid: WeakMap = new WeakMap(); + let nextId: number = 0; + + function getUid(fiber: Fiber): string { + let uid = fiberToUid.get(fiber); + if (uid != null) return uid; + const alt = fiber.alternate; + if (alt != null) { + uid = fiberToUid.get(alt); + if (uid != null) { + fiberToUid.set(fiber, uid); + return uid; + } + } + uid = 'r' + nextId++; + fiberToUid.set(fiber, uid); + return uid; + } + + // Collect direct children of a fiber via the child/sibling linked list. + function collectChildren(fiber: Fiber): Array { + const result: Array = []; + let child = fiber.child; + while (child !== null) { + result.push(child); + child = child.sibling; + } + return result; + } + + function collectNodes( + internals: RendererInternals, + fiber: Fiber, + maxDepth: number, + currentDepth: number, + nodes: Array, + ): void { + const children = currentDepth < maxDepth ? collectChildren(fiber) : []; + const firstChild = children.length > 0 ? getUid(children[0]) : null; + nodes.push({ + uid: getUid(fiber), + type: getTypeTagForFiber(internals, fiber), + name: getDisplayName(internals, fiber), + key: fiber.key != null ? String(fiber.key) : null, + firstChild, + nextSibling: null, + }); + for (let i = 0; i < children.length; i++) { + collectNodes(internals, children[i], maxDepth, currentDepth + 1, nodes); + if (i < children.length - 1) { + const childUid = getUid(children[i]); + for (let j = nodes.length - 1; j >= 0; j--) { + if (nodes[j].uid === childUid) { + nodes[j].nextSibling = getUid(children[i + 1]); + break; + } + } + } + } + } + + function findByUid(fiber: Fiber, targetUid: string): Fiber | null { + if (getUid(fiber) === targetUid) return fiber; + const children = collectChildren(fiber); + for (let i = 0; i < children.length; i++) { + const found = findByUid(children[i], targetUid); + if (found != null) return found; + } + return null; + } + + // Find a fiber by uid across all mounted roots. + // Returns the fiber and its renderer's internals, or an error. + function findFiberByUid( + uid: string, + ): + | {fiber: Fiber, internals: RendererInternals, error: null} + | {fiber: null, internals: null, error: string} { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [rendererID, roots] of fiberRoots) { + const internals = rendererInternals.get(rendererID); + if (internals == null) { + return { + fiber: null, + internals: null, + error: 'Missing internals for renderer ' + rendererID, + }; + } + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const root of roots) { + const fiber = findByUid(root.current, uid); + if (fiber != null) return {fiber, internals, error: null}; + } + } + return { + fiber: null, + internals: null, + error: 'Component not found: "' + uid + '"', + }; + } + + /** + * Returns a snapshot of the component tree as an array of nodes. Each node + * includes: uid, type, name, key, firstChild, nextSibling (the last two + * reference other nodes by uid). + * + * @param depth - Maximum tree depth to traverse (default 20). + * @param rootUid - If provided, snapshot starts from this component. + */ + function getComponentTree( + depth?: number = 20, + rootUid?: string, + ): Array | ToolError { + if (rootUid != null) { + const result = findFiberByUid(rootUid); + if (result.error != null) { + return {error: result.error}; + } + const nodes: Array = []; + collectNodes(result.internals, result.fiber, depth, 0, nodes); + return nodes; + } + + const nodes: Array = []; + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [rendererID, roots] of fiberRoots) { + const internals = rendererInternals.get(rendererID); + if (internals == null) { + return {error: 'Missing internals for renderer ' + rendererID}; + } + roots.forEach(root => { + collectNodes(internals, root.current, depth, 0, nodes); + }); + } + if (nodes.length === 0) { + return {error: 'No mounted React roots found'}; + } + return nodes; + } + + /** + * Returns detailed info about a single component by its uid: type, name, + * key, props (excluding children), and — for function components — the + * inspected hooks tree. Values are normalized to a serialization-safe shape. + * + * Inspecting hooks re-renders the component's render function (effects are + * not run); failures are tolerated and simply omit `hooks`. + * + * @param uid - The component uid (e.g. "r5"). + */ + function getComponentByUid(uid: string): NodeInfo | ToolError { + const result = findFiberByUid(uid); + if (result.error != null) { + return {error: result.error}; + } + const {fiber, internals} = result; + const info: NodeInfo = { + uid: getUid(fiber), + type: getTypeTagForFiber(internals, fiber), + name: getDisplayName(internals, fiber), + }; + if (fiber.key != null) { + info.key = String(fiber.key); + } + const props = normalizeProps(fiber.memoizedProps); + if (props != null) { + info.props = props; + } + // Hooks are only inspectable for function components, forwardRef, and + // simple-memo components. inspectHooksOfFiberWithoutDefaultDispatcher + // re-renders the component (using the renderer's injected dispatcher, never + // React's shared internals), so guard by tag and tolerate failures (e.g. a + // component that throws). + const {FunctionComponent, SimpleMemoComponent, ForwardRef} = + internals.ReactTypeOfWork; + if ( + fiber.tag === FunctionComponent || + fiber.tag === SimpleMemoComponent || + fiber.tag === ForwardRef + ) { + try { + const hooksTree = inspectHooksOfFiberWithoutDefaultDispatcher( + fiber, + getDispatcherRef(internals), + ); + info.hooks = normalizeHooks(hooksTree); + } catch { + // Hook inspection failed; omit hooks rather than failing the call. + } + } + return info; + } + + function collectMatches( + internals: RendererInternals, + fiber: Fiber, + query: string, + matches: Array, + ): void { + const displayName = internals.getDisplayNameForFiber(fiber); + if ( + displayName != null && + displayName.toLowerCase().indexOf(query) !== -1 + ) { + matches.push(fiber); + } + let child = fiber.child; + while (child !== null) { + collectMatches(internals, child, query, matches); + child = child.sibling; + } + } + + type FiberMatch = {fiber: Fiber, internals: RendererInternals}; + + /** + * Searches for components by name (case-insensitive substring match). + * Returns a paginated result with matching components. + * + * @param name - Search query to match against component display names. + * @param rootUid - If provided, limits search to this component's subtree. + * @param page - Page number (default 1, clamped to valid range). + * @param pageSize - Results per page (default 10). + */ + function findComponents( + name: string, + rootUid?: string, + page?: number = 1, + pageSize?: number = 10, + ): FindComponentsResult | ToolError { + const query = name.toLowerCase(); + const allMatches: Array = []; + + if (rootUid != null) { + const found = findFiberByUid(rootUid); + if (found.error != null) { + return {error: found.error}; + } + const fibers: Array = []; + collectMatches(found.internals, found.fiber, query, fibers); + for (let i = 0; i < fibers.length; i++) { + allMatches.push({fiber: fibers[i], internals: found.internals}); + } + } else { + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const [rendererID, roots] of fiberRoots) { + const internals = rendererInternals.get(rendererID); + if (internals == null) { + return {error: 'Missing internals for renderer ' + rendererID}; + } + roots.forEach(root => { + const fibers: Array = []; + collectMatches(internals, root.current, query, fibers); + for (let i = 0; i < fibers.length; i++) { + allMatches.push({fiber: fibers[i], internals}); + } + }); + } + } + + const totalCount = allMatches.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const clampedPage = Math.max(1, Math.min(page, totalPages)); + const startIdx = (clampedPage - 1) * pageSize; + const pageMatches = allMatches.slice(startIdx, startIdx + pageSize); + + const rows: Array = []; + for (let i = 0; i < pageMatches.length; i++) { + const {fiber, internals} = pageMatches[i]; + const children = collectChildren(fiber); + rows.push({ + uid: getUid(fiber), + type: getTypeTagForFiber(internals, fiber), + name: getDisplayName(internals, fiber), + key: fiber.key != null ? String(fiber.key) : null, + firstChild: children.length > 0 ? getUid(children[0]) : null, + nextSibling: null, + }); + } + + return { + page: clampedPage, + pageSize, + totalCount, + totalPages, + results: rows, + }; + } + + /** + * Returns the definition location of a component — where the component + * function or class is defined in source code. Uses the same "throwing + * trick" as React DevTools to capture a stack frame from within the + * component's function body. + * + * Returns {source: {name, fileName, line, column}} or {source: null} if the + * location cannot be determined (e.g. host components, production builds). + * + * @param uid - The component uid (e.g. "r5"). + */ + function getComponentSource(uid: string): ComponentSource | ToolError { + const result = findFiberByUid(uid); + if (result.error != null) { + return {error: result.error}; + } + const {fiber, internals} = result; + const stackFrame = getSourceLocationByFiber( + internals.ReactTypeOfWork, + fiber, + internals.currentDispatcherRef, + ); + if (stackFrame == null) { + return {source: null}; + } + const location = extractLocationFromComponentStack(stackFrame); + if (location == null) { + return {source: null}; + } + const [name, fileName, line, column] = location; + return {source: {name, fileName, line, column}}; + } + + /** + * Returns the raw owner stack trace string — the chain of JSX creation + * locations from this component up to the root. Each line is a stack frame + * showing where was written in the owner's code. The stack can + * be passed to source map tools for symbolication. + * + * Returns {stack: string}. DEV-only — in production, the stack will be empty. + * + * @param uid - The component uid (e.g. "r5"). + */ + function getOwnersStack(uid: string): OwnersStack | ToolError { + const result = findFiberByUid(uid); + if (result.error != null) { + return {error: result.error}; + } + const {fiber, internals} = result; + const stackString = getOwnerStackByFiberInDev( + internals.ReactTypeOfWork, + fiber, + internals.currentDispatcherRef, + ); + return {stack: stackString}; + } + + /** + * Returns the structured list of owner components — which components rendered + * this component, ordered from immediate owner to root ancestor. Each entry + * includes a uid for cross-referencing with other tools (e.g. + * getComponentByUid, getComponentSource, getComponentTree). + * + * Returns an array of {uid, name, type}, or an empty array if the component + * has no owner (root component). DEV-only — in production, _debugOwner is not + * available. + * + * @param uid - The component uid (e.g. "r5"). + */ + function getOwnersBranch(uid: string): Array | ToolError { + const result = findFiberByUid(uid); + if (result.error != null) { + return {error: result.error}; + } + const {fiber, internals} = result; + + const owners: Array = []; + // Walk the JSX-creation owner chain from this component up to the root, + // collecting only Fiber owners (client components). A Fiber's _debugOwner + // points to the next owner — itself a Fiber (client) or a + // ReactComponentInfo (server component); the latter continues the chain + // via its .owner field. + let owner: mixed = fiber._debugOwner; + while (owner != null) { + const node: any = owner; + if (typeof node.tag === 'number') { + owners.push({ + uid: getUid(node), + name: getDisplayName(internals, node), + type: getTypeTagForFiber(internals, node), + }); + owner = node._debugOwner; + } else { + // Server component (ReactComponentInfo): continue via its .owner. + owner = node.owner; + } + } + return owners; + } + + return { + getComponentTree, + getComponentByUid, + findComponents, + getComponentSource, + getOwnersStack, + getOwnersBranch, + getUid, + }; +} diff --git a/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js index 53fc2cdc6d90..6e824e5c65c1 100644 --- a/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js +++ b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js @@ -8,12 +8,19 @@ 'use strict'; let installFacade; +let createTools; let facade; let React; let ReactDOMClient; let act; let container; +// Profiler durations are timing-dependent: null when the build does not collect +// them, otherwise a non-negative number. +function isDuration(value) { + return value === null || (typeof value === 'number' && value >= 0); +} + describe('react-devtools-facade', () => { beforeEach(() => { jest.resetModules(); @@ -27,7 +34,9 @@ describe('react-devtools-facade', () => { // Install the facade BEFORE React so the hook captures the first commit. // Import through the package entry point to exercise the public surface. - installFacade = require('../../index').installFacade; + const facadeAPI = require('../../index'); + installFacade = facadeAPI.installFacade; + createTools = facadeAPI.createTools; facade = installFacade(); React = require('react'); @@ -35,11 +44,9 @@ describe('react-devtools-facade', () => { act = React.act; container = document.createElement('div'); - document.body.appendChild(container); }); afterEach(() => { - document.body.removeChild(container); container = null; }); @@ -65,11 +72,53 @@ describe('react-devtools-facade', () => { expect(globalThis.__REACT_LLM_TOOLS__).toBeUndefined(); }); - it('throws if a DevTools hook is already installed', () => { - // A hook was already installed on globalThis in beforeEach. - expect(() => installFacade()).toThrow( - /React DevTools global hook is already installed/, - ); + it('attaches to an existing hook instead of installing a second one', () => { + // A facade hook is already installed on globalThis (beforeEach). A second + // installFacade() attaches to it rather than throwing or replacing it — this + // is the path taken when the React DevTools extension is present. + const attached = installFacade(); + expect(attached.hook).toBe(facade.hook); + expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook); + }); + + it('an attached facade back-fills roots already tracked by the hook', () => { + function App() { + return
hi
; + } + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + // Attaching after the app mounted picks up the already-tracked root. + const attached = installFacade(); + const tree = createTools(attached).getComponentTree(); + expect(tree.find(n => n.name === 'App')).toBeDefined(); + }); + + it('an attached facade tracks later commits and profiles them', () => { + function Counter({count}) { + return
{'n:' + count}
; + } + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + + const tools = createTools(installFacade()); + expect( + tools.getComponentTree().find(n => n.name === 'Counter'), + ).toBeDefined(); + + // A commit after attaching flows through the wrapped onCommitFiberRoot. + tools.startProfiling('attached-trace'); + act(() => { + root.render(); + }); + expect(tools.stopProfiling()).toEqual({ + status: 'stopped', + traceName: 'attached-trace', + commits: 1, + }); }); it('installs onto an explicit target without touching globalThis', () => { @@ -122,4 +171,1975 @@ describe('react-devtools-facade', () => { expect(facade.hook.getFiberRoots(rendererID).size).toBe(0); }); + + describe('getComponentTree', () => { + let getComponentTree; + + beforeEach(() => { + getComponentTree = createTools(facade).getComponentTree; + }); + + it('returns error when nothing is rendered', () => { + const result = getComponentTree(); + expect(result.error).toMatch(/No mounted React roots found/); + }); + + it('returns an array of component nodes', () => { + function App() { + return
hello
; + } + + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + const result = getComponentTree(); + expect(Array.isArray(result)).toBe(true); + const app = result.find(n => n.name === 'App'); + const div = result.find(n => n.name === 'div'); + // App is the root's only child; its child is the host div. + expect(app).toEqual({ + uid: 'r0', + type: 'function', + name: 'App', + key: null, + firstChild: div.uid, + nextSibling: null, + }); + // A single string child ('hello') is stored as a prop, not a child fiber, + // so the div is a leaf in the tree. + expect(div).toEqual({ + uid: 'r2', + type: 'host', + name: 'div', + key: null, + firstChild: null, + nextSibling: null, + }); + }); + + it('encodes firstChild and nextSibling relationships', () => { + function Header() { + return

title

; + } + function Footer() { + return
foot
; + } + function App() { + return ( +
+
+
+
+ ); + } + + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + const nodes = getComponentTree(); + const app = nodes.find(n => n.name === 'App'); + const div = nodes.find(n => n.name === 'div'); + const header = nodes.find(n => n.name === 'Header'); + const footer = nodes.find(n => n.name === 'Footer'); + + // App's firstChild is div + expect(app.firstChild).toBe(div.uid); + // div's firstChild is Header + expect(div.firstChild).toBe(header.uid); + // Header's nextSibling is Footer + expect(header.nextSibling).toBe(footer.uid); + // Footer has no nextSibling + expect(footer.nextSibling).toBe(null); + }); + + it('shows keys in the output', () => { + function Item() { + return
  • item
  • ; + } + function List() { + return ( +
      + + +
    + ); + } + + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + const items = getComponentTree().filter(n => n.name === 'Item'); + expect(items.map(i => i.key)).toEqual(['a', 'b']); + }); + + it('limits depth with the depth parameter', () => { + function Child() { + return leaf; + } + function Parent() { + return ; + } + function App() { + return ; + } + + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + const names = snapshot => snapshot.map(n => n.name); + + // depth=0: only the root node (HostRoot) + const shallow = getComponentTree(0); + expect(shallow).toHaveLength(1); + expect(shallow[0].type).toBe('root'); + + // depth=1: root + App + const d1 = getComponentTree(1); + expect(names(d1)).toContain('App'); + expect(names(d1)).not.toContain('Parent'); + + // depth=2: root + App + Parent + const d2 = getComponentTree(2); + expect(names(d2)).toContain('App'); + expect(names(d2)).toContain('Parent'); + expect(names(d2)).not.toContain('Child'); + + const deep = getComponentTree(20); + expect(names(deep)).toEqual( + expect.arrayContaining(['App', 'Parent', 'Child']), + ); + }); + + it('starts from a specific node when rootUid is provided', () => { + function Nav() { + return ; + } + function Header() { + return