diff --git a/packages/react-reconciler/src/ReactFiberWakeable.new.js b/packages/react-reconciler/src/ReactFiberWakeable.new.js new file mode 100644 index 0000000000000..589d61eae814a --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberWakeable.new.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Wakeable} from 'shared/ReactTypes'; + +let suspendedWakeable: Wakeable | null = null; +let wasPinged = false; +let adHocSuspendCount: number = 0; + +const MAX_AD_HOC_SUSPEND_COUNT = 50; + +export function suspendedWakeableWasPinged() { + return wasPinged; +} + +export function trackSuspendedWakeable(wakeable: Wakeable) { + adHocSuspendCount++; + suspendedWakeable = wakeable; +} + +export function attemptToPingSuspendedWakeable(wakeable: Wakeable) { + if (wakeable === suspendedWakeable) { + // This ping is from the wakeable that just suspended. Mark it as pinged. + // When the work loop resumes, we'll immediately try rendering the fiber + // again instead of unwinding the stack. + wasPinged = true; + return true; + } + return false; +} + +export function resetWakeableState() { + suspendedWakeable = null; + wasPinged = false; + adHocSuspendCount = 0; +} + +export function throwIfInfinitePingLoopDetected() { + if (adHocSuspendCount > MAX_AD_HOC_SUSPEND_COUNT) { + // TODO: Guard against an infinite loop by throwing an error if the same + // component suspends too many times in a row. This should be thrown from + // the render phase so that it gets the component stack. + } +} diff --git a/packages/react-reconciler/src/ReactFiberWakeable.old.js b/packages/react-reconciler/src/ReactFiberWakeable.old.js new file mode 100644 index 0000000000000..589d61eae814a --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberWakeable.old.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Wakeable} from 'shared/ReactTypes'; + +let suspendedWakeable: Wakeable | null = null; +let wasPinged = false; +let adHocSuspendCount: number = 0; + +const MAX_AD_HOC_SUSPEND_COUNT = 50; + +export function suspendedWakeableWasPinged() { + return wasPinged; +} + +export function trackSuspendedWakeable(wakeable: Wakeable) { + adHocSuspendCount++; + suspendedWakeable = wakeable; +} + +export function attemptToPingSuspendedWakeable(wakeable: Wakeable) { + if (wakeable === suspendedWakeable) { + // This ping is from the wakeable that just suspended. Mark it as pinged. + // When the work loop resumes, we'll immediately try rendering the fiber + // again instead of unwinding the stack. + wasPinged = true; + return true; + } + return false; +} + +export function resetWakeableState() { + suspendedWakeable = null; + wasPinged = false; + adHocSuspendCount = 0; +} + +export function throwIfInfinitePingLoopDetected() { + if (adHocSuspendCount > MAX_AD_HOC_SUSPEND_COUNT) { + // TODO: Guard against an infinite loop by throwing an error if the same + // component suspends too many times in a row. This should be thrown from + // the render phase so that it gets the component stack. + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index db52772afc833..a8844d2c1a411 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -86,6 +86,7 @@ import { import { createWorkInProgress, assignFiberPropertiesInDEV, + resetWorkInProgress, } from './ReactFiber.new'; import {isRootDehydrated} from './ReactFiberShellHydration'; import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext.new'; @@ -245,6 +246,12 @@ import { isConcurrentActEnvironment, } from './ReactFiberAct.new'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new'; +import { + resetWakeableState, + trackSuspendedWakeable, + suspendedWakeableWasPinged, + attemptToPingSuspendedWakeable, +} from './ReactFiberWakeable.new'; const ceil = Math.ceil; @@ -1549,6 +1556,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } + resetWakeableState(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -1884,6 +1892,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. + trackSuspendedWakeable(maybeWakeable); break; } } @@ -1966,10 +1975,52 @@ function performUnitOfWork(unitOfWork: Fiber): void { function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { // This is a fork of performUnitOfWork specifcally for resuming a fiber that - // just suspended. It's a separate function to keep the additional logic out - // of the work loop's hot path. + // 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. + + if (!suspendedWakeableWasPinged()) { + // The wakeable wasn't pinged. Return to the normal work loop. This will + // unwind the stack, and potentially result in showing a fallback. + workInProgressIsSuspended = false; + resetWakeableState(); + completeUnitOfWork(unitOfWork); + return; + } + + // The work-in-progress was immediately pinged. Instead of unwinding the + // stack and potentially showing a fallback, reset the fiber and try rendering + // it again. + unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); + + const current = unitOfWork.alternate; + setCurrentDebugFiberInDEV(unitOfWork); + + let next; + if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { + startProfilerTimer(unitOfWork); + next = beginWork(current, unitOfWork, renderLanes); + stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + } else { + next = beginWork(current, unitOfWork, renderLanes); + } + + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. workInProgressIsSuspended = false; - completeUnitOfWork(unitOfWork); + resetWakeableState(); + + resetCurrentDebugFiberInDEV(); + unitOfWork.memoizedProps = unitOfWork.pendingProps; + if (next === null) { + // If this doesn't spawn new work, complete the current work. + completeUnitOfWork(unitOfWork); + } else { + workInProgress = next; + } + + ReactCurrentOwner.current = null; } function completeUnitOfWork(unitOfWork: Fiber): void { @@ -2783,27 +2834,31 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - - // TODO: If we're rendering sync either due to Sync, Batched or expired, - // we should probably never restart. - - // If we're suspended with delay, or if it's a retry, we'll always suspend - // so we can always restart. - if ( - workInProgressRootExitStatus === RootSuspendedWithDelay || - (workInProgressRootExitStatus === RootSuspended && - includesOnlyRetries(workInProgressRootRenderLanes) && - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) - ) { - // Restart from the root. - prepareFreshStack(root, NoLanes); + const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable); + if (didPingSuspendedWakeable) { + // Successfully pinged the in-progress fiber. Don't unwind the stack. } else { - // Even though we can't restart right now, we might get an - // opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes, - ); + // TODO: If we're rendering sync either due to Sync, Batched or expired, + // we should probably never restart. + + // If we're suspended with delay, or if it's a retry, we'll always suspend + // so we can always restart. + if ( + workInProgressRootExitStatus === RootSuspendedWithDelay || + (workInProgressRootExitStatus === RootSuspended && + includesOnlyRetries(workInProgressRootRenderLanes) && + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) + ) { + // Restart from the root. + prepareFreshStack(root, NoLanes); + } else { + // Even though we can't restart right now, we might get an + // opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = mergeLanes( + workInProgressRootPingedLanes, + pingedLanes, + ); + } } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 7886d441d377f..14e378794bfbc 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -86,6 +86,7 @@ import { import { createWorkInProgress, assignFiberPropertiesInDEV, + resetWorkInProgress, } from './ReactFiber.old'; import {isRootDehydrated} from './ReactFiberShellHydration'; import {didSuspendOrErrorWhileHydratingDEV} from './ReactFiberHydrationContext.old'; @@ -245,6 +246,12 @@ import { isConcurrentActEnvironment, } from './ReactFiberAct.old'; import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old'; +import { + resetWakeableState, + trackSuspendedWakeable, + suspendedWakeableWasPinged, + attemptToPingSuspendedWakeable, +} from './ReactFiberWakeable.old'; const ceil = Math.ceil; @@ -1549,6 +1556,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { ); interruptedWork = interruptedWork.return; } + resetWakeableState(); } workInProgressRoot = root; const rootWorkInProgress = createWorkInProgress(root.current, null); @@ -1884,6 +1892,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { // If this fiber just suspended, it's possible the data is already // cached. Yield to the the main thread to give it a chance to ping. If // it does, we can retry immediately without unwinding the stack. + trackSuspendedWakeable(maybeWakeable); break; } } @@ -1966,10 +1975,52 @@ function performUnitOfWork(unitOfWork: Fiber): void { function resumeSuspendedUnitOfWork(unitOfWork: Fiber): void { // This is a fork of performUnitOfWork specifcally for resuming a fiber that - // just suspended. It's a separate function to keep the additional logic out - // of the work loop's hot path. + // 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. + + if (!suspendedWakeableWasPinged()) { + // The wakeable wasn't pinged. Return to the normal work loop. This will + // unwind the stack, and potentially result in showing a fallback. + workInProgressIsSuspended = false; + resetWakeableState(); + completeUnitOfWork(unitOfWork); + return; + } + + // The work-in-progress was immediately pinged. Instead of unwinding the + // stack and potentially showing a fallback, reset the fiber and try rendering + // it again. + unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); + + const current = unitOfWork.alternate; + setCurrentDebugFiberInDEV(unitOfWork); + + let next; + if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { + startProfilerTimer(unitOfWork); + next = beginWork(current, unitOfWork, renderLanes); + stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + } else { + next = beginWork(current, unitOfWork, renderLanes); + } + + // The begin phase finished successfully without suspending. Reset the state + // used to track the fiber while it was suspended. Then return to the normal + // work loop. workInProgressIsSuspended = false; - completeUnitOfWork(unitOfWork); + resetWakeableState(); + + resetCurrentDebugFiberInDEV(); + unitOfWork.memoizedProps = unitOfWork.pendingProps; + if (next === null) { + // If this doesn't spawn new work, complete the current work. + completeUnitOfWork(unitOfWork); + } else { + workInProgress = next; + } + + ReactCurrentOwner.current = null; } function completeUnitOfWork(unitOfWork: Fiber): void { @@ -2783,27 +2834,31 @@ export function pingSuspendedRoot( // Received a ping at the same priority level at which we're currently // rendering. We might want to restart this render. This should mirror // the logic of whether or not a root suspends once it completes. - - // TODO: If we're rendering sync either due to Sync, Batched or expired, - // we should probably never restart. - - // If we're suspended with delay, or if it's a retry, we'll always suspend - // so we can always restart. - if ( - workInProgressRootExitStatus === RootSuspendedWithDelay || - (workInProgressRootExitStatus === RootSuspended && - includesOnlyRetries(workInProgressRootRenderLanes) && - now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) - ) { - // Restart from the root. - prepareFreshStack(root, NoLanes); + const didPingSuspendedWakeable = attemptToPingSuspendedWakeable(wakeable); + if (didPingSuspendedWakeable) { + // Successfully pinged the in-progress fiber. Don't unwind the stack. } else { - // Even though we can't restart right now, we might get an - // opportunity later. So we mark this render as having a ping. - workInProgressRootPingedLanes = mergeLanes( - workInProgressRootPingedLanes, - pingedLanes, - ); + // TODO: If we're rendering sync either due to Sync, Batched or expired, + // we should probably never restart. + + // If we're suspended with delay, or if it's a retry, we'll always suspend + // so we can always restart. + if ( + workInProgressRootExitStatus === RootSuspendedWithDelay || + (workInProgressRootExitStatus === RootSuspended && + includesOnlyRetries(workInProgressRootRenderLanes) && + now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) + ) { + // Restart from the root. + prepareFreshStack(root, NoLanes); + } else { + // Even though we can't restart right now, we might get an + // opportunity later. So we mark this render as having a ping. + workInProgressRootPingedLanes = mergeLanes( + workInProgressRootPingedLanes, + pingedLanes, + ); + } } } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 3f2f4cc38aa09..0289fd2c05e84 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -963,33 +963,36 @@ describe('ReactSuspenseWithNoopRenderer', () => { // @gate enableCache it('resolves successfully even if fallback render is pending', async () => { - ReactNoop.render( + const root = ReactNoop.createRoot(); + root.render( <> } /> , ); expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([]); + expect(root).toMatchRenderedOutput(null); if (gate(flags => flags.enableSyncDefaultUpdates)) { React.startTransition(() => { - ReactNoop.render( + root.render( <> }> + , ); }); } else { - ReactNoop.render( + root.render( <> }> + , ); } - expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']); + expect(Scheduler).toFlushAndYieldThrough(['Suspend! [Async]', 'Sibling']); await resolveText('Async'); expect(Scheduler).toFlushAndYield([ @@ -998,8 +1001,14 @@ describe('ReactSuspenseWithNoopRenderer', () => { 'Loading...', // Once we've completed the boundary we restarted. 'Async', + 'Sibling', ]); - expect(ReactNoop.getChildren()).toEqual([span('Async')]); + expect(root).toMatchRenderedOutput( + <> + + + , + ); }); // @gate enableCache diff --git a/packages/react-reconciler/src/__tests__/ReactWakeable-test.js b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js new file mode 100644 index 0000000000000..848962c696c0b --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactWakeable-test.js @@ -0,0 +1,67 @@ +'use strict'; + +let React; +let ReactNoop; +let Scheduler; +let act; +let Suspense; +let startTransition; + +describe('ReactWakeable', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('jest-react').act; + Suspense = React.Suspense; + startTransition = React.startTransition; + }); + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return props.text; + } + + test('if suspended fiber is pinged in a microtask, retry immediately without unwinding the stack', async () => { + let resolved = false; + function Async() { + if (resolved) { + return ; + } + Scheduler.unstable_yieldValue('Suspend!'); + throw Promise.resolve().then(() => { + Scheduler.unstable_yieldValue('Resolve in microtask'); + resolved = true; + }); + } + + function App() { + return ( + }> + + + ); + } + + await act(async () => { + startTransition(() => { + ReactNoop.render(); + }); + + // React will yield when the async component suspends. + expect(Scheduler).toFlushUntilNextPaint(['Suspend!']); + + // Wait for microtasks to resolve + // TODO: The async form of `act` should automatically yield to microtasks + // when a continuation is returned, the way Scheduler does. + await null; + + expect(Scheduler).toHaveYielded(['Resolve in microtask']); + }); + + // Finished rendering without unwinding the stack. + expect(Scheduler).toHaveYielded(['Async']); + }); +});