Skip to content

Commit 40a2cb9

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

File tree

3 files changed

+128
-8
lines changed

3 files changed

+128
-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: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 Scheduler;
15+
16+
describe('ReactFragment', () => {
17+
beforeEach(function() {
18+
jest.resetModules();
19+
20+
React = require('react');
21+
ReactNoop = require('react-noop-renderer');
22+
Scheduler = require('scheduler');
23+
});
24+
25+
function componentStack(components) {
26+
return components
27+
.map(component => `\n in ${component} (at **)`)
28+
.join('');
29+
}
30+
31+
function normalizeCodeLocInfo(str) {
32+
return (
33+
str &&
34+
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
35+
return '\n in ' + name + ' (at **)';
36+
})
37+
);
38+
}
39+
40+
it('retains component stacks when rethrowing an error', () => {
41+
function Foo() {
42+
return (
43+
<RethrowingBoundary>
44+
<Bar />
45+
</RethrowingBoundary>
46+
);
47+
}
48+
function Bar() {
49+
return <SomethingThatErrors />;
50+
}
51+
function SomethingThatErrors() {
52+
throw new Error('uh oh');
53+
}
54+
55+
class RethrowingBoundary extends React.Component {
56+
static getDerivedStateFromError(error) {
57+
throw error;
58+
}
59+
60+
render() {
61+
return this.props.children;
62+
}
63+
}
64+
65+
const errors = [];
66+
class CatchingBoundary extends React.Component {
67+
constructor() {
68+
super();
69+
this.state = {};
70+
}
71+
static getDerivedStateFromError(error) {
72+
return {errored: true};
73+
}
74+
componentDidCatch(err, errInfo) {
75+
errors.push(err.message, normalizeCodeLocInfo(errInfo.componentStack));
76+
}
77+
render() {
78+
if (this.state.errored) {
79+
return null;
80+
}
81+
return this.props.children;
82+
}
83+
}
84+
85+
ReactNoop.render(
86+
<CatchingBoundary>
87+
<Foo />
88+
</CatchingBoundary>,
89+
);
90+
expect(Scheduler).toFlushWithoutYielding();
91+
expect(errors).toEqual([
92+
'uh oh',
93+
componentStack([
94+
'SomethingThatErrors',
95+
'Bar',
96+
'RethrowingBoundary',
97+
'Foo',
98+
'CatchingBoundary',
99+
]),
100+
]);
101+
});
102+
});

0 commit comments

Comments
 (0)