diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index 9e59cee39a891..48ab3565c6466 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -430,26 +430,6 @@ describe('ReactDOMServerHooks', () => { expect(domNode.textContent).toEqual('hi'); }); - itThrowsWhenRendering( - 'with a warning for useRef inside useReducer', - async render => { - function App() { - const [value, dispatch] = useReducer((state, action) => { - useRef(0); - return state + 1; - }, 0); - if (value === 0) { - dispatch(); - } - return value; - } - - const domNode = await render(, 1); - expect(domNode.textContent).toEqual('1'); - }, - 'Rendered more hooks than during the previous render', - ); - itRenders('with a warning for useRef inside useState', async render => { function App() { const [value] = useState(() => { @@ -686,6 +666,32 @@ describe('ReactDOMServerHooks', () => { ); }); + describe('invalid hooks', () => { + it('warns when calling useRef inside useReducer', async () => { + function App() { + const [value, dispatch] = useReducer((state, action) => { + useRef(0); + return state + 1; + }, 0); + if (value === 0) { + dispatch(); + } + return value; + } + + let error; + try { + await serverRender(); + } catch (x) { + error = x; + } + expect(error).not.toBe(undefined); + expect(error.message).toContain( + 'Rendered more hooks than during the previous render', + ); + }); + }); + itRenders( 'can use the same context multiple times in the same function', async render => { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 2f307e8771464..50dd170a6028f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -38,6 +38,7 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new'; import type {RootState} from './ReactFiberRoot.new'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; + import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -203,6 +204,7 @@ import { renderWithHooks, checkDidRenderIdHook, bailoutHooks, + replaySuspendedComponentWithHooks, } from './ReactFiberHooks.new'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer.new'; import { @@ -1159,6 +1161,54 @@ function updateFunctionComponent( return workInProgress.child; } +export function replayFunctionComponent( + current: Fiber | null, + workInProgress: Fiber, + nextProps: any, + Component: any, + 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, + ); + 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..1f22a6e18bcf5 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -38,6 +38,7 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old'; import type {RootState} from './ReactFiberRoot.old'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; + import checkPropTypes from 'shared/checkPropTypes'; import { markComponentRenderStarted, @@ -203,6 +204,7 @@ import { renderWithHooks, checkDidRenderIdHook, bailoutHooks, + replaySuspendedComponentWithHooks, } from './ReactFiberHooks.old'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer.old'; import { @@ -1159,6 +1161,54 @@ function updateFunctionComponent( return workInProgress.child; } +export function replayFunctionComponent( + current: Fiber | null, + workInProgress: Fiber, + nextProps: any, + Component: any, + 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, + ); + 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 7538bb9b3c217..3f97d0e8639c0 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -105,7 +105,6 @@ import { requestEventTime, markSkippedUpdateLanes, isInvalidExecutionContextForEventFunction, - getSuspendedThenableState, } from './ReactFiberWorkLoop.new'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -141,9 +140,9 @@ import { import {getTreeId} from './ReactFiberTreeContext.new'; import {now} from './Scheduler'; import { - prepareThenableState, trackUsedThenable, checkIfUseWrappedInTryCatch, + createThenableState, } from './ReactFiberThenable.new'; import type {ThenableState} from './ReactFiberThenable.new'; @@ -247,6 +246,7 @@ let shouldDoubleInvokeUserFnsInHooksDEV: boolean = false; let localIdCounter: number = 0; // Counts number of `use`-d thenables let thenableIndexCounter: number = 0; +let thenableState: ThenableState | null = null; // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across @@ -449,6 +449,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; // thenableIndexCounter = 0; + // thenableState = null; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -477,10 +478,6 @@ export function renderWithHooks( : HooksDispatcherOnUpdate; } - // If this is a replay, restore the thenable state from the previous attempt. - const prevThenableState = getSuspendedThenableState(); - prepareThenableState(prevThenableState); - // In Strict Mode, during development, user functions are double invoked to // help detect side effects. The logic for how this is implemented for in // hook components is a bit complex so let's break it down. @@ -525,7 +522,6 @@ export function renderWithHooks( Component, props, secondArg, - prevThenableState, ); } @@ -538,13 +534,18 @@ export function renderWithHooks( Component, props, secondArg, - prevThenableState, ); } finally { setIsStrictModeForDevtools(false); } } + 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; @@ -594,7 +595,9 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; + thenableState = null; if (didRenderTooFewHooks) { throw new Error( @@ -638,7 +641,39 @@ export function renderWithHooks( } } } +} +export function replaySuspendedComponentWithHooks( + current: Fiber | null, + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, + props: Props, + secondArg: SecondArg, +): 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, + ); + finishRenderingHooks(current, workInProgress); return children; } @@ -647,7 +682,6 @@ function renderWithHooksAgain( Component: (p: Props, arg: SecondArg) => any, props: Props, secondArg: SecondArg, - prevThenableState: ThenableState | null, ) { // This is used to perform another render pass. It's used when setState is // called during render, and for double invoking components in Strict Mode @@ -695,7 +729,6 @@ function renderWithHooksAgain( ? HooksDispatcherOnRerenderInDEV : HooksDispatcherOnRerender; - prepareThenableState(prevThenableState); children = Component(props, secondArg); } while (didScheduleRenderPhaseUpdateDuringThisPass); return children; @@ -732,10 +765,19 @@ export function bailoutHooks( } export function resetHooksAfterThrow(): void { + // This is called immediaetly after a throw. It shouldn't reset the entire + // module state, because the work loop might decide to replay the component + // again without rewinding. + // + // It should only reset things like the current dispatcher, to prevent hooks + // from being called outside of a component. + // 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; +} +export function resetHooksOnUnwind(): void { if (didScheduleRenderPhaseUpdate) { // There were render phase updates. These are only valid for this render // phase, which we are now aborting. Remove the updates from the queues so @@ -772,6 +814,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; thenableIndexCounter = 0; + thenableState = null; } function mountWorkInProgressHook(): Hook { @@ -830,7 +873,24 @@ function updateWorkInProgressHook(): Hook { // Clone from the current hook. if (nextCurrentHook === null) { - throw new Error('Rendered more hooks than during the previous render.'); + const currentFiber = currentlyRenderingFiber.alternate; + if (currentFiber === null) { + // This is the initial render. This branch is reached when the component + // suspends, resumes, then renders an additional hook. + const newHook: Hook = { + memoizedState: null, + + baseState: null, + baseQueue: null, + queue: null, + + next: null, + }; + nextCurrentHook = newHook; + } else { + // This is an update. We should always have a current hook. + throw new Error('Rendered more hooks than during the previous render.'); + } } currentHook = nextCurrentHook; @@ -888,7 +948,11 @@ function use(usable: Usable): T { // Track the position of the thenable within this fiber. const index = thenableIndexCounter; thenableIndexCounter += 1; - return trackUsedThenable(thenable, index); + + if (thenableState === null) { + thenableState = createThenableState(); + } + return trackUsedThenable(thenableState, thenable, index); } else if ( usable.$$typeof === REACT_CONTEXT_TYPE || usable.$$typeof === REACT_SERVER_CONTEXT_TYPE diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index e98d684a78e6f..6c272d6b0613d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -105,7 +105,6 @@ import { requestEventTime, markSkippedUpdateLanes, isInvalidExecutionContextForEventFunction, - getSuspendedThenableState, } from './ReactFiberWorkLoop.old'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; @@ -141,9 +140,9 @@ import { import {getTreeId} from './ReactFiberTreeContext.old'; import {now} from './Scheduler'; import { - prepareThenableState, trackUsedThenable, checkIfUseWrappedInTryCatch, + createThenableState, } from './ReactFiberThenable.old'; import type {ThenableState} from './ReactFiberThenable.old'; @@ -247,6 +246,7 @@ let shouldDoubleInvokeUserFnsInHooksDEV: boolean = false; let localIdCounter: number = 0; // Counts number of `use`-d thenables let thenableIndexCounter: number = 0; +let thenableState: ThenableState | null = null; // Used for ids that are generated completely client-side (i.e. not during // hydration). This counter is global, so client ids are not stable across @@ -449,6 +449,7 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // localIdCounter = 0; // thenableIndexCounter = 0; + // thenableState = null; // TODO Warn if no hooks are used at all during mount, then some are used during update. // Currently we will identify the update render as a mount because memoizedState === null. @@ -477,10 +478,6 @@ export function renderWithHooks( : HooksDispatcherOnUpdate; } - // If this is a replay, restore the thenable state from the previous attempt. - const prevThenableState = getSuspendedThenableState(); - prepareThenableState(prevThenableState); - // In Strict Mode, during development, user functions are double invoked to // help detect side effects. The logic for how this is implemented for in // hook components is a bit complex so let's break it down. @@ -525,7 +522,6 @@ export function renderWithHooks( Component, props, secondArg, - prevThenableState, ); } @@ -538,13 +534,18 @@ export function renderWithHooks( Component, props, secondArg, - prevThenableState, ); } finally { setIsStrictModeForDevtools(false); } } + 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; @@ -594,7 +595,9 @@ export function renderWithHooks( didScheduleRenderPhaseUpdate = false; // This is reset by checkDidRenderIdHook // localIdCounter = 0; + thenableIndexCounter = 0; + thenableState = null; if (didRenderTooFewHooks) { throw new Error( @@ -638,7 +641,39 @@ export function renderWithHooks( } } } +} +export function replaySuspendedComponentWithHooks( + current: Fiber | null, + workInProgress: Fiber, + Component: (p: Props, arg: SecondArg) => any, + props: Props, + secondArg: SecondArg, +): 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, + ); + finishRenderingHooks(current, workInProgress); return children; } @@ -647,7 +682,6 @@ function renderWithHooksAgain( Component: (p: Props, arg: SecondArg) => any, props: Props, secondArg: SecondArg, - prevThenableState: ThenableState | null, ) { // This is used to perform another render pass. It's used when setState is // called during render, and for double invoking components in Strict Mode @@ -695,7 +729,6 @@ function renderWithHooksAgain( ? HooksDispatcherOnRerenderInDEV : HooksDispatcherOnRerender; - prepareThenableState(prevThenableState); children = Component(props, secondArg); } while (didScheduleRenderPhaseUpdateDuringThisPass); return children; @@ -732,10 +765,19 @@ export function bailoutHooks( } export function resetHooksAfterThrow(): void { + // This is called immediaetly after a throw. It shouldn't reset the entire + // module state, because the work loop might decide to replay the component + // again without rewinding. + // + // It should only reset things like the current dispatcher, to prevent hooks + // from being called outside of a component. + // 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; +} +export function resetHooksOnUnwind(): void { if (didScheduleRenderPhaseUpdate) { // There were render phase updates. These are only valid for this render // phase, which we are now aborting. Remove the updates from the queues so @@ -772,6 +814,7 @@ export function resetHooksAfterThrow(): void { didScheduleRenderPhaseUpdateDuringThisPass = false; localIdCounter = 0; thenableIndexCounter = 0; + thenableState = null; } function mountWorkInProgressHook(): Hook { @@ -830,7 +873,24 @@ function updateWorkInProgressHook(): Hook { // Clone from the current hook. if (nextCurrentHook === null) { - throw new Error('Rendered more hooks than during the previous render.'); + const currentFiber = currentlyRenderingFiber.alternate; + if (currentFiber === null) { + // This is the initial render. This branch is reached when the component + // suspends, resumes, then renders an additional hook. + const newHook: Hook = { + memoizedState: null, + + baseState: null, + baseQueue: null, + queue: null, + + next: null, + }; + nextCurrentHook = newHook; + } else { + // This is an update. We should always have a current hook. + throw new Error('Rendered more hooks than during the previous render.'); + } } currentHook = nextCurrentHook; @@ -888,7 +948,11 @@ function use(usable: Usable): T { // Track the position of the thenable within this fiber. const index = thenableIndexCounter; thenableIndexCounter += 1; - return trackUsedThenable(thenable, index); + + if (thenableState === null) { + thenableState = createThenableState(); + } + return trackUsedThenable(thenableState, thenable, index); } else if ( usable.$$typeof === REACT_CONTEXT_TYPE || usable.$$typeof === REACT_SERVER_CONTEXT_TYPE diff --git a/packages/react-reconciler/src/ReactFiberThenable.new.js b/packages/react-reconciler/src/ReactFiberThenable.new.js index c884737ae5de7..53d1c7176d588 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.new.js +++ b/packages/react-reconciler/src/ReactFiberThenable.new.js @@ -31,61 +31,40 @@ export const SuspenseException: mixed = new Error( "call the promise's `.catch` method and pass the result to `use`", ); -let thenableState: ThenableState | null = null; - export function createThenableState(): ThenableState { // The ThenableState is created the first time a component suspends. If it // suspends again, we'll reuse the same state. return []; } -export function prepareThenableState(prevThenableState: ThenableState | null) { - // This function is called before every function that might suspend - // with `use`. Right now, that's only Hooks, but in the future we'll use the - // same mechanism for unwrapping promises during reconciliation. - thenableState = prevThenableState; -} - -export function getThenableStateAfterSuspending(): ThenableState | null { - // Called by the work loop so it can stash the thenable state. It will use - // the state to replay the component when the promise resolves. - const state = thenableState; - thenableState = null; - return state; -} - -export function isThenableStateResolved(thenables: ThenableState): boolean { - const lastThenable = thenables[thenables.length - 1]; - if (lastThenable !== undefined) { - const status = lastThenable.status; - return status === 'fulfilled' || status === 'rejected'; - } - return true; +export function isThenableResolved(thenable: Thenable): boolean { + const status = thenable.status; + return status === 'fulfilled' || status === 'rejected'; } function noop(): void {} -export function trackUsedThenable(thenable: Thenable, index: number): T { +export function trackUsedThenable( + thenableState: ThenableState, + thenable: Thenable, + index: number, +): T { if (__DEV__ && ReactCurrentActQueue.current !== null) { ReactCurrentActQueue.didUsePromise = true; } - if (thenableState === null) { - thenableState = [thenable]; + const previous = thenableState[index]; + if (previous === undefined) { + thenableState.push(thenable); } else { - const previous = thenableState[index]; - if (previous === undefined) { - thenableState.push(thenable); - } else { - if (previous !== thenable) { - // Reuse the previous thenable, and drop the new one. We can assume - // they represent the same value, because components are idempotent. - - // Avoid an unhandled rejection errors for the Promises that we'll - // intentionally ignore. - thenable.then(noop, noop); - thenable = previous; - } + if (previous !== thenable) { + // Reuse the previous thenable, and drop the new one. We can assume + // they represent the same value, because components are idempotent. + + // Avoid an unhandled rejection errors for the Promises that we'll + // intentionally ignore. + thenable.then(noop, noop); + thenable = previous; } } diff --git a/packages/react-reconciler/src/ReactFiberThenable.old.js b/packages/react-reconciler/src/ReactFiberThenable.old.js index c884737ae5de7..53d1c7176d588 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.old.js +++ b/packages/react-reconciler/src/ReactFiberThenable.old.js @@ -31,61 +31,40 @@ export const SuspenseException: mixed = new Error( "call the promise's `.catch` method and pass the result to `use`", ); -let thenableState: ThenableState | null = null; - export function createThenableState(): ThenableState { // The ThenableState is created the first time a component suspends. If it // suspends again, we'll reuse the same state. return []; } -export function prepareThenableState(prevThenableState: ThenableState | null) { - // This function is called before every function that might suspend - // with `use`. Right now, that's only Hooks, but in the future we'll use the - // same mechanism for unwrapping promises during reconciliation. - thenableState = prevThenableState; -} - -export function getThenableStateAfterSuspending(): ThenableState | null { - // Called by the work loop so it can stash the thenable state. It will use - // the state to replay the component when the promise resolves. - const state = thenableState; - thenableState = null; - return state; -} - -export function isThenableStateResolved(thenables: ThenableState): boolean { - const lastThenable = thenables[thenables.length - 1]; - if (lastThenable !== undefined) { - const status = lastThenable.status; - return status === 'fulfilled' || status === 'rejected'; - } - return true; +export function isThenableResolved(thenable: Thenable): boolean { + const status = thenable.status; + return status === 'fulfilled' || status === 'rejected'; } function noop(): void {} -export function trackUsedThenable(thenable: Thenable, index: number): T { +export function trackUsedThenable( + thenableState: ThenableState, + thenable: Thenable, + index: number, +): T { if (__DEV__ && ReactCurrentActQueue.current !== null) { ReactCurrentActQueue.didUsePromise = true; } - if (thenableState === null) { - thenableState = [thenable]; + const previous = thenableState[index]; + if (previous === undefined) { + thenableState.push(thenable); } else { - const previous = thenableState[index]; - if (previous === undefined) { - thenableState.push(thenable); - } else { - if (previous !== thenable) { - // Reuse the previous thenable, and drop the new one. We can assume - // they represent the same value, because components are idempotent. - - // Avoid an unhandled rejection errors for the Promises that we'll - // intentionally ignore. - thenable.then(noop, noop); - thenable = previous; - } + if (previous !== thenable) { + // Reuse the previous thenable, and drop the new one. We can assume + // they represent the same value, because components are idempotent. + + // Avoid an unhandled rejection errors for the Promises that we'll + // intentionally ignore. + thenable.then(noop, noop); + thenable = previous; } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 59adc4c1b5ad0..b8135becc4360 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -25,7 +25,6 @@ import type { TransitionAbort, } from './ReactFiberTracingMarkerComponent.new'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; -import type {ThenableState} from './ReactFiberThenable.new'; import { warnAboutDeprecatedLifecycles, @@ -181,6 +180,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'; @@ -210,6 +210,7 @@ import {enqueueUpdate} from './ReactFiberClassUpdateQueue.new'; import {resetContextDependencies} from './ReactFiberNewContext.new'; import { resetHooksAfterThrow, + resetHooksOnUnwind, ContextOnlyDispatcher, } from './ReactFiberHooks.new'; import {DefaultCacheDispatcher} from './ReactFiberCache.new'; @@ -273,14 +274,14 @@ import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.new import { SuspenseException, getSuspendedThenable, - getThenableStateAfterSuspending, - isThenableStateResolved, + isThenableResolved, } from './ReactFiberThenable.new'; import {schedulePostPaintCallback} from './ReactPostPaintCallback'; import { getSuspenseHandler, isBadSuspenseFallback, } from './ReactFiberSuspenseContext.new'; +import {resolveDefaultProps} from './ReactFiberLazyComponent.new'; const ceil = Math.ceil; @@ -319,13 +320,14 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; -opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5; +opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6; const NotSuspended: SuspendedReason = 0; const SuspendedOnError: SuspendedReason = 1; const SuspendedOnData: SuspendedReason = 2; const SuspendedOnImmediate: SuspendedReason = 3; -const SuspendedAndReadyToUnwind: SuspendedReason = 4; -const SuspendedOnHydration: SuspendedReason = 5; +const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 4; +const SuspendedAndReadyToUnwind: SuspendedReason = 5; +const SuspendedOnHydration: SuspendedReason = 6; // 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 @@ -333,7 +335,6 @@ const SuspendedOnHydration: SuspendedReason = 5; // immediately instead of unwinding the stack. let workInProgressSuspendedReason: SuspendedReason = NotSuspended; let workInProgressThrownValue: mixed = null; -let workInProgressSuspendedThenableState: ThenableState | null = null; // Whether a ping listener was attached during this render. This is slightly // different that whether something suspended, because we don't add multiple @@ -1719,10 +1720,17 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { } if (workInProgress !== null) { - let interruptedWork = - workInProgressSuspendedReason === NotSuspended - ? workInProgress.return - : workInProgress; + let interruptedWork; + if (workInProgressSuspendedReason === NotSuspended) { + // Normal case. Work-in-progress hasn't started yet. Unwind all + // its parents. + interruptedWork = workInProgress.return; + } else { + // Work-in-progress is in suspended state. Reset the work loop and unwind + // both the suspended fiber and all its parents. + resetSuspendedWorkLoopOnUnwind(); + interruptedWork = workInProgress; + } while (interruptedWork !== null) { const current = interruptedWork.alternate; unwindInterruptedWork( @@ -1739,7 +1747,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = renderLanes = lanes; workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - workInProgressSuspendedThenableState = null; workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; @@ -1759,13 +1766,30 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleThrow(root, thrownValue): void { +function resetSuspendedWorkLoopOnUnwind() { // Reset module-level state that was set during the render phase. resetContextDependencies(); + resetHooksOnUnwind(); +} + +function handleThrow(root, thrownValue): void { + // A component threw an exception. Usually this is because it suspended, but + // it also includes regular program errors. + // + // We're either going to unwind the stack to show a Suspense or error + // boundary, or we're going to replay the component again. Like after a + // promise resolves. + // + // Until we decide whether we're going to unwind or replay, we should preserve + // the current state of the work loop without resetting anything. + // + // If we do decide to unwind the stack, module-level variables will be reset + // in resetSuspendedWorkLoopOnUnwind. + + // These should be reset immediately because they're only supposed to be set + // when React is executing user code. resetHooksAfterThrow(); resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. ReactCurrentOwner.current = null; if (thrownValue === SuspenseException) { @@ -1775,7 +1799,6 @@ function handleThrow(root, thrownValue): void { // API for suspending. This implementation detail can change later, once we // deprecate the old API in favor of `use`. thrownValue = getSuspendedThenable(); - workInProgressSuspendedThenableState = getThenableStateAfterSuspending(); workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves() ? SuspendedOnData : SuspendedOnImmediate; @@ -1789,13 +1812,9 @@ function handleThrow(root, thrownValue): void { // // We could name this something more general but as of now it's the only // case where we think this should happen. - workInProgressSuspendedThenableState = null; workInProgressSuspendedReason = SuspendedOnHydration; } 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; - + // This is a regular error. const isWakeable = thrownValue !== null && typeof thrownValue === 'object' && @@ -1805,7 +1824,7 @@ function handleThrow(root, thrownValue): void { workInProgressSuspendedReason = isWakeable ? // A wakeable object was thrown by a legacy Suspense implementation. // This has slightly different behavior than suspending with `use`. - SuspendedAndReadyToUnwind + SuspendedOnDeprecatedThrowPromise : // 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; @@ -1849,7 +1868,7 @@ function handleThrow(root, thrownValue): void { function shouldAttemptToSuspendUntilDataResolves() { // TODO: We should be able to move the - // renderDidSuspend/renderDidSuspendWithDelay logic into this function, + // renderDidSuspend/renderDidSuspendDelayIfPossible logic into this function, // instead of repeating it in the complete phase. Or something to that effect. if (includesOnlyRetries(workInProgressRootRenderLanes)) { @@ -1857,6 +1876,18 @@ function shouldAttemptToSuspendUntilDataResolves() { return true; } + // Check if there are other pending updates that might possibly unblock this + // component from suspending. This mirrors the check in + // renderDidSuspendDelayIfPossible. We should attempt to unify them somehow. + if ( + includesNonIdleWork(workInProgressRootSkippedLanes) || + includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes) + ) { + // Suspend normally. renderDidSuspendDelayIfPossible will handle + // interrupting the work loop. + return false; + } + // TODO: We should be able to remove the equivalent check in // finishConcurrentRender, and rely just on this one. if (includesOnlyTransitions(workInProgressRootRenderLanes)) { @@ -2165,24 +2196,21 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { break; } case SuspendedOnData: { - const didResolve = - workInProgressSuspendedThenableState !== null && - isThenableStateResolved(workInProgressSuspendedThenableState); - if (didResolve) { + const thenable: Thenable = (thrownValue: any); + if (isThenableResolved(thenable)) { + // The data resolved. Try rendering the component again. workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - replaySuspendedUnitOfWork(unitOfWork, thrownValue); - } else { - // The work loop is suspended on data. We should wait for it to - // resolve before continuing to render. - const thenable: Thenable = (workInProgressThrownValue: any); - const onResolution = () => { - ensureRootIsScheduled(root, now()); - }; - thenable.then(onResolution, onResolution); - break outer; + replaySuspendedUnitOfWork(unitOfWork); + break; } - break; + // The work loop is suspended on data. We should wait for it to + // resolve before continuing to render. + const onResolution = () => { + ensureRootIsScheduled(root, now()); + }; + thenable.then(onResolution, onResolution); + break outer; } case SuspendedOnImmediate: { // If this fiber just suspended, it's possible the data is already @@ -2191,6 +2219,31 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workInProgressSuspendedReason = SuspendedAndReadyToUnwind; break outer; } + case SuspendedAndReadyToUnwind: { + const thenable: Thenable = (thrownValue: any); + if (isThenableResolved(thenable)) { + // The data resolved. Try rendering the component again. + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + replaySuspendedUnitOfWork(unitOfWork); + } else { + // Otherwise, unwind then continue with the normal work loop. + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + } + break; + } + case SuspendedOnDeprecatedThrowPromise: { + // Suspended by an old implementation that uses the `throw promise` + // pattern. The newer replaying behavior can cause subtle issues + // like infinite ping loops. So we maintain the old behavior and + // always unwind. + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + break; + } case SuspendedOnHydration: { // Selective hydration. An update flowed into a dehydrated tree. // Interrupt the current render so the work loop can switch to the @@ -2200,18 +2253,9 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { break outer; } default: { - workInProgressSuspendedReason = NotSuspended; - workInProgressThrownValue = null; - const didResolve = - workInProgressSuspendedThenableState !== null && - isThenableStateResolved(workInProgressSuspendedThenableState); - if (didResolve) { - replaySuspendedUnitOfWork(unitOfWork, thrownValue); - } else { - unwindSuspendedUnitOfWork(unitOfWork, thrownValue); - } - // Continue with the normal work loop. - break; + throw new Error( + 'Unexpected SuspendedReason. This is a bug in React.', + ); } } } @@ -2295,34 +2339,85 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function replaySuspendedUnitOfWork( - unitOfWork: Fiber, - thrownValue: mixed, -): void { +function replaySuspendedUnitOfWork(unitOfWork: Fiber): void { // 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); - 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, + workInProgressRootRenderLanes, + ); + break; + } + case SimpleMemoComponent: { + const Component = unitOfWork.type; + const nextProps = unitOfWork.pendingProps; + next = replayFunctionComponent( + current, + unitOfWork, + nextProps, + Component, + 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 - // used to track the fiber while it was suspended. Then return to the normal - // work loop. - workInProgressSuspendedThenableState = null; + // The begin phase finished successfully without suspending. Return to the + // normal work loop. resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2342,7 +2437,7 @@ function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) { // // Return to the normal work loop. This will unwind the stack, and potentially // result in showing a fallback. - workInProgressSuspendedThenableState = null; + resetSuspendedWorkLoopOnUnwind(); const returnFiber = unitOfWork.return; if (returnFiber === null || workInProgressRoot === null) { @@ -2385,10 +2480,6 @@ function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) { completeUnitOfWork(unitOfWork); } -export function getSuspendedThenableState(): ThenableState | null { - return workInProgressSuspendedThenableState; -} - function completeUnitOfWork(unitOfWork: Fiber): void { // Attempt to complete the current unit of work, then move to the next // sibling. If there are no more siblings, return to the parent fiber. @@ -3695,14 +3786,11 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleThrow; any changes here must have - // corresponding changes there. - resetContextDependencies(); - resetHooksAfterThrow(); // Don't reset current debug fiber, since we're about to work on the // same fiber again. // Unwind the failed stack frame + resetSuspendedWorkLoopOnUnwind(); unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); // Restore the original properties of the fiber. diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 101004007e399..3b571b1ceb3d7 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -25,7 +25,6 @@ import type { TransitionAbort, } from './ReactFiberTracingMarkerComponent.old'; import type {OffscreenInstance} from './ReactFiberOffscreenComponent'; -import type {ThenableState} from './ReactFiberThenable.old'; import { warnAboutDeprecatedLifecycles, @@ -181,6 +180,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'; @@ -210,6 +210,7 @@ import {enqueueUpdate} from './ReactFiberClassUpdateQueue.old'; import {resetContextDependencies} from './ReactFiberNewContext.old'; import { resetHooksAfterThrow, + resetHooksOnUnwind, ContextOnlyDispatcher, } from './ReactFiberHooks.old'; import {DefaultCacheDispatcher} from './ReactFiberCache.old'; @@ -273,14 +274,14 @@ import {processTransitionCallbacks} from './ReactFiberTracingMarkerComponent.old import { SuspenseException, getSuspendedThenable, - getThenableStateAfterSuspending, - isThenableStateResolved, + isThenableResolved, } from './ReactFiberThenable.old'; import {schedulePostPaintCallback} from './ReactPostPaintCallback'; import { getSuspenseHandler, isBadSuspenseFallback, } from './ReactFiberSuspenseContext.old'; +import {resolveDefaultProps} from './ReactFiberLazyComponent.old'; const ceil = Math.ceil; @@ -319,13 +320,14 @@ let workInProgress: Fiber | null = null; // The lanes we're rendering let workInProgressRootRenderLanes: Lanes = NoLanes; -opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5; +opaque type SuspendedReason = 0 | 1 | 2 | 3 | 4 | 5 | 6; const NotSuspended: SuspendedReason = 0; const SuspendedOnError: SuspendedReason = 1; const SuspendedOnData: SuspendedReason = 2; const SuspendedOnImmediate: SuspendedReason = 3; -const SuspendedAndReadyToUnwind: SuspendedReason = 4; -const SuspendedOnHydration: SuspendedReason = 5; +const SuspendedOnDeprecatedThrowPromise: SuspendedReason = 4; +const SuspendedAndReadyToUnwind: SuspendedReason = 5; +const SuspendedOnHydration: SuspendedReason = 6; // 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 @@ -333,7 +335,6 @@ const SuspendedOnHydration: SuspendedReason = 5; // immediately instead of unwinding the stack. let workInProgressSuspendedReason: SuspendedReason = NotSuspended; let workInProgressThrownValue: mixed = null; -let workInProgressSuspendedThenableState: ThenableState | null = null; // Whether a ping listener was attached during this render. This is slightly // different that whether something suspended, because we don't add multiple @@ -1719,10 +1720,17 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { } if (workInProgress !== null) { - let interruptedWork = - workInProgressSuspendedReason === NotSuspended - ? workInProgress.return - : workInProgress; + let interruptedWork; + if (workInProgressSuspendedReason === NotSuspended) { + // Normal case. Work-in-progress hasn't started yet. Unwind all + // its parents. + interruptedWork = workInProgress.return; + } else { + // Work-in-progress is in suspended state. Reset the work loop and unwind + // both the suspended fiber and all its parents. + resetSuspendedWorkLoopOnUnwind(); + interruptedWork = workInProgress; + } while (interruptedWork !== null) { const current = interruptedWork.alternate; unwindInterruptedWork( @@ -1739,7 +1747,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { workInProgressRootRenderLanes = renderLanes = lanes; workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - workInProgressSuspendedThenableState = null; workInProgressRootDidAttachPingListener = false; workInProgressRootExitStatus = RootInProgress; workInProgressRootFatalError = null; @@ -1759,13 +1766,30 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { return rootWorkInProgress; } -function handleThrow(root, thrownValue): void { +function resetSuspendedWorkLoopOnUnwind() { // Reset module-level state that was set during the render phase. resetContextDependencies(); + resetHooksOnUnwind(); +} + +function handleThrow(root, thrownValue): void { + // A component threw an exception. Usually this is because it suspended, but + // it also includes regular program errors. + // + // We're either going to unwind the stack to show a Suspense or error + // boundary, or we're going to replay the component again. Like after a + // promise resolves. + // + // Until we decide whether we're going to unwind or replay, we should preserve + // the current state of the work loop without resetting anything. + // + // If we do decide to unwind the stack, module-level variables will be reset + // in resetSuspendedWorkLoopOnUnwind. + + // These should be reset immediately because they're only supposed to be set + // when React is executing user code. resetHooksAfterThrow(); resetCurrentDebugFiberInDEV(); - // TODO: I found and added this missing line while investigating a - // separate issue. Write a regression test using string refs. ReactCurrentOwner.current = null; if (thrownValue === SuspenseException) { @@ -1775,7 +1799,6 @@ function handleThrow(root, thrownValue): void { // API for suspending. This implementation detail can change later, once we // deprecate the old API in favor of `use`. thrownValue = getSuspendedThenable(); - workInProgressSuspendedThenableState = getThenableStateAfterSuspending(); workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves() ? SuspendedOnData : SuspendedOnImmediate; @@ -1789,13 +1812,9 @@ function handleThrow(root, thrownValue): void { // // We could name this something more general but as of now it's the only // case where we think this should happen. - workInProgressSuspendedThenableState = null; workInProgressSuspendedReason = SuspendedOnHydration; } 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; - + // This is a regular error. const isWakeable = thrownValue !== null && typeof thrownValue === 'object' && @@ -1805,7 +1824,7 @@ function handleThrow(root, thrownValue): void { workInProgressSuspendedReason = isWakeable ? // A wakeable object was thrown by a legacy Suspense implementation. // This has slightly different behavior than suspending with `use`. - SuspendedAndReadyToUnwind + SuspendedOnDeprecatedThrowPromise : // 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; @@ -1849,7 +1868,7 @@ function handleThrow(root, thrownValue): void { function shouldAttemptToSuspendUntilDataResolves() { // TODO: We should be able to move the - // renderDidSuspend/renderDidSuspendWithDelay logic into this function, + // renderDidSuspend/renderDidSuspendDelayIfPossible logic into this function, // instead of repeating it in the complete phase. Or something to that effect. if (includesOnlyRetries(workInProgressRootRenderLanes)) { @@ -1857,6 +1876,18 @@ function shouldAttemptToSuspendUntilDataResolves() { return true; } + // Check if there are other pending updates that might possibly unblock this + // component from suspending. This mirrors the check in + // renderDidSuspendDelayIfPossible. We should attempt to unify them somehow. + if ( + includesNonIdleWork(workInProgressRootSkippedLanes) || + includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes) + ) { + // Suspend normally. renderDidSuspendDelayIfPossible will handle + // interrupting the work loop. + return false; + } + // TODO: We should be able to remove the equivalent check in // finishConcurrentRender, and rely just on this one. if (includesOnlyTransitions(workInProgressRootRenderLanes)) { @@ -2165,24 +2196,21 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { break; } case SuspendedOnData: { - const didResolve = - workInProgressSuspendedThenableState !== null && - isThenableStateResolved(workInProgressSuspendedThenableState); - if (didResolve) { + const thenable: Thenable = (thrownValue: any); + if (isThenableResolved(thenable)) { + // The data resolved. Try rendering the component again. workInProgressSuspendedReason = NotSuspended; workInProgressThrownValue = null; - replaySuspendedUnitOfWork(unitOfWork, thrownValue); - } else { - // The work loop is suspended on data. We should wait for it to - // resolve before continuing to render. - const thenable: Thenable = (workInProgressThrownValue: any); - const onResolution = () => { - ensureRootIsScheduled(root, now()); - }; - thenable.then(onResolution, onResolution); - break outer; + replaySuspendedUnitOfWork(unitOfWork); + break; } - break; + // The work loop is suspended on data. We should wait for it to + // resolve before continuing to render. + const onResolution = () => { + ensureRootIsScheduled(root, now()); + }; + thenable.then(onResolution, onResolution); + break outer; } case SuspendedOnImmediate: { // If this fiber just suspended, it's possible the data is already @@ -2191,6 +2219,31 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { workInProgressSuspendedReason = SuspendedAndReadyToUnwind; break outer; } + case SuspendedAndReadyToUnwind: { + const thenable: Thenable = (thrownValue: any); + if (isThenableResolved(thenable)) { + // The data resolved. Try rendering the component again. + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + replaySuspendedUnitOfWork(unitOfWork); + } else { + // Otherwise, unwind then continue with the normal work loop. + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + } + break; + } + case SuspendedOnDeprecatedThrowPromise: { + // Suspended by an old implementation that uses the `throw promise` + // pattern. The newer replaying behavior can cause subtle issues + // like infinite ping loops. So we maintain the old behavior and + // always unwind. + workInProgressSuspendedReason = NotSuspended; + workInProgressThrownValue = null; + unwindSuspendedUnitOfWork(unitOfWork, thrownValue); + break; + } case SuspendedOnHydration: { // Selective hydration. An update flowed into a dehydrated tree. // Interrupt the current render so the work loop can switch to the @@ -2200,18 +2253,9 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { break outer; } default: { - workInProgressSuspendedReason = NotSuspended; - workInProgressThrownValue = null; - const didResolve = - workInProgressSuspendedThenableState !== null && - isThenableStateResolved(workInProgressSuspendedThenableState); - if (didResolve) { - replaySuspendedUnitOfWork(unitOfWork, thrownValue); - } else { - unwindSuspendedUnitOfWork(unitOfWork, thrownValue); - } - // Continue with the normal work loop. - break; + throw new Error( + 'Unexpected SuspendedReason. This is a bug in React.', + ); } } } @@ -2295,34 +2339,85 @@ function performUnitOfWork(unitOfWork: Fiber): void { ReactCurrentOwner.current = null; } -function replaySuspendedUnitOfWork( - unitOfWork: Fiber, - thrownValue: mixed, -): void { +function replaySuspendedUnitOfWork(unitOfWork: Fiber): void { // 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); - 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, + workInProgressRootRenderLanes, + ); + break; + } + case SimpleMemoComponent: { + const Component = unitOfWork.type; + const nextProps = unitOfWork.pendingProps; + next = replayFunctionComponent( + current, + unitOfWork, + nextProps, + Component, + 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 - // used to track the fiber while it was suspended. Then return to the normal - // work loop. - workInProgressSuspendedThenableState = null; + // The begin phase finished successfully without suspending. Return to the + // normal work loop. resetCurrentDebugFiberInDEV(); unitOfWork.memoizedProps = unitOfWork.pendingProps; @@ -2342,7 +2437,7 @@ function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) { // // Return to the normal work loop. This will unwind the stack, and potentially // result in showing a fallback. - workInProgressSuspendedThenableState = null; + resetSuspendedWorkLoopOnUnwind(); const returnFiber = unitOfWork.return; if (returnFiber === null || workInProgressRoot === null) { @@ -2385,10 +2480,6 @@ function unwindSuspendedUnitOfWork(unitOfWork: Fiber, thrownValue: mixed) { completeUnitOfWork(unitOfWork); } -export function getSuspendedThenableState(): ThenableState | null { - return workInProgressSuspendedThenableState; -} - function completeUnitOfWork(unitOfWork: Fiber): void { // Attempt to complete the current unit of work, then move to the next // sibling. If there are no more siblings, return to the parent fiber. @@ -3695,14 +3786,11 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { throw originalError; } - // Keep this code in sync with handleThrow; any changes here must have - // corresponding changes there. - resetContextDependencies(); - resetHooksAfterThrow(); // Don't reset current debug fiber, since we're about to work on the // same fiber again. // Unwind the failed stack frame + resetSuspendedWorkLoopOnUnwind(); unwindInterruptedWork(current, unitOfWork, workInProgressRootRenderLanes); // Restore the original properties of the fiber. diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 37fd06f1e0260..63dc3c04d0a37 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -1071,7 +1071,9 @@ describe('ReactHooks', () => { expect(() => { expect(() => { ReactTestRenderer.create(); - }).toThrow('Rendered more hooks than during the previous render.'); + }).toThrow( + 'Should have a queue. This is likely a bug in React. Please file an issue.', + ); }).toErrorDev([ 'Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks', 'Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks', diff --git a/packages/react-reconciler/src/__tests__/ReactThenable-test.js b/packages/react-reconciler/src/__tests__/ReactThenable-test.js index f0bca1420145c..0af8ccafd381d 100644 --- a/packages/react-reconciler/src/__tests__/ReactThenable-test.js +++ b/packages/react-reconciler/src/__tests__/ReactThenable-test.js @@ -5,6 +5,8 @@ let ReactNoop; let Scheduler; let act; let use; +let useState; +let useMemo; let Suspense; let startTransition; let pendingTextRequests; @@ -18,6 +20,8 @@ describe('ReactThenable', () => { Scheduler = require('scheduler'); act = require('jest-react').act; use = React.use; + useState = React.useState; + useMemo = React.useMemo; Suspense = React.Suspense; startTransition = React.startTransition; @@ -641,6 +645,37 @@ describe('ReactThenable', () => { expect(Scheduler).toHaveYielded(['Something different']); }); + // @gate enableUseHook + test('while suspended, hooks cannot be called (i.e. current dispatcher is unset correctly)', async () => { + function App() { + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(} />); + }); + + await act(async () => { + startTransition(() => { + root.render( + }> + + , + ); + }); + }); + expect(Scheduler).toHaveYielded([ + 'Async text requested [Will never resolve]', + ]); + + // Calling a hook should error because we're oustide of a component. + expect(useState).toThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a ' + + 'function component.', + ); + }); + // @gate enableUseHook test('unwraps thenable that fulfills synchronously without suspending', async () => { function App() { @@ -668,4 +703,177 @@ describe('ReactThenable', () => { expect(Scheduler).toHaveYielded(['Hi']); expect(root).toMatchRenderedOutput('Hi'); }); + + // @gate enableUseHook + test('does not suspend indefinitely if an interleaved update was skipped', async () => { + function Child({childShouldSuspend}) { + return ( + + ); + } + + let setChildShouldSuspend; + let setShowChild; + function Parent() { + const [showChild, _setShowChild] = useState(true); + setShowChild = _setShowChild; + + const [childShouldSuspend, _setChildShouldSuspend] = useState(false); + setChildShouldSuspend = _setChildShouldSuspend; + + Scheduler.unstable_yieldValue( + `childShouldSuspend: ${childShouldSuspend}, showChild: ${showChild}`, + ); + return showChild ? ( + + ) : ( + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'childShouldSuspend: false, showChild: true', + 'Child', + ]); + expect(root).toMatchRenderedOutput('Child'); + + await act(() => { + // Perform an update that causes the app to suspend + startTransition(() => { + setChildShouldSuspend(true); + }); + expect(Scheduler).toFlushAndYieldThrough([ + 'childShouldSuspend: true, showChild: true', + ]); + // While the update is in progress, schedule another update. + startTransition(() => { + setShowChild(false); + }); + }); + expect(Scheduler).toHaveYielded([ + // Because the interleaved update is not higher priority than what we were + // already working on, it won't interrupt. The first update will continue, + // and will suspend. + 'Async text requested [Will never resolve]', + + // Instead of waiting for the promise to resolve, React notices there's + // another pending update that it hasn't tried yet. It will switch to + // rendering that instead. + // + // This time, the update hides the component that previous was suspending, + // so it finishes successfully. + 'childShouldSuspend: false, showChild: false', + '(empty)', + + // Finally, React attempts to render the first update again. It also + // finishes successfully, because it was rebased on top of the update that + // hid the suspended component. + // NOTE: These this render happened to not be entangled with the previous + // one. If they had been, this update would have been included in the + // previous render, and there wouldn't be an extra one here. This could + // change if we change our entanglement heurstics. Semantically, it + // shouldn't matter, though in general we try to work on transitions in + // parallel whenever possible. So even though in this particular case, the + // extra render is unnecessary, it's a nice property that it wasn't + // entangled with the other transition. + 'childShouldSuspend: true, showChild: false', + '(empty)', + ]); + 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', + ]); + }, + ); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 03ccbc3e76a25..b4ea290152e4d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -446,5 +446,6 @@ "458": "Currently React only supports one RSC renderer at a time.", "459": "Expected a suspended thenable. This is a bug in React. Please file an issue.", "460": "Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary, or call the promise's `.catch` method and pass the result to `use`", - "461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue." + "461": "This is not a real error. It's an implementation detail of React's selective hydration feature. If this leaks into userspace, it's a bug in React. Please file an issue.", + "462": "Unexpected SuspendedReason. This is a bug in React." }