@samitouri / QOS-React / commits / 113543851a

[react-devtools-cdt-mcp] Make registration idempotent (#36971)

Adds support for the scenario, when the package is imported multiple times for the same target. This will return the same (referentially) facade object with tools, and will only clean up if all invocations were cleaned up.

Ruslan Lesiutin committed Jul 16, 2026 at 17:56 UTC 113543851aa080b240368ffdabde5275fbbb95d1
2 files changed +111 -2
packages/react-devtools-cdt-mcp/src/DevToolsCdtMcp.js
+27 -2
@@ -307,6 +307,17 @@ export type CdtMcpToolGroup = {
307 tools: Array<CdtMcpTool>,
308 };
309
310 +type Registration = {
311 + facade: Facade,
312 + unregister: () => void,
313 +};
314 +
315 +type ToolDiscoveryEvent = {
316 + respondWith: (toolGroup: CdtMcpToolGroup) => void,
317 +};
318 +
319 +const registrations: WeakMap<any, Registration> = new WeakMap();
320 +
321 /**
322 * Build the chrome-devtools-mcp tool group from an assembled set of facade
323 * tools. Each tool returns its facade result directly.
@@ -350,10 +361,15 @@ export function register(target?: any = globalThis): {
361 facade: Facade,
362 unregister: () => void,
363 } {
364 + const existingRegistration: Registration | void = registrations.get(target);
365 + if (existingRegistration !== undefined) {
366 + return existingRegistration;
367 + }
368 +
369 const facade = installFacade(target);
370
371 let toolGroup: CdtMcpToolGroup | null = null;
356 - const listener = (event: any) => {
372 + const listener = (event: ToolDiscoveryEvent) => {
373 if (toolGroup === null) {
374 toolGroup = buildToolGroup(createTools(facade));
375 }
@@ -361,10 +377,19 @@ export function register(target?: any = globalThis): {
377 };
378 target.addEventListener('devtoolstooldiscovery', listener);
379
364 - return {
380 + let isRegistered = true;
381 + const registration: Registration = {
382 facade,
383 unregister: () => {
384 + if (!isRegistered) {
385 + return;
386 + }
387 + isRegistered = false;
388 target.removeEventListener('devtoolstooldiscovery', listener);
389 + registrations.delete(target);
390 },
391 };
392 +
393 + registrations.set(target, registration);
394 + return registration;
395 }
packages/react-devtools-cdt-mcp/src/__tests__/DevToolsCdtMcp-test.js
+84
@@ -118,6 +118,90 @@ describe('react-devtools-cdt-mcp', () => {
118 }
119 });
120
121 + it('returns the cached registration for repeated calls per target', () => {
122 + let listener = null;
123 + const target = {
124 + addEventListener: jest.fn((type, callback) => {
125 + expect(type).toBe('devtoolstooldiscovery');
126 + listener = callback;
127 + }),
128 + removeEventListener: jest.fn(),
129 + };
130 +
131 + const first = register(target);
132 + const second = register(target);
133 +
134 + expect(target.addEventListener).toHaveBeenCalledTimes(1);
135 + expect(second).toBe(first);
136 + expect(second.facade).toBe(first.facade);
137 +
138 + let firstGroup = null;
139 + let secondGroup = null;
140 + listener({
141 + respondWith: group => {
142 + firstGroup = group;
143 + },
144 + });
145 + listener({
146 + respondWith: group => {
147 + secondGroup = group;
148 + },
149 + });
150 + expect(secondGroup).toBe(firstGroup);
151 +
152 + first.unregister();
153 + expect(target.removeEventListener).toHaveBeenCalledTimes(1);
154 + expect(target.removeEventListener).toHaveBeenCalledWith(
155 + 'devtoolstooldiscovery',
156 + listener,
157 + );
158 +
159 + second.unregister();
160 + expect(target.removeEventListener).toHaveBeenCalledTimes(1);
161 +
162 + const third = register(target);
163 + expect(third).not.toBe(first);
164 + expect(target.addEventListener).toHaveBeenCalledTimes(2);
165 + third.unregister();
166 + });
167 +
168 + it('does not write registration state to the target', () => {
169 + let listener = null;
170 + const existingHook = {
171 + inject: jest.fn(() => 0),
172 + onCommitFiberRoot: jest.fn(),
173 + onPostCommitFiberRoot: jest.fn(),
174 + renderers: new Map(),
175 + };
176 + const target = Object.preventExtensions({
177 + __REACT_DEVTOOLS_GLOBAL_HOOK__: existingHook,
178 + addEventListener: jest.fn((type, callback) => {
179 + expect(type).toBe('devtoolstooldiscovery');
180 + listener = callback;
181 + }),
182 + removeEventListener: jest.fn(),
183 + });
184 +
185 + const first = register(target);
186 + const second = register(target);
187 +
188 + expect(target.addEventListener).toHaveBeenCalledTimes(1);
189 + expect(second).toBe(first);
190 + expect(second.facade).toBe(first.facade);
191 + expect(Object.keys(target).sort()).toEqual([
192 + '__REACT_DEVTOOLS_GLOBAL_HOOK__',
193 + 'addEventListener',
194 + 'removeEventListener',
195 + ]);
196 +
197 + first.unregister();
198 + second.unregister();
199 + expect(target.removeEventListener).toHaveBeenCalledWith(
200 + 'devtoolstooldiscovery',
201 + listener,
202 + );
203 + });
204 +
205 it('builds a "react" tool group exposing every facade tool', () => {
206 expect(toolGroup.name).toBe('react');
207 expect(typeof toolGroup.description).toBe('string');