@samitouri / QOS-React / commits / a9cc32511a

stash the component stack on the thrown value and reuse (#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

Josh Story committed Feb 15, 2024 at 20:44 UTC a9cc32511a12c261ee719e5383818182800d6af4
3 files changed +129 -8
packages/react-reconciler/src/ReactCapturedValue.js
+23 -5
@@ -11,8 +11,10 @@ import type {Fiber} from './ReactInternalTypes';
11
12 import {getStackByFiberInDevAndProd} from './ReactFiberComponentStack';
13
14 +const CapturedStacks: WeakMap<any, string> = new WeakMap();
15 +
16 export type CapturedValue<T> = {
15 - value: T,
17 + +value: T,
18 source: Fiber | null,
19 stack: string | null,
20 digest: string | null,
@@ -24,19 +26,35 @@ export function createCapturedValueAtFiber<T>(
26 ): CapturedValue<T> {
27 // If the value is an error, call this function immediately after it is thrown
28 // 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 +
42 return {
43 value,
44 source,
30 - stack: getStackByFiberInDevAndProd(source),
45 + stack,
46 digest: null,
47 };
48 }
49
35 -export function createCapturedValue<T>(
36 - value: T,
50 +export function createCapturedValueFromError(
51 + value: Error,
52 digest: ?string,
53 stack: ?string,
39 -): CapturedValue<T> {
54 +): CapturedValue<Error> {
55 + if (typeof stack === 'string') {
56 + CapturedStacks.set(value, stack);
57 + }
58 return {
59 value,
60 source: null,
packages/react-reconciler/src/ReactFiberBeginWork.js
+3 -3
@@ -264,7 +264,7 @@ import {
264 import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
265 import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent';
266 import {
267 - createCapturedValue,
267 + createCapturedValueFromError,
268 createCapturedValueAtFiber,
269 type CapturedValue,
270 } from './ReactCapturedValue';
@@ -2804,7 +2804,7 @@ function updateDehydratedSuspenseComponent(
2804 );
2805 }
2806 (error: any).digest = digest;
2807 - capturedValue = createCapturedValue<mixed>(error, digest, stack);
2807 + capturedValue = createCapturedValueFromError(error, digest, stack);
2808 }
2809 return retrySuspenseComponentWithoutHydrating(
2810 current,
@@ -2941,7 +2941,7 @@ function updateDehydratedSuspenseComponent(
2941 pushPrimaryTreeSuspenseHandler(workInProgress);
2942
2943 workInProgress.flags &= ~ForceClientRender;
2944 - const capturedValue = createCapturedValue<mixed>(
2944 + const capturedValue = createCapturedValueFromError(
2945 new Error(
2946 'There was an error while hydrating this Suspense boundary. ' +
2947 'Switched to client rendering.',
packages/react-reconciler/src/__tests__/ReactErrorStacks-test.js new
+103
@@ -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 +});