Skip to content

Commit da27d9e

Browse files
committed
Bugfix: Suspended update must finish to unhide
1 parent 35a2f74 commit da27d9e

5 files changed

Lines changed: 240 additions & 31 deletions

File tree

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: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,37 +1570,88 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) {
15701570
}
15711571
}
15721572

1573-
const SUSPENDED_MARKER: SuspenseState = {
1574-
dehydrated: null,
1575-
retryTime: NoWork,
1576-
};
1573+
function mountSuspenseState(
1574+
renderExpirationTime: ExpirationTime,
1575+
): SuspenseState {
1576+
return {
1577+
dehydrated: null,
1578+
skippedTime: renderExpirationTime,
1579+
retryTime: NoWork,
1580+
};
1581+
}
1582+
1583+
function updateSuspenseState(
1584+
prevSuspenseState: SuspenseState,
1585+
renderExpirationTime: ExpirationTime,
1586+
): SuspenseState {
1587+
const prevSuspendedTime = prevSuspenseState.skippedTime;
1588+
return {
1589+
dehydrated: null,
1590+
skippedTime:
1591+
// Choose whichever time is a superset of the other one
1592+
prevSuspendedTime !== NoWork && prevSuspendedTime < renderExpirationTime
1593+
? prevSuspendedTime
1594+
: renderExpirationTime,
1595+
retryTime: NoWork,
1596+
};
1597+
}
15771598

15781599
function shouldRemainOnFallback(
15791600
suspenseContext: SuspenseContext,
15801601
current: null | Fiber,
15811602
workInProgress: Fiber,
1603+
renderExpirationTime: ExpirationTime,
15821604
) {
1583-
// If the context is telling us that we should show a fallback, and we're not
1584-
// already showing content, then we should show the fallback instead.
1585-
return (
1586-
hasSuspenseContext(
1605+
if (current === null) {
1606+
return hasSuspenseContext(
15871607
suspenseContext,
15881608
(ForceSuspenseFallback: SuspenseContext),
1589-
) &&
1590-
(current === null || current.memoizedState !== null)
1609+
);
1610+
}
1611+
1612+
const suspenseState: SuspenseState = current.memoizedState;
1613+
if (suspenseState === null) {
1614+
// Not already suspended.
1615+
return false;
1616+
}
1617+
1618+
const skippedTime = suspenseState.skippedTime;
1619+
if (skippedTime !== NoWork && skippedTime < renderExpirationTime) {
1620+
return true;
1621+
}
1622+
1623+
return hasSuspenseContext(
1624+
suspenseContext,
1625+
(ForceSuspenseFallback: SuspenseContext),
15911626
);
15921627
}
15931628

15941629
function getRemainingWorkInPrimaryTree(
1595-
workInProgress,
1596-
currentChildExpirationTime,
1630+
current: Fiber,
1631+
workInProgress: Fiber,
1632+
currentPrimaryChildFragment: Fiber | null,
15971633
renderExpirationTime,
15981634
) {
1635+
const currentSuspenseState: SuspenseState = current.memoizedState;
1636+
if (currentSuspenseState !== null) {
1637+
const skippedTime = currentSuspenseState.skippedTime;
1638+
if (skippedTime !== NoWork && skippedTime < renderExpirationTime) {
1639+
return skippedTime;
1640+
}
1641+
}
1642+
1643+
const currentParentOfPrimaryChildren =
1644+
currentPrimaryChildFragment !== null
1645+
? currentPrimaryChildFragment
1646+
: current;
1647+
const currentChildExpirationTime =
1648+
currentParentOfPrimaryChildren.childExpirationTime;
15991649
if (currentChildExpirationTime < renderExpirationTime) {
16001650
// The highest priority remaining work is not part of this render. So the
16011651
// remaining work has not changed.
16021652
return currentChildExpirationTime;
16031653
}
1654+
16041655
if ((workInProgress.mode & BlockingMode) !== NoMode) {
16051656
// The highest priority remaining work is part of this render. Since we only
16061657
// keep track of the highest level, we don't know if there's a lower
@@ -1642,7 +1693,12 @@ function updateSuspenseComponent(
16421693

16431694
if (
16441695
didSuspend ||
1645-
shouldRemainOnFallback(suspenseContext, current, workInProgress)
1696+
shouldRemainOnFallback(
1697+
suspenseContext,
1698+
current,
1699+
workInProgress,
1700+
renderExpirationTime,
1701+
)
16461702
) {
16471703
// Something in this boundary's subtree already suspended. Switch to
16481704
// rendering the fallback children.
@@ -1758,7 +1814,7 @@ function updateSuspenseComponent(
17581814
primaryChildFragment.sibling = fallbackChildFragment;
17591815
// Skip the primary children, and continue working on the
17601816
// fallback children.
1761-
workInProgress.memoizedState = SUSPENDED_MARKER;
1817+
workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
17621818
workInProgress.child = primaryChildFragment;
17631819
return fallbackChildFragment;
17641820
} else {
@@ -1862,15 +1918,15 @@ function updateSuspenseComponent(
18621918
primaryChildFragment.sibling = fallbackChildFragment;
18631919
fallbackChildFragment.effectTag |= Placement;
18641920
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1921+
current,
18651922
workInProgress,
1866-
// This argument represents the remaining work in the current
1867-
// primary tree. Since the current tree did not already time out
1868-
// the direct parent of the primary children is the Suspense
1869-
// fiber, not a fragment.
1870-
current.childExpirationTime,
1923+
null,
1924+
renderExpirationTime,
1925+
);
1926+
workInProgress.memoizedState = updateSuspenseState(
1927+
current.memoizedState,
18711928
renderExpirationTime,
18721929
);
1873-
workInProgress.memoizedState = SUSPENDED_MARKER;
18741930
workInProgress.child = primaryChildFragment;
18751931

18761932
// Skip the primary children, and continue working on the
@@ -1933,13 +1989,17 @@ function updateSuspenseComponent(
19331989
fallbackChildFragment.return = workInProgress;
19341990
primaryChildFragment.sibling = fallbackChildFragment;
19351991
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
1992+
current,
19361993
workInProgress,
1937-
currentPrimaryChildFragment.childExpirationTime,
1994+
currentPrimaryChildFragment,
19381995
renderExpirationTime,
19391996
);
19401997
// Skip the primary children, and continue working on the
19411998
// fallback children.
1942-
workInProgress.memoizedState = SUSPENDED_MARKER;
1999+
workInProgress.memoizedState = updateSuspenseState(
2000+
current.memoizedState,
2001+
renderExpirationTime,
2002+
);
19432003
workInProgress.child = primaryChildFragment;
19442004
return fallbackChildFragment;
19452005
} else {
@@ -2031,17 +2091,14 @@ function updateSuspenseComponent(
20312091
primaryChildFragment.sibling = fallbackChildFragment;
20322092
fallbackChildFragment.effectTag |= Placement;
20332093
primaryChildFragment.childExpirationTime = getRemainingWorkInPrimaryTree(
2094+
current,
20342095
workInProgress,
2035-
// This argument represents the remaining work in the current
2036-
// primary tree. Since the current tree did not already time out
2037-
// the direct parent of the primary children is the Suspense
2038-
// fiber, not a fragment.
2039-
current.childExpirationTime,
2096+
null,
20402097
renderExpirationTime,
20412098
);
20422099
// Skip the primary children, and continue working on the
20432100
// fallback children.
2044-
workInProgress.memoizedState = SUSPENDED_MARKER;
2101+
workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
20452102
workInProgress.child = primaryChildFragment;
20462103
return fallbackChildFragment;
20472104
} 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+
skippedTime: NoWork,
234235
retryTime: Never,
235236
};
236237
fiber.memoizedState = suspenseState;

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ 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+
skippedTime: ExpirationTime,
3839
// Represents the earliest expiration time we should attempt to hydrate
3940
// a dehydrated boundary at.
4041
// Never is the default for dehydrated boundaries.

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3168,9 +3168,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
31683168
});
31693169

31703170
expect(Scheduler).toHaveYielded([
3171-
// First try to update the suspended tree. It's still suspended.
3172-
'Suspend! [C]',
3171+
// First try to render the high pri update. We won't try to re-render
3172+
// the suspended tree during this pass, because it still has unfinished
3173+
// updates at a lower priority.
31733174
'Loading...',
3175+
3176+
// Now try the suspended update again. It's still suspended.
3177+
'Suspend! [C]',
3178+
31743179
// Then complete the update to the fallback.
31753180
'Still loading...',
31763181
]);
@@ -3260,4 +3265,148 @@ describe('ReactSuspenseWithNoopRenderer', () => {
32603265
expect(root).toMatchRenderedOutput(<span prop="D" />);
32613266
},
32623267
);
3268+
3269+
it(
3270+
'after showing fallback, should not flip back to primary content until ' +
3271+
'the update that suspended finishes',
3272+
async () => {
3273+
const {useState, useEffect} = React;
3274+
const root = ReactNoop.createRoot();
3275+
3276+
let setOuterText;
3277+
function Parent({step}) {
3278+
const [text, _setText] = useState('A');
3279+
setOuterText = _setText;
3280+
return (
3281+
<>
3282+
<Text text={'Outer text: ' + text} />
3283+
<Text text={'Outer step: ' + step} />
3284+
<Suspense fallback={<Text text="Loading..." />}>
3285+
<Child step={step} outerText={text} />
3286+
</Suspense>
3287+
</>
3288+
);
3289+
}
3290+
3291+
let setInnerText;
3292+
function Child({step, outerText}) {
3293+
const [text, _setText] = useState('A');
3294+
setInnerText = _setText;
3295+
3296+
// This will log if the component commits in an inconsistent state
3297+
useEffect(() => {
3298+
if (text === outerText) {
3299+
Scheduler.unstable_yieldValue('Commit Child');
3300+
} else {
3301+
Scheduler.unstable_yieldValue(
3302+
'FIXME: Texts are inconsistent (tearing)',
3303+
);
3304+
}
3305+
}, [text, outerText]);
3306+
3307+
return (
3308+
<>
3309+
<AsyncText text={'Inner text: ' + text} />
3310+
<Text text={'Inner step: ' + step} />
3311+
</>
3312+
);
3313+
}
3314+
3315+
// These always update simultaneously. They must be consistent.
3316+
function setText(text) {
3317+
setOuterText(text);
3318+
setInnerText(text);
3319+
}
3320+
3321+
// Mount an initial tree. Resolve A so that it doesn't suspend.
3322+
await resolveText('Inner text: A');
3323+
await ReactNoop.act(async () => {
3324+
root.render(<Parent step={0} />);
3325+
});
3326+
expect(Scheduler).toHaveYielded([
3327+
'Outer text: A',
3328+
'Outer step: 0',
3329+
'Inner text: A',
3330+
'Inner step: 0',
3331+
'Commit Child',
3332+
]);
3333+
expect(root).toMatchRenderedOutput(
3334+
<>
3335+
<span prop="Outer text: A" />
3336+
<span prop="Outer step: 0" />
3337+
<span prop="Inner text: A" />
3338+
<span prop="Inner step: 0" />
3339+
</>,
3340+
);
3341+
3342+
// Update. This causes the inner component to suspend.
3343+
await ReactNoop.act(async () => {
3344+
setText('B');
3345+
});
3346+
expect(Scheduler).toHaveYielded([
3347+
'Outer text: B',
3348+
'Outer step: 0',
3349+
'Suspend! [Inner text: B]',
3350+
'Inner step: 0',
3351+
'Loading...',
3352+
]);
3353+
// Commit the placeholder
3354+
await advanceTimers(250);
3355+
expect(root).toMatchRenderedOutput(
3356+
<>
3357+
<span prop="Outer text: B" />
3358+
<span prop="Outer step: 0" />
3359+
<span hidden={true} prop="Inner text: A" />
3360+
<span hidden={true} prop="Inner step: 0" />
3361+
<span prop="Loading..." />
3362+
</>,
3363+
);
3364+
3365+
// Schedule a high pri update on the parent.
3366+
await ReactNoop.act(async () => {
3367+
ReactNoop.discreteUpdates(() => {
3368+
root.render(<Parent step={1} />);
3369+
});
3370+
});
3371+
// Only the outer part can update. The inner part should still show a
3372+
// fallback because we haven't finished loading B yet. Otherwise, the
3373+
// inner text would be inconsistent with the outer text.
3374+
expect(Scheduler).toHaveYielded([
3375+
'Outer text: B',
3376+
'Outer step: 1',
3377+
'Loading...',
3378+
3379+
'Suspend! [Inner text: B]',
3380+
'Inner step: 1',
3381+
]);
3382+
expect(root).toMatchRenderedOutput(
3383+
<>
3384+
<span prop="Outer text: B" />
3385+
<span prop="Outer step: 1" />
3386+
<span hidden={true} prop="Inner text: A" />
3387+
<span hidden={true} prop="Inner step: 0" />
3388+
<span prop="Loading..." />
3389+
</>,
3390+
);
3391+
3392+
// Now finish resolving the inner text
3393+
await ReactNoop.act(async () => {
3394+
await resolveText('Inner text: B');
3395+
});
3396+
expect(Scheduler).toHaveYielded([
3397+
'Promise resolved [Inner text: B]',
3398+
'Inner text: B',
3399+
'Inner step: 1',
3400+
'Commit Child',
3401+
]);
3402+
expect(root).toMatchRenderedOutput(
3403+
<>
3404+
<span prop="Outer text: B" />
3405+
<span prop="Outer step: 1" />
3406+
<span prop="Inner text: B" />
3407+
<span prop="Inner step: 1" />
3408+
</>,
3409+
);
3410+
},
3411+
);
32633412
});

0 commit comments

Comments
 (0)