diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index edf5814023142..dd43cbd20d279 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1783,4 +1783,263 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(container)).toEqual(
client
); expect(ref.current).toEqual(serverRenderedDiv); }); + + // @gate supportsNativeUseSyncExternalStore + // @gate experimental + it( + 'errors during hydration force a client render at the nearest Suspense ' + + 'boundary, and during the client render it recovers', + async () => { + let isClient = false; + + function subscribe() { + return () => {}; + } + function getClientSnapshot() { + return 'Yay!'; + } + + // At the time of writing, the only API that exposes whether it's currently + // hydrating is the `getServerSnapshot` API, so I'm using that here to + // simulate an error during hydration. + function getServerSnapshot() { + if (isClient) { + throw new Error('Hydration error'); + } + return 'Yay!'; + } + + function Child() { + const value = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + Scheduler.unstable_yieldValue(value); + return value; + } + + const span1Ref = React.createRef(); + const span2Ref = React.createRef(); + const span3Ref = React.createRef(); + + function App() { + return ( +
+ + + + + + + +
+ ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['Yay!']); + + const [span1, span2, span3] = container.getElementsByTagName('span'); + + // Hydrate the tree. Child will throw during hydration, but not when it + // falls back to client rendering. + isClient = true; + ReactDOM.hydrateRoot(container, ); + + expect(Scheduler).toFlushAndYield(['Yay!']); + expect(getVisibleChildren(container)).toEqual( +
+ + Yay! + +
, + ); + + // The node that's inside the boundary that errored during hydration was + // not hydrated. + expect(span2Ref.current).not.toBe(span2); + + // But the nodes outside the boundary were. + expect(span1Ref.current).toBe(span1); + expect(span3Ref.current).toBe(span3); + }, + ); + + // @gate experimental + it( + 'errors during hydration force a client render at the nearest Suspense ' + + 'boundary, and during the client render it fails again', + async () => { + // Similar to previous test, but the client render errors, too. We should + // be able to capture it with an error boundary. + + let isClient = false; + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error !== null) { + return this.state.error.message; + } + return this.props.children; + } + } + + function Child() { + if (isClient) { + throw new Error('Oops!'); + } + Scheduler.unstable_yieldValue('Yay!'); + return 'Yay!'; + } + + const span1Ref = React.createRef(); + const span2Ref = React.createRef(); + const span3Ref = React.createRef(); + + function App() { + return ( + + + + + + + + + + ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['Yay!']); + + // Hydrate the tree. Child will throw during render. + isClient = true; + ReactDOM.hydrateRoot(container, ); + + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual('Oops!'); + }, + ); + + // @gate supportsNativeUseSyncExternalStore + // @gate experimental + it( + 'errors during hydration force a client render at the nearest Suspense ' + + 'boundary, and during the client render it recovers, then a deeper ' + + 'child suspends', + async () => { + let isClient = false; + + function subscribe() { + return () => {}; + } + function getClientSnapshot() { + return 'Yay!'; + } + + // At the time of writing, the only API that exposes whether it's currently + // hydrating is the `getServerSnapshot` API, so I'm using that here to + // simulate an error during hydration. + function getServerSnapshot() { + if (isClient) { + throw new Error('Hydration error'); + } + return 'Yay!'; + } + + function Child() { + const value = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + if (isClient) { + readText(value); + } + Scheduler.unstable_yieldValue(value); + return value; + } + + const span1Ref = React.createRef(); + const span2Ref = React.createRef(); + const span3Ref = React.createRef(); + + function App() { + return ( +
+ + + + + + + +
+ ); + } + + await act(async () => { + const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable( + , + writable, + ); + startWriting(); + }); + expect(Scheduler).toHaveYielded(['Yay!']); + + const [span1, span2, span3] = container.getElementsByTagName('span'); + + // Hydrate the tree. Child will throw during hydration, but not when it + // falls back to client rendering. + isClient = true; + ReactDOM.hydrateRoot(container, ); + + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual( +
+ + Loading... + +
, + ); + + await act(async () => { + resolveText('Yay!'); + }); + expect(Scheduler).toFlushAndYield(['Yay!']); + expect(getVisibleChildren(container)).toEqual( +
+ + Yay! + +
, + ); + + // The node that's inside the boundary that errored during hydration was + // not hydrated. + expect(span2Ref.current).not.toBe(span2); + + // But the nodes outside the boundary were. + expect(span1Ref.current).toBe(span1); + expect(span3Ref.current).toBe(span3); + }, + ); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 6097ec4ea1fe9..6ad63ae763c2a 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -74,6 +74,7 @@ import { ForceUpdateForLegacySuspense, StaticMask, ShouldCapture, + ForceClientRender, } from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { prevState, renderLanes, ); + } else if (workInProgress.flags & ForceClientRender) { + // Something errored during hydration. Try again without hydrating. + workInProgress.flags &= ~ForceClientRender; + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); } else if ( (workInProgress.memoizedState: null | SuspenseState) !== null ) { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 9779c6fb528f8..ffa753593e1a4 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -74,6 +74,7 @@ import { ForceUpdateForLegacySuspense, StaticMask, ShouldCapture, + ForceClientRender, } from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -2081,6 +2082,14 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) { prevState, renderLanes, ); + } else if (workInProgress.flags & ForceClientRender) { + // Something errored during hydration. Try again without hydrating. + workInProgress.flags &= ~ForceClientRender; + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + ); } else if ( (workInProgress.memoizedState: null | SuspenseState) !== null ) { diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 13f843ad80607..a82278222bf0a 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,53 +12,54 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b000000000000000000000000; -export const PerformedWork = /* */ 0b000000000000000000000001; +export const NoFlags = /* */ 0b0000000000000000000000000; +export const PerformedWork = /* */ 0b0000000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b000000000000000000000010; -export const Update = /* */ 0b000000000000000000000100; +export const Placement = /* */ 0b0000000000000000000000010; +export const Update = /* */ 0b0000000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b000000000000000000001000; -export const ChildDeletion = /* */ 0b000000000000000000010000; -export const ContentReset = /* */ 0b000000000000000000100000; -export const Callback = /* */ 0b000000000000000001000000; -export const DidCapture = /* */ 0b000000000000000010000000; -export const Ref = /* */ 0b000000000000000100000000; -export const Snapshot = /* */ 0b000000000000001000000000; -export const Passive = /* */ 0b000000000000010000000000; -export const Hydrating = /* */ 0b000000000000100000000000; +export const Deletion = /* */ 0b0000000000000000000001000; +export const ChildDeletion = /* */ 0b0000000000000000000010000; +export const ContentReset = /* */ 0b0000000000000000000100000; +export const Callback = /* */ 0b0000000000000000001000000; +export const DidCapture = /* */ 0b0000000000000000010000000; +export const ForceClientRender = /* */ 0b0000000000000000100000000; +export const Ref = /* */ 0b0000000000000001000000000; +export const Snapshot = /* */ 0b0000000000000010000000000; +export const Passive = /* */ 0b0000000000000100000000000; +export const Hydrating = /* */ 0b0000000000001000000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b000000000001000000000000; -export const StoreConsistency = /* */ 0b000000000010000000000000; +export const Visibility = /* */ 0b0000000000010000000000000; +export const StoreConsistency = /* */ 0b0000000000100000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot | StoreConsistency; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b000000000011111111111111; +export const HostEffectMask = /* */ 0b0000000000111111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b000000000100000000000000; -export const ShouldCapture = /* */ 0b000000001000000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b000000010000000000000000; -export const DidPropagateContext = /* */ 0b000000100000000000000000; -export const NeedsPropagation = /* */ 0b000001000000000000000000; +export const Incomplete = /* */ 0b0000000001000000000000000; +export const ShouldCapture = /* */ 0b0000000010000000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b0000000100000000000000000; +export const DidPropagateContext = /* */ 0b0000001000000000000000000; +export const NeedsPropagation = /* */ 0b0000010000000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const RefStatic = /* */ 0b000010000000000000000000; -export const LayoutStatic = /* */ 0b000100000000000000000000; -export const PassiveStatic = /* */ 0b001000000000000000000000; +export const RefStatic = /* */ 0b0000100000000000000000000; +export const LayoutStatic = /* */ 0b0001000000000000000000000; +export const PassiveStatic = /* */ 0b0010000000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b010000000000000000000000; -export const MountPassiveDev = /* */ 0b100000000000000000000000; +export const MountLayoutDev = /* */ 0b0100000000000000000000000; +export const MountPassiveDev = /* */ 0b1000000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 5b07409a7729f..78e88f125a1aa 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -32,6 +32,7 @@ import { ShouldCapture, LifecycleEffectMask, ForceUpdateForLegacySuspense, + ForceClientRender, } from './ReactFiberFlags'; import { supportsPersistence, @@ -78,6 +79,7 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.new'; +import {getIsHydrating} from './ReactFiberHydrationContext.new'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -160,35 +162,264 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a listener to the promise to "ping" the root and retry. But only if - // one does not already exist for the lanes we're currently rendering (which - // acts like a "thread ID" here). - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { +function attachWakeableListeners( + suspenseBoundary: Fiber, + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + if (suspenseBoundary.mode & ConcurrentMode) { + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); threadIDs = new Set(); pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); + if (!threadIDs.has(lanes)) { + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } } + wakeable.then(ping, ping); } - wakeable.then(ping, ping); } + + // Retry listener + // + // If the fallback does commit, we need to attach a different type of + // listener. This one schedules an update on the Suspense boundary to turn + // the fallback state off. + // + // Stash the wakeable on the boundary fiber so we can access it in the + // commit phase. + // + // When the wakeable resolves, we'll attempt to render the boundary + // again ("retry"). + const wakeables: Set | null = (suspenseBoundary.updateQueue: any); + if (wakeables === null) { + const updateQueue = (new Set(): any); + updateQueue.add(wakeable); + suspenseBoundary.updateQueue = updateQueue; + } else { + wakeables.add(wakeable); + } +} + +function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { + if (enableLazyContextPropagation) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber !== null) { + // Since we never visited the children of the suspended component, we + // need to propagate the context change now, to ensure that we visit + // them during the retry. + // + // We don't have to do this for errors because we retry errors without + // committing in between. So this is specific to Suspense. + propagateParentContextChangesToDeferredTree( + currentSourceFiber, + sourceFiber, + rootRenderLanes, + ); + } + } + + // Reset the memoizedState to what it was before we attempted to render it. + // A legacy mode Suspense quirk, only relevant to hook components. + const tag = sourceFiber.tag; + if ( + (sourceFiber.mode & ConcurrentMode) === NoMode && + (tag === FunctionComponent || + tag === ForwardRef || + tag === SimpleMemoComponent) + ) { + const currentSource = sourceFiber.alternate; + if (currentSource) { + sourceFiber.updateQueue = currentSource.updateQueue; + sourceFiber.memoizedState = currentSource.memoizedState; + sourceFiber.lanes = currentSource.lanes; + } else { + sourceFiber.updateQueue = null; + sourceFiber.memoizedState = null; + } + } +} + +function markNearestSuspenseBoundaryShouldCapture( + returnFiber: Fiber, + sourceFiber: Fiber, + root: FiberRoot, + rootRenderLanes: Lanes, +): Fiber | null { + const hasInvisibleParentBoundary = hasSuspenseContext( + suspenseStackCursor.current, + (InvisibleParentSuspenseContext: SuspenseContext), + ); + let node = returnFiber; + do { + if ( + node.tag === SuspenseComponent && + shouldCaptureSuspense(node, hasInvisibleParentBoundary) + ) { + // Found the nearest boundary. + const suspenseBoundary = node; + + // This marks a Suspense boundary so that when we're unwinding the stack, + // it captures the suspended "exception" and does a second (fallback) pass. + + if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) { + // Legacy Mode Suspense + // + // If the boundary is in legacy mode, we should *not* + // suspend the commit. Pretend as if the suspended component rendered + // null and keep rendering. When the Suspense boundary completes, + // we'll do a second pass to render the fallback. + if (suspenseBoundary === returnFiber) { + // Special case where we suspended while reconciling the children of + // a Suspense boundary's inner Offscreen wrapper fiber. This happens + // when a React.lazy component is a direct child of a + // Suspense boundary. + // + // Suspense boundaries are implemented as multiple fibers, but they + // are a single conceptual unit. The legacy mode behavior where we + // pretend the suspended fiber committed as `null` won't work, + // because in this case the "suspended" fiber is the inner + // Offscreen wrapper. + // + // Because the contents of the boundary haven't started rendering + // yet (i.e. nothing in the tree has partially rendered) we can + // switch to the regular, concurrent mode behavior: mark the + // boundary with ShouldCapture and enter the unwind phase. + suspenseBoundary.flags |= ShouldCapture; + } else { + suspenseBoundary.flags |= DidCapture; + sourceFiber.flags |= ForceUpdateForLegacySuspense; + + // We're going to commit this fiber even though it didn't complete. + // But we shouldn't call any lifecycle methods or callbacks. Remove + // all lifecycle effect tags. + sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete); + + if (supportsPersistence && enablePersistentOffscreenHostContainer) { + // Another legacy Suspense quirk. In persistent mode, if this is the + // initial mount, override the props of the host container to hide + // its contents. + const currentSuspenseBoundary = suspenseBoundary.alternate; + if (currentSuspenseBoundary === null) { + const offscreenFiber: Fiber = (suspenseBoundary.child: any); + const offscreenContainer = offscreenFiber.child; + if (offscreenContainer !== null) { + const children = offscreenContainer.memoizedProps.children; + const containerProps = getOffscreenContainerProps( + 'hidden', + children, + ); + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; + } + } + } + + if (sourceFiber.tag === ClassComponent) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber === null) { + // This is a new mount. Change the tag so it's not mistaken for a + // completed class component. For example, we should not call + // componentWillUnmount if it is deleted. + sourceFiber.tag = IncompleteClassComponent; + } else { + // When we try rendering again, we should not reuse the current fiber, + // since it's known to be in an inconsistent state. Use a force update to + // prevent a bail out. + const update = createUpdate(NoTimestamp, SyncLane); + update.tag = ForceUpdate; + enqueueUpdate(sourceFiber, update, SyncLane); + } + } + + // The source fiber did not complete. Mark it with Sync priority to + // indicate that it still has pending work. + sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane); + } + return suspenseBoundary; + } + // Confirmed that the boundary is in a concurrent mode tree. Continue + // with the normal suspend path. + // + // After this we'll use a set of heuristics to determine whether this + // render pass will run to completion or restart or "suspend" the commit. + // The actual logic for this is spread out in different places. + // + // This first principle is that if we're going to suspend when we complete + // a root, then we should also restart if we get an update or ping that + // might unsuspend it, and vice versa. The only reason to suspend is + // because you think you might want to restart before committing. However, + // it doesn't make sense to restart only while in the period we're suspended. + // + // Restarting too aggressively is also not good because it starves out any + // intermediate loading state. So we use heuristics to determine when. + + // Suspense Heuristics + // + // If nothing threw a Promise or all the same fallbacks are already showing, + // then don't suspend/restart. + // + // If this is an initial render of a new tree of Suspense boundaries and + // those trigger a fallback, then don't suspend/restart. We want to ensure + // that we can show the initial loading state as quickly as possible. + // + // If we hit a "Delayed" case, such as when we'd switch from content back into + // a fallback, then we should always suspend/restart. Transitions apply + // to this case. If none is defined, JND is used instead. + // + // If we're already showing a fallback and it gets "retried", allowing us to show + // another level, but there's still an inner boundary that would show a fallback, + // then we suspend/restart for 500ms since the last time we showed a fallback + // anywhere in the tree. This effectively throttles progressive loading into a + // consistent train of commits. This also gives us an opportunity to restart to + // get to the completed state slightly earlier. + // + // If there's ambiguity due to batching it's resolved in preference of: + // 1) "delayed", 2) "initial render", 3) "retry". + // + // We want to ensure that a "busy" state doesn't get force committed. We want to + // ensure that new initial loading states can commit as soon as possible. + suspenseBoundary.flags |= ShouldCapture; + // TODO: I think we can remove this, since we now use `DidCapture` in + // the begin phase to prevent an early bailout. + suspenseBoundary.lanes = rootRenderLanes; + return suspenseBoundary; + } + // This boundary already captured during this render. Continue to the next + // boundary. + node = node.return; + } while (node !== null); + + // Could not find a Suspense boundary capable of capturing. + return null; } function throwException( @@ -213,25 +444,9 @@ function throwException( typeof value === 'object' && typeof value.then === 'function' ) { - if (enableLazyContextPropagation) { - const currentSourceFiber = sourceFiber.alternate; - if (currentSourceFiber !== null) { - // Since we never visited the children of the suspended component, we - // need to propagate the context change now, to ensure that we visit - // them during the retry. - // - // We don't have to do this for errors because we retry errors without - // committing in between. So this is specific to Suspense. - propagateParentContextChangesToDeferredTree( - currentSourceFiber, - sourceFiber, - rootRenderLanes, - ); - } - } - - // This is a wakeable. + // This is a wakeable. The component suspended. const wakeable: Wakeable = (value: any); + resetSuspendedComponent(sourceFiber, rootRenderLanes); if (__DEV__) { if (enableDebugTracing) { @@ -242,190 +457,54 @@ function throwException( } } - // Reset the memoizedState to what it was before we attempted to render it. - // A legacy mode Suspense quirk, only relevant to hook components. - const tag = sourceFiber.tag; - if ( - (sourceFiber.mode & ConcurrentMode) === NoMode && - (tag === FunctionComponent || - tag === ForwardRef || - tag === SimpleMemoComponent) - ) { - const currentSource = sourceFiber.alternate; - if (currentSource) { - sourceFiber.updateQueue = currentSource.updateQueue; - sourceFiber.memoizedState = currentSource.memoizedState; - sourceFiber.lanes = currentSource.lanes; - } else { - sourceFiber.updateQueue = null; - sourceFiber.memoizedState = null; - } - } - - const hasInvisibleParentBoundary = hasSuspenseContext( - suspenseStackCursor.current, - (InvisibleParentSuspenseContext: SuspenseContext), - ); - // Schedule the nearest Suspense to re-render the timed out view. - let workInProgress = returnFiber; - do { - if ( - workInProgress.tag === SuspenseComponent && - shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary) - ) { - // Found the nearest boundary. - - // Stash the promise on the boundary fiber. If the boundary times out, we'll - // attach another listener to flip the boundary back to its normal state. - const wakeables: Set = (workInProgress.updateQueue: any); - if (wakeables === null) { - const updateQueue = (new Set(): any); - updateQueue.add(wakeable); - workInProgress.updateQueue = updateQueue; - } else { - wakeables.add(wakeable); - } - - if ((workInProgress.mode & ConcurrentMode) === NoMode) { - // Legacy Mode Suspense - // - // If the boundary is in legacy mode, we should *not* - // suspend the commit. Pretend as if the suspended component rendered - // null and keep rendering. When the Suspense boundary completes, - // we'll do a second pass to render the fallback. - if (workInProgress === returnFiber) { - // Special case where we suspended while reconciling the children of - // a Suspense boundary's inner Offscreen wrapper fiber. This happens - // when a React.lazy component is a direct child of a - // Suspense boundary. - // - // Suspense boundaries are implemented as multiple fibers, but they - // are a single conceptual unit. The legacy mode behavior where we - // pretend the suspended fiber committed as `null` won't work, - // because in this case the "suspended" fiber is the inner - // Offscreen wrapper. - // - // Because the contents of the boundary haven't started rendering - // yet (i.e. nothing in the tree has partially rendered) we can - // switch to the regular, concurrent mode behavior: mark the - // boundary with ShouldCapture and enter the unwind phase. - workInProgress.flags |= ShouldCapture; - } else { - workInProgress.flags |= DidCapture; - sourceFiber.flags |= ForceUpdateForLegacySuspense; - - // We're going to commit this fiber even though it didn't complete. - // But we shouldn't call any lifecycle methods or callbacks. Remove - // all lifecycle effect tags. - sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete); - - if (supportsPersistence && enablePersistentOffscreenHostContainer) { - // Another legacy Suspense quirk. In persistent mode, if this is the - // initial mount, override the props of the host container to hide - // its contents. - const currentSuspenseBoundary = workInProgress.alternate; - if (currentSuspenseBoundary === null) { - const offscreenFiber: Fiber = (workInProgress.child: any); - const offscreenContainer = offscreenFiber.child; - if (offscreenContainer !== null) { - const children = offscreenContainer.memoizedProps.children; - const containerProps = getOffscreenContainerProps( - 'hidden', - children, - ); - offscreenContainer.pendingProps = containerProps; - offscreenContainer.memoizedProps = containerProps; - } - } - } - - if (sourceFiber.tag === ClassComponent) { - const currentSourceFiber = sourceFiber.alternate; - if (currentSourceFiber === null) { - // This is a new mount. Change the tag so it's not mistaken for a - // completed class component. For example, we should not call - // componentWillUnmount if it is deleted. - sourceFiber.tag = IncompleteClassComponent; - } else { - // When we try rendering again, we should not reuse the current fiber, - // since it's known to be in an inconsistent state. Use a force update to - // prevent a bail out. - const update = createUpdate(NoTimestamp, SyncLane); - update.tag = ForceUpdate; - enqueueUpdate(sourceFiber, update, SyncLane); - } - } - - // The source fiber did not complete. Mark it with Sync priority to - // indicate that it still has pending work. - sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane); - } - return; - } - // Confirmed that the boundary is in a concurrent mode tree. Continue - // with the normal suspend path. - // - // After this we'll use a set of heuristics to determine whether this - // render pass will run to completion or restart or "suspend" the commit. - // The actual logic for this is spread out in different places. - // - // This first principle is that if we're going to suspend when we complete - // a root, then we should also restart if we get an update or ping that - // might unsuspend it, and vice versa. The only reason to suspend is - // because you think you might want to restart before committing. However, - // it doesn't make sense to restart only while in the period we're suspended. - // - // Restarting too aggressively is also not good because it starves out any - // intermediate loading state. So we use heuristics to determine when. - - // Suspense Heuristics - // - // If nothing threw a Promise or all the same fallbacks are already showing, - // then don't suspend/restart. - // - // If this is an initial render of a new tree of Suspense boundaries and - // those trigger a fallback, then don't suspend/restart. We want to ensure - // that we can show the initial loading state as quickly as possible. - // - // If we hit a "Delayed" case, such as when we'd switch from content back into - // a fallback, then we should always suspend/restart. Transitions apply - // to this case. If none is defined, JND is used instead. - // - // If we're already showing a fallback and it gets "retried", allowing us to show - // another level, but there's still an inner boundary that would show a fallback, - // then we suspend/restart for 500ms since the last time we showed a fallback - // anywhere in the tree. This effectively throttles progressive loading into a - // consistent train of commits. This also gives us an opportunity to restart to - // get to the completed state slightly earlier. - // - // If there's ambiguity due to batching it's resolved in preference of: - // 1) "delayed", 2) "initial render", 3) "retry". - // - // We want to ensure that a "busy" state doesn't get force committed. We want to - // ensure that new initial loading states can commit as soon as possible. - - attachPingListener(root, wakeable, rootRenderLanes); - - workInProgress.flags |= ShouldCapture; - // TODO: I think we can remove this, since we now use `DidCapture` in - // the begin phase to prevent an early bailout. - workInProgress.lanes = rootRenderLanes; + const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); + if (suspenseBoundary !== null) { + attachWakeableListeners( + suspenseBoundary, + root, + wakeable, + rootRenderLanes, + ); + return; + } else { + // No boundary was found. Fallthrough to error mode. + // TODO: Use invariant so the message is stripped in prod? + value = new Error( + (getComponentNameFromFiber(sourceFiber) || 'A React component') + + ' suspended while rendering, but no fallback UI was specified.\n' + + '\n' + + 'Add a component higher in the tree to ' + + 'provide a loading indicator or placeholder to display.', + ); + } + } else { + // This is a regular error, not a Suspense wakeable. + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + // If the error was thrown during hydration, we may be able to recover by + // discarding the dehydrated content and switching to a client render. + // Instead of surfacing the error, find the nearest Suspense boundary + // and render it again without hydration. + const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); + if (suspenseBoundary !== null) { + // Set a flag to indicate that we should try rendering the normal + // children again, not the fallback. + suspenseBoundary.flags |= ForceClientRender; return; } - // This boundary already captured during this render. Continue to the next - // boundary. - workInProgress = workInProgress.return; - } while (workInProgress !== null); - // No boundary was found. Fallthrough to error mode. - // TODO: Use invariant so the message is stripped in prod? - value = new Error( - (getComponentNameFromFiber(sourceFiber) || 'A React component') + - ' suspended while rendering, but no fallback UI was specified.\n' + - '\n' + - 'Add a component higher in the tree to ' + - 'provide a loading indicator or placeholder to display.', - ); + } else { + // Otherwise, fall through to the error path. + } } // We didn't find a boundary that could handle this type of exception. Start diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index dcba4b521aebc..d2d39793a3bc0 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -32,6 +32,7 @@ import { ShouldCapture, LifecycleEffectMask, ForceUpdateForLegacySuspense, + ForceClientRender, } from './ReactFiberFlags'; import { supportsPersistence, @@ -78,6 +79,7 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.old'; +import {getIsHydrating} from './ReactFiberHydrationContext.old'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -160,35 +162,264 @@ function createClassErrorUpdate( return update; } -function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) { - // Attach a listener to the promise to "ping" the root and retry. But only if - // one does not already exist for the lanes we're currently rendering (which - // acts like a "thread ID" here). - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(wakeable, threadIDs); - } else { - threadIDs = pingCache.get(wakeable); - if (threadIDs === undefined) { +function attachWakeableListeners( + suspenseBoundary: Fiber, + root: FiberRoot, + wakeable: Wakeable, + lanes: Lanes, +) { + // Attach a ping listener + // + // The data might resolve before we have a chance to commit the fallback. Or, + // in the case of a refresh, we'll never commit a fallback. So we need to + // attach a listener now. When it resolves ("pings"), we can decide whether to + // try rendering the tree again. + // + // Only attach a listener if one does not already exist for the lanes + // we're currently rendering (which acts like a "thread ID" here). + // + // We only need to do this in concurrent mode. Legacy Suspense always + // commits fallbacks synchronously, so there are no pings. + if (suspenseBoundary.mode & ConcurrentMode) { + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); threadIDs = new Set(); pingCache.set(wakeable, threadIDs); + } else { + threadIDs = pingCache.get(wakeable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(wakeable, threadIDs); + } } - } - if (!threadIDs.has(lanes)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(lanes); - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); - if (enableUpdaterTracking) { - if (isDevToolsPresent) { - // If we have pending work still, restore the original updaters - restorePendingUpdaters(root, lanes); + if (!threadIDs.has(lanes)) { + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(lanes); + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes); + if (enableUpdaterTracking) { + if (isDevToolsPresent) { + // If we have pending work still, restore the original updaters + restorePendingUpdaters(root, lanes); + } } + wakeable.then(ping, ping); } - wakeable.then(ping, ping); } + + // Retry listener + // + // If the fallback does commit, we need to attach a different type of + // listener. This one schedules an update on the Suspense boundary to turn + // the fallback state off. + // + // Stash the wakeable on the boundary fiber so we can access it in the + // commit phase. + // + // When the wakeable resolves, we'll attempt to render the boundary + // again ("retry"). + const wakeables: Set | null = (suspenseBoundary.updateQueue: any); + if (wakeables === null) { + const updateQueue = (new Set(): any); + updateQueue.add(wakeable); + suspenseBoundary.updateQueue = updateQueue; + } else { + wakeables.add(wakeable); + } +} + +function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { + if (enableLazyContextPropagation) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber !== null) { + // Since we never visited the children of the suspended component, we + // need to propagate the context change now, to ensure that we visit + // them during the retry. + // + // We don't have to do this for errors because we retry errors without + // committing in between. So this is specific to Suspense. + propagateParentContextChangesToDeferredTree( + currentSourceFiber, + sourceFiber, + rootRenderLanes, + ); + } + } + + // Reset the memoizedState to what it was before we attempted to render it. + // A legacy mode Suspense quirk, only relevant to hook components. + const tag = sourceFiber.tag; + if ( + (sourceFiber.mode & ConcurrentMode) === NoMode && + (tag === FunctionComponent || + tag === ForwardRef || + tag === SimpleMemoComponent) + ) { + const currentSource = sourceFiber.alternate; + if (currentSource) { + sourceFiber.updateQueue = currentSource.updateQueue; + sourceFiber.memoizedState = currentSource.memoizedState; + sourceFiber.lanes = currentSource.lanes; + } else { + sourceFiber.updateQueue = null; + sourceFiber.memoizedState = null; + } + } +} + +function markNearestSuspenseBoundaryShouldCapture( + returnFiber: Fiber, + sourceFiber: Fiber, + root: FiberRoot, + rootRenderLanes: Lanes, +): Fiber | null { + const hasInvisibleParentBoundary = hasSuspenseContext( + suspenseStackCursor.current, + (InvisibleParentSuspenseContext: SuspenseContext), + ); + let node = returnFiber; + do { + if ( + node.tag === SuspenseComponent && + shouldCaptureSuspense(node, hasInvisibleParentBoundary) + ) { + // Found the nearest boundary. + const suspenseBoundary = node; + + // This marks a Suspense boundary so that when we're unwinding the stack, + // it captures the suspended "exception" and does a second (fallback) pass. + + if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) { + // Legacy Mode Suspense + // + // If the boundary is in legacy mode, we should *not* + // suspend the commit. Pretend as if the suspended component rendered + // null and keep rendering. When the Suspense boundary completes, + // we'll do a second pass to render the fallback. + if (suspenseBoundary === returnFiber) { + // Special case where we suspended while reconciling the children of + // a Suspense boundary's inner Offscreen wrapper fiber. This happens + // when a React.lazy component is a direct child of a + // Suspense boundary. + // + // Suspense boundaries are implemented as multiple fibers, but they + // are a single conceptual unit. The legacy mode behavior where we + // pretend the suspended fiber committed as `null` won't work, + // because in this case the "suspended" fiber is the inner + // Offscreen wrapper. + // + // Because the contents of the boundary haven't started rendering + // yet (i.e. nothing in the tree has partially rendered) we can + // switch to the regular, concurrent mode behavior: mark the + // boundary with ShouldCapture and enter the unwind phase. + suspenseBoundary.flags |= ShouldCapture; + } else { + suspenseBoundary.flags |= DidCapture; + sourceFiber.flags |= ForceUpdateForLegacySuspense; + + // We're going to commit this fiber even though it didn't complete. + // But we shouldn't call any lifecycle methods or callbacks. Remove + // all lifecycle effect tags. + sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete); + + if (supportsPersistence && enablePersistentOffscreenHostContainer) { + // Another legacy Suspense quirk. In persistent mode, if this is the + // initial mount, override the props of the host container to hide + // its contents. + const currentSuspenseBoundary = suspenseBoundary.alternate; + if (currentSuspenseBoundary === null) { + const offscreenFiber: Fiber = (suspenseBoundary.child: any); + const offscreenContainer = offscreenFiber.child; + if (offscreenContainer !== null) { + const children = offscreenContainer.memoizedProps.children; + const containerProps = getOffscreenContainerProps( + 'hidden', + children, + ); + offscreenContainer.pendingProps = containerProps; + offscreenContainer.memoizedProps = containerProps; + } + } + } + + if (sourceFiber.tag === ClassComponent) { + const currentSourceFiber = sourceFiber.alternate; + if (currentSourceFiber === null) { + // This is a new mount. Change the tag so it's not mistaken for a + // completed class component. For example, we should not call + // componentWillUnmount if it is deleted. + sourceFiber.tag = IncompleteClassComponent; + } else { + // When we try rendering again, we should not reuse the current fiber, + // since it's known to be in an inconsistent state. Use a force update to + // prevent a bail out. + const update = createUpdate(NoTimestamp, SyncLane); + update.tag = ForceUpdate; + enqueueUpdate(sourceFiber, update, SyncLane); + } + } + + // The source fiber did not complete. Mark it with Sync priority to + // indicate that it still has pending work. + sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane); + } + return suspenseBoundary; + } + // Confirmed that the boundary is in a concurrent mode tree. Continue + // with the normal suspend path. + // + // After this we'll use a set of heuristics to determine whether this + // render pass will run to completion or restart or "suspend" the commit. + // The actual logic for this is spread out in different places. + // + // This first principle is that if we're going to suspend when we complete + // a root, then we should also restart if we get an update or ping that + // might unsuspend it, and vice versa. The only reason to suspend is + // because you think you might want to restart before committing. However, + // it doesn't make sense to restart only while in the period we're suspended. + // + // Restarting too aggressively is also not good because it starves out any + // intermediate loading state. So we use heuristics to determine when. + + // Suspense Heuristics + // + // If nothing threw a Promise or all the same fallbacks are already showing, + // then don't suspend/restart. + // + // If this is an initial render of a new tree of Suspense boundaries and + // those trigger a fallback, then don't suspend/restart. We want to ensure + // that we can show the initial loading state as quickly as possible. + // + // If we hit a "Delayed" case, such as when we'd switch from content back into + // a fallback, then we should always suspend/restart. Transitions apply + // to this case. If none is defined, JND is used instead. + // + // If we're already showing a fallback and it gets "retried", allowing us to show + // another level, but there's still an inner boundary that would show a fallback, + // then we suspend/restart for 500ms since the last time we showed a fallback + // anywhere in the tree. This effectively throttles progressive loading into a + // consistent train of commits. This also gives us an opportunity to restart to + // get to the completed state slightly earlier. + // + // If there's ambiguity due to batching it's resolved in preference of: + // 1) "delayed", 2) "initial render", 3) "retry". + // + // We want to ensure that a "busy" state doesn't get force committed. We want to + // ensure that new initial loading states can commit as soon as possible. + suspenseBoundary.flags |= ShouldCapture; + // TODO: I think we can remove this, since we now use `DidCapture` in + // the begin phase to prevent an early bailout. + suspenseBoundary.lanes = rootRenderLanes; + return suspenseBoundary; + } + // This boundary already captured during this render. Continue to the next + // boundary. + node = node.return; + } while (node !== null); + + // Could not find a Suspense boundary capable of capturing. + return null; } function throwException( @@ -213,25 +444,9 @@ function throwException( typeof value === 'object' && typeof value.then === 'function' ) { - if (enableLazyContextPropagation) { - const currentSourceFiber = sourceFiber.alternate; - if (currentSourceFiber !== null) { - // Since we never visited the children of the suspended component, we - // need to propagate the context change now, to ensure that we visit - // them during the retry. - // - // We don't have to do this for errors because we retry errors without - // committing in between. So this is specific to Suspense. - propagateParentContextChangesToDeferredTree( - currentSourceFiber, - sourceFiber, - rootRenderLanes, - ); - } - } - - // This is a wakeable. + // This is a wakeable. The component suspended. const wakeable: Wakeable = (value: any); + resetSuspendedComponent(sourceFiber, rootRenderLanes); if (__DEV__) { if (enableDebugTracing) { @@ -242,190 +457,54 @@ function throwException( } } - // Reset the memoizedState to what it was before we attempted to render it. - // A legacy mode Suspense quirk, only relevant to hook components. - const tag = sourceFiber.tag; - if ( - (sourceFiber.mode & ConcurrentMode) === NoMode && - (tag === FunctionComponent || - tag === ForwardRef || - tag === SimpleMemoComponent) - ) { - const currentSource = sourceFiber.alternate; - if (currentSource) { - sourceFiber.updateQueue = currentSource.updateQueue; - sourceFiber.memoizedState = currentSource.memoizedState; - sourceFiber.lanes = currentSource.lanes; - } else { - sourceFiber.updateQueue = null; - sourceFiber.memoizedState = null; - } - } - - const hasInvisibleParentBoundary = hasSuspenseContext( - suspenseStackCursor.current, - (InvisibleParentSuspenseContext: SuspenseContext), - ); - // Schedule the nearest Suspense to re-render the timed out view. - let workInProgress = returnFiber; - do { - if ( - workInProgress.tag === SuspenseComponent && - shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary) - ) { - // Found the nearest boundary. - - // Stash the promise on the boundary fiber. If the boundary times out, we'll - // attach another listener to flip the boundary back to its normal state. - const wakeables: Set = (workInProgress.updateQueue: any); - if (wakeables === null) { - const updateQueue = (new Set(): any); - updateQueue.add(wakeable); - workInProgress.updateQueue = updateQueue; - } else { - wakeables.add(wakeable); - } - - if ((workInProgress.mode & ConcurrentMode) === NoMode) { - // Legacy Mode Suspense - // - // If the boundary is in legacy mode, we should *not* - // suspend the commit. Pretend as if the suspended component rendered - // null and keep rendering. When the Suspense boundary completes, - // we'll do a second pass to render the fallback. - if (workInProgress === returnFiber) { - // Special case where we suspended while reconciling the children of - // a Suspense boundary's inner Offscreen wrapper fiber. This happens - // when a React.lazy component is a direct child of a - // Suspense boundary. - // - // Suspense boundaries are implemented as multiple fibers, but they - // are a single conceptual unit. The legacy mode behavior where we - // pretend the suspended fiber committed as `null` won't work, - // because in this case the "suspended" fiber is the inner - // Offscreen wrapper. - // - // Because the contents of the boundary haven't started rendering - // yet (i.e. nothing in the tree has partially rendered) we can - // switch to the regular, concurrent mode behavior: mark the - // boundary with ShouldCapture and enter the unwind phase. - workInProgress.flags |= ShouldCapture; - } else { - workInProgress.flags |= DidCapture; - sourceFiber.flags |= ForceUpdateForLegacySuspense; - - // We're going to commit this fiber even though it didn't complete. - // But we shouldn't call any lifecycle methods or callbacks. Remove - // all lifecycle effect tags. - sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete); - - if (supportsPersistence && enablePersistentOffscreenHostContainer) { - // Another legacy Suspense quirk. In persistent mode, if this is the - // initial mount, override the props of the host container to hide - // its contents. - const currentSuspenseBoundary = workInProgress.alternate; - if (currentSuspenseBoundary === null) { - const offscreenFiber: Fiber = (workInProgress.child: any); - const offscreenContainer = offscreenFiber.child; - if (offscreenContainer !== null) { - const children = offscreenContainer.memoizedProps.children; - const containerProps = getOffscreenContainerProps( - 'hidden', - children, - ); - offscreenContainer.pendingProps = containerProps; - offscreenContainer.memoizedProps = containerProps; - } - } - } - - if (sourceFiber.tag === ClassComponent) { - const currentSourceFiber = sourceFiber.alternate; - if (currentSourceFiber === null) { - // This is a new mount. Change the tag so it's not mistaken for a - // completed class component. For example, we should not call - // componentWillUnmount if it is deleted. - sourceFiber.tag = IncompleteClassComponent; - } else { - // When we try rendering again, we should not reuse the current fiber, - // since it's known to be in an inconsistent state. Use a force update to - // prevent a bail out. - const update = createUpdate(NoTimestamp, SyncLane); - update.tag = ForceUpdate; - enqueueUpdate(sourceFiber, update, SyncLane); - } - } - - // The source fiber did not complete. Mark it with Sync priority to - // indicate that it still has pending work. - sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane); - } - return; - } - // Confirmed that the boundary is in a concurrent mode tree. Continue - // with the normal suspend path. - // - // After this we'll use a set of heuristics to determine whether this - // render pass will run to completion or restart or "suspend" the commit. - // The actual logic for this is spread out in different places. - // - // This first principle is that if we're going to suspend when we complete - // a root, then we should also restart if we get an update or ping that - // might unsuspend it, and vice versa. The only reason to suspend is - // because you think you might want to restart before committing. However, - // it doesn't make sense to restart only while in the period we're suspended. - // - // Restarting too aggressively is also not good because it starves out any - // intermediate loading state. So we use heuristics to determine when. - - // Suspense Heuristics - // - // If nothing threw a Promise or all the same fallbacks are already showing, - // then don't suspend/restart. - // - // If this is an initial render of a new tree of Suspense boundaries and - // those trigger a fallback, then don't suspend/restart. We want to ensure - // that we can show the initial loading state as quickly as possible. - // - // If we hit a "Delayed" case, such as when we'd switch from content back into - // a fallback, then we should always suspend/restart. Transitions apply - // to this case. If none is defined, JND is used instead. - // - // If we're already showing a fallback and it gets "retried", allowing us to show - // another level, but there's still an inner boundary that would show a fallback, - // then we suspend/restart for 500ms since the last time we showed a fallback - // anywhere in the tree. This effectively throttles progressive loading into a - // consistent train of commits. This also gives us an opportunity to restart to - // get to the completed state slightly earlier. - // - // If there's ambiguity due to batching it's resolved in preference of: - // 1) "delayed", 2) "initial render", 3) "retry". - // - // We want to ensure that a "busy" state doesn't get force committed. We want to - // ensure that new initial loading states can commit as soon as possible. - - attachPingListener(root, wakeable, rootRenderLanes); - - workInProgress.flags |= ShouldCapture; - // TODO: I think we can remove this, since we now use `DidCapture` in - // the begin phase to prevent an early bailout. - workInProgress.lanes = rootRenderLanes; + const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); + if (suspenseBoundary !== null) { + attachWakeableListeners( + suspenseBoundary, + root, + wakeable, + rootRenderLanes, + ); + return; + } else { + // No boundary was found. Fallthrough to error mode. + // TODO: Use invariant so the message is stripped in prod? + value = new Error( + (getComponentNameFromFiber(sourceFiber) || 'A React component') + + ' suspended while rendering, but no fallback UI was specified.\n' + + '\n' + + 'Add a component higher in the tree to ' + + 'provide a loading indicator or placeholder to display.', + ); + } + } else { + // This is a regular error, not a Suspense wakeable. + if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { + // If the error was thrown during hydration, we may be able to recover by + // discarding the dehydrated content and switching to a client render. + // Instead of surfacing the error, find the nearest Suspense boundary + // and render it again without hydration. + const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture( + returnFiber, + sourceFiber, + root, + rootRenderLanes, + ); + if (suspenseBoundary !== null) { + // Set a flag to indicate that we should try rendering the normal + // children again, not the fallback. + suspenseBoundary.flags |= ForceClientRender; return; } - // This boundary already captured during this render. Continue to the next - // boundary. - workInProgress = workInProgress.return; - } while (workInProgress !== null); - // No boundary was found. Fallthrough to error mode. - // TODO: Use invariant so the message is stripped in prod? - value = new Error( - (getComponentNameFromFiber(sourceFiber) || 'A React component') + - ' suspended while rendering, but no fallback UI was specified.\n' + - '\n' + - 'Add a component higher in the tree to ' + - 'provide a loading indicator or placeholder to display.', - ); + } else { + // Otherwise, fall through to the error path. + } } // We didn't find a boundary that could handle this type of exception. Start