diff --git a/.eslintrc.js b/.eslintrc.js index 395506eae20d..8c9261aadce0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -336,6 +336,7 @@ module.exports = { 'packages/react-test-renderer/**/*.js', 'packages/react-debug-tools/**/*.js', 'packages/react-devtools-extensions/**/*.js', + 'packages/react-devtools-facade/**/*.js', 'packages/react-devtools-timeline/**/*.js', 'packages/react-native-renderer/**/*.js', 'packages/eslint-plugin-react-hooks/**/*.js', diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index b4eb6c2b5965..cda7457ca2fb 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -1196,17 +1196,13 @@ function handleRenderFunctionError(error: any): void { throw wrapperError; } -export function inspectHooks( +// Shared implementation. Requires an explicit dispatcher and never references +// ReactSharedInternals, so importing it does not pull React into the bundle. +function inspectHooksImpl( renderFunction: Props => React$Node, props: Props, - currentDispatcher: ?CurrentDispatcherRef, + currentDispatcher: CurrentDispatcherRef, ): HooksTree { - // DevTools will pass the current renderer's injected dispatcher. - // Other apps might compile debug hooks as part of their app though. - if (currentDispatcher == null) { - currentDispatcher = ReactSharedInternals; - } - const previousDispatcher = currentDispatcher.H; currentDispatcher.H = DispatcherProxy; @@ -1231,6 +1227,31 @@ export function inspectHooks( return buildTree(rootStack, readHookLog); } +// DevTools will pass the current renderer's injected dispatcher. Other apps +// might compile debug hooks as part of their app though, so default to the +// running React's shared internals when no dispatcher is provided. +export function inspectHooks( + renderFunction: Props => React$Node, + props: Props, + currentDispatcher: ?CurrentDispatcherRef, +): HooksTree { + return inspectHooksImpl( + renderFunction, + props, + currentDispatcher ?? ReactSharedInternals, + ); +} + +// Like inspectHooks but requires an explicit dispatcher and never references +// ReactSharedInternals, so importing it does not pull React into the bundle. +export function inspectHooksWithoutDefaultDispatcher( + renderFunction: Props => React$Node, + props: Props, + currentDispatcher: CurrentDispatcherRef, +): HooksTree { + return inspectHooksImpl(renderFunction, props, currentDispatcher); +} + function setupContexts(contextMap: Map, any>, fiber: Fiber) { let current: null | Fiber = fiber; while (current) { @@ -1297,16 +1318,13 @@ function resolveDefaultProps(Component: any, baseProps: any) { return baseProps; } -export function inspectHooksOfFiber( +// Shared implementation. Requires an explicit dispatcher and never references +// ReactSharedInternals (it delegates to inspectHooksImpl), so importing it does +// not pull React into the bundle. +function inspectHooksOfFiberImpl( fiber: Fiber, - currentDispatcher: ?CurrentDispatcherRef, + currentDispatcher: CurrentDispatcherRef, ): HooksTree { - // DevTools will pass the current renderer's injected dispatcher. - // Other apps might compile debug hooks as part of their app though. - if (currentDispatcher == null) { - currentDispatcher = ReactSharedInternals; - } - if ( fiber.tag !== FunctionComponent && fiber.tag !== SimpleMemoComponent && @@ -1381,7 +1399,7 @@ export function inspectHooksOfFiber( ); } - return inspectHooks(type, props, currentDispatcher); + return inspectHooksImpl(type, props, currentDispatcher); } finally { currentFiber = null; currentHook = null; @@ -1392,3 +1410,27 @@ export function inspectHooksOfFiber( restoreContexts(contextMap); } } + +// DevTools will pass the current renderer's injected dispatcher. Other apps +// might compile debug hooks as part of their app though, so default to the +// running React's shared internals when no dispatcher is provided. +export function inspectHooksOfFiber( + fiber: Fiber, + currentDispatcher: ?CurrentDispatcherRef, +): HooksTree { + return inspectHooksOfFiberImpl( + fiber, + currentDispatcher ?? ReactSharedInternals, + ); +} + +// Like inspectHooksOfFiber but requires an explicit dispatcher and never +// references ReactSharedInternals. Callers that always have the renderer's +// injected dispatcher (e.g. react-devtools-facade) can use this to avoid +// pulling React into their bundle. +export function inspectHooksOfFiberWithoutDefaultDispatcher( + fiber: Fiber, + currentDispatcher: CurrentDispatcherRef, +): HooksTree { + return inspectHooksOfFiberImpl(fiber, currentDispatcher); +} diff --git a/packages/react-debug-tools/src/ReactDebugTools.js b/packages/react-debug-tools/src/ReactDebugTools.js index 23d568a68b27..58f0b50059c8 100644 --- a/packages/react-debug-tools/src/ReactDebugTools.js +++ b/packages/react-debug-tools/src/ReactDebugTools.js @@ -7,6 +7,16 @@ * @flow */ -import {inspectHooks, inspectHooksOfFiber} from './ReactDebugHooks'; +import { + inspectHooks, + inspectHooksWithoutDefaultDispatcher, + inspectHooksOfFiber, + inspectHooksOfFiberWithoutDefaultDispatcher, +} from './ReactDebugHooks'; -export {inspectHooks, inspectHooksOfFiber}; +export { + inspectHooks, + inspectHooksWithoutDefaultDispatcher, + inspectHooksOfFiber, + inspectHooksOfFiberWithoutDefaultDispatcher, +}; diff --git a/packages/react-devtools-facade/README.md b/packages/react-devtools-facade/README.md new file mode 100644 index 000000000000..f8427ef7c82d --- /dev/null +++ b/packages/react-devtools-facade/README.md @@ -0,0 +1,37 @@ +# react-devtools-facade + +Experimental, private package that defines building blocks for querying React runtime state. + +The facade installs the `__REACT_DEVTOOLS_GLOBAL_HOOK__` that React looks for at +initialization time — enabling fiber-root tracking without the overhead of the +full DevTools backend — and exposes a small, framework-agnostic library API that +integrators compose into tools. + +This package is intentionally low-level. It does **not** install any tool +globals and it does **not** decide how tools are surfaced. It installs the hook, +tracks fiber roots, and hands back building blocks; the integrator (for example, +a `chrome-devtools-mcp` integration) decides everything else — including whether +to expose anything else on globals. + +## API + +### `installFacade(target = globalThis): Facade` + +Installs `__REACT_DEVTOOLS_GLOBAL_HOOK__` on `target` and returns a `Facade` +handle holding the hook plus the runtime state it tracks (`fiberRoots`, +`rendererInternals`, `profilingState`). Building blocks read from the returned +`Facade`; they never reach for globals. + +Call this **before** React initializes so the hook captures the first commit: + +```js +import {installFacade} from 'react-devtools-facade'; + +const facade = installFacade(); +// ...load React, render your app... +``` + +`installFacade` installs **only** the DevTools hook. It does not install +`__REACT_TOOLS__`, `__REACT_LLM_TOOLS__`, or any other global. Once the tool +building blocks land, an integrator composes them from the returned `Facade` and +decides whether to expose anything on globals. diff --git a/packages/react-devtools-facade/index.js b/packages/react-devtools-facade/index.js new file mode 100644 index 000000000000..cabb714663e9 --- /dev/null +++ b/packages/react-devtools-facade/index.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export * from './src/DevToolsFacade'; diff --git a/packages/react-devtools-facade/package.json b/packages/react-devtools-facade/package.json new file mode 100644 index 000000000000..9fd00eb06b58 --- /dev/null +++ b/packages/react-devtools-facade/package.json @@ -0,0 +1,13 @@ +{ + "name": "react-devtools-facade", + "version": "0.0.0", + "private": true, + "description": "Building blocks for querying React runtime state", + "license": "MIT", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/react-devtools-facade" + } +} diff --git a/packages/react-devtools-facade/src/DevToolsFacade.js b/packages/react-devtools-facade/src/DevToolsFacade.js new file mode 100644 index 000000000000..5147ba0b3602 --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacade.js @@ -0,0 +1,191 @@ +/** + * 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 { + DevToolsHook, + WorkTagMap, + CurrentDispatcherRef, +} from 'react-devtools-shared/src/backend/types'; +import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; +import type { + getDisplayNameForFiberType, + ReactPriorityLevelsType, +} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants'; + +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 = { + getDisplayNameForFiber: getDisplayNameForFiberType, + ReactTypeOfWork: WorkTagMap, + ReactPriorityLevels: ReactPriorityLevelsType, + currentDispatcherRef: CurrentDispatcherRef, +}; + +// Profiling session state, shared between the hook (which records commits) and +// the profiler building blocks (which start/stop sessions and read results). +export type ProfilingState = { + isActive: boolean, + currentTraceName: string | null, + traces: Map, + onCommit: + | (( + rendererID: number, + root: FiberRoot, + schedulerPriority: number | void, + ) => void) + | null, + onPostCommit: ((root: FiberRoot) => void) | null, +}; + +// A self-contained handle over the installed DevTools hook and the runtime +// state it tracks. Building blocks (createTools, the tree/profiler factories) +// read from a Facade and never touch globals, so the integrator fully owns it. +export type Facade = { + hook: DevToolsHook, + fiberRoots: Map>, + rendererInternals: Map, + profilingState: ProfilingState, +}; + +/** + * Install the React DevTools facade: install `__REACT_DEVTOOLS_GLOBAL_HOOK__` + * on `target` (defaults to globalThis) 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. + * + * Must run BEFORE React initializes so the hook captures the first commit. + */ +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, + traces: new Map(), + onCommit: null, + onPostCommit: null, + }; + + let registeredRenderersCount = 0; + + // $FlowFixMe[incompatible-type] the facade provides a minimal subset of DevToolsHook + const hook: DevToolsHook = { + listeners: {}, + rendererInterfaces: new Map(), + renderers: new Map(), + hasUnsupportedRendererAttached: false, + backends: new Map(), + emit() {}, + getFiberRoots(rendererID: number) { + let roots = fiberRoots.get(rendererID); + if (roots == null) { + roots = new Set(); + fiberRoots.set(rendererID, roots); + } + return roots; + }, + 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, + }); + } + return id; + }, + on() {}, + off() {}, + sub() { + return () => {}; + }, + supportsFiber: true, + supportsFlight: true, + checkDCE() {}, + onCommitFiberRoot( + rendererID: number, + 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); + } + }, + onCommitFiberUnmount() {}, + onPostCommitFiberRoot(rendererID: number, root: any) { + if (profilingState.isActive && profilingState.onPostCommit != null) { + profilingState.onPostCommit(root); + } + }, + getInternalModuleRanges(): Array<[string, string]> { + return []; + }, + registerInternalModuleStart() {}, + registerInternalModuleStop() {}, + }; + + Object.defineProperty(target, '__REACT_DEVTOOLS_GLOBAL_HOOK__', { + configurable: __DEV__, + enumerable: false, + get() { + return hook; + }, + }); + + return {hook, fiberRoots, rendererInternals, profilingState}; +} diff --git a/packages/react-devtools-facade/src/DevToolsFacadeTools.js b/packages/react-devtools-facade/src/DevToolsFacadeTools.js new file mode 100644 index 000000000000..6dac70370764 --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacadeTools.js @@ -0,0 +1,75 @@ +/** + * 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 {createTreeTools} from './DevToolsFacadeTreeTools'; + +export type { + TreeNode, + NodeInfo, + HookNode, + ComponentSource, + SourceLocation, + OwnersStack, + OwnerEntry, + FindComponentsResult, + ToolError, +} from './DevToolsFacadeTreeTools'; + +// The set of tools assembled from a Facade. Each tool returns a plain +// JavaScript value (see the types in ./DevToolsFacadeTreeTools); serialization is the +// integrator's responsibility. Integrators decide whether to expose these on +// globals or call them directly. +export type Tools = { + getComponentTree: ( + depth?: number, + rootLabel?: string, + ) => Array | ToolError, + getComponentByLabel: (label: string) => NodeInfo | ToolError, + findComponents: ( + name: string, + rootLabel?: string, + page?: number, + pageSize?: number, + ) => FindComponentsResult | ToolError, + getComponentSource: (label: string) => ComponentSource | ToolError, + getOwnersStack: (label: string) => OwnersStack | ToolError, + getOwnersBranch: (label: string) => Array | ToolError, +}; + +/** + * Assemble the set of tools from a Facade. The tools read the facade's tracked + * runtime state (fiber roots, per-renderer internals) lazily on each call and + * never touch globals, so the integrator fully owns both the facade and the + * returned tools. + * + * @param facade - A Facade returned by installFacade(). + */ +export function createTools(facade: Facade): Tools { + const tree = createTreeTools(facade.fiberRoots, facade.rendererInternals); + + return { + getComponentTree: tree.getComponentTree, + getComponentByLabel: tree.getComponentByLabel, + findComponents: tree.findComponents, + getComponentSource: tree.getComponentSource, + getOwnersStack: tree.getOwnersStack, + getOwnersBranch: tree.getOwnersBranch, + }; +} diff --git a/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js b/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js new file mode 100644 index 000000000000..125c6a912f23 --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js @@ -0,0 +1,672 @@ +/** + * 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 label, forming an adjacency list the integrator can rebuild. +export type TreeNode = { + label: 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 = { + label: 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 = {label: string, name: string, type: string}; + +export type FindComponentsResult = { + page: number, + pageSize: number, + totalCount: number, + totalPages: number, + results: Array, +}; + +export type TreeTools = { + getComponentTree: ( + depth?: number, + rootLabel?: string, + ) => Array | ToolError, + getComponentByLabel: (label: string) => NodeInfo | ToolError, + findComponents: ( + name: string, + rootLabel?: string, + page?: number, + pageSize?: number, + ) => FindComponentsResult | ToolError, + getComponentSource: (label: string) => ComponentSource | ToolError, + getOwnersStack: (label: string) => OwnersStack | ToolError, + getOwnersBranch: (label: string) => Array | ToolError, + getLabel: (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: 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: 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: 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 label state — survives across calls so the same fiber + // always maps to the same label, even after React re-renders (which + // swap fiber objects via double-buffering / alternates). + const fiberToLabel: WeakMap = new WeakMap(); + let nextId: number = 0; + + function getLabel(fiber: Fiber): string { + let label = fiberToLabel.get(fiber); + if (label != null) return label; + const alt = fiber.alternate; + if (alt != null) { + label = fiberToLabel.get(alt); + if (label != null) { + fiberToLabel.set(fiber, label); + return label; + } + } + label = '@c' + nextId++; + fiberToLabel.set(fiber, label); + return label; + } + + // 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 ? getLabel(children[0]) : null; + nodes.push({ + label: getLabel(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 childLabel = getLabel(children[i]); + for (let j = nodes.length - 1; j >= 0; j--) { + if (nodes[j].label === childLabel) { + nodes[j].nextSibling = getLabel(children[i + 1]); + break; + } + } + } + } + } + + function findByLabel(fiber: Fiber, targetLabel: string): Fiber | null { + if (getLabel(fiber) === targetLabel) return fiber; + const children = collectChildren(fiber); + for (let i = 0; i < children.length; i++) { + const found = findByLabel(children[i], targetLabel); + if (found != null) return found; + } + return null; + } + + // Find a fiber by label across all mounted roots. + // Returns the fiber and its renderer's internals, or an error. + function findFiberByLabel( + label: 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 = findByLabel(root.current, label); + if (fiber != null) return {fiber, internals, error: null}; + } + } + return { + fiber: null, + internals: null, + error: 'Component not found: "' + label + '"', + }; + } + + /** + * Returns a snapshot of the component tree as an array of nodes. Each node + * includes: label, type, name, key, firstChild, nextSibling (the last two + * reference other nodes by label). + * + * @param depth - Maximum tree depth to traverse (default 20). + * @param rootLabel - If provided, snapshot starts from this component. + */ + function getComponentTree( + depth?: number = 20, + rootLabel?: string, + ): Array | ToolError { + if (rootLabel != null) { + const result = findFiberByLabel(rootLabel); + 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 label: 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 label - The component label (e.g. "@c5"). + */ + function getComponentByLabel(label: string): NodeInfo | ToolError { + const result = findFiberByLabel(label); + if (result.error != null) { + return {error: result.error}; + } + const {fiber, internals} = result; + const info: NodeInfo = { + label: getLabel(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 rootLabel - 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, + rootLabel?: string, + page?: number = 1, + pageSize?: number = 10, + ): FindComponentsResult | ToolError { + const query = name.toLowerCase(); + const allMatches: Array = []; + + if (rootLabel != null) { + const found = findFiberByLabel(rootLabel); + 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({ + label: getLabel(fiber), + type: getTypeTagForFiber(internals, fiber), + name: getDisplayName(internals, fiber), + key: fiber.key != null ? String(fiber.key) : null, + firstChild: children.length > 0 ? getLabel(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 label - The component label (e.g. "@c5"). + */ + function getComponentSource(label: string): ComponentSource | ToolError { + const result = findFiberByLabel(label); + 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 label - The component label (e.g. "@c5"). + */ + function getOwnersStack(label: string): OwnersStack | ToolError { + const result = findFiberByLabel(label); + 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 label for cross-referencing with other tools (e.g. + * getComponentByLabel, getComponentSource, getComponentTree). + * + * Returns an array of {label, name, type}, or an empty array if the component + * has no owner (root component). DEV-only — in production, _debugOwner is not + * available. + * + * @param label - The component label (e.g. "@c5"). + */ + function getOwnersBranch(label: string): Array | ToolError { + const result = findFiberByLabel(label); + 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({ + label: getLabel(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, + getComponentByLabel, + findComponents, + getComponentSource, + getOwnersStack, + getOwnersBranch, + getLabel, + }; +} diff --git a/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js new file mode 100644 index 000000000000..6fd50349b5dc --- /dev/null +++ b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js @@ -0,0 +1,1537 @@ +/** + * 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. + */ + +'use strict'; + +let installFacade; +let createTools; +let facade; +let React; +let ReactDOMClient; +let act; +let container; + +describe('react-devtools-facade', () => { + beforeEach(() => { + jest.resetModules(); + global.IS_REACT_ACT_ENVIRONMENT = true; + + // The hook lives on globalThis, which jsdom shares across tests in this + // file, so a leftover hook would make installFacade() below throw. Remove + // it for a clean slate. (The facade never installs any other global, which + // the "does not install any tool globals" test verifies.) + delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + + // Install the facade BEFORE React so the hook captures the first commit. + // Import through the package entry point to exercise the public surface. + const facadeAPI = require('../../index'); + installFacade = facadeAPI.installFacade; + createTools = facadeAPI.createTools; + facade = installFacade(); + + React = require('react'); + ReactDOMClient = require('react-dom/client'); + act = React.act; + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + it('installs __REACT_DEVTOOLS_GLOBAL_HOOK__ on globalThis', () => { + expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook); + }); + + it('returns a Facade handle exposing the hook and tracked state', () => { + expect(facade.hook).toBe(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__); + expect(facade.fiberRoots).toBeInstanceOf(Map); + expect(facade.rendererInternals).toBeInstanceOf(Map); + expect(facade.profilingState).toEqual({ + isActive: false, + currentTraceName: null, + traces: expect.any(Map), + onCommit: null, + onPostCommit: null, + }); + }); + + it('does not install any tool globals (the integrator decides those)', () => { + expect(globalThis.__REACT_TOOLS__).toBeUndefined(); + 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('installs onto an explicit target without touching globalThis', () => { + const target = {}; + const localFacade = installFacade(target); + + expect(target.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(localFacade.hook); + // The explicit-target facade is fully independent of the global one. + expect(localFacade.hook).not.toBe(facade.hook); + expect(localFacade.fiberRoots).not.toBe(facade.fiberRoots); + // ...and installing onto a target does not disturb the global hook. + expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook); + }); + + it('records the renderer and its fiber root on mount', () => { + function Greeting() { + return
Hello
; + } + + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + // React injected a renderer: its internal constants were captured... + expect(facade.rendererInternals.size).toBeGreaterThan(0); + // ...and the hook recorded the committed root in facade.fiberRoots. + let totalRoots = 0; + facade.fiberRoots.forEach(roots => { + totalRoots += roots.size; + }); + expect(totalRoots).toBeGreaterThan(0); + }); + + it('removes unmounted roots from tracking', () => { + function App() { + return
hello
; + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + + const rendererID = Array.from(facade.hook.renderers.keys())[0]; + expect(facade.hook.getFiberRoots(rendererID).size).toBeGreaterThan(0); + + act(() => { + root.unmount(); + }); + + 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({ + label: '@c0', + type: 'function', + name: 'App', + key: null, + firstChild: div.label, + 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({ + label: '@c2', + 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.label); + // div's firstChild is Header + expect(div.firstChild).toBe(header.label); + // Header's nextSibling is Footer + expect(header.nextSibling).toBe(footer.label); + // 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 rootLabel is provided', () => { + function Nav() { + return ; + } + function Header() { + return