@samitouri / QOS-React-2 / commits / 11c9fd0c53

Batch async actions even if useTransition is unmounted (#28078)

If there are multiple updates inside an async action, they should all be rendered in the same batch, even if they are separate by an async operation (`await`). We currently implement this by suspending in the `useTransition` hook to block the update from committing until all possible updates have been scheduled by the action. The reason we did it this way is so you can "cancel" an action by navigating away from the UI that triggered it. The problem with that approach, though, is that even if you navigate away from the `useTransition` hook, the action may have updated shared parts of the UI that are still in the tree. So we may need to continue suspending even after the `useTransition` hook is deleted. In other words, the lifetime of an async action scope is longer than the lifetime of a particular `useTransition` hook. The solution is to suspend whenever _any_ update that is part of the async action scope is unwrapped during render. So, inside useState and useReducer. This fixes a related issue where an optimistic update is reverted before the async action has finished, because we were relying on the `useTransition` hook to prevent the optimistic update from finishing. This also prepares us to support async actions being passed to the non-hook form of `startTransition` (though this isn't implemented yet).

Andrew Clark committed Jan 24, 2024 at 23:54 UTC 11c9fd0c53133f24f5270d36591b65b8fc2ebd25
8 files changed +582 -173
packages/react-reconciler/src/ReactFiberAsyncAction.js
+70 -117
@@ -9,7 +9,6 @@
9
10 import type {
11 Thenable,
12 - PendingThenable,
12 FulfilledThenable,
13 RejectedThenable,
14 } from 'shared/ReactTypes';
@@ -32,111 +31,32 @@ let currentEntangledListeners: Array<() => mixed> | null = null;
31 let currentEntangledPendingCount: number = 0;
32 // The transition lane shared by all updates in the entangled scope.
33 let currentEntangledLane: Lane = NoLane;
34 +// A thenable that resolves when the entangled scope completes. It does not
35 +// resolve to a particular value because it's only used for suspending the UI
36 +// until the async action scope has completed.
37 +let currentEntangledActionThenable: Thenable<void> | null = null;
38
36 -export function requestAsyncActionContext<S>(
37 - actionReturnValue: Thenable<any>,
38 - // If this is provided, this resulting thenable resolves to this value instead
39 - // of the return value of the action. This is a perf trick to avoid composing
40 - // an extra async function.
41 - overrideReturnValue: S | null,
42 -): Thenable<S> {
43 - // This is an async action.
44 - //
45 - // Return a thenable that resolves once the action scope (i.e. the async
46 - // function passed to startTransition) has finished running.
47 -
48 - const thenable: Thenable<S> = (actionReturnValue: any);
49 - let entangledListeners;
39 +export function entangleAsyncAction<S>(thenable: Thenable<S>): Thenable<S> {
40 + // `thenable` is the return value of the async action scope function. Create
41 + // a combined thenable that resolves once every entangled scope function
42 + // has finished.
43 if (currentEntangledListeners === null) {
44 // There's no outer async action scope. Create a new one.
52 - entangledListeners = currentEntangledListeners = [];
45 + const entangledListeners = (currentEntangledListeners = []);
46 currentEntangledPendingCount = 0;
47 currentEntangledLane = requestTransitionLane();
55 - } else {
56 - entangledListeners = currentEntangledListeners;
48 + const entangledThenable: Thenable<void> = {
49 + status: 'pending',
50 + value: undefined,
51 + then(resolve: void => mixed) {
52 + entangledListeners.push(resolve);
53 + },
54 + };
55 + currentEntangledActionThenable = entangledThenable;
56 }
58 -
57 currentEntangledPendingCount++;
60 -
61 - // Create a thenable that represents the result of this action, but doesn't
62 - // resolve until the entire entangled scope has finished.
63 - //
64 - // Expressed using promises:
65 - // const [thisResult] = await Promise.all([thisAction, entangledAction]);
66 - // return thisResult;
67 - const resultThenable = createResultThenable<S>(entangledListeners);
68 -
69 - let resultStatus = 'pending';
70 - let resultValue;
71 - let rejectedReason;
72 - thenable.then(
73 - (value: S) => {
74 - resultStatus = 'fulfilled';
75 - resultValue = overrideReturnValue !== null ? overrideReturnValue : value;
76 - pingEngtangledActionScope();
77 - },
78 - error => {
79 - resultStatus = 'rejected';
80 - rejectedReason = error;
81 - pingEngtangledActionScope();
82 - },
83 - );
84 -
85 - // Attach a listener to fill in the result.
86 - entangledListeners.push(() => {
87 - switch (resultStatus) {
88 - case 'fulfilled': {
89 - const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
90 - fulfilledThenable.status = 'fulfilled';
91 - fulfilledThenable.value = resultValue;
92 - break;
93 - }
94 - case 'rejected': {
95 - const rejectedThenable: RejectedThenable<S> = (resultThenable: any);
96 - rejectedThenable.status = 'rejected';
97 - rejectedThenable.reason = rejectedReason;
98 - break;
99 - }
100 - case 'pending':
101 - default: {
102 - // The listener above should have been called first, so `resultStatus`
103 - // should already be set to the correct value.
104 - throw new Error(
105 - 'Thenable should have already resolved. This ' + 'is a bug in React.',
106 - );
107 - }
108 - }
109 - });
110 -
111 - return resultThenable;
112 -}
113 -
114 -export function requestSyncActionContext<S>(
115 - actionReturnValue: any,
116 - // If this is provided, this resulting thenable resolves to this value instead
117 - // of the return value of the action. This is a perf trick to avoid composing
118 - // an extra async function.
119 - overrideReturnValue: S | null,
120 -): Thenable<S> | S {
121 - const resultValue: S =
122 - overrideReturnValue !== null
123 - ? overrideReturnValue
124 - : (actionReturnValue: any);
125 - // This is not an async action, but it may be part of an outer async action.
126 - if (currentEntangledListeners === null) {
127 - return resultValue;
128 - } else {
129 - // Return a thenable that does not resolve until the entangled actions
130 - // have finished.
131 - const entangledListeners = currentEntangledListeners;
132 - const resultThenable = createResultThenable<S>(entangledListeners);
133 - entangledListeners.push(() => {
134 - const fulfilledThenable: FulfilledThenable<S> = (resultThenable: any);
135 - fulfilledThenable.status = 'fulfilled';
136 - fulfilledThenable.value = resultValue;
137 - });
138 - return resultThenable;
139 - }
58 + thenable.then(pingEngtangledActionScope, pingEngtangledActionScope);
59 + return thenable;
60 }
61
62 function pingEngtangledActionScope() {
@@ -146,9 +66,15 @@ function pingEngtangledActionScope() {
66 ) {
67 // All the actions have finished. Close the entangled async action scope
68 // and notify all the listeners.
69 + if (currentEntangledActionThenable !== null) {
70 + const fulfilledThenable: FulfilledThenable<void> =
71 + (currentEntangledActionThenable: any);
72 + fulfilledThenable.status = 'fulfilled';
73 + }
74 const listeners = currentEntangledListeners;
75 currentEntangledListeners = null;
76 currentEntangledLane = NoLane;
77 + currentEntangledActionThenable = null;
78 for (let i = 0; i < listeners.length; i++) {
79 const listener = listeners[i];
80 listener();
@@ -156,31 +82,58 @@ function pingEngtangledActionScope() {
82 }
83 }
84
159 -function createResultThenable<S>(
160 - entangledListeners: Array<() => mixed>,
161 -): Thenable<S> {
162 - // Waits for the entangled async action to complete, then resolves to the
163 - // result of an individual action.
164 - const resultThenable: PendingThenable<S> = {
85 +export function chainThenableValue<T>(
86 + thenable: Thenable<T>,
87 + result: T,
88 +): Thenable<T> {
89 + // Equivalent to: Promise.resolve(thenable).then(() => result), except we can
90 + // cheat a bit since we know that that this thenable is only ever consumed
91 + // by React.
92 + //
93 + // We don't technically require promise support on the client yet, hence this
94 + // extra code.
95 + const listeners = [];
96 + const thenableWithOverride: Thenable<T> = {
97 status: 'pending',
98 value: null,
99 reason: null,
168 - then(resolve: S => mixed) {
169 - // This is a bit of a cheat. `resolve` expects a value of type `S` to be
170 - // passed, but because we're instrumenting the `status` field ourselves,
171 - // and we know this thenable will only be used by React, we also know
172 - // the value isn't actually needed. So we add the resolve function
173 - // directly to the entangled listeners.
174 - //
175 - // This is also why we don't need to check if the thenable is still
176 - // pending; the Suspense implementation already performs that check.
177 - const ping: () => mixed = (resolve: any);
178 - entangledListeners.push(ping);
100 + then(resolve: T => mixed) {
101 + listeners.push(resolve);
102 },
103 };
181 - return resultThenable;
104 + thenable.then(
105 + (value: T) => {
106 + const fulfilledThenable: FulfilledThenable<T> =
107 + (thenableWithOverride: any);
108 + fulfilledThenable.status = 'fulfilled';
109 + fulfilledThenable.value = result;
110 + for (let i = 0; i < listeners.length; i++) {
111 + const listener = listeners[i];
112 + listener(result);
113 + }
114 + },
115 + error => {
116 + const rejectedThenable: RejectedThenable<T> = (thenableWithOverride: any);
117 + rejectedThenable.status = 'rejected';
118 + rejectedThenable.reason = error;
119 + for (let i = 0; i < listeners.length; i++) {
120 + const listener = listeners[i];
121 + // This is a perf hack where we call the `onFulfill` ping function
122 + // instead of `onReject`, because we know that React is the only
123 + // consumer of these promises, and it passes the same listener to both.
124 + // We also know that it will read the error directly off the
125 + // `.reason` field.
126 + listener((undefined: any));
127 + }
128 + },
129 + );
130 + return thenableWithOverride;
131 }
132
133 export function peekEntangledActionLane(): Lane {
134 return currentEntangledLane;
135 }
136 +
137 +export function peekEntangledActionThenable(): Thenable<void> | null {
138 + return currentEntangledActionThenable;
139 +}
packages/react-reconciler/src/ReactFiberBeginWork.js
+7
@@ -137,6 +137,7 @@ import {
137 cloneUpdateQueue,
138 initializeUpdateQueue,
139 enqueueCapturedUpdate,
140 + suspendIfUpdateReadFromEntangledAsyncAction,
141 } from './ReactFiberClassUpdateQueue';
142 import {
143 NoLane,
@@ -945,6 +946,7 @@ function updateCacheComponent(
946 if (includesSomeLane(current.lanes, renderLanes)) {
947 cloneUpdateQueue(current, workInProgress);
948 processUpdateQueue(workInProgress, null, null, renderLanes);
949 + suspendIfUpdateReadFromEntangledAsyncAction();
950 }
951 const prevState: CacheComponentState = current.memoizedState;
952 const nextState: CacheComponentState = workInProgress.memoizedState;
@@ -1475,6 +1477,11 @@ function updateHostRoot(
1477 }
1478 }
1479
1480 + // This would ideally go inside processUpdateQueue, but because it suspends,
1481 + // it needs to happen after the `pushCacheProvider` call above to avoid a
1482 + // context stack mismatch. A bit unfortunate.
1483 + suspendIfUpdateReadFromEntangledAsyncAction();
1484 +
1485 // Caution: React DevTools currently depends on this property
1486 // being called "element".
1487 const nextChildren = nextState.element;
packages/react-reconciler/src/ReactFiberClassComponent.js
+4
@@ -53,6 +53,7 @@ import {
53 ForceUpdate,
54 initializeUpdateQueue,
55 cloneUpdateQueue,
56 + suspendIfUpdateReadFromEntangledAsyncAction,
57 } from './ReactFiberClassUpdateQueue';
58 import {NoLanes} from './ReactFiberLane';
59 import {
@@ -892,6 +893,7 @@ function mountClassInstance(
893 // If we had additional state updates during this life-cycle, let's
894 // process them now.
895 processUpdateQueue(workInProgress, newProps, instance, renderLanes);
896 + suspendIfUpdateReadFromEntangledAsyncAction();
897 instance.state = workInProgress.memoizedState;
898 }
899
@@ -959,6 +961,7 @@ function resumeMountClassInstance(
961 const oldState = workInProgress.memoizedState;
962 let newState = (instance.state = oldState);
963 processUpdateQueue(workInProgress, newProps, instance, renderLanes);
964 + suspendIfUpdateReadFromEntangledAsyncAction();
965 newState = workInProgress.memoizedState;
966 if (
967 oldProps === newProps &&
@@ -1109,6 +1112,7 @@ function updateClassInstance(
1112 const oldState = workInProgress.memoizedState;
1113 let newState = (instance.state = oldState);
1114 processUpdateQueue(workInProgress, newProps, instance, renderLanes);
1115 + suspendIfUpdateReadFromEntangledAsyncAction();
1116 newState = workInProgress.memoizedState;
1117
1118 if (
packages/react-reconciler/src/ReactFiberClassUpdateQueue.js
+37
@@ -125,6 +125,10 @@ import {
125 import {setIsStrictModeForDevtools} from './ReactFiberDevToolsHook';
126
127 import assign from 'shared/assign';
128 +import {
129 + peekEntangledActionLane,
130 + peekEntangledActionThenable,
131 +} from './ReactFiberAsyncAction';
132
133 export type Update<State> = {
134 lane: Lane,
@@ -463,12 +467,38 @@ function getStateFromUpdate<State>(
467 return prevState;
468 }
469
470 +let didReadFromEntangledAsyncAction: boolean = false;
471 +
472 +// Each call to processUpdateQueue should be accompanied by a call to this. It's
473 +// only in a separate function because in updateHostRoot, it must happen after
474 +// all the context stacks have been pushed to, to prevent a stack mismatch. A
475 +// bit unfortunate.
476 +export function suspendIfUpdateReadFromEntangledAsyncAction() {
477 + // Check if this update is part of a pending async action. If so, we'll
478 + // need to suspend until the action has finished, so that it's batched
479 + // together with future updates in the same action.
480 + // TODO: Once we support hooks inside useMemo (or an equivalent
481 + // memoization boundary like Forget), hoist this logic so that it only
482 + // suspends if the memo boundary produces a new value.
483 + if (didReadFromEntangledAsyncAction) {
484 + const entangledActionThenable = peekEntangledActionThenable();
485 + if (entangledActionThenable !== null) {
486 + // TODO: Instead of the throwing the thenable directly, throw a
487 + // special object like `use` does so we can detect if it's captured
488 + // by userspace.
489 + throw entangledActionThenable;
490 + }
491 + }
492 +}
493 +
494 export function processUpdateQueue<State>(
495 workInProgress: Fiber,
496 props: any,
497 instance: any,
498 renderLanes: Lanes,
499 ): void {
500 + didReadFromEntangledAsyncAction = false;
501 +
502 // This is always non-null on a ClassComponent or HostRoot
503 const queue: UpdateQueue<State> = (workInProgress.updateQueue: any);
504
@@ -571,6 +601,13 @@ export function processUpdateQueue<State>(
601 } else {
602 // This update does have sufficient priority.
603
604 + // Check if this update is part of a pending async action. If so,
605 + // we'll need to suspend until the action has finished, so that it's
606 + // batched together with future updates in the same action.
607 + if (updateLane !== NoLane && updateLane === peekEntangledActionLane()) {
608 + didReadFromEntangledAsyncAction = true;
609 + }
610 +
611 if (newLastBaseUpdate !== null) {
612 const clone: Update<State> = {
613 // This update is going to be committed so we never want uncommit
packages/react-reconciler/src/ReactFiberHooks.js
+36 -20
@@ -145,9 +145,10 @@ import {
145 import type {ThenableState} from './ReactFiberThenable';
146 import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
147 import {
148 - requestAsyncActionContext,
149 - requestSyncActionContext,
148 + entangleAsyncAction,
149 peekEntangledActionLane,
150 + peekEntangledActionThenable,
151 + chainThenableValue,
152 } from './ReactFiberAsyncAction';
153 import {HostTransitionContext} from './ReactFiberHostContext';
154 import {requestTransitionLane} from './ReactFiberRootScheduler';
@@ -1274,6 +1275,7 @@ function updateReducerImpl<S, A>(
1275 let newBaseQueueFirst = null;
1276 let newBaseQueueLast: Update<S, A> | null = null;
1277 let update = first;
1278 + let didReadFromEntangledAsyncAction = false;
1279 do {
1280 // An extra OffscreenLane bit is added to updates that were made to
1281 // a hidden tree, so that we can distinguish them from updates that were
@@ -1317,6 +1319,13 @@ function updateReducerImpl<S, A>(
1319 } else {
1320 // This update does have sufficient priority.
1321
1322 + // Check if this update is part of a pending async action. If so,
1323 + // we'll need to suspend until the action has finished, so that it's
1324 + // batched together with future updates in the same action.
1325 + if (updateLane !== NoLane && updateLane === peekEntangledActionLane()) {
1326 + didReadFromEntangledAsyncAction = true;
1327 + }
1328 +
1329 // Check if this is an optimistic update.
1330 const revertLane = update.revertLane;
1331 if (!enableAsyncActions || revertLane === NoLane) {
@@ -1407,6 +1416,22 @@ function updateReducerImpl<S, A>(
1416 // different from the current state.
1417 if (!is(newState, hook.memoizedState)) {
1418 markWorkInProgressReceivedUpdate();
1419 +
1420 + // Check if this update is part of a pending async action. If so, we'll
1421 + // need to suspend until the action has finished, so that it's batched
1422 + // together with future updates in the same action.
1423 + // TODO: Once we support hooks inside useMemo (or an equivalent
1424 + // memoization boundary like Forget), hoist this logic so that it only
1425 + // suspends if the memo boundary produces a new value.
1426 + if (didReadFromEntangledAsyncAction) {
1427 + const entangledActionThenable = peekEntangledActionThenable();
1428 + if (entangledActionThenable !== null) {
1429 + // TODO: Instead of the throwing the thenable directly, throw a
1430 + // special object like `use` does so we can detect if it's captured
1431 + // by userspace.
1432 + throw entangledActionThenable;
1433 + }
1434 + }
1435 }
1436
1437 hook.memoizedState = newState;
@@ -1964,13 +1989,10 @@ function runFormStateAction<S, P>(
1989 () => finishRunningFormStateAction(actionQueue, (setState: any)),
1990 );
1991
1967 - const entangledResult = requestAsyncActionContext<S>(thenable, null);
1968 - setState((entangledResult: any));
1992 + entangleAsyncAction<Awaited<S>>(thenable);
1993 + setState((thenable: any));
1994 } else {
1970 - // This is either `returnValue` or a thenable that resolves to
1971 - // `returnValue`, depending on whether we're inside an async action scope.
1972 - const entangledResult = requestSyncActionContext<S>(returnValue, null);
1973 - setState((entangledResult: any));
1995 + setState((returnValue: any));
1996
1997 const nextState = ((returnValue: any): Awaited<S>);
1998 actionQueue.state = nextState;
@@ -2832,22 +2854,16 @@ function startTransition<S>(
2854 typeof returnValue.then === 'function'
2855 ) {
2856 const thenable = ((returnValue: any): Thenable<mixed>);
2835 - // This is a thenable that resolves to `finishedState` once the async
2836 - // action scope has finished.
2837 - const entangledResult = requestAsyncActionContext(
2857 + entangleAsyncAction<mixed>(thenable);
2858 + // Create a thenable that resolves to `finishedState` once the async
2859 + // action has completed.
2860 + const thenableForFinishedState = chainThenableValue(
2861 thenable,
2862 finishedState,
2863 );
2841 - dispatchSetState(fiber, queue, entangledResult);
2864 + dispatchSetState(fiber, queue, (thenableForFinishedState: any));
2865 } else {
2843 - // This is either `finishedState` or a thenable that resolves to
2844 - // `finishedState`, depending on whether we're inside an async
2845 - // action scope.
2846 - const entangledResult = requestSyncActionContext(
2847 - returnValue,
2848 - finishedState,
2849 - );
2850 - dispatchSetState(fiber, queue, entangledResult);
2866 + dispatchSetState(fiber, queue, finishedState);
2867 }
2868 } else {
2869 // Async actions are not enabled.
packages/react-reconciler/src/ReactFiberThrow.js
+18 -9
@@ -206,7 +206,7 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
206
207 function markSuspenseBoundaryShouldCapture(
208 suspenseBoundary: Fiber,
209 - returnFiber: Fiber,
209 + returnFiber: Fiber | null,
210 sourceFiber: Fiber,
211 root: FiberRoot,
212 rootRenderLanes: Lanes,
@@ -319,11 +319,11 @@ function markSuspenseBoundaryShouldCapture(
319
320 function throwException(
321 root: FiberRoot,
322 - returnFiber: Fiber,
322 + returnFiber: Fiber | null,
323 sourceFiber: Fiber,
324 value: mixed,
325 rootRenderLanes: Lanes,
326 -): void {
326 +): boolean {
327 // The source fiber did not complete.
328 sourceFiber.flags |= Incomplete;
329
@@ -446,7 +446,7 @@ function throwException(
446 attachPingListener(root, wakeable, rootRenderLanes);
447 }
448 }
449 - return;
449 + return false;
450 }
451 case OffscreenComponent: {
452 if (suspenseBoundary.mode & ConcurrentMode) {
@@ -476,7 +476,7 @@ function throwException(
476
477 attachPingListener(root, wakeable, rootRenderLanes);
478 }
479 - return;
479 + return false;
480 }
481 }
482 }
@@ -497,7 +497,7 @@ function throwException(
497 // and potentially log a warning. Revisit this for a future release.
498 attachPingListener(root, wakeable, rootRenderLanes);
499 renderDidSuspendDelayIfPossible();
500 - return;
500 + return false;
501 } else {
502 // In a legacy root, suspending without a boundary is always an error.
503 const uncaughtSuspenseError = new Error(
@@ -537,7 +537,7 @@ function throwException(
537 // Even though the user may not be affected by this error, we should
538 // still log it so it can be fixed.
539 queueHydrationError(createCapturedValueAtFiber(value, sourceFiber));
540 - return;
540 + return false;
541 }
542 } else {
543 // Otherwise, fall through to the error path.
@@ -549,6 +549,13 @@ function throwException(
549 // We didn't find a boundary that could handle this type of exception. Start
550 // over and traverse parent path again, this time treating the exception
551 // as an error.
552 +
553 + if (returnFiber === null) {
554 + // There's no return fiber, which means the root errored. This should never
555 + // happen. Return `true` to trigger a fatal error (panic).
556 + return true;
557 + }
558 +
559 let workInProgress: Fiber = returnFiber;
560 do {
561 switch (workInProgress.tag) {
@@ -559,7 +566,7 @@ function throwException(
566 workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
567 const update = createRootErrorUpdate(workInProgress, errorInfo, lane);
568 enqueueCapturedUpdate(workInProgress, update);
562 - return;
569 + return false;
570 }
571 case ClassComponent:
572 // Capture and retry
@@ -583,7 +590,7 @@ function throwException(
590 lane,
591 );
592 enqueueCapturedUpdate(workInProgress, update);
586 - return;
593 + return false;
594 }
595 break;
596 default:
@@ -592,6 +599,8 @@ function throwException(
599 // $FlowFixMe[incompatible-type] we bail out when we get a null
600 workInProgress = workInProgress.return;
601 } while (workInProgress !== null);
602 +
603 + return false;
604 }
605
606 export {throwException, createRootErrorUpdate, createClassErrorUpdate};
packages/react-reconciler/src/ReactFiberWorkLoop.js
+39 -27
@@ -1994,7 +1994,7 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
1994 // Unwind then continue with the normal work loop.
1995 workInProgressSuspendedReason = NotSuspended;
1996 workInProgressThrownValue = null;
1997 - throwAndUnwindWorkLoop(unitOfWork, thrownValue);
1997 + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
1998 break;
1999 }
2000 }
@@ -2114,7 +2114,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2114 // Unwind then continue with the normal work loop.
2115 workInProgressSuspendedReason = NotSuspended;
2116 workInProgressThrownValue = null;
2117 - throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2117 + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
2118 break;
2119 }
2120 case SuspendedOnData: {
@@ -2172,7 +2172,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2172 // Otherwise, unwind then continue with the normal work loop.
2173 workInProgressSuspendedReason = NotSuspended;
2174 workInProgressThrownValue = null;
2175 - throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2175 + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
2176 }
2177 break;
2178 }
@@ -2229,7 +2229,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2229 // Otherwise, unwind then continue with the normal work loop.
2230 workInProgressSuspendedReason = NotSuspended;
2231 workInProgressThrownValue = null;
2232 - throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2232 + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
2233 break;
2234 }
2235 case SuspendedOnDeprecatedThrowPromise: {
@@ -2239,7 +2239,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
2239 // always unwind.
2240 workInProgressSuspendedReason = NotSuspended;
2241 workInProgressThrownValue = null;
2242 - throwAndUnwindWorkLoop(unitOfWork, thrownValue);
2242 + throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
2243 break;
2244 }
2245 case SuspendedOnHydration: {
@@ -2464,7 +2464,11 @@ function replaySuspendedUnitOfWork(unitOfWork: Fiber): void {
2464 ReactCurrentOwner.current = null;
2465 }
2466
2467 -function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
2467 +function throwAndUnwindWorkLoop(
2468 + root: FiberRoot,
2469 + unitOfWork: Fiber,
2470 + thrownValue: mixed,
2471 +) {
2472 // This is a fork of performUnitOfWork specifcally for unwinding a fiber
2473 // that threw an exception.
2474 //
@@ -2473,40 +2477,32 @@ function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
2477 resetSuspendedWorkLoopOnUnwind(unitOfWork);
2478
2479 const returnFiber = unitOfWork.return;
2476 - if (returnFiber === null || workInProgressRoot === null) {
2477 - // Expected to be working on a non-root fiber. This is a fatal error
2478 - // because there's no ancestor that can handle it; the root is
2479 - // supposed to capture all errors that weren't caught by an error
2480 - // boundary.
2481 - workInProgressRootExitStatus = RootFatalErrored;
2482 - workInProgressRootFatalError = thrownValue;
2483 - // Set `workInProgress` to null. This represents advancing to the next
2484 - // sibling, or the parent if there are no siblings. But since the root
2485 - // has no siblings nor a parent, we set it to null. Usually this is
2486 - // handled by `completeUnitOfWork` or `unwindWork`, but since we're
2487 - // intentionally not calling those, we need set it here.
2488 - // TODO: Consider calling `unwindWork` to pop the contexts.
2489 - workInProgress = null;
2490 - return;
2491 - }
2492 -
2480 try {
2481 // Find and mark the nearest Suspense or error boundary that can handle
2482 // this "exception".
2496 - throwException(
2497 - workInProgressRoot,
2483 + const didFatal = throwException(
2484 + root,
2485 returnFiber,
2486 unitOfWork,
2487 thrownValue,
2488 workInProgressRootRenderLanes,
2489 );
2490 + if (didFatal) {
2491 + panicOnRootError(thrownValue);
2492 + return;
2493 + }
2494 } catch (error) {
2495 // We had trouble processing the error. An example of this happening is
2496 // when accessing the `componentDidCatch` property of an error boundary
2497 // throws an error. A weird edge case. There's a regression test for this.
2498 // To prevent an infinite loop, bubble the error up to the next parent.
2508 - workInProgress = returnFiber;
2509 - throw error;
2499 + if (returnFiber !== null) {
2500 + workInProgress = returnFiber;
2501 + throw error;
2502 + } else {
2503 + panicOnRootError(thrownValue);
2504 + return;
2505 + }
2506 }
2507
2508 if (unitOfWork.flags & Incomplete) {
@@ -2526,6 +2522,22 @@ function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
2522 }
2523 }
2524
2525 +function panicOnRootError(error: mixed) {
2526 + // There's no ancestor that can handle this exception. This should never
2527 + // happen because the root is supposed to capture all errors that weren't
2528 + // caught by an error boundary. This is a fatal error, or panic condition,
2529 + // because we've run out of ways to recover.
2530 + workInProgressRootExitStatus = RootFatalErrored;
2531 + workInProgressRootFatalError = error;
2532 + // Set `workInProgress` to null. This represents advancing to the next
2533 + // sibling, or the parent if there are no siblings. But since the root
2534 + // has no siblings nor a parent, we set it to null. Usually this is
2535 + // handled by `completeUnitOfWork` or `unwindWork`, but since we're
2536 + // intentionally not calling those, we need set it here.
2537 + // TODO: Consider calling `unwindWork` to pop the contexts.
2538 + workInProgress = null;
2539 +}
2540 +
2541 function completeUnitOfWork(unitOfWork: Fiber): void {
2542 // Attempt to complete the current unit of work, then move to the next
2543 // sibling. If there are no more siblings, return to the parent fiber.
packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
+371
@@ -1270,4 +1270,375 @@ describe('ReactAsyncActions', () => {
1270 assertLog(['Loading... (25%)', 'A', 'B']);
1271 expect(root).toMatchRenderedOutput(<div>B</div>);
1272 });
1273 +
1274 + // @gate enableAsyncActions
1275 + test(
1276 + 'optimistic state is not reverted until async action finishes, even if ' +
1277 + 'useTransition hook is unmounted',
1278 + async () => {
1279 + let startTransition;
1280 + function Updater() {
1281 + const [isPending, _start] = useTransition();
1282 + startTransition = _start;
1283 + return (
1284 + <span>
1285 + <Text text={'Pending: ' + isPending} />
1286 + </span>
1287 + );
1288 + }
1289 +
1290 + let setText;
1291 + let setOptimisticText;
1292 + function Sibling() {
1293 + const [canonicalText, _setText] = useState('A');
1294 + setText = _setText;
1295 +
1296 + const [text, _setOptimisticText] = useOptimistic(
1297 + canonicalText,
1298 + (_, optimisticText) => `${optimisticText} (loading...)`,
1299 + );
1300 + setOptimisticText = _setOptimisticText;
1301 +
1302 + return (
1303 + <span>
1304 + <Text text={text} />
1305 + </span>
1306 + );
1307 + }
1308 +
1309 + function App({showUpdater}) {
1310 + return (
1311 + <>
1312 + {showUpdater ? <Updater /> : null}
1313 + <Sibling />
1314 + </>
1315 + );
1316 + }
1317 +
1318 + const root = ReactNoop.createRoot();
1319 + await act(() => {
1320 + root.render(<App showUpdater={true} />);
1321 + });
1322 + assertLog(['Pending: false', 'A']);
1323 + expect(root).toMatchRenderedOutput(
1324 + <>
1325 + <span>Pending: false</span>
1326 + <span>A</span>
1327 + </>,
1328 + );
1329 +
1330 + // Start an async action that has multiple updates with async
1331 + // operations in between.
1332 + await act(() => {
1333 + startTransition(async () => {
1334 + Scheduler.log('Async action started');
1335 +
1336 + setOptimisticText('C');
1337 +
1338 + startTransition(() => setText('B'));
1339 +
1340 + await getText('Wait before updating to C');
1341 +
1342 + Scheduler.log('Async action ended');
1343 + startTransition(() => setText('C'));
1344 + });
1345 + });
1346 + assertLog([
1347 + 'Async action started',
1348 + 'Pending: true',
1349 + // Render an optimistic value
1350 + 'C (loading...)',
1351 + ]);
1352 + expect(root).toMatchRenderedOutput(
1353 + <>
1354 + <span>Pending: true</span>
1355 + <span>C (loading...)</span>
1356 + </>,
1357 + );
1358 +
1359 + // Delete the component that contains the useTransition hook. This
1360 + // component no longer blocks the transition from completing. But the
1361 + // we're still showing an optimistic state, because the async action has
1362 + // not yet finished.
1363 + await act(() => {
1364 + root.render(<App showUpdater={false} />);
1365 + });
1366 + assertLog(['C (loading...)']);
1367 + expect(root).toMatchRenderedOutput(<span>C (loading...)</span>);
1368 +
1369 + // Finish the async action. Now the optimistic state is reverted and we
1370 + // switch to the canonical value.
1371 + await act(() => resolveText('Wait before updating to C'));
1372 + assertLog(['Async action ended', 'C']);
1373 + expect(root).toMatchRenderedOutput(<span>C</span>);
1374 + },
1375 + );
1376 +
1377 + // @gate enableAsyncActions
1378 + test(
1379 + 'updates in an async action are entangled even if useTransition hook ' +
1380 + 'is unmounted before it finishes',
1381 + async () => {
1382 + let startTransition;
1383 + function Updater() {
1384 + const [isPending, _start] = useTransition();
1385 + startTransition = _start;
1386 + return (
1387 + <span>
1388 + <Text text={'Pending: ' + isPending} />
1389 + </span>
1390 + );
1391 + }
1392 +
1393 + let setText;
1394 + function Sibling() {
1395 + const [text, _setText] = useState('A');
1396 + setText = _setText;
1397 + return (
1398 + <span>
1399 + <Text text={text} />
1400 + </span>
1401 + );
1402 + }
1403 +
1404 + function App({showUpdater}) {
1405 + return (
1406 + <>
1407 + {showUpdater ? <Updater /> : null}
1408 + <Sibling />
1409 + </>
1410 + );
1411 + }
1412 +
1413 + const root = ReactNoop.createRoot();
1414 + await act(() => {
1415 + root.render(<App showUpdater={true} />);
1416 + });
1417 + assertLog(['Pending: false', 'A']);
1418 + expect(root).toMatchRenderedOutput(
1419 + <>
1420 + <span>Pending: false</span>
1421 + <span>A</span>
1422 + </>,
1423 + );
1424 +
1425 + // Start an async action that has multiple updates with async
1426 + // operations in between.
1427 + await act(() => {
1428 + startTransition(async () => {
1429 + Scheduler.log('Async action started');
1430 + startTransition(() => setText('B'));
1431 +
1432 + await getText('Wait before updating to C');
1433 +
1434 + Scheduler.log('Async action ended');
1435 + startTransition(() => setText('C'));
1436 + });
1437 + });
1438 + assertLog(['Async action started', 'Pending: true']);
1439 + expect(root).toMatchRenderedOutput(
1440 + <>
1441 + <span>Pending: true</span>
1442 + <span>A</span>
1443 + </>,
1444 + );
1445 +
1446 + // Delete the component that contains the useTransition hook. This
1447 + // component no longer blocks the transition from completing. But the
1448 + // pending update to Sibling should not be allowed to finish, because it's
1449 + // part of the async action.
1450 + await act(() => {
1451 + root.render(<App showUpdater={false} />);
1452 + });
1453 + assertLog(['A']);
1454 + expect(root).toMatchRenderedOutput(<span>A</span>);
1455 +
1456 + // Finish the async action. Notice the intermediate B state was never
1457 + // shown, because it was batched with the update that came later in the
1458 + // same action.
1459 + await act(() => resolveText('Wait before updating to C'));
1460 + assertLog(['Async action ended', 'C']);
1461 + expect(root).toMatchRenderedOutput(<span>C</span>);
1462 + },
1463 + );
1464 +
1465 + // @gate enableAsyncActions
1466 + test(
1467 + 'updates in an async action are entangled even if useTransition hook ' +
1468 + 'is unmounted before it finishes (class component)',
1469 + async () => {
1470 + let startTransition;
1471 + function Updater() {
1472 + const [isPending, _start] = useTransition();
1473 + startTransition = _start;
1474 + return (
1475 + <span>
1476 + <Text text={'Pending: ' + isPending} />
1477 + </span>
1478 + );
1479 + }
1480 +
1481 + let setText;
1482 + class Sibling extends React.Component {
1483 + state = {text: 'A'};
1484 + render() {
1485 + setText = text => this.setState({text});
1486 + return (
1487 + <span>
1488 + <Text text={this.state.text} />
1489 + </span>
1490 + );
1491 + }
1492 + }
1493 +
1494 + function App({showUpdater}) {
1495 + return (
1496 + <>
1497 + {showUpdater ? <Updater /> : null}
1498 + <Sibling />
1499 + </>
1500 + );
1501 + }
1502 +
1503 + const root = ReactNoop.createRoot();
1504 + await act(() => {
1505 + root.render(<App showUpdater={true} />);
1506 + });
1507 + assertLog(['Pending: false', 'A']);
1508 + expect(root).toMatchRenderedOutput(
1509 + <>
1510 + <span>Pending: false</span>
1511 + <span>A</span>
1512 + </>,
1513 + );
1514 +
1515 + // Start an async action that has multiple updates with async
1516 + // operations in between.
1517 + await act(() => {
1518 + startTransition(async () => {
1519 + Scheduler.log('Async action started');
1520 + startTransition(() => setText('B'));
1521 +
1522 + await getText('Wait before updating to C');
1523 +
1524 + Scheduler.log('Async action ended');
1525 + startTransition(() => setText('C'));
1526 + });
1527 + });
1528 + assertLog(['Async action started', 'Pending: true']);
1529 + expect(root).toMatchRenderedOutput(
1530 + <>
1531 + <span>Pending: true</span>
1532 + <span>A</span>
1533 + </>,
1534 + );
1535 +
1536 + // Delete the component that contains the useTransition hook. This
1537 + // component no longer blocks the transition from completing. But the
1538 + // pending update to Sibling should not be allowed to finish, because it's
1539 + // part of the async action.
1540 + await act(() => {
1541 + root.render(<App showUpdater={false} />);
1542 + });
1543 + assertLog(['A']);
1544 + expect(root).toMatchRenderedOutput(<span>A</span>);
1545 +
1546 + // Finish the async action. Notice the intermediate B state was never
1547 + // shown, because it was batched with the update that came later in the
1548 + // same action.
1549 + await act(() => resolveText('Wait before updating to C'));
1550 + assertLog(['Async action ended', 'C']);
1551 + expect(root).toMatchRenderedOutput(<span>C</span>);
1552 +
1553 + // Check that subsequent updates are unaffected.
1554 + await act(() => setText('D'));
1555 + assertLog(['D']);
1556 + expect(root).toMatchRenderedOutput(<span>D</span>);
1557 + },
1558 + );
1559 +
1560 + // @gate enableAsyncActions
1561 + test(
1562 + 'updates in an async action are entangled even if useTransition hook ' +
1563 + 'is unmounted before it finishes (root update)',
1564 + async () => {
1565 + let startTransition;
1566 + function Updater() {
1567 + const [isPending, _start] = useTransition();
1568 + startTransition = _start;
1569 + return (
1570 + <span>
1571 + <Text text={'Pending: ' + isPending} />
1572 + </span>
1573 + );
1574 + }
1575 +
1576 + let setShowUpdater;
1577 + function App({text}) {
1578 + const [showUpdater, _setShowUpdater] = useState(true);
1579 + setShowUpdater = _setShowUpdater;
1580 + return (
1581 + <>
1582 + {showUpdater ? <Updater /> : null}
1583 + <span>
1584 + <Text text={text} />
1585 + </span>
1586 + </>
1587 + );
1588 + }
1589 +
1590 + const root = ReactNoop.createRoot();
1591 + await act(() => {
1592 + root.render(<App text="A" />);
1593 + });
1594 + assertLog(['Pending: false', 'A']);
1595 + expect(root).toMatchRenderedOutput(
1596 + <>
1597 + <span>Pending: false</span>
1598 + <span>A</span>
1599 + </>,
1600 + );
1601 +
1602 + // Start an async action that has multiple updates with async
1603 + // operations in between.
1604 + await act(() => {
1605 + startTransition(async () => {
1606 + Scheduler.log('Async action started');
1607 + startTransition(() => root.render(<App text="B" />));
1608 +
1609 + await getText('Wait before updating to C');
1610 +
1611 + Scheduler.log('Async action ended');
1612 + startTransition(() => root.render(<App text="C" />));
1613 + });
1614 + });
1615 + assertLog(['Async action started', 'Pending: true']);
1616 + expect(root).toMatchRenderedOutput(
1617 + <>
1618 + <span>Pending: true</span>
1619 + <span>A</span>
1620 + </>,
1621 + );
1622 +
1623 + // Delete the component that contains the useTransition hook. This
1624 + // component no longer blocks the transition from completing. But the
1625 + // pending update to Sibling should not be allowed to finish, because it's
1626 + // part of the async action.
1627 + await act(() => setShowUpdater(false));
1628 + assertLog(['A']);
1629 + expect(root).toMatchRenderedOutput(<span>A</span>);
1630 +
1631 + // Finish the async action. Notice the intermediate B state was never
1632 + // shown, because it was batched with the update that came later in the
1633 + // same action.
1634 + await act(() => resolveText('Wait before updating to C'));
1635 + assertLog(['Async action ended', 'C']);
1636 + expect(root).toMatchRenderedOutput(<span>C</span>);
1637 +
1638 + // Check that subsequent updates are unaffected.
1639 + await act(() => root.render(<App text="D" />));
1640 + assertLog(['D']);
1641 + expect(root).toMatchRenderedOutput(<span>D</span>);
1642 + },
1643 + );
1644 });