Skip to content

Commit 55444a6

Browse files
authored
Try rendering again if a timed out tree receives an update (#13921)
Found a bug related to suspending inside an already mounted tree. While investigating this I noticed we really don't have much coverage of suspended updates. I think this would greatly benefit from some fuzz testing; still haven't thought of a good test case, though.
1 parent 04c4f2f commit 55444a6

File tree

5 files changed

+224
-135
lines changed

5 files changed

+224
-135
lines changed

packages/jest-react/src/JestReact.js

+5-7
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,17 @@ function assertYieldsWereCleared(root) {
3535
}
3636

3737
export function toFlushAndYield(root, expectedYields) {
38+
assertYieldsWereCleared(root);
39+
const actualYields = root.unstable_flushAll();
3840
return captureAssertion(() => {
39-
assertYieldsWereCleared(root);
40-
const actualYields = root.unstable_flushAll();
4141
expect(actualYields).toEqual(expectedYields);
4242
});
4343
}
4444

4545
export function toFlushAndYieldThrough(root, expectedYields) {
46+
assertYieldsWereCleared(root);
47+
const actualYields = root.unstable_flushNumberOfYields(expectedYields.length);
4648
return captureAssertion(() => {
47-
assertYieldsWereCleared(root);
48-
const actualYields = root.unstable_flushNumberOfYields(
49-
expectedYields.length,
50-
);
5149
expect(actualYields).toEqual(expectedYields);
5250
});
5351
}
@@ -76,8 +74,8 @@ export function toHaveYielded(ReactTestRenderer, expectedYields) {
7674
}
7775

7876
export function toFlushAndThrow(root, ...rest) {
77+
assertYieldsWereCleared(root);
7978
return captureAssertion(() => {
80-
assertYieldsWereCleared(root);
8179
expect(() => {
8280
root.unstable_flushAll();
8381
}).toThrow(...rest);

packages/react-reconciler/src/ReactFiberBeginWork.js

+39-20
Original file line numberDiff line numberDiff line change
@@ -1018,20 +1018,16 @@ function updateSuspenseComponent(
10181018
// We should attempt to render the primary children unless this boundary
10191019
// already suspended during this render (`alreadyCaptured` is true).
10201020
let nextState: SuspenseState | null = workInProgress.memoizedState;
1021-
let nextDidTimeout;
10221021
if (nextState === null) {
10231022
// An empty suspense state means this boundary has not yet timed out.
1024-
nextDidTimeout = false;
10251023
} else {
10261024
if (!nextState.alreadyCaptured) {
10271025
// Since we haven't already suspended during this commit, clear the
10281026
// existing suspense state. We'll try rendering again.
1029-
nextDidTimeout = false;
10301027
nextState = null;
10311028
} else {
10321029
// Something in this boundary's subtree already suspended. Switch to
10331030
// rendering the fallback children. Set `alreadyCaptured` to true.
1034-
nextDidTimeout = true;
10351031
if (current !== null && nextState === current.memoizedState) {
10361032
// Create a new suspense state to avoid mutating the current tree's.
10371033
nextState = {
@@ -1046,6 +1042,7 @@ function updateSuspenseComponent(
10461042
}
10471043
}
10481044
}
1045+
const nextDidTimeout = nextState !== null && nextState.didTimeout;
10491046

10501047
// This next part is a bit confusing. If the children timeout, we switch to
10511048
// showing the fallback children in place of the "primary" children.
@@ -1127,8 +1124,8 @@ function updateSuspenseComponent(
11271124
// its fragment. We're going to skip over these entirely.
11281125
const nextFallbackChildren = nextProps.fallback;
11291126
const primaryChildFragment = createWorkInProgress(
1130-
currentFallbackChildFragment,
1131-
currentFallbackChildFragment.pendingProps,
1127+
currentPrimaryChildFragment,
1128+
currentPrimaryChildFragment.pendingProps,
11321129
NoWork,
11331130
);
11341131
primaryChildFragment.effectTag |= Placement;
@@ -1481,23 +1478,45 @@ function beginWork(
14811478
}
14821479
break;
14831480
case SuspenseComponent: {
1484-
const child = bailoutOnAlreadyFinishedWork(
1485-
current,
1486-
workInProgress,
1487-
renderExpirationTime,
1488-
);
1489-
if (child !== null) {
1490-
const nextState = workInProgress.memoizedState;
1491-
const nextDidTimeout = nextState !== null && nextState.didTimeout;
1492-
if (nextDidTimeout) {
1493-
child.childExpirationTime = NoWork;
1494-
return child.sibling;
1481+
const state: SuspenseState | null = workInProgress.memoizedState;
1482+
const didTimeout = state !== null && state.didTimeout;
1483+
if (didTimeout) {
1484+
// If this boundary is currently timed out, we need to decide
1485+
// whether to retry the primary children, or to skip over it and
1486+
// go straight to the fallback. Check the priority of the primary
1487+
// child fragment.
1488+
const primaryChildFragment: Fiber = (workInProgress.child: any);
1489+
const primaryChildExpirationTime =
1490+
primaryChildFragment.childExpirationTime;
1491+
if (
1492+
primaryChildExpirationTime !== NoWork &&
1493+
primaryChildExpirationTime <= renderExpirationTime
1494+
) {
1495+
// The primary children have pending work. Use the normal path
1496+
// to attempt to render the primary children again.
1497+
return updateSuspenseComponent(
1498+
current,
1499+
workInProgress,
1500+
renderExpirationTime,
1501+
);
14951502
} else {
1496-
return child;
1503+
// The primary children do not have pending work with sufficient
1504+
// priority. Bailout.
1505+
const child = bailoutOnAlreadyFinishedWork(
1506+
current,
1507+
workInProgress,
1508+
renderExpirationTime,
1509+
);
1510+
if (child !== null) {
1511+
// The fallback children have pending work. Skip over the
1512+
// primary children and work on the fallback.
1513+
return child.sibling;
1514+
} else {
1515+
return null;
1516+
}
14971517
}
1498-
} else {
1499-
return null;
15001518
}
1519+
break;
15011520
}
15021521
}
15031522
return bailoutOnAlreadyFinishedWork(

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

+15
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* @flow
88
*/
99

10+
import type {Fiber} from './ReactFiber';
1011
import type {ExpirationTime} from './ReactFiberExpirationTime';
1112

1213
export type SuspenseState = {|
@@ -23,3 +24,17 @@ export type SuspenseState = {|
2324
// `didTimeout` because it's not set unless the boundary actually commits.
2425
timedOutAt: ExpirationTime,
2526
|};
27+
28+
export function shouldCaptureSuspense(
29+
current: Fiber | null,
30+
workInProgress: Fiber,
31+
): boolean {
32+
// In order to capture, the Suspense component must have a fallback prop.
33+
if (workInProgress.memoizedProps.fallback === undefined) {
34+
return false;
35+
}
36+
// If it was the primary children that just suspended, capture and render the
37+
// fallback. Otherwise, don't capture and bubble to the next boundary.
38+
const nextState: SuspenseState | null = workInProgress.memoizedState;
39+
return nextState === null || !nextState.didTimeout;
40+
}

0 commit comments

Comments
 (0)