Skip to content

Commit a9cc325

Browse files
authored
stash the component stack on the thrown value and reuse (facebook#25790)
ErrorBoundaries are currently not fully composable. The reason is if you decide your boundary cannot handle a particular error and rethrow it to higher boundary the React runtime does not understand that this throw is a forward and it recreates the component stack from the Boundary position. This loses fidelity and is especially bad if the boundary is limited it what it handles and high up in the component tree. This implementation uses a WeakMap to store component stacks for values that are objects. If an error is rethrown from an ErrorBoundary the stack will be pulled from the map if it exists. This doesn't work for thrown primitives but this is uncommon and stashing the stack on the primitive also wouldn't work
1 parent 2ba1b78 commit a9cc325

File tree

3 files changed

+129
-8
lines changed

3 files changed

+129
-8
lines changed

packages/react-reconciler/src/ReactCapturedValue.js

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import type {Fiber} from './ReactInternalTypes';
1111

1212
import {getStackByFiberInDevAndProd} from './ReactFiberComponentStack';
1313

14+
const CapturedStacks: WeakMap<any, string> = new WeakMap();
15+
1416
export type CapturedValue<T> = {
15-
value: T,
17+
+value: T,
1618
source: Fiber | null,
1719
stack: string | null,
1820
digest: string | null,
@@ -24,19 +26,35 @@ export function createCapturedValueAtFiber<T>(
2426
): CapturedValue<T> {
2527
// If the value is an error, call this function immediately after it is thrown
2628
// so the stack is accurate.
29+
let stack;
30+
if (typeof value === 'object' && value !== null) {
31+
const capturedStack = CapturedStacks.get(value);
32+
if (typeof capturedStack === 'string') {
33+
stack = capturedStack;
34+
} else {
35+
stack = getStackByFiberInDevAndProd(source);
36+
CapturedStacks.set(value, stack);
37+
}
38+
} else {
39+
stack = getStackByFiberInDevAndProd(source);
40+
}
41+
2742
return {
2843
value,
2944
source,
30-
stack: getStackByFiberInDevAndProd(source),
45+
stack,
3146
digest: null,
3247
};
3348
}
3449

35-
export function createCapturedValue<T>(
36-
value: T,
50+
export function createCapturedValueFromError(
51+
value: Error,
3752
digest: ?string,
3853
stack: ?string,
39-
): CapturedValue<T> {
54+
): CapturedValue<Error> {
55+
if (typeof stack === 'string') {
56+
CapturedStacks.set(value, stack);
57+
}
4058
return {
4159
value,
4260
source: null,

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ import {
264264
import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
265265
import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent';
266266
import {
267-
createCapturedValue,
267+
createCapturedValueFromError,
268268
createCapturedValueAtFiber,
269269
type CapturedValue,
270270
} from './ReactCapturedValue';
@@ -2804,7 +2804,7 @@ function updateDehydratedSuspenseComponent(
28042804
);
28052805
}
28062806
(error: any).digest = digest;
2807-
capturedValue = createCapturedValue<mixed>(error, digest, stack);
2807+
capturedValue = createCapturedValueFromError(error, digest, stack);
28082808
}
28092809
return retrySuspenseComponentWithoutHydrating(
28102810
current,
@@ -2941,7 +2941,7 @@ function updateDehydratedSuspenseComponent(
29412941
pushPrimaryTreeSuspenseHandler(workInProgress);
29422942

29432943
workInProgress.flags &= ~ForceClientRender;
2944-
const capturedValue = createCapturedValue<mixed>(
2944+
const capturedValue = createCapturedValueFromError(
29452945
new Error(
29462946
'There was an error while hydrating this Suspense boundary. ' +
29472947
'Switched to client rendering.',
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
* @emails react-core
8+
* @jest-environment node
9+
*/
10+
'use strict';
11+
12+
let React;
13+
let ReactNoop;
14+
let waitForAll;
15+
16+
describe('ReactFragment', () => {
17+
beforeEach(function () {
18+
jest.resetModules();
19+
20+
React = require('react');
21+
ReactNoop = require('react-noop-renderer');
22+
const InternalTestUtils = require('internal-test-utils');
23+
waitForAll = InternalTestUtils.waitForAll;
24+
});
25+
26+
function componentStack(components) {
27+
return components
28+
.map(component => `\n in ${component} (at **)`)
29+
.join('');
30+
}
31+
32+
function normalizeCodeLocInfo(str) {
33+
return (
34+
str &&
35+
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
36+
return '\n in ' + name + ' (at **)';
37+
})
38+
);
39+
}
40+
41+
it('retains component stacks when rethrowing an error', async () => {
42+
function Foo() {
43+
return (
44+
<RethrowingBoundary>
45+
<Bar />
46+
</RethrowingBoundary>
47+
);
48+
}
49+
function Bar() {
50+
return <SomethingThatErrors />;
51+
}
52+
function SomethingThatErrors() {
53+
throw new Error('uh oh');
54+
}
55+
56+
class RethrowingBoundary extends React.Component {
57+
static getDerivedStateFromError(error) {
58+
throw error;
59+
}
60+
61+
render() {
62+
return this.props.children;
63+
}
64+
}
65+
66+
const errors = [];
67+
class CatchingBoundary extends React.Component {
68+
constructor() {
69+
super();
70+
this.state = {};
71+
}
72+
static getDerivedStateFromError(error) {
73+
return {errored: true};
74+
}
75+
componentDidCatch(err, errInfo) {
76+
errors.push(err.message, normalizeCodeLocInfo(errInfo.componentStack));
77+
}
78+
render() {
79+
if (this.state.errored) {
80+
return null;
81+
}
82+
return this.props.children;
83+
}
84+
}
85+
86+
ReactNoop.render(
87+
<CatchingBoundary>
88+
<Foo />
89+
</CatchingBoundary>,
90+
);
91+
await waitForAll([]);
92+
expect(errors).toEqual([
93+
'uh oh',
94+
componentStack([
95+
'SomethingThatErrors',
96+
'Bar',
97+
'RethrowingBoundary',
98+
'Foo',
99+
'CatchingBoundary',
100+
]),
101+
]);
102+
});
103+
});

0 commit comments

Comments
 (0)