@samitouri / QOS-React / commits / f128fdea48

Suspending outside of strict trees and async trees (#13098)

We can support components that suspend outside of an async mode tree by immediately committing their placeholders. In strict mode, the Timeout acts effectively like an error boundary. Within a single render pass, we unwind to the nearest Timeout and re-render the placeholder view. Outside of strict mode, it's not safe to unwind and re-render the siblings without committing. (Technically, this is true of error boundaries, too, though probably not a huge deal, since we don't support using error boundaries for control flow (yet, at least)). We need to be clever. What we do is pretend the suspended component rendered null.* There's no unwinding. The siblings commit like normal. Then, in the commit phase, schedule an update on the Timeout to synchronously re-render the placeholder. Although this requires an extra commit, it will not be observable. And because the siblings were not blocked from committing, they don't have to be strict mode compatible. Another caveat is that if a component suspends during an async render, but it's captured by a non-async Timeout, we need to revert to sync mode. In other words, if any non-async component renders, the entire tree must complete and commit without yielding. * The downside of rendering null is that the existing children will be deleted. We should hide them instead. I'll work on this in a follow-up.

Andrew Clark committed Jul 3, 2018 at 19:44 UTC f128fdea4840f494991054f608b99757a5ce5764
8 files changed +690 -123
packages/react-noop-renderer/src/createReactNoop.js
+23
@@ -357,6 +357,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
357 ReactNoop.renderToRootWithID(element, DEFAULT_ROOT_ID, callback);
358 },
359
360 + renderLegacySyncRoot(element: React$Element<any>, callback: ?Function) {
361 + const rootID = DEFAULT_ROOT_ID;
362 + let root = roots.get(rootID);
363 + if (!root) {
364 + const container = {rootID: rootID, children: []};
365 + rootContainers.set(rootID, container);
366 + const isAsync = false;
367 + root = NoopRenderer.createContainer(container, isAsync, false);
368 + roots.set(rootID, root);
369 + }
370 + NoopRenderer.updateContainer(element, root, null, callback);
371 + },
372 +
373 renderToRootWithID(
374 element: React$Element<any>,
375 rootID: string,
@@ -444,6 +457,16 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
457 expect(actual).toEqual(expected);
458 },
459
460 + flushNextYield(): Array<mixed> {
461 + let actual = null;
462 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
463 + for (const value of flushUnitsOfWork(Infinity)) {
464 + actual = value;
465 + break;
466 + }
467 + return actual !== null ? actual : [];
468 + },
469 +
470 expire(ms: number): Array<mixed> {
471 ReactNoop.advanceTime(ms);
472 return ReactNoop.flushExpired();
packages/react-reconciler/src/ReactFiber.js
-3
@@ -399,9 +399,6 @@ export function createFiberFromElement(
399 return createFiberFromProfiler(pendingProps, mode, expirationTime, key);
400 case REACT_TIMEOUT_TYPE:
401 fiberTag = TimeoutComponent;
402 - // Suspense does not require async, but its children should be strict
403 - // mode compatible.
404 - mode |= StrictMode;
402 break;
403 default:
404 fiberTag = getFiberTagFromObjectType(type, owner);
packages/react-reconciler/src/ReactFiberBeginWork.js
+38 -15
@@ -120,11 +120,11 @@ function reconcileChildren(current, workInProgress, nextChildren) {
120 );
121 }
122
123 -function reconcileChildrenAtExpirationTime(
124 - current,
125 - workInProgress,
126 - nextChildren,
127 - renderExpirationTime,
123 +export function reconcileChildrenAtExpirationTime(
124 + current: Fiber | null,
125 + workInProgress: Fiber,
126 + nextChildren: any,
127 + renderExpirationTime: ExpirationTime,
128 ) {
129 if (current === null) {
130 // If this is a fresh new component that hasn't been rendered yet, we
@@ -376,7 +376,7 @@ function finishClassComponent(
376
377 // React DevTools reads this flag.
378 workInProgress.effectTag |= PerformedWork;
379 - if (didCaptureError) {
379 + if (current !== null && didCaptureError) {
380 // If we're recovering from an error, reconcile twice: first to delete
381 // all the existing children.
382 reconcileChildrenAtExpirationTime(
@@ -714,7 +714,28 @@ function updateTimeoutComponent(current, workInProgress, renderExpirationTime) {
714 // and we timed out, render the placeholder state.
715 const alreadyCaptured =
716 (workInProgress.effectTag & DidCapture) === NoEffect;
717 - const nextDidTimeout = !alreadyCaptured;
717 +
718 + let nextDidTimeout;
719 + if (current !== null && workInProgress.updateQueue !== null) {
720 + // We're outside strict mode. Something inside this Timeout boundary
721 + // suspended during the last commit. Switch to the placholder.
722 + workInProgress.updateQueue = null;
723 + nextDidTimeout = true;
724 + // If we're recovering from an error, reconcile twice: first to delete
725 + // all the existing children.
726 + reconcileChildrenAtExpirationTime(
727 + current,
728 + workInProgress,
729 + null,
730 + renderExpirationTime,
731 + );
732 + current.child = null;
733 + // Now we can continue reconciling like normal. This has the effect of
734 + // remounting all children regardless of whether their their
735 + // identity matches.
736 + } else {
737 + nextDidTimeout = !alreadyCaptured;
738 + }
739
740 if (hasLegacyContextChanged()) {
741 // Normally we can bail out on props equality but if context has changed
@@ -723,14 +744,16 @@ function updateTimeoutComponent(current, workInProgress, renderExpirationTime) {
744 return bailoutOnAlreadyFinishedWork(current, workInProgress);
745 }
746
726 - if (nextDidTimeout) {
727 - // If the timed-out view commits, schedule an update effect to record
728 - // the committed time.
729 - workInProgress.effectTag |= Update;
730 - } else {
731 - // The state node points to the time at which placeholder timed out.
732 - // We can clear it once we switch back to the normal children.
733 - workInProgress.stateNode = null;
747 + if ((workInProgress.mode & StrictMode) !== NoEffect) {
748 + if (nextDidTimeout) {
749 + // If the timed-out view commits, schedule an update effect to record
750 + // the committed time.
751 + workInProgress.effectTag |= Update;
752 + } else {
753 + // The state node points to the time at which placeholder timed out.
754 + // We can clear it once we switch back to the normal children.
755 + workInProgress.stateNode = null;
756 + }
757 }
758
759 const render = nextProps.children;
packages/react-reconciler/src/ReactFiberCommitWork.js
+20 -2
@@ -32,6 +32,7 @@ import {
32 } from 'shared/ReactTypeOfWork';
33 import ReactErrorUtils from 'shared/ReactErrorUtils';
34 import {
35 + NoEffect,
36 ContentReset,
37 Placement,
38 Snapshot,
@@ -41,6 +42,7 @@ import {commitUpdateQueue} from './ReactUpdateQueue';
42 import invariant from 'shared/invariant';
43 import warning from 'shared/warning';
44
45 +import {Sync} from './ReactFiberExpirationTime';
46 import {onCommitUnmount} from './ReactFiberDevToolsHook';
47 import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
48 import getComponentName from 'shared/getComponentName';
@@ -66,7 +68,9 @@ import {
68 import {
69 captureCommitPhaseError,
70 requestCurrentTime,
71 + scheduleWork,
72 } from './ReactFiberScheduler';
73 +import {StrictMode} from './ReactTypeOfMode';
74
75 const {
76 invokeGuardedCallback,
@@ -74,6 +78,8 @@ const {
78 clearCaughtError,
79 } = ReactErrorUtils;
80
81 +const emptyObject = {};
82 +
83 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
84 if (__DEV__) {
85 didWarnAboutUndefinedSnapshotBeforeUpdate = new Set();
@@ -318,8 +324,20 @@ function commitLifeCycles(
324 }
325 case TimeoutComponent: {
326 if (enableSuspense) {
321 - const currentTime = requestCurrentTime();
322 - finishedWork.stateNode = {timedOutAt: currentTime};
327 + if ((finishedWork.mode & StrictMode) === NoEffect) {
328 + // In loose mode, a placeholder times out by scheduling a synchronous
329 + // update in the commit phase. Use `updateQueue` field to signal that
330 + // the Timeout needs to switch to the placeholder. We don't need an
331 + // entire queue. Any non-null value works.
332 + // $FlowFixMe - Intentionally using a value other than an UpdateQueue.
333 + finishedWork.updateQueue = emptyObject;
334 + scheduleWork(finishedWork, Sync);
335 + } else {
336 + // In strict mode, the Update effect is used to record the time at
337 + // which the placeholder timed out.
338 + const currentTime = requestCurrentTime();
339 + finishedWork.stateNode = {timedOutAt: currentTime};
340 + }
341 }
342 return;
343 }
packages/react-reconciler/src/ReactFiberScheduler.js
+59 -51
@@ -871,6 +871,7 @@ function completeUnitOfWork(workInProgress: Fiber): Fiber | null {
871 if (__DEV__ && ReactFiberInstrumentation.debugTool) {
872 ReactFiberInstrumentation.debugTool.onCompleteWork(workInProgress);
873 }
874 +
875 // If completing this work spawned new work, do that next. We'll come
876 // back here again.
877 // Since we're restarting, remove anything that is not a host effect
@@ -1026,63 +1027,67 @@ function renderRoot(
1027
1028 startWorkLoopTimer(nextUnitOfWork);
1029
1029 - try {
1030 - workLoop(isYieldy);
1031 - } catch (thrownValue) {
1032 - if (enableProfilerTimer) {
1033 - // Stop "base" render timer in the event of an error.
1034 - stopBaseRenderTimerIfRunning();
1035 - }
1036 -
1037 - if (nextUnitOfWork === null) {
1038 - // This is a fatal error.
1039 - didFatal = true;
1040 - onUncaughtError(thrownValue);
1041 - } else {
1042 - if (__DEV__) {
1043 - // Reset global debug state
1044 - // We assume this is defined in DEV
1045 - (resetCurrentlyProcessingQueue: any)();
1046 - }
1047 -
1048 - const failedUnitOfWork: Fiber = nextUnitOfWork;
1049 - if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
1050 - replayUnitOfWork(failedUnitOfWork, thrownValue, isYieldy);
1030 + do {
1031 + try {
1032 + workLoop(isYieldy);
1033 + } catch (thrownValue) {
1034 + if (enableProfilerTimer) {
1035 + // Stop "base" render timer in the event of an error.
1036 + stopBaseRenderTimerIfRunning();
1037 }
1038
1053 - // TODO: we already know this isn't true in some cases.
1054 - // At least this shows a nicer error message until we figure out the cause.
1055 - // https://github.com/facebook/react/issues/12449#issuecomment-386727431
1056 - invariant(
1057 - nextUnitOfWork !== null,
1058 - 'Failed to replay rendering after an error. This ' +
1059 - 'is likely caused by a bug in React. Please file an issue ' +
1060 - 'with a reproducing case to help us find it.',
1061 - );
1062 -
1063 - const sourceFiber: Fiber = nextUnitOfWork;
1064 - let returnFiber = sourceFiber.return;
1065 - if (returnFiber === null) {
1066 - // This is the root. The root could capture its own errors. However,
1067 - // we don't know if it errors before or after we pushed the host
1068 - // context. This information is needed to avoid a stack mismatch.
1069 - // Because we're not sure, treat this as a fatal error. We could track
1070 - // which phase it fails in, but doesn't seem worth it. At least
1071 - // for now.
1039 + if (nextUnitOfWork === null) {
1040 + // This is a fatal error.
1041 didFatal = true;
1042 onUncaughtError(thrownValue);
1043 } else {
1075 - throwException(
1076 - root,
1077 - returnFiber,
1078 - sourceFiber,
1079 - thrownValue,
1080 - nextRenderExpirationTime,
1044 + if (__DEV__) {
1045 + // Reset global debug state
1046 + // We assume this is defined in DEV
1047 + (resetCurrentlyProcessingQueue: any)();
1048 + }
1049 +
1050 + const failedUnitOfWork: Fiber = nextUnitOfWork;
1051 + if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) {
1052 + replayUnitOfWork(failedUnitOfWork, thrownValue, isYieldy);
1053 + }
1054 +
1055 + // TODO: we already know this isn't true in some cases.
1056 + // At least this shows a nicer error message until we figure out the cause.
1057 + // https://github.com/facebook/react/issues/12449#issuecomment-386727431
1058 + invariant(
1059 + nextUnitOfWork !== null,
1060 + 'Failed to replay rendering after an error. This ' +
1061 + 'is likely caused by a bug in React. Please file an issue ' +
1062 + 'with a reproducing case to help us find it.',
1063 );
1082 - nextUnitOfWork = completeUnitOfWork(sourceFiber);
1064 +
1065 + const sourceFiber: Fiber = nextUnitOfWork;
1066 + let returnFiber = sourceFiber.return;
1067 + if (returnFiber === null) {
1068 + // This is the root. The root could capture its own errors. However,
1069 + // we don't know if it errors before or after we pushed the host
1070 + // context. This information is needed to avoid a stack mismatch.
1071 + // Because we're not sure, treat this as a fatal error. We could track
1072 + // which phase it fails in, but doesn't seem worth it. At least
1073 + // for now.
1074 + didFatal = true;
1075 + onUncaughtError(thrownValue);
1076 + } else {
1077 + throwException(
1078 + root,
1079 + returnFiber,
1080 + sourceFiber,
1081 + thrownValue,
1082 + nextRenderExpirationTime,
1083 + );
1084 + nextUnitOfWork = completeUnitOfWork(sourceFiber);
1085 + continue;
1086 + }
1087 }
1088 }
1085 - }
1089 + break;
1090 + } while (true);
1091
1092 // We're done performing work. Time to clean up.
1093 isWorking = false;
@@ -1151,11 +1156,14 @@ function renderRoot(
1156 -1, // Indicates no timeout
1157 );
1158 return;
1154 - } else if (!root.didError && expirationTime !== Sync) {
1159 + } else if (
1160 // There's no lower priority work, but we're rendering asynchronously.
1161 // Synchronsouly attempt to render the same level one more time. This is
1162 // similar to a suspend, but without a timeout because we're not waiting
1163 // for a promise to resolve.
1164 + !root.didError &&
1165 + !isExpired
1166 + ) {
1167 root.didError = true;
1168 const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime);
1169 const rootExpirationTime = (root.expirationTime = Sync);
@@ -1348,7 +1356,7 @@ function renderDidSuspend(
1356 }
1357 }
1358
1351 -function renderDidError(root: FiberRoot) {
1359 +function renderDidError() {
1360 nextRenderDidError = true;
1361 }
1362
packages/react-reconciler/src/ReactFiberUnwindWork.js
+88 -39
@@ -15,6 +15,8 @@ import type {Update} from './ReactUpdateQueue';
15 import type {Thenable} from './ReactFiberScheduler';
16
17 import {
18 + IndeterminateComponent,
19 + FunctionalComponent,
20 ClassComponent,
21 HostRoot,
22 HostComponent,
@@ -27,13 +29,14 @@ import {
29 Incomplete,
30 NoEffect,
31 ShouldCapture,
32 + Update as UpdateEffect,
33 } from 'shared/ReactTypeOfSideEffect';
34 import {
35 enableGetDerivedStateFromCatch,
36 enableProfilerTimer,
37 enableSuspense,
38 } from 'shared/ReactFeatureFlags';
36 -import {ProfileMode} from './ReactTypeOfMode';
39 +import {ProfileMode, StrictMode, AsyncMode} from './ReactTypeOfMode';
40
41 import {createCapturedValue} from './ReactCapturedValue';
42 import {
@@ -60,6 +63,7 @@ import {
63 isAlreadyFailedLegacyErrorBoundary,
64 retrySuspendedRoot,
65 } from './ReactFiberScheduler';
66 +import {Sync} from './ReactFiberExpirationTime';
67
68 import invariant from 'shared/invariant';
69 import maxSigned31BitInt from './maxSigned31BitInt';
@@ -68,6 +72,7 @@ import {
72 LOW_PRIORITY_EXPIRATION,
73 } from './ReactFiberExpirationTime';
74 import {findEarliestOutstandingPriorityLevel} from './ReactFiberPendingPriority';
75 +import {reconcileChildrenAtExpirationTime} from './ReactFiberBeginWork';
76
77 function createRootErrorUpdate(
78 fiber: Fiber,
@@ -194,54 +199,98 @@ function throwException(
199 workInProgress = workInProgress.return;
200 } while (workInProgress !== null);
201
197 - let absoluteTimeoutMs;
198 - if (earliestTimeoutMs === -1) {
199 - // If no explicit threshold is given, default to an abitrarily large
200 - // value. The actual size doesn't matter because the threshold for the
201 - // whole tree will be clamped to the expiration time.
202 - absoluteTimeoutMs = maxSigned31BitInt;
203 - } else {
204 - if (startTimeMs === -1) {
205 - // This suspend happened outside of any already timed-out
206 - // placeholders. We don't know exactly when the update was scheduled,
207 - // but we can infer an approximate start time from the expiration
208 - // time. First, find the earliest uncommitted expiration time in the
209 - // tree, including work that is suspended. Then subtract the offset
210 - // used to compute an async update's expiration time. This will cause
211 - // high priority (interactive) work to expire earlier than neccessary,
212 - // but we can account for this by adjusting for the Just Noticable
213 - // Difference.
214 - const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
215 - root,
216 - renderExpirationTime,
217 - );
218 - const earliestExpirationTimeMs = expirationTimeToMs(
219 - earliestExpirationTime,
220 - );
221 - startTimeMs = earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
222 - }
223 - absoluteTimeoutMs = startTimeMs + earliestTimeoutMs;
224 - }
225 -
226 - // Mark the earliest timeout in the suspended fiber's ancestor path. After
227 - // completing the root, we'll take the largest of all the suspended
228 - // fiber's timeouts and use it to compute a timeout for the whole tree.
229 - renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime);
230 -
202 // Schedule the nearest Timeout to re-render using a placeholder.
203 workInProgress = returnFiber;
204 do {
205 if (workInProgress.tag === TimeoutComponent) {
235 - if ((workInProgress.effectTag & DidCapture) === NoEffect) {
236 - workInProgress.effectTag |= ShouldCapture;
206 + const didTimeout = workInProgress.memoizedState;
207 + if (!didTimeout) {
208 + // Found the nearest Timeout.
209 +
210 + // If the Timeout is not in async mode, we should not suspend, and
211 + // likewise, when the promise resolves, we should ping synchronously.
212 + const pingTime =
213 + (workInProgress.mode & AsyncMode) === NoEffect
214 + ? Sync
215 + : renderExpirationTime;
216 +
217 // Attach a listener to the promise to "ping" the root and retry.
218 const onResolveOrReject = retrySuspendedRoot.bind(
219 null,
220 root,
221 workInProgress,
242 - renderExpirationTime,
222 + pingTime,
223 );
224 thenable.then(onResolveOrReject, onResolveOrReject);
225 +
226 + // If the Timeout component is outside of strict mode, we should *not*
227 + // suspend the commit. Pretend as if the suspended component rendered
228 + // null and keep rendering. In the commit phase, we'll schedule a
229 + // subsequent synchronous update to re-render the Timeout.
230 + //
231 + // Note: It doesn't matter whether the component that suspended was
232 + // inside a strict mode tree. If the Timeout is outside of it, we
233 + // should *not* suspend the commit.
234 + if ((workInProgress.mode & StrictMode) === NoEffect) {
235 + workInProgress.effectTag |= UpdateEffect;
236 +
237 + // Unmount the source fiber's children
238 + const nextChildren = null;
239 + reconcileChildrenAtExpirationTime(
240 + sourceFiber.alternate,
241 + sourceFiber,
242 + nextChildren,
243 + renderExpirationTime,
244 + );
245 + sourceFiber.effectTag &= ~Incomplete;
246 + if (sourceFiber.tag === IndeterminateComponent) {
247 + // Let's just assume it's a functional component. This fiber will
248 + // be unmounted in the immediate next commit, anyway.
249 + sourceFiber.tag = FunctionalComponent;
250 + }
251 +
252 + // Exit without suspending.
253 + return;
254 + }
255 +
256 + // Confirmed that the Timeout is in a strict mode tree. Continue with
257 + // the normal suspend path.
258 +
259 + let absoluteTimeoutMs;
260 + if (earliestTimeoutMs === -1) {
261 + // If no explicit threshold is given, default to an abitrarily large
262 + // value. The actual size doesn't matter because the threshold for the
263 + // whole tree will be clamped to the expiration time.
264 + absoluteTimeoutMs = maxSigned31BitInt;
265 + } else {
266 + if (startTimeMs === -1) {
267 + // This suspend happened outside of any already timed-out
268 + // placeholders. We don't know exactly when the update was scheduled,
269 + // but we can infer an approximate start time from the expiration
270 + // time. First, find the earliest uncommitted expiration time in the
271 + // tree, including work that is suspended. Then subtract the offset
272 + // used to compute an async update's expiration time. This will cause
273 + // high priority (interactive) work to expire earlier than neccessary,
274 + // but we can account for this by adjusting for the Just Noticable
275 + // Difference.
276 + const earliestExpirationTime = findEarliestOutstandingPriorityLevel(
277 + root,
278 + renderExpirationTime,
279 + );
280 + const earliestExpirationTimeMs = expirationTimeToMs(
281 + earliestExpirationTime,
282 + );
283 + startTimeMs = earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION;
284 + }
285 + absoluteTimeoutMs = startTimeMs + earliestTimeoutMs;
286 + }
287 +
288 + // Mark the earliest timeout in the suspended fiber's ancestor path. After
289 + // completing the root, we'll take the largest of all the suspended
290 + // fiber's timeouts and use it to compute a timeout for the whole tree.
291 + renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime);
292 +
293 + workInProgress.effectTag |= ShouldCapture;
294 return;
295 }
296 // Already captured during this render. Continue to the next
@@ -258,7 +307,7 @@ function throwException(
307 // We didn't find a boundary that could handle this type of exception. Start
308 // over and traverse parent path again, this time treating the exception
309 // as an error.
261 - renderDidError(root);
310 + renderDidError();
311 value = createCapturedValue(value, sourceFiber);
312 let workInProgress = returnFiber;
313 do {
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+459
@@ -4,6 +4,8 @@ let Fragment;
4 let ReactNoop;
5 let SimpleCacheProvider;
6 let Timeout;
7 +let StrictMode;
8 +let AsyncMode;
9
10 let cache;
11 let TextResource;
@@ -21,6 +23,8 @@ describe('ReactSuspense', () => {
23 ReactNoop = require('react-noop-renderer');
24 SimpleCacheProvider = require('simple-cache-provider');
25 Timeout = React.Timeout;
26 + StrictMode = React.StrictMode;
27 + AsyncMode = React.unstable_AsyncMode;
28
29 function invalidateCache() {
30 cache = SimpleCacheProvider.createCache(invalidateCache);
@@ -829,6 +833,461 @@ describe('ReactSuspense', () => {
833 expect(ReactNoop.getChildren()).toEqual([span('A')]);
834 });
835 });
836 +
837 + describe('sync mode', () => {
838 + it('times out immediately', async () => {
839 + function App() {
840 + return (
841 + <Fallback timeout={1000} placeholder={<Text text="Loading..." />}>
842 + <AsyncText ms={100} text="Result" />
843 + </Fallback>
844 + );
845 + }
846 +
847 + // Times out immediately, ignoring the specified threshold.
848 + ReactNoop.renderLegacySyncRoot(<App />);
849 + expect(ReactNoop.clearYields()).toEqual([
850 + 'Suspend! [Result]',
851 + 'Loading...',
852 + ]);
853 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
854 +
855 + await advanceTimers(100);
856 + expect(ReactNoop.expire(100)).toEqual([
857 + 'Promise resolved [Result]',
858 + 'Result',
859 + ]);
860 +
861 + expect(ReactNoop.getChildren()).toEqual([span('Result')]);
862 + });
863 +
864 + it('times out immediately when Timeout is in loose mode, even if the suspender is async', async () => {
865 + class UpdatingText extends React.Component {
866 + state = {step: 1};
867 + render() {
868 + return <AsyncText ms={100} text={`Step: ${this.state.step}`} />;
869 + }
870 + }
871 +
872 + function Placeholder() {
873 + return (
874 + <Fragment>
875 + <Text text="Loading (1)" />
876 + <Text text="Loading (2)" />
877 + <Text text="Loading (3)" />
878 + </Fragment>
879 + );
880 + }
881 +
882 + const text = React.createRef(null);
883 + function App() {
884 + return (
885 + <Fallback timeout={1000} placeholder={<Placeholder />}>
886 + <AsyncMode>
887 + <UpdatingText ref={text} />
888 + <Text text="Sibling" />
889 + </AsyncMode>
890 + </Fallback>
891 + );
892 + }
893 +
894 + // Initial mount. This is synchronous, because the root is sync.
895 + ReactNoop.renderLegacySyncRoot(<App />);
896 + await advanceTimers(100);
897 + expect(ReactNoop.clearYields()).toEqual([
898 + 'Suspend! [Step: 1]',
899 + 'Sibling',
900 + 'Loading (1)',
901 + 'Loading (2)',
902 + 'Loading (3)',
903 + 'Promise resolved [Step: 1]',
904 + 'Step: 1',
905 + 'Sibling',
906 + ]);
907 + expect(ReactNoop.getChildren()).toEqual([
908 + span('Step: 1'),
909 + span('Sibling'),
910 + ]);
911 +
912 + // Update. This starts out asynchronously.
913 + text.current.setState({step: 2}, () =>
914 + ReactNoop.yield('Update did commit'),
915 + );
916 +
917 + // Suspend during an async render.
918 + expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Step: 2]']);
919 + expect(ReactNoop.flush()).toEqual([
920 + 'Update did commit',
921 + // Switch to the placeholder in a subsequent commit
922 + 'Loading (1)',
923 + 'Loading (2)',
924 + 'Loading (3)',
925 + ]);
926 + expect(ReactNoop.getChildren()).toEqual([
927 + span('Loading (1)'),
928 + span('Loading (2)'),
929 + span('Loading (3)'),
930 + ]);
931 +
932 + await advanceTimers(100);
933 + expect(ReactNoop.flush()).toEqual([
934 + 'Promise resolved [Step: 2]',
935 + // TODO: The state of the children is lost when switching back. Revisit
936 + // this in the follow up PR.
937 + 'Step: 1',
938 + 'Sibling',
939 + ]);
940 + expect(ReactNoop.getChildren()).toEqual([
941 + span('Step: 1'),
942 + span('Sibling'),
943 + ]);
944 + });
945 +
946 + it(
947 + 'continues rendering asynchronously even if a promise is captured by ' +
948 + 'a sync boundary (strict)',
949 + async () => {
950 + class UpdatingText extends React.Component {
951 + state = {text: this.props.initialText};
952 + render() {
953 + return this.props.children(this.state.text);
954 + }
955 + }
956 +
957 + const text1 = React.createRef(null);
958 + const text2 = React.createRef(null);
959 + function App() {
960 + return (
961 + <StrictMode>
962 + <Fallback timeout={1000} placeholder={<Text text="Loading..." />}>
963 + <AsyncMode>
964 + <UpdatingText ref={text1} initialText="Async: 1">
965 + {text => (
966 + <Fragment>
967 + <Text text="Before" />
968 + <AsyncText text={text} />
969 + <Text text="After" />
970 + </Fragment>
971 + )}
972 + </UpdatingText>
973 + </AsyncMode>
974 + </Fallback>
975 + <AsyncMode>
976 + <UpdatingText ref={text2} initialText="Sync: 1">
977 + {text => (
978 + <Fragment>
979 + <Text text="Before" />
980 + <Text text={text} />
981 + <Text text="After" />
982 + </Fragment>
983 + )}
984 + </UpdatingText>
985 + </AsyncMode>
986 + </StrictMode>
987 + );
988 + }
989 +
990 + // Initial mount
991 + ReactNoop.renderLegacySyncRoot(<App />, () =>
992 + ReactNoop.yield('Did mount'),
993 + );
994 + await advanceTimers(100);
995 + expect(ReactNoop.clearYields()).toEqual([
996 + 'Before',
997 + 'Suspend! [Async: 1]',
998 + 'After',
999 + 'Loading...',
1000 + 'Before',
1001 + 'Sync: 1',
1002 + 'After',
1003 + 'Did mount',
1004 + 'Promise resolved [Async: 1]',
1005 + 'Before',
1006 + 'Async: 1',
1007 + 'After',
1008 + ]);
1009 + expect(ReactNoop.getChildren()).toEqual([
1010 + span('Before'),
1011 + span('Async: 1'),
1012 + span('After'),
1013 +
1014 + span('Before'),
1015 + span('Sync: 1'),
1016 + span('After'),
1017 + ]);
1018 +
1019 + // Update. This starts out asynchronously.
1020 + text1.current.setState({text: 'Async: 2'}, () =>
1021 + ReactNoop.yield('Update 1 did commit'),
1022 + );
1023 + text2.current.setState({text: 'Sync: 2'}, () =>
1024 + ReactNoop.yield('Update 2 did commit'),
1025 + );
1026 +
1027 + // Start rendering asynchronously
1028 + ReactNoop.flushThrough([
1029 + 'Before',
1030 + // This child suspends
1031 + 'Suspend! [Async: 2]',
1032 + // But we can still render the rest of the async tree asynchronously
1033 + 'After',
1034 + ]);
1035 +
1036 + // Suspend during an async render.
1037 + expect(ReactNoop.flushNextYield()).toEqual(['Loading...']);
1038 + expect(ReactNoop.flush()).toEqual(['Before', 'Sync: 2', 'After']);
1039 + // Commit was suspended.
1040 + expect(ReactNoop.getChildren()).toEqual([
1041 + span('Before'),
1042 + span('Async: 1'),
1043 + span('After'),
1044 +
1045 + span('Before'),
1046 + span('Sync: 1'),
1047 + span('After'),
1048 + ]);
1049 +
1050 + // When the placeholder is pinged, the boundary re-
1051 + // renders asynchronously.
1052 + ReactNoop.expire(100);
1053 + await advanceTimers(100);
1054 + expect(ReactNoop.flush()).toEqual([
1055 + 'Promise resolved [Async: 2]',
1056 + 'Before',
1057 + 'Async: 2',
1058 + 'After',
1059 + 'Before',
1060 + 'Sync: 2',
1061 + 'After',
1062 + 'Update 1 did commit',
1063 + 'Update 2 did commit',
1064 + ]);
1065 +
1066 + expect(ReactNoop.getChildren()).toEqual([
1067 + span('Before'),
1068 + span('Async: 2'),
1069 + span('After'),
1070 +
1071 + span('Before'),
1072 + span('Sync: 2'),
1073 + span('After'),
1074 + ]);
1075 + },
1076 + );
1077 +
1078 + it(
1079 + 'continues rendering asynchronously even if a promise is captured by ' +
1080 + 'a sync boundary (loose)',
1081 + async () => {
1082 + class UpdatingText extends React.Component {
1083 + state = {text: this.props.initialText};
1084 + render() {
1085 + return this.props.children(this.state.text);
1086 + }
1087 + }
1088 +
1089 + const text1 = React.createRef(null);
1090 + const text2 = React.createRef(null);
1091 + function App() {
1092 + return (
1093 + <Fragment>
1094 + <Fallback timeout={1000} placeholder={<Text text="Loading..." />}>
1095 + <AsyncMode>
1096 + <UpdatingText ref={text1} initialText="Async: 1">
1097 + {text => (
1098 + <Fragment>
1099 + <Text text="Before" />
1100 + <AsyncText text={text} />
1101 + <Text text="After" />
1102 + </Fragment>
1103 + )}
1104 + </UpdatingText>
1105 + </AsyncMode>
1106 + </Fallback>
1107 + <AsyncMode>
1108 + <UpdatingText ref={text2} initialText="Sync: 1">
1109 + {text => (
1110 + <Fragment>
1111 + <Text text="Before" />
1112 + <Text text={text} />
1113 + <Text text="After" />
1114 + </Fragment>
1115 + )}
1116 + </UpdatingText>
1117 + </AsyncMode>
1118 + </Fragment>
1119 + );
1120 + }
1121 +
1122 + // Initial mount
1123 + ReactNoop.renderLegacySyncRoot(<App />, () =>
1124 + ReactNoop.yield('Did mount'),
1125 + );
1126 + await advanceTimers(100);
1127 + expect(ReactNoop.clearYields()).toEqual([
1128 + 'Before',
1129 + 'Suspend! [Async: 1]',
1130 + 'After',
1131 + 'Before',
1132 + 'Sync: 1',
1133 + 'After',
1134 + 'Did mount',
1135 + // The placeholder is rendered in a subsequent commit
1136 + 'Loading...',
1137 + 'Promise resolved [Async: 1]',
1138 + 'Before',
1139 + 'Async: 1',
1140 + 'After',
1141 + ]);
1142 + expect(ReactNoop.getChildren()).toEqual([
1143 + span('Before'),
1144 + span('Async: 1'),
1145 + span('After'),
1146 +
1147 + span('Before'),
1148 + span('Sync: 1'),
1149 + span('After'),
1150 + ]);
1151 +
1152 + // Update. This starts out asynchronously.
1153 + text1.current.setState({text: 'Async: 2'}, () =>
1154 + ReactNoop.yield('Update 1 did commit'),
1155 + );
1156 + text2.current.setState({text: 'Sync: 2'}, () =>
1157 + ReactNoop.yield('Update 2 did commit'),
1158 + );
1159 +
1160 + // Start rendering asynchronously
1161 + ReactNoop.flushThrough(['Before']);
1162 +
1163 + // Now render the next child, which suspends
1164 + expect(ReactNoop.flushNextYield()).toEqual([
1165 + // This child suspends
1166 + 'Suspend! [Async: 2]',
1167 + ]);
1168 + expect(ReactNoop.flush()).toEqual([
1169 + 'After',
1170 + 'Before',
1171 + 'Sync: 2',
1172 + 'After',
1173 + 'Update 1 did commit',
1174 + 'Update 2 did commit',
1175 +
1176 + // Switch to the placeholder in a subsequent commit
1177 + 'Loading...',
1178 + ]);
1179 + expect(ReactNoop.getChildren()).toEqual([
1180 + span('Loading...'),
1181 +
1182 + span('Before'),
1183 + span('Sync: 2'),
1184 + span('After'),
1185 + ]);
1186 +
1187 + // When the placeholder is pinged, the boundary must be re-rendered
1188 + // synchronously.
1189 + await advanceTimers(100);
1190 + expect(ReactNoop.clearYields()).toEqual([
1191 + 'Promise resolved [Async: 2]',
1192 + 'Before',
1193 + 'Async: 1',
1194 + 'After',
1195 + ]);
1196 +
1197 + expect(ReactNoop.getChildren()).toEqual([
1198 + span('Before'),
1199 + // TODO: The state of the children is lost when switching back. Revisit
1200 + // this in the follow up PR.
1201 + span('Async: 1'),
1202 + span('After'),
1203 +
1204 + span('Before'),
1205 + span('Sync: 2'),
1206 + span('After'),
1207 + ]);
1208 + },
1209 + );
1210 +
1211 + it('does not re-render siblings in loose mode', async () => {
1212 + class TextWithLifecycle extends React.Component {
1213 + componentDidMount() {
1214 + ReactNoop.yield(`Mount [${this.props.text}]`);
1215 + }
1216 + componentDidUpdate() {
1217 + ReactNoop.yield(`Update [${this.props.text}]`);
1218 + }
1219 + render() {
1220 + return <Text {...this.props} />;
1221 + }
1222 + }
1223 +
1224 + class AsyncTextWithLifecycle extends React.Component {
1225 + componentDidMount() {
1226 + ReactNoop.yield(`Mount [${this.props.text}]`);
1227 + }
1228 + componentDidUpdate() {
1229 + ReactNoop.yield(`Update [${this.props.text}]`);
1230 + }
1231 + render() {
1232 + return <AsyncText {...this.props} />;
1233 + }
1234 + }
1235 +
1236 + function App() {
1237 + return (
1238 + <Fallback
1239 + timeout={1000}
1240 + placeholder={<TextWithLifecycle text="Loading..." />}>
1241 + <TextWithLifecycle text="A" />
1242 + <AsyncTextWithLifecycle ms={100} text="B" />
1243 + <TextWithLifecycle text="C" />
1244 + </Fallback>
1245 + );
1246 + }
1247 +
1248 + ReactNoop.renderLegacySyncRoot(<App />, () =>
1249 + ReactNoop.yield('Commit root'),
1250 + );
1251 + expect(ReactNoop.clearYields()).toEqual([
1252 + 'A',
1253 + 'Suspend! [B]',
1254 + 'C',
1255 +
1256 + 'Mount [A]',
1257 + 'Mount [B]',
1258 + 'Mount [C]',
1259 + 'Commit root',
1260 +
1261 + // In a subsequent commit, render a placeholder
1262 + 'Loading...',
1263 + // Force delete all the existing children when switching to the
1264 + // placeholder. This should be a mount, not an update.
1265 + 'Mount [Loading...]',
1266 + ]);
1267 + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1268 +
1269 + await advanceTimers(1000);
1270 + expect(ReactNoop.expire(1000)).toEqual([
1271 + 'Promise resolved [B]',
1272 + 'A',
1273 + 'B',
1274 + 'C',
1275 + // 'A' matched with the placeholder. It's ok to reuse children when
1276 + // switching back. Though in a real app you probably don't want to.
1277 + // TODO: This is wrong. The timed out children and the placeholder
1278 + // should be siblings in async mode. Revisit in follow-up PR.
1279 + 'Update [A]',
1280 + 'Mount [B]',
1281 + 'Mount [C]',
1282 + ]);
1283 +
1284 + expect(ReactNoop.getChildren()).toEqual([
1285 + span('A'),
1286 + span('B'),
1287 + span('C'),
1288 + ]);
1289 + });
1290 + });
1291 });
1292
1293 // TODO:
packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap
+3 -13
@@ -286,24 +286,18 @@ exports[`ReactDebugFiberPerf recovers from caught errors 1`] = `
286 "⚛ (Waiting for async callback... will force flush in 5250 ms)
287
288 // Stop on Baddie and restart from Boundary
289 -⚛ (React Tree Reconciliation: Yielded)
289 +⚛ (React Tree Reconciliation: Completed Root)
290 ⚛ Parent [mount]
291 ⛔ Boundary [mount] Warning: An error was thrown inside this error boundary
292 ⚛ Parent [mount]
293 ⚛ Baddie [mount]
294 -
295 -⚛ (React Tree Reconciliation: Completed Root)
296 - ⚛ Parent [mount]
294 ⚛ Boundary [mount]
295
299 -⚛ (React Tree Reconciliation: Yielded)
296 +⚛ (React Tree Reconciliation: Completed Root)
297 ⚛ Parent [mount]
298 ⛔ Boundary [mount] Warning: An error was thrown inside this error boundary
299 ⚛ Parent [mount]
300 ⚛ Baddie [mount]
304 -
305 -⚛ (React Tree Reconciliation: Completed Root)
306 - ⚛ Parent [mount]
301 ⚛ Boundary [mount]
302
303 ⛔ (Committing Changes) Warning: Lifecycle hook scheduled a cascading update
@@ -326,18 +320,14 @@ exports[`ReactDebugFiberPerf recovers from fatal errors 1`] = `
320 "⚛ (Waiting for async callback... will force flush in 5250 ms)
321
322 // Will fatal
329 -⚛ (React Tree Reconciliation: Yielded)
323 +⚛ (React Tree Reconciliation: Completed Root)
324 ⚛ Parent [mount]
325 ⚛ Baddie [mount]
326
327 ⚛ (React Tree Reconciliation: Completed Root)
334 -
335 -⚛ (React Tree Reconciliation: Yielded)
328 ⚛ Parent [mount]
329 ⚛ Baddie [mount]
330
339 -⚛ (React Tree Reconciliation: Completed Root)
340 -
331 ⚛ (Committing Changes)
332 ⚛ (Committing Snapshot Effects: 0 Total)
333 ⚛ (Committing Host Effects: 1 Total)