@samitouri / QOS-React / commits / fc08438abd

[DevTools] Harden Bridge and Wall lifecycle types (#37049)

Builds on #37048 by replacing `any`-based Bridge and Wall boundaries with typed `mixed` values and explicit runtime validation. Invalid messages and post-shutdown operations now throw, while shutdown reliably flushes queued messages even if cleanup fails. Strengthens the DevTools Bridge and Wall contracts: - Models event dictionaries as event-to-payload maps, using `void` for events without payloads. - Types `send(event, payload?)` directly, eliminating runtime payload-arity handling. - Replaces broad `any` transport types with `mixed` and boundary validation. - Throws on invalid lifecycle usage instead of warning or silently returning. - Ensures shutdown flushes queued messages even when Wall cleanup fails. - Updates Wall implementations and adds Bridge lifecycle coverage.

Ruslan Lesiutin committed Jul 23, 2026 at 10:39 UTC fc08438abd8fe11f819f609fefc39f06a279ef5b
18 files changed +321 -171
packages/react-devtools-core/src/backend.js
+10 -6
@@ -163,7 +163,11 @@ export function connectToDevTools(options: ?ConnectOptions) {
163 }
164 };
165 },
166 - send(event: string, payload: any, transferable?: Array<any>) {
166 + send(
167 + event: string,
168 + payload: mixed,
169 + transferable?: $ReadOnlyArray<mixed>,
170 + ) {
171 if (ws.readyState === ws.OPEN) {
172 // $FlowFixMe[constant-condition]
173 if (__DEBUG__) {
@@ -327,9 +331,9 @@ export function connectToDevTools(options: ?ConnectOptions) {
331 }
332
333 type ConnectWithCustomMessagingOptions = {
330 - onSubscribe: (cb: Function) => void,
331 - onUnsubscribe: (cb: Function) => void,
332 - onMessage: (event: string, payload: any) => void,
334 + onSubscribe: (cb: (message: mixed) => void) => void,
335 + onUnsubscribe: (cb: (message: mixed) => void) => void,
336 + onMessage: (event: string, payload: mixed) => void,
337 nativeStyleEditorValidAttributes?: $ReadOnlyArray<string>,
338 resolveRNStyle?: ResolveNativeStyle,
339 onSettingsUpdated?: (settings: $ReadOnly<DevToolsHookSettings>) => void,
@@ -358,14 +362,14 @@ export function connectWithCustomMessagingProtocol({
362 }
363
364 const wall: Wall = {
361 - listen(fn: Function) {
365 + listen(fn: (message: mixed) => void) {
366 onSubscribe(fn);
367
368 return () => {
369 onUnsubscribe(fn);
370 };
371 },
368 - send(event: string, payload: any) {
372 + send(event: string, payload: mixed) {
373 onMessage(event, payload);
374 },
375 };
packages/react-devtools-core/src/standalone.js
+2 -2
@@ -209,7 +209,7 @@ function onError({code, message}: $FlowFixMe) {
209
210 function openProfiler() {
211 // Mocked up bridge and store to allow the DevTools to be rendered
212 - bridge = new Bridge({listen: () => {}, send: () => {}});
212 + bridge = new Bridge({listen: () => () => {}, send: () => {}});
213 store = new Store(bridge, {});
214
215 // Ensure the Profiler tab is shown initially.
@@ -260,7 +260,7 @@ function initialize(socket: WebSocket) {
260 }
261 };
262 },
263 - send(event: string, payload: any, transferable?: Array<any>) {
263 + send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
264 if (socket.readyState === socket.OPEN) {
265 socket.send(JSON.stringify({event, payload}));
266 }
packages/react-devtools-extensions/src/background/index.js
+2 -2
@@ -163,7 +163,7 @@ function connectExtensionAndProxyPorts(
163 );
164 }
165
166 - function extensionPortMessageListener(message: any) {
166 + function extensionPortMessageListener(message: mixed) {
167 try {
168 proxyPort.postMessage(message);
169 } catch (e) {
@@ -175,7 +175,7 @@ function connectExtensionAndProxyPorts(
175 }
176 }
177
178 - function proxyPortMessageListener(message: any) {
178 + function proxyPortMessageListener(message: mixed) {
179 try {
180 extensionPort.postMessage(message);
181 } catch (e) {
packages/react-devtools-extensions/src/contentScripts/backendManager.js
+1 -1
@@ -133,7 +133,7 @@ function activateBackend(version: string, hook: DevToolsHook) {
133 window.removeEventListener('message', listener);
134 };
135 },
136 - send(event: string, payload: any, transferable?: Array<any>) {
136 + send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
137 window.postMessage(
138 {
139 source: 'react-devtools-bridge',
packages/react-devtools-extensions/src/contentScripts/proxy.js
+1 -1
@@ -71,7 +71,7 @@ function sayHelloToBackendManager() {
71 );
72 }
73
74 -function handleMessageFromDevtools(message: any) {
74 +function handleMessageFromDevtools(message: mixed) {
75 window.postMessage(
76 {
77 source: 'react-devtools-content-script',
packages/react-devtools-extensions/src/main/index.js
+3 -3
@@ -2,7 +2,7 @@
2 /** @flow */
3
4 import type {RootType} from 'react-dom/src/client/ReactDOMRoot';
5 -import type {FrontendBridge, Message} from 'react-devtools-shared/src/bridge';
5 +import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
6 import type {
7 TabID,
8 ViewElementSource,
@@ -59,7 +59,7 @@ const hookNamesModuleLoaderFunction = () => resolvedParseHookNames;
59 function createBridge() {
60 bridge = new Bridge({
61 listen(fn) {
62 - const bridgeListener = (message: Message) => fn(message);
62 + const bridgeListener = (message: mixed) => fn(message);
63 // Store the reference so that we unsubscribe from the same object.
64 const portOnMessage = port.onMessage;
65 portOnMessage.addListener(bridgeListener);
@@ -72,7 +72,7 @@ function createBridge() {
72 };
73 },
74
75 - send(event: string, payload: any, transferable?: Array<any>) {
75 + send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
76 port?.postMessage({event, payload}, transferable);
77 },
78 });
packages/react-devtools-fusebox/src/frontend.d.ts
+3 -3
@@ -14,14 +14,14 @@ export type MessagePayload =
14 | MessagePayload[];
15 export type Message = {event: string; payload?: MessagePayload};
16
17 -export type WallListener = (message: Message) => void;
17 +export type WallListener = (message: unknown) => void;
18 export type Wall = {
19 - listen: (fn: WallListener) => Function;
19 + listen: (fn: WallListener) => () => void;
20 send: (event: string, payload?: MessagePayload) => void;
21 };
22
23 export type Bridge = {
24 - addListener(event: string, listener: (params: unknown) => any): void;
24 + addListener(event: string, listener: (params: unknown) => unknown): void;
25 removeListener(event: string, listener: Function): void;
26 shutdown: () => void;
27 };
packages/react-devtools-fusebox/src/frontend.js
+1 -1
@@ -32,7 +32,7 @@ export function createBridge(wall?: Wall): FrontendBridge {
32 return new Bridge(wall);
33 }
34
35 - return new Bridge({listen: () => {}, send: () => {}});
35 + return new Bridge({listen: () => () => {}, send: () => {}});
36 }
37
38 export function createStore(bridge: FrontendBridge, config?: Config): Store {
packages/react-devtools-inline/src/backend.js
+5 -1
@@ -103,7 +103,11 @@ export function createBridge(contentWindow: any, wall?: Wall): BackendBridge {
103 contentWindow.removeEventListener('message', onMessage);
104 };
105 },
106 - send(event: string, payload: any, transferable?: Array<any>) {
106 + send(
107 + event: string,
108 + payload: mixed,
109 + transferable?: $ReadOnlyArray<mixed>,
110 + ) {
111 parent.postMessage({event, payload}, '*', transferable);
112 },
113 };
packages/react-devtools-inline/src/frontend.js
+5 -1
@@ -34,7 +34,11 @@ export function createBridge(contentWindow: any, wall?: Wall): FrontendBridge {
34 window.removeEventListener('message', onMessage);
35 };
36 },
37 - send(event: string, payload: any, transferable?: Array<any>) {
37 + send(
38 + event: string,
39 + payload: mixed,
40 + transferable?: $ReadOnlyArray<mixed>,
41 + ) {
42 contentWindow.postMessage({event, payload}, '*', transferable);
43 },
44 };
packages/react-devtools-shared/src/__tests__/bridge-test.js
+77 -5
@@ -40,14 +40,86 @@ describe('Bridge', () => {
40 expect(wall.send).toHaveBeenCalledWith('shutdown', undefined);
41 expect(shutdownCallback).toHaveBeenCalledTimes(1);
42
43 - // Verify that the Bridge doesn't send messages after shutdown.
44 - jest.spyOn(console, 'warn').mockImplementation(() => {});
43 + // Using a Bridge after shutdown is a lifecycle error.
44 wall.send.mockClear();
46 - bridge.send('should not send');
45 + expect(() => bridge.send('should not send')).toThrow(
46 + 'Cannot send a message through a Bridge that has been shut down.',
47 + );
48 + expect(() => bridge.addListener('event', () => {})).toThrow(
49 + 'Cannot add a listener through a Bridge that has been shut down.',
50 + );
51 + expect(() => bridge.emit('event')).toThrow(
52 + 'Cannot emit an event through a Bridge that has been shut down.',
53 + );
54 + expect(() => bridge.shutdown()).toThrow(
55 + 'Cannot shut down through a Bridge that has been shut down.',
56 + );
57 jest.runAllTimers();
58 expect(wall.send).not.toHaveBeenCalled();
49 - expect(console.warn).toHaveBeenCalledWith(
50 - 'Cannot send message "should not send" through a Bridge that has been shutdown.',
59 + });
60 +
61 + // @reactVersion >=16.0
62 + it('validates messages received from the wall', () => {
63 + let wallListener: ((message: mixed) => void) | null = null;
64 + const wall = {
65 + listen: jest.fn(listener => {
66 + wallListener = listener;
67 + return () => {};
68 + }),
69 + send: jest.fn(),
70 + };
71 + const bridge = new Bridge(wall);
72 + const listener = jest.fn();
73 + bridge.addListener('event', listener);
74 +
75 + const dispatch = (message: mixed) => {
76 + if (wallListener === null) {
77 + throw new Error('Expected the Bridge to subscribe to the wall.');
78 + }
79 + wallListener(message);
80 + };
81 +
82 + // Walls may share their transport with unrelated or legacy messages.
83 + dispatch(null);
84 + dispatch({type: 'event'});
85 + expect(listener).not.toHaveBeenCalled();
86 +
87 + expect(() => dispatch({event: 123})).toThrow(
88 + 'Bridge event names must be non-empty strings.',
89 );
90 + expect(() => dispatch({event: ''})).toThrow(
91 + 'Bridge event names must be non-empty strings.',
92 + );
93 +
94 + dispatch({event: 'event', payload: 123});
95 + expect(listener).toHaveBeenCalledWith(123);
96 + });
97 +
98 + // @reactVersion >=16.0
99 + it('requires Wall.listen to return a cleanup function', () => {
100 + expect(
101 + () =>
102 + new Bridge({
103 + listen: () => undefined,
104 + send: jest.fn(),
105 + }),
106 + ).toThrow('Wall.listen() must return an unlisten function.');
107 + });
108 +
109 + // @reactVersion >=16.0
110 + it('flushes pending messages when wall cleanup throws', () => {
111 + const expectedError = new Error('Failed to unsubscribe');
112 + const wall = {
113 + listen: jest.fn(() => () => {
114 + throw expectedError;
115 + }),
116 + send: jest.fn(),
117 + };
118 + const bridge = new Bridge(wall);
119 +
120 + bridge.send('update', 'value');
121 + expect(() => bridge.shutdown()).toThrow(expectedError);
122 + expect(wall.send).toHaveBeenCalledWith('update', 'value');
123 + expect(wall.send).toHaveBeenCalledWith('shutdown', undefined);
124 });
125 });
packages/react-devtools-shared/src/__tests__/setupTests.js
+1 -1
@@ -258,7 +258,7 @@ beforeEach(() => {
258 }
259 };
260 },
261 - send(event: string, payload: any, transferable?: Array<any>) {
261 + send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
262 bridgeListeners.forEach(callback => callback({event, payload}));
263 },
264 });
packages/react-devtools-shared/src/bridge.js
+176 -128
@@ -9,7 +9,7 @@
9
10 import EventEmitter from './events';
11
12 -import type {ComponentFilter, Wall} from './frontend/types';
12 +import type {ComponentFilter, Wall, WallMessage} from './frontend/types';
13 import type {
14 InspectedElementPayload,
15 OwnersList,
@@ -74,9 +74,17 @@ export const currentBridgeProtocol: BridgeProtocol =
74
75 type ElementAndRendererID = {id: number, rendererID: RendererID};
76
77 -export type Message = {
77 +export type Message = WallMessage;
78 +
79 +type QueuedMessage = {
80 event: string,
79 - payload: any,
81 + payload: mixed,
82 +};
83 +
84 +type EventArguments<Payload> = Payload extends void ? [] : [Payload];
85 +
86 +type EventEmitterEvents<Events: Object> = {
87 + [Event in keyof Events]: EventArguments<Events[Event]>,
88 };
89
90 type HighlightHostInstance = {
@@ -101,7 +109,7 @@ type OverrideValue = {
109 ...ElementAndRendererID,
110 path: Array<string | number>,
111 wasForwarded?: boolean,
104 - value: any,
112 + value: mixed,
113 };
114
115 type OverrideHookState = {
@@ -131,7 +139,7 @@ type OverrideValueAtPath = {
139 type: PathType,
140 hookID?: ?number,
141 path: Array<string | number>,
134 - value: any,
142 + value: mixed,
143 };
144
145 type OverrideError = {
@@ -197,95 +205,96 @@ export type SavedPreferencesParams = {
205 };
206
207 export type BackendEvents = {
200 - backendInitialized: [],
201 - backendVersion: [string],
202 - bridgeProtocol: [BridgeProtocol],
203 - extensionBackendInitialized: [],
204 - fastRefreshScheduled: [],
205 - getSavedPreferences: [],
206 - inspectedElement: [InspectedElementPayload],
207 - inspectedScreen: [InspectedElementPayload],
208 - isReloadAndProfileSupportedByBackend: [boolean],
209 - operations: [Array<number>],
210 - ownersList: [OwnersList],
211 - environmentNames: [Array<string>],
212 - profilingData: [ProfilingDataBackend],
213 - profilingStatus: [boolean],
214 - reloadAppForProfiling: [],
215 - saveToClipboard: [string],
216 - selectElement: [number | null],
217 - shutdown: [],
218 - stopInspectingHost: [boolean],
219 - scrollTo: [{left: number, top: number, right: number, bottom: number}],
220 - syncSelectionToBuiltinElementsPanel: [],
221 - unsupportedRendererVersion: [],
222 -
223 - extensionComponentsPanelShown: [],
224 - extensionComponentsPanelHidden: [],
225 -
226 - resumeElementPolling: [],
227 - pauseElementPolling: [],
208 + backendInitialized: void,
209 + backendVersion: string,
210 + bridgeProtocol: BridgeProtocol,
211 + extensionBackendInitialized: void,
212 + fastRefreshScheduled: void,
213 + getSavedPreferences: void,
214 + inspectedElement: InspectedElementPayload,
215 + inspectedScreen: InspectedElementPayload,
216 + isReloadAndProfileSupportedByBackend: boolean,
217 + operations: Array<number>,
218 + ownersList: OwnersList,
219 + environmentNames: Array<string>,
220 + profilingData: ProfilingDataBackend,
221 + profilingStatus: boolean,
222 + reloadAppForProfiling: void,
223 + saveToClipboard: string,
224 + selectElement: number | null,
225 + shutdown: void,
226 + stopInspectingHost: boolean,
227 + scrollTo: {left: number, top: number, right: number, bottom: number},
228 + syncSelectionToBuiltinElementsPanel: void,
229 + unsupportedRendererVersion: void,
230 +
231 + extensionComponentsPanelShown: void,
232 + extensionComponentsPanelHidden: void,
233 +
234 + resumeElementPolling: void,
235 + pauseElementPolling: void,
236
237 // React Native style editor plug-in.
230 - isNativeStyleEditorSupported: [
231 - {isSupported: boolean, validAttributes: ?$ReadOnlyArray<string>},
232 - ],
233 - NativeStyleEditor_styleAndLayout: [StyleAndLayoutPayload],
238 + isNativeStyleEditorSupported: {
239 + isSupported: boolean,
240 + validAttributes: ?$ReadOnlyArray<string>,
241 + },
242 + NativeStyleEditor_styleAndLayout: StyleAndLayoutPayload,
243
235 - hookSettings: [$ReadOnly<DevToolsHookSettings>],
244 + hookSettings: $ReadOnly<DevToolsHookSettings>,
245 };
246
247 type StartProfilingParams = ProfilingSettings;
248 type ReloadAndProfilingParams = ProfilingSettings;
249
250 export type FrontendEvents = {
242 - clearErrorsAndWarnings: [{rendererID: RendererID}],
243 - clearErrorsForElementID: [ElementAndRendererID],
244 - clearHostInstanceHighlight: [],
245 - clearWarningsForElementID: [ElementAndRendererID],
246 - copyElementPath: [CopyElementPathParams],
247 - deletePath: [DeletePath],
248 - getBackendVersion: [],
249 - getBridgeProtocol: [],
250 - getIfHasUnsupportedRendererVersion: [],
251 - getOwnersList: [ElementAndRendererID],
252 - getProfilingData: [{rendererID: RendererID}],
253 - getProfilingStatus: [],
254 - highlightHostInstance: [HighlightHostInstance],
255 - highlightHostInstances: [HighlightHostInstances],
256 - inspectElement: [InspectElementParams],
257 - inspectScreen: [InspectScreenParams],
258 - logElementToConsole: [ElementAndRendererID],
259 - overrideError: [OverrideError],
260 - overrideSuspense: [OverrideSuspense],
261 - overrideSuspenseMilestone: [OverrideSuspenseMilestone],
262 - overrideValueAtPath: [OverrideValueAtPath],
263 - profilingData: [ProfilingDataBackend],
264 - reloadAndProfile: [ReloadAndProfilingParams],
265 - renamePath: [RenamePath],
266 - savedPreferences: [SavedPreferencesParams],
267 - setTraceUpdatesEnabled: [boolean],
268 - shutdown: [],
269 - startInspectingHost: [boolean],
270 - startProfiling: [StartProfilingParams],
271 - stopInspectingHost: [],
272 - scrollToHostInstance: [ScrollToHostInstance],
273 - scrollTo: [{left: number, top: number, right: number, bottom: number}],
274 - requestScrollPosition: [],
275 - stopProfiling: [],
276 - storeAsGlobal: [StoreAsGlobalParams],
277 - updateComponentFilters: [Array<ComponentFilter>],
278 - getEnvironmentNames: [],
279 - updateHookSettings: [$ReadOnly<DevToolsHookSettings>],
280 - viewAttributeSource: [ViewAttributeSourceParams],
281 - viewElementSource: [ElementAndRendererID],
282 -
283 - syncSelectionFromBuiltinElementsPanel: [],
251 + clearErrorsAndWarnings: {rendererID: RendererID},
252 + clearErrorsForElementID: ElementAndRendererID,
253 + clearHostInstanceHighlight: void,
254 + clearWarningsForElementID: ElementAndRendererID,
255 + copyElementPath: CopyElementPathParams,
256 + deletePath: DeletePath,
257 + getBackendVersion: void,
258 + getBridgeProtocol: void,
259 + getIfHasUnsupportedRendererVersion: void,
260 + getOwnersList: ElementAndRendererID,
261 + getProfilingData: {rendererID: RendererID},
262 + getProfilingStatus: void,
263 + highlightHostInstance: HighlightHostInstance,
264 + highlightHostInstances: HighlightHostInstances,
265 + inspectElement: InspectElementParams,
266 + inspectScreen: InspectScreenParams,
267 + logElementToConsole: ElementAndRendererID,
268 + overrideError: OverrideError,
269 + overrideSuspense: OverrideSuspense,
270 + overrideSuspenseMilestone: OverrideSuspenseMilestone,
271 + overrideValueAtPath: OverrideValueAtPath,
272 + profilingData: ProfilingDataBackend,
273 + reloadAndProfile: ReloadAndProfilingParams,
274 + renamePath: RenamePath,
275 + savedPreferences: SavedPreferencesParams,
276 + setTraceUpdatesEnabled: boolean,
277 + shutdown: void,
278 + startInspectingHost: boolean,
279 + startProfiling: StartProfilingParams,
280 + stopInspectingHost: void,
281 + scrollToHostInstance: ScrollToHostInstance,
282 + scrollTo: {left: number, top: number, right: number, bottom: number},
283 + requestScrollPosition: void,
284 + stopProfiling: void,
285 + storeAsGlobal: StoreAsGlobalParams,
286 + updateComponentFilters: Array<ComponentFilter>,
287 + getEnvironmentNames: void,
288 + updateHookSettings: $ReadOnly<DevToolsHookSettings>,
289 + viewAttributeSource: ViewAttributeSourceParams,
290 + viewElementSource: ElementAndRendererID,
291 +
292 + syncSelectionFromBuiltinElementsPanel: void,
293
294 // React Native style editor plug-in.
286 - NativeStyleEditor_measure: [ElementAndRendererID],
287 - NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams],
288 - NativeStyleEditor_setValue: [NativeStyleEditor_SetValueParams],
295 + NativeStyleEditor_measure: ElementAndRendererID,
296 + NativeStyleEditor_renameAttribute: NativeStyleEditor_RenameAttributeParams,
297 + NativeStyleEditor_setValue: NativeStyleEditor_SetValueParams,
298
299 // Temporarily support newer standalone front-ends sending commands to older embedded backends.
300 // We do this because React Native embeds the React DevTools backend,
@@ -297,35 +306,34 @@ export type FrontendEvents = {
306 // Note that this approach does no support the combination of a newer backend with an older frontend.
307 // It would be more work to support both approaches (and not run handlers twice)
308 // so I chose to support the more likely/common scenario (and the one more difficult for an end user to "fix").
300 - overrideContext: [OverrideValue],
301 - overrideHookState: [OverrideHookState],
302 - overrideProps: [OverrideValue],
303 - overrideState: [OverrideValue],
309 + overrideContext: OverrideValue,
310 + overrideHookState: OverrideHookState,
311 + overrideProps: OverrideValue,
312 + overrideState: OverrideValue,
313
305 - getHookSettings: [],
314 + getHookSettings: void,
315 };
316
317 class Bridge<
318 OutgoingEvents: Object,
319 IncomingEvents: Object,
311 -> extends EventEmitter<IncomingEvents> {
320 +> extends EventEmitter<EventEmitterEvents<IncomingEvents>> {
321 _isShutdown: boolean = false;
313 - _messageQueue: Array<any> = [];
322 + _messageQueue: Array<QueuedMessage> = [];
323 _scheduledFlush: boolean = false;
324 _wall: Wall;
316 - _wallUnlisten: Function | null = null;
325 + _wallUnlisten: (() => void) | null = null;
326
327 constructor(wall: Wall) {
328 super();
329
330 this._wall = wall;
331
323 - this._wallUnlisten =
324 - wall.listen((message: Message) => {
325 - if (message && message.event) {
326 - (this as any).emit(message.event, message.payload);
327 - }
328 - }) || null;
332 + const wallUnlisten = wall.listen(this._handleMessage);
333 + if (typeof wallUnlisten !== 'function') {
334 + throw new TypeError('Wall.listen() must return an unlisten function.');
335 + }
336 + this._wallUnlisten = wallUnlisten;
337
338 // Temporarily support older standalone front-ends sending commands to newer embedded backends.
339 // We do this because React Native embeds the React DevTools backend,
@@ -339,15 +347,30 @@ class Bridge<
347 return this._wall;
348 }
349
350 + addListener<Event: $Keys<EventEmitterEvents<IncomingEvents>>>(
351 + event: Event,
352 + listener: (...EventEmitterEvents<IncomingEvents>[Event]) => mixed,
353 + ): void {
354 + this._assertNotShutdown('add a listener');
355 + super.addListener(event, listener);
356 + }
357 +
358 + emit<Event: $Keys<EventEmitterEvents<IncomingEvents>>>(
359 + event: Event,
360 + ...args: EventEmitterEvents<IncomingEvents>[Event]
361 + ): void {
362 + this._assertNotShutdown('emit an event');
363 + super.emit(event, ...args);
364 + }
365 +
366 send<EventName: $Keys<OutgoingEvents>>(
367 event: EventName,
344 - ...payload: OutgoingEvents[EventName]
345 - ) {
346 - if (this._isShutdown) {
347 - console.warn(
348 - `Cannot send message "${event}" through a Bridge that has been shutdown.`,
349 - );
350 - return;
368 + payload?: OutgoingEvents[EventName],
369 + ): void {
370 + this._assertNotShutdown('send a message');
371 +
372 + if (typeof event !== 'string' || event.length === 0) {
373 + throw new TypeError('Bridge event names must be non-empty strings.');
374 }
375
376 // When we receive a message:
@@ -358,7 +381,10 @@ class Bridge<
381 // - if there *has* been a message flushed in the last BATCH_DURATION ms
382 // (or we're waiting for our setTimeout-0 to fire), then _timeoutID will
383 // be set, and we'll simply add to the queue and wait for that
361 - this._messageQueue.push(event, payload);
384 + this._messageQueue.push({
385 + event,
386 + payload,
387 + });
388 if (!this._scheduledFlush) {
389 this._scheduledFlush = true;
390 // $FlowFixMe[cannot-resolve-name]
@@ -375,11 +401,8 @@ class Bridge<
401 }
402 }
403
378 - shutdown() {
379 - if (this._isShutdown) {
380 - console.warn('Bridge was already shutdown.');
381 - return;
382 - }
404 + shutdown(): void {
405 + this._assertNotShutdown('shut down');
406
407 // Queue the shutdown outgoing message for subscribers.
408 this.emit('shutdown');
@@ -388,27 +411,23 @@ class Bridge<
411 // Mark this bridge as destroyed, i.e. disable its public API.
412 this._isShutdown = true;
413
391 - // Disable the API inherited from EventEmitter that can add more listeners and send more messages.
392 - // $FlowFixMe[cannot-write] This property is not writable.
393 - this.addListener = function () {};
394 - // $FlowFixMe[cannot-write] This property is not writable.
395 - this.emit = function () {};
396 - // NOTE: There's also EventEmitter API like `on` and `prependListener` that we didn't add to our Flow type of EventEmitter.
397 -
414 // Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that.
415 this.removeAllListeners();
416
417 // Stop accepting and emitting incoming messages from the wall.
418 const wallUnlisten = this._wallUnlisten;
403 - if (wallUnlisten) {
404 - wallUnlisten();
419 + this._wallUnlisten = null;
420 + try {
421 + if (wallUnlisten !== null) {
422 + wallUnlisten();
423 + }
424 + } finally {
425 + // Synchronously flush all queued outgoing messages.
426 + // At this step the subscribers' code may run in this call stack.
427 + do {
428 + this._flush();
429 + } while (this._messageQueue.length);
430 }
406 -
407 - // Synchronously flush all queued outgoing messages.
408 - // At this step the subscribers' code may run in this call stack.
409 - do {
410 - this._flush();
411 - } while (this._messageQueue.length);
431 }
432
433 _flush: () => void = () => {
@@ -417,9 +436,9 @@ class Bridge<
436 // It is a private method that the bridge ensures is only called at the right times.
437 try {
438 if (this._messageQueue.length) {
420 - for (let i = 0; i < this._messageQueue.length; i += 2) {
421 - // This only supports one argument in practice but the types suggests it should support multiple.
422 - this._wall.send(this._messageQueue[i], this._messageQueue[i + 1][0]);
439 + for (let i = 0; i < this._messageQueue.length; i++) {
440 + const {event, payload} = this._messageQueue[i];
441 + this._wall.send(event, payload);
442 }
443 this._messageQueue.length = 0;
444 }
@@ -430,6 +449,35 @@ class Bridge<
449 }
450 };
451
452 + _assertNotShutdown(action: string): void {
453 + if (this._isShutdown) {
454 + throw new Error(
455 + `Cannot ${action} through a Bridge that has been shut down.`,
456 + );
457 + }
458 + }
459 +
460 + _handleMessage: (message: mixed) => void = message => {
461 + // Some Walls share a transport with unrelated messages or legacy DevTools
462 + // protocols. A message without an event field does not belong to this Bridge.
463 + if (
464 + message === null ||
465 + typeof message !== 'object' ||
466 + !('event' in message)
467 + ) {
468 + return;
469 + }
470 +
471 + const event = message.event;
472 + if (typeof event !== 'string' || event.length === 0) {
473 + throw new TypeError('Bridge event names must be non-empty strings.');
474 + }
475 +
476 + this._assertNotShutdown('receive a message');
477 + // The wire event name cannot be statically refined to a key of IncomingEvents.
478 + (this as any).emit(event, message.payload);
479 + };
480 +
481 // Temporarily support older standalone backends by forwarding "overrideValueAtPath" commands
482 // to the older message types they may be listening to.
483 overrideValueAtPath: OverrideValueAtPath => void = ({
packages/react-devtools-shared/src/devtools/views/DevTools.js
+2 -6
@@ -275,12 +275,8 @@ export default function DevTools({
275
276 useLayoutEffect(() => {
277 return () => {
278 - try {
279 - // Shut the Bridge down synchronously (during unmount).
280 - bridge.shutdown();
281 - } catch (error) {
282 - // Attempting to use a disconnected port.
283 - }
278 + // Shut the Bridge down synchronously (during unmount).
279 + bridge.shutdown();
280 };
281 }, [bridge]);
282
packages/react-devtools-shared/src/devtools/views/WarnIfLegacyBackendDetected.js
+9 -5
@@ -22,8 +22,12 @@ export default function WarnIfLegacyBackendDetected(_: {}): null {
22 // We do this by listening to a message that it broadcasts but the v4 backend doesn't.
23 // In this case the frontend should show upgrade instructions.
24 useEffect(() => {
25 - // Wall.listen returns a cleanup function
26 - let unlisten: $FlowFixMe = bridge.wall.listen(message => {
25 + let unlisten: (() => void) | null = null;
26 + unlisten = bridge.wall.listen(message => {
27 + if (message === null || typeof message !== 'object') {
28 + return;
29 + }
30 +
31 switch (message.type) {
32 case 'call':
33 case 'event':
@@ -38,7 +42,7 @@ export default function WarnIfLegacyBackendDetected(_: {}): null {
42 });
43
44 // Once we've identified the backend version, it's safe to unsubscribe.
41 - if (typeof unlisten === 'function') {
45 + if (unlisten !== null) {
46 unlisten();
47 unlisten = null;
48 }
@@ -54,7 +58,7 @@ export default function WarnIfLegacyBackendDetected(_: {}): null {
58 case 'overrideComponentFilters':
59 // Any of these is sufficient to indicate a v4 backend.
60 // Once we've identified the backend version, it's safe to unsubscribe.
57 - if (typeof unlisten === 'function') {
61 + if (unlisten !== null) {
62 unlisten();
63 unlisten = null;
64 }
@@ -65,7 +69,7 @@ export default function WarnIfLegacyBackendDetected(_: {}): null {
69 });
70
71 return () => {
68 - if (typeof unlisten === 'function') {
72 + if (unlisten !== null) {
73 unlisten();
74 unlisten = null;
75 }
packages/react-devtools-shared/src/frontend/types.js
+13 -3
@@ -23,10 +23,20 @@ import type {UnknownSuspendersReason} from '../constants';
23
24 export type BrowserTheme = 'dark' | 'light';
25
26 +export type WallMessage = {
27 + event: string,
28 + payload?: mixed,
29 +};
30 +
31 export type Wall = {
27 - // `listen` returns the "unlisten" function.
28 - listen: (fn: Function) => Function,
29 - send: (event: string, payload: any, transferable?: Array<any>) => void,
32 + // A Wall may share its transport with unrelated or legacy messages, so the
33 + // Bridge must refine incoming values at the boundary.
34 + listen: (fn: (message: mixed) => void) => () => void,
35 + send: (
36 + event: string,
37 + payload: mixed,
38 + transferable?: $ReadOnlyArray<mixed>,
39 + ) => void,
40 };
41
42 // WARNING
packages/react-devtools-shell/src/multi/devtools.js
+6
@@ -43,6 +43,12 @@ function init(appIframe, devtoolsContainer, appSource) {
43 }
44
45 wall._listeners.push(listener);
46 + return () => {
47 + const index = wall._listeners.indexOf(listener);
48 + if (index !== -1) {
49 + wall._listeners.splice(index, 1);
50 + }
51 + };
52 },
53 send(event, payload) {
54 if (__DEBUG__) {
scripts/flow/react-devtools.js
+4 -2
@@ -58,9 +58,11 @@ interface ExtensionRuntimeSender {
58 interface ExtensionRuntimePort {
59 disconnect(): void;
60 name: string;
61 - onMessage: ExtensionEvent<(message: any, port: ExtensionRuntimePort) => void>;
61 + onMessage: ExtensionEvent<
62 + (message: mixed, port: ExtensionRuntimePort) => void,
63 + >;
64 onDisconnect: ExtensionEvent<(port: ExtensionRuntimePort) => void>;
63 - postMessage(message: mixed, transferable?: Array<mixed>): void;
65 + postMessage(message: mixed, transferable?: $ReadOnlyArray<mixed>): void;
66 sender?: ExtensionRuntimeSender;
67 }
68