Skip to content

Commit 3408207

Browse files
committed
Retain component stacks when rethrowing an error that already generated a component stack
1 parent fea900e commit 3408207

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)