From 6f10f5a9cb431cd609dcc377014c7e20ed168d36 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Oct 2022 10:13:26 -0700 Subject: [PATCH 1/3] In work loop, add enum of reasons for suspending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a pure refactor, no change to behavior. When a component throws, the work loop can handle that in one of several ways — unwind immediately, wait for microtasks, and so on. I'm about to add another one, too. So I've changed the variable that tracks whether the work loop is suspended from a boolean (workInProgressIsSuspended) to an enum (workInProgressSuspendedReason). --- .../src/ReactFiberWorkLoop.new.js | 53 ++++++++++++------- .../src/ReactFiberWorkLoop.old.js | 53 ++++++++++++------- 2 files changed, 66 insertions(+), 40 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 80ab4cb3735..21dddf71e60 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, @@ -1968,11 +1981,11 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { function workLoopSync() { // Perform work without checking if we need to yield between fiber. - if (workInProgressIsSuspended) { + if (workInProgressSuspendedReason !== NotSuspended) { // 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; + workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; if (workInProgress !== null) { resumeSuspendedUnitOfWork(workInProgress, thrownValue); @@ -2079,11 +2092,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - if (workInProgressIsSuspended) { + if (workInProgressSuspendedReason !== NotSuspended) { // 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; + workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; if (workInProgress !== null) { resumeSuspendedUnitOfWork(workInProgress, thrownValue); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index db7f8585246..1978ef7fec9 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, @@ -1968,11 +1981,11 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { function workLoopSync() { // Perform work without checking if we need to yield between fiber. - if (workInProgressIsSuspended) { + if (workInProgressSuspendedReason !== NotSuspended) { // 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; + workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; if (workInProgress !== null) { resumeSuspendedUnitOfWork(workInProgress, thrownValue); @@ -2079,11 +2092,11 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - if (workInProgressIsSuspended) { + if (workInProgressSuspendedReason !== NotSuspended) { // 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; + workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; if (workInProgress !== null) { resumeSuspendedUnitOfWork(workInProgress, thrownValue); From c3caa8ff43f77a5e09221af1ef17d5ec039534de Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Oct 2022 13:23:27 -0700 Subject: [PATCH 2/3] Split suspended work loop logic into separate functions Refactors the logic for handling when the work loop is suspended into separate functions for replaying versus unwinding. This allows us to hoist certain checks into the caller. For example, when rendering due to flushSync, we will always unwind the stack without yielding the microtasks. No intentional behavior change in this commit. --- .../src/ReactFiberWorkLoop.new.js | 205 +++++++++++------- .../src/ReactFiberWorkLoop.old.js | 205 +++++++++++------- 2 files changed, 242 insertions(+), 168 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 21dddf71e60..ce03757caa4 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -1936,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) { @@ -1980,18 +2016,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopSync() { // Perform work without checking if we need to yield between fiber. - - if (workInProgressSuspendedReason !== NotSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressSuspendedReason = NotSuspended; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null) { performUnitOfWork(workInProgress); } @@ -2039,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) { @@ -2091,18 +2145,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - - if (workInProgressSuspendedReason !== NotSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressSuspendedReason = NotSuspended; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null && !shouldYield()) { // $FlowFixMe[incompatible-call] found when upgrading Flow performUnitOfWork(workInProgress); @@ -2137,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); @@ -2232,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 1978ef7fec9..444b8129c0d 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -1936,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) { @@ -1980,18 +2016,6 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopSync() { // Perform work without checking if we need to yield between fiber. - - if (workInProgressSuspendedReason !== NotSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressSuspendedReason = NotSuspended; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null) { performUnitOfWork(workInProgress); } @@ -2039,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) { @@ -2091,18 +2145,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { /** @noinline */ function workLoopConcurrent() { // Perform work until Scheduler asks us to yield - - if (workInProgressSuspendedReason !== NotSuspended) { - // The current work-in-progress was already attempted. We need to unwind - // it before we continue the normal work loop. - const thrownValue = workInProgressThrownValue; - workInProgressSuspendedReason = NotSuspended; - workInProgressThrownValue = null; - if (workInProgress !== null) { - resumeSuspendedUnitOfWork(workInProgress, thrownValue); - } - } - while (workInProgress !== null && !shouldYield()) { // $FlowFixMe[incompatible-call] found when upgrading Flow performUnitOfWork(workInProgress); @@ -2137,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); @@ -2232,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; } From d3705eef912ef7096aeeac4e80d7ff309d352ffc Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Oct 2022 10:47:29 -0700 Subject: [PATCH 3/3] Extract logic for detecting bad fallback to helper Pure refactor, no change in behavior. Extracts the logic for detecting whether a suspended component will result in a "bad" Suspense fallback into a helper function. An example of a bad Suspense fallback is one that causes already-visible content to disappear. I want to reuse this same logic in the work loop, too. --- .../src/ReactFiberCompleteWork.new.js | 22 ++----------- .../src/ReactFiberCompleteWork.old.js | 22 ++----------- .../src/ReactFiberSuspenseComponent.new.js | 1 + .../src/ReactFiberSuspenseComponent.old.js | 1 + .../src/ReactFiberSuspenseContext.new.js | 32 ++++++++++++++++++- .../src/ReactFiberSuspenseContext.old.js | 32 ++++++++++++++++++- 6 files changed, 70 insertions(+), 40 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index a43b0a650c9..e091bfbbb81 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 b3322972f23..8ec8b3e4e92 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 ffbc13a9f09..a672e8b0ef5 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 05663830c38..2ce9c2adf26 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 d28440dcb23..1bdd54ba83f 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 71caf48e60e..025de9bf944 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;