diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js
index 536200025c819..0b3335dfbacb8 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js
@@ -85,6 +85,38 @@ describe('ReactDOMFizzForm', () => {
expect(container.textContent).toEqual('Final');
});
+ // @gate enableUseDeferredValueInitialArg
+ // @gate enablePostpone
+ it(
+ 'if initial value postpones during hydration, it will switch to the ' +
+ 'final value instead',
+ async () => {
+ function Content() {
+ const isInitial = useDeferredValue(false, true);
+ if (isInitial) {
+ React.unstable_postpone();
+ }
+ return ;
+ }
+
+ function App() {
+ return (
+ }>
+
+
+ );
+ }
+
+ const stream = await ReactDOMServer.renderToReadableStream();
+ await readIntoContainer(stream);
+ expect(container.textContent).toEqual('Loading...');
+
+ // After hydration, it's updated to the final value
+ await act(() => ReactDOMClient.hydrateRoot(container, ));
+ expect(container.textContent).toEqual('Final');
+ },
+ );
+
// @gate enableUseDeferredValueInitialArg
it(
'useDeferredValue during hydration has higher priority than remaining ' +
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index b4693697bff94..f2a96b4d34d9f 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -90,6 +90,7 @@ import {
ShouldCapture,
ForceClientRender,
Passive,
+ DidDefer,
} from './ReactFiberFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
@@ -257,6 +258,7 @@ import {
renderDidSuspendDelayIfPossible,
markSkippedUpdateLanes,
getWorkInProgressRoot,
+ peekDeferredLane,
} from './ReactFiberWorkLoop';
import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent';
@@ -2228,9 +2230,22 @@ function shouldRemainOnFallback(
);
}
-function getRemainingWorkInPrimaryTree(current: Fiber, renderLanes: Lanes) {
- // TODO: Should not remove render lanes that were pinged during this render
- return removeLanes(current.childLanes, renderLanes);
+function getRemainingWorkInPrimaryTree(
+ current: Fiber | null,
+ primaryTreeDidDefer: boolean,
+ renderLanes: Lanes,
+) {
+ let remainingLanes =
+ current !== null ? removeLanes(current.childLanes, renderLanes) : NoLanes;
+ if (primaryTreeDidDefer) {
+ // A useDeferredValue hook spawned a deferred task inside the primary tree.
+ // Ensure that we retry this component at the deferred priority.
+ // TODO: We could make this a per-subtree value instead of a global one.
+ // Would need to track it on the context stack somehow, similar to what
+ // we'd have to do for resumable contexts.
+ remainingLanes = mergeLanes(remainingLanes, peekDeferredLane());
+ }
+ return remainingLanes;
}
function updateSuspenseComponent(
@@ -2259,6 +2274,11 @@ function updateSuspenseComponent(
workInProgress.flags &= ~DidCapture;
}
+ // Check if the primary children spawned a deferred task (useDeferredValue)
+ // during the first pass.
+ const didPrimaryChildrenDefer = (workInProgress.flags & DidDefer) !== NoFlags;
+ workInProgress.flags &= ~DidDefer;
+
// OK, the next part is confusing. We're about to reconcile the Suspense
// boundary's children. This involves some custom reconciliation logic. Two
// main reasons this is so complicated.
@@ -2329,6 +2349,11 @@ function updateSuspenseComponent(
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState =
mountSuspenseOffscreenState(renderLanes);
+ primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
+ current,
+ didPrimaryChildrenDefer,
+ renderLanes,
+ );
workInProgress.memoizedState = SUSPENDED_MARKER;
if (enableTransitionTracing) {
const currentTransitions = getPendingTransitions();
@@ -2368,6 +2393,11 @@ function updateSuspenseComponent(
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState =
mountSuspenseOffscreenState(renderLanes);
+ primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
+ current,
+ didPrimaryChildrenDefer,
+ renderLanes,
+ );
workInProgress.memoizedState = SUSPENDED_MARKER;
// TODO: Transition Tracing is not yet implemented for CPU Suspense.
@@ -2402,6 +2432,7 @@ function updateSuspenseComponent(
current,
workInProgress,
didSuspend,
+ didPrimaryChildrenDefer,
nextProps,
dehydrated,
prevState,
@@ -2464,6 +2495,7 @@ function updateSuspenseComponent(
}
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
current,
+ didPrimaryChildrenDefer,
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
@@ -2834,6 +2866,7 @@ function updateDehydratedSuspenseComponent(
current: Fiber,
workInProgress: Fiber,
didSuspend: boolean,
+ didPrimaryChildrenDefer: boolean,
nextProps: any,
suspenseInstance: SuspenseInstance,
suspenseState: SuspenseState,
@@ -3063,6 +3096,11 @@ function updateDehydratedSuspenseComponent(
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState =
mountSuspenseOffscreenState(renderLanes);
+ primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
+ current,
+ didPrimaryChildrenDefer,
+ renderLanes,
+ );
workInProgress.memoizedState = SUSPENDED_MARKER;
return fallbackChildFragment;
}
diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js
index 5febfa3d528dd..718add62948e0 100644
--- a/packages/react-reconciler/src/ReactFiberFlags.js
+++ b/packages/react-reconciler/src/ReactFiberFlags.js
@@ -41,6 +41,7 @@ export const StoreConsistency = /* */ 0b0000000000000100000000000000
// possible, because we're about to run out of bits.
export const ScheduleRetry = StoreConsistency;
export const ShouldSuspendCommit = Visibility;
+export const DidDefer = ContentReset;
export const LifecycleEffectMask =
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index 54a4a122eab91..6aaf0e936bd89 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -127,6 +127,7 @@ import {
Visibility,
MountPassiveDev,
MountLayoutDev,
+ DidDefer,
} from './ReactFiberFlags';
import {
NoLanes,
@@ -714,6 +715,20 @@ export function requestDeferredLane(): Lane {
workInProgressDeferredLane = requestTransitionLane();
}
}
+
+ // Mark the parent Suspense boundary so it knows to spawn the deferred lane.
+ const suspenseHandler = getSuspenseHandler();
+ if (suspenseHandler !== null) {
+ // TODO: As an optimization, we shouldn't entangle the lanes at the root; we
+ // can entangle them using the baseLanes of the Suspense boundary instead.
+ // We only need to do something special if there's no Suspense boundary.
+ suspenseHandler.flags |= DidDefer;
+ }
+
+ return workInProgressDeferredLane;
+}
+
+export function peekDeferredLane(): Lane {
return workInProgressDeferredLane;
}
@@ -1361,7 +1376,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
// The render unwound without completing the tree. This happens in special
// cases where need to exit the current render without producing a
// consistent tree or committing.
- markRootSuspended(root, lanes, NoLane);
+ markRootSuspended(root, lanes, workInProgressDeferredLane);
ensureRootIsScheduled(root);
return null;
}
diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
index 22f2d33f33961..b321f4bba0de4 100644
--- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
@@ -409,7 +409,7 @@ describe('ReactDeferredValue', () => {
// @gate enableUseDeferredValueInitialArg
it(
'if a suspended render spawns a deferred task, we can switch to the ' +
- 'deferred task without finishing the original one',
+ 'deferred task without finishing the original one (no Suspense boundary)',
async () => {
function App() {
const text = useDeferredValue('Final', 'Loading...');
@@ -439,6 +439,87 @@ describe('ReactDeferredValue', () => {
},
);
+ // @gate enableUseDeferredValueInitialArg
+ it(
+ 'if a suspended render spawns a deferred task, we can switch to the ' +
+ 'deferred task without finishing the original one (no Suspense boundary, ' +
+ 'synchronous parent update)',
+ async () => {
+ function App() {
+ const text = useDeferredValue('Final', 'Loading...');
+ return ;
+ }
+
+ const root = ReactNoop.createRoot();
+ // TODO: This made me realize that we don't warn if an update spawns a
+ // deferred task without being wrapped with `act`. Usually it would work
+ // anyway because the parent task has to wrapped with `act`... but not
+ // if it was flushed with `flushSync` instead.
+ await act(() => {
+ ReactNoop.flushSync(() => root.render());
+ });
+ assertLog([
+ 'Suspend! [Loading...]',
+ // The initial value suspended, so we attempt the final value, which
+ // also suspends.
+ 'Suspend! [Final]',
+ ]);
+ expect(root).toMatchRenderedOutput(null);
+
+ // The final value loads, so we can skip the initial value entirely.
+ await act(() => resolveText('Final'));
+ assertLog(['Final']);
+ expect(root).toMatchRenderedOutput('Final');
+
+ // When the initial value finally loads, nothing happens because we no
+ // longer need it.
+ await act(() => resolveText('Loading...'));
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Final');
+ },
+ );
+
+ // @gate enableUseDeferredValueInitialArg
+ it(
+ 'if a suspended render spawns a deferred task, we can switch to the ' +
+ 'deferred task without finishing the original one (Suspense boundary)',
+ async () => {
+ function App() {
+ const text = useDeferredValue('Final', 'Loading...');
+ return ;
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() =>
+ root.render(
+ }>
+
+ ,
+ ),
+ );
+ assertLog([
+ 'Suspend! [Loading...]',
+ 'Fallback',
+
+ // The initial value suspended, so we attempt the final value, which
+ // also suspends.
+ 'Suspend! [Final]',
+ ]);
+ expect(root).toMatchRenderedOutput('Fallback');
+
+ // The final value loads, so we can skip the initial value entirely.
+ await act(() => resolveText('Final'));
+ assertLog(['Final']);
+ expect(root).toMatchRenderedOutput('Final');
+
+ // When the initial value finally loads, nothing happens because we no
+ // longer need it.
+ await act(() => resolveText('Loading...'));
+ assertLog([]);
+ expect(root).toMatchRenderedOutput('Final');
+ },
+ );
+
// @gate enableUseDeferredValueInitialArg
it(
'if a suspended render spawns a deferred task that also suspends, we can ' +