Skip to content

Commit 77df6e1

Browse files
authored
[react-devtools-facade] 1/ scaffold package + installFacade building block (react#36593)
Introduces **`react-devtools-facade`**, a private, source-only package of building blocks for React runtime state introspection. Integrates with Reconciler through React DevTools global hook. ### Plan The facade is the shared, low-level layer that public, MCP-server–specific packages build on: - **`react-devtools-facade` (private)** — installs the React DevTools global hook and exposes a small, framework-agnostic API: install the hook, then assemble a set of tools from the returned handle. It installs *no* tool globals and makes no decision about how tools are surfaced. - **Integration packages (public)**, e.g. `react-devtools-cdt-mcp` — compose the facade's tools and target a specific MCP server (chrome-devtools-mcp first). This keeps all runtime-introspection logic in one reusable place while each integration owns its own protocol, packaging, serialisation and globals. ### This PR - `installFacade(target = globalThis): Facade` — installs **only** `__REACT_DEVTOOLS_GLOBAL_HOOK__` (the global React looks for at init) and returns a `Facade` handle `{hook, fiberRoots, rendererInternals, profilingState}`. The hook tracks fiber roots on commit and forwards commits to the profiling state when a session is active; building blocks read from the returned handle and never touch globals. - Guards against double-install (mixing with the full DevTools backend). *Temporary*, will later add compatibility for the RDT extension scenario. - Package scaffold: `package.json` (private, source-only), `index.js`, `README.md`. - Excluded from the general jest runners; runs under the build-devtools project, matching `react-devtools-shared` / `react-devtools-extensions`. > Tool building blocks and `createTools(facade)` land in the follow-ups.
1 parent a7cce7b commit 77df6e1

11 files changed

Lines changed: 377 additions & 0 deletions

File tree

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ module.exports = {
336336
'packages/react-test-renderer/**/*.js',
337337
'packages/react-debug-tools/**/*.js',
338338
'packages/react-devtools-extensions/**/*.js',
339+
'packages/react-devtools-facade/**/*.js',
339340
'packages/react-devtools-timeline/**/*.js',
340341
'packages/react-native-renderer/**/*.js',
341342
'packages/eslint-plugin-react-hooks/**/*.js',
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# react-devtools-facade
2+
3+
Experimental, private package that defines building blocks for querying React runtime state.
4+
5+
The facade installs the `__REACT_DEVTOOLS_GLOBAL_HOOK__` that React looks for at
6+
initialization time — enabling fiber-root tracking without the overhead of the
7+
full DevTools backend — and exposes a small, framework-agnostic library API that
8+
integrators compose into tools.
9+
10+
This package is intentionally low-level. It does **not** install any tool
11+
globals and it does **not** decide how tools are surfaced. It installs the hook,
12+
tracks fiber roots, and hands back building blocks; the integrator (for example,
13+
a `chrome-devtools-mcp` integration) decides everything else — including whether
14+
to expose anything else on globals.
15+
16+
## API
17+
18+
### `installFacade(target = globalThis): Facade`
19+
20+
Installs `__REACT_DEVTOOLS_GLOBAL_HOOK__` on `target` and returns a `Facade`
21+
handle holding the hook plus the runtime state it tracks (`fiberRoots`,
22+
`rendererInternals`, `profilingState`). Building blocks read from the returned
23+
`Facade`; they never reach for globals.
24+
25+
Call this **before** React initializes so the hook captures the first commit:
26+
27+
```js
28+
import {installFacade} from 'react-devtools-facade';
29+
30+
const facade = installFacade();
31+
// ...load React, render your app...
32+
```
33+
34+
`installFacade` installs **only** the DevTools hook. It does not install
35+
`__REACT_TOOLS__`, `__REACT_LLM_TOOLS__`, or any other global. Once the tool
36+
building blocks land, an integrator composes them from the returned `Facade` and
37+
decides whether to expose anything on globals.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
export * from './src/DevToolsFacade';
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "react-devtools-facade",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Building blocks for querying React runtime state",
6+
"license": "MIT",
7+
"main": "index.js",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/facebook/react.git",
11+
"directory": "packages/react-devtools-facade"
12+
}
13+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {
11+
DevToolsHook,
12+
WorkTagMap,
13+
CurrentDispatcherRef,
14+
} from 'react-devtools-shared/src/backend/types';
15+
import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
16+
import type {
17+
getDisplayNameForFiberType,
18+
ReactPriorityLevelsType,
19+
} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants';
20+
21+
import {getInternalReactConstants} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants';
22+
23+
// Per-renderer internal constants, initialized at inject() time. Building
24+
// blocks read these to translate fibers into human-readable output.
25+
export type RendererInternals = {
26+
getDisplayNameForFiber: getDisplayNameForFiberType,
27+
ReactTypeOfWork: WorkTagMap,
28+
ReactPriorityLevels: ReactPriorityLevelsType,
29+
currentDispatcherRef: CurrentDispatcherRef,
30+
};
31+
32+
// Profiling session state, shared between the hook (which records commits) and
33+
// the profiler building blocks (which start/stop sessions and read results).
34+
export type ProfilingState = {
35+
isActive: boolean,
36+
currentTraceName: string | null,
37+
traces: Map<string, any>,
38+
onCommit:
39+
| ((
40+
rendererID: number,
41+
root: FiberRoot,
42+
schedulerPriority: number | void,
43+
) => void)
44+
| null,
45+
onPostCommit: ((root: FiberRoot) => void) | null,
46+
};
47+
48+
// A self-contained handle over the installed DevTools hook and the runtime
49+
// state it tracks. Building blocks (createTools, the tree/profiler factories)
50+
// read from a Facade and never touch globals, so the integrator fully owns it.
51+
export type Facade = {
52+
hook: DevToolsHook,
53+
fiberRoots: Map<number, Set<FiberRoot>>,
54+
rendererInternals: Map<number, RendererInternals>,
55+
profilingState: ProfilingState,
56+
};
57+
58+
/**
59+
* Install the React DevTools facade: install `__REACT_DEVTOOLS_GLOBAL_HOOK__`
60+
* on `target` (defaults to globalThis) and return a Facade handle.
61+
*
62+
* This installs ONLY `__REACT_DEVTOOLS_GLOBAL_HOOK__` — the global React looks
63+
* for at initialization time. It does not install any tool globals: the
64+
* returned Facade is passed to building blocks such as `createTools(facade)`,
65+
* and the integrator decides whether to expose the resulting tools on globals.
66+
*
67+
* Must run BEFORE React initializes so the hook captures the first commit.
68+
*/
69+
export function installFacade(target?: any = globalThis): Facade {
70+
// Guard against double-install (e.g. bundled twice or mixed with full DevTools).
71+
if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
72+
throw new Error(
73+
'React DevTools global hook is already installed. ' +
74+
'react-devtools-facade should not be used with any other React DevTools package.',
75+
);
76+
}
77+
78+
// Fiber root tracking — the only runtime state the hook maintains.
79+
// onCommitFiberRoot adds/removes entries so that unmounted roots are
80+
// garbage-collected. Building blocks walk from these roots on demand.
81+
const fiberRoots: Map<number, Set<FiberRoot>> = new Map();
82+
83+
const rendererInternals: Map<number, RendererInternals> = new Map();
84+
85+
const profilingState: ProfilingState = {
86+
isActive: false,
87+
currentTraceName: null,
88+
traces: new Map(),
89+
onCommit: null,
90+
onPostCommit: null,
91+
};
92+
93+
let registeredRenderersCount = 0;
94+
95+
// $FlowFixMe[incompatible-type] the facade provides a minimal subset of DevToolsHook
96+
const hook: DevToolsHook = {
97+
listeners: {},
98+
rendererInterfaces: new Map(),
99+
renderers: new Map(),
100+
hasUnsupportedRendererAttached: false,
101+
backends: new Map(),
102+
emit() {},
103+
getFiberRoots(rendererID: number) {
104+
let roots = fiberRoots.get(rendererID);
105+
if (roots == null) {
106+
roots = new Set();
107+
fiberRoots.set(rendererID, roots);
108+
}
109+
return roots;
110+
},
111+
inject(renderer: any): number {
112+
const id = registeredRenderersCount++;
113+
hook.renderers.set(id, renderer);
114+
// Initialize internal constants for this renderer's React version.
115+
const version = renderer.reconcilerVersion || renderer.version;
116+
if (version == null) {
117+
console.error(
118+
'react-devtools-facade: Renderer %s has no version, internals not initialized.',
119+
id,
120+
);
121+
} else {
122+
const {getDisplayNameForFiber, ReactTypeOfWork, ReactPriorityLevels} =
123+
getInternalReactConstants(version);
124+
rendererInternals.set(id, {
125+
getDisplayNameForFiber,
126+
ReactTypeOfWork,
127+
ReactPriorityLevels,
128+
currentDispatcherRef: renderer.currentDispatcherRef,
129+
});
130+
}
131+
return id;
132+
},
133+
on() {},
134+
off() {},
135+
sub() {
136+
return () => {};
137+
},
138+
supportsFiber: true,
139+
supportsFlight: true,
140+
checkDCE() {},
141+
onCommitFiberRoot(
142+
rendererID: number,
143+
root: any,
144+
schedulerPriority?: number,
145+
) {
146+
// Hot path — called on every React commit. Keep minimal: just
147+
// add or remove the root so building blocks can find it later.
148+
const mountedRoots = hook.getFiberRoots(rendererID);
149+
const current = root.current;
150+
const isKnownRoot = mountedRoots.has(root);
151+
const isUnmounting =
152+
current.memoizedState == null || current.memoizedState.element == null;
153+
if (!isKnownRoot && !isUnmounting) {
154+
mountedRoots.add(root);
155+
} else if (isKnownRoot && isUnmounting) {
156+
mountedRoots.delete(root);
157+
}
158+
159+
// Profiling: record commit durations when a session is active.
160+
if (profilingState.isActive && profilingState.onCommit != null) {
161+
profilingState.onCommit(rendererID, root, schedulerPriority);
162+
}
163+
},
164+
onCommitFiberUnmount() {},
165+
onPostCommitFiberRoot(rendererID: number, root: any) {
166+
if (profilingState.isActive && profilingState.onPostCommit != null) {
167+
profilingState.onPostCommit(root);
168+
}
169+
},
170+
getInternalModuleRanges(): Array<[string, string]> {
171+
return [];
172+
},
173+
registerInternalModuleStart() {},
174+
registerInternalModuleStop() {},
175+
};
176+
177+
Object.defineProperty(target, '__REACT_DEVTOOLS_GLOBAL_HOOK__', {
178+
configurable: __DEV__,
179+
enumerable: false,
180+
get() {
181+
return hook;
182+
},
183+
});
184+
185+
return {hook, fiberRoots, rendererInternals, profilingState};
186+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
'use strict';
9+
10+
let installFacade;
11+
let facade;
12+
let React;
13+
let ReactDOMClient;
14+
let act;
15+
let container;
16+
17+
describe('react-devtools-facade', () => {
18+
beforeEach(() => {
19+
jest.resetModules();
20+
global.IS_REACT_ACT_ENVIRONMENT = true;
21+
22+
// The hook lives on globalThis, which jsdom shares across tests in this
23+
// file, so a leftover hook would make installFacade() below throw. Remove
24+
// it for a clean slate. (The facade never installs any other global, which
25+
// the "does not install any tool globals" test verifies.)
26+
delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
27+
28+
// Install the facade BEFORE React so the hook captures the first commit.
29+
// Import through the package entry point to exercise the public surface.
30+
installFacade = require('../../index').installFacade;
31+
facade = installFacade();
32+
33+
React = require('react');
34+
ReactDOMClient = require('react-dom/client');
35+
act = React.act;
36+
37+
container = document.createElement('div');
38+
document.body.appendChild(container);
39+
});
40+
41+
afterEach(() => {
42+
document.body.removeChild(container);
43+
container = null;
44+
});
45+
46+
it('installs __REACT_DEVTOOLS_GLOBAL_HOOK__ on globalThis', () => {
47+
expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook);
48+
});
49+
50+
it('returns a Facade handle exposing the hook and tracked state', () => {
51+
expect(facade.hook).toBe(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__);
52+
expect(facade.fiberRoots).toBeInstanceOf(Map);
53+
expect(facade.rendererInternals).toBeInstanceOf(Map);
54+
expect(facade.profilingState).toEqual({
55+
isActive: false,
56+
currentTraceName: null,
57+
traces: expect.any(Map),
58+
onCommit: null,
59+
onPostCommit: null,
60+
});
61+
});
62+
63+
it('does not install any tool globals (the integrator decides those)', () => {
64+
expect(globalThis.__REACT_TOOLS__).toBeUndefined();
65+
expect(globalThis.__REACT_LLM_TOOLS__).toBeUndefined();
66+
});
67+
68+
it('throws if a DevTools hook is already installed', () => {
69+
// A hook was already installed on globalThis in beforeEach.
70+
expect(() => installFacade()).toThrow(
71+
/React DevTools global hook is already installed/,
72+
);
73+
});
74+
75+
it('installs onto an explicit target without touching globalThis', () => {
76+
const target = {};
77+
const localFacade = installFacade(target);
78+
79+
expect(target.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(localFacade.hook);
80+
// The explicit-target facade is fully independent of the global one.
81+
expect(localFacade.hook).not.toBe(facade.hook);
82+
expect(localFacade.fiberRoots).not.toBe(facade.fiberRoots);
83+
// ...and installing onto a target does not disturb the global hook.
84+
expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook);
85+
});
86+
87+
it('records the renderer and its fiber root on mount', () => {
88+
function Greeting() {
89+
return <div>Hello</div>;
90+
}
91+
92+
act(() => {
93+
ReactDOMClient.createRoot(container).render(<Greeting />);
94+
});
95+
96+
// React injected a renderer: its internal constants were captured...
97+
expect(facade.rendererInternals.size).toBeGreaterThan(0);
98+
// ...and the hook recorded the committed root in facade.fiberRoots.
99+
let totalRoots = 0;
100+
facade.fiberRoots.forEach(roots => {
101+
totalRoots += roots.size;
102+
});
103+
expect(totalRoots).toBeGreaterThan(0);
104+
});
105+
106+
it('removes unmounted roots from tracking', () => {
107+
function App() {
108+
return <div>hello</div>;
109+
}
110+
111+
const root = ReactDOMClient.createRoot(container);
112+
act(() => {
113+
root.render(<App />);
114+
});
115+
116+
const rendererID = Array.from(facade.hook.renderers.keys())[0];
117+
expect(facade.hook.getFiberRoots(rendererID).size).toBeGreaterThan(0);
118+
119+
act(() => {
120+
root.unmount();
121+
});
122+
123+
expect(facade.hook.getFiberRoots(rendererID).size).toBe(0);
124+
});
125+
});

scripts/jest/config.build.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ module.exports = Object.assign({}, baseConfig, {
6060
modulePathIgnorePatterns: [
6161
...baseConfig.modulePathIgnorePatterns,
6262
'packages/react-devtools-extensions',
63+
'packages/react-devtools-facade',
6364
'packages/react-devtools-shared',
6465
],
6566
// Don't run bundle tests on -test.internal.* files

scripts/jest/config.source-persistent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module.exports = Object.assign({}, baseConfig, {
66
modulePathIgnorePatterns: [
77
...baseConfig.modulePathIgnorePatterns,
88
'packages/react-devtools-extensions',
9+
'packages/react-devtools-facade',
910
'packages/react-devtools-shared',
1011
'ReactIncrementalPerf',
1112
'ReactIncrementalUpdatesMinimalism',

0 commit comments

Comments
 (0)