Skip to content

Commit 9f699db

Browse files
committed
Fix: uDV initialValue suspends without switching to final
Fixes a bug in the experimental `initialValue` option for `useDeferredValue` (added in facebook#27500). If rendering the `initialValue` causes the tree to suspend, React should skip it and switch to rendering the final value instead. It should not wait for `initialValue` to resolve. This is not just an optimization, because in some cases the initial value may _never_ resolve — intentionally. For example, if the application does not provide an instant fallback state. This capability is, in fact, the primary motivation for the `initialValue` API. I mostly implemented this correctly in the original PR, but I missed some cases where it wasn't working: - If there's no Suspense boundary between the `useDeferredValue` hook and the component that suspends, and we're not in the shell of the transition (i.e. there's a parent Suspense boundary wrapping the `useDeferredValue` hook), the deferred task would get incorrectly dropped. - Similarly, if there's no Suspense boundary between the `useDeferredValue` hook and the component that suspends, and we're rendering a synchronous update, the deferred task would get incorrectly dropped. What these cases have in common is that it causes the `useDeferredValue` hook itself to be replaced by a Suspense fallback. The fix was the same for both. (It already worked in cases where there's no Suspense fallback at all, because those are handled differently, at the root.) The way I discovered this was when investigating a particular bug in Next.js that would happen during a 'popstate' transition (back/forward), but not during a regular navigation. That's because we render popstate transitions synchronously to preserve browser's scroll position — which in this case triggered the second scenario above.
1 parent 1d5667a commit 9f699db

File tree

5 files changed

+171
-5
lines changed

5 files changed

+171
-5
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzDeferredValue-test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,38 @@ describe('ReactDOMFizzForm', () => {
8585
expect(container.textContent).toEqual('Final');
8686
});
8787

88+
// @gate enableUseDeferredValueInitialArg
89+
// @gate enablePostpone
90+
it(
91+
'if initial value postpones during hydration, it will switch to the ' +
92+
'final value instead',
93+
async () => {
94+
function Content() {
95+
const isInitial = useDeferredValue(false, true);
96+
if (isInitial) {
97+
React.unstable_postpone();
98+
}
99+
return <Text text="Final" />;
100+
}
101+
102+
function App() {
103+
return (
104+
<Suspense fallback={<Text text="Loading..." />}>
105+
<Content />
106+
</Suspense>
107+
);
108+
}
109+
110+
const stream = await ReactDOMServer.renderToReadableStream(<App />);
111+
await readIntoContainer(stream);
112+
expect(container.textContent).toEqual('Loading...');
113+
114+
// After hydration, it's updated to the final value
115+
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
116+
expect(container.textContent).toEqual('Final');
117+
},
118+
);
119+
88120
// @gate enableUseDeferredValueInitialArg
89121
it(
90122
'useDeferredValue during hydration has higher priority than remaining ' +

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
ShouldCapture,
9191
ForceClientRender,
9292
Passive,
93+
DidDefer,
9394
} from './ReactFiberFlags';
9495
import ReactSharedInternals from 'shared/ReactSharedInternals';
9596
import {
@@ -257,6 +258,7 @@ import {
257258
renderDidSuspendDelayIfPossible,
258259
markSkippedUpdateLanes,
259260
getWorkInProgressRoot,
261+
peekDeferredLane,
260262
} from './ReactFiberWorkLoop';
261263
import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
262264
import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent';
@@ -2228,9 +2230,22 @@ function shouldRemainOnFallback(
22282230
);
22292231
}
22302232

2231-
function getRemainingWorkInPrimaryTree(current: Fiber, renderLanes: Lanes) {
2232-
// TODO: Should not remove render lanes that were pinged during this render
2233-
return removeLanes(current.childLanes, renderLanes);
2233+
function getRemainingWorkInPrimaryTree(
2234+
current: Fiber | null,
2235+
primaryTreeDidDefer: boolean,
2236+
renderLanes: Lanes,
2237+
) {
2238+
let remainingLanes =
2239+
current !== null ? removeLanes(current.childLanes, renderLanes) : NoLanes;
2240+
if (primaryTreeDidDefer) {
2241+
// A useDeferredValue hook spawned a deferred task inside the primary tree.
2242+
// Ensure that we retry this component at the deferred priority.
2243+
// TODO: We could make this a per-subtree value instead of a global one.
2244+
// Would need to track it on the context stack somehow, similar to what
2245+
// we'd have to do for resumable contexts.
2246+
remainingLanes = mergeLanes(remainingLanes, peekDeferredLane());
2247+
}
2248+
return remainingLanes;
22342249
}
22352250

22362251
function updateSuspenseComponent(
@@ -2259,6 +2274,11 @@ function updateSuspenseComponent(
22592274
workInProgress.flags &= ~DidCapture;
22602275
}
22612276

2277+
// Check if the primary children spawned a deferred task (useDeferredValue)
2278+
// during the first pass.
2279+
const didPrimaryChildrenDefer = (workInProgress.flags & DidDefer) !== NoFlags;
2280+
workInProgress.flags &= ~DidDefer;
2281+
22622282
// OK, the next part is confusing. We're about to reconcile the Suspense
22632283
// boundary's children. This involves some custom reconciliation logic. Two
22642284
// main reasons this is so complicated.
@@ -2329,6 +2349,11 @@ function updateSuspenseComponent(
23292349
const primaryChildFragment: Fiber = (workInProgress.child: any);
23302350
primaryChildFragment.memoizedState =
23312351
mountSuspenseOffscreenState(renderLanes);
2352+
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
2353+
current,
2354+
didPrimaryChildrenDefer,
2355+
renderLanes,
2356+
);
23322357
workInProgress.memoizedState = SUSPENDED_MARKER;
23332358
if (enableTransitionTracing) {
23342359
const currentTransitions = getPendingTransitions();
@@ -2368,6 +2393,11 @@ function updateSuspenseComponent(
23682393
const primaryChildFragment: Fiber = (workInProgress.child: any);
23692394
primaryChildFragment.memoizedState =
23702395
mountSuspenseOffscreenState(renderLanes);
2396+
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
2397+
current,
2398+
didPrimaryChildrenDefer,
2399+
renderLanes,
2400+
);
23712401
workInProgress.memoizedState = SUSPENDED_MARKER;
23722402

23732403
// TODO: Transition Tracing is not yet implemented for CPU Suspense.
@@ -2402,6 +2432,7 @@ function updateSuspenseComponent(
24022432
current,
24032433
workInProgress,
24042434
didSuspend,
2435+
didPrimaryChildrenDefer,
24052436
nextProps,
24062437
dehydrated,
24072438
prevState,
@@ -2464,6 +2495,7 @@ function updateSuspenseComponent(
24642495
}
24652496
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
24662497
current,
2498+
didPrimaryChildrenDefer,
24672499
renderLanes,
24682500
);
24692501
workInProgress.memoizedState = SUSPENDED_MARKER;
@@ -2834,6 +2866,7 @@ function updateDehydratedSuspenseComponent(
28342866
current: Fiber,
28352867
workInProgress: Fiber,
28362868
didSuspend: boolean,
2869+
didPrimaryChildrenDefer: boolean,
28372870
nextProps: any,
28382871
suspenseInstance: SuspenseInstance,
28392872
suspenseState: SuspenseState,
@@ -3063,6 +3096,11 @@ function updateDehydratedSuspenseComponent(
30633096
const primaryChildFragment: Fiber = (workInProgress.child: any);
30643097
primaryChildFragment.memoizedState =
30653098
mountSuspenseOffscreenState(renderLanes);
3099+
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
3100+
current,
3101+
didPrimaryChildrenDefer,
3102+
renderLanes,
3103+
);
30663104
workInProgress.memoizedState = SUSPENDED_MARKER;
30673105
return fallbackChildFragment;
30683106
}

packages/react-reconciler/src/ReactFiberFlags.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const StoreConsistency = /* */ 0b0000000000000100000000000000
4141
// possible, because we're about to run out of bits.
4242
export const ScheduleRetry = StoreConsistency;
4343
export const ShouldSuspendCommit = Visibility;
44+
export const DidDefer = ContentReset;
4445

4546
export const LifecycleEffectMask =
4647
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ import {
127127
Visibility,
128128
MountPassiveDev,
129129
MountLayoutDev,
130+
DidDefer,
130131
} from './ReactFiberFlags';
131132
import {
132133
NoLanes,
@@ -714,6 +715,20 @@ export function requestDeferredLane(): Lane {
714715
workInProgressDeferredLane = requestTransitionLane();
715716
}
716717
}
718+
719+
// Mark the parent Suspense boundary so it knows to spawn the deferred lane.
720+
const suspenseHandler = getSuspenseHandler();
721+
if (suspenseHandler !== null) {
722+
// TODO: As an optimization, we shouldn't entangle the lanes at the root; we
723+
// can entangle them using the baseLanes of the Suspense boundary instead.
724+
// We only need to do something special if there's no Suspense boundary.
725+
suspenseHandler.flags |= DidDefer;
726+
}
727+
728+
return workInProgressDeferredLane;
729+
}
730+
731+
export function peekDeferredLane(): Lane {
717732
return workInProgressDeferredLane;
718733
}
719734

@@ -1361,7 +1376,7 @@ export function performSyncWorkOnRoot(root: FiberRoot, lanes: Lanes): null {
13611376
// The render unwound without completing the tree. This happens in special
13621377
// cases where need to exit the current render without producing a
13631378
// consistent tree or committing.
1364-
markRootSuspended(root, lanes, NoLane);
1379+
markRootSuspended(root, lanes, workInProgressDeferredLane);
13651380
ensureRootIsScheduled(root);
13661381
return null;
13671382
}

packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ describe('ReactDeferredValue', () => {
409409
// @gate enableUseDeferredValueInitialArg
410410
it(
411411
'if a suspended render spawns a deferred task, we can switch to the ' +
412-
'deferred task without finishing the original one',
412+
'deferred task without finishing the original one (no Suspense boundary)',
413413
async () => {
414414
function App() {
415415
const text = useDeferredValue('Final', 'Loading...');
@@ -439,6 +439,86 @@ describe('ReactDeferredValue', () => {
439439
},
440440
);
441441

442+
it(
443+
'if a suspended render spawns a deferred task, we can switch to the ' +
444+
'deferred task without finishing the original one (no Suspense boundary, ' +
445+
'synchronous parent update)',
446+
async () => {
447+
function App() {
448+
const text = useDeferredValue('Final', 'Loading...');
449+
return <AsyncText text={text} />;
450+
}
451+
452+
const root = ReactNoop.createRoot();
453+
// TODO: This made me realize that we don't warn if an update spawns a
454+
// deferred task without being wrapped with `act`. Usually it would work
455+
// anyway because the parent task has to wrapped with `act`... but not
456+
// if it was flushed with `flushSync` instead.
457+
await act(() => {
458+
ReactNoop.flushSync(() => root.render(<App />));
459+
});
460+
assertLog([
461+
'Suspend! [Loading...]',
462+
// The initial value suspended, so we attempt the final value, which
463+
// also suspends.
464+
'Suspend! [Final]',
465+
]);
466+
expect(root).toMatchRenderedOutput(null);
467+
468+
// The final value loads, so we can skip the initial value entirely.
469+
await act(() => resolveText('Final'));
470+
assertLog(['Final']);
471+
expect(root).toMatchRenderedOutput('Final');
472+
473+
// When the initial value finally loads, nothing happens because we no
474+
// longer need it.
475+
await act(() => resolveText('Loading...'));
476+
assertLog([]);
477+
expect(root).toMatchRenderedOutput('Final');
478+
},
479+
);
480+
481+
// @gate enableUseDeferredValueInitialArg
482+
it(
483+
'if a suspended render spawns a deferred task, we can switch to the ' +
484+
'deferred task without finishing the original one (Suspense boundary)',
485+
async () => {
486+
function App() {
487+
const text = useDeferredValue('Final', 'Loading...');
488+
return <AsyncText text={text} />;
489+
}
490+
491+
const root = ReactNoop.createRoot();
492+
await act(() =>
493+
root.render(
494+
<Suspense fallback={<Text text="Fallback" />}>
495+
<App />
496+
</Suspense>,
497+
),
498+
);
499+
assertLog([
500+
'Suspend! [Loading...]',
501+
'Fallback',
502+
503+
// The initial value suspended, so we attempt the final value, which
504+
// also suspends.
505+
'Suspend! [Final]',
506+
]);
507+
expect(root).toMatchRenderedOutput('Fallback');
508+
509+
// The final value loads, so we can skip the initial value entirely.
510+
await act(() => resolveText('Final'));
511+
assertLog(['Final']);
512+
expect(root).toMatchRenderedOutput('Final');
513+
514+
// When the initial value finally loads, nothing happens because we no
515+
// longer need it.
516+
await act(() => resolveText('Loading...'));
517+
assertLog([]);
518+
expect(root).toMatchRenderedOutput('Final');
519+
},
520+
);
521+
442522
// @gate enableUseDeferredValueInitialArg
443523
it(
444524
'if a suspended render spawns a deferred task that also suspends, we can ' +

0 commit comments

Comments
 (0)