From 1d8bddf8ce5d22207b96704733fd5699fa9bc647 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 3 Jun 2026 17:26:41 +0100 Subject: [PATCH 1/7] [react-debug-tools] add WithoutDefaultDispatcher hook inspectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspectHooks/inspectHooksOfFiber default currentDispatcher to ReactSharedInternals, and shared/ReactSharedInternals imports React — so bundling them drags all of React in, even for callers that always pass the renderer's injected dispatcher (e.g. react-devtools-facade). Extract the shared logic into private inspectHooksImpl / inspectHooksOfFiberImpl, which require an explicit dispatcher and never reference ReactSharedInternals. inspectHooks and inspectHooksOfFiber keep their existing behavior as thin wrappers that supply the ReactSharedInternals default. Add exported inspectHooksWithoutDefaultDispatcher and inspectHooksOfFiberWithoutDefaultDispatcher that delegate to the Impls directly, so a bundle importing only them references no React. --- .../react-debug-tools/src/ReactDebugHooks.js | 76 ++++++++++++++----- .../react-debug-tools/src/ReactDebugTools.js | 14 +++- 2 files changed, 71 insertions(+), 19 deletions(-) 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, +}; From 468bb014f680366b47c160cc5e8f15982e30b06f Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Wed, 3 Jun 2026 17:26:41 +0100 Subject: [PATCH 2/7] [react-devtools-shared] extract getDispatcherRef into a shared module Move getDispatcherRef out of backend/fiber/renderer.js into backend/shared/DevToolsReactDispatcher.js and import it back. This lets other consumers (e.g. react-devtools-facade) reuse the renderer-dispatcher adapter without importing the whole renderer module. --- .../src/backend/fiber/renderer.js | 28 +-------------- .../backend/shared/DevToolsReactDispatcher.js | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 27 deletions(-) create mode 100644 packages/react-devtools-shared/src/backend/shared/DevToolsReactDispatcher.js diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 037ce1c5cc3b..17b746429123 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -171,8 +171,6 @@ import type { RendererInterface, SerializedElement, SerializedAsyncInfo, - CurrentDispatcherRef, - LegacyDispatcherRef, ProfilingSettings, } from '../types'; import type { @@ -194,6 +192,7 @@ import { VIRTUAL_INSTANCE, FILTERED_FIBER_INSTANCE, } from './shared/DevToolsFiberTypes'; +import {getDispatcherRef} from '../shared/DevToolsReactDispatcher'; import {getSourceLocationByFiber} from './DevToolsFiberComponentStack'; import {formatOwnerStack} from '../shared/DevToolsOwnerStack'; @@ -275,31 +274,6 @@ function createSuspenseNode( }); } -export function getDispatcherRef(renderer: { - +currentDispatcherRef?: LegacyDispatcherRef | CurrentDispatcherRef, - ... -}): void | CurrentDispatcherRef { - if (renderer.currentDispatcherRef === undefined) { - return undefined; - } - const injectedRef = renderer.currentDispatcherRef; - if ( - typeof injectedRef.H === 'undefined' && - typeof injectedRef.current !== 'undefined' - ) { - // We got a legacy dispatcher injected, let's create a wrapper proxy to translate. - return { - get H() { - return (injectedRef: any).current; - }, - set H(value) { - (injectedRef: any).current = value; - }, - }; - } - return (injectedRef: any); -} - // All environment names we've seen so far. This lets us create a list of filters to apply. // This should ideally include env of filtered Components too so that you can add those as // filters at the same time as removing some other filter. diff --git a/packages/react-devtools-shared/src/backend/shared/DevToolsReactDispatcher.js b/packages/react-devtools-shared/src/backend/shared/DevToolsReactDispatcher.js new file mode 100644 index 000000000000..cdc334018b59 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/shared/DevToolsReactDispatcher.js @@ -0,0 +1,35 @@ +/** + * 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 {CurrentDispatcherRef, LegacyDispatcherRef} from '../types'; + +export function getDispatcherRef(renderer: { + +currentDispatcherRef?: LegacyDispatcherRef | CurrentDispatcherRef, + ... +}): void | CurrentDispatcherRef { + if (renderer.currentDispatcherRef === undefined) { + return undefined; + } + const injectedRef = renderer.currentDispatcherRef; + if ( + typeof injectedRef.H === 'undefined' && + typeof injectedRef.current !== 'undefined' + ) { + // We got a legacy dispatcher injected, let's create a wrapper proxy to translate. + return { + get H() { + return (injectedRef: any).current; + }, + set H(value) { + (injectedRef: any).current = value; + }, + }; + } + return (injectedRef: any); +} From 5f56d29f37a670e91b4984f53e6b30f05e137865 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Mon, 1 Jun 2026 18:28:31 +0100 Subject: [PATCH 3/7] [react-devtools-facade] scaffold package + installFacade building block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces react-devtools-facade, a private, source-only library of building blocks for querying React runtime state. This first commit adds installFacade(target): it installs only __REACT_DEVTOOLS_GLOBAL_HOOK__ (the global React looks for at init) and returns a Facade handle {hook, fiberRoots, rendererInternals, profilingState}. It installs no tool globals — integrators (e.g. a chrome-devtools-mcp package) compose the Facade into tools and decide what to expose. Its tests rely on the devtools build/dev setup, so the package is excluded from the general test runners and runs only under the build-devtools jest project — matching react-devtools-shared and react-devtools-extensions. Tool building blocks and a createTools(facade) assembler land in follow-ups. --- .eslintrc.js | 1 + packages/react-devtools-facade/README.md | 37 ++++ packages/react-devtools-facade/index.js | 10 + packages/react-devtools-facade/package.json | 13 ++ .../src/DevToolsFacade.js | 186 ++++++++++++++++++ .../src/__tests__/DevToolsFacade-test.js | 125 ++++++++++++ scripts/jest/config.build.js | 1 + scripts/jest/config.source-persistent.js | 1 + scripts/jest/config.source-www.js | 1 + scripts/jest/config.source-xplat.js | 1 + scripts/jest/config.source.js | 1 + 11 files changed, 377 insertions(+) create mode 100644 packages/react-devtools-facade/README.md create mode 100644 packages/react-devtools-facade/index.js create mode 100644 packages/react-devtools-facade/package.json create mode 100644 packages/react-devtools-facade/src/DevToolsFacade.js create mode 100644 packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js 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-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..0c329bc1a5d1 --- /dev/null +++ b/packages/react-devtools-facade/src/DevToolsFacade.js @@ -0,0 +1,186 @@ +/** + * 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'; + +// 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/__tests__/DevToolsFacade-test.js b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js new file mode 100644 index 000000000000..53fc2cdc6d90 --- /dev/null +++ b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js @@ -0,0 +1,125 @@ +/** + * 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 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. + installFacade = require('../../index').installFacade; + 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); + }); +}); diff --git a/scripts/jest/config.build.js b/scripts/jest/config.build.js index d1bbeb5a8ddf..4d0789c676f9 100644 --- a/scripts/jest/config.build.js +++ b/scripts/jest/config.build.js @@ -60,6 +60,7 @@ module.exports = Object.assign({}, baseConfig, { modulePathIgnorePatterns: [ ...baseConfig.modulePathIgnorePatterns, 'packages/react-devtools-extensions', + 'packages/react-devtools-facade', 'packages/react-devtools-shared', ], // Don't run bundle tests on -test.internal.* files diff --git a/scripts/jest/config.source-persistent.js b/scripts/jest/config.source-persistent.js index 80bec669b643..ba61d1c1407f 100644 --- a/scripts/jest/config.source-persistent.js +++ b/scripts/jest/config.source-persistent.js @@ -6,6 +6,7 @@ module.exports = Object.assign({}, baseConfig, { modulePathIgnorePatterns: [ ...baseConfig.modulePathIgnorePatterns, 'packages/react-devtools-extensions', + 'packages/react-devtools-facade', 'packages/react-devtools-shared', 'ReactIncrementalPerf', 'ReactIncrementalUpdatesMinimalism', diff --git a/scripts/jest/config.source-www.js b/scripts/jest/config.source-www.js index 31e8841ac363..e8ba98eb7fb1 100644 --- a/scripts/jest/config.source-www.js +++ b/scripts/jest/config.source-www.js @@ -6,6 +6,7 @@ module.exports = Object.assign({}, baseConfig, { modulePathIgnorePatterns: [ ...baseConfig.modulePathIgnorePatterns, 'packages/react-devtools-extensions', + 'packages/react-devtools-facade', 'packages/react-devtools-shared', ], setupFiles: [ diff --git a/scripts/jest/config.source-xplat.js b/scripts/jest/config.source-xplat.js index 760a584cc1e9..2c0ab74353cb 100644 --- a/scripts/jest/config.source-xplat.js +++ b/scripts/jest/config.source-xplat.js @@ -6,6 +6,7 @@ module.exports = Object.assign({}, baseConfig, { modulePathIgnorePatterns: [ ...baseConfig.modulePathIgnorePatterns, 'packages/react-devtools-extensions', + 'packages/react-devtools-facade', 'packages/react-devtools-shared', 'ReactIncrementalPerf', 'ReactIncrementalUpdatesMinimalism', diff --git a/scripts/jest/config.source.js b/scripts/jest/config.source.js index 710df337b5ab..ab68154433f2 100644 --- a/scripts/jest/config.source.js +++ b/scripts/jest/config.source.js @@ -6,6 +6,7 @@ module.exports = Object.assign({}, baseConfig, { modulePathIgnorePatterns: [ ...baseConfig.modulePathIgnorePatterns, 'packages/react-devtools-extensions', + 'packages/react-devtools-facade', 'packages/react-devtools-shared', ], setupFiles: [ From 7a322cb9094f7c528ec739e4d36a93615ae58338 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Tue, 2 Jun 2026 16:06:31 +0100 Subject: [PATCH 4/7] [react-devtools-facade] component-tree tools + createTools Adds the component-tree building blocks and the createTools assembler. createTools(facade) reads the facade's tracked state and returns the tools object (no globals): getComponentTree, getComponentByLabel, findComponents, getComponentSource, getOwnersStack, getOwnersBranch. Tools return typed plain JavaScript values (see DevToolsFacadeTreeTools); serialization is left to the integration package. getComponentByLabel includes the component's props and, for function components, its inspected hooks tree, obtained via react-debug-tools' inspectHooksOfFiberWithoutDefaultDispatcher with the renderer's injected dispatcher (normalized by getDispatcherRef), so the facade never falls back to (and bundles) React's shared internals. --- .../src/DevToolsFacade.js | 5 + .../src/DevToolsFacadeTools.js | 75 + .../src/DevToolsFacadeTreeTools.js | 672 ++++++++ .../src/__tests__/DevToolsFacade-test.js | 1414 ++++++++++++++++- 4 files changed, 2165 insertions(+), 1 deletion(-) create mode 100644 packages/react-devtools-facade/src/DevToolsFacadeTools.js create mode 100644 packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js diff --git a/packages/react-devtools-facade/src/DevToolsFacade.js b/packages/react-devtools-facade/src/DevToolsFacade.js index 0c329bc1a5d1..5147ba0b3602 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 = { 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 index 53fc2cdc6d90..6fd50349b5dc 100644 --- a/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js +++ b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js @@ -8,6 +8,7 @@ 'use strict'; let installFacade; +let createTools; let facade; let React; let ReactDOMClient; @@ -27,7 +28,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'); @@ -122,4 +125,1413 @@ 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({ + 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