Skip to content

Commit d7382b6

Browse files
authored
Bugfix: Do not unhide a suspended tree without finishing the suspended update (facebook#18411)
* Bugfix: Suspended update must finish to unhide When we commit a fallback, we cannot unhide the content without including the level that originally suspended. That's because the work at level outside the boundary (i.e. everything that wasn't hidden during that render) already committed. * Test unblocking with a high-pri update
1 parent 1f8c404 commit d7382b6

File tree

5 files changed

+363
-33
lines changed

5 files changed

+363
-33
lines changed

packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ describe('React hooks DevTools integration', () => {
265265
// Release the lock
266266
setSuspenseHandler(() => false);
267267
scheduleUpdate(fiber); // Re-render
268+
Scheduler.unstable_flushAll();
268269
expect(renderer.toJSON().children).toEqual(['Done']);
269270
scheduleUpdate(fiber); // Re-render
270271
expect(renderer.toJSON().children).toEqual(['Done']);

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,37 +1558,102 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
15581558
}
15591559
}
15601560

1561-
const SUSPENDED_MARKER: SuspenseState = {
1562-
dehydrated: null,
1563-
retryTime: NoWork,
1564-
};
1561+
function mountSuspenseState(
1562+
renderExpirationTime: ExpirationTime,
1563+
): SuspenseState {
1564+
return {
1565+
dehydrated: null,
1566+
baseTime: renderExpirationTime,
1567+
retryTime: NoWork,
1568+
};
1569+
}
1570+
1571+
function updateSuspenseState(
1572+
prevSuspenseState: SuspenseState,
1573+
renderExpirationTime: ExpirationTime,
1574+
): SuspenseState {
1575+
const prevSuspendedTime = prevSuspenseState.baseTime;
1576+
return {
1577+
dehydrated: null,
1578+
baseTime:
1579+
// Choose whichever time is inclusive of the other one. This represents
1580+
// the union of all the levels that suspended.
1581+
prevSuspendedTime !== NoWork && prevSuspendedTime < renderExpirationTime
1582+
? prevSuspendedTime
1583+
: renderExpirationTime,
1584+
retryTime: NoWork,
1585+
};
1586+
}
15651587

15661588
function shouldRemainOnFallback(
15671589
suspenseContext: SuspenseContext,
15681590
current: null | Fiber,
15691591
workInProgress: Fiber,
1592+
renderExpirationTime: ExpirationTime,
15701593
) {
1571-
// If the context is telling us that we should show a fallback, and we're not
1572-
// already showing content, then we should show the fallback instead.
1573-
return (
1574-
hasSuspenseContext(
1575-
suspenseContext,
1576-
(ForceSuspenseFallback: SuspenseContext),
1577-
) &&
1578-
(current === null || current.memoizedState !== null)
1594+
// If we're already showing a fallback, there are cases where we need to
1595+
// remain on that fallback regardless of whether the content has resolved.
1596+
// For example, SuspenseList coordinates when nested content appears.
1597+
if (current !== null) {
1598+
const suspenseState: SuspenseState = current.memoizedState;
1599+
if (suspenseState !== null) {
1600+
// Currently showing a fallback. If the current render includes
1601+
// the level that triggered the fallback, we must continue showing it,
1602+
// regardless of what the Suspense context says.
1603+
const baseTime = suspenseState.baseTime;
1604+
if (baseTime !== NoWork && baseTime < renderExpirationTime) {
1605+
return true;
1606+
}
1607+
// Otherwise, fall through to check the Suspense context.
1608+
} else {
1609+
// Currently showing content. Don't hide it, even if ForceSuspenseFallack
1610+
// is true. More precise name might be "ForceRemainSuspenseFallback".
1611+
// Note: This is a factoring smell. Can't remain on a fallback if there's
1612+
// no fallback to remain on.
1613+
return false;
1614+
}
1615+
}
1616+
// Not currently showing content. Consult the Suspense context.
1617+
return hasSuspenseContext(
1618+
suspenseContext,
1619+
(ForceSuspenseFallback: SuspenseContext),
15791620
);
15801621
}
15811622

15821623
function getRemainingWorkInPrimaryTree(
1583-
workInProgress,
1584-
currentChildExpirationTime,
1624+
current: Fiber,
1625+
workInProgress: Fiber,
1626+
currentPrimaryChildFragment: Fiber | null,
15851627
renderExpirationTime,
15861628
) {
1629+
const currentParentOfPrimaryChildren =
1630+
currentPrimaryChildFragment !== null
1631+
? currentPrimaryChildFragment
1632+
: current;
1633+
const currentChildExpirationTime =
1634+
currentParentOfPrimaryChildren.childExpirationTime;
1635+
1636+
const currentSuspenseState: SuspenseState = current.memoizedState;
1637+
if (currentSuspenseState !== null) {
1638+
// This boundary already timed out. Check if this render includes the level
1639+
// that previously suspended.
1640+
const baseTime = currentSuspenseState.baseTime;
1641+
if (
1642+
baseTime !== NoWork &&
1643+
baseTime < renderExpirationTime &&
1644+
baseTime > currentChildExpirationTime
1645+
) {
1646+
// There's pending work at a lower level that might now be unblocked.
1647+
return baseTime;
1648+
}
1649+
}
1650+
15871651
if (currentChildExpirationTime < renderExpirationTime) {
15881652
// The highest priority remaining work is not part of this render. So the
15891653
// remaining work has not changed.
15901654
return currentChildExpirationTime;
15911655
}
1656+
15921657
if ((workInProgress.mode & BlockingMode) !== NoMode) {
15931658
// The highest priority remaining work is part of this render. Since we only
15941659
// keep track of the highest level, we don't know if there's a lower
@@ -1630,7 +1695,12 @@ function updateSuspenseComponent(
16301695

16311696
if (
16321697
didSuspend ||
1633-
shouldRemainOnFallback(suspenseContext, current, workInProgress)
1698+
shouldRemainOnFallback(
1699+
suspenseContext,
1700+
current,
1701+
workInProgress,
1702+
renderExpirationTime,
1703+
)
16341704
) {
16351705
// Something in this boundary's subtree already suspended. Switch to
16361706
// rendering the fallback children.
@@ -1746,7 +1816,7 @@ function updateSuspenseComponent(
17461816
primaryChildFragment.sibling = fallbackChildFragment;
17471817
// Skip the primary children, and continue working on the
17481818
// fallback children.
1749-
workInProgress.memoizedState = SUSPENDED_MARKER;
1819+
workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
17501820
workInProgress.child = primaryChildFragment;
17511821
return fallbackChildFragment;
17521822
} else {
@@ -1850,15 +1920,15 @@ function updateSuspenseComponent(
18501920
primaryChildFragment.sibling = fallbackChildFragment;
18511921
fallbackChildFragment.effectTag |= Placement;
18521922
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1923+
current,
18531924
workInProgress,
1854-
// This argument represents the remaining work in the current
1855-
// primary tree. Since the current tree did not already time out
1856-
// the direct parent of the primary children is the Suspense
1857-
// fiber, not a fragment.
1858-
current.childExpirationTime,
1925+
null,
1926+
renderExpirationTime,
1927+
);
1928+
workInProgress.memoizedState = updateSuspenseState(
1929+
current.memoizedState,
18591930
renderExpirationTime,
18601931
);
1861-
workInProgress.memoizedState = SUSPENDED_MARKER;
18621932
workInProgress.child = primaryChildFragment;
18631933

18641934
// Skip the primary children, and continue working on the
@@ -1921,13 +1991,17 @@ function updateSuspenseComponent(
19211991
fallbackChildFragment.return = workInProgress;
19221992
primaryChildFragment.sibling = fallbackChildFragment;
19231993
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1994+
current,
19241995
workInProgress,
1925-
currentPrimaryChildFragment.childExpirationTime,
1996+
currentPrimaryChildFragment,
19261997
renderExpirationTime,
19271998
);
19281999
// Skip the primary children, and continue working on the
19292000
// fallback children.
1930-
workInProgress.memoizedState = SUSPENDED_MARKER;
2001+
workInProgress.memoizedState = updateSuspenseState(
2002+
current.memoizedState,
2003+
renderExpirationTime,
2004+
);
19312005
workInProgress.child = primaryChildFragment;
19322006
return fallbackChildFragment;
19332007
} else {
@@ -2019,17 +2093,14 @@ function updateSuspenseComponent(
20192093
primaryChildFragment.sibling = fallbackChildFragment;
20202094
fallbackChildFragment.effectTag |= Placement;
20212095
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
2096+
current,
20222097
workInProgress,
2023-
// This argument represents the remaining work in the current
2024-
// primary tree. Since the current tree did not already time out
2025-
// the direct parent of the primary children is the Suspense
2026-
// fiber, not a fragment.
2027-
current.childExpirationTime,
2098+
null,
20282099
renderExpirationTime,
20292100
);
20302101
// Skip the primary children, and continue working on the
20312102
// fallback children.
2032-
workInProgress.memoizedState = SUSPENDED_MARKER;
2103+
workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
20332104
workInProgress.child = primaryChildFragment;
20342105
return fallbackChildFragment;
20352106
} else {

packages/react-reconciler/src/ReactFiberHydrationContext.js

Lines changed: 2 additions & 1 deletion
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} from './ReactFiberExpirationTime';
58+
import {Never, NoWork} from './ReactFiberExpirationTime';
5959

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

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export type SuspenseState = {|
3535
// here to indicate that it is dehydrated (flag) and for quick access
3636
// to check things like isSuspenseInstancePending.
3737
dehydrated: null | SuspenseInstance,
38+
// Represents the work that was deprioritized when we committed the fallback.
39+
// The work outside the boundary already committed at this level, so we cannot
40+
// unhide the content without including it.
41+
baseTime: ExpirationTime,
3842
// Represents the earliest expiration time we should attempt to hydrate
3943
// a dehydrated boundary at.
4044
// Never is the default for dehydrated boundaries.

0 commit comments

Comments
 (0)