@samitouri / QOS-React-1 / commits / 270229f0c3

[Fiber] Create virtual Fiber when an error occurs during reconcilation (#29804)

This lets us rethrow it in the conceptual place of the child. There's currently a problem when we suspend or throw in the child fiber reconciliation phase. This work is done by the parent component, so if it suspends or errors it is as if that component errored or suspended. However, conceptually it's like a child suspended or errored. In theory any thing can throw but it is really mainly due to either `React.lazy` (both in the element.type position and node position), `Thenable`s or the `Thenable`s that make up `AsyncIterable`s. Mainly this happens because a Server Component that errors turns into a `React.lazy`. In practice this means that if you have a Server Component as the direct child of an Error Boundary. Errors inside of it won't be caught. We used to have the same problem with Thenables and Suspense but because it's now always nested inside an inner Offscreen boundary that shields it by being one level nested. However, when we have raw Offscreen (Activity) boundaries they should also be able to catch the suspense if it's in a hidden state so the problem returns. This fixes it for thrown promises but it doesn't fix it for SuspenseException. I'm not sure this is even the right strategy for Suspense though. It kind of relies on the node never actually mounting/committing. It's conceptually a little tricky because the current component can inspect the children and make decisions based on them. Such as SuspenseList. The other thing that this PR tries to address is that it sets the foundation for dealing with error reporting for Server Components that errored. If something client side errors it'll be a stack like Server (DebugInfo) -> Fiber -> Fiber -> Server -> (DebugInfo) -> Fiber. However, all error reporting relies on it eventually terminating into a Fiber that is responsible for the error. To avoid having to fork too much it would be nice if I could create a Fiber to associate with the error so that even a Server component error in this case ultimately terminates in a Fiber.

Sebastian Markbåge committed Jun 11, 2024 at 15:57 UTC 270229f0c337dc652f07ef27d2254bb922bfaa9e
8 files changed +109 -61
packages/react-client/src/__tests__/ReactFlight-test.js
+23 -45
@@ -964,67 +964,47 @@ describe('ReactFlight', () => {
964 const testCases = (
965 <>
966 <ClientErrorBoundary expectedMessage="This is a real Error.">
967 - <div>
968 - <Throw value={new TypeError('This is a real Error.')} />
969 - </div>
967 + <Throw value={new TypeError('This is a real Error.')} />
968 </ClientErrorBoundary>
969 <ClientErrorBoundary expectedMessage="This is a string error.">
972 - <div>
973 - <Throw value="This is a string error." />
974 - </div>
970 + <Throw value="This is a string error." />
971 </ClientErrorBoundary>
972 <ClientErrorBoundary expectedMessage="{message: ..., extra: ..., nested: ...}">
977 - <div>
978 - <Throw
979 - value={{
980 - message: 'This is a long message',
981 - extra: 'properties',
982 - nested: {more: 'prop'},
983 - }}
984 - />
985 - </div>
973 + <Throw
974 + value={{
975 + message: 'This is a long message',
976 + extra: 'properties',
977 + nested: {more: 'prop'},
978 + }}
979 + />
980 </ClientErrorBoundary>
981 <ClientErrorBoundary
982 expectedMessage={'{message: "Short", extra: ..., nested: ...}'}>
989 - <div>
990 - <Throw
991 - value={{
992 - message: 'Short',
993 - extra: 'properties',
994 - nested: {more: 'prop'},
995 - }}
996 - />
997 - </div>
983 + <Throw
984 + value={{
985 + message: 'Short',
986 + extra: 'properties',
987 + nested: {more: 'prop'},
988 + }}
989 + />
990 </ClientErrorBoundary>
991 <ClientErrorBoundary expectedMessage="Symbol(hello)">
1000 - <div>
1001 - <Throw value={Symbol('hello')} />
1002 - </div>
992 + <Throw value={Symbol('hello')} />
993 </ClientErrorBoundary>
994 <ClientErrorBoundary expectedMessage="123">
1005 - <div>
1006 - <Throw value={123} />
1007 - </div>
995 + <Throw value={123} />
996 </ClientErrorBoundary>
997 <ClientErrorBoundary expectedMessage="undefined">
1010 - <div>
1011 - <Throw value={undefined} />
1012 - </div>
998 + <Throw value={undefined} />
999 </ClientErrorBoundary>
1000 <ClientErrorBoundary expectedMessage="<div/>">
1015 - <div>
1016 - <Throw value={<div />} />
1017 - </div>
1001 + <Throw value={<div />} />
1002 </ClientErrorBoundary>
1003 <ClientErrorBoundary expectedMessage="function Foo() {}">
1020 - <div>
1021 - <Throw value={function Foo() {}} />
1022 - </div>
1004 + <Throw value={function Foo() {}} />
1005 </ClientErrorBoundary>
1006 <ClientErrorBoundary expectedMessage={'["array"]'}>
1025 - <div>
1026 - <Throw value={['array']} />
1027 - </div>
1007 + <Throw value={['array']} />
1008 </ClientErrorBoundary>
1009 <ClientErrorBoundary
1010 expectedMessage={
@@ -1034,9 +1014,7 @@ describe('ReactFlight', () => {
1014 '- A library pre-bundled an old copy of "react" or "react/jsx-runtime".\n' +
1015 '- A compiler tries to "inline" JSX instead of using the runtime.'
1016 }>
1037 - <div>
1038 - <LazyInlined />
1039 - </div>
1017 + <LazyInlined />
1018 </ClientErrorBoundary>
1019 </>
1020 );
packages/react-devtools-shared/src/backend/renderer.js
+11
@@ -268,6 +268,7 @@ export function getInternalReactConstants(version: string): {
268 TracingMarkerComponent: 25, // Experimental - This is technically in 18 but we don't
269 // want to fork again so we're adding it here instead
270 YieldComponent: -1, // Removed
271 + Throw: 29,
272 };
273 } else if (gte(version, '17.0.0-alpha')) {
274 ReactTypeOfWork = {
@@ -302,6 +303,7 @@ export function getInternalReactConstants(version: string): {
303 SuspenseListComponent: 19, // Experimental
304 TracingMarkerComponent: -1, // Doesn't exist yet
305 YieldComponent: -1, // Removed
306 + Throw: -1, // Doesn't exist yet
307 };
308 } else if (gte(version, '16.6.0-beta.0')) {
309 ReactTypeOfWork = {
@@ -336,6 +338,7 @@ export function getInternalReactConstants(version: string): {
338 SuspenseListComponent: 19, // Experimental
339 TracingMarkerComponent: -1, // Doesn't exist yet
340 YieldComponent: -1, // Removed
341 + Throw: -1, // Doesn't exist yet
342 };
343 } else if (gte(version, '16.4.3-alpha')) {
344 ReactTypeOfWork = {
@@ -370,6 +373,7 @@ export function getInternalReactConstants(version: string): {
373 SuspenseListComponent: -1, // Doesn't exist yet
374 TracingMarkerComponent: -1, // Doesn't exist yet
375 YieldComponent: -1, // Removed
376 + Throw: -1, // Doesn't exist yet
377 };
378 } else {
379 ReactTypeOfWork = {
@@ -404,6 +408,7 @@ export function getInternalReactConstants(version: string): {
408 SuspenseListComponent: -1, // Doesn't exist yet
409 TracingMarkerComponent: -1, // Doesn't exist yet
410 YieldComponent: 9,
411 + Throw: -1, // Doesn't exist yet
412 };
413 }
414 // **********************************************************
@@ -445,6 +450,7 @@ export function getInternalReactConstants(version: string): {
450 SuspenseComponent,
451 SuspenseListComponent,
452 TracingMarkerComponent,
453 + Throw,
454 } = ReactTypeOfWork;
455
456 function resolveFiberType(type: any): $FlowFixMe {
@@ -551,6 +557,9 @@ export function getInternalReactConstants(version: string): {
557 return 'Profiler';
558 case TracingMarkerComponent:
559 return 'TracingMarker';
560 + case Throw:
561 + // This should really never be visible.
562 + return 'Error';
563 default:
564 const typeSymbol = getTypeSymbol(type);
565
@@ -672,6 +681,7 @@ export function attach(
681 SuspenseComponent,
682 SuspenseListComponent,
683 TracingMarkerComponent,
684 + Throw,
685 } = ReactTypeOfWork;
686 const {
687 ImmediatePriority,
@@ -1036,6 +1046,7 @@ export function attach(
1046 case HostText:
1047 case LegacyHiddenComponent:
1048 case OffscreenComponent:
1049 + case Throw:
1050 return true;
1051 case HostRoot:
1052 // It is never valid to filter the root element.
packages/react-devtools-shared/src/backend/types.js
+1
@@ -72,6 +72,7 @@ export type WorkTagMap = {
72 SuspenseListComponent: WorkTag,
73 TracingMarkerComponent: WorkTag,
74 YieldComponent: WorkTag,
75 + Throw: WorkTag,
76 };
77
78 // TODO: If it's useful for the frontend to know which types of data an Element has
packages/react-reconciler/src/ReactChildFiber.js
+47 -15
@@ -25,6 +25,7 @@ import {
25 Forked,
26 PlacementDEV,
27 } from './ReactFiberFlags';
28 +import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
29 import {
30 getIteratorFn,
31 ASYNC_ITERATOR,
@@ -46,6 +47,7 @@ import isArray from 'shared/isArray';
47 import {
48 enableRefAsProp,
49 enableAsyncIterableChildren,
50 + disableLegacyMode,
51 } from 'shared/ReactFeatureFlags';
52
53 import {
@@ -55,11 +57,16 @@ import {
57 createFiberFromFragment,
58 createFiberFromText,
59 createFiberFromPortal,
60 + createFiberFromThrow,
61 } from './ReactFiber';
62 import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading';
63 import {getIsHydrating} from './ReactFiberHydrationContext';
64 import {pushTreeFork} from './ReactFiberTreeContext';
62 -import {createThenableState, trackUsedThenable} from './ReactFiberThenable';
65 +import {
66 + SuspenseException,
67 + createThenableState,
68 + trackUsedThenable,
69 +} from './ReactFiberThenable';
70 import {readContextDuringReconciliation} from './ReactFiberNewContext';
71 import {callLazyInitInDEV} from './ReactFiberCallUserSpace';
72
@@ -1919,20 +1926,45 @@ function createChildReconciler(
1926 newChild: any,
1927 lanes: Lanes,
1928 ): Fiber | null {
1922 - // This indirection only exists so we can reset `thenableState` at the end.
1923 - // It should get inlined by Closure.
1924 - thenableIndexCounter = 0;
1925 - const firstChildFiber = reconcileChildFibersImpl(
1926 - returnFiber,
1927 - currentFirstChild,
1928 - newChild,
1929 - lanes,
1930 - null, // debugInfo
1931 - );
1932 - thenableState = null;
1933 - // Don't bother to reset `thenableIndexCounter` to 0 because it always gets
1934 - // set at the beginning.
1935 - return firstChildFiber;
1929 + try {
1930 + // This indirection only exists so we can reset `thenableState` at the end.
1931 + // It should get inlined by Closure.
1932 + thenableIndexCounter = 0;
1933 + const firstChildFiber = reconcileChildFibersImpl(
1934 + returnFiber,
1935 + currentFirstChild,
1936 + newChild,
1937 + lanes,
1938 + null, // debugInfo
1939 + );
1940 + thenableState = null;
1941 + // Don't bother to reset `thenableIndexCounter` to 0 because it always gets
1942 + // set at the beginning.
1943 + return firstChildFiber;
1944 + } catch (x) {
1945 + if (
1946 + x === SuspenseException ||
1947 + (!disableLegacyMode &&
1948 + (returnFiber.mode & ConcurrentMode) === NoMode &&
1949 + typeof x === 'object' &&
1950 + x !== null &&
1951 + typeof x.then === 'function')
1952 + ) {
1953 + // Suspense exceptions need to read the current suspended state before
1954 + // yielding and replay it using the same sequence so this trick doesn't
1955 + // work here.
1956 + // Suspending in legacy mode actually mounts so if we let the child
1957 + // mount then we delete its state in an update.
1958 + throw x;
1959 + }
1960 + // Something errored during reconciliation but it's conceptually a child that
1961 + // errored and not the current component itself so we create a virtual child
1962 + // that throws in its begin phase. That way the current component can handle
1963 + // the error or suspending if needed.
1964 + const throwFiber = createFiberFromThrow(x, returnFiber.mode, lanes);
1965 + throwFiber.return = returnFiber;
1966 + return throwFiber;
1967 + }
1968 }
1969
1970 return reconcileChildFibers;
packages/react-reconciler/src/ReactFiber.js
+11
@@ -67,6 +67,7 @@ import {
67 OffscreenComponent,
68 LegacyHiddenComponent,
69 TracingMarkerComponent,
70 + Throw,
71 } from './ReactWorkTags';
72 import {OffscreenVisible} from './ReactFiberActivityComponent';
73 import {getComponentNameFromOwner} from 'react-reconciler/src/getComponentNameFromFiber';
@@ -879,3 +880,13 @@ export function createFiberFromPortal(
880 };
881 return fiber;
882 }
883 +
884 +export function createFiberFromThrow(
885 + error: mixed,
886 + mode: TypeOfMode,
887 + lanes: Lanes,
888 +): Fiber {
889 + const fiber = createFiber(Throw, error, null, mode);
890 + fiber.lanes = lanes;
891 + return fiber;
892 +}
packages/react-reconciler/src/ReactFiberBeginWork.js
+6
@@ -72,6 +72,7 @@ import {
72 LegacyHiddenComponent,
73 CacheComponent,
74 TracingMarkerComponent,
75 + Throw,
76 } from './ReactWorkTags';
77 import {
78 NoFlags,
@@ -4126,6 +4127,11 @@ function beginWork(
4127 }
4128 break;
4129 }
4130 + case Throw: {
4131 + // This represents a Component that threw in the reconciliation phase.
4132 + // So we'll rethrow here. This might be
4133 + throw workInProgress.pendingProps;
4134 + }
4135 }
4136
4137 throw new Error(
packages/react-reconciler/src/ReactFiberCompleteWork.js
+7
@@ -72,6 +72,7 @@ import {
72 LegacyHiddenComponent,
73 CacheComponent,
74 TracingMarkerComponent,
75 + Throw,
76 } from './ReactWorkTags';
77 import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode';
78 import {
@@ -1802,6 +1803,12 @@ function completeWork(
1803 }
1804 return null;
1805 }
1806 + case Throw: {
1807 + if (!disableLegacyMode) {
1808 + // Only Legacy Mode completes an errored node.
1809 + return null;
1810 + }
1811 + }
1812 }
1813
1814 throw new Error(
packages/react-reconciler/src/ReactWorkTags.js
+3 -1
@@ -36,7 +36,8 @@ export type WorkTag =
36 | 25
37 | 26
38 | 27
39 - | 28;
39 + | 28
40 + | 29;
41
42 export const FunctionComponent = 0;
43 export const ClassComponent = 1;
@@ -65,3 +66,4 @@ export const TracingMarkerComponent = 25;
66 export const HostHoistable = 26;
67 export const HostSingleton = 27;
68 export const IncompleteFunctionComponent = 28;
69 +export const Throw = 29;