Skip to content

Commit ab302c8

Browse files
author
Brian Vaughn
committed
DevTools Store emits errors before throwing
The Store should never throw an Error without also emitting an event. Otherwise Store errors will be invisible to users, but the downstream errors they cause will be reported as bugs. (For example, github.com//issues/21402) Emitting an error event allows the ErrorBoundary to show the original error. Throwing is still valuable for local development and for unit testing the Store itself.
1 parent 9a130e1 commit ab302c8

File tree

4 files changed

+83
-42
lines changed

4 files changed

+83
-42
lines changed

packages/react-devtools-shared/src/devtools/store.js

+57-23
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export type Capabilities = {|
7878
export default class Store extends EventEmitter<{|
7979
collapseNodesByDefault: [],
8080
componentFilters: [],
81+
error: [Error],
8182
mutated: [[Array<number>, Map<number, number>]],
8283
recordChangeDescriptions: [],
8384
roots: [],
@@ -270,12 +271,14 @@ export default class Store extends EventEmitter<{|
270271
assertMapSizeMatchesRootCount(map: Map<any, any>, mapName: string) {
271272
const expectedSize = this.roots.length;
272273
if (map.size !== expectedSize) {
273-
throw new Error(
274-
`Expected ${mapName} to contain ${expectedSize} items, but it contains ${
275-
map.size
276-
} items\n\n${inspect(map, {
277-
depth: 20,
278-
})}`,
274+
this._throwAndEmitError(
275+
Error(
276+
`Expected ${mapName} to contain ${expectedSize} items, but it contains ${
277+
map.size
278+
} items\n\n${inspect(map, {
279+
depth: 20,
280+
})}`,
281+
),
279282
);
280283
}
281284
}
@@ -301,7 +304,9 @@ export default class Store extends EventEmitter<{|
301304
if (this._profilerStore.isProfiling) {
302305
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
303306
// If necessary, we could support this- but it doesn't seem like a necessary use case.
304-
throw Error('Cannot modify filter preferences while profiling');
307+
this._throwAndEmitError(
308+
Error('Cannot modify filter preferences while profiling'),
309+
);
305310
}
306311

307312
// Filter updates are expensive to apply (since they impact the entire tree).
@@ -607,7 +612,7 @@ export default class Store extends EventEmitter<{|
607612
}
608613

609614
if (depth === 0) {
610-
throw Error('Invalid owners list');
615+
this._throwAndEmitError(Error('Invalid owners list'));
611616
}
612617

613618
list.push({...innerElement, depth});
@@ -667,7 +672,7 @@ export default class Store extends EventEmitter<{|
667672
if (element !== null) {
668673
if (isCollapsed) {
669674
if (element.type === ElementTypeRoot) {
670-
throw Error('Root nodes cannot be collapsed');
675+
this._throwAndEmitError(Error('Root nodes cannot be collapsed'));
671676
}
672677

673678
if (!element.isCollapsed) {
@@ -825,8 +830,10 @@ export default class Store extends EventEmitter<{|
825830
i += 3;
826831

827832
if (this._idToElement.has(id)) {
828-
throw Error(
829-
`Cannot add node "${id}" because a node with that id is already in the Store.`,
833+
this._throwAndEmitError(
834+
Error(
835+
`Cannot add node "${id}" because a node with that id is already in the Store.`,
836+
),
830837
);
831838
}
832839

@@ -888,8 +895,10 @@ export default class Store extends EventEmitter<{|
888895
}
889896

890897
if (!this._idToElement.has(parentID)) {
891-
throw Error(
892-
`Cannot add child "${id}" to parent "${parentID}" because parent node was not found in the Store.`,
898+
this._throwAndEmitError(
899+
Error(
900+
`Cannot add child "${id}" to parent "${parentID}" because parent node was not found in the Store.`,
901+
),
893902
);
894903
}
895904

@@ -940,8 +949,10 @@ export default class Store extends EventEmitter<{|
940949
const id = ((operations[i]: any): number);
941950

942951
if (!this._idToElement.has(id)) {
943-
throw Error(
944-
`Cannot remove node "${id}" because no matching node was found in the Store.`,
952+
this._throwAndEmitError(
953+
Error(
954+
`Cannot remove node "${id}" because no matching node was found in the Store.`,
955+
),
945956
);
946957
}
947958

@@ -950,7 +961,9 @@ export default class Store extends EventEmitter<{|
950961
const element = ((this._idToElement.get(id): any): Element);
951962
const {children, ownerID, parentID, weight} = element;
952963
if (children.length > 0) {
953-
throw new Error(`Node "${id}" was removed before its children.`);
964+
this._throwAndEmitError(
965+
Error(`Node "${id}" was removed before its children.`),
966+
);
954967
}
955968

956969
this._idToElement.delete(id);
@@ -972,8 +985,10 @@ export default class Store extends EventEmitter<{|
972985
}
973986
parentElement = ((this._idToElement.get(parentID): any): Element);
974987
if (parentElement === undefined) {
975-
throw Error(
976-
`Cannot remove node "${id}" from parent "${parentID}" because no matching node was found in the Store.`,
988+
this._throwAndEmitError(
989+
Error(
990+
`Cannot remove node "${id}" from parent "${parentID}" because no matching node was found in the Store.`,
991+
),
977992
);
978993
}
979994
const index = parentElement.children.indexOf(id);
@@ -1033,16 +1048,20 @@ export default class Store extends EventEmitter<{|
10331048
i += 3;
10341049

10351050
if (!this._idToElement.has(id)) {
1036-
throw Error(
1037-
`Cannot reorder children for node "${id}" because no matching node was found in the Store.`,
1051+
this._throwAndEmitError(
1052+
Error(
1053+
`Cannot reorder children for node "${id}" because no matching node was found in the Store.`,
1054+
),
10381055
);
10391056
}
10401057

10411058
const element = ((this._idToElement.get(id): any): Element);
10421059
const children = element.children;
10431060
if (children.length !== numChildren) {
1044-
throw Error(
1045-
`Children cannot be added or removed during a reorder operation.`,
1061+
this._throwAndEmitError(
1062+
Error(
1063+
`Children cannot be added or removed during a reorder operation.`,
1064+
),
10461065
);
10471066
}
10481067

@@ -1087,7 +1106,9 @@ export default class Store extends EventEmitter<{|
10871106
haveErrorsOrWarningsChanged = true;
10881107
break;
10891108
default:
1090-
throw Error(`Unsupported Bridge operation "${operation}"`);
1109+
this._throwAndEmitError(
1110+
Error(`Unsupported Bridge operation "${operation}"`),
1111+
);
10911112
}
10921113
}
10931114

@@ -1251,4 +1272,17 @@ export default class Store extends EventEmitter<{|
12511272

12521273
this.emit('unsupportedBridgeProtocolDetected');
12531274
};
1275+
1276+
// The Store should never throw an Error without also emitting an event.
1277+
// Otherwise Store errors will be invisible to users,
1278+
// but the downstream errors they cause will be reported as bugs.
1279+
// For example, https://github.com/facebook/react/issues/21402
1280+
// Emitting an error event allows the ErrorBoundary to show the original error.
1281+
_throwAndEmitError(error: Error) {
1282+
this.emit('error', error);
1283+
1284+
// Throwing is still valuable for local development
1285+
// and for unit testing the Store itself.
1286+
throw error;
1287+
}
12541288
}

packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js

+24-8
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99

1010
import * as React from 'react';
1111
import {Component, Suspense} from 'react';
12+
import Store from 'react-devtools-shared/src/devtools/store';
1213
import ErrorView from './ErrorView';
1314
import SearchingGitHubIssues from './SearchingGitHubIssues';
1415
import SuspendingErrorView from './SuspendingErrorView';
1516

1617
type Props = {|
1718
children: React$Node,
19+
store: Store,
1820
|};
1921

2022
type State = {|
@@ -42,13 +44,6 @@ export default class ErrorBoundary extends Component<Props, State> {
4244
? error.message
4345
: '' + error;
4446

45-
return {
46-
errorMessage,
47-
hasError: true,
48-
};
49-
}
50-
51-
componentDidCatch(error: any, {componentStack}: any) {
5247
const callStack =
5348
typeof error === 'object' &&
5449
error !== null &&
@@ -59,12 +54,27 @@ export default class ErrorBoundary extends Component<Props, State> {
5954
.join('\n')
6055
: null;
6156

62-
this.setState({
57+
return {
6358
callStack,
59+
errorMessage,
60+
hasError: true,
61+
};
62+
}
63+
64+
componentDidCatch(error: any, {componentStack}: any) {
65+
this.setState({
6466
componentStack,
6567
});
6668
}
6769

70+
componentDidMount() {
71+
this.props.store.addListener('error', this._onStoreError);
72+
}
73+
74+
componentWillUnmount() {
75+
this.props.store.removeListener('error', this._onStoreError);
76+
}
77+
6878
render() {
6979
const {children} = this.props;
7080
const {callStack, componentStack, errorMessage, hasError} = this.state;
@@ -88,4 +98,10 @@ export default class ErrorBoundary extends Component<Props, State> {
8898

8999
return children;
90100
}
101+
102+
_onStoreError = (error: Error) => {
103+
if (!this.state.hasError) {
104+
this.setState(ErrorBoundary.getDerivedStateFromError(error));
105+
}
106+
};
91107
}

packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js

+1-8
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/Set
2626
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
2727
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
2828
import portaledContent from '../portaledContent';
29-
import Store from '../../store';
3029

3130
import styles from './Profiler.css';
3231

@@ -188,10 +187,4 @@ const RecordingInProgress = () => (
188187
</div>
189188
);
190189

191-
function onErrorRetry(store: Store) {
192-
// If an error happened in the Profiler,
193-
// we should clear data on retry (or it will just happen again).
194-
store.profilerStore.profilingData = null;
195-
}
196-
197-
export default portaledContent(Profiler, onErrorRetry);
190+
export default portaledContent(Profiler);

packages/react-devtools-shared/src/devtools/views/portaledContent.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,17 @@ import {useContext} from 'react';
1212
import {createPortal} from 'react-dom';
1313
import ErrorBoundary from './ErrorBoundary';
1414
import {StoreContext} from './context';
15-
import Store from '../store';
1615

1716
export type Props = {portalContainer?: Element, ...};
1817

1918
export default function portaledContent(
2019
Component: React$StatelessFunctionalComponent<any>,
21-
onErrorRetry?: (store: Store) => void,
2220
): React$StatelessFunctionalComponent<any> {
2321
return function PortaledContent({portalContainer, ...rest}: Props) {
2422
const store = useContext(StoreContext);
2523

2624
const children = (
27-
<ErrorBoundary store={store} onRetry={onErrorRetry}>
25+
<ErrorBoundary store={store}>
2826
<Component {...rest} />
2927
</ErrorBoundary>
3028
);

0 commit comments

Comments
 (0)