From 270229f0c337dc652f07ef27d2254bb922bfaa9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 11 Jun 2024 15:57:41 -0400 Subject: [PATCH] [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. --- .../src/__tests__/ReactFlight-test.js | 68 +++++++------------ .../src/backend/renderer.js | 11 +++ .../src/backend/types.js | 1 + .../react-reconciler/src/ReactChildFiber.js | 62 +++++++++++++---- packages/react-reconciler/src/ReactFiber.js | 11 +++ .../src/ReactFiberBeginWork.js | 6 ++ .../src/ReactFiberCompleteWork.js | 7 ++ .../react-reconciler/src/ReactWorkTags.js | 4 +- 8 files changed, 109 insertions(+), 61 deletions(-) diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index bc660a207f..77fb121f64 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -964,67 +964,47 @@ describe('ReactFlight', () => { const testCases = ( <> -
- -
+
-
- -
+
-
- -
+
-
- -
+
-
- -
+
-
- -
+
-
- -
+
-
- } /> -
+ } />
-
- -
+
-
- -
+
{ '- A library pre-bundled an old copy of "react" or "react/jsx-runtime".\n' + '- A compiler tries to "inline" JSX instead of using the runtime.' }> -
- -
+
); diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 2e6cc9b287..666493cbd9 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -268,6 +268,7 @@ export function getInternalReactConstants(version: string): { TracingMarkerComponent: 25, // Experimental - This is technically in 18 but we don't // want to fork again so we're adding it here instead YieldComponent: -1, // Removed + Throw: 29, }; } else if (gte(version, '17.0.0-alpha')) { ReactTypeOfWork = { @@ -302,6 +303,7 @@ export function getInternalReactConstants(version: string): { SuspenseListComponent: 19, // Experimental TracingMarkerComponent: -1, // Doesn't exist yet YieldComponent: -1, // Removed + Throw: -1, // Doesn't exist yet }; } else if (gte(version, '16.6.0-beta.0')) { ReactTypeOfWork = { @@ -336,6 +338,7 @@ export function getInternalReactConstants(version: string): { SuspenseListComponent: 19, // Experimental TracingMarkerComponent: -1, // Doesn't exist yet YieldComponent: -1, // Removed + Throw: -1, // Doesn't exist yet }; } else if (gte(version, '16.4.3-alpha')) { ReactTypeOfWork = { @@ -370,6 +373,7 @@ export function getInternalReactConstants(version: string): { SuspenseListComponent: -1, // Doesn't exist yet TracingMarkerComponent: -1, // Doesn't exist yet YieldComponent: -1, // Removed + Throw: -1, // Doesn't exist yet }; } else { ReactTypeOfWork = { @@ -404,6 +408,7 @@ export function getInternalReactConstants(version: string): { SuspenseListComponent: -1, // Doesn't exist yet TracingMarkerComponent: -1, // Doesn't exist yet YieldComponent: 9, + Throw: -1, // Doesn't exist yet }; } // ********************************************************** @@ -445,6 +450,7 @@ export function getInternalReactConstants(version: string): { SuspenseComponent, SuspenseListComponent, TracingMarkerComponent, + Throw, } = ReactTypeOfWork; function resolveFiberType(type: any): $FlowFixMe { @@ -551,6 +557,9 @@ export function getInternalReactConstants(version: string): { return 'Profiler'; case TracingMarkerComponent: return 'TracingMarker'; + case Throw: + // This should really never be visible. + return 'Error'; default: const typeSymbol = getTypeSymbol(type); @@ -672,6 +681,7 @@ export function attach( SuspenseComponent, SuspenseListComponent, TracingMarkerComponent, + Throw, } = ReactTypeOfWork; const { ImmediatePriority, @@ -1036,6 +1046,7 @@ export function attach( case HostText: case LegacyHiddenComponent: case OffscreenComponent: + case Throw: return true; case HostRoot: // It is never valid to filter the root element. diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index f11f631cd9..a64d1e5337 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -72,6 +72,7 @@ export type WorkTagMap = { SuspenseListComponent: WorkTag, TracingMarkerComponent: WorkTag, YieldComponent: WorkTag, + Throw: WorkTag, }; // TODO: If it's useful for the frontend to know which types of data an Element has diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index 9334107301..60f8dd9746 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -25,6 +25,7 @@ import { Forked, PlacementDEV, } from './ReactFiberFlags'; +import {NoMode, ConcurrentMode} from './ReactTypeOfMode'; import { getIteratorFn, ASYNC_ITERATOR, @@ -46,6 +47,7 @@ import isArray from 'shared/isArray'; import { enableRefAsProp, enableAsyncIterableChildren, + disableLegacyMode, } from 'shared/ReactFeatureFlags'; import { @@ -55,11 +57,16 @@ import { createFiberFromFragment, createFiberFromText, createFiberFromPortal, + createFiberFromThrow, } from './ReactFiber'; import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading'; import {getIsHydrating} from './ReactFiberHydrationContext'; import {pushTreeFork} from './ReactFiberTreeContext'; -import {createThenableState, trackUsedThenable} from './ReactFiberThenable'; +import { + SuspenseException, + createThenableState, + trackUsedThenable, +} from './ReactFiberThenable'; import {readContextDuringReconciliation} from './ReactFiberNewContext'; import {callLazyInitInDEV} from './ReactFiberCallUserSpace'; @@ -1919,20 +1926,45 @@ function createChildReconciler( newChild: any, lanes: Lanes, ): Fiber | null { - // This indirection only exists so we can reset `thenableState` at the end. - // It should get inlined by Closure. - thenableIndexCounter = 0; - const firstChildFiber = reconcileChildFibersImpl( - returnFiber, - currentFirstChild, - newChild, - lanes, - null, // debugInfo - ); - thenableState = null; - // Don't bother to reset `thenableIndexCounter` to 0 because it always gets - // set at the beginning. - return firstChildFiber; + try { + // This indirection only exists so we can reset `thenableState` at the end. + // It should get inlined by Closure. + thenableIndexCounter = 0; + const firstChildFiber = reconcileChildFibersImpl( + returnFiber, + currentFirstChild, + newChild, + lanes, + null, // debugInfo + ); + thenableState = null; + // Don't bother to reset `thenableIndexCounter` to 0 because it always gets + // set at the beginning. + return firstChildFiber; + } catch (x) { + if ( + x === SuspenseException || + (!disableLegacyMode && + (returnFiber.mode & ConcurrentMode) === NoMode && + typeof x === 'object' && + x !== null && + typeof x.then === 'function') + ) { + // Suspense exceptions need to read the current suspended state before + // yielding and replay it using the same sequence so this trick doesn't + // work here. + // Suspending in legacy mode actually mounts so if we let the child + // mount then we delete its state in an update. + throw x; + } + // Something errored during reconciliation but it's conceptually a child that + // errored and not the current component itself so we create a virtual child + // that throws in its begin phase. That way the current component can handle + // the error or suspending if needed. + const throwFiber = createFiberFromThrow(x, returnFiber.mode, lanes); + throwFiber.return = returnFiber; + return throwFiber; + } } return reconcileChildFibers; diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 7234117a50..28cd65dbb3 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -67,6 +67,7 @@ import { OffscreenComponent, LegacyHiddenComponent, TracingMarkerComponent, + Throw, } from './ReactWorkTags'; import {OffscreenVisible} from './ReactFiberActivityComponent'; import {getComponentNameFromOwner} from 'react-reconciler/src/getComponentNameFromFiber'; @@ -879,3 +880,13 @@ export function createFiberFromPortal( }; return fiber; } + +export function createFiberFromThrow( + error: mixed, + mode: TypeOfMode, + lanes: Lanes, +): Fiber { + const fiber = createFiber(Throw, error, null, mode); + fiber.lanes = lanes; + return fiber; +} diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 793a3fa942..8a03e53f30 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -72,6 +72,7 @@ import { LegacyHiddenComponent, CacheComponent, TracingMarkerComponent, + Throw, } from './ReactWorkTags'; import { NoFlags, @@ -4126,6 +4127,11 @@ function beginWork( } break; } + case Throw: { + // This represents a Component that threw in the reconciliation phase. + // So we'll rethrow here. This might be + throw workInProgress.pendingProps; + } } throw new Error( diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 4a671940ba..dd797b8d09 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -72,6 +72,7 @@ import { LegacyHiddenComponent, CacheComponent, TracingMarkerComponent, + Throw, } from './ReactWorkTags'; import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; import { @@ -1802,6 +1803,12 @@ function completeWork( } return null; } + case Throw: { + if (!disableLegacyMode) { + // Only Legacy Mode completes an errored node. + return null; + } + } } throw new Error( diff --git a/packages/react-reconciler/src/ReactWorkTags.js b/packages/react-reconciler/src/ReactWorkTags.js index a4d4eb1751..0af97479ef 100644 --- a/packages/react-reconciler/src/ReactWorkTags.js +++ b/packages/react-reconciler/src/ReactWorkTags.js @@ -36,7 +36,8 @@ export type WorkTag = | 25 | 26 | 27 - | 28; + | 28 + | 29; export const FunctionComponent = 0; export const ClassComponent = 1; @@ -65,3 +66,4 @@ export const TracingMarkerComponent = 25; export const HostHoistable = 26; export const HostSingleton = 27; export const IncompleteFunctionComponent = 28; +export const Throw = 29;