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."
}