@samitouri / QOS-React-2 / commits / cd2b79dedd

Initial (client-only) async actions support (#26621)

Implements initial (client-only) support for async actions behind a flag. This is an experimental feature and the design isn't completely finalized but we're getting closer. It will be layered alongside other features we're working on, so it may not feel complete when considered in isolation. The basic description is you can pass an async function to `startTransition` and all the transition updates that are scheduled inside that async function will be grouped together. The `isPending` flag will be set to true immediately, and only set back to false once the async action has completed (as well as all the updates that it triggers). The ideal behavior would be that all updates spawned by the async action are automatically inferred and grouped together; however, doing this properly requires the upcoming (stage 2) Async Context API, which is not yet implemented by browsers. In the meantime, we will fake this by grouping together all transition updates that occur until the async function has terminated. This can lead to overgrouping between unrelated actions, which is not wrong per se, just not ideal. If the `useTransition` hook is removed from the UI before an async action has completed — for example, if the user navigates to a new page — subsequent transitions will no longer be grouped with together with that action. Another consequence of the lack of Async Context is that if you call `setState` inside an action but after an `await`, it must be wrapped in `startTransition` in order to be grouped properly. If we didn't require this, then there would be no way to distinguish action updates from urgent updates caused by user input, too. This is an unfortunate footgun but we can likely detect the most common mistakes using a lint rule. Once Async Context lands in browsers, we can start warning in dev if we detect an update that hasn't been wrapped in `startTransition`. Then, longer term, once the feature is ubiquitous, we can rely on it for real and allow you to call `setState` without the additional wrapper. Things that are _not_ yet implemented in this PR, but will be added as follow ups: - Support for non-hook form of `startTransition` - Canceling the async action scope if the `useTransition` hook is deleted from the UI - Anything related to server actions

Andrew Clark committed Apr 19, 2023 at 13:33 UTC cd2b79dedd6d81abb0b01d38396afa083feaf9e9
13 files changed +771 -55
packages/react-reconciler/src/ReactFiberAsyncAction.js new
+130
@@ -0,0 +1,130 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Wakeable} from 'shared/ReactTypes';
11 +import type {Lane} from './ReactFiberLane';
12 +import {requestTransitionLane} from './ReactFiberRootScheduler';
13 +
14 +interface AsyncActionImpl {
15 + lane: Lane;
16 + listeners: Array<(false) => mixed>;
17 + count: number;
18 + then(
19 + onFulfill: (value: boolean) => mixed,
20 + onReject: (error: mixed) => mixed,
21 + ): void;
22 +}
23 +
24 +interface PendingAsyncAction extends AsyncActionImpl {
25 + status: 'pending';
26 +}
27 +
28 +interface FulfilledAsyncAction extends AsyncActionImpl {
29 + status: 'fulfilled';
30 + value: boolean;
31 +}
32 +
33 +interface RejectedAsyncAction extends AsyncActionImpl {
34 + status: 'rejected';
35 + reason: mixed;
36 +}
37 +
38 +type AsyncAction =
39 + | PendingAsyncAction
40 + | FulfilledAsyncAction
41 + | RejectedAsyncAction;
42 +
43 +let currentAsyncAction: AsyncAction | null = null;
44 +
45 +export function requestAsyncActionContext(
46 + actionReturnValue: mixed,
47 +): AsyncAction | false {
48 + if (
49 + actionReturnValue !== null &&
50 + typeof actionReturnValue === 'object' &&
51 + typeof actionReturnValue.then === 'function'
52 + ) {
53 + // This is an async action.
54 + //
55 + // Return a thenable that resolves once the action scope (i.e. the async
56 + // function passed to startTransition) has finished running. The fulfilled
57 + // value is `false` to represent that the action is not pending.
58 + const thenable: Wakeable = (actionReturnValue: any);
59 + if (currentAsyncAction === null) {
60 + // There's no outer async action scope. Create a new one.
61 + const asyncAction: AsyncAction = {
62 + lane: requestTransitionLane(),
63 + listeners: [],
64 + count: 0,
65 + status: 'pending',
66 + value: false,
67 + reason: undefined,
68 + then(resolve: boolean => mixed) {
69 + asyncAction.listeners.push(resolve);
70 + },
71 + };
72 + attachPingListeners(thenable, asyncAction);
73 + currentAsyncAction = asyncAction;
74 + return asyncAction;
75 + } else {
76 + // Inherit the outer scope.
77 + const asyncAction: AsyncAction = (currentAsyncAction: any);
78 + attachPingListeners(thenable, asyncAction);
79 + return asyncAction;
80 + }
81 + } else {
82 + // This is not an async action, but it may be part of an outer async action.
83 + if (currentAsyncAction === null) {
84 + // There's no outer async action scope.
85 + return false;
86 + } else {
87 + // Inherit the outer scope.
88 + return currentAsyncAction;
89 + }
90 + }
91 +}
92 +
93 +export function peekAsyncActionContext(): AsyncAction | null {
94 + return currentAsyncAction;
95 +}
96 +
97 +function attachPingListeners(thenable: Wakeable, asyncAction: AsyncAction) {
98 + asyncAction.count++;
99 + thenable.then(
100 + () => {
101 + if (--asyncAction.count === 0) {
102 + const fulfilledAsyncAction: FulfilledAsyncAction = (asyncAction: any);
103 + fulfilledAsyncAction.status = 'fulfilled';
104 + completeAsyncActionScope(asyncAction);
105 + }
106 + },
107 + (error: mixed) => {
108 + if (--asyncAction.count === 0) {
109 + const rejectedAsyncAction: RejectedAsyncAction = (asyncAction: any);
110 + rejectedAsyncAction.status = 'rejected';
111 + rejectedAsyncAction.reason = error;
112 + completeAsyncActionScope(asyncAction);
113 + }
114 + },
115 + );
116 + return asyncAction;
117 +}
118 +
119 +function completeAsyncActionScope(action: AsyncAction) {
120 + if (currentAsyncAction === action) {
121 + currentAsyncAction = null;
122 + }
123 +
124 + const listeners = action.listeners;
125 + action.listeners = [];
126 + for (let i = 0; i < listeners.length; i++) {
127 + const listener = listeners[i];
128 + listener(false);
129 + }
130 +}
packages/react-reconciler/src/ReactFiberHooks.js
+77 -34
@@ -15,6 +15,7 @@ import type {
15 StartTransitionOptions,
16 Usable,
17 Thenable,
18 + RejectedThenable,
19 } from 'shared/ReactTypes';
20 import type {
21 Fiber,
@@ -41,6 +42,7 @@ import {
42 enableUseEffectEventHook,
43 enableLegacyCache,
44 debugRenderPhaseSideEffectsForStrictMode,
45 + enableAsyncActions,
46 } from 'shared/ReactFeatureFlags';
47 import {
48 REACT_CONTEXT_TYPE,
@@ -143,6 +145,7 @@ import {
145 } from './ReactFiberThenable';
146 import type {ThenableState} from './ReactFiberThenable';
147 import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
148 +import {requestAsyncActionContext} from './ReactFiberAsyncAction';
149
150 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
151
@@ -947,38 +950,40 @@ if (enableUseMemoCacheHook) {
950 };
951 }
952
953 +function useThenable<T>(thenable: Thenable<T>): T {
954 + // Track the position of the thenable within this fiber.
955 + const index = thenableIndexCounter;
956 + thenableIndexCounter += 1;
957 + if (thenableState === null) {
958 + thenableState = createThenableState();
959 + }
960 + const result = trackUsedThenable(thenableState, thenable, index);
961 + if (
962 + currentlyRenderingFiber.alternate === null &&
963 + (workInProgressHook === null
964 + ? currentlyRenderingFiber.memoizedState === null
965 + : workInProgressHook.next === null)
966 + ) {
967 + // Initial render, and either this is the first time the component is
968 + // called, or there were no Hooks called after this use() the previous
969 + // time (perhaps because it threw). Subsequent Hook calls should use the
970 + // mount dispatcher.
971 + if (__DEV__) {
972 + ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
973 + } else {
974 + ReactCurrentDispatcher.current = HooksDispatcherOnMount;
975 + }
976 + }
977 + return result;
978 +}
979 +
980 function use<T>(usable: Usable<T>): T {
981 if (usable !== null && typeof usable === 'object') {
982 // $FlowFixMe[method-unbinding]
983 if (typeof usable.then === 'function') {
984 // This is a thenable.
985 const thenable: Thenable<T> = (usable: any);
956 -
957 - // Track the position of the thenable within this fiber.
958 - const index = thenableIndexCounter;
959 - thenableIndexCounter += 1;
960 -
961 - if (thenableState === null) {
962 - thenableState = createThenableState();
963 - }
964 - const result = trackUsedThenable(thenableState, thenable, index);
965 - if (
966 - currentlyRenderingFiber.alternate === null &&
967 - (workInProgressHook === null
968 - ? currentlyRenderingFiber.memoizedState === null
969 - : workInProgressHook.next === null)
970 - ) {
971 - // Initial render, and either this is the first time the component is
972 - // called, or there were no Hooks called after this use() the previous
973 - // time (perhaps because it threw). Subsequent Hook calls should use the
974 - // mount dispatcher.
975 - if (__DEV__) {
976 - ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
977 - } else {
978 - ReactCurrentDispatcher.current = HooksDispatcherOnMount;
979 - }
980 - }
981 - return result;
986 + return useThenable(thenable);
987 } else if (
988 usable.$$typeof === REACT_CONTEXT_TYPE ||
989 usable.$$typeof === REACT_SERVER_CONTEXT_TYPE
@@ -2400,8 +2405,8 @@ function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
2405 }
2406
2407 function startTransition(
2403 - setPending: boolean => void,
2404 - callback: () => void,
2408 + setPending: (Thenable<boolean> | boolean) => void,
2409 + callback: () => mixed,
2410 options?: StartTransitionOptions,
2411 ): void {
2412 const previousPriority = getCurrentUpdatePriority();
@@ -2427,8 +2432,36 @@ function startTransition(
2432 }
2433
2434 try {
2430 - setPending(false);
2431 - callback();
2435 + if (enableAsyncActions) {
2436 + const returnValue = callback();
2437 +
2438 + // `isPending` is either `false` or a thenable that resolves to `false`,
2439 + // depending on whether the action scope is an async function. In the
2440 + // async case, the resulting render will suspend until the async action
2441 + // scope has finished.
2442 + const isPending = requestAsyncActionContext(returnValue);
2443 + setPending(isPending);
2444 + } else {
2445 + // Async actions are not enabled.
2446 + setPending(false);
2447 + callback();
2448 + }
2449 + } catch (error) {
2450 + if (enableAsyncActions) {
2451 + // This is a trick to get the `useTransition` hook to rethrow the error.
2452 + // When it unwraps the thenable with the `use` algorithm, the error
2453 + // will be thrown.
2454 + const rejectedThenable: RejectedThenable<boolean> = {
2455 + then() {},
2456 + status: 'rejected',
2457 + reason: error,
2458 + };
2459 + setPending(rejectedThenable);
2460 + } else {
2461 + // The error rethrowing behavior is only enabled when the async actions
2462 + // feature is on, even for sync actions.
2463 + throw error;
2464 + }
2465 } finally {
2466 setCurrentUpdatePriority(previousPriority);
2467
@@ -2454,21 +2487,26 @@ function mountTransition(): [
2487 boolean,
2488 (callback: () => void, options?: StartTransitionOptions) => void,
2489 ] {
2457 - const [isPending, setPending] = mountState(false);
2490 + const [, setPending] = mountState((false: Thenable<boolean> | boolean));
2491 // The `start` method never changes.
2492 const start = startTransition.bind(null, setPending);
2493 const hook = mountWorkInProgressHook();
2494 hook.memoizedState = start;
2462 - return [isPending, start];
2495 + return [false, start];
2496 }
2497
2498 function updateTransition(): [
2499 boolean,
2500 (callback: () => void, options?: StartTransitionOptions) => void,
2501 ] {
2469 - const [isPending] = updateState(false);
2502 + const [booleanOrThenable] = updateState(false);
2503 const hook = updateWorkInProgressHook();
2504 const start = hook.memoizedState;
2505 + const isPending =
2506 + typeof booleanOrThenable === 'boolean'
2507 + ? booleanOrThenable
2508 + : // This will suspend until the async action scope has finished.
2509 + useThenable(booleanOrThenable);
2510 return [isPending, start];
2511 }
2512
@@ -2476,9 +2514,14 @@ function rerenderTransition(): [
2514 boolean,
2515 (callback: () => void, options?: StartTransitionOptions) => void,
2516 ] {
2479 - const [isPending] = rerenderState(false);
2517 + const [booleanOrThenable] = rerenderState(false);
2518 const hook = updateWorkInProgressHook();
2519 const start = hook.memoizedState;
2520 + const isPending =
2521 + typeof booleanOrThenable === 'boolean'
2522 + ? booleanOrThenable
2523 + : // This will suspend until the async action scope has finished.
2524 + useThenable(booleanOrThenable);
2525 return [isPending, start];
2526 }
2527
packages/react-reconciler/src/ReactFiberRootScheduler.js
+14 -6
@@ -22,6 +22,7 @@ import {
22 markStarvedLanesAsExpired,
23 markRootEntangled,
24 mergeLanes,
25 + claimNextTransitionLane,
26 } from './ReactFiberLane';
27 import {
28 CommitContext,
@@ -78,7 +79,7 @@ let mightHavePendingSyncWork: boolean = false;
79
80 let isFlushingWork: boolean = false;
81
81 -let currentEventTransitionLane: Lane = NoLanes;
82 +let currentEventTransitionLane: Lane = NoLane;
83
84 export function ensureRootIsScheduled(root: FiberRoot): void {
85 // This function is called whenever a root receives an update. It does two
@@ -491,10 +492,17 @@ function scheduleImmediateTask(cb: () => mixed) {
492 }
493 }
494
494 -export function getCurrentEventTransitionLane(): Lane {
495 +export function requestTransitionLane(): Lane {
496 + // The algorithm for assigning an update to a lane should be stable for all
497 + // updates at the same priority within the same event. To do this, the
498 + // inputs to the algorithm must be the same.
499 + //
500 + // The trick we use is to cache the first of each of these inputs within an
501 + // event. Then reset the cached values once we can be sure the event is
502 + // over. Our heuristic for that is whenever we enter a concurrent work loop.
503 + if (currentEventTransitionLane === NoLane) {
504 + // All transitions within the same event are assigned the same lane.
505 + currentEventTransitionLane = claimNextTransitionLane();
506 + }
507 return currentEventTransitionLane;
508 }
497 -
498 -export function setCurrentEventTransitionLane(lane: Lane): void {
499 - currentEventTransitionLane = lane;
500 -}
packages/react-reconciler/src/ReactFiberWorkLoop.js
+11 -15
@@ -129,7 +129,6 @@ import {
129 NoLanes,
130 NoLane,
131 SyncLane,
132 - claimNextTransitionLane,
132 claimNextRetryLane,
133 includesSyncLane,
134 isSubsetOfLanes,
@@ -278,10 +277,10 @@ import {
277 flushSyncWorkOnAllRoots,
278 flushSyncWorkOnLegacyRootsOnly,
279 getContinuationForRoot,
281 - getCurrentEventTransitionLane,
282 - setCurrentEventTransitionLane,
280 + requestTransitionLane,
281 } from './ReactFiberRootScheduler';
282 import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
283 +import {peekAsyncActionContext} from './ReactFiberAsyncAction';
284
285 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
286
@@ -633,18 +632,15 @@ export function requestUpdateLane(fiber: Fiber): Lane {
632
633 transition._updatedFibers.add(fiber);
634 }
636 - // The algorithm for assigning an update to a lane should be stable for all
637 - // updates at the same priority within the same event. To do this, the
638 - // inputs to the algorithm must be the same.
639 - //
640 - // The trick we use is to cache the first of each of these inputs within an
641 - // event. Then reset the cached values once we can be sure the event is
642 - // over. Our heuristic for that is whenever we enter a concurrent work loop.
643 - if (getCurrentEventTransitionLane() === NoLane) {
644 - // All transitions within the same event are assigned the same lane.
645 - setCurrentEventTransitionLane(claimNextTransitionLane());
646 - }
647 - return getCurrentEventTransitionLane();
635 +
636 + const asyncAction = peekAsyncActionContext();
637 + return asyncAction !== null
638 + ? // We're inside an async action scope. Reuse the same lane.
639 + asyncAction.lane
640 + : // We may or may not be inside an async action scope. If we are, this
641 + // is the first update in that scope. Either way, we need to get a
642 + // fresh transition lane.
643 + requestTransitionLane();
644 }
645
646 // Updates originating inside certain React methods, like flushSync, have
packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js new
+530
@@ -0,0 +1,530 @@
1 +let React;
2 +let ReactNoop;
3 +let Scheduler;
4 +let act;
5 +let assertLog;
6 +let useTransition;
7 +let useState;
8 +let textCache;
9 +
10 +describe('ReactAsyncActions', () => {
11 + beforeEach(() => {
12 + jest.resetModules();
13 +
14 + React = require('react');
15 + ReactNoop = require('react-noop-renderer');
16 + Scheduler = require('scheduler');
17 + act = require('internal-test-utils').act;
18 + assertLog = require('internal-test-utils').assertLog;
19 + useTransition = React.useTransition;
20 + useState = React.useState;
21 +
22 + textCache = new Map();
23 + });
24 +
25 + function resolveText(text) {
26 + const record = textCache.get(text);
27 + if (record === undefined) {
28 + const newRecord = {
29 + status: 'resolved',
30 + value: text,
31 + };
32 + textCache.set(text, newRecord);
33 + } else if (record.status === 'pending') {
34 + const thenable = record.value;
35 + record.status = 'resolved';
36 + record.value = text;
37 + thenable.pings.forEach(t => t());
38 + }
39 + }
40 +
41 + function readText(text) {
42 + const record = textCache.get(text);
43 + if (record !== undefined) {
44 + switch (record.status) {
45 + case 'pending':
46 + Scheduler.log(`Suspend! [${text}]`);
47 + throw record.value;
48 + case 'rejected':
49 + throw record.value;
50 + case 'resolved':
51 + return record.value;
52 + }
53 + } else {
54 + Scheduler.log(`Suspend! [${text}]`);
55 + const thenable = {
56 + pings: [],
57 + then(resolve) {
58 + if (newRecord.status === 'pending') {
59 + thenable.pings.push(resolve);
60 + } else {
61 + Promise.resolve().then(() => resolve(newRecord.value));
62 + }
63 + },
64 + };
65 +
66 + const newRecord = {
67 + status: 'pending',
68 + value: thenable,
69 + };
70 + textCache.set(text, newRecord);
71 +
72 + throw thenable;
73 + }
74 + }
75 +
76 + function getText(text) {
77 + const record = textCache.get(text);
78 + if (record === undefined) {
79 + const thenable = {
80 + pings: [],
81 + then(resolve) {
82 + if (newRecord.status === 'pending') {
83 + thenable.pings.push(resolve);
84 + } else {
85 + Promise.resolve().then(() => resolve(newRecord.value));
86 + }
87 + },
88 + };
89 + const newRecord = {
90 + status: 'pending',
91 + value: thenable,
92 + };
93 + textCache.set(text, newRecord);
94 + return thenable;
95 + } else {
96 + switch (record.status) {
97 + case 'pending':
98 + return record.value;
99 + case 'rejected':
100 + return Promise.reject(record.value);
101 + case 'resolved':
102 + return Promise.resolve(record.value);
103 + }
104 + }
105 + }
106 +
107 + function Text({text}) {
108 + Scheduler.log(text);
109 + return text;
110 + }
111 +
112 + function AsyncText({text}) {
113 + readText(text);
114 + Scheduler.log(text);
115 + return text;
116 + }
117 +
118 + // @gate enableAsyncActions
119 + test('isPending remains true until async action finishes', async () => {
120 + let startTransition;
121 + function App() {
122 + const [isPending, _start] = useTransition();
123 + startTransition = _start;
124 + return <Text text={'Pending: ' + isPending} />;
125 + }
126 +
127 + const root = ReactNoop.createRoot();
128 + await act(() => {
129 + root.render(<App />);
130 + });
131 + assertLog(['Pending: false']);
132 + expect(root).toMatchRenderedOutput('Pending: false');
133 +
134 + // At the start of an async action, isPending is set to true.
135 + await act(() => {
136 + startTransition(async () => {
137 + Scheduler.log('Async action started');
138 + await getText('Wait');
139 + Scheduler.log('Async action ended');
140 + });
141 + });
142 + assertLog(['Async action started', 'Pending: true']);
143 + expect(root).toMatchRenderedOutput('Pending: true');
144 +
145 + // Once the action finishes, isPending is set back to false.
146 + await act(() => resolveText('Wait'));
147 + assertLog(['Async action ended', 'Pending: false']);
148 + expect(root).toMatchRenderedOutput('Pending: false');
149 + });
150 +
151 + // @gate enableAsyncActions
152 + test('multiple updates in an async action scope are entangled together', async () => {
153 + let startTransition;
154 + function App({text}) {
155 + const [isPending, _start] = useTransition();
156 + startTransition = _start;
157 + return (
158 + <>
159 + <span>
160 + <Text text={'Pending: ' + isPending} />
161 + </span>
162 + <span>
163 + <Text text={text} />
164 + </span>
165 + </>
166 + );
167 + }
168 +
169 + const root = ReactNoop.createRoot();
170 + await act(() => {
171 + root.render(<App text="A" />);
172 + });
173 + assertLog(['Pending: false', 'A']);
174 + expect(root).toMatchRenderedOutput(
175 + <>
176 + <span>Pending: false</span>
177 + <span>A</span>
178 + </>,
179 + );
180 +
181 + await act(() => {
182 + startTransition(async () => {
183 + Scheduler.log('Async action started');
184 + await getText('Yield before updating');
185 + Scheduler.log('Async action ended');
186 + startTransition(() => root.render(<App text="B" />));
187 + });
188 + });
189 + assertLog(['Async action started', 'Pending: true', 'A']);
190 + expect(root).toMatchRenderedOutput(
191 + <>
192 + <span>Pending: true</span>
193 + <span>A</span>
194 + </>,
195 + );
196 +
197 + await act(() => resolveText('Yield before updating'));
198 + assertLog(['Async action ended', 'Pending: false', 'B']);
199 + expect(root).toMatchRenderedOutput(
200 + <>
201 + <span>Pending: false</span>
202 + <span>B</span>
203 + </>,
204 + );
205 + });
206 +
207 + // @gate enableAsyncActions
208 + test('multiple async action updates in the same scope are entangled together', async () => {
209 + let setStepA;
210 + function A() {
211 + const [step, setStep] = useState(0);
212 + setStepA = setStep;
213 + return <AsyncText text={'A' + step} />;
214 + }
215 +
216 + let setStepB;
217 + function B() {
218 + const [step, setStep] = useState(0);
219 + setStepB = setStep;
220 + return <AsyncText text={'B' + step} />;
221 + }
222 +
223 + let setStepC;
224 + function C() {
225 + const [step, setStep] = useState(0);
226 + setStepC = setStep;
227 + return <AsyncText text={'C' + step} />;
228 + }
229 +
230 + let startTransition;
231 + function App() {
232 + const [isPending, _start] = useTransition();
233 + startTransition = _start;
234 + return (
235 + <>
236 + <span>
237 + <Text text={'Pending: ' + isPending} />
238 + </span>
239 + <span>
240 + <A />, <B />, <C />
241 + </span>
242 + </>
243 + );
244 + }
245 +
246 + const root = ReactNoop.createRoot();
247 + resolveText('A0');
248 + resolveText('B0');
249 + resolveText('C0');
250 + await act(() => {
251 + root.render(<App text="A" />);
252 + });
253 + assertLog(['Pending: false', 'A0', 'B0', 'C0']);
254 + expect(root).toMatchRenderedOutput(
255 + <>
256 + <span>Pending: false</span>
257 + <span>A0, B0, C0</span>
258 + </>,
259 + );
260 +
261 + await act(() => {
262 + startTransition(async () => {
263 + Scheduler.log('Async action started');
264 + setStepA(1);
265 + await getText('Wait before updating B');
266 + startTransition(() => setStepB(1));
267 + await getText('Wait before updating C');
268 + startTransition(() => setStepC(1));
269 + Scheduler.log('Async action ended');
270 + });
271 + });
272 + assertLog(['Async action started', 'Pending: true', 'A0', 'B0', 'C0']);
273 + expect(root).toMatchRenderedOutput(
274 + <>
275 + <span>Pending: true</span>
276 + <span>A0, B0, C0</span>
277 + </>,
278 + );
279 +
280 + // This will schedule an update on B, but nothing will render yet because
281 + // the async action scope hasn't finished.
282 + await act(() => resolveText('Wait before updating B'));
283 + assertLog([]);
284 + expect(root).toMatchRenderedOutput(
285 + <>
286 + <span>Pending: true</span>
287 + <span>A0, B0, C0</span>
288 + </>,
289 + );
290 +
291 + // This will schedule an update on C, and also the async action scope
292 + // will end. This will allow React to attempt to render the updates.
293 + await act(() => resolveText('Wait before updating C'));
294 + assertLog(['Async action ended', 'Pending: false', 'Suspend! [A1]']);
295 + expect(root).toMatchRenderedOutput(
296 + <>
297 + <span>Pending: true</span>
298 + <span>A0, B0, C0</span>
299 + </>,
300 + );
301 +
302 + // Progressively load the all the data. Because they are all entangled
303 + // together, only when the all of A, B, and C updates are unblocked is the
304 + // render allowed to proceed.
305 + await act(() => resolveText('A1'));
306 + assertLog(['Pending: false', 'A1', 'Suspend! [B1]']);
307 + expect(root).toMatchRenderedOutput(
308 + <>
309 + <span>Pending: true</span>
310 + <span>A0, B0, C0</span>
311 + </>,
312 + );
313 + await act(() => resolveText('B1'));
314 + assertLog(['Pending: false', 'A1', 'B1', 'Suspend! [C1]']);
315 + expect(root).toMatchRenderedOutput(
316 + <>
317 + <span>Pending: true</span>
318 + <span>A0, B0, C0</span>
319 + </>,
320 + );
321 +
322 + // Finally, all the data has loaded and the transition is complete.
323 + await act(() => resolveText('C1'));
324 + assertLog(['Pending: false', 'A1', 'B1', 'C1']);
325 + expect(root).toMatchRenderedOutput(
326 + <>
327 + <span>Pending: false</span>
328 + <span>A1, B1, C1</span>
329 + </>,
330 + );
331 + });
332 +
333 + // @gate enableAsyncActions
334 + test('urgent updates are not blocked during an async action', async () => {
335 + let setStepA;
336 + function A() {
337 + const [step, setStep] = useState(0);
338 + setStepA = setStep;
339 + return <Text text={'A' + step} />;
340 + }
341 +
342 + let setStepB;
343 + function B() {
344 + const [step, setStep] = useState(0);
345 + setStepB = setStep;
346 + return <Text text={'B' + step} />;
347 + }
348 +
349 + let startTransition;
350 + function App() {
351 + const [isPending, _start] = useTransition();
352 + startTransition = _start;
353 + return (
354 + <>
355 + <span>
356 + <Text text={'Pending: ' + isPending} />
357 + </span>
358 + <span>
359 + <A />, <B />
360 + </span>
361 + </>
362 + );
363 + }
364 +
365 + const root = ReactNoop.createRoot();
366 + await act(() => {
367 + root.render(<App text="A" />);
368 + });
369 + assertLog(['Pending: false', 'A0', 'B0']);
370 + expect(root).toMatchRenderedOutput(
371 + <>
372 + <span>Pending: false</span>
373 + <span>A0, B0</span>
374 + </>,
375 + );
376 +
377 + await act(() => {
378 + startTransition(async () => {
379 + Scheduler.log('Async action started');
380 + startTransition(() => setStepA(1));
381 + await getText('Wait');
382 + Scheduler.log('Async action ended');
383 + });
384 + });
385 + assertLog(['Async action started', 'Pending: true', 'A0', 'B0']);
386 + expect(root).toMatchRenderedOutput(
387 + <>
388 + <span>Pending: true</span>
389 + <span>A0, B0</span>
390 + </>,
391 + );
392 +
393 + // Update B at urgent priority. This should be allowed to finish.
394 + await act(() => setStepB(1));
395 + assertLog(['B1']);
396 + expect(root).toMatchRenderedOutput(
397 + <>
398 + <span>Pending: true</span>
399 + <span>A0, B1</span>
400 + </>,
401 + );
402 +
403 + // Finish the async action.
404 + await act(() => resolveText('Wait'));
405 + assertLog(['Async action ended', 'Pending: false', 'A1', 'B1']);
406 + expect(root).toMatchRenderedOutput(
407 + <>
408 + <span>Pending: false</span>
409 + <span>A1, B1</span>
410 + </>,
411 + );
412 + });
413 +
414 + // @gate enableAsyncActions
415 + test("if a sync action throws, it's rethrown from the `useTransition`", async () => {
416 + class ErrorBoundary extends React.Component {
417 + state = {error: null};
418 + static getDerivedStateFromError(error) {
419 + return {error};
420 + }
421 + render() {
422 + if (this.state.error) {
423 + return <Text text={this.state.error.message} />;
424 + }
425 + return this.props.children;
426 + }
427 + }
428 +
429 + let startTransition;
430 + function App() {
431 + const [isPending, _start] = useTransition();
432 + startTransition = _start;
433 + return <Text text={'Pending: ' + isPending} />;
434 + }
435 +
436 + const root = ReactNoop.createRoot();
437 + await act(() => {
438 + root.render(
439 + <ErrorBoundary>
440 + <App />
441 + </ErrorBoundary>,
442 + );
443 + });
444 + assertLog(['Pending: false']);
445 + expect(root).toMatchRenderedOutput('Pending: false');
446 +
447 + await act(() => {
448 + startTransition(() => {
449 + throw new Error('Oops!');
450 + });
451 + });
452 + assertLog(['Pending: true', 'Oops!', 'Oops!']);
453 + expect(root).toMatchRenderedOutput('Oops!');
454 + });
455 +
456 + // @gate enableAsyncActions
457 + test("if an async action throws, it's rethrown from the `useTransition`", async () => {
458 + class ErrorBoundary extends React.Component {
459 + state = {error: null};
460 + static getDerivedStateFromError(error) {
461 + return {error};
462 + }
463 + render() {
464 + if (this.state.error) {
465 + return <Text text={this.state.error.message} />;
466 + }
467 + return this.props.children;
468 + }
469 + }
470 +
471 + let startTransition;
472 + function App() {
473 + const [isPending, _start] = useTransition();
474 + startTransition = _start;
475 + return <Text text={'Pending: ' + isPending} />;
476 + }
477 +
478 + const root = ReactNoop.createRoot();
479 + await act(() => {
480 + root.render(
481 + <ErrorBoundary>
482 + <App />
483 + </ErrorBoundary>,
484 + );
485 + });
486 + assertLog(['Pending: false']);
487 + expect(root).toMatchRenderedOutput('Pending: false');
488 +
489 + await act(() => {
490 + startTransition(async () => {
491 + Scheduler.log('Async action started');
492 + await getText('Wait');
493 + throw new Error('Oops!');
494 + });
495 + });
496 + assertLog(['Async action started', 'Pending: true']);
497 + expect(root).toMatchRenderedOutput('Pending: true');
498 +
499 + await act(() => resolveText('Wait'));
500 + assertLog(['Oops!', 'Oops!']);
501 + expect(root).toMatchRenderedOutput('Oops!');
502 + });
503 +
504 + // @gate !enableAsyncActions
505 + test('when enableAsyncActions is disabled, and a sync action throws, `isPending` is turned off', async () => {
506 + let startTransition;
507 + function App() {
508 + const [isPending, _start] = useTransition();
509 + startTransition = _start;
510 + return <Text text={'Pending: ' + isPending} />;
511 + }
512 +
513 + const root = ReactNoop.createRoot();
514 + await act(() => {
515 + root.render(<App />);
516 + });
517 + assertLog(['Pending: false']);
518 + expect(root).toMatchRenderedOutput('Pending: false');
519 +
520 + await act(() => {
521 + expect(() => {
522 + startTransition(() => {
523 + throw new Error('Oops!');
524 + });
525 + }).toThrow('Oops!');
526 + });
527 + assertLog(['Pending: true', 'Pending: false']);
528 + expect(root).toMatchRenderedOutput('Pending: false');
529 + });
530 +});
packages/shared/ReactFeatureFlags.js
+2
@@ -120,6 +120,8 @@ export const enableFizzExternalRuntime = true;
120 // Performance related test
121 export const diffInCommitPhase = __EXPERIMENTAL__;
122
123 +export const enableAsyncActions = __EXPERIMENTAL__;
124 +
125 // -----------------------------------------------------------------------------
126 // Chopping Block
127 //
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -82,6 +82,7 @@ export const useModernStrictMode = false;
82 export const enableFizzExternalRuntime = false;
83
84 export const diffInCommitPhase = true;
85 +export const enableAsyncActions = false;
86
87 // Flow magic to verify the exports of this file match the original version.
88 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -72,6 +72,7 @@ export const enableFizzExternalRuntime = false;
72 export const enableDeferRootSchedulingToMicrotask = true;
73
74 export const diffInCommitPhase = true;
75 +export const enableAsyncActions = false;
76
77 // Flow magic to verify the exports of this file match the original version.
78 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -72,6 +72,7 @@ export const enableFizzExternalRuntime = false;
72 export const enableDeferRootSchedulingToMicrotask = true;
73
74 export const diffInCommitPhase = true;
75 +export const enableAsyncActions = false;
76
77 // Flow magic to verify the exports of this file match the original version.
78 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -69,6 +69,7 @@ export const useModernStrictMode = false;
69 export const enableDeferRootSchedulingToMicrotask = true;
70
71 export const diffInCommitPhase = true;
72 +export const enableAsyncActions = false;
73
74 // Flow magic to verify the exports of this file match the original version.
75 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -74,6 +74,7 @@ export const enableFizzExternalRuntime = false;
74 export const enableDeferRootSchedulingToMicrotask = true;
75
76 export const diffInCommitPhase = true;
77 +export const enableAsyncActions = false;
78
79 // Flow magic to verify the exports of this file match the original version.
80 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -26,6 +26,7 @@ export const enableTransitionTracing = __VARIANT__;
26 export const enableCustomElementPropertySupport = __VARIANT__;
27 export const enableDeferRootSchedulingToMicrotask = __VARIANT__;
28 export const diffInCommitPhase = __VARIANT__;
29 +export const enableAsyncActions = __VARIANT__;
30
31 // Enable this flag to help with concurrent mode debugging.
32 // It logs information to the console about React scheduling, rendering, and commit phases.
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -29,6 +29,7 @@ export const {
29 enableCustomElementPropertySupport,
30 enableDeferRootSchedulingToMicrotask,
31 diffInCommitPhase,
32 + enableAsyncActions,
33 } = dynamicFeatureFlags;
34
35 // On WWW, __EXPERIMENTAL__ is used for a new modern build.