Skip to content

Commit a4b5955

Browse files
committed
Initial (client-only) async actions support
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 commit, but will be added as follow ups: - Support for non-hook from of `startTransition` - Canceling the async action scope if the `useTransition` hook is deleted from the UI - Anything related to server actions
1 parent d121c67 commit a4b5955

13 files changed

+635
-55
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
enableUseEffectEventHook,
4242
enableLegacyCache,
4343
debugRenderPhaseSideEffectsForStrictMode,
44+
enableAsyncActions,
4445
} from 'shared/ReactFeatureFlags';
4546
import {
4647
REACT_CONTEXT_TYPE,
@@ -143,6 +144,7 @@ import {
143144
} from './ReactFiberThenable';
144145
import type {ThenableState} from './ReactFiberThenable';
145146
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
147+
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
146148

147149
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
148150

@@ -947,38 +949,40 @@ if (enableUseMemoCacheHook) {
947949
};
948950
}
949951

952+
function useThenable<T>(thenable: Thenable<T>): T {
953+
// Track the position of the thenable within this fiber.
954+
const index = thenableIndexCounter;
955+
thenableIndexCounter += 1;
956+
if (thenableState === null) {
957+
thenableState = createThenableState();
958+
}
959+
const result = trackUsedThenable(thenableState, thenable, index);
960+
if (
961+
currentlyRenderingFiber.alternate === null &&
962+
(workInProgressHook === null
963+
? currentlyRenderingFiber.memoizedState === null
964+
: workInProgressHook.next === null)
965+
) {
966+
// Initial render, and either this is the first time the component is
967+
// called, or there were no Hooks called after this use() the previous
968+
// time (perhaps because it threw). Subsequent Hook calls should use the
969+
// mount dispatcher.
970+
if (__DEV__) {
971+
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
972+
} else {
973+
ReactCurrentDispatcher.current = HooksDispatcherOnMount;
974+
}
975+
}
976+
return result;
977+
}
978+
950979
function use<T>(usable: Usable<T>): T {
951980
if (usable !== null && typeof usable === 'object') {
952981
// $FlowFixMe[method-unbinding]
953982
if (typeof usable.then === 'function') {
954983
// This is a thenable.
955984
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;
985+
return useThenable(thenable);
982986
} else if (
983987
usable.$$typeof === REACT_CONTEXT_TYPE ||
984988
usable.$$typeof === REACT_SERVER_CONTEXT_TYPE
@@ -2400,8 +2404,8 @@ function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
24002404
}
24012405

24022406
function startTransition(
2403-
setPending: boolean => void,
2404-
callback: () => void,
2407+
setPending: (Thenable<boolean> | boolean) => void,
2408+
callback: () => mixed,
24052409
options?: StartTransitionOptions,
24062410
): void {
24072411
const previousPriority = getCurrentUpdatePriority();
@@ -2427,8 +2431,18 @@ function startTransition(
24272431
}
24282432

24292433
try {
2430-
setPending(false);
2431-
callback();
2434+
const returnValue = callback();
2435+
if (enableAsyncActions) {
2436+
// `isPending` is either `false` or a thenable that resolves to `false`,
2437+
// depending on whether the action scope is an async function. In the
2438+
// async case, the resulting render will suspend until the async action
2439+
// scope has finished.
2440+
const isPending = requestAsyncActionContext(returnValue);
2441+
setPending(isPending);
2442+
} else {
2443+
// Async actions are not enabled.
2444+
setPending(false);
2445+
}
24322446
} finally {
24332447
setCurrentUpdatePriority(previousPriority);
24342448

@@ -2454,31 +2468,41 @@ function mountTransition(): [
24542468
boolean,
24552469
(callback: () => void, options?: StartTransitionOptions) => void,
24562470
] {
2457-
const [isPending, setPending] = mountState(false);
2471+
const [, setPending] = mountState((false: Thenable<boolean> | boolean));
24582472
// The `start` method never changes.
24592473
const start = startTransition.bind(null, setPending);
24602474
const hook = mountWorkInProgressHook();
24612475
hook.memoizedState = start;
2462-
return [isPending, start];
2476+
return [false, start];
24632477
}
24642478

24652479
function updateTransition(): [
24662480
boolean,
24672481
(callback: () => void, options?: StartTransitionOptions) => void,
24682482
] {
2469-
const [isPending] = updateState(false);
2483+
const [booleanOrThenable] = updateState(false);
24702484
const hook = updateWorkInProgressHook();
24712485
const start = hook.memoizedState;
2486+
const isPending =
2487+
typeof booleanOrThenable === 'boolean'
2488+
? booleanOrThenable
2489+
: // This will suspend until the async action scope has finished.
2490+
useThenable(booleanOrThenable);
24722491
return [isPending, start];
24732492
}
24742493

24752494
function rerenderTransition(): [
24762495
boolean,
24772496
(callback: () => void, options?: StartTransitionOptions) => void,
24782497
] {
2479-
const [isPending] = rerenderState(false);
2498+
const [booleanOrThenable] = rerenderState(false);
24802499
const hook = updateWorkInProgressHook();
24812500
const start = hook.memoizedState;
2501+
const isPending =
2502+
typeof booleanOrThenable === 'boolean'
2503+
? booleanOrThenable
2504+
: // This will suspend until the async action scope has finished.
2505+
useThenable(booleanOrThenable);
24822506
return [isPending, start];
24832507
}
24842508

packages/react-reconciler/src/ReactFiberRootScheduler.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
markStarvedLanesAsExpired,
2323
markRootEntangled,
2424
mergeLanes,
25+
claimNextTransitionLane,
2526
} from './ReactFiberLane';
2627
import {
2728
CommitContext,
@@ -78,7 +79,7 @@ let mightHavePendingSyncWork: boolean = false;
7879

7980
let isFlushingWork: boolean = false;
8081

81-
let currentEventTransitionLane: Lane = NoLanes;
82+
let currentEventTransitionLane: Lane = NoLane;
8283

8384
export function ensureRootIsScheduled(root: FiberRoot): void {
8485
// This function is called whenever a root receives an update. It does two
@@ -491,10 +492,17 @@ function scheduleImmediateTask(cb: () => mixed) {
491492
}
492493
}
493494

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+
}
495507
return currentEventTransitionLane;
496508
}
497-
498-
export function setCurrentEventTransitionLane(lane: Lane): void {
499-
currentEventTransitionLane = lane;
500-
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ import {
129129
NoLanes,
130130
NoLane,
131131
SyncLane,
132-
claimNextTransitionLane,
133132
claimNextRetryLane,
134133
includesSyncLane,
135134
isSubsetOfLanes,
@@ -278,10 +277,10 @@ import {
278277
flushSyncWorkOnAllRoots,
279278
flushSyncWorkOnLegacyRootsOnly,
280279
getContinuationForRoot,
281-
getCurrentEventTransitionLane,
282-
setCurrentEventTransitionLane,
280+
requestTransitionLane,
283281
} from './ReactFiberRootScheduler';
284282
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
283+
import {peekAsyncActionContext} from './ReactFiberAsyncAction';
285284

286285
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
287286

@@ -633,18 +632,15 @@ export function requestUpdateLane(fiber: Fiber): Lane {
633632

634633
transition._updatedFibers.add(fiber);
635634
}
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();
648644
}
649645

650646
// Updates originating inside certain React methods, like flushSync, have

0 commit comments

Comments
 (0)