[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');