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: [