diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 2f307e8771464..70a0c69542ad1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -38,6 +38,8 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {RootState} from './ReactFiberRoot.new'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; +import type {ThenableState} from './ReactFiberThenable.new'; + import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -203,6 +205,7 @@ import { renderWithHooks, checkDidRenderIdHook, bailoutHooks, + replaySuspendedComponentWithHooks, } from './ReactFiberHooks.new'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer.new'; import { @@ -1159,6 +1162,56 @@ function updateFunctionComponent( return workInProgress.child; } +export function replayFunctionComponent( + current: Fiber | null, + workInProgress: Fiber, + nextProps: any, + Component: any, + prevThenableState: ThenableState, + renderLanes: Lanes, +): Fiber | null { + // This function is used to replay a component that previously suspended, + // after its data resolves. It's a simplified version of + // updateFunctionComponent that reuses the hooks from the previous attempt. + + let context; + if (!disableLegacyContext) { + const unmaskedContext = getUnmaskedContext(workInProgress, Component, true); + context = getMaskedContext(workInProgress, unmaskedContext); + } + + prepareToReadContext(workInProgress, renderLanes); + if (enableSchedulingProfiler) { + markComponentRenderStarted(workInProgress); + } + const nextChildren = replaySuspendedComponentWithHooks( + current, + workInProgress, + Component, + nextProps, + context, + prevThenableState, + ); + const hasId = checkDidRenderIdHook(); + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + } + + if (current !== null && !didReceiveUpdate) { + bailoutHooks(current, workInProgress, renderLanes); + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); + } + + if (getIsHydrating() && hasId) { + pushMaterializedTreeId(workInProgress); + } + + // React DevTools reads this flag. + workInProgress.flags |= PerformedWork; + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; +} + function updateClassComponent( current: Fiber | null, workInProgress: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 57371ca36a69f..1c22e2a7da834 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -38,6 +38,8 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {RootState} from './ReactFiberRoot.old'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; +import type {ThenableState} from './ReactFiberThenable.old'; + import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -203,6 +205,7 @@ import { renderWithHooks, checkDidRenderIdHook, bailoutHooks, + replaySuspendedComponentWithHooks, } from './ReactFiberHooks.old'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer.old'; import { @@ -1159,6 +1162,56 @@ function updateFunctionComponent( return workInProgress.child; } +export function replayFunctionComponent( + current: Fiber | null, + workInProgress: Fiber, + nextProps: any, + Component: any, + prevThenableState: ThenableState, + renderLanes: Lanes, +): Fiber | null { + // This function is used to replay a component that previously suspended, + // after its data resolves. It's a simplified version of + // updateFunctionComponent that reuses the hooks from the previous attempt. + + let context; + if (!disableLegacyContext) { + const unmaskedContext = getUnmaskedContext(workInProgress, Component, true); + context = getMaskedContext(workInProgress, unmaskedContext); + } + + prepareToReadContext(workInProgress, renderLanes); + if (enableSchedulingProfiler) { + markComponentRenderStarted(workInProgress); + } + const nextChildren = replaySuspendedComponentWithHooks( + current, + workInProgress, + Component, + nextProps, + context, + prevThenableState, + ); + const hasId = checkDidRenderIdHook(); + if (enableSchedulingProfiler) { + markComponentRenderStopped(); + } + + if (current !== null && !didReceiveUpdate) { + bailoutHooks(current, workInProgress, renderLanes); + return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes); + } + + if (getIsHydrating() && hasId) { + pushMaterializedTreeId(workInProgress); + } + + // React DevTools reads this flag. + workInProgress.flags |= PerformedWork; + reconcileChildren(current, workInProgress, nextChildren, renderLanes); + return workInProgress.child; +} + function updateClassComponent( current: Fiber | null, workInProgress: Fiber, diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 09828fd12b04b..5ed7d34e1e784 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -545,6 +545,12 @@ export function renderWithHooks( } } + finishRenderingHooks(current, workInProgress); + + return children; +} + +function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) { // We can assume the previous dispatcher is always this one, since we set it // at the beginning of the render phase and there's no re-entrance. ReactCurrentDispatcher.current = ContextOnlyDispatcher; @@ -638,7 +644,41 @@ export function renderWithHooks( } } } +} +export function replaySuspendedComponentWithHooks( + current: Fiber | null, + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, + props: Props, + secondArg: SecondArg, + prevThenableState: ThenableState | null, +): any { + // This function is used to replay a component that previously suspended, + // after its data resolves. + // + // It's a simplified version of renderWithHooks, but it doesn't need to do + // most of the set up work because they weren't reset when we suspended; they + // only get reset when the component either completes (finishRenderingHooks) + // or unwinds (resetHooksOnUnwind). + if (__DEV__) { + hookTypesDev = + current !== null + ? ((current._debugHookTypes: any): Array) + : null; + hookTypesUpdateIndexDev = -1; + // Used for hot reloading: + ignorePreviousDependencies = + current !== null && current.type !== workInProgress.type; + } + const children = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg, + prevThenableState, + ); + finishRenderingHooks(current, workInProgress); return children; } diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 08f3ee46ffe1f..fb98e9b7b58db 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -545,6 +545,12 @@ export function renderWithHooks( } } + finishRenderingHooks(current, workInProgress); + + return children; +} + +function finishRenderingHooks(current: Fiber | null, workInProgress: Fiber) { // We can assume the previous dispatcher is always this one, since we set it // at the beginning of the render phase and there's no re-entrance. ReactCurrentDispatcher.current = ContextOnlyDispatcher; @@ -638,7 +644,41 @@ export function renderWithHooks( } } } +} +export function replaySuspendedComponentWithHooks( + current: Fiber | null, + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, + props: Props, + secondArg: SecondArg, + prevThenableState: ThenableState | null, +): any { + // This function is used to replay a component that previously suspended, + // after its data resolves. + // + // It's a simplified version of renderWithHooks, but it doesn't need to do + // most of the set up work because they weren't reset when we suspended; they + // only get reset when the component either completes (finishRenderingHooks) + // or unwinds (resetHooksOnUnwind). + if (__DEV__) { + hookTypesDev = + current !== null + ? ((current._debugHookTypes: any): Array) + : null; + hookTypesUpdateIndexDev = -1; + // Used for hot reloading: + ignorePreviousDependencies = + current !== null && current.type !== workInProgress.type; + } + const children = renderWithHooksAgain( + workInProgress, + Component, + props, + secondArg, + prevThenableState, + ); + finishRenderingHooks(current, workInProgress); return children; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 6e20ac2548f05..e65f5569b93ef 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -181,6 +181,7 @@ import {requestCurrentTransition, NoTransition} from './ReactFiberTransition'; import { SelectiveHydrationException, beginWork as originalBeginWork, + replayFunctionComponent, } from './ReactFiberBeginWork.new'; import {completeWork} from './ReactFiberCompleteWork.new'; import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.new'; @@ -282,6 +283,7 @@ import { getSuspenseHandler, isBadSuspenseFallback, } from './ReactFiberSuspenseContext.new'; +import {resolveDefaultProps} from './ReactFiberLazyComponent.new'; const ceil = Math.ceil; @@ -2353,22 +2355,79 @@ function replaySuspendedUnitOfWork( // 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; - resetSuspendedWorkLoopOnUnwind(); - unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); - unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); - setCurrentDebugFiberInDEV(unitOfWork); let next; - if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { + setCurrentDebugFiberInDEV(unitOfWork); + const isProfilingMode = + enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode; + if (isProfilingMode) { startProfilerTimer(unitOfWork); - next = beginWork(current, unitOfWork, renderLanes); + } + switch (unitOfWork.tag) { + case IndeterminateComponent: { + // Because it suspended with `use`, we can assume it's a + // function component. + unitOfWork.tag = FunctionComponent; + // Fallthrough to the next branch. + } + // eslint-disable-next-line no-fallthrough + case FunctionComponent: + case ForwardRef: { + // Resolve `defaultProps`. This logic is copied from `beginWork`. + // TODO: Consider moving this switch statement into that module. Also, + // could maybe use this as an opportunity to say `use` doesn't work with + // `defaultProps` :) + const Component = unitOfWork.type; + const unresolvedProps = unitOfWork.pendingProps; + const resolvedProps = + unitOfWork.elementType === Component + ? unresolvedProps + : resolveDefaultProps(Component, unresolvedProps); + next = replayFunctionComponent( + current, + unitOfWork, + resolvedProps, + Component, + thenableState, + workInProgressRootRenderLanes, + ); + break; + } + case SimpleMemoComponent: { + const Component = unitOfWork.type; + const nextProps = unitOfWork.pendingProps; + next = replayFunctionComponent( + current, + unitOfWork, + nextProps, + Component, + thenableState, + workInProgressRootRenderLanes, + ); + break; + } + default: { + if (__DEV__) { + console.error( + 'Unexpected type of work: %s, Currently only function ' + + 'components are replayed after suspending. This is a bug in React.', + unitOfWork.tag, + ); + } + resetSuspendedWorkLoopOnUnwind(); + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); + unitOfWork = workInProgress = resetWorkInProgress( + unitOfWork, + renderLanes, + ); + next = beginWork(current, unitOfWork, renderLanes); + break; + } + } + if (isProfilingMode) { stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); - } else { - next = beginWork(current, unitOfWork, renderLanes); } // The begin phase finished successfully without suspending. Reset the state diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index e6820548ee859..e8d15a3ea5018 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -181,6 +181,7 @@ import {requestCurrentTransition, NoTransition} from './ReactFiberTransition'; import { SelectiveHydrationException, beginWork as originalBeginWork, + replayFunctionComponent, } from './ReactFiberBeginWork.old'; import {completeWork} from './ReactFiberCompleteWork.old'; import {unwindWork, unwindInterruptedWork} from './ReactFiberUnwindWork.old'; @@ -282,6 +283,7 @@ import { getSuspenseHandler, isBadSuspenseFallback, } from './ReactFiberSuspenseContext.old'; +import {resolveDefaultProps} from './ReactFiberLazyComponent.old'; const ceil = Math.ceil; @@ -2353,22 +2355,79 @@ function replaySuspendedUnitOfWork( // 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; - resetSuspendedWorkLoopOnUnwind(); - unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); - unitOfWork = workInProgress = resetWorkInProgress(unitOfWork, renderLanes); - setCurrentDebugFiberInDEV(unitOfWork); let next; - if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) { + setCurrentDebugFiberInDEV(unitOfWork); + const isProfilingMode = + enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode; + if (isProfilingMode) { startProfilerTimer(unitOfWork); - next = beginWork(current, unitOfWork, renderLanes); + } + switch (unitOfWork.tag) { + case IndeterminateComponent: { + // Because it suspended with `use`, we can assume it's a + // function component. + unitOfWork.tag = FunctionComponent; + // Fallthrough to the next branch. + } + // eslint-disable-next-line no-fallthrough + case FunctionComponent: + case ForwardRef: { + // Resolve `defaultProps`. This logic is copied from `beginWork`. + // TODO: Consider moving this switch statement into that module. Also, + // could maybe use this as an opportunity to say `use` doesn't work with + // `defaultProps` :) + const Component = unitOfWork.type; + const unresolvedProps = unitOfWork.pendingProps; + const resolvedProps = + unitOfWork.elementType === Component + ? unresolvedProps + : resolveDefaultProps(Component, unresolvedProps); + next = replayFunctionComponent( + current, + unitOfWork, + resolvedProps, + Component, + thenableState, + workInProgressRootRenderLanes, + ); + break; + } + case SimpleMemoComponent: { + const Component = unitOfWork.type; + const nextProps = unitOfWork.pendingProps; + next = replayFunctionComponent( + current, + unitOfWork, + nextProps, + Component, + thenableState, + workInProgressRootRenderLanes, + ); + break; + } + default: { + if (__DEV__) { + console.error( + 'Unexpected type of work: %s, Currently only function ' + + 'components are replayed after suspending. This is a bug in React.', + unitOfWork.tag, + ); + } + resetSuspendedWorkLoopOnUnwind(); + unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); + unitOfWork = workInProgress = resetWorkInProgress( + unitOfWork, + renderLanes, + ); + next = beginWork(current, unitOfWork, renderLanes); + break; + } + } + if (isProfilingMode) { stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); - } else { - next = beginWork(current, unitOfWork, renderLanes); } // The begin phase finished successfully without suspending. Reset the state diff --git a/packages/react-reconciler/src/__tests__/ReactThenable-test.js b/packages/react-reconciler/src/__tests__/ReactThenable-test.js index 600c6e3e5ba9e..0af8ccafd381d 100644 --- a/packages/react-reconciler/src/__tests__/ReactThenable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactThenable-test.js @@ -6,6 +6,7 @@ let Scheduler; let act; let use; let useState; +let useMemo; let Suspense; let startTransition; let pendingTextRequests; @@ -20,6 +21,7 @@ describe('ReactThenable', () => { act = require('jest-react').act; use = React.use; useState = React.useState; + useMemo = React.useMemo; Suspense = React.Suspense; startTransition = React.startTransition; @@ -789,4 +791,89 @@ describe('ReactThenable', () => { ]); expect(root).toMatchRenderedOutput('(empty)'); }); + + test('when replaying a suspended component, reuses the hooks computed during the previous attempt', async () => { + function ExcitingText({text}) { + // This computes the uppercased version of some text. Pretend it's an + // expensive operation that we want to reuse. + const uppercaseText = useMemo(() => { + Scheduler.unstable_yieldValue('Compute uppercase: ' + text); + return text.toUpperCase(); + }, [text]); + + // This adds an exclamation point to the text. Pretend it's an async + // operation that is sent to a service for processing. + const exclamatoryText = use(getAsyncText(uppercaseText + '!')); + + // This surrounds the text with sparkle emojis. The purpose in this test + // is to show that you can suspend in the middle of a sequence of hooks + // without breaking anything. + const sparklingText = useMemo(() => { + Scheduler.unstable_yieldValue('Add sparkles: ' + exclamatoryText); + return `✨ ${exclamatoryText} ✨`; + }, [exclamatoryText]); + + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + // Suspends while we wait for the async service to respond. + expect(Scheduler).toHaveYielded([ + 'Compute uppercase: Hello', + 'Async text requested [HELLO!]', + ]); + expect(root).toMatchRenderedOutput(null); + + // The data is received. + await act(async () => { + resolveTextRequests('HELLO!'); + }); + expect(Scheduler).toHaveYielded([ + // We shouldn't run the uppercase computation again, because we can reuse + // the computation from the previous attempt. + // 'Compute uppercase: Hello', + + 'Async text requested [HELLO!]', + 'Add sparkles: HELLO!', + '✨ HELLO! ✨', + ]); + }); + + // @gate enableUseHook + test( + 'wrap an async function with useMemo to skip running the function ' + + 'twice when loading new data', + async () => { + function App({text}) { + const promiseForText = useMemo(async () => getAsyncText(text), [text]); + const asyncText = use(promiseForText); + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + startTransition(() => { + root.render(); + }); + }); + expect(Scheduler).toHaveYielded(['Async text requested [Hello]']); + expect(root).toMatchRenderedOutput(null); + + await act(async () => { + resolveTextRequests('Hello'); + }); + expect(Scheduler).toHaveYielded([ + // We shouldn't request async text again, because the async function + // was memoized + // 'Async text requested [Hello]' + + 'Hello', + ]); + }, + ); });