@samitouri / QOS-React / commits / e9a2ec9156

[suspense] Avoid double commit by re-rendering immediately and reusing primary children (#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.

Andrew Clark committed Nov 5, 2018 at 16:32 UTC e9a2ec915655fa1968b7d41c4e8ad9e90f7268cb
10 files changed +432 -178
packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js
+49
@@ -9,6 +9,7 @@
9
10 'use strict';
11
12 +let ReactFeatureFlags;
13 let React;
14 let ReactDOM;
15 let Suspense;
@@ -20,6 +21,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
21
22 beforeEach(() => {
23 jest.resetModules();
24 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
25 + ReactFeatureFlags.enableHooks = true;
26 React = require('react');
27 ReactDOM = require('react-dom');
28 ReactCache = require('react-cache');
@@ -108,4 +111,50 @@ describe('ReactDOMSuspensePlaceholder', () => {
111
112 expect(container.textContent).toEqual('ABC');
113 });
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 + );
160 });
packages/react-reconciler/src/ReactFiberBeginWork.js
+109 -45
@@ -66,7 +66,7 @@ import {
66 } from './ReactChildFiber';
67 import {processUpdateQueue} from './ReactUpdateQueue';
68 import {NoWork, Never} from './ReactFiberExpirationTime';
69 -import {ConcurrentMode, StrictMode} from './ReactTypeOfMode';
69 +import {ConcurrentMode, StrictMode, NoContext} from './ReactTypeOfMode';
70 import {
71 shouldSetTextContent,
72 shouldDeprioritizeSubtree,
@@ -1071,31 +1071,21 @@ function updateSuspenseComponent(
1071 // We should attempt to render the primary children unless this boundary
1072 // already suspended during this render (`alreadyCaptured` is true).
1073 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;
1080 } 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;
1088 }
1098 - const nextDidTimeout = nextState !== null && nextState.didTimeout;
1089
1090 // This next part is a bit confusing. If the children timeout, we switch to
1091 // showing the fallback children in place of the "primary" children.
@@ -1140,6 +1130,22 @@ function updateSuspenseComponent(
1130 NoWork,
1131 null,
1132 );
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 +
1149 const fallbackChildFragment = createFiberFromFragment(
1150 nextFallbackChildren,
1151 mode,
@@ -1166,7 +1172,7 @@ function updateSuspenseComponent(
1172 // This is an update. This branch is more complicated because we need to
1173 // ensure the state of the primary children is preserved.
1174 const prevState = current.memoizedState;
1169 - const prevDidTimeout = prevState !== null && prevState.didTimeout;
1175 + const prevDidTimeout = prevState !== null;
1176 if (prevDidTimeout) {
1177 // The current tree already timed out. That means each child set is
1178 // wrapped in a fragment fiber.
@@ -1182,6 +1188,24 @@ function updateSuspenseComponent(
1188 NoWork,
1189 );
1190 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 +
1209 // Clone the fallback child fragment, too. These we'll continue
1210 // working on.
1211 const fallbackChildFragment = (primaryChildFragment.sibling = createWorkInProgress(
@@ -1237,6 +1261,22 @@ function updateSuspenseComponent(
1261 primaryChildFragment.effectTag |= Placement;
1262 primaryChildFragment.child = currentPrimaryChild;
1263 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 +
1280 // Create a fragment from the fallback children, too.
1281 const fallbackChildFragment = (primaryChildFragment.sibling = createFiberFromFragment(
1282 nextFallbackChildren,
@@ -1270,6 +1310,49 @@ function updateSuspenseComponent(
1310 return next;
1311 }
1312
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 +
1356 function updatePortalComponent(
1357 current: Fiber | null,
1358 workInProgress: Fiber,
@@ -1426,25 +1509,6 @@ function updateContextConsumer(
1509 return workInProgress.child;
1510 }
1511
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 -
1512 function bailoutOnAlreadyFinishedWork(
1513 current: Fiber | null,
1514 workInProgress: Fiber,
@@ -1528,7 +1592,7 @@ function beginWork(
1592 break;
1593 case SuspenseComponent: {
1594 const state: SuspenseState | null = workInProgress.memoizedState;
1531 - const didTimeout = state !== null && state.didTimeout;
1595 + const didTimeout = state !== null;
1596 if (didTimeout) {
1597 // If this boundary is currently timed out, we need to decide
1598 // whether to retry the primary children, or to skip over it and
packages/react-reconciler/src/ReactFiberCommitWork.js
+23 -48
@@ -50,13 +50,12 @@ import {
50 Placement,
51 Snapshot,
52 Update,
53 - Callback,
53 } from 'shared/ReactSideEffectTags';
54 import getComponentName from 'shared/getComponentName';
55 import invariant from 'shared/invariant';
56 import warningWithoutStack from 'shared/warningWithoutStack';
57
59 -import {NoWork, Sync} from './ReactFiberExpirationTime';
58 +import {NoWork} from './ReactFiberExpirationTime';
59 import {onCommitUnmount} from './ReactFiberDevToolsHook';
60 import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
61 import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -86,9 +85,7 @@ import {
85 } from './ReactFiberHostConfig';
86 import {
87 captureCommitPhaseError,
89 - flushPassiveEffects,
88 requestCurrentTime,
91 - scheduleWork,
89 } from './ReactFiberScheduler';
90 import {
91 NoEffect as NoHookEffect,
@@ -452,50 +449,8 @@ function commitLifeCycles(
449 }
450 return;
451 }
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;
454 case IncompleteClassComponent:
455 break;
456 default: {
@@ -1066,6 +1021,26 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void {
1021 return;
1022 }
1023 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 + }
1044 return;
1045 }
1046 case IncompleteClassComponent: {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+33 -11
@@ -39,7 +39,13 @@ import {
39 LazyComponent,
40 IncompleteClassComponent,
41 } from 'shared/ReactWorkTags';
42 -import {Placement, Ref, Update} from 'shared/ReactSideEffectTags';
42 +import {
43 + Placement,
44 + Ref,
45 + Update,
46 + NoEffect,
47 + DidCapture,
48 +} from 'shared/ReactSideEffectTags';
49 import invariant from 'shared/invariant';
50
51 import {
@@ -75,6 +81,7 @@ import {
81 prepareToHydrateHostTextInstance,
82 popHydrationState,
83 } from './ReactFiberHydrationContext';
84 +import {ConcurrentMode, NoContext} from './ReactTypeOfMode';
85
86 function markUpdate(workInProgress: Fiber) {
87 // Tag the fiber with an update effect. This turns a Placement into
@@ -247,8 +254,8 @@ if (supportsMutation) {
254 if (current !== null) {
255 const oldState: SuspenseState = current.memoizedState;
256 const newState: SuspenseState = node.memoizedState;
250 - const oldIsHidden = oldState !== null && oldState.didTimeout;
251 - const newIsHidden = newState !== null && newState.didTimeout;
257 + const oldIsHidden = oldState !== null;
258 + const newIsHidden = newState !== null;
259 if (oldIsHidden !== newIsHidden) {
260 // The placeholder either just timed out or switched back to the normal
261 // children after having previously timed out. Toggle the visibility of
@@ -350,8 +357,8 @@ if (supportsMutation) {
357 if (current !== null) {
358 const oldState: SuspenseState = current.memoizedState;
359 const newState: SuspenseState = node.memoizedState;
353 - const oldIsHidden = oldState !== null && oldState.didTimeout;
354 - const newIsHidden = newState !== null && newState.didTimeout;
360 + const oldIsHidden = oldState !== null;
361 + const newIsHidden = newState !== null;
362 if (oldIsHidden !== newIsHidden) {
363 // The placeholder either just timed out or switched back to the normal
364 // children after having previously timed out. Toggle the visibility of
@@ -690,12 +697,27 @@ function completeWork(
697 break;
698 case SuspenseComponent: {
699 const nextState = workInProgress.memoizedState;
693 - const prevState = current !== null ? current.memoizedState : null;
694 - const nextDidTimeout = nextState !== null && nextState.didTimeout;
695 - const prevDidTimeout = prevState !== null && prevState.didTimeout;
696 - if (nextDidTimeout !== prevDidTimeout) {
697 - // If this render commits, and it switches between the normal state
698 - // and the timed-out state, schedule an effect.
700 + if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
701 + // Something suspended. Re-render with the fallback children.
702 + workInProgress.expirationTime = renderExpirationTime;
703 + workInProgress.firstEffect = workInProgress.lastEffect = null;
704 + return workInProgress;
705 + }
706 +
707 + const nextDidTimeout = nextState !== null;
708 + const prevDidTimeout = current !== null && current.memoizedState !== null;
709 + // The children either timed out after previously being visible, or
710 + // were restored after previously being hidden. Schedule an effect
711 + // to update their visiblity.
712 + if (
713 + //
714 + nextDidTimeout !== prevDidTimeout ||
715 + // Outside concurrent mode, the primary children commit in an
716 + // inconsistent state, even if they are hidden. So if they are hidden,
717 + // we need to schedule an effect to re-hide them, just in case.
718 + ((workInProgress.effectTag & ConcurrentMode) === NoContext &&
719 + nextDidTimeout)
720 + ) {
721 workInProgress.effectTag |= Update;
722 }
723 break;
packages/react-reconciler/src/ReactFiberScheduler.js
+6
@@ -981,6 +981,12 @@ function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
981 ReactCurrentFiber.resetCurrentFiber();
982 }
983
984 + if (nextUnitOfWork !== null) {
985 + // Completing this fiber spawned new work. Work on that next.
986 + nextUnitOfWork.firstEffect = nextUnitOfWork.lastEffect = null;
987 + return nextUnitOfWork;
988 + }
989 +
990 if (
991 returnFiber !== null &&
992 // Do not append effects to parents if a sibling failed to complete
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+1 -12
@@ -11,17 +11,6 @@ import type {Fiber} from './ReactFiber';
11 import type {ExpirationTime} from './ReactFiberExpirationTime';
12
13 export type SuspenseState = {|
14 - // Whether a component in the child subtree already suspended. If true,
15 - // subsequent suspends should bubble up to the next boundary.
16 - alreadyCaptured: boolean,
17 - // Whether the boundary renders the primary or fallback children. This is
18 - // separate from `alreadyCaptured` because outside of strict mode, when a
19 - // boundary times out, the first commit renders the primary children in an
20 - // incomplete state, then performs a second commit to switch the fallback.
21 - // In that first commit, `alreadyCaptured` is false and `didTimeout` is true.
22 - didTimeout: boolean,
23 - // The time at which the boundary timed out. This is separate from
24 - // `didTimeout` because it's not set unless the boundary actually commits.
14 timedOutAt: ExpirationTime,
15 |};
16
@@ -36,5 +25,5 @@ export function shouldCaptureSuspense(
25 // If it was the primary children that just suspended, capture and render the
26 // fallback. Otherwise, don't capture and bubble to the next boundary.
27 const nextState: SuspenseState | null = workInProgress.memoizedState;
39 - return nextState === null || !nextState.didTimeout;
28 + return nextState === null;
29 }
packages/react-reconciler/src/ReactFiberUnwindWork.js
+7 -31
@@ -32,7 +32,6 @@ import {
32 Incomplete,
33 NoEffect,
34 ShouldCapture,
35 - Callback as CallbackEffect,
35 LifecycleEffectMask,
36 } from 'shared/ReactSideEffectTags';
37 import {enableSchedulerTracing} from 'shared/ReactFeatureFlags';
@@ -66,7 +65,6 @@ import {
65 import invariant from 'shared/invariant';
66 import maxSigned31BitInt from './maxSigned31BitInt';
67 import {
69 - NoWork,
68 Sync,
69 expirationTimeToMs,
70 LOW_PRIORITY_EXPIRATION,
@@ -176,7 +174,7 @@ function throwException(
174 const current = workInProgress.alternate;
175 if (current !== null) {
176 const currentState: SuspenseState | null = current.memoizedState;
179 - if (currentState !== null && currentState.didTimeout) {
177 + if (currentState !== null) {
178 // Reached a boundary that already timed out. Do not search
179 // any further.
180 const timedOutAt = currentState.timedOutAt;
@@ -238,7 +236,7 @@ function throwException(
236 // inside a concurrent mode tree. If the Suspense is outside of it, we
237 // should *not* suspend the commit.
238 if ((workInProgress.mode & ConcurrentMode) === NoEffect) {
241 - workInProgress.effectTag |= CallbackEffect;
239 + workInProgress.effectTag |= DidCapture;
240
241 // Unmount the source fiber's children
242 const nextChildren = null;
@@ -265,6 +263,10 @@ function throwException(
263 }
264 }
265
266 + // The source fiber did not complete. Mark it with the current
267 + // render priority to indicate that it still has pending work.
268 + sourceFiber.expirationTime = renderExpirationTime;
269 +
270 // Exit without suspending.
271 return;
272 }
@@ -415,33 +417,7 @@ function unwindWork(
417 const effectTag = workInProgress.effectTag;
418 if (effectTag & ShouldCapture) {
419 workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
418 - // Captured a suspense effect. Set the boundary's `alreadyCaptured`
419 - // state to true so we know to render the fallback.
420 - const current = workInProgress.alternate;
421 - const currentState: SuspenseState | null =
422 - current !== null ? current.memoizedState : null;
423 - let nextState: SuspenseState | null = workInProgress.memoizedState;
424 - if (nextState === null) {
425 - // No existing state. Create a new object.
426 - nextState = {
427 - alreadyCaptured: true,
428 - didTimeout: false,
429 - timedOutAt: NoWork,
430 - };
431 - } else if (currentState === nextState) {
432 - // There is an existing state but it's the same as the current tree's.
433 - // Clone the object.
434 - nextState = {
435 - alreadyCaptured: true,
436 - didTimeout: nextState.didTimeout,
437 - timedOutAt: nextState.timedOutAt,
438 - };
439 - } else {
440 - // Already have a clone, so it's safe to mutate.
441 - nextState.alreadyCaptured = true;
442 - }
443 - workInProgress.memoizedState = nextState;
444 - // Re-render the boundary.
420 + // Captured a suspense effect. Re-render the boundary.
421 return workInProgress;
422 }
423 return null;
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+152 -4
@@ -401,14 +401,12 @@ describe('ReactSuspense', () => {
401 'A',
402 'Suspend! [B:1]',
403 'C',
404 + 'Loading...',
405
406 'Mount [A]',
407 // B's lifecycle should not fire because it suspended
408 // 'Mount [B]',
409 'Mount [C]',
409 -
410 - // In a subsequent commit, render a placeholder
411 - 'Loading...',
410 'Mount [Loading...]',
411 ]);
412 expect(root).toMatchRenderedOutput('Loading...');
@@ -483,13 +481,76 @@ describe('ReactSuspense', () => {
481 expect(root).toMatchRenderedOutput('Loading...');
482
483 instance.setState({step: 2});
484 + expect(ReactTestRenderer).toHaveYielded(['Stateful: 2', 'Suspend! [B]']);
485 + expect(root).toMatchRenderedOutput('Loading...');
486 +
487 + jest.advanceTimersByTime(1000);
488 expect(ReactTestRenderer).toHaveYielded([
487 - 'Stateful: 2',
489 + 'Promise resolved [B]',
490 + 'B',
491 + 'B',
492 + ]);
493 + expect(root).toMatchRenderedOutput('Stateful: 2B');
494 + });
495 +
496 + it('when updating a timed-out tree, always retries the suspended component', () => {
497 + let instance;
498 + class Stateful extends React.Component {
499 + state = {step: 1};
500 + render() {
501 + instance = this;
502 + return <Text text={`Stateful: ${this.state.step}`} />;
503 + }
504 + }
505 +
506 + const Indirection = React.Fragment;
507 +
508 + function App(props) {
509 + return (
510 + <Suspense fallback={<Text text="Loading..." />}>
511 + <Stateful />
512 + <Indirection>
513 + <Indirection>
514 + <Indirection>
515 + <AsyncText ms={1000} text={props.text} />
516 + </Indirection>
517 + </Indirection>
518 + </Indirection>
519 + </Suspense>
520 + );
521 + }
522 +
523 + const root = ReactTestRenderer.create(<App text="A" />);
524 +
525 + expect(ReactTestRenderer).toHaveYielded([
526 + 'Stateful: 1',
527 + 'Suspend! [A]',
528 + 'Loading...',
529 + ]);
530 +
531 + jest.advanceTimersByTime(1000);
532 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [A]', 'A']);
533 + expect(root).toMatchRenderedOutput('Stateful: 1A');
534 +
535 + root.update(<App text="B" />);
536 + expect(ReactTestRenderer).toHaveYielded([
537 + 'Stateful: 1',
538 'Suspend! [B]',
539 'Loading...',
540 ]);
541 expect(root).toMatchRenderedOutput('Loading...');
542
543 + instance.setState({step: 2});
544 + expect(ReactTestRenderer).toHaveYielded([
545 + 'Stateful: 2',
546 +
547 + // The suspended component should suspend again. If it doesn't, the
548 + // likely mistake is that the suspended fiber wasn't marked with
549 + // pending work, so it was improperly treated as complete.
550 + 'Suspend! [B]',
551 + ]);
552 + expect(root).toMatchRenderedOutput('Loading...');
553 +
554 jest.advanceTimersByTime(1000);
555 expect(ReactTestRenderer).toHaveYielded([
556 'Promise resolved [B]',
@@ -631,5 +692,92 @@ describe('ReactSuspense', () => {
692 expect(root).toFlushAndYield(['Step: 3']);
693 expect(root).toMatchRenderedOutput('Step: 3');
694 });
695 +
696 + it('does not remount the fallback while suspended children resolve in sync mode', () => {
697 + let mounts = 0;
698 + class ShouldMountOnce extends React.Component {
699 + componentDidMount() {
700 + mounts++;
701 + }
702 + render() {
703 + return <Text text="Loading..." />;
704 + }
705 + }
706 +
707 + function App(props) {
708 + return (
709 + <Suspense maxDuration={10} fallback={<ShouldMountOnce />}>
710 + <AsyncText ms={1000} text="Child 1" />
711 + <AsyncText ms={2000} text="Child 2" />
712 + <AsyncText ms={3000} text="Child 3" />
713 + </Suspense>
714 + );
715 + }
716 +
717 + const root = ReactTestRenderer.create(<App />);
718 +
719 + // Initial render
720 + expect(ReactTestRenderer).toHaveYielded([
721 + 'Suspend! [Child 1]',
722 + 'Suspend! [Child 2]',
723 + 'Suspend! [Child 3]',
724 + 'Loading...',
725 + ]);
726 + expect(root).toFlushAndYield([]);
727 + jest.advanceTimersByTime(1000);
728 + expect(ReactTestRenderer).toHaveYielded([
729 + 'Promise resolved [Child 1]',
730 + 'Child 1',
731 + 'Suspend! [Child 2]',
732 + 'Suspend! [Child 3]',
733 + ]);
734 + jest.advanceTimersByTime(1000);
735 + expect(ReactTestRenderer).toHaveYielded([
736 + 'Promise resolved [Child 2]',
737 + 'Child 2',
738 + 'Suspend! [Child 3]',
739 + // TODO: This suspends twice because there were two pings, once for each
740 + // time Child 2 suspended. This is only an issue in sync mode because
741 + // pings aren't batched.
742 + 'Suspend! [Child 3]',
743 + ]);
744 + jest.advanceTimersByTime(1000);
745 + expect(ReactTestRenderer).toHaveYielded([
746 + 'Promise resolved [Child 3]',
747 + 'Child 3',
748 + ]);
749 + expect(root).toMatchRenderedOutput(
750 + ['Child 1', 'Child 2', 'Child 3'].join(''),
751 + );
752 + expect(mounts).toBe(1);
753 + });
754 +
755 + it('does not get stuck with fallback in concurrent mode for a large delay', () => {
756 + function App(props) {
757 + return (
758 + <Suspense maxDuration={10} fallback={<Text text="Loading..." />}>
759 + <AsyncText ms={1000} text="Child 1" />
760 + <AsyncText ms={7000} text="Child 2" />
761 + </Suspense>
762 + );
763 + }
764 +
765 + const root = ReactTestRenderer.create(<App />, {
766 + unstable_isConcurrent: true,
767 + });
768 +
769 + expect(root).toFlushAndYield([
770 + 'Suspend! [Child 1]',
771 + 'Suspend! [Child 2]',
772 + 'Loading...',
773 + ]);
774 + jest.advanceTimersByTime(1000);
775 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [Child 1]']);
776 + expect(root).toFlushAndYield(['Child 1', 'Suspend! [Child 2]']);
777 + jest.advanceTimersByTime(6000);
778 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [Child 2]']);
779 + expect(root).toFlushAndYield(['Child 1', 'Child 2']);
780 + expect(root).toMatchRenderedOutput(['Child 1', 'Child 2'].join(''));
781 + });
782 });
783 });
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+50 -22
@@ -17,6 +17,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
17 beforeEach(() => {
18 jest.resetModules();
19 ReactFeatureFlags = require('shared/ReactFeatureFlags');
20 + ReactFeatureFlags.enableHooks = true;
21 ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
22 ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
23 React = require('react');
@@ -929,11 +930,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
930 // Suspend during an async render.
931 expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Step: 2]']);
932 expect(ReactNoop.flush()).toEqual([
932 - 'Update did commit',
933 - // Switch to the placeholder in a subsequent commit
933 'Loading (1)',
934 'Loading (2)',
935 'Loading (3)',
936 + 'Update did commit',
937 ]);
938 expect(ReactNoop.getChildrenAsJSX()).toEqual(
939 <React.Fragment>
@@ -1012,12 +1012,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1012 'Before',
1013 'Suspend! [Async: 1]',
1014 'After',
1015 + 'Loading...',
1016 'Before',
1017 'Sync: 1',
1018 'After',
1019 'Did mount',
1019 - // The placeholder is rendered in a subsequent commit
1020 - 'Loading...',
1020 'Promise resolved [Async: 1]',
1021 'Async: 1',
1022 ]);
@@ -1051,14 +1050,12 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1050 ]);
1051 expect(ReactNoop.flush()).toEqual([
1052 'After',
1053 + 'Loading...',
1054 'Before',
1055 'Sync: 2',
1056 'After',
1057 'Update 1 did commit',
1058 'Update 2 did commit',
1059 -
1060 - // Switch to the placeholder in a subsequent commit
1061 - 'Loading...',
1059 ]);
1060 expect(ReactNoop.getChildrenAsJSX()).toEqual(
1061 <React.Fragment>
@@ -1149,12 +1146,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1146 'Before',
1147 'Suspend! [Async: 1]',
1148 'After',
1149 + 'Loading...',
1150 'Before',
1151 'Sync: 1',
1152 'After',
1153 'Did mount',
1156 - // The placeholder is rendered in a subsequent commit
1157 - 'Loading...',
1154 'Promise resolved [Async: 1]',
1155 'Async: 1',
1156 ]);
@@ -1188,14 +1184,12 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1184 ]);
1185 expect(ReactNoop.flush()).toEqual([
1186 'After',
1187 + 'Loading...',
1188 'Before',
1189 'Sync: 2',
1190 'After',
1191 'Update 1 did commit',
1192 'Update 2 did commit',
1196 -
1197 - // Switch to the placeholder in a subsequent commit
1198 - 'Loading...',
1193 ]);
1194 expect(ReactNoop.getChildrenAsJSX()).toEqual(
1195 <React.Fragment>
@@ -1276,16 +1270,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1270 'Suspend! [B]',
1271 'C',
1272
1273 + 'Loading...',
1274 'Mount [A]',
1275 'Mount [B]',
1276 'Mount [C]',
1282 - 'Commit root',
1283 -
1284 - // In a subsequent commit, render a placeholder
1285 - 'Loading...',
1286 - // Force delete all the existing children when switching to the
1287 - // placeholder. This should be a mount, not an update.
1277 + // This should be a mount, not an update.
1278 'Mount [Loading...]',
1279 + 'Commit root',
1280 ]);
1281 expect(ReactNoop.getChildrenAsJSX()).toEqual(
1282 <React.Fragment>
@@ -1388,6 +1379,45 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1379 await advanceTimers(100);
1380 expect(ReactNoop.getChildren()).toEqual([span('Hi')]);
1381 });
1382 +
1383 + it('toggles visibility during the mutation phase', async () => {
1384 + const {useRef, useLayoutEffect} = React;
1385 +
1386 + function Parent() {
1387 + const child = useRef(null);
1388 +
1389 + useLayoutEffect(() => {
1390 + ReactNoop.yield('Child is hidden: ' + child.current.hidden);
1391 + });
1392 +
1393 + return (
1394 + <span ref={child} hidden={false}>
1395 + <AsyncText ms={1000} text="Hi" />
1396 + </span>
1397 + );
1398 + }
1399 +
1400 + function App(props) {
1401 + return (
1402 + <Suspense fallback={<Text text="Loading..." />}>
1403 + <Parent />
1404 + </Suspense>
1405 + );
1406 + }
1407 +
1408 + ReactNoop.renderLegacySyncRoot(<App middleText="B" />);
1409 +
1410 + expect(ReactNoop.clearYields()).toEqual([
1411 + 'Suspend! [Hi]',
1412 + 'Loading...',
1413 + // The child should have already been hidden
1414 + 'Child is hidden: true',
1415 + ]);
1416 +
1417 + await advanceTimers(1000);
1418 +
1419 + expect(ReactNoop.clearYields()).toEqual(['Promise resolved [Hi]', 'Hi']);
1420 + });
1421 });
1422
1423 it('does not call lifecycles of a suspended component', async () => {
@@ -1453,16 +1483,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1483 'A',
1484 'Suspend! [B]',
1485 'C',
1486 + 'Loading...',
1487
1488 'Mount [A]',
1489 // B's lifecycle should not fire because it suspended
1490 // 'Mount [B]',
1491 'Mount [C]',
1461 - 'Commit root',
1462 -
1463 - // In a subsequent commit, render a placeholder
1464 - 'Loading...',
1492 'Mount [Loading...]',
1493 + 'Commit root',
1494 ]);
1495 expect(ReactNoop.getChildrenAsJSX()).toEqual(
1496 <React.Fragment>
packages/react/src/__tests__/ReactProfiler-test.internal.js
+2 -5
@@ -2501,7 +2501,7 @@ describe('Profiler', () => {
2501 expect(renderer.toJSON()).toEqual(['loading', 'initial']);
2502
2503 expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();
2504 - expect(onRender).toHaveBeenCalledTimes(2); // Sync null commit, placeholder commit
2504 + expect(onRender).toHaveBeenCalledTimes(1);
2505 expect(onRender.mock.calls[0][6]).toMatchInteractions([
2506 initialRenderInteraction,
2507 ]);
@@ -2535,13 +2535,10 @@ describe('Profiler', () => {
2535 });
2536 expect(renderer.toJSON()).toEqual(['loading', 'updated']);
2537
2538 - expect(onRender).toHaveBeenCalledTimes(2); // Sync null commit, placeholder commit
2538 + expect(onRender).toHaveBeenCalledTimes(1);
2539 expect(onRender.mock.calls[0][6]).toMatchInteractions([
2540 highPriUpdateInteraction,
2541 ]);
2542 - expect(onRender.mock.calls[1][6]).toMatchInteractions([
2543 - highPriUpdateInteraction,
2544 - ]);
2542 onRender.mockClear();
2543
2544 expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled();