Skip to content

Commit 41dfb55

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 41dfb55

13 files changed

+771
-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: 77 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
StartTransitionOptions,
1616
Usable,
1717
Thenable,
18+
RejectedThenable,
1819
} from 'shared/ReactTypes';
1920
import type {
2021
Fiber,
@@ -41,6 +42,7 @@ import {
4142
enableUseEffectEventHook,
4243
enableLegacyCache,
4344
debugRenderPhaseSideEffectsForStrictMode,
45+
enableAsyncActions,
4446
} from 'shared/ReactFeatureFlags';
4547
import {
4648
REACT_CONTEXT_TYPE,
@@ -143,6 +145,7 @@ import {
143145
} from './ReactFiberThenable';
144146
import type {ThenableState} from './ReactFiberThenable';
145147
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
148+
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
146149

147150
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
148151

@@ -947,38 +950,40 @@ if (enableUseMemoCacheHook) {
947950
};
948951
}
949952

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+
950980
function use<T>(usable: Usable<T>): T {
951981
if (usable !== null && typeof usable === 'object') {
952982
// $FlowFixMe[method-unbinding]
953983
if (typeof usable.then === 'function') {
954984
// This is a thenable.
955985
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);
982987
} else if (
983988
usable.$$typeof === REACT_CONTEXT_TYPE ||
984989
usable.$$typeof === REACT_SERVER_CONTEXT_TYPE
@@ -2400,8 +2405,8 @@ function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
24002405
}
24012406

24022407
function startTransition(
2403-
setPending: boolean => void,
2404-
callback: () => void,
2408+
setPending: (Thenable<boolean> | boolean) => void,
2409+
callback: () => mixed,
24052410
options?: StartTransitionOptions,
24062411
): void {
24072412
const previousPriority = getCurrentUpdatePriority();
@@ -2427,8 +2432,36 @@ function startTransition(
24272432
}
24282433

24292434
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+
}
24322465
} finally {
24332466
setCurrentUpdatePriority(previousPriority);
24342467

@@ -2454,31 +2487,41 @@ function mountTransition(): [
24542487
boolean,
24552488
(callback: () => void, options?: StartTransitionOptions) => void,
24562489
] {
2457-
const [isPending, setPending] = mountState(false);
2490+
const [, setPending] = mountState((false: Thenable<boolean> | boolean));
24582491
// The `start` method never changes.
24592492
const start = startTransition.bind(null, setPending);
24602493
const hook = mountWorkInProgressHook();
24612494
hook.memoizedState = start;
2462-
return [isPending, start];
2495+
return [false, start];
24632496
}
24642497

24652498
function updateTransition(): [
24662499
boolean,
24672500
(callback: () => void, options?: StartTransitionOptions) => void,
24682501
] {
2469-
const [isPending] = updateState(false);
2502+
const [booleanOrThenable] = updateState(false);
24702503
const hook = updateWorkInProgressHook();
24712504
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);
24722510
return [isPending, start];
24732511
}
24742512

24752513
function rerenderTransition(): [
24762514
boolean,
24772515
(callback: () => void, options?: StartTransitionOptions) => void,
24782516
] {
2479-
const [isPending] = rerenderState(false);
2517+
const [booleanOrThenable] = rerenderState(false);
24802518
const hook = updateWorkInProgressHook();
24812519
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);
24822525
return [isPending, start];
24832526
}
24842527

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-
}

0 commit comments

Comments
 (0)