Skip to content

Commit 1815355

Browse files
committed
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.
1 parent f444c28 commit 1815355

10 files changed

+289
-163
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');
@@ -109,4 +112,50 @@ describe('ReactDOMSuspensePlaceholder', () => {
109112

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

packages/react-reconciler/src/ReactFiberBeginWork.js

+107-45
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
DidCapture,
4444
Update,
4545
Ref,
46+
Incomplete,
4647
} from 'shared/ReactSideEffectTags';
4748
import ReactSharedInternals from 'shared/ReactSharedInternals';
4849
import {
@@ -66,7 +67,7 @@ import {
6667
} from './ReactChildFiber';
6768
import {processUpdateQueue} from './ReactUpdateQueue';
6869
import {NoWork, Never} from './ReactFiberExpirationTime';
69-
import {ConcurrentMode, StrictMode} from './ReactTypeOfMode';
70+
import {ConcurrentMode, StrictMode, NoContext} from './ReactTypeOfMode';
7071
import {
7172
shouldSetTextContent,
7273
shouldDeprioritizeSubtree,
@@ -1071,31 +1072,21 @@ function updateSuspenseComponent(
10711072
// We should attempt to render the primary children unless this boundary
10721073
// already suspended during this render (`alreadyCaptured` is true).
10731074
let nextState: SuspenseState | null = workInProgress.memoizedState;
1074-
if (nextState === null) {
1075-
// An empty suspense state means this boundary has not yet timed out.
1075+
1076+
let nextDidTimeout;
1077+
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
1078+
// This is the first attempt.
1079+
nextState = null;
1080+
nextDidTimeout = false;
10761081
} 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-
}
1082+
// Something in this boundary's subtree already suspended. Switch to
1083+
// rendering the fallback children.
1084+
nextState = {
1085+
timedOutAt: nextState !== null ? nextState.timedOutAt : NoWork,
1086+
};
1087+
nextDidTimeout = true;
1088+
workInProgress.effectTag &= ~DidCapture;
10971089
}
1098-
const nextDidTimeout = nextState !== null && nextState.didTimeout;
10991090

11001091
// This next part is a bit confusing. If the children timeout, we switch to
11011092
// showing the fallback children in place of the "primary" children.
@@ -1140,6 +1131,22 @@ function updateSuspenseComponent(
11401131
NoWork,
11411132
null,
11421133
);
1134+
1135+
if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1136+
// Outside of concurrent mode, we commit the effects from the
1137+
// partially completed, timed-out tree, too.
1138+
const progressedState: SuspenseState = workInProgress.memoizedState;
1139+
const progressedPrimaryChild: Fiber | null =
1140+
progressedState !== null
1141+
? (workInProgress.child: any).child
1142+
: (workInProgress.child: any);
1143+
reuseProgressedPrimaryChild(
1144+
workInProgress,
1145+
primaryChildFragment,
1146+
progressedPrimaryChild,
1147+
);
1148+
}
1149+
11431150
const fallbackChildFragment = createFiberFromFragment(
11441151
nextFallbackChildren,
11451152
mode,
@@ -1166,7 +1173,7 @@ function updateSuspenseComponent(
11661173
// This is an update. This branch is more complicated because we need to
11671174
// ensure the state of the primary children is preserved.
11681175
const prevState = current.memoizedState;
1169-
const prevDidTimeout = prevState !== null && prevState.didTimeout;
1176+
const prevDidTimeout = prevState !== null;
11701177
if (prevDidTimeout) {
11711178
// The current tree already timed out. That means each child set is
11721179
// wrapped in a fragment fiber.
@@ -1182,6 +1189,24 @@ function updateSuspenseComponent(
11821189
NoWork,
11831190
);
11841191
primaryChildFragment.effectTag |= Placement;
1192+
1193+
if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1194+
// Outside of concurrent mode, we commit the effects from the
1195+
// partially completed, timed-out tree, too.
1196+
const progressedState: SuspenseState = workInProgress.memoizedState;
1197+
const progressedPrimaryChild: Fiber | null =
1198+
progressedState !== null
1199+
? (workInProgress.child: any).child
1200+
: (workInProgress.child: any);
1201+
if (progressedPrimaryChild !== currentPrimaryChildFragment.child) {
1202+
reuseProgressedPrimaryChild(
1203+
workInProgress,
1204+
primaryChildFragment,
1205+
progressedPrimaryChild,
1206+
);
1207+
}
1208+
}
1209+
11851210
// Clone the fallback child fragment, too. These we'll continue
11861211
// working on.
11871212
const fallbackChildFragment = (primaryChildFragment.sibling = createWorkInProgress(
@@ -1237,6 +1262,22 @@ function updateSuspenseComponent(
12371262
primaryChildFragment.effectTag |= Placement;
12381263
primaryChildFragment.child = currentPrimaryChild;
12391264
currentPrimaryChild.return = primaryChildFragment;
1265+
1266+
if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1267+
// Outside of concurrent mode, we commit the effects from the
1268+
// partially completed, timed-out tree, too.
1269+
const progressedState: SuspenseState = workInProgress.memoizedState;
1270+
const progressedPrimaryChild: Fiber | null =
1271+
progressedState !== null
1272+
? (workInProgress.child: any).child
1273+
: (workInProgress.child: any);
1274+
reuseProgressedPrimaryChild(
1275+
workInProgress,
1276+
primaryChildFragment,
1277+
progressedPrimaryChild,
1278+
);
1279+
}
1280+
12401281
// Create a fragment from the fallback children, too.
12411282
const fallbackChildFragment = (primaryChildFragment.sibling = createFiberFromFragment(
12421283
nextFallbackChildren,
@@ -1270,6 +1311,46 @@ function updateSuspenseComponent(
12701311
return next;
12711312
}
12721313

1314+
function reuseProgressedPrimaryChild(
1315+
workInProgress: Fiber,
1316+
primaryChildFragment: Fiber,
1317+
progressedChild: Fiber | null,
1318+
) {
1319+
let child = (primaryChildFragment.child = progressedChild);
1320+
while (child !== null) {
1321+
if ((child.effectTag & Incomplete) === NoEffect) {
1322+
// Ensure that the first and last effect of the parent corresponds
1323+
// to the children's first and last effect.
1324+
if (primaryChildFragment.firstEffect === null) {
1325+
primaryChildFragment.firstEffect = child.firstEffect;
1326+
}
1327+
if (child.lastEffect !== null) {
1328+
if (primaryChildFragment.lastEffect !== null) {
1329+
primaryChildFragment.lastEffect.nextEffect = child.firstEffect;
1330+
}
1331+
primaryChildFragment.lastEffect = child.lastEffect;
1332+
}
1333+
1334+
// Append all the effects of the subtree and this fiber onto the effect
1335+
// list of the parent. The completion order of the children affects the
1336+
// side-effect order.
1337+
if (child.effectTag > PerformedWork) {
1338+
if (primaryChildFragment.lastEffect !== null) {
1339+
primaryChildFragment.lastEffect.nextEffect = child;
1340+
} else {
1341+
primaryChildFragment.firstEffect = child;
1342+
}
1343+
primaryChildFragment.lastEffect = child;
1344+
}
1345+
}
1346+
child.return = primaryChildFragment;
1347+
child = child.sibling;
1348+
}
1349+
1350+
workInProgress.firstEffect = primaryChildFragment.firstEffect;
1351+
workInProgress.lastEffect = primaryChildFragment.lastEffect;
1352+
}
1353+
12731354
function updatePortalComponent(
12741355
current: Fiber | null,
12751356
workInProgress: Fiber,
@@ -1426,25 +1507,6 @@ function updateContextConsumer(
14261507
return workInProgress.child;
14271508
}
14281509

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-
14481510
function bailoutOnAlreadyFinishedWork(
14491511
current: Fiber | null,
14501512
workInProgress: Fiber,
@@ -1528,7 +1590,7 @@ function beginWork(
15281590
break;
15291591
case SuspenseComponent: {
15301592
const state: SuspenseState | null = workInProgress.memoizedState;
1531-
const didTimeout = state !== null && state.didTimeout;
1593+
const didTimeout = state !== null;
15321594
if (didTimeout) {
15331595
// If this boundary is currently timed out, we need to decide
15341596
// whether to retry the primary children, or to skip over it and

packages/react-reconciler/src/ReactFiberCommitWork.js

+10-33
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,12 @@ import {
4949
Placement,
5050
Snapshot,
5151
Update,
52-
Callback,
5352
} from 'shared/ReactSideEffectTags';
5453
import getComponentName from 'shared/getComponentName';
5554
import invariant from 'shared/invariant';
5655
import warningWithoutStack from 'shared/warningWithoutStack';
5756

58-
import {NoWork, Sync} from './ReactFiberExpirationTime';
57+
import {NoWork} from './ReactFiberExpirationTime';
5958
import {onCommitUnmount} from './ReactFiberDevToolsHook';
6059
import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
6160
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -85,9 +84,7 @@ import {
8584
} from './ReactFiberHostConfig';
8685
import {
8786
captureCommitPhaseError,
88-
flushPassiveEffects,
8987
requestCurrentTime,
90-
scheduleWork,
9188
} from './ReactFiberScheduler';
9289
import {
9390
NoEffect as NoHookEffect,
@@ -431,47 +428,27 @@ function commitLifeCycles(
431428
return;
432429
}
433430
case SuspenseComponent: {
434-
if (finishedWork.effectTag & Callback) {
435-
// In non-strict mode, a suspense boundary times out by commiting
436-
// twice: first, by committing the children in an inconsistent state,
437-
// then hiding them and showing the fallback children in a subsequent
438-
// commit.
439-
const newState: SuspenseState = {
440-
alreadyCaptured: true,
441-
didTimeout: false,
442-
timedOutAt: NoWork,
443-
};
444-
finishedWork.memoizedState = newState;
445-
flushPassiveEffects();
446-
scheduleWork(finishedWork, Sync);
447-
return;
448-
}
449-
let oldState: SuspenseState | null =
450-
current !== null ? current.memoizedState : null;
451431
let newState: SuspenseState | null = finishedWork.memoizedState;
452-
let oldDidTimeout = oldState !== null ? oldState.didTimeout : false;
453432

454433
let newDidTimeout;
455434
let primaryChildParent = finishedWork;
456435
if (newState === null) {
457436
newDidTimeout = false;
458437
} else {
459-
newDidTimeout = newState.didTimeout;
460-
if (newDidTimeout) {
461-
primaryChildParent = finishedWork.child;
462-
newState.alreadyCaptured = false;
463-
if (newState.timedOutAt === NoWork) {
464-
// If the children had not already timed out, record the time.
465-
// This is used to compute the elapsed time during subsequent
466-
// attempts to render the children.
467-
newState.timedOutAt = requestCurrentTime();
468-
}
438+
newDidTimeout = true;
439+
primaryChildParent = finishedWork.child;
440+
if (newState.timedOutAt === NoWork) {
441+
// If the children had not already timed out, record the time.
442+
// This is used to compute the elapsed time during subsequent
443+
// attempts to render the children.
444+
newState.timedOutAt = requestCurrentTime();
469445
}
470446
}
471447

472-
if (newDidTimeout !== oldDidTimeout && primaryChildParent !== null) {
448+
if (primaryChildParent !== null) {
473449
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
474450
}
451+
475452
return;
476453
}
477454
case IncompleteClassComponent:

0 commit comments

Comments
 (0)