Skip to content

Commit c40d206

Browse files
committed
Unhide Suspense trees without entanglement
When a Suspense boundary is in its fallback state, you cannot switch back to the main content without also finishing any updates inside the tree that might have been skipped. That would be a form of tearing. Before we fixed this in #18411, the way this bug manifested was that a boundary was suspended by an update that originated from a child component (as opposed to props from a parent). While the fallback was showing, it received another update, this time at high priority. React would render the high priority update without also including the original update. That would cause the fallback to switch back to the main content, since the update that caused the tree to suspend was no longer part of the render. But then, React would immediately try to render the original update, which would again suspend and show the fallback, leading to a momentary flicker in the UI. The approach added in #18411 is, when receiving a high priority update to a Suspense tree that's in its fallback state is to bail out, keep showing the fallback and finish the update in the rest of the tree. After that commits, render again at the original priority. Because low priority expiration times are inclusive of higher priority expiration times, this ensures that all the updates are committed together. The new approach in this commit is to turn `renderExpirationTime` into a context-like value that lives on the stack. Then, when unhiding the Suspense boundary, we can push a new `renderExpirationTime` that is inclusive of both the high pri update and the original update that suspended. Then the boundary can be unblocked in a single render pass. An advantage of the old approach is that by deferring the work of unhiding, there's less work to do in the high priority update. The key advantage of the new approach is that it solves the consistency problem without having to entangle the entire root.
1 parent cb70753 commit c40d206

7 files changed

+185
-100
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.new.js

Lines changed: 64 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ import {
187187
renderDidSuspendDelayIfPossible,
188188
markUnprocessedUpdateTime,
189189
getWorkInProgressRoot,
190+
pushRenderExpirationTime,
190191
} from './ReactFiberWorkLoop.new';
191192

192193
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -569,17 +570,30 @@ function updateOffscreenComponent(
569570
const nextProps: OffscreenProps = workInProgress.pendingProps;
570571
const nextChildren = nextProps.children;
571572

573+
let subtreeRenderTime = renderExpirationTime;
572574
if (current !== null) {
573575
if (nextProps.mode === 'hidden') {
574576
// TODO: Should currently be unreachable because Offscreen is only used as
575577
// an implementation detail of Suspense. Once this is a public API, it
576578
// will need to create an OffscreenState.
577579
} else {
578-
// Clear the offscreen state.
579-
workInProgress.memoizedState = null;
580+
const prevState: OffscreenState | null = current.memoizedState;
581+
if (prevState !== null) {
582+
const baseTime = prevState.baseTime;
583+
subtreeRenderTime = !isSameOrHigherPriority(
584+
baseTime,
585+
renderExpirationTime,
586+
)
587+
? baseTime
588+
: renderExpirationTime;
589+
590+
// Since we're not hidden anymore, reset the state
591+
workInProgress.memoizedState = null;
592+
}
580593
}
581594
}
582595

596+
pushRenderExpirationTime(workInProgress, subtreeRenderTime);
583597
reconcileChildren(
584598
current,
585599
workInProgress,
@@ -1651,33 +1665,32 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
16511665
}
16521666
}
16531667

1654-
function mountSuspenseState(
1668+
const SUSPENDED_MARKER: SuspenseState = {
1669+
dehydrated: null,
1670+
retryTime: NoWork,
1671+
};
1672+
1673+
function mountSuspenseOffscreenState(
16551674
renderExpirationTime: ExpirationTimeOpaque,
1656-
): SuspenseState {
1675+
): OffscreenState {
16571676
return {
1658-
dehydrated: null,
16591677
baseTime: renderExpirationTime,
1660-
retryTime: NoWork,
16611678
};
16621679
}
16631680

1664-
function updateSuspenseState(
1665-
prevSuspenseState: SuspenseState,
1681+
function updateSuspenseOffscreenState(
1682+
prevOffscreenState: OffscreenState,
16661683
renderExpirationTime: ExpirationTimeOpaque,
1667-
): SuspenseState {
1668-
const prevSuspendedTime = prevSuspenseState.baseTime;
1684+
): OffscreenState {
1685+
const prevBaseTime = prevOffscreenState.baseTime;
16691686
return {
1670-
dehydrated: null,
1687+
// Choose whichever time is inclusive of the other one. This represents
1688+
// the union of all the levels that suspended.
16711689
baseTime:
1672-
// Choose whichever time is inclusive of the other one. This represents
1673-
// the union of all the levels that suspended.
1674-
!isSameExpirationTime(
1675-
prevSuspendedTime,
1676-
(NoWork: ExpirationTimeOpaque),
1677-
) && !isSameOrHigherPriority(prevSuspendedTime, renderExpirationTime)
1678-
? prevSuspendedTime
1690+
!isSameExpirationTime(prevBaseTime, (NoWork: ExpirationTimeOpaque)) &&
1691+
!isSameOrHigherPriority(prevBaseTime, renderExpirationTime)
1692+
? prevBaseTime
16791693
: renderExpirationTime,
1680-
retryTime: NoWork,
16811694
};
16821695
}
16831696

@@ -1692,26 +1705,15 @@ function shouldRemainOnFallback(
16921705
// For example, SuspenseList coordinates when nested content appears.
16931706
if (current !== null) {
16941707
const suspenseState: SuspenseState = current.memoizedState;
1695-
if (suspenseState !== null) {
1696-
// Currently showing a fallback. If the current render includes
1697-
// the level that triggered the fallback, we must continue showing it,
1698-
// regardless of what the Suspense context says.
1699-
const baseTime = suspenseState.baseTime;
1700-
if (
1701-
!isSameExpirationTime(baseTime, (NoWork: ExpirationTimeOpaque)) &&
1702-
!isSameOrHigherPriority(baseTime, renderExpirationTime)
1703-
) {
1704-
return true;
1705-
}
1706-
// Otherwise, fall through to check the Suspense context.
1707-
} else {
1708+
if (suspenseState === null) {
17081709
// Currently showing content. Don't hide it, even if ForceSuspenseFallack
17091710
// is true. More precise name might be "ForceRemainSuspenseFallback".
17101711
// Note: This is a factoring smell. Can't remain on a fallback if there's
17111712
// no fallback to remain on.
17121713
return false;
17131714
}
17141715
}
1716+
17151717
// Not currently showing content. Consult the Suspense context.
17161718
return hasSuspenseContext(
17171719
suspenseContext,
@@ -1725,20 +1727,6 @@ function getRemainingWorkInPrimaryTree(
17251727
renderExpirationTime,
17261728
) {
17271729
const currentChildExpirationTime = current.childExpirationTime_opaque;
1728-
const currentSuspenseState: SuspenseState = current.memoizedState;
1729-
if (currentSuspenseState !== null) {
1730-
// This boundary already timed out. Check if this render includes the level
1731-
// that previously suspended.
1732-
const baseTime = currentSuspenseState.baseTime;
1733-
if (
1734-
!isSameExpirationTime(baseTime, (NoWork: ExpirationTimeOpaque)) &&
1735-
!isSameOrHigherPriority(baseTime, renderExpirationTime)
1736-
) {
1737-
// There's pending work at a lower level that might now be unblocked.
1738-
return baseTime;
1739-
}
1740-
}
1741-
17421730
if (
17431731
!isSameOrHigherPriority(currentChildExpirationTime, renderExpirationTime)
17441732
) {
@@ -1880,8 +1868,10 @@ function updateSuspenseComponent(
18801868
renderExpirationTime,
18811869
);
18821870
const primaryChildFragment: Fiber = (workInProgress.child: any);
1883-
primaryChildFragment.memoizedState = ({baseTime: NoWork}: OffscreenState);
1884-
workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
1871+
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
1872+
renderExpirationTime,
1873+
);
1874+
workInProgress.memoizedState = SUSPENDED_MARKER;
18851875
return fallbackFragment;
18861876
} else {
18871877
const nextPrimaryChildren = nextProps.children;
@@ -1935,14 +1925,10 @@ function updateSuspenseComponent(
19351925
renderExpirationTime,
19361926
);
19371927
const primaryChildFragment: Fiber = (workInProgress.child: any);
1938-
primaryChildFragment.memoizedState = ({
1939-
baseTime: NoWork,
1940-
}: OffscreenState);
1941-
workInProgress.memoizedState = updateSuspenseState(
1942-
current.memoizedState,
1928+
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
19431929
renderExpirationTime,
19441930
);
1945-
1931+
workInProgress.memoizedState = SUSPENDED_MARKER;
19461932
return fallbackChildFragment;
19471933
}
19481934
}
@@ -1959,18 +1945,21 @@ function updateSuspenseComponent(
19591945
renderExpirationTime,
19601946
);
19611947
const primaryChildFragment: Fiber = (workInProgress.child: any);
1962-
primaryChildFragment.memoizedState = ({
1963-
baseTime: NoWork,
1964-
}: OffscreenState);
1948+
const prevOffscreenState: OffscreenState | null = (current.child: any)
1949+
.memoizedState;
1950+
primaryChildFragment.memoizedState =
1951+
prevOffscreenState === null
1952+
? mountSuspenseOffscreenState(renderExpirationTime)
1953+
: updateSuspenseOffscreenState(
1954+
prevOffscreenState,
1955+
renderExpirationTime,
1956+
);
19651957
primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
19661958
current,
19671959
workInProgress,
19681960
renderExpirationTime,
19691961
);
1970-
workInProgress.memoizedState = updateSuspenseState(
1971-
current.memoizedState,
1972-
renderExpirationTime,
1973-
);
1962+
workInProgress.memoizedState = SUSPENDED_MARKER;
19741963
return fallbackChildFragment;
19751964
} else {
19761965
const nextPrimaryChildren = nextProps.children;
@@ -1997,17 +1986,23 @@ function updateSuspenseComponent(
19971986
renderExpirationTime,
19981987
);
19991988
const primaryChildFragment: Fiber = (workInProgress.child: any);
2000-
primaryChildFragment.memoizedState = ({
2001-
baseTime: NoWork,
2002-
}: OffscreenState);
1989+
const prevOffscreenState: OffscreenState | null = (current.child: any)
1990+
.memoizedState;
1991+
primaryChildFragment.memoizedState =
1992+
prevOffscreenState === null
1993+
? mountSuspenseOffscreenState(renderExpirationTime)
1994+
: updateSuspenseOffscreenState(
1995+
prevOffscreenState,
1996+
renderExpirationTime,
1997+
);
20031998
primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
20041999
current,
20052000
workInProgress,
20062001
renderExpirationTime,
20072002
);
20082003
// Skip the primary children, and continue working on the
20092004
// fallback children.
2010-
workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
2005+
workInProgress.memoizedState = SUSPENDED_MARKER;
20112006
return fallbackChildFragment;
20122007
} else {
20132008
// Still haven't timed out. Continue rendering the children, like we
@@ -3384,6 +3379,10 @@ function beginWork(
33843379
return null;
33853380
}
33863381
}
3382+
case OffscreenComponent: {
3383+
pushRenderExpirationTime(workInProgress, renderExpirationTime);
3384+
break;
3385+
}
33873386
}
33883387
return bailoutOnAlreadyFinishedWork(
33893388
current,

packages/react-reconciler/src/ReactFiberCompleteWork.new.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
renderDidSuspend,
126126
renderDidSuspendDelayIfPossible,
127127
renderHasNotSuspendedYet,
128+
popRenderExpirationTime,
128129
} from './ReactFiberWorkLoop.new';
129130
import {createFundamentalStateInstance} from './ReactFiberFundamental.new';
130131
import {
@@ -1291,6 +1292,7 @@ function completeWork(
12911292
}
12921293
break;
12931294
case OffscreenComponent: {
1295+
popRenderExpirationTime(workInProgress);
12941296
if (current !== null) {
12951297
const nextState: OffscreenState | null = workInProgress.memoizedState;
12961298
const prevState: OffscreenState | null = current.memoizedState;

packages/react-reconciler/src/ReactFiberHydrationContext.new.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {
5555
didNotFindHydratableSuspenseInstance,
5656
} from './ReactFiberHostConfig';
5757
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
58-
import {Never, NoWork} from './ReactFiberExpirationTime.new';
58+
import {Never} from './ReactFiberExpirationTime.new';
5959

6060
// The deepest Fiber on the stack involved in a hydration context.
6161
// This may have been an insertion or a hydration.
@@ -231,7 +231,6 @@ function tryHydrate(fiber, nextInstance) {
231231
if (suspenseInstance !== null) {
232232
const suspenseState: SuspenseState = {
233233
dehydrated: suspenseInstance,
234-
baseTime: NoWork,
235234
retryTime: Never,
236235
};
237236
fiber.memoizedState = suspenseState;

packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ export type SuspenseState = {|
2929
// here to indicate that it is dehydrated (flag) and for quick access
3030
// to check things like isSuspenseInstancePending.
3131
dehydrated: null | SuspenseInstance,
32-
// Represents the work that was deprioritized when we committed the fallback.
33-
// The work outside the boundary already committed at this level, so we cannot
34-
// unhide the content without including it.
35-
baseTime: ExpirationTimeOpaque,
3632
// Represents the earliest expiration time we should attempt to hydrate
3733
// a dehydrated boundary at.
3834
// Never is the default for dehydrated boundaries.

packages/react-reconciler/src/ReactFiberUnwindWork.new.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
ContextProvider,
2121
SuspenseComponent,
2222
SuspenseListComponent,
23+
OffscreenComponent,
2324
} from './ReactWorkTags';
2425
import {DidCapture, NoEffect, ShouldCapture} from './ReactSideEffectTags';
2526
import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
@@ -33,6 +34,7 @@ import {
3334
popTopLevelContextObject as popTopLevelLegacyContextObject,
3435
} from './ReactFiberContext.new';
3536
import {popProvider} from './ReactFiberNewContext.new';
37+
import {popRenderExpirationTime} from './ReactFiberWorkLoop.new';
3638

3739
import invariant from 'shared/invariant';
3840

@@ -105,6 +107,9 @@ function unwindWork(
105107
case ContextProvider:
106108
popProvider(workInProgress);
107109
return null;
110+
case OffscreenComponent:
111+
popRenderExpirationTime(workInProgress);
112+
return null;
108113
default:
109114
return null;
110115
}
@@ -141,6 +146,9 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
141146
case ContextProvider:
142147
popProvider(interruptedWork);
143148
break;
149+
case OffscreenComponent:
150+
popRenderExpirationTime(interruptedWork);
151+
break;
144152
default:
145153
break;
146154
}

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {Interaction} from 'scheduler/src/Tracing';
1515
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
1616
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
1717
import type {Effect as HookEffect} from './ReactFiberHooks.new';
18+
import type {StackCursor} from './ReactFiberStack.new';
1819

1920
import {
2021
warnAboutDeprecatedLifecycles,
@@ -168,6 +169,11 @@ import {
168169
getIsUpdatingOpaqueValueInRenderPhaseInDEV,
169170
} from './ReactFiberHooks.new';
170171
import {createCapturedValue} from './ReactCapturedValue';
172+
import {
173+
push as pushToStack,
174+
pop as popFromStack,
175+
createCursor,
176+
} from './ReactFiberStack.new';
171177

172178
import {
173179
recordCommitTime,
@@ -231,6 +237,12 @@ let workInProgressRoot: FiberRoot | null = null;
231237
let workInProgress: Fiber | null = null;
232238
// The expiration time we're rendering
233239
let renderExpirationTime: ExpirationTimeOpaque = NoWork;
240+
241+
// Stack that allows components to channge renderExpirationTime for its subtree
242+
const renderExpirationTimeCursor: StackCursor<ExpirationTimeOpaque> = createCursor(
243+
NoWork,
244+
);
245+
234246
// Whether to root completed, errored, suspended, etc.
235247
let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
236248
// A fatal error, if one is thrown
@@ -1262,6 +1274,19 @@ export function flushControlled(fn: () => mixed): void {
12621274
}
12631275
}
12641276

1277+
export function pushRenderExpirationTime(
1278+
fiber: Fiber,
1279+
subtreeRenderTime: ExpirationTimeOpaque,
1280+
) {
1281+
pushToStack(renderExpirationTimeCursor, renderExpirationTime, fiber);
1282+
renderExpirationTime = subtreeRenderTime;
1283+
}
1284+
1285+
export function popRenderExpirationTime(fiber: Fiber) {
1286+
renderExpirationTime = renderExpirationTimeCursor.current;
1287+
popFromStack(renderExpirationTimeCursor, fiber);
1288+
}
1289+
12651290
function prepareFreshStack(root, expirationTime) {
12661291
root.finishedWork = null;
12671292
root.finishedExpirationTime_opaque = NoWork;

0 commit comments

Comments
 (0)