diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index a43b0a650c984..e091bfbbb81d6 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -31,7 +31,6 @@ import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; import type {Cache} from './ReactFiberCacheComponent.new'; import { - enableSuspenseAvoidThisFallback, enableLegacyHidden, enableHostSingletons, enableSuspenseCallback, @@ -127,11 +126,9 @@ import { setShallowSuspenseListContext, ForceSuspenseFallback, setDefaultShallowSuspenseListContext, + isBadSuspenseFallback, } from './ReactFiberSuspenseContext.new'; -import { - popHiddenContext, - isCurrentTreeHidden, -} from './ReactFiberHiddenContext.new'; +import {popHiddenContext} from './ReactFiberHiddenContext.new'; import {findFirstSuspended} from './ReactFiberSuspenseComponent.new'; import { isContextProvider as isLegacyContextProvider, @@ -1272,20 +1269,7 @@ function completeWork( // If this render already had a ping or lower pri updates, // and this is the first time we know we're going to suspend we // should be able to immediately restart from within throwException. - - // Check if this is a "bad" fallback state or a good one. A bad - // fallback state is one that we only show as a last resort; if this - // is a transition, we'll block it from displaying, and wait for - // more data to arrive. - const isBadFallback = - // It's bad to switch to a fallback if content is already visible - (current !== null && !prevDidTimeout && !isCurrentTreeHidden()) || - // Experimental: Some fallbacks are always bad - (enableSuspenseAvoidThisFallback && - workInProgress.memoizedProps.unstable_avoidThisFallback === - true); - - if (isBadFallback) { + if (isBadSuspenseFallback(current, newProps)) { renderDidSuspendDelayIfPossible(); } else { renderDidSuspend(); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index b3322972f23af..8ec8b3e4e927f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -31,7 +31,6 @@ import type {OffscreenState} from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; import type {Cache} from './ReactFiberCacheComponent.old'; import { - enableSuspenseAvoidThisFallback, enableLegacyHidden, enableHostSingletons, enableSuspenseCallback, @@ -127,11 +126,9 @@ import { setShallowSuspenseListContext, ForceSuspenseFallback, setDefaultShallowSuspenseListContext, + isBadSuspenseFallback, } from './ReactFiberSuspenseContext.old'; -import { - popHiddenContext, - isCurrentTreeHidden, -} from './ReactFiberHiddenContext.old'; +import {popHiddenContext} from './ReactFiberHiddenContext.old'; import {findFirstSuspended} from './ReactFiberSuspenseComponent.old'; import { isContextProvider as isLegacyContextProvider, @@ -1272,20 +1269,7 @@ function completeWork( // If this render already had a ping or lower pri updates, // and this is the first time we know we're going to suspend we // should be able to immediately restart from within throwException. - - // Check if this is a "bad" fallback state or a good one. A bad - // fallback state is one that we only show as a last resort; if this - // is a transition, we'll block it from displaying, and wait for - // more data to arrive. - const isBadFallback = - // It's bad to switch to a fallback if content is already visible - (current !== null && !prevDidTimeout && !isCurrentTreeHidden()) || - // Experimental: Some fallbacks are always bad - (enableSuspenseAvoidThisFallback && - workInProgress.memoizedProps.unstable_avoidThisFallback === - true); - - if (isBadFallback) { + if (isBadSuspenseFallback(current, newProps)) { renderDidSuspendDelayIfPossible(); } else { renderDidSuspend(); diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index ffbc13a9f09fd..a672e8b0ef568 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -27,6 +27,7 @@ export type SuspenseProps = { // TODO: Add "unstable_" prefix? suspenseCallback?: (Set | null) => mixed, + unstable_avoidThisFallback?: boolean, unstable_expectedLoadTime?: number, unstable_name?: string, }; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index 05663830c3886..2ce9c2adf261b 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -27,6 +27,7 @@ export type SuspenseProps = { // TODO: Add "unstable_" prefix? suspenseCallback?: (Set | null) => mixed, + unstable_avoidThisFallback?: boolean, unstable_expectedLoadTime?: number, unstable_name?: string, }; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js index d28440dcb239f..1bdd54ba83fb6 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.new.js @@ -9,7 +9,10 @@ import type {Fiber} from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack.new'; -import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; +import type { + SuspenseState, + SuspenseProps, +} from './ReactFiberSuspenseComponent.new'; import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {createCursor, push, pop} from './ReactFiberStack.new'; @@ -55,6 +58,33 @@ function shouldAvoidedBoundaryCapture( return false; } +export function isBadSuspenseFallback( + current: Fiber | null, + nextProps: SuspenseProps, +): boolean { + // Check if this is a "bad" fallback state or a good one. A bad fallback state + // is one that we only show as a last resort; if this is a transition, we'll + // block it from displaying, and wait for more data to arrive. + if (current !== null) { + const prevState: SuspenseState = current.memoizedState; + const isShowingFallback = prevState !== null; + if (!isShowingFallback && !isCurrentTreeHidden()) { + // It's bad to switch to a fallback if content is already visible + return true; + } + } + + if ( + enableSuspenseAvoidThisFallback && + nextProps.unstable_avoidThisFallback === true + ) { + // Experimental: Some fallbacks are always bad + return true; + } + + return false; +} + export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void { const props = handler.pendingProps; const handlerOnStack = suspenseHandlerStackCursor.current; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js index 71caf48e60e71..025de9bf94457 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.old.js @@ -9,7 +9,10 @@ import type {Fiber} from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack.old'; -import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; +import type { + SuspenseState, + SuspenseProps, +} from './ReactFiberSuspenseComponent.old'; import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {createCursor, push, pop} from './ReactFiberStack.old'; @@ -55,6 +58,33 @@ function shouldAvoidedBoundaryCapture( return false; } +export function isBadSuspenseFallback( + current: Fiber | null, + nextProps: SuspenseProps, +): boolean { + // Check if this is a "bad" fallback state or a good one. A bad fallback state + // is one that we only show as a last resort; if this is a transition, we'll + // block it from displaying, and wait for more data to arrive. + if (current !== null) { + const prevState: SuspenseState = current.memoizedState; + const isShowingFallback = prevState !== null; + if (!isShowingFallback && !isCurrentTreeHidden()) { + // It's bad to switch to a fallback if content is already visible + return true; + } + } + + if ( + enableSuspenseAvoidThisFallback && + nextProps.unstable_avoidThisFallback === true + ) { + // Experimental: Some fallbacks are always bad + return true; + } + + return false; +} + export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void { const props = handler.pendingProps; const handlerOnStack = suspenseHandlerStackCursor.current; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 80ab4cb37357a..ce03757caa4e7 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -310,11 +310,18 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; +opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4; +const NotSuspended: SuspendedReason = 0; +const SuspendedOnError: SuspendedReason = 1; +// const SuspendedOnData: SuspendedReason = 2; +const SuspendedOnImmediate: SuspendedReason = 3; +const SuspendedAndReadyToUnwind: SuspendedReason = 4; + // When this is true, the work-in-progress fiber just suspended (or errored) and // we've yet to unwind the stack. In some cases, we may yield to the main thread // after this happens. If the fiber is pinged before we resume, we can retry // immediately instead of unwinding the stack. -let workInProgressIsSuspended: boolean = false; +let workInProgressSuspendedReason: SuspendedReason = NotSuspended; let workInProgressThrownValue: mixed = null; let workInProgressSuspendedThenableState: ThenableState | null = null; @@ -1676,9 +1683,10 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { } if (workInProgress !== null) { - let interruptedWork = workInProgressIsSuspended - ? workInProgress - : workInProgress.return; + let interruptedWork = + workInProgressSuspendedReason === NotSuspended + ? workInProgress.return + : workInProgress; while (interruptedWork !== null) { const current = interruptedWork.alternate; unwindInterruptedWork( @@ -1693,7 +1701,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; - workInProgressIsSuspended = false; + workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; workInProgressSuspendedThenableState = null; workInProgressRootDidAttachPingListener = false; @@ -1732,17 +1740,27 @@ function handleThrow(root, thrownValue): void { // deprecate the old API in favor of `use`. thrownValue = getSuspendedThenable(); workInProgressSuspendedThenableState = getThenableStateAfterSuspending(); + workInProgressSuspendedReason = SuspendedOnImmediate; } else { // This is a regular error. If something earlier in the component already // suspended, we must clear the thenable state to unblock the work loop. workInProgressSuspendedThenableState = null; + + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + // $FlowFixMe[method-unbinding] + typeof thrownValue.then === 'function'; + + workInProgressSuspendedReason = isWakeable + ? // A wakeable object was thrown by a legacy Suspense implementation. + // This has slightly different behavior than suspending with `use`. + SuspendedAndReadyToUnwind + : // This is a regular error. If something earlier in the component already + // suspended, we must clear the thenable state to unblock the work loop. + SuspendedOnError; } - // Setting this to `true` tells the work loop to unwind the stack instead - // of entering the begin phase. It's called "suspended" because it usually - // happens because of Suspense, but it also applies to errors. Think of it - // as suspending the execution of the work loop. - workInProgressIsSuspended = true; workInProgressThrownValue = thrownValue; const erroredWork = workInProgress; @@ -1762,12 +1780,7 @@ function handleThrow(root, thrownValue): void { if (enableSchedulingProfiler) { markComponentRenderStopped(); - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - // $FlowFixMe[method-unbinding] - typeof thrownValue.then === 'function' - ) { + if (workInProgressSuspendedReason !== SuspendedOnError) { const wakeable: Wakeable = (thrownValue: any); markComponentSuspended( erroredWork, @@ -1923,6 +1936,42 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { do { try { + if ( + workInProgressSuspendedReason !== NotSuspended && + workInProgress !== null + ) { + // The work loop is suspended. We need to either unwind the stack or + // replay the suspended component. + const unitOfWork = workInProgress; + const thrownValue = workInProgressThrownValue; + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + + // TODO: This check is only here to account for thenables that + // synchronously resolve. Otherwise we would always unwind when + // rendering with renderRootSync. (In the future, discrete updates will + // use renderRootConcurrent instead.) We should account for + // synchronously resolved thenables before hitting this path. + switch (workInProgressSuspendedReason) { + case SuspendedOnError: { + // Unwind then continue with the normal work loop. + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + break; + } + default: { + const wasPinged = + workInProgressSuspendedThenableState !== null && + isThenableStateResolved(workInProgressSuspendedThenableState); + if (wasPinged) { + replaySuspendedUnitOfWork(unitOfWork, thrownValue); + } else { + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + } + // Continue with the normal work loop. + break; + } + } + } workLoopSync(); break; } catch (thrownValue) { @@ -1967,18 +2016,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopSync() { // Perform work without checking if we need to yield between fiber. - - if (workInProgressIsSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressIsSuspended = false; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null) { performUnitOfWork(workInProgress); } @@ -2026,6 +2063,36 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { do { try { + if ( + workInProgressSuspendedReason !== NotSuspended && + workInProgress !== null + ) { + // The work loop is suspended. We need to either unwind the stack or + // replay the suspended component. + const unitOfWork = workInProgress; + const thrownValue = workInProgressThrownValue; + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + switch (workInProgressSuspendedReason) { + case SuspendedOnError: { + // Unwind then continue with the normal work loop. + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + break; + } + default: { + const wasPinged = + workInProgressSuspendedThenableState !== null && + isThenableStateResolved(workInProgressSuspendedThenableState); + if (wasPinged) { + replaySuspendedUnitOfWork(unitOfWork, thrownValue); + } else { + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + } + // Continue with the normal work loop. + break; + } + } + } workLoopConcurrent(); break; } catch (thrownValue) { @@ -2078,18 +2145,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - - if (workInProgressIsSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressIsSuspended = false; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null && !shouldYield()) { // $FlowFixMe[incompatible-call] found when upgrading Flow performUnitOfWork(workInProgress); @@ -2124,69 +2179,15 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function resumeSuspendedUnitOfWork( +function replaySuspendedUnitOfWork( unitOfWork: Fiber, thrownValue: mixed, ): void { - // This is a fork of performUnitOfWork specifcally for resuming a fiber that - // just suspended. In some cases, we may choose to retry the fiber immediately - // instead of unwinding the stack. It's a separate function to keep the - // additional logic out of the work loop's hot path. - - const wasPinged = - workInProgressSuspendedThenableState !== null && - isThenableStateResolved(workInProgressSuspendedThenableState); - - if (!wasPinged) { - // The thenable wasn't pinged. Return to the normal work loop. This will - // unwind the stack, and potentially result in showing a fallback. - workInProgressSuspendedThenableState = null; - - const returnFiber = unitOfWork.return; - if (returnFiber === null || workInProgressRoot === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return; - } - - try { - // Find and mark the nearest Suspense or error boundary that can handle - // this "exception". - throwException( - workInProgressRoot, - returnFiber, - unitOfWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } catch (error) { - // We had trouble processing the error. An example of this happening is - // when accessing the `componentDidCatch` property of an error boundary - // throws an error. A weird edge case. There's a regression test for this. - // To prevent an infinite loop, bubble the error up to the next parent. - workInProgress = returnFiber; - throw error; - } - - // Return to the normal work loop. - completeUnitOfWork(unitOfWork); - return; - } - - // The work-in-progress was immediately pinged. Instead of unwinding the - // stack and potentially showing a fallback, unwind only the last stack frame, - // reset the fiber, and try rendering it again. + // This is a fork of performUnitOfWork specifcally for replaying a fiber that + // just suspended. + // + // Instead of unwinding the stack and potentially showing a fallback, unwind + // only the last stack frame, reset the fiber, and try rendering it again. const current = unitOfWork.alternate; unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); @@ -2219,6 +2220,55 @@ function resumeSuspendedUnitOfWork( ReactCurrentOwner.current = null; } +function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) { + // This is a fork of performUnitOfWork specifcally for unwinding a fiber + // that threw an exception. + // + // Return to the normal work loop. This will unwind the stack, and potentially + // result in showing a fallback. + workInProgressSuspendedThenableState = null; + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. + completeUnitOfWork(unitOfWork); +} + export function getSuspendedThenableState(): ThenableState | null { return workInProgressSuspendedThenableState; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index db7f8585246e6..444b8129c0db5 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -310,11 +310,18 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; +opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4; +const NotSuspended: SuspendedReason = 0; +const SuspendedOnError: SuspendedReason = 1; +// const SuspendedOnData: SuspendedReason = 2; +const SuspendedOnImmediate: SuspendedReason = 3; +const SuspendedAndReadyToUnwind: SuspendedReason = 4; + // When this is true, the work-in-progress fiber just suspended (or errored) and // we've yet to unwind the stack. In some cases, we may yield to the main thread // after this happens. If the fiber is pinged before we resume, we can retry // immediately instead of unwinding the stack. -let workInProgressIsSuspended: boolean = false; +let workInProgressSuspendedReason: SuspendedReason = NotSuspended; let workInProgressThrownValue: mixed = null; let workInProgressSuspendedThenableState: ThenableState | null = null; @@ -1676,9 +1683,10 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { } if (workInProgress !== null) { - let interruptedWork = workInProgressIsSuspended - ? workInProgress - : workInProgress.return; + let interruptedWork = + workInProgressSuspendedReason === NotSuspended + ? workInProgress.return + : workInProgress; while (interruptedWork !== null) { const current = interruptedWork.alternate; unwindInterruptedWork( @@ -1693,7 +1701,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { const rootWorkInProgress = createWorkInProgress(root.current, null); workInProgress = rootWorkInProgress; workInProgressRootRenderLanes = renderLanes = lanes; - workInProgressIsSuspended = false; + workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; workInProgressSuspendedThenableState = null; workInProgressRootDidAttachPingListener = false; @@ -1732,17 +1740,27 @@ function handleThrow(root, thrownValue): void { // deprecate the old API in favor of `use`. thrownValue = getSuspendedThenable(); workInProgressSuspendedThenableState = getThenableStateAfterSuspending(); + workInProgressSuspendedReason = SuspendedOnImmediate; } else { // This is a regular error. If something earlier in the component already // suspended, we must clear the thenable state to unblock the work loop. workInProgressSuspendedThenableState = null; + + const isWakeable = + thrownValue !== null && + typeof thrownValue === 'object' && + // $FlowFixMe[method-unbinding] + typeof thrownValue.then === 'function'; + + workInProgressSuspendedReason = isWakeable + ? // A wakeable object was thrown by a legacy Suspense implementation. + // This has slightly different behavior than suspending with `use`. + SuspendedAndReadyToUnwind + : // This is a regular error. If something earlier in the component already + // suspended, we must clear the thenable state to unblock the work loop. + SuspendedOnError; } - // Setting this to `true` tells the work loop to unwind the stack instead - // of entering the begin phase. It's called "suspended" because it usually - // happens because of Suspense, but it also applies to errors. Think of it - // as suspending the execution of the work loop. - workInProgressIsSuspended = true; workInProgressThrownValue = thrownValue; const erroredWork = workInProgress; @@ -1762,12 +1780,7 @@ function handleThrow(root, thrownValue): void { if (enableSchedulingProfiler) { markComponentRenderStopped(); - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - // $FlowFixMe[method-unbinding] - typeof thrownValue.then === 'function' - ) { + if (workInProgressSuspendedReason !== SuspendedOnError) { const wakeable: Wakeable = (thrownValue: any); markComponentSuspended( erroredWork, @@ -1923,6 +1936,42 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { do { try { + if ( + workInProgressSuspendedReason !== NotSuspended && + workInProgress !== null + ) { + // The work loop is suspended. We need to either unwind the stack or + // replay the suspended component. + const unitOfWork = workInProgress; + const thrownValue = workInProgressThrownValue; + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + + // TODO: This check is only here to account for thenables that + // synchronously resolve. Otherwise we would always unwind when + // rendering with renderRootSync. (In the future, discrete updates will + // use renderRootConcurrent instead.) We should account for + // synchronously resolved thenables before hitting this path. + switch (workInProgressSuspendedReason) { + case SuspendedOnError: { + // Unwind then continue with the normal work loop. + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + break; + } + default: { + const wasPinged = + workInProgressSuspendedThenableState !== null && + isThenableStateResolved(workInProgressSuspendedThenableState); + if (wasPinged) { + replaySuspendedUnitOfWork(unitOfWork, thrownValue); + } else { + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + } + // Continue with the normal work loop. + break; + } + } + } workLoopSync(); break; } catch (thrownValue) { @@ -1967,18 +2016,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopSync() { // Perform work without checking if we need to yield between fiber. - - if (workInProgressIsSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressIsSuspended = false; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null) { performUnitOfWork(workInProgress); } @@ -2026,6 +2063,36 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { do { try { + if ( + workInProgressSuspendedReason !== NotSuspended && + workInProgress !== null + ) { + // The work loop is suspended. We need to either unwind the stack or + // replay the suspended component. + const unitOfWork = workInProgress; + const thrownValue = workInProgressThrownValue; + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + switch (workInProgressSuspendedReason) { + case SuspendedOnError: { + // Unwind then continue with the normal work loop. + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + break; + } + default: { + const wasPinged = + workInProgressSuspendedThenableState !== null && + isThenableStateResolved(workInProgressSuspendedThenableState); + if (wasPinged) { + replaySuspendedUnitOfWork(unitOfWork, thrownValue); + } else { + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + } + // Continue with the normal work loop. + break; + } + } + } workLoopConcurrent(); break; } catch (thrownValue) { @@ -2078,18 +2145,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - - if (workInProgressIsSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressIsSuspended = false; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null && !shouldYield()) { // $FlowFixMe[incompatible-call] found when upgrading Flow performUnitOfWork(workInProgress); @@ -2124,69 +2179,15 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function resumeSuspendedUnitOfWork( +function replaySuspendedUnitOfWork( unitOfWork: Fiber, thrownValue: mixed, ): void { - // This is a fork of performUnitOfWork specifcally for resuming a fiber that - // just suspended. In some cases, we may choose to retry the fiber immediately - // instead of unwinding the stack. It's a separate function to keep the - // additional logic out of the work loop's hot path. - - const wasPinged = - workInProgressSuspendedThenableState !== null && - isThenableStateResolved(workInProgressSuspendedThenableState); - - if (!wasPinged) { - // The thenable wasn't pinged. Return to the normal work loop. This will - // unwind the stack, and potentially result in showing a fallback. - workInProgressSuspendedThenableState = null; - - const returnFiber = unitOfWork.return; - if (returnFiber === null || workInProgressRoot === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - workInProgressRootExitStatus = RootFatalErrored; - workInProgressRootFatalError = thrownValue; - // Set `workInProgress` to null. This represents advancing to the next - // sibling, or the parent if there are no siblings. But since the root - // has no siblings nor a parent, we set it to null. Usually this is - // handled by `completeUnitOfWork` or `unwindWork`, but since we're - // intentionally not calling those, we need set it here. - // TODO: Consider calling `unwindWork` to pop the contexts. - workInProgress = null; - return; - } - - try { - // Find and mark the nearest Suspense or error boundary that can handle - // this "exception". - throwException( - workInProgressRoot, - returnFiber, - unitOfWork, - thrownValue, - workInProgressRootRenderLanes, - ); - } catch (error) { - // We had trouble processing the error. An example of this happening is - // when accessing the `componentDidCatch` property of an error boundary - // throws an error. A weird edge case. There's a regression test for this. - // To prevent an infinite loop, bubble the error up to the next parent. - workInProgress = returnFiber; - throw error; - } - - // Return to the normal work loop. - completeUnitOfWork(unitOfWork); - return; - } - - // The work-in-progress was immediately pinged. Instead of unwinding the - // stack and potentially showing a fallback, unwind only the last stack frame, - // reset the fiber, and try rendering it again. + // This is a fork of performUnitOfWork specifcally for replaying a fiber that + // just suspended. + // + // Instead of unwinding the stack and potentially showing a fallback, unwind + // only the last stack frame, reset the fiber, and try rendering it again. const current = unitOfWork.alternate; unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); @@ -2219,6 +2220,55 @@ function resumeSuspendedUnitOfWork( ReactCurrentOwner.current = null; } +function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) { + // This is a fork of performUnitOfWork specifcally for unwinding a fiber + // that threw an exception. + // + // Return to the normal work loop. This will unwind the stack, and potentially + // result in showing a fallback. + workInProgressSuspendedThenableState = null; + + const returnFiber = unitOfWork.return; + if (returnFiber === null || workInProgressRoot === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + workInProgressRootExitStatus = RootFatalErrored; + workInProgressRootFatalError = thrownValue; + // Set `workInProgress` to null. This represents advancing to the next + // sibling, or the parent if there are no siblings. But since the root + // has no siblings nor a parent, we set it to null. Usually this is + // handled by `completeUnitOfWork` or `unwindWork`, but since we're + // intentionally not calling those, we need set it here. + // TODO: Consider calling `unwindWork` to pop the contexts. + workInProgress = null; + return; + } + + try { + // Find and mark the nearest Suspense or error boundary that can handle + // this "exception". + throwException( + workInProgressRoot, + returnFiber, + unitOfWork, + thrownValue, + workInProgressRootRenderLanes, + ); + } catch (error) { + // We had trouble processing the error. An example of this happening is + // when accessing the `componentDidCatch` property of an error boundary + // throws an error. A weird edge case. There's a regression test for this. + // To prevent an infinite loop, bubble the error up to the next parent. + workInProgress = returnFiber; + throw error; + } + + // Return to the normal work loop. + completeUnitOfWork(unitOfWork); +} + export function getSuspendedThenableState(): ThenableState | null { return workInProgressSuspendedThenableState; }