@samitouri / QOS-React / commits / 796fff5483

Allow suspending outside a Suspense boundary (#23267)

(If the update is wrapped in startTransition) Currently you're not allowed to suspend outside of a Suspense boundary. We throw an error: > A React component suspended while rendering, but no fallback UI was specified We treat this case like an error because discrete renders are expected to finish synchronously to maintain consistency with external state. However, during a concurrent transition (startTransition), what we can do instead is treat this case like a refresh transition: suspend the commit without showing a fallback. The behavior is roughly as if there were a built-in Suspense boundary at the root of the app with unstable_avoidThisFallback enabled. Conceptually it's very similar because during hydration you're already showing server-rendered UI; there's no need to replace that with a fallback when something suspends.

Andrew Clark committed Feb 11, 2022 at 11:26 UTC 796fff5483925f90db5fe705af6d8b06fce661a5
7 files changed +414 -182
packages/react-reconciler/src/ReactFiberThrow.new.js
+60 -35
@@ -62,6 +62,7 @@ import {
62 } from './ReactFiberSuspenseContext.new';
63 import {
64 renderDidError,
65 + renderDidSuspendDelayIfPossible,
66 onUncaughtError,
67 markLegacyErrorBoundaryAsFailed,
68 isAlreadyFailedLegacyErrorBoundary,
@@ -78,6 +79,7 @@ import {
79 includesSomeLane,
80 mergeLanes,
81 pickArbitraryLane,
82 + includesOnlyTransitions,
83 } from './ReactFiberLane.new';
84 import {
85 getIsHydrating,
@@ -165,12 +167,7 @@ function createClassErrorUpdate(
167 return update;
168 }
169
168 -function attachWakeableListeners(
169 - suspenseBoundary: Fiber,
170 - root: FiberRoot,
171 - wakeable: Wakeable,
172 - lanes: Lanes,
173 -) {
170 +function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
171 // Attach a ping listener
172 //
173 // The data might resolve before we have a chance to commit the fallback. Or,
@@ -183,34 +180,39 @@ function attachWakeableListeners(
180 //
181 // We only need to do this in concurrent mode. Legacy Suspense always
182 // commits fallbacks synchronously, so there are no pings.
186 - if (suspenseBoundary.mode & ConcurrentMode) {
187 - let pingCache = root.pingCache;
188 - let threadIDs;
189 - if (pingCache === null) {
190 - pingCache = root.pingCache = new PossiblyWeakMap();
183 + let pingCache = root.pingCache;
184 + let threadIDs;
185 + if (pingCache === null) {
186 + pingCache = root.pingCache = new PossiblyWeakMap();
187 + threadIDs = new Set();
188 + pingCache.set(wakeable, threadIDs);
189 + } else {
190 + threadIDs = pingCache.get(wakeable);
191 + if (threadIDs === undefined) {
192 threadIDs = new Set();
193 pingCache.set(wakeable, threadIDs);
193 - } else {
194 - threadIDs = pingCache.get(wakeable);
195 - if (threadIDs === undefined) {
196 - threadIDs = new Set();
197 - pingCache.set(wakeable, threadIDs);
198 - }
194 }
200 - if (!threadIDs.has(lanes)) {
201 - // Memoize using the thread ID to prevent redundant listeners.
202 - threadIDs.add(lanes);
203 - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
204 - if (enableUpdaterTracking) {
205 - if (isDevToolsPresent) {
206 - // If we have pending work still, restore the original updaters
207 - restorePendingUpdaters(root, lanes);
208 - }
195 + }
196 + if (!threadIDs.has(lanes)) {
197 + // Memoize using the thread ID to prevent redundant listeners.
198 + threadIDs.add(lanes);
199 + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
200 + if (enableUpdaterTracking) {
201 + if (isDevToolsPresent) {
202 + // If we have pending work still, restore the original updaters
203 + restorePendingUpdaters(root, lanes);
204 }
210 - wakeable.then(ping, ping);
205 }
206 + wakeable.then(ping, ping);
207 }
208 +}
209
210 +function attachRetryListener(
211 + suspenseBoundary: Fiber,
212 + root: FiberRoot,
213 + wakeable: Wakeable,
214 + lanes: Lanes,
215 +) {
216 // Retry listener
217 //
218 // If the fallback does commit, we need to attach a different type of
@@ -470,24 +472,47 @@ function throwException(
472 root,
473 rootRenderLanes,
474 );
473 - attachWakeableListeners(
474 - suspenseBoundary,
475 - root,
476 - wakeable,
477 - rootRenderLanes,
478 - );
475 + // We only attach ping listeners in concurrent mode. Legacy Suspense always
476 + // commits fallbacks synchronously, so there are no pings.
477 + if (suspenseBoundary.mode & ConcurrentMode) {
478 + attachPingListener(root, wakeable, rootRenderLanes);
479 + }
480 + attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
481 return;
482 } else {
481 - // No boundary was found. Fallthrough to error mode.
483 + // No boundary was found. If we're inside startTransition, this is OK.
484 + // We can suspend and wait for more data to arrive.
485 +
486 + if (includesOnlyTransitions(rootRenderLanes)) {
487 + // This is a transition. Suspend. Since we're not activating a Suspense
488 + // boundary, this will unwind all the way to the root without performing
489 + // a second pass to render a fallback. (This is arguably how refresh
490 + // transitions should work, too, since we're not going to commit the
491 + // fallbacks anyway.)
492 + attachPingListener(root, wakeable, rootRenderLanes);
493 + renderDidSuspendDelayIfPossible();
494 + return;
495 + }
496 +
497 + // We're not in a transition. We treat this case like an error because
498 + // discrete renders are expected to finish synchronously to maintain
499 + // consistency with external state.
500 + // TODO: This will error during non-transition concurrent renders, too.
501 + // But maybe it shouldn't?
502 +
503 // TODO: We should never call getComponentNameFromFiber in production.
504 // Log a warning or something to prevent us from accidentally bundling it.
484 - value = new Error(
505 + const uncaughtSuspenseError = new Error(
506 (getComponentNameFromFiber(sourceFiber) || 'A React component') +
507 ' suspended while rendering, but no fallback UI was specified.\n' +
508 '\n' +
509 'Add a <Suspense fallback=...> component higher in the tree to ' +
510 'provide a loading indicator or placeholder to display.',
511 );
512 +
513 + // If we're outside a transition, fall through to the regular error path.
514 + // The error will be caught by the nearest suspense boundary.
515 + value = uncaughtSuspenseError;
516 }
517 } else {
518 // This is a regular error, not a Suspense wakeable.
packages/react-reconciler/src/ReactFiberThrow.old.js
+60 -35
@@ -62,6 +62,7 @@ import {
62 } from './ReactFiberSuspenseContext.old';
63 import {
64 renderDidError,
65 + renderDidSuspendDelayIfPossible,
66 onUncaughtError,
67 markLegacyErrorBoundaryAsFailed,
68 isAlreadyFailedLegacyErrorBoundary,
@@ -78,6 +79,7 @@ import {
79 includesSomeLane,
80 mergeLanes,
81 pickArbitraryLane,
82 + includesOnlyTransitions,
83 } from './ReactFiberLane.old';
84 import {
85 getIsHydrating,
@@ -165,12 +167,7 @@ function createClassErrorUpdate(
167 return update;
168 }
169
168 -function attachWakeableListeners(
169 - suspenseBoundary: Fiber,
170 - root: FiberRoot,
171 - wakeable: Wakeable,
172 - lanes: Lanes,
173 -) {
170 +function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
171 // Attach a ping listener
172 //
173 // The data might resolve before we have a chance to commit the fallback. Or,
@@ -183,34 +180,39 @@ function attachWakeableListeners(
180 //
181 // We only need to do this in concurrent mode. Legacy Suspense always
182 // commits fallbacks synchronously, so there are no pings.
186 - if (suspenseBoundary.mode & ConcurrentMode) {
187 - let pingCache = root.pingCache;
188 - let threadIDs;
189 - if (pingCache === null) {
190 - pingCache = root.pingCache = new PossiblyWeakMap();
183 + let pingCache = root.pingCache;
184 + let threadIDs;
185 + if (pingCache === null) {
186 + pingCache = root.pingCache = new PossiblyWeakMap();
187 + threadIDs = new Set();
188 + pingCache.set(wakeable, threadIDs);
189 + } else {
190 + threadIDs = pingCache.get(wakeable);
191 + if (threadIDs === undefined) {
192 threadIDs = new Set();
193 pingCache.set(wakeable, threadIDs);
193 - } else {
194 - threadIDs = pingCache.get(wakeable);
195 - if (threadIDs === undefined) {
196 - threadIDs = new Set();
197 - pingCache.set(wakeable, threadIDs);
198 - }
194 }
200 - if (!threadIDs.has(lanes)) {
201 - // Memoize using the thread ID to prevent redundant listeners.
202 - threadIDs.add(lanes);
203 - const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
204 - if (enableUpdaterTracking) {
205 - if (isDevToolsPresent) {
206 - // If we have pending work still, restore the original updaters
207 - restorePendingUpdaters(root, lanes);
208 - }
195 + }
196 + if (!threadIDs.has(lanes)) {
197 + // Memoize using the thread ID to prevent redundant listeners.
198 + threadIDs.add(lanes);
199 + const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
200 + if (enableUpdaterTracking) {
201 + if (isDevToolsPresent) {
202 + // If we have pending work still, restore the original updaters
203 + restorePendingUpdaters(root, lanes);
204 }
210 - wakeable.then(ping, ping);
205 }
206 + wakeable.then(ping, ping);
207 }
208 +}
209
210 +function attachRetryListener(
211 + suspenseBoundary: Fiber,
212 + root: FiberRoot,
213 + wakeable: Wakeable,
214 + lanes: Lanes,
215 +) {
216 // Retry listener
217 //
218 // If the fallback does commit, we need to attach a different type of
@@ -470,24 +472,47 @@ function throwException(
472 root,
473 rootRenderLanes,
474 );
473 - attachWakeableListeners(
474 - suspenseBoundary,
475 - root,
476 - wakeable,
477 - rootRenderLanes,
478 - );
475 + // We only attach ping listeners in concurrent mode. Legacy Suspense always
476 + // commits fallbacks synchronously, so there are no pings.
477 + if (suspenseBoundary.mode & ConcurrentMode) {
478 + attachPingListener(root, wakeable, rootRenderLanes);
479 + }
480 + attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
481 return;
482 } else {
481 - // No boundary was found. Fallthrough to error mode.
483 + // No boundary was found. If we're inside startTransition, this is OK.
484 + // We can suspend and wait for more data to arrive.
485 +
486 + if (includesOnlyTransitions(rootRenderLanes)) {
487 + // This is a transition. Suspend. Since we're not activating a Suspense
488 + // boundary, this will unwind all the way to the root without performing
489 + // a second pass to render a fallback. (This is arguably how refresh
490 + // transitions should work, too, since we're not going to commit the
491 + // fallbacks anyway.)
492 + attachPingListener(root, wakeable, rootRenderLanes);
493 + renderDidSuspendDelayIfPossible();
494 + return;
495 + }
496 +
497 + // We're not in a transition. We treat this case like an error because
498 + // discrete renders are expected to finish synchronously to maintain
499 + // consistency with external state.
500 + // TODO: This will error during non-transition concurrent renders, too.
501 + // But maybe it shouldn't?
502 +
503 // TODO: We should never call getComponentNameFromFiber in production.
504 // Log a warning or something to prevent us from accidentally bundling it.
484 - value = new Error(
505 + const uncaughtSuspenseError = new Error(
506 (getComponentNameFromFiber(sourceFiber) || 'A React component') +
507 ' suspended while rendering, but no fallback UI was specified.\n' +
508 '\n' +
509 'Add a <Suspense fallback=...> component higher in the tree to ' +
510 'provide a loading indicator or placeholder to display.',
511 );
512 +
513 + // If we're outside a transition, fall through to the regular error path.
514 + // The error will be caught by the nearest suspense boundary.
515 + value = uncaughtSuspenseError;
516 }
517 } else {
518 // This is a regular error, not a Suspense wakeable.
packages/react-reconciler/src/ReactFiberUnwindWork.new.js
+10 -9
@@ -89,16 +89,17 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
89 popTopLevelLegacyContextObject(workInProgress);
90 resetMutableSourceWorkInProgressVersions();
91 const flags = workInProgress.flags;
92 -
93 - if ((flags & DidCapture) !== NoFlags) {
94 - throw new Error(
95 - 'The root failed to unmount after an error. This is likely a bug in ' +
96 - 'React. Please file an issue.',
97 - );
92 + if (
93 + (flags & ShouldCapture) !== NoFlags &&
94 + (flags & DidCapture) === NoFlags
95 + ) {
96 + // There was an error during render that wasn't captured by a suspense
97 + // boundary. Do a second pass on the root to unmount the children.
98 + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
99 + return workInProgress;
100 }
99 -
100 - workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
101 - return workInProgress;
101 + // We unwound to the root without completing it. Exit.
102 + return null;
103 }
104 case HostComponent: {
105 // TODO: popHydrationState
packages/react-reconciler/src/ReactFiberUnwindWork.old.js
+10 -9
@@ -89,16 +89,17 @@ function unwindWork(workInProgress: Fiber, renderLanes: Lanes) {
89 popTopLevelLegacyContextObject(workInProgress);
90 resetMutableSourceWorkInProgressVersions();
91 const flags = workInProgress.flags;
92 -
93 - if ((flags & DidCapture) !== NoFlags) {
94 - throw new Error(
95 - 'The root failed to unmount after an error. This is likely a bug in ' +
96 - 'React. Please file an issue.',
97 - );
92 + if (
93 + (flags & ShouldCapture) !== NoFlags &&
94 + (flags & DidCapture) === NoFlags
95 + ) {
96 + // There was an error during render that wasn't captured by a suspense
97 + // boundary. Do a second pass on the root to unmount the children.
98 + workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
99 + return workInProgress;
100 }
99 -
100 - workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
101 - return workInProgress;
101 + // We unwound to the root without completing it. Exit.
102 + return null;
103 }
104 case HostComponent: {
105 // TODO: popHydrationState
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+70 -47
@@ -251,13 +251,14 @@ const RenderContext = /* */ 0b0010;
251 const CommitContext = /* */ 0b0100;
252 export const RetryAfterError = /* */ 0b1000;
253
254 -type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
255 -const RootIncomplete = 0;
254 +type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
255 +const RootInProgress = 0;
256 const RootFatalErrored = 1;
257 const RootErrored = 2;
258 const RootSuspended = 3;
259 const RootSuspendedWithDelay = 4;
260 const RootCompleted = 5;
261 +const RootDidNotComplete = 6;
262
263 // Describes where we are in the React execution stack
264 let executionContext: ExecutionContext = NoContext;
@@ -280,7 +281,7 @@ export let subtreeRenderLanes: Lanes = NoLanes;
281 const subtreeRenderLanesCursor: StackCursor<Lanes> = createCursor(NoLanes);
282
283 // Whether to root completed, errored, suspended, etc.
283 -let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
284 +let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
285 // A fatal error, if one is thrown
286 let workInProgressRootFatalError: mixed = null;
287 // "Included" lanes refer to lanes that were worked on during this render. It's
@@ -817,7 +818,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
818 let exitStatus = shouldTimeSlice
819 ? renderRootConcurrent(root, lanes)
820 : renderRootSync(root, lanes);
820 - if (exitStatus !== RootIncomplete) {
821 + if (exitStatus !== RootInProgress) {
822 if (exitStatus === RootErrored) {
823 // If something threw an error, try rendering one more time. We'll
824 // render synchronously to block concurrent data mutations, and we'll
@@ -837,45 +838,58 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
838 throw fatalError;
839 }
840
840 - // Check if this render may have yielded to a concurrent event, and if so,
841 - // confirm that any newly rendered stores are consistent.
842 - // TODO: It's possible that even a concurrent render may never have yielded
843 - // to the main thread, if it was fast enough, or if it expired. We could
844 - // skip the consistency check in that case, too.
845 - const renderWasConcurrent = !includesBlockingLane(root, lanes);
846 - const finishedWork: Fiber = (root.current.alternate: any);
847 - if (
848 - renderWasConcurrent &&
849 - !isRenderConsistentWithExternalStores(finishedWork)
850 - ) {
851 - // A store was mutated in an interleaved event. Render again,
852 - // synchronously, to block further mutations.
853 - exitStatus = renderRootSync(root, lanes);
854 -
855 - // We need to check again if something threw
856 - if (exitStatus === RootErrored) {
857 - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root);
858 - if (errorRetryLanes !== NoLanes) {
859 - lanes = errorRetryLanes;
860 - exitStatus = recoverFromConcurrentError(root, errorRetryLanes);
861 - // We assume the tree is now consistent because we didn't yield to any
862 - // concurrent events.
841 + if (exitStatus === RootDidNotComplete) {
842 + // The render unwound without completing the tree. This happens in special
843 + // cases where need to exit the current render without producing a
844 + // consistent tree or committing.
845 + //
846 + // This should only happen during a concurrent render, not a discrete or
847 + // synchronous update. We should have already checked for this when we
848 + // unwound the stack.
849 + markRootSuspended(root, lanes);
850 + } else {
851 + // The render completed.
852 +
853 + // Check if this render may have yielded to a concurrent event, and if so,
854 + // confirm that any newly rendered stores are consistent.
855 + // TODO: It's possible that even a concurrent render may never have yielded
856 + // to the main thread, if it was fast enough, or if it expired. We could
857 + // skip the consistency check in that case, too.
858 + const renderWasConcurrent = !includesBlockingLane(root, lanes);
859 + const finishedWork: Fiber = (root.current.alternate: any);
860 + if (
861 + renderWasConcurrent &&
862 + !isRenderConsistentWithExternalStores(finishedWork)
863 + ) {
864 + // A store was mutated in an interleaved event. Render again,
865 + // synchronously, to block further mutations.
866 + exitStatus = renderRootSync(root, lanes);
867 +
868 + // We need to check again if something threw
869 + if (exitStatus === RootErrored) {
870 + const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root);
871 + if (errorRetryLanes !== NoLanes) {
872 + lanes = errorRetryLanes;
873 + exitStatus = recoverFromConcurrentError(root, errorRetryLanes);
874 + // We assume the tree is now consistent because we didn't yield to any
875 + // concurrent events.
876 + }
877 + }
878 + if (exitStatus === RootFatalErrored) {
879 + const fatalError = workInProgressRootFatalError;
880 + prepareFreshStack(root, NoLanes);
881 + markRootSuspended(root, lanes);
882 + ensureRootIsScheduled(root, now());
883 + throw fatalError;
884 }
885 }
865 - if (exitStatus === RootFatalErrored) {
866 - const fatalError = workInProgressRootFatalError;
867 - prepareFreshStack(root, NoLanes);
868 - markRootSuspended(root, lanes);
869 - ensureRootIsScheduled(root, now());
870 - throw fatalError;
871 - }
872 - }
886
874 - // We now have a consistent tree. The next step is either to commit it,
875 - // or, if something suspended, wait to commit it after a timeout.
876 - root.finishedWork = finishedWork;
877 - root.finishedLanes = lanes;
878 - finishConcurrentRender(root, exitStatus, lanes);
887 + // We now have a consistent tree. The next step is either to commit it,
888 + // or, if something suspended, wait to commit it after a timeout.
889 + root.finishedWork = finishedWork;
890 + root.finishedLanes = lanes;
891 + finishConcurrentRender(root, exitStatus, lanes);
892 + }
893 }
894
895 ensureRootIsScheduled(root, now());
@@ -932,7 +946,7 @@ export function queueRecoverableErrors(errors: Array<mixed>) {
946
947 function finishConcurrentRender(root, exitStatus, lanes) {
948 switch (exitStatus) {
935 - case RootIncomplete:
949 + case RootInProgress:
950 case RootFatalErrored: {
951 throw new Error('Root did not complete. This is a bug in React.');
952 }
@@ -1148,6 +1162,10 @@ function performSyncWorkOnRoot(root) {
1162 throw fatalError;
1163 }
1164
1165 + if (exitStatus === RootDidNotComplete) {
1166 + throw new Error('Root did not complete. This is a bug in React.');
1167 + }
1168 +
1169 // We now have a consistent tree. Because this is a sync render, we
1170 // will commit it even if something suspended.
1171 const finishedWork: Fiber = (root.current.alternate: any);
@@ -1343,7 +1361,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
1361 workInProgressRoot = root;
1362 workInProgress = createWorkInProgress(root.current, null);
1363 workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
1346 - workInProgressRootExitStatus = RootIncomplete;
1364 + workInProgressRootExitStatus = RootInProgress;
1365 workInProgressRootFatalError = null;
1366 workInProgressRootSkippedLanes = NoLanes;
1367 workInProgressRootInterleavedUpdatedLanes = NoLanes;
@@ -1473,14 +1491,14 @@ export function markSkippedUpdateLanes(lane: Lane | Lanes): void {
1491 }
1492
1493 export function renderDidSuspend(): void {
1476 - if (workInProgressRootExitStatus === RootIncomplete) {
1494 + if (workInProgressRootExitStatus === RootInProgress) {
1495 workInProgressRootExitStatus = RootSuspended;
1496 }
1497 }
1498
1499 export function renderDidSuspendDelayIfPossible(): void {
1500 if (
1483 - workInProgressRootExitStatus === RootIncomplete ||
1501 + workInProgressRootExitStatus === RootInProgress ||
1502 workInProgressRootExitStatus === RootSuspended ||
1503 workInProgressRootExitStatus === RootErrored
1504 ) {
@@ -1521,7 +1539,7 @@ export function renderDidError(error: mixed) {
1539 export function renderHasNotSuspendedYet(): boolean {
1540 // If something errored or completed, we can't really be sure,
1541 // so those are false.
1524 - return workInProgressRootExitStatus === RootIncomplete;
1542 + return workInProgressRootExitStatus === RootInProgress;
1543 }
1544
1545 function renderRootSync(root: FiberRoot, lanes: Lanes) {
@@ -1671,7 +1689,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
1689 if (enableSchedulingProfiler) {
1690 markRenderYielded();
1691 }
1674 - return RootIncomplete;
1692 + return RootInProgress;
1693 } else {
1694 // Completed the tree.
1695 if (enableSchedulingProfiler) {
@@ -1796,6 +1814,11 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
1814 returnFiber.flags |= Incomplete;
1815 returnFiber.subtreeFlags = NoFlags;
1816 returnFiber.deletions = null;
1817 + } else {
1818 + // We've unwound all the way to the root.
1819 + workInProgressRootExitStatus = RootDidNotComplete;
1820 + workInProgress = null;
1821 + return;
1822 }
1823 }
1824
@@ -1812,7 +1835,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
1835 } while (completedWork !== null);
1836
1837 // We've reached the root.
1815 - if (workInProgressRootExitStatus === RootIncomplete) {
1838 + if (workInProgressRootExitStatus === RootInProgress) {
1839 workInProgressRootExitStatus = RootCompleted;
1840 }
1841 }
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+70 -47
@@ -251,13 +251,14 @@ const RenderContext = /* */ 0b0010;
251 const CommitContext = /* */ 0b0100;
252 export const RetryAfterError = /* */ 0b1000;
253
254 -type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
255 -const RootIncomplete = 0;
254 +type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
255 +const RootInProgress = 0;
256 const RootFatalErrored = 1;
257 const RootErrored = 2;
258 const RootSuspended = 3;
259 const RootSuspendedWithDelay = 4;
260 const RootCompleted = 5;
261 +const RootDidNotComplete = 6;
262
263 // Describes where we are in the React execution stack
264 let executionContext: ExecutionContext = NoContext;
@@ -280,7 +281,7 @@ export let subtreeRenderLanes: Lanes = NoLanes;
281 const subtreeRenderLanesCursor: StackCursor<Lanes> = createCursor(NoLanes);
282
283 // Whether to root completed, errored, suspended, etc.
283 -let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
284 +let workInProgressRootExitStatus: RootExitStatus = RootInProgress;
285 // A fatal error, if one is thrown
286 let workInProgressRootFatalError: mixed = null;
287 // "Included" lanes refer to lanes that were worked on during this render. It's
@@ -817,7 +818,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
818 let exitStatus = shouldTimeSlice
819 ? renderRootConcurrent(root, lanes)
820 : renderRootSync(root, lanes);
820 - if (exitStatus !== RootIncomplete) {
821 + if (exitStatus !== RootInProgress) {
822 if (exitStatus === RootErrored) {
823 // If something threw an error, try rendering one more time. We'll
824 // render synchronously to block concurrent data mutations, and we'll
@@ -837,45 +838,58 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
838 throw fatalError;
839 }
840
840 - // Check if this render may have yielded to a concurrent event, and if so,
841 - // confirm that any newly rendered stores are consistent.
842 - // TODO: It's possible that even a concurrent render may never have yielded
843 - // to the main thread, if it was fast enough, or if it expired. We could
844 - // skip the consistency check in that case, too.
845 - const renderWasConcurrent = !includesBlockingLane(root, lanes);
846 - const finishedWork: Fiber = (root.current.alternate: any);
847 - if (
848 - renderWasConcurrent &&
849 - !isRenderConsistentWithExternalStores(finishedWork)
850 - ) {
851 - // A store was mutated in an interleaved event. Render again,
852 - // synchronously, to block further mutations.
853 - exitStatus = renderRootSync(root, lanes);
854 -
855 - // We need to check again if something threw
856 - if (exitStatus === RootErrored) {
857 - const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root);
858 - if (errorRetryLanes !== NoLanes) {
859 - lanes = errorRetryLanes;
860 - exitStatus = recoverFromConcurrentError(root, errorRetryLanes);
861 - // We assume the tree is now consistent because we didn't yield to any
862 - // concurrent events.
841 + if (exitStatus === RootDidNotComplete) {
842 + // The render unwound without completing the tree. This happens in special
843 + // cases where need to exit the current render without producing a
844 + // consistent tree or committing.
845 + //
846 + // This should only happen during a concurrent render, not a discrete or
847 + // synchronous update. We should have already checked for this when we
848 + // unwound the stack.
849 + markRootSuspended(root, lanes);
850 + } else {
851 + // The render completed.
852 +
853 + // Check if this render may have yielded to a concurrent event, and if so,
854 + // confirm that any newly rendered stores are consistent.
855 + // TODO: It's possible that even a concurrent render may never have yielded
856 + // to the main thread, if it was fast enough, or if it expired. We could
857 + // skip the consistency check in that case, too.
858 + const renderWasConcurrent = !includesBlockingLane(root, lanes);
859 + const finishedWork: Fiber = (root.current.alternate: any);
860 + if (
861 + renderWasConcurrent &&
862 + !isRenderConsistentWithExternalStores(finishedWork)
863 + ) {
864 + // A store was mutated in an interleaved event. Render again,
865 + // synchronously, to block further mutations.
866 + exitStatus = renderRootSync(root, lanes);
867 +
868 + // We need to check again if something threw
869 + if (exitStatus === RootErrored) {
870 + const errorRetryLanes = getLanesToRetrySynchronouslyOnError(root);
871 + if (errorRetryLanes !== NoLanes) {
872 + lanes = errorRetryLanes;
873 + exitStatus = recoverFromConcurrentError(root, errorRetryLanes);
874 + // We assume the tree is now consistent because we didn't yield to any
875 + // concurrent events.
876 + }
877 + }
878 + if (exitStatus === RootFatalErrored) {
879 + const fatalError = workInProgressRootFatalError;
880 + prepareFreshStack(root, NoLanes);
881 + markRootSuspended(root, lanes);
882 + ensureRootIsScheduled(root, now());
883 + throw fatalError;
884 }
885 }
865 - if (exitStatus === RootFatalErrored) {
866 - const fatalError = workInProgressRootFatalError;
867 - prepareFreshStack(root, NoLanes);
868 - markRootSuspended(root, lanes);
869 - ensureRootIsScheduled(root, now());
870 - throw fatalError;
871 - }
872 - }
886
874 - // We now have a consistent tree. The next step is either to commit it,
875 - // or, if something suspended, wait to commit it after a timeout.
876 - root.finishedWork = finishedWork;
877 - root.finishedLanes = lanes;
878 - finishConcurrentRender(root, exitStatus, lanes);
887 + // We now have a consistent tree. The next step is either to commit it,
888 + // or, if something suspended, wait to commit it after a timeout.
889 + root.finishedWork = finishedWork;
890 + root.finishedLanes = lanes;
891 + finishConcurrentRender(root, exitStatus, lanes);
892 + }
893 }
894
895 ensureRootIsScheduled(root, now());
@@ -932,7 +946,7 @@ export function queueRecoverableErrors(errors: Array<mixed>) {
946
947 function finishConcurrentRender(root, exitStatus, lanes) {
948 switch (exitStatus) {
935 - case RootIncomplete:
949 + case RootInProgress:
950 case RootFatalErrored: {
951 throw new Error('Root did not complete. This is a bug in React.');
952 }
@@ -1148,6 +1162,10 @@ function performSyncWorkOnRoot(root) {
1162 throw fatalError;
1163 }
1164
1165 + if (exitStatus === RootDidNotComplete) {
1166 + throw new Error('Root did not complete. This is a bug in React.');
1167 + }
1168 +
1169 // We now have a consistent tree. Because this is a sync render, we
1170 // will commit it even if something suspended.
1171 const finishedWork: Fiber = (root.current.alternate: any);
@@ -1343,7 +1361,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
1361 workInProgressRoot = root;
1362 workInProgress = createWorkInProgress(root.current, null);
1363 workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
1346 - workInProgressRootExitStatus = RootIncomplete;
1364 + workInProgressRootExitStatus = RootInProgress;
1365 workInProgressRootFatalError = null;
1366 workInProgressRootSkippedLanes = NoLanes;
1367 workInProgressRootInterleavedUpdatedLanes = NoLanes;
@@ -1473,14 +1491,14 @@ export function markSkippedUpdateLanes(lane: Lane | Lanes): void {
1491 }
1492
1493 export function renderDidSuspend(): void {
1476 - if (workInProgressRootExitStatus === RootIncomplete) {
1494 + if (workInProgressRootExitStatus === RootInProgress) {
1495 workInProgressRootExitStatus = RootSuspended;
1496 }
1497 }
1498
1499 export function renderDidSuspendDelayIfPossible(): void {
1500 if (
1483 - workInProgressRootExitStatus === RootIncomplete ||
1501 + workInProgressRootExitStatus === RootInProgress ||
1502 workInProgressRootExitStatus === RootSuspended ||
1503 workInProgressRootExitStatus === RootErrored
1504 ) {
@@ -1521,7 +1539,7 @@ export function renderDidError(error: mixed) {
1539 export function renderHasNotSuspendedYet(): boolean {
1540 // If something errored or completed, we can't really be sure,
1541 // so those are false.
1524 - return workInProgressRootExitStatus === RootIncomplete;
1542 + return workInProgressRootExitStatus === RootInProgress;
1543 }
1544
1545 function renderRootSync(root: FiberRoot, lanes: Lanes) {
@@ -1671,7 +1689,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
1689 if (enableSchedulingProfiler) {
1690 markRenderYielded();
1691 }
1674 - return RootIncomplete;
1692 + return RootInProgress;
1693 } else {
1694 // Completed the tree.
1695 if (enableSchedulingProfiler) {
@@ -1796,6 +1814,11 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
1814 returnFiber.flags |= Incomplete;
1815 returnFiber.subtreeFlags = NoFlags;
1816 returnFiber.deletions = null;
1817 + } else {
1818 + // We've unwound all the way to the root.
1819 + workInProgressRootExitStatus = RootDidNotComplete;
1820 + workInProgress = null;
1821 + return;
1822 }
1823 }
1824
@@ -1812,7 +1835,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void {
1835 } while (completedWork !== null);
1836
1837 // We've reached the root.
1815 - if (workInProgressRootExitStatus === RootIncomplete) {
1838 + if (workInProgressRootExitStatus === RootInProgress) {
1839 workInProgressRootExitStatus = RootCompleted;
1840 }
1841 }
packages/react-reconciler/src/__tests__/ReactConcurrentErrorRecovery-test.js
+134
@@ -398,4 +398,138 @@ describe('ReactConcurrentErrorRecovery', () => {
398 // Now we can show the error boundary that's wrapped around B.
399 expect(root).toMatchRenderedOutput('Oops!B2');
400 });
401 +
402 + // @gate enableCache
403 + test('suspending in the shell (outside a Suspense boundary) should not throw, warn, or log during a transition', async () => {
404 + class ErrorBoundary extends React.Component {
405 + state = {error: null};
406 + static getDerivedStateFromError(error) {
407 + return {error};
408 + }
409 + render() {
410 + if (this.state.error !== null) {
411 + return <Text text={this.state.error.message} />;
412 + }
413 + return this.props.children;
414 + }
415 + }
416 +
417 + // The initial render suspends without a Suspense boundary. Since it's
418 + // wrapped in startTransition, it suspends instead of erroring.
419 + const root = ReactNoop.createRoot();
420 + await act(async () => {
421 + startTransition(() => {
422 + root.render(<AsyncText text="Async" />);
423 + });
424 + });
425 + expect(Scheduler).toHaveYielded(['Suspend! [Async]']);
426 + expect(root).toMatchRenderedOutput(null);
427 +
428 + // This also works if the suspended component is wrapped with an error
429 + // boundary. (This is only interesting because when a component suspends
430 + // outside of a transition, we throw an error, which can be captured by
431 + // an error boundary.
432 + await act(async () => {
433 + startTransition(() => {
434 + root.render(
435 + <ErrorBoundary>
436 + <AsyncText text="Async" />
437 + </ErrorBoundary>,
438 + );
439 + });
440 + });
441 + expect(Scheduler).toHaveYielded(['Suspend! [Async]']);
442 + expect(root).toMatchRenderedOutput(null);
443 +
444 + // Continues rendering once data resolves
445 + await act(async () => {
446 + resolveText('Async');
447 + });
448 + expect(Scheduler).toHaveYielded(['Async']);
449 + expect(root).toMatchRenderedOutput('Async');
450 + });
451 +
452 + // @gate enableCache
453 + test(
454 + 'errors during a suspended transition at the shell should not force ' +
455 + 'fallbacks to display (error then suspend)',
456 + async () => {
457 + // This is similar to the earlier test for errors that occur during
458 + // a refresh transition. Suspending in the shell is conceptually the same
459 + // as a refresh, but they have slightly different implementation paths.
460 +
461 + class ErrorBoundary extends React.Component {
462 + state = {error: null};
463 + static getDerivedStateFromError(error) {
464 + return {error};
465 + }
466 + render() {
467 + if (this.state.error !== null) {
468 + return (
469 + <Text text={'Caught an error: ' + this.state.error.message} />
470 + );
471 + }
472 + return this.props.children;
473 + }
474 + }
475 +
476 + function Throws() {
477 + throw new Error('Oops!');
478 + }
479 +
480 + // Suspend and throw in the same transition
481 + const root = ReactNoop.createRoot();
482 + await act(async () => {
483 + startTransition(() => {
484 + root.render(
485 + <ErrorBoundary>
486 + <AsyncText text="Async" />
487 + <Throws />
488 + </ErrorBoundary>,
489 + );
490 + });
491 + });
492 + expect(Scheduler).toHaveYielded([
493 + 'Suspend! [Async]',
494 + // TODO: Ideally we would skip this second render pass to render the
495 + // error UI, since it's not going to commit anyway. The same goes for
496 + // Suspense fallbacks during a refresh transition.
497 + 'Caught an error: Oops!',
498 + ]);
499 + // The render suspended without committing or surfacing the error.
500 + expect(root).toMatchRenderedOutput(null);
501 +
502 + // Try the reverse order, too: throw then suspend
503 + await act(async () => {
504 + startTransition(() => {
505 + root.render(
506 + <ErrorBoundary>
507 + <Throws />
508 + <AsyncText text="Async" />
509 + </ErrorBoundary>,
510 + );
511 + });
512 + });
513 + expect(Scheduler).toHaveYielded([
514 + 'Suspend! [Async]',
515 + 'Caught an error: Oops!',
516 + ]);
517 + expect(root).toMatchRenderedOutput(null);
518 +
519 + await act(async () => {
520 + await resolveText('Async');
521 + });
522 +
523 + expect(Scheduler).toHaveYielded([
524 + 'Async',
525 + 'Caught an error: Oops!',
526 +
527 + // Try recovering from the error by rendering again synchronously
528 + 'Async',
529 + 'Caught an error: Oops!',
530 + ]);
531 +
532 + expect(root).toMatchRenderedOutput('Caught an error: Oops!');
533 + },
534 + );
535 });