@samitouri / QOS-React-2 / commits / 35a06228b3

[DevTools] Deduplicate error reporting (#37137)

Previously, every instance of ErrorBoundary, which wraps every custom panel in extension, was subscribing to errors from the Store. This would report the same error for every mounted panel. ErrorBoundary now only intercepts render-time errors, and Store errors are captured and reported in an external subscription at the place where Store is created.

Ruslan Lesiutin committed Aug 3, 2026 at 16:32 UTC 35a06228b34428a14673d203bc9fcf17bc23fb00
9 files changed +249 -23
packages/react-devtools-core/src/standalone.js
+10 -2
@@ -12,6 +12,7 @@ import {flushSync} from 'react-dom';
12 import {createRoot} from 'react-dom/client';
13 import Bridge from 'react-devtools-shared/src/bridge';
14 import Store from 'react-devtools-shared/src/devtools/store';
15 +import {subscribeToStoreErrors} from 'react-devtools-shared/src/devtools/storeErrorLogger';
16 import {getSavedComponentFilters} from 'react-devtools-shared/src/utils';
17 import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger';
18 import {Server} from 'ws';
@@ -209,8 +210,14 @@ function onError({code, message}: $FlowFixMe) {
210
211 function openProfiler() {
212 // Mocked up bridge and store to allow the DevTools to be rendered
212 - bridge = new Bridge({listen: () => () => {}, send: () => {}});
213 - store = new Store(bridge, {});
213 + const profilerBridge: FrontendBridge = new Bridge({
214 + listen: () => () => {},
215 + send: () => {},
216 + });
217 + const profilerStore = new Store(profilerBridge, {});
218 + bridge = profilerBridge;
219 + store = profilerStore;
220 + subscribeToStoreErrors(profilerStore, profilerBridge);
221
222 // Ensure the Profiler tab is shown initially.
223 localStorageSetItem(
@@ -276,6 +283,7 @@ function initialize(socket: WebSocket) {
283 supportsTraceUpdates: true,
284 supportsClickToInspect: true,
285 });
286 + subscribeToStoreErrors(store, bridge as any as FrontendBridge);
287
288 log('Connected');
289 statusListener('DevTools initialized.', 'devtools-connected');
packages/react-devtools-extensions/src/main/index.js
+2
@@ -15,6 +15,7 @@ import {flushSync} from 'react-dom';
15 import {createRoot} from 'react-dom/client';
16 import Bridge from 'react-devtools-shared/src/bridge';
17 import Store from 'react-devtools-shared/src/devtools/store';
18 +import {subscribeToStoreErrors} from 'react-devtools-shared/src/devtools/storeErrorLogger';
19 import {getBrowserTheme} from '../utils';
20 import {
21 localStorageGetItem,
@@ -260,6 +261,7 @@ function createDevToolsInstance(): DevToolsInstance {
261 supportsInspectMatchingDOMElement: true,
262 supportsClickToInspect: true,
263 });
264 + subscribeToStoreErrors(store, bridge);
265
266 store.addListener('settingsUpdated', (hookSettings, componentFilters) => {
267 chrome.storage.local.set({...hookSettings, componentFilters});
packages/react-devtools-fusebox/src/frontend.js
+4 -1
@@ -11,6 +11,7 @@ import * as React from 'react';
11 import {createRoot} from 'react-dom/client';
12 import Bridge from 'react-devtools-shared/src/bridge';
13 import Store from 'react-devtools-shared/src/devtools/store';
14 +import {subscribeToStoreErrors} from 'react-devtools-shared/src/devtools/storeErrorLogger';
15 import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
16
17 import type {
@@ -36,12 +37,14 @@ export function createBridge(wall?: Wall): FrontendBridge {
37 }
38
39 export function createStore(bridge: FrontendBridge, config?: Config): Store {
39 - return new Store(bridge, {
40 + const store = new Store(bridge, {
41 checkBridgeProtocolCompatibility: true,
42 supportsTraceUpdates: true,
43 supportsClickToInspect: true,
44 ...config,
45 });
46 + subscribeToStoreErrors(store, bridge);
47 + return store;
48 }
49
50 type InitializationOptions = {
packages/react-devtools-inline/src/frontend.js
+4 -1
@@ -4,6 +4,7 @@ import * as React from 'react';
4 import {forwardRef} from 'react';
5 import Bridge from 'react-devtools-shared/src/bridge';
6 import Store from 'react-devtools-shared/src/devtools/store';
7 +import {subscribeToStoreErrors} from 'react-devtools-shared/src/devtools/storeErrorLogger';
8 import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
9 import {getSavedComponentFilters} from 'react-devtools-shared/src/utils';
10
@@ -13,12 +14,14 @@ import type {Props} from 'react-devtools-shared/src/devtools/views/DevTools';
14 import type {Config} from 'react-devtools-shared/src/devtools/store';
15
16 export function createStore(bridge: FrontendBridge, config?: Config): Store {
16 - return new Store(bridge, {
17 + const store = new Store(bridge, {
18 checkBridgeProtocolCompatibility: true,
19 supportsTraceUpdates: true,
20 supportsTimeline: true,
21 ...config,
22 });
23 + subscribeToStoreErrors(store, bridge);
24 + return store;
25 }
26
27 export function createBridge(contentWindow: any, wall?: Wall): FrontendBridge {
packages/react-devtools-shared/src/Logger.js
+25
@@ -81,6 +81,31 @@ export const logEvent: LogFunction =
81 }
82 : function logEvent() {};
83
84 +export function logErrorEvent(
85 + error: mixed,
86 + componentStack: string | null,
87 +): void {
88 + const errorMessage =
89 + typeof error === 'object' &&
90 + error !== null &&
91 + typeof error.message === 'string'
92 + ? error.message
93 + : null;
94 + const errorStack =
95 + typeof error === 'object' &&
96 + error !== null &&
97 + typeof error.stack === 'string'
98 + ? error.stack
99 + : null;
100 +
101 + logEvent({
102 + event_name: 'error',
103 + error_message: errorMessage,
104 + error_stack: errorStack,
105 + error_component_stack: componentStack,
106 + });
107 +}
108 +
109 export const registerEventLogger: (logFunction: LogFunction) => () => void =
110 enableLogger === true
111 ? function registerEventLogger(logFunction: LogFunction): () => void {
packages/react-devtools-shared/src/__tests__/errorReporting-test.js new
+164
@@ -0,0 +1,164 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {getVersionedRenderImplementation} from './utils';
11 +
12 +jest.mock('react-devtools-feature-flags', () => ({
13 + ...jest.requireActual('react-devtools-feature-flags'),
14 + enableLogger: true,
15 +}));
16 +
17 +describe('error reporting', () => {
18 + let React;
19 + let act;
20 +
21 + beforeEach(() => {
22 + React = require('react');
23 + act = require('./utils').act;
24 + });
25 +
26 + const {getContainer, render, unmount} = getVersionedRenderImplementation();
27 +
28 + it('reports a Store error before the frontend mounts', () => {
29 + const {registerEventLogger} = require('react-devtools-shared/src/Logger');
30 + const {
31 + subscribeToStoreErrors,
32 + } = require('react-devtools-shared/src/devtools/storeErrorLogger');
33 + const eventLogger = jest.fn();
34 + const unregisterEventLogger = registerEventLogger(eventLogger);
35 + const unsubscribeFromStoreErrors = subscribeToStoreErrors(
36 + global.store,
37 + global.bridge,
38 + );
39 + const error = new Error('Initial render error');
40 +
41 + try {
42 + expect(() => global.store._throwAndEmitError(error)).toThrow(error);
43 + expect(eventLogger).toHaveBeenCalledTimes(1);
44 + expect(eventLogger).toHaveBeenCalledWith({
45 + event_name: 'error',
46 + error_message: error.message,
47 + error_stack: error.stack,
48 + error_component_stack: null,
49 + });
50 + } finally {
51 + unsubscribeFromStoreErrors();
52 + unregisterEventLogger();
53 + }
54 + });
55 +
56 + it('reports a Store error once when multiple error boundaries observe it', () => {
57 + const {registerEventLogger} = require('react-devtools-shared/src/Logger');
58 + const ErrorBoundary =
59 + require('react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary').default;
60 + const {
61 + subscribeToStoreErrors,
62 + } = require('react-devtools-shared/src/devtools/storeErrorLogger');
63 + const store = global.store;
64 + const eventLogger = jest.fn();
65 + const unregisterEventLogger = registerEventLogger(eventLogger);
66 + const unsubscribeFromStoreErrors = subscribeToStoreErrors(
67 + store,
68 + global.bridge,
69 + );
70 +
71 + act(() => {
72 + render(
73 + <>
74 + <ErrorBoundary store={store}>First boundary</ErrorBoundary>
75 + <ErrorBoundary store={store}>Second boundary</ErrorBoundary>
76 + <ErrorBoundary store={store}>Third boundary</ErrorBoundary>
77 + </>,
78 + );
79 + });
80 +
81 + const error = new Error('Store error');
82 + try {
83 + act(() => {
84 + expect(() => store._throwAndEmitError(error)).toThrow(error);
85 + });
86 +
87 + expect(eventLogger).toHaveBeenCalledTimes(1);
88 + expect(eventLogger).toHaveBeenCalledWith({
89 + event_name: 'error',
90 + error_message: error.message,
91 + error_stack: error.stack,
92 + error_component_stack: null,
93 + });
94 + expect(
95 + getContainer().textContent.match(/Uncaught Error: Store error/g),
96 + ).toHaveLength(3);
97 + } finally {
98 + unsubscribeFromStoreErrors();
99 + unregisterEventLogger();
100 + act(() => unmount());
101 + }
102 + });
103 +
104 + it('registers the event logger once while its iframe is loading', () => {
105 + const Logger = require('react-devtools-shared/src/Logger');
106 + const registerEventLogger = jest.spyOn(Logger, 'registerEventLogger');
107 + const loggingURL = 'https://example.com/react-devtools-logging';
108 + const previousLoggingURL = process.env.LOGGING_URL;
109 + process.env.LOGGING_URL = loggingURL;
110 +
111 + try {
112 + const {
113 + registerDevToolsEventLogger,
114 + } = require('react-devtools-shared/src/registerDevToolsEventLogger');
115 +
116 + registerDevToolsEventLogger('test');
117 + registerDevToolsEventLogger('test');
118 +
119 + expect(registerEventLogger).toHaveBeenCalledTimes(1);
120 + expect(
121 + document.querySelectorAll(`iframe[src="${loggingURL}"]`),
122 + ).toHaveLength(1);
123 + } finally {
124 + registerEventLogger.mockRestore();
125 + if (previousLoggingURL === undefined) {
126 + delete process.env.LOGGING_URL;
127 + } else {
128 + process.env.LOGGING_URL = previousLoggingURL;
129 + }
130 + document
131 + .querySelectorAll(`iframe[src="${loggingURL}"]`)
132 + .forEach(iframe => iframe.remove());
133 + }
134 + });
135 +
136 + it('normalizes values that are not Error objects', () => {
137 + const {
138 + logErrorEvent,
139 + registerEventLogger,
140 + } = require('react-devtools-shared/src/Logger');
141 + const eventLogger = jest.fn();
142 + const unregisterEventLogger = registerEventLogger(eventLogger);
143 +
144 + try {
145 + logErrorEvent(null, null);
146 + logErrorEvent({message: 42, stack: {}}, null);
147 +
148 + expect(eventLogger).toHaveBeenNthCalledWith(1, {
149 + event_name: 'error',
150 + error_message: null,
151 + error_stack: null,
152 + error_component_stack: null,
153 + });
154 + expect(eventLogger).toHaveBeenNthCalledWith(2, {
155 + event_name: 'error',
156 + error_message: null,
157 + error_stack: null,
158 + error_component_stack: null,
159 + });
160 + } finally {
161 + unregisterEventLogger();
162 + }
163 + });
164 +});
packages/react-devtools-shared/src/devtools/storeErrorLogger.js new
+33
@@ -0,0 +1,33 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type Store from './store';
11 +import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
12 +
13 +import {logErrorEvent} from 'react-devtools-shared/src/Logger';
14 +
15 +export function subscribeToStoreErrors(
16 + store: Store,
17 + bridge: FrontendBridge,
18 +): () => void {
19 + const onError = (error: Error) => logErrorEvent(error, null);
20 + let isSubscribed = true;
21 +
22 + const unsubscribe = () => {
23 + if (isSubscribed) {
24 + isSubscribed = false;
25 + store.removeListener('error', onError);
26 + bridge.removeListener('shutdown', unsubscribe);
27 + }
28 + };
29 +
30 + store.addListener('error', onError);
31 + bridge.addListener('shutdown', unsubscribe);
32 + return unsubscribe;
33 +}
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js
+4 -18
@@ -20,7 +20,7 @@ import UnsupportedBridgeOperationError from 'react-devtools-shared/src/Unsupport
20 import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError';
21 import UserError from 'react-devtools-shared/src/errors/UserError';
22 import UnknownHookError from 'react-devtools-shared/src/errors/UnknownHookError';
23 -import {logEvent} from 'react-devtools-shared/src/Logger';
23 +import {logErrorEvent} from 'react-devtools-shared/src/Logger';
24
25 type Props = {
26 children: React$Node,
@@ -97,10 +97,9 @@ export default class ErrorBoundary extends Component<Props, State> {
97 }
98
99 componentDidCatch(error: any, {componentStack}: any) {
100 - this._logError(error, componentStack);
101 - this.setState({
102 - componentStack,
103 - });
100 + // This is an error trown during render, not a Store error
101 + logErrorEvent(error, componentStack);
102 + this.setState({componentStack});
103 }
104
105 componentDidMount() {
@@ -206,18 +205,6 @@ export default class ErrorBoundary extends Component<Props, State> {
205 return children;
206 }
207
209 - _logError: (error: any, componentStack: string | null) => void = (
210 - error,
211 - componentStack,
212 - ) => {
213 - logEvent({
214 - event_name: 'error',
215 - error_message: error.message ?? null,
216 - error_stack: error.stack ?? null,
217 - error_component_stack: componentStack ?? null,
218 - });
219 - };
220 -
208 _dismissError: () => void = () => {
209 const onBeforeDismissCallback = this.props.onBeforeDismissCallback;
210 if (typeof onBeforeDismissCallback === 'function') {
@@ -229,7 +216,6 @@ export default class ErrorBoundary extends Component<Props, State> {
216
217 _onStoreError: (error: Error) => void = error => {
218 if (!this.state.hasError) {
232 - this._logError(error, null);
219 this.setState({
220 ...ErrorBoundary.getDerivedStateFromError(error),
221 canDismiss: true,
packages/react-devtools-shared/src/registerDevToolsEventLogger.js
+3 -1
@@ -15,6 +15,7 @@ import {enableLogger} from 'react-devtools-feature-flags';
15 let currentLoggingIFrame = null;
16 let currentSessionId = null;
17 let missedEvents: Array<LoggerEvent> = [];
18 +let hasRegisteredEventLogger = false;
19
20 type LoggerContext = {
21 page_url: ?string,
@@ -72,8 +73,9 @@ export function registerDevToolsEventLogger(
73 typeof loggingUrl === 'string' &&
74 loggingUrl.length > 0 &&
75 body != null &&
75 - currentLoggingIFrame == null
76 + !hasRegisteredEventLogger
77 ) {
78 + hasRegisteredEventLogger = true;
79 registerEventLogger(logEvent);
80 currentSessionId = window.crypto.randomUUID();
81