@samitouri / QOS-React-1 / commits / e92ecef280

[react-devtools-facade] 5/ support an already-installed DevTools hook (#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.

Ruslan Lesiutin committed Jun 18, 2026 at 20:33 UTC e92ecef2806692f34e9d535da77405eb28464625
2 files changed +273 -59
packages/react-devtools-facade/src/DevToolsFacade.js
+170 -54
@@ -60,33 +60,162 @@ export type Facade = {
60 profilingState: ProfilingState,
61 };
62
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 +
202 /**
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.
204 *
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.
209 *
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.
215 */
216 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.
217 const fiberRoots: Map<number, Set<FiberRoot>> = new Map();
87 -
218 const rendererInternals: Map<number, RendererInternals> = new Map();
89 -
219 const profilingState: ProfilingState = {
220 isActive: false,
221 currentTraceName: null,
@@ -95,6 +224,19 @@ export function installFacade(target?: any = globalThis): Facade {
224 onPostCommit: null,
225 };
226
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 +
240 let registeredRenderersCount = 0;
241
242 // $FlowFixMe[incompatible-type] the facade provides a minimal subset of DevToolsHook
@@ -116,23 +258,7 @@ export function installFacade(target?: any = globalThis): Facade {
258 inject(renderer: any): number {
259 const id = registeredRenderersCount++;
260 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);
262 return id;
263 },
264 on() {},
@@ -148,23 +274,13 @@ export function installFacade(target?: any = globalThis): Facade {
274 root: any,
275 schedulerPriority?: number,
276 ) {
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 + );
284 },
285 onCommitFiberUnmount() {},
286 onPostCommitFiberRoot(rendererID: number, root: any) {
packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js
+103 -5
@@ -72,11 +72,53 @@ describe('react-devtools-facade', () => {
72 expect(globalThis.__REACT_LLM_TOOLS__).toBeUndefined();
73 });
74
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 + });
122 });
123
124 it('installs onto an explicit target without touching globalThis', () => {
@@ -2044,4 +2086,60 @@ describe('react-devtools-facade', () => {
2086 expect(names1).not.toContain('CounterA');
2087 });
2088 });
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 + });
2145 });