Skip to content

Commit e92ecef

Browse files
authored
[react-devtools-facade] 5/ support an already-installed DevTools hook (react#36682)
Makes `installFacade` usable on pages that already have a DevTools backend — most importantly the **React DevTools browser extension** — by attaching to the existing `__REACT_DEVTOOLS_GLOBAL_HOOK__` instead of refusing to install. This is important, because otherwise if the page had Facade installed, the user won't be able to use React DevTools browser extension.
1 parent 552037d commit e92ecef

2 files changed

Lines changed: 273 additions & 59 deletions

File tree

packages/react-devtools-facade/src/DevToolsFacade.js

Lines changed: 170 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -60,33 +60,162 @@ export type Facade = {
6060
profilingState: ProfilingState,
6161
};
6262

63+
// Initialize per-renderer internal constants for a renderer registered with the
64+
// hook. Shared by the installed hook's inject() and the attach path.
65+
function initializeRendererInternals(
66+
rendererInternals: Map<number, RendererInternals>,
67+
id: number,
68+
renderer: any,
69+
): void {
70+
const version = renderer.reconcilerVersion || renderer.version;
71+
if (version == null) {
72+
console.error(
73+
'react-devtools-facade: Renderer %s has no version, internals not initialized.',
74+
id,
75+
);
76+
return;
77+
}
78+
const {getDisplayNameForFiber, ReactTypeOfWork, ReactPriorityLevels} =
79+
getInternalReactConstants(version);
80+
rendererInternals.set(id, {
81+
getDisplayNameForFiber,
82+
ReactTypeOfWork,
83+
ReactPriorityLevels,
84+
currentDispatcherRef: renderer.currentDispatcherRef,
85+
});
86+
}
87+
88+
// Record a commit: keep fiberRoots in sync (add new roots, drop unmounted ones)
89+
// and drive a profiling session when one is active. Shared by the installed
90+
// hook's onCommitFiberRoot and the attach path's wrapper.
91+
function recordCommitFiberRoot(
92+
fiberRoots: Map<number, Set<FiberRoot>>,
93+
profilingState: ProfilingState,
94+
rendererID: number,
95+
root: any,
96+
schedulerPriority?: number,
97+
): void {
98+
let mountedRoots = fiberRoots.get(rendererID);
99+
if (mountedRoots == null) {
100+
mountedRoots = new Set();
101+
fiberRoots.set(rendererID, mountedRoots);
102+
}
103+
const current = root.current;
104+
const isKnownRoot = mountedRoots.has(root);
105+
const isUnmounting =
106+
current.memoizedState == null || current.memoizedState.element == null;
107+
if (!isKnownRoot && !isUnmounting) {
108+
mountedRoots.add(root);
109+
} else if (isKnownRoot && isUnmounting) {
110+
mountedRoots.delete(root);
111+
}
112+
113+
if (profilingState.isActive && profilingState.onCommit != null) {
114+
profilingState.onCommit(rendererID, root, schedulerPriority);
115+
}
116+
}
117+
118+
// Attach to a DevTools hook that is already installed on the page — for example
119+
// the React DevTools browser extension. Rather than replacing it (React would
120+
// ignore a second hook), read the renderers and fiber roots it is already
121+
// tracking, then wrap inject / onCommitFiberRoot / onPostCommitFiberRoot so
122+
// future renderers, commits, and passive passes also feed the facade's state.
123+
// The existing hook's own bookkeeping is preserved — we always call through to
124+
// it first.
125+
function attachToExistingHook(
126+
hook: any,
127+
fiberRoots: Map<number, Set<FiberRoot>>,
128+
rendererInternals: Map<number, RendererInternals>,
129+
profilingState: ProfilingState,
130+
): void {
131+
// Back-fill renderers and roots registered before we attached (React may have
132+
// initialized first).
133+
if (hook.renderers instanceof Map) {
134+
hook.renderers.forEach((renderer: any, id: number) => {
135+
if (!rendererInternals.has(id)) {
136+
initializeRendererInternals(rendererInternals, id, renderer);
137+
}
138+
if (typeof hook.getFiberRoots === 'function') {
139+
let roots = fiberRoots.get(id);
140+
if (roots == null) {
141+
roots = new Set();
142+
fiberRoots.set(id, roots);
143+
}
144+
// Alias to a const so the non-null refinement survives into the closure.
145+
const mountedRoots = roots;
146+
hook.getFiberRoots(id).forEach((root: FiberRoot) => {
147+
mountedRoots.add(root);
148+
});
149+
}
150+
});
151+
}
152+
153+
const originalInject = hook.inject;
154+
hook.inject = function inject(renderer: any, ...rest: Array<mixed>): number {
155+
const id = originalInject.call(hook, renderer, ...rest);
156+
if (typeof id === 'number') {
157+
initializeRendererInternals(rendererInternals, id, renderer);
158+
}
159+
return id;
160+
};
161+
162+
const originalOnCommitFiberRoot = hook.onCommitFiberRoot;
163+
hook.onCommitFiberRoot = function onCommitFiberRoot(
164+
rendererID: number,
165+
root: any,
166+
schedulerPriority?: number,
167+
...rest: Array<mixed>
168+
) {
169+
if (typeof originalOnCommitFiberRoot === 'function') {
170+
originalOnCommitFiberRoot.call(
171+
hook,
172+
rendererID,
173+
root,
174+
schedulerPriority,
175+
...rest,
176+
);
177+
}
178+
recordCommitFiberRoot(
179+
fiberRoots,
180+
profilingState,
181+
rendererID,
182+
root,
183+
schedulerPriority,
184+
);
185+
};
186+
187+
const originalOnPostCommitFiberRoot = hook.onPostCommitFiberRoot;
188+
hook.onPostCommitFiberRoot = function onPostCommitFiberRoot(
189+
rendererID: number,
190+
root: any,
191+
...rest: Array<mixed>
192+
) {
193+
if (typeof originalOnPostCommitFiberRoot === 'function') {
194+
originalOnPostCommitFiberRoot.call(hook, rendererID, root, ...rest);
195+
}
196+
if (profilingState.isActive && profilingState.onPostCommit != null) {
197+
profilingState.onPostCommit(root);
198+
}
199+
};
200+
}
201+
63202
/**
64-
* Install the React DevTools facade: install `__REACT_DEVTOOLS_GLOBAL_HOOK__`
65-
* on `target` (defaults to globalThis) and return a Facade handle.
203+
* Install the React DevTools facade and return a Facade handle.
66204
*
67-
* This installs ONLY `__REACT_DEVTOOLS_GLOBAL_HOOK__` — the global React looks
68-
* for at initialization time. It does not install any tool globals: the
69-
* returned Facade is passed to building blocks such as `createTools(facade)`,
70-
* and the integrator decides whether to expose the resulting tools on globals.
205+
* If `__REACT_DEVTOOLS_GLOBAL_HOOK__` is not yet present, this installs the
206+
* facade's own minimal hook (the global React looks for at init). If a hook is
207+
* already installed — e.g. the user has the React DevTools browser extension —
208+
* the facade attaches to that hook instead of installing a second one.
71209
*
72-
* Must run BEFORE React initializes so the hook captures the first commit.
210+
* Either way the returned Facade exposes the same `{hook, fiberRoots,
211+
* rendererInternals, profilingState}` that building blocks such as
212+
* `createTools(facade)` read from. Install before React initializes so the first
213+
* commit is captured; when attaching, roots committed before attach are
214+
* back-filled from the existing hook.
73215
*/
74216
export function installFacade(target?: any = globalThis): Facade {
75-
// Guard against double-install (e.g. bundled twice or mixed with full DevTools).
76-
if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
77-
throw new Error(
78-
'React DevTools global hook is already installed. ' +
79-
'react-devtools-facade should not be used with any other React DevTools package.',
80-
);
81-
}
82-
83-
// Fiber root tracking — the only runtime state the hook maintains.
84-
// onCommitFiberRoot adds/removes entries so that unmounted roots are
85-
// garbage-collected. Building blocks walk from these roots on demand.
86217
const fiberRoots: Map<number, Set<FiberRoot>> = new Map();
87-
88218
const rendererInternals: Map<number, RendererInternals> = new Map();
89-
90219
const profilingState: ProfilingState = {
91220
isActive: false,
92221
currentTraceName: null,
@@ -95,6 +224,19 @@ export function installFacade(target?: any = globalThis): Facade {
95224
onPostCommit: null,
96225
};
97226

227+
// A hook is already installed (e.g. the React DevTools extension). Attach to
228+
// it rather than replacing it.
229+
const existingHook = target.__REACT_DEVTOOLS_GLOBAL_HOOK__;
230+
if (existingHook != null) {
231+
attachToExistingHook(
232+
existingHook,
233+
fiberRoots,
234+
rendererInternals,
235+
profilingState,
236+
);
237+
return {hook: existingHook, fiberRoots, rendererInternals, profilingState};
238+
}
239+
98240
let registeredRenderersCount = 0;
99241

100242
// $FlowFixMe[incompatible-type] the facade provides a minimal subset of DevToolsHook
@@ -116,23 +258,7 @@ export function installFacade(target?: any = globalThis): Facade {
116258
inject(renderer: any): number {
117259
const id = registeredRenderersCount++;
118260
hook.renderers.set(id, renderer);
119-
// Initialize internal constants for this renderer's React version.
120-
const version = renderer.reconcilerVersion || renderer.version;
121-
if (version == null) {
122-
console.error(
123-
'react-devtools-facade: Renderer %s has no version, internals not initialized.',
124-
id,
125-
);
126-
} else {
127-
const {getDisplayNameForFiber, ReactTypeOfWork, ReactPriorityLevels} =
128-
getInternalReactConstants(version);
129-
rendererInternals.set(id, {
130-
getDisplayNameForFiber,
131-
ReactTypeOfWork,
132-
ReactPriorityLevels,
133-
currentDispatcherRef: renderer.currentDispatcherRef,
134-
});
135-
}
261+
initializeRendererInternals(rendererInternals, id, renderer);
136262
return id;
137263
},
138264
on() {},
@@ -148,23 +274,13 @@ export function installFacade(target?: any = globalThis): Facade {
148274
root: any,
149275
schedulerPriority?: number,
150276
) {
151-
// Hot path — called on every React commit. Keep minimal: just
152-
// add or remove the root so building blocks can find it later.
153-
const mountedRoots = hook.getFiberRoots(rendererID);
154-
const current = root.current;
155-
const isKnownRoot = mountedRoots.has(root);
156-
const isUnmounting =
157-
current.memoizedState == null || current.memoizedState.element == null;
158-
if (!isKnownRoot && !isUnmounting) {
159-
mountedRoots.add(root);
160-
} else if (isKnownRoot && isUnmounting) {
161-
mountedRoots.delete(root);
162-
}
163-
164-
// Profiling: record commit durations when a session is active.
165-
if (profilingState.isActive && profilingState.onCommit != null) {
166-
profilingState.onCommit(rendererID, root, schedulerPriority);
167-
}
277+
recordCommitFiberRoot(
278+
fiberRoots,
279+
profilingState,
280+
rendererID,
281+
root,
282+
schedulerPriority,
283+
);
168284
},
169285
onCommitFiberUnmount() {},
170286
onPostCommitFiberRoot(rendererID: number, root: any) {

packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,53 @@ describe('react-devtools-facade', () => {
7272
expect(globalThis.__REACT_LLM_TOOLS__).toBeUndefined();
7373
});
7474

75-
it('throws if a DevTools hook is already installed', () => {
76-
// A hook was already installed on globalThis in beforeEach.
77-
expect(() => installFacade()).toThrow(
78-
/React DevTools global hook is already installed/,
79-
);
75+
it('attaches to an existing hook instead of installing a second one', () => {
76+
// A facade hook is already installed on globalThis (beforeEach). A second
77+
// installFacade() attaches to it rather than throwing or replacing it — this
78+
// is the path taken when the React DevTools extension is present.
79+
const attached = installFacade();
80+
expect(attached.hook).toBe(facade.hook);
81+
expect(globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__).toBe(facade.hook);
82+
});
83+
84+
it('an attached facade back-fills roots already tracked by the hook', () => {
85+
function App() {
86+
return <div>hi</div>;
87+
}
88+
act(() => {
89+
ReactDOMClient.createRoot(container).render(<App />);
90+
});
91+
92+
// Attaching after the app mounted picks up the already-tracked root.
93+
const attached = installFacade();
94+
const tree = createTools(attached).getComponentTree();
95+
expect(tree.find(n => n.name === 'App')).toBeDefined();
96+
});
97+
98+
it('an attached facade tracks later commits and profiles them', () => {
99+
function Counter({count}) {
100+
return <div>{'n:' + count}</div>;
101+
}
102+
const root = ReactDOMClient.createRoot(container);
103+
act(() => {
104+
root.render(<Counter count={0} />);
105+
});
106+
107+
const tools = createTools(installFacade());
108+
expect(
109+
tools.getComponentTree().find(n => n.name === 'Counter'),
110+
).toBeDefined();
111+
112+
// A commit after attaching flows through the wrapped onCommitFiberRoot.
113+
tools.startProfiling('attached-trace');
114+
act(() => {
115+
root.render(<Counter count={1} />);
116+
});
117+
expect(tools.stopProfiling()).toEqual({
118+
status: 'stopped',
119+
traceName: 'attached-trace',
120+
commits: 1,
121+
});
80122
});
81123

82124
it('installs onto an explicit target without touching globalThis', () => {
@@ -2044,4 +2086,60 @@ describe('react-devtools-facade', () => {
20442086
expect(names1).not.toContain('CounterA');
20452087
});
20462088
});
2089+
2090+
describe('with the React DevTools extension hook already installed', () => {
2091+
// Simulate the extension: a real React DevTools hook is installed, and React
2092+
// registers with it, before the facade attaches. Re-set up the module graph
2093+
// so react-dom injects into this hook rather than the facade's own.
2094+
let localContainer;
2095+
2096+
beforeEach(() => {
2097+
jest.resetModules();
2098+
global.IS_REACT_ACT_ENVIRONMENT = true;
2099+
delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
2100+
require('react-devtools-shared/src/hook').installHook(window, []);
2101+
2102+
const facadeAPI = require('../../index');
2103+
installFacade = facadeAPI.installFacade;
2104+
createTools = facadeAPI.createTools;
2105+
React = require('react');
2106+
ReactDOMClient = require('react-dom/client');
2107+
act = React.act;
2108+
2109+
localContainer = document.createElement('div');
2110+
document.body.appendChild(localContainer);
2111+
});
2112+
2113+
afterEach(() => {
2114+
document.body.removeChild(localContainer);
2115+
localContainer = null;
2116+
});
2117+
2118+
it('attaches to the extension hook and reads its component tree', () => {
2119+
const extensionHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
2120+
2121+
function Child() {
2122+
return <span>c</span>;
2123+
}
2124+
function App() {
2125+
return (
2126+
<div>
2127+
<Child />
2128+
</div>
2129+
);
2130+
}
2131+
2132+
act(() => {
2133+
ReactDOMClient.createRoot(localContainer).render(<App />);
2134+
});
2135+
2136+
const localFacade = installFacade();
2137+
// Attached to the extension's hook rather than replacing it.
2138+
expect(localFacade.hook).toBe(extensionHook);
2139+
2140+
const tree = createTools(localFacade).getComponentTree();
2141+
expect(tree.find(n => n.name === 'App')).toBeDefined();
2142+
expect(tree.find(n => n.name === 'Child')).toBeDefined();
2143+
});
2144+
});
20472145
});

0 commit comments

Comments
 (0)