Skip to content

Commit e9a2ec9

Browse files
authored
[suspense] Avoid double commit by re-rendering immediately and reusing primary children (facebook#14083)
* Avoid double commit by re-rendering immediately and reusing children To support Suspense outside of concurrent mode, any component that starts rendering must commit synchronously without being interrupted. This means normal path, where we unwind the stack and try again from the nearest Suspense boundary, won't work. We used to have a special case where we commit the suspended tree in an incomplete state. Then, in a subsequent commit, we re-render using the fallback. The first part — committing an incomplete tree — hasn't changed with this PR. But I've changed the second part — now we render the fallback children immediately, within the same commit. * Add a failing test for remounting fallback in sync mode * Add failing test for stuck Suspense fallback * Toggle visibility of Suspense children in mutation phase, not layout If parent reads visibility of children in a lifecycle, they should have already updated.
1 parent 9d47143 commit e9a2ec9

10 files changed

+432
-178
lines changed

packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js

+49
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
'use strict';
1111

12+
let ReactFeatureFlags;
1213
let React;
1314
let ReactDOM;
1415
let Suspense;
@@ -20,6 +21,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
2021

2122
beforeEach(() => {
2223
jest.resetModules();
24+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
25+
ReactFeatureFlags.enableHooks = true;
2326
React = require('react');
2427
ReactDOM = require('react-dom');
2528
ReactCache = require('react-cache');
@@ -108,4 +111,50 @@ describe('ReactDOMSuspensePlaceholder', () => {
108111

109112
expect(container.textContent).toEqual('ABC');
110113
});
114+
115+
it(
116+
'outside concurrent mode, re-hides children if their display is updated ' +
117+
'but the boundary is still showing the fallback',
118+
async () => {
119+
const {useState} = React;
120+
121+
let setIsVisible;
122+
function Sibling({children}) {
123+
const [isVisible, _setIsVisible] = useState(false);
124+
setIsVisible = _setIsVisible;
125+
return (
126+
<span style={{display: isVisible ? 'inline' : 'none'}}>
127+
{children}
128+
</span>
129+
);
130+
}
131+
132+
function App() {
133+
return (
134+
<Suspense maxDuration={500} fallback={<Text text="Loading..." />}>
135+
<Sibling>Sibling</Sibling>
136+
<span>
137+
<AsyncText ms={1000} text="Async" />
138+
</span>
139+
</Suspense>
140+
);
141+
}
142+
143+
ReactDOM.render(<App />, container);
144+
expect(container.innerHTML).toEqual(
145+
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
146+
);
147+
148+
setIsVisible(true);
149+
expect(container.innerHTML).toEqual(
150+
'<span style="display: none;">Sibling</span><span style="display: none;"></span>Loading...',
151+
);
152+
153+
await advanceTimers(1000);
154+
155+
expect(container.innerHTML).toEqual(
156+
'<span style="display: inline;">Sibling</span><span style="">Async</span>',
157+
);
158+
},
159+
);
111160
});

packages/react-reconciler/src/ReactFiberBeginWork.js

+109-45
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import {
6666
} from './ReactChildFiber';
6767
import {processUpdateQueue} from './ReactUpdateQueue';
6868
import {NoWork, Never} from './ReactFiberExpirationTime';
69-
import {ConcurrentMode, StrictMode} from './ReactTypeOfMode';
69+
import {ConcurrentMode, StrictMode, NoContext} from './ReactTypeOfMode';
7070
import {
7171
shouldSetTextContent,
7272
shouldDeprioritizeSubtree,
@@ -1071,31 +1071,21 @@ function updateSuspenseComponent(
10711071
// We should attempt to render the primary children unless this boundary
10721072
// already suspended during this render (`alreadyCaptured` is true).
10731073
let nextState: SuspenseState | null = workInProgress.memoizedState;
1074-
if (nextState === null) {
1075-
// An empty suspense state means this boundary has not yet timed out.
1074+
1075+
let nextDidTimeout;
1076+
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
1077+
// This is the first attempt.
1078+
nextState = null;
1079+
nextDidTimeout = false;
10761080
} else {
1077-
if (!nextState.alreadyCaptured) {
1078-
// Since we haven't already suspended during this commit, clear the
1079-
// existing suspense state. We'll try rendering again.
1080-
nextState = null;
1081-
} else {
1082-
// Something in this boundary's subtree already suspended. Switch to
1083-
// rendering the fallback children. Set `alreadyCaptured` to true.
1084-
if (current !== null && nextState === current.memoizedState) {
1085-
// Create a new suspense state to avoid mutating the current tree's.
1086-
nextState = {
1087-
alreadyCaptured: true,
1088-
didTimeout: true,
1089-
timedOutAt: nextState.timedOutAt,
1090-
};
1091-
} else {
1092-
// Already have a clone, so it's safe to mutate.
1093-
nextState.alreadyCaptured = true;
1094-
nextState.didTimeout = true;
1095-
}
1096-
}
1081+
// Something in this boundary's subtree already suspended. Switch to
1082+
// rendering the fallback children.
1083+
nextState = {
1084+
timedOutAt: nextState !== null ? nextState.timedOutAt : NoWork,
1085+
};
1086+
nextDidTimeout = true;
1087+
workInProgress.effectTag &= ~DidCapture;
10971088
}
1098-
const nextDidTimeout = nextState !== null && nextState.didTimeout;
10991089

11001090
// This next part is a bit confusing. If the children timeout, we switch to
11011091
// showing the fallback children in place of the "primary" children.
@@ -1140,6 +1130,22 @@ function updateSuspenseComponent(
11401130
NoWork,
11411131
null,
11421132
);
1133+
1134+
if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1135+
// Outside of concurrent mode, we commit the effects from the
1136+
// partially completed, timed-out tree, too.
1137+
const progressedState: SuspenseState = workInProgress.memoizedState;
1138+
const progressedPrimaryChild: Fiber | null =
1139+
progressedState !== null
1140+
? (workInProgress.child: any).child
1141+
: (workInProgress.child: any);
1142+
reuseProgressedPrimaryChild(
1143+
workInProgress,
1144+
primaryChildFragment,
1145+
progressedPrimaryChild,
1146+
);
1147+
}
1148+
11431149
const fallbackChildFragment = createFiberFromFragment(
11441150
nextFallbackChildren,
11451151
mode,
@@ -1166,7 +1172,7 @@ function updateSuspenseComponent(
11661172
// This is an update. This branch is more complicated because we need to
11671173
// ensure the state of the primary children is preserved.
11681174
const prevState = current.memoizedState;
1169-
const prevDidTimeout = prevState !== null && prevState.didTimeout;
1175+
const prevDidTimeout = prevState !== null;
11701176
if (prevDidTimeout) {
11711177
// The current tree already timed out. That means each child set is
11721178
// wrapped in a fragment fiber.
@@ -1182,6 +1188,24 @@ function updateSuspenseComponent(
11821188
NoWork,
11831189
);
11841190
primaryChildFragment.effectTag |= Placement;
1191+
1192+
if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1193+
// Outside of concurrent mode, we commit the effects from the
1194+
// partially completed, timed-out tree, too.
1195+
const progressedState: SuspenseState = workInProgress.memoizedState;
1196+
const progressedPrimaryChild: Fiber | null =
1197+
progressedState !== null
1198+
? (workInProgress.child: any).child
1199+
: (workInProgress.child: any);
1200+
if (progressedPrimaryChild !== currentPrimaryChildFragment.child) {
1201+
reuseProgressedPrimaryChild(
1202+
workInProgress,
1203+
primaryChildFragment,
1204+
progressedPrimaryChild,
1205+
);
1206+
}
1207+
}
1208+
11851209
// Clone the fallback child fragment, too. These we'll continue
11861210
// working on.
11871211
const fallbackChildFragment = (primaryChildFragment.sibling = createWorkInProgress(
@@ -1237,6 +1261,22 @@ function updateSuspenseComponent(
12371261
primaryChildFragment.effectTag |= Placement;
12381262
primaryChildFragment.child = currentPrimaryChild;
12391263
currentPrimaryChild.return = primaryChildFragment;
1264+
1265+
if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1266+
// Outside of concurrent mode, we commit the effects from the
1267+
// partially completed, timed-out tree, too.
1268+
const progressedState: SuspenseState = workInProgress.memoizedState;
1269+
const progressedPrimaryChild: Fiber | null =
1270+
progressedState !== null
1271+
? (workInProgress.child: any).child
1272+
: (workInProgress.child: any);
1273+
reuseProgressedPrimaryChild(
1274+
workInProgress,
1275+
primaryChildFragment,
1276+
progressedPrimaryChild,
1277+
);
1278+
}
1279+
12401280
// Create a fragment from the fallback children, too.
12411281
const fallbackChildFragment = (primaryChildFragment.sibling = createFiberFromFragment(
12421282
nextFallbackChildren,
@@ -1270,6 +1310,49 @@ function updateSuspenseComponent(
12701310
return next;
12711311
}
12721312

1313+
function reuseProgressedPrimaryChild(
1314+
workInProgress: Fiber,
1315+
primaryChildFragment: Fiber,
1316+
progressedChild: Fiber | null,
1317+
) {
1318+
// This function is only called outside concurrent mode. Usually, if a work-
1319+
// in-progress primary tree suspends, we throw it out and revert back to
1320+
// current. Outside concurrent mode, though, we commit the suspended work-in-
1321+
// progress, even though it didn't complete. This function reuses the children
1322+
// and transfers the effects.
1323+
let child = (primaryChildFragment.child = progressedChild);
1324+
while (child !== null) {
1325+
// Ensure that the first and last effect of the parent corresponds
1326+
// to the children's first and last effect.
1327+
if (primaryChildFragment.firstEffect === null) {
1328+
primaryChildFragment.firstEffect = child.firstEffect;
1329+
}
1330+
if (child.lastEffect !== null) {
1331+
if (primaryChildFragment.lastEffect !== null) {
1332+
primaryChildFragment.lastEffect.nextEffect = child.firstEffect;
1333+
}
1334+
primaryChildFragment.lastEffect = child.lastEffect;
1335+
}
1336+
1337+
// Append all the effects of the subtree and this fiber onto the effect
1338+
// list of the parent. The completion order of the children affects the
1339+
// side-effect order.
1340+
if (child.effectTag > PerformedWork) {
1341+
if (primaryChildFragment.lastEffect !== null) {
1342+
primaryChildFragment.lastEffect.nextEffect = child;
1343+
} else {
1344+
primaryChildFragment.firstEffect = child;
1345+
}
1346+
primaryChildFragment.lastEffect = child;
1347+
}
1348+
child.return = primaryChildFragment;
1349+
child = child.sibling;
1350+
}
1351+
1352+
workInProgress.firstEffect = primaryChildFragment.firstEffect;
1353+
workInProgress.lastEffect = primaryChildFragment.lastEffect;
1354+
}
1355+
12731356
function updatePortalComponent(
12741357
current: Fiber | null,
12751358
workInProgress: Fiber,
@@ -1426,25 +1509,6 @@ function updateContextConsumer(
14261509
return workInProgress.child;
14271510
}
14281511

1429-
/*
1430-
function reuseChildrenEffects(returnFiber : Fiber, firstChild : Fiber) {
1431-
let child = firstChild;
1432-
do {
1433-
// Ensure that the first and last effect of the parent corresponds
1434-
// to the children's first and last effect.
1435-
if (!returnFiber.firstEffect) {
1436-
returnFiber.firstEffect = child.firstEffect;
1437-
}
1438-
if (child.lastEffect) {
1439-
if (returnFiber.lastEffect) {
1440-
returnFiber.lastEffect.nextEffect = child.firstEffect;
1441-
}
1442-
returnFiber.lastEffect = child.lastEffect;
1443-
}
1444-
} while (child = child.sibling);
1445-
}
1446-
*/
1447-
14481512
function bailoutOnAlreadyFinishedWork(
14491513
current: Fiber | null,
14501514
workInProgress: Fiber,
@@ -1528,7 +1592,7 @@ function beginWork(
15281592
break;
15291593
case SuspenseComponent: {
15301594
const state: SuspenseState | null = workInProgress.memoizedState;
1531-
const didTimeout = state !== null && state.didTimeout;
1595+
const didTimeout = state !== null;
15321596
if (didTimeout) {
15331597
// If this boundary is currently timed out, we need to decide
15341598
// whether to retry the primary children, or to skip over it and

packages/react-reconciler/src/ReactFiberCommitWork.js

+23-48
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,12 @@ import {
5050
Placement,
5151
Snapshot,
5252
Update,
53-
Callback,
5453
} from 'shared/ReactSideEffectTags';
5554
import getComponentName from 'shared/getComponentName';
5655
import invariant from 'shared/invariant';
5756
import warningWithoutStack from 'shared/warningWithoutStack';
5857

59-
import {NoWork, Sync} from './ReactFiberExpirationTime';
58+
import {NoWork} from './ReactFiberExpirationTime';
6059
import {onCommitUnmount} from './ReactFiberDevToolsHook';
6160
import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
6261
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -86,9 +85,7 @@ import {
8685
} from './ReactFiberHostConfig';
8786
import {
8887
captureCommitPhaseError,
89-
flushPassiveEffects,
9088
requestCurrentTime,
91-
scheduleWork,
9289
} from './ReactFiberScheduler';
9390
import {
9491
NoEffect as NoHookEffect,
@@ -452,50 +449,8 @@ function commitLifeCycles(
452449
}
453450
return;
454451
}
455-
case SuspenseComponent: {
456-
if (finishedWork.effectTag & Callback) {
457-
// In non-strict mode, a suspense boundary times out by commiting
458-
// twice: first, by committing the children in an inconsistent state,
459-
// then hiding them and showing the fallback children in a subsequent
460-
// commit.
461-
const newState: SuspenseState = {
462-
alreadyCaptured: true,
463-
didTimeout: false,
464-
timedOutAt: NoWork,
465-
};
466-
finishedWork.memoizedState = newState;
467-
flushPassiveEffects();
468-
scheduleWork(finishedWork, Sync);
469-
return;
470-
}
471-
let oldState: SuspenseState | null =
472-
current !== null ? current.memoizedState : null;
473-
let newState: SuspenseState | null = finishedWork.memoizedState;
474-
let oldDidTimeout = oldState !== null ? oldState.didTimeout : false;
475-
476-
let newDidTimeout;
477-
let primaryChildParent = finishedWork;
478-
if (newState === null) {
479-
newDidTimeout = false;
480-
} else {
481-
newDidTimeout = newState.didTimeout;
482-
if (newDidTimeout) {
483-
primaryChildParent = finishedWork.child;
484-
newState.alreadyCaptured = false;
485-
if (newState.timedOutAt === NoWork) {
486-
// If the children had not already timed out, record the time.
487-
// This is used to compute the elapsed time during subsequent
488-
// attempts to render the children.
489-
newState.timedOutAt = requestCurrentTime();
490-
}
491-
}
492-
}
493-
494-
if (newDidTimeout !== oldDidTimeout && primaryChildParent !== null) {
495-
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
496-
}
497-
return;
498-
}
452+
case SuspenseComponent:
453+
break;
499454
case IncompleteClassComponent:
500455
break;
501456
default: {
@@ -1066,6 +1021,26 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
10661021
return;
10671022
}
10681023
case SuspenseComponent: {
1024+
let newState: SuspenseState | null = finishedWork.memoizedState;
1025+
1026+
let newDidTimeout;
1027+
let primaryChildParent = finishedWork;
1028+
if (newState === null) {
1029+
newDidTimeout = false;
1030+
} else {
1031+
newDidTimeout = true;
1032+
primaryChildParent = finishedWork.child;
1033+
if (newState.timedOutAt === NoWork) {
1034+
// If the children had not already timed out, record the time.
1035+
// This is used to compute the elapsed time during subsequent
1036+
// attempts to render the children.
1037+
newState.timedOutAt = requestCurrentTime();
1038+
}
1039+
}
1040+
1041+
if (primaryChildParent !== null) {
1042+
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
1043+
}
10691044
return;
10701045
}
10711046
case IncompleteClassComponent: {

0 commit comments

Comments
 (0)