Skip to content

Commit 3302d1f

Browse files
authored
Fix: uDV skipped initial value if earlier transition suspended (#34376)
Fixes a bug in useDeferredValue's optional `initialValue` argument. In the regression case, if a new useDeferredValue hook is mounted while an earlier transition is suspended, the `initialValue` argument of the new hook was ignored. After the fix, the `initialValue` argument is correctly rendered during the initial mount, regardless of whether other transitions were suspended. The culprit was related to the mechanism we use to track whether a render is the result of a `useDeferredValue` hook: we assign the deferred lane a TransitionLane, then entangle that lane with the DeferredLane bit. During the subsequent render, we check for the presence of the DeferredLane bit to determine whether to switch to the final, canonical value. But because transition lanes can themselves become entangled with other transitions, the effect is that every entangled transition was being treated as if it were the result of a `useDeferredValue` hook, causing us to skip the initial value and go straight to the final one. The fix I've chosen is to reserve some subset of TransitionLanes to be used only for deferred work, instead of using entanglement. This is similar to how retries are already implemented. Originally I tried not to implement it this way because it means there are now slightly fewer lanes allocated for regular transitions, but I underestimated how similar deferred work is to retries; they end up having a lot of the same requirements. Eventually it may be possible to merge the two concepts.
1 parent 7697a9f commit 3302d1f

File tree

5 files changed

+104
-15
lines changed

5 files changed

+104
-15
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
includesSomeLane,
7474
isGestureRender,
7575
GestureLane,
76+
UpdateLanes,
7677
} from './ReactFiberLane';
7778
import {
7879
ContinuousEventPriority,
@@ -2983,6 +2984,20 @@ function rerenderDeferredValue<T>(value: T, initialValue?: T): T {
29832984
}
29842985
}
29852986

2987+
function isRenderingDeferredWork(): boolean {
2988+
if (!includesSomeLane(renderLanes, DeferredLane)) {
2989+
// None of the render lanes are deferred lanes.
2990+
return false;
2991+
}
2992+
// At least one of the render lanes are deferred lanes. However, if the
2993+
// current render is also batched together with an update, then we can't
2994+
// say that the render is wholly the result of deferred work. We can check
2995+
// this by checking if the root render lanes contain any "update" lanes, i.e.
2996+
// lanes that are only assigned to updates, like setState.
2997+
const rootRenderLanes = getWorkInProgressRootRenderLanes();
2998+
return !includesSomeLane(rootRenderLanes, UpdateLanes);
2999+
}
3000+
29863001
function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
29873002
if (
29883003
// When `initialValue` is provided, we defer the initial render even if the
@@ -2991,7 +3006,7 @@ function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
29913006
// However, to avoid waterfalls, we do not defer if this render
29923007
// was itself spawned by an earlier useDeferredValue. Check if DeferredLane
29933008
// is part of the render lanes.
2994-
!includesSomeLane(renderLanes, DeferredLane)
3009+
!isRenderingDeferredWork()
29953010
) {
29963011
// Render with the initial value
29973012
hook.memoizedState = initialValue;
@@ -3038,8 +3053,7 @@ function updateDeferredValueImpl<T>(
30383053
}
30393054

30403055
const shouldDeferValue =
3041-
!includesOnlyNonUrgentLanes(renderLanes) &&
3042-
!includesSomeLane(renderLanes, DeferredLane);
3056+
!includesOnlyNonUrgentLanes(renderLanes) && !isRenderingDeferredWork();
30433057
if (shouldDeferValue) {
30443058
// This is an urgent update. Since the value has changed, keep using the
30453059
// previous value and spawn a deferred render to update it later.

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ const TransitionLane12: Lane = /* */ 0b0000000000010000000
7373
const TransitionLane13: Lane = /* */ 0b0000000000100000000000000000000;
7474
const TransitionLane14: Lane = /* */ 0b0000000001000000000000000000000;
7575

76+
const TransitionUpdateLanes =
77+
TransitionLane1 |
78+
TransitionLane2 |
79+
TransitionLane3 |
80+
TransitionLane4 |
81+
TransitionLane5 |
82+
TransitionLane6 |
83+
TransitionLane7 |
84+
TransitionLane8 |
85+
TransitionLane9 |
86+
TransitionLane10;
87+
const TransitionDeferredLanes =
88+
TransitionLane11 | TransitionLane12 | TransitionLane13 | TransitionLane14;
89+
7690
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
7791
const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000;
7892
const RetryLane2: Lane = /* */ 0b0000000100000000000000000000000;
@@ -94,7 +108,7 @@ export const DeferredLane: Lane = /* */ 0b1000000000000000000
94108
// Any lane that might schedule an update. This is used to detect infinite
95109
// update loops, so it doesn't include hydration lanes or retries.
96110
export const UpdateLanes: Lanes =
97-
SyncLane | InputContinuousLane | DefaultLane | TransitionLanes;
111+
SyncLane | InputContinuousLane | DefaultLane | TransitionUpdateLanes;
98112

99113
export const HydrationLanes =
100114
SyncHydrationLane |
@@ -155,7 +169,8 @@ export function getLabelForLane(lane: Lane): string | void {
155169

156170
export const NoTimestamp = -1;
157171

158-
let nextTransitionLane: Lane = TransitionLane1;
172+
let nextTransitionUpdateLane: Lane = TransitionLane1;
173+
let nextTransitionDeferredLane: Lane = TransitionLane11;
159174
let nextRetryLane: Lane = RetryLane1;
160175

161176
function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
@@ -190,11 +205,12 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
190205
case TransitionLane8:
191206
case TransitionLane9:
192207
case TransitionLane10:
208+
return lanes & TransitionUpdateLanes;
193209
case TransitionLane11:
194210
case TransitionLane12:
195211
case TransitionLane13:
196212
case TransitionLane14:
197-
return lanes & TransitionLanes;
213+
return lanes & TransitionDeferredLanes;
198214
case RetryLane1:
199215
case RetryLane2:
200216
case RetryLane3:
@@ -679,14 +695,23 @@ export function isGestureRender(lanes: Lanes): boolean {
679695
return lanes === GestureLane;
680696
}
681697

682-
export function claimNextTransitionLane(): Lane {
698+
export function claimNextTransitionUpdateLane(): Lane {
683699
// Cycle through the lanes, assigning each new transition to the next lane.
684700
// In most cases, this means every transition gets its own lane, until we
685701
// run out of lanes and cycle back to the beginning.
686-
const lane = nextTransitionLane;
687-
nextTransitionLane <<= 1;
688-
if ((nextTransitionLane & TransitionLanes) === NoLanes) {
689-
nextTransitionLane = TransitionLane1;
702+
const lane = nextTransitionUpdateLane;
703+
nextTransitionUpdateLane <<= 1;
704+
if ((nextTransitionUpdateLane & TransitionUpdateLanes) === NoLanes) {
705+
nextTransitionUpdateLane = TransitionLane1;
706+
}
707+
return lane;
708+
}
709+
710+
export function claimNextTransitionDeferredLane(): Lane {
711+
const lane = nextTransitionDeferredLane;
712+
nextTransitionDeferredLane <<= 1;
713+
if ((nextTransitionDeferredLane & TransitionDeferredLanes) === NoLanes) {
714+
nextTransitionDeferredLane = TransitionLane11;
690715
}
691716
return lane;
692717
}
@@ -952,6 +977,14 @@ function markSpawnedDeferredLane(
952977
// Entangle the spawned lane with the DeferredLane bit so that we know it
953978
// was the result of another render. This lets us avoid a useDeferredValue
954979
// waterfall — only the first level will defer.
980+
// TODO: Now that there is a reserved set of transition lanes that are used
981+
// exclusively for deferred work, we should get rid of this special
982+
// DeferredLane bit; the same information can be inferred by checking whether
983+
// the lane is one of the TransitionDeferredLanes. The only reason this still
984+
// exists is because we need to also do the same for OffscreenLane. That
985+
// requires additional changes because there are more places around the
986+
// codebase that treat OffscreenLane as a magic value; would need to check
987+
// for a new OffscreenDeferredLane, too. Will leave this for a follow-up.
955988
const spawnedLaneIndex = laneToIndex(spawnedLane);
956989
root.entangledLanes |= spawnedLane;
957990
root.entanglements[spawnedLaneIndex] |=

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
getNextLanes,
3232
includesSyncLane,
3333
markStarvedLanesAsExpired,
34-
claimNextTransitionLane,
34+
claimNextTransitionUpdateLane,
3535
getNextLanesToFlushSync,
3636
checkIfRootIsPrerendering,
3737
isGestureRender,
@@ -716,7 +716,7 @@ export function requestTransitionLane(
716716
: // We may or may not be inside an async action scope. If we are, this
717717
// is the first update in that scope. Either way, we need to get a
718718
// fresh transition lane.
719-
claimNextTransitionLane();
719+
claimNextTransitionUpdateLane();
720720
}
721721
return currentEventTransitionLane;
722722
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ import {
192192
OffscreenLane,
193193
SyncUpdateLanes,
194194
UpdateLanes,
195-
claimNextTransitionLane,
195+
claimNextTransitionDeferredLane,
196196
checkIfRootIsPrerendering,
197197
includesOnlyViewTransitionEligibleLanes,
198198
isGestureRender,
@@ -827,7 +827,7 @@ export function requestDeferredLane(): Lane {
827827
workInProgressDeferredLane = OffscreenLane;
828828
} else {
829829
// Everything else is spawned as a transition.
830-
workInProgressDeferredLane = claimNextTransitionLane();
830+
workInProgressDeferredLane = claimNextTransitionDeferredLane();
831831
}
832832
}
833833

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,48 @@ describe('ReactDeferredValue', () => {
608608
},
609609
);
610610

611+
it(
612+
"regression: useDeferredValue's initial value argument works even if an unrelated " +
613+
'transition is suspended',
614+
async () => {
615+
// Simulates a previous bug where a new useDeferredValue hook is mounted
616+
// while some unrelated transition is suspended. In the regression case,
617+
// the initial values was skipped/ignored.
618+
619+
function Content({text}) {
620+
return (
621+
<AsyncText text={useDeferredValue(text, `Preview ${text}...`)} />
622+
);
623+
}
624+
625+
function App({text}) {
626+
// Use a key to force a new Content instance to be mounted each time
627+
// the text changes.
628+
return <Content key={text} text={text} />;
629+
}
630+
631+
const root = ReactNoop.createRoot();
632+
633+
// Render a previous UI using useDeferredValue. Suspend on the
634+
// final value.
635+
resolveText('Preview A...');
636+
await act(() => startTransition(() => root.render(<App text="A" />)));
637+
assertLog(['Preview A...', 'Suspend! [A]']);
638+
639+
// While it's still suspended, update the UI to show a different screen
640+
// with a different preview value. We should be able to show the new
641+
// preview even though the previous transition never finished.
642+
resolveText('Preview B...');
643+
await act(() => startTransition(() => root.render(<App text="B" />)));
644+
assertLog(['Preview B...', 'Suspend! [B]']);
645+
646+
// Now finish loading the final value.
647+
await act(() => resolveText('B'));
648+
assertLog(['B']);
649+
expect(root).toMatchRenderedOutput('B');
650+
},
651+
);
652+
611653
it('avoids a useDeferredValue waterfall when separated by a Suspense boundary', async () => {
612654
// Same as the previous test but with a Suspense boundary separating the
613655
// two useDeferredValue hooks.

0 commit comments

Comments
 (0)