Skip to content

Commit 8fab1de

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 0b31e6b commit 8fab1de

10 files changed

+292
-163
lines changed

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

Lines changed: 49 additions & 0 deletions
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

Lines changed: 109 additions & 45 deletions
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

Lines changed: 10 additions & 33 deletions
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,
@@ -453,47 +450,27 @@ function commitLifeCycles(
453450
return;
454451
}
455452
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;
473453
let newState: SuspenseState | null = finishedWork.memoizedState;
474-
let oldDidTimeout = oldState !== null ? oldState.didTimeout : false;
475454

476455
let newDidTimeout;
477456
let primaryChildParent = finishedWork;
478457
if (newState === null) {
479458
newDidTimeout = false;
480459
} 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-
}
460+
newDidTimeout = true;
461+
primaryChildParent = finishedWork.child;
462+
if (newState.timedOutAt === NoWork) {
463+
// If the children had not already timed out, record the time.
464+
// This is used to compute the elapsed time during subsequent
465+
// attempts to render the children.
466+
newState.timedOutAt = requestCurrentTime();
491467
}
492468
}
493469

494-
if (newDidTimeout !== oldDidTimeout && primaryChildParent !== null) {
470+
if (primaryChildParent !== null) {
495471
hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
496472
}
473+
497474
return;
498475
}
499476
case IncompleteClassComponent:

0 commit comments

Comments
 (0)