Skip to content

Commit 2a83d9a

Browse files
committed
Async action support for React.startTransition
This adds support for async actions to the "isomorphic" version of startTransition (i.e. the one exported by the "react" package). Previously, async actions were only supported by the startTransition that is returned from the useTransition hook. The interesting part about the isomorphic startTransition is that it's not associated with any particular root. It must work with updates to arbitrary roots, or even arbitrary React renderers in the same app. (For example, both React DOM and React Three Fiber.) The idea is that React.startTransition should behave as if every root had an implicit useTransition hook, and you composed together all the startTransitions provided by those hooks. Multiple updates to the same root will be batched together. However, updates to one root will not be batched with updates to other roots. Features like useOptimistic work the same as with the hook version. There is one difference from from the hook version of startTransition: an error triggered inside an async action cannot be captured by an error boundary, because it's not associated with any particular part of the tree. You should handle errors the same way you would in a regular event, e.g. with a global error event handler, or with a local `try/catch`.
1 parent c7e735f commit 2a83d9a

8 files changed

+195
-34
lines changed

packages/react-reconciler/src/ReactFiberAsyncAction.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
RejectedThenable,
1414
} from 'shared/ReactTypes';
1515
import type {Lane} from './ReactFiberLane';
16+
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
1617

1718
import {requestTransitionLane} from './ReactFiberRootScheduler';
1819
import {NoLane} from './ReactFiberLane';
@@ -36,15 +37,18 @@ let currentEntangledLane: Lane = NoLane;
3637
// until the async action scope has completed.
3738
let currentEntangledActionThenable: Thenable<void> | null = null;
3839

39-
export function entangleAsyncAction<S>(thenable: Thenable<S>): Thenable<S> {
40+
export function entangleAsyncAction<S>(
41+
transition: BatchConfigTransition,
42+
thenable: Thenable<S>,
43+
): Thenable<S> {
4044
// `thenable` is the return value of the async action scope function. Create
4145
// a combined thenable that resolves once every entangled scope function
4246
// has finished.
4347
if (currentEntangledListeners === null) {
4448
// There's no outer async action scope. Create a new one.
4549
const entangledListeners = (currentEntangledListeners = []);
4650
currentEntangledPendingCount = 0;
47-
currentEntangledLane = requestTransitionLane();
51+
currentEntangledLane = requestTransitionLane(transition);
4852
const entangledThenable: Thenable<void> = {
4953
status: 'pending',
5054
value: undefined,

packages/react-reconciler/src/ReactFiberHooks.js

+32-15
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,17 @@ import {
145145
import type {ThenableState} from './ReactFiberThenable';
146146
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
147147
import {
148-
entangleAsyncAction,
149148
peekEntangledActionLane,
150149
peekEntangledActionThenable,
151150
chainThenableValue,
152151
} from './ReactFiberAsyncAction';
153152
import {HostTransitionContext} from './ReactFiberHostContext';
154153
import {requestTransitionLane} from './ReactFiberRootScheduler';
155154
import {isCurrentTreeHidden} from './ReactFiberHiddenContext';
155+
import {
156+
notifyTransitionCallbacks,
157+
requestCurrentTransition,
158+
} from './ReactFiberTransition';
156159

157160
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
158161

@@ -1319,13 +1322,6 @@ function updateReducerImpl<S, A>(
13191322
} else {
13201323
// This update does have sufficient priority.
13211324

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-
13291325
// Check if this is an optimistic update.
13301326
const revertLane = update.revertLane;
13311327
if (!enableAsyncActions || revertLane === NoLane) {
@@ -1346,6 +1342,13 @@ function updateReducerImpl<S, A>(
13461342
};
13471343
newBaseQueueLast = newBaseQueueLast.next = clone;
13481344
}
1345+
1346+
// Check if this update is part of a pending async action. If so,
1347+
// we'll need to suspend until the action has finished, so that it's
1348+
// batched together with future updates in the same action.
1349+
if (updateLane === peekEntangledActionLane()) {
1350+
didReadFromEntangledAsyncAction = true;
1351+
}
13491352
} else {
13501353
// This is an optimistic update. If the "revert" priority is
13511354
// sufficient, don't apply the update. Otherwise, apply the update,
@@ -1356,6 +1359,13 @@ function updateReducerImpl<S, A>(
13561359
// has finished. Pretend the update doesn't exist by skipping
13571360
// over it.
13581361
update = update.next;
1362+
1363+
// Check if this update is part of a pending async action. If so,
1364+
// we'll need to suspend until the action has finished, so that it's
1365+
// batched together with future updates in the same action.
1366+
if (revertLane === peekEntangledActionLane()) {
1367+
didReadFromEntangledAsyncAction = true;
1368+
}
13591369
continue;
13601370
} else {
13611371
const clone: Update<S, A> = {
@@ -1964,13 +1974,17 @@ function runFormStateAction<S, P>(
19641974

19651975
// This is a fork of startTransition
19661976
const prevTransition = ReactCurrentBatchConfig.transition;
1967-
ReactCurrentBatchConfig.transition = ({}: BatchConfigTransition);
1968-
const currentTransition = ReactCurrentBatchConfig.transition;
1977+
const currentTransition: BatchConfigTransition = {
1978+
_callbacks: new Set<(BatchConfigTransition, mixed) => mixed>(),
1979+
};
1980+
ReactCurrentBatchConfig.transition = currentTransition;
19691981
if (__DEV__) {
19701982
ReactCurrentBatchConfig.transition._updatedFibers = new Set();
19711983
}
19721984
try {
19731985
const returnValue = action(prevState, payload);
1986+
notifyTransitionCallbacks(currentTransition, returnValue);
1987+
19741988
if (
19751989
returnValue !== null &&
19761990
typeof returnValue === 'object' &&
@@ -1989,7 +2003,6 @@ function runFormStateAction<S, P>(
19892003
() => finishRunningFormStateAction(actionQueue, (setState: any)),
19902004
);
19912005

1992-
entangleAsyncAction<Awaited<S>>(thenable);
19932006
setState((thenable: any));
19942007
} else {
19952008
setState((returnValue: any));
@@ -2808,7 +2821,9 @@ function startTransition<S>(
28082821
);
28092822

28102823
const prevTransition = ReactCurrentBatchConfig.transition;
2811-
const currentTransition: BatchConfigTransition = {};
2824+
const currentTransition: BatchConfigTransition = {
2825+
_callbacks: new Set<(BatchConfigTransition, mixed) => mixed>(),
2826+
};
28122827

28132828
if (enableAsyncActions) {
28142829
// We don't really need to use an optimistic update here, because we
@@ -2839,6 +2854,7 @@ function startTransition<S>(
28392854
try {
28402855
if (enableAsyncActions) {
28412856
const returnValue = callback();
2857+
notifyTransitionCallbacks(currentTransition, returnValue);
28422858

28432859
// Check if we're inside an async action scope. If so, we'll entangle
28442860
// this new action with the existing scope.
@@ -2854,7 +2870,6 @@ function startTransition<S>(
28542870
typeof returnValue.then === 'function'
28552871
) {
28562872
const thenable = ((returnValue: any): Thenable<mixed>);
2857-
entangleAsyncAction<mixed>(thenable);
28582873
// Create a thenable that resolves to `finishedState` once the async
28592874
// action has completed.
28602875
const thenableForFinishedState = chainThenableValue(
@@ -3281,8 +3296,10 @@ function dispatchOptimisticSetState<S, A>(
32813296
queue: UpdateQueue<S, A>,
32823297
action: A,
32833298
): void {
3299+
const transition = requestCurrentTransition();
3300+
32843301
if (__DEV__) {
3285-
if (ReactCurrentBatchConfig.transition === null) {
3302+
if (transition === null) {
32863303
// An optimistic update occurred, but startTransition is not on the stack.
32873304
// There are two likely scenarios.
32883305

@@ -3323,7 +3340,7 @@ function dispatchOptimisticSetState<S, A>(
33233340
lane: SyncLane,
33243341
// After committing, the optimistic update is "reverted" using the same
33253342
// lane as the transition it's associated with.
3326-
revertLane: requestTransitionLane(),
3343+
revertLane: requestTransitionLane(transition),
33273344
action,
33283345
hasEagerState: false,
33293346
eagerState: null,

packages/react-reconciler/src/ReactFiberRootScheduler.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {FiberRoot} from './ReactInternalTypes';
1111
import type {Lane} from './ReactFiberLane';
1212
import type {PriorityLevel} from 'scheduler/src/SchedulerPriorities';
13+
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
1314

1415
import {enableDeferRootSchedulingToMicrotask} from 'shared/ReactFeatureFlags';
1516
import {
@@ -492,7 +493,12 @@ function scheduleImmediateTask(cb: () => mixed) {
492493
}
493494
}
494495

495-
export function requestTransitionLane(): Lane {
496+
export function requestTransitionLane(
497+
// This argument isn't used, it's only here to encourage the caller to
498+
// check that it's inside a transition before calling this function.
499+
// TODO: Make this non-nullable. Requires a tweak to useOptimistic.
500+
transition: BatchConfigTransition | null,
501+
): Lane {
496502
// The algorithm for assigning an update to a lane should be stable for all
497503
// updates at the same priority within the same event. To do this, the
498504
// inputs to the algorithm must be the same.

packages/react-reconciler/src/ReactFiberTracingMarkerComponent.js

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type PendingTransitionCallbacks = {
3636
markerComplete: Map<string, Set<Transition>> | null,
3737
};
3838

39+
// TODO: Unclear to me why these are separate types
3940
export type Transition = {
4041
name: string,
4142
startTime: number,
@@ -45,6 +46,7 @@ export type BatchConfigTransition = {
4546
name?: string,
4647
startTime?: number,
4748
_updatedFibers?: Set<Fiber>,
49+
_callbacks: Set<(BatchConfigTransition, mixed) => mixed>,
4850
};
4951

5052
// TODO: Is there a way to not include the tag or name here?

packages/react-reconciler/src/ReactFiberTransition.js

+43-4
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@
77
* @flow
88
*/
99
import type {Fiber, FiberRoot} from './ReactInternalTypes';
10+
import type {Thenable} from 'shared/ReactTypes';
1011
import type {Lanes} from './ReactFiberLane';
1112
import type {StackCursor} from './ReactFiberStack';
1213
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent';
13-
import type {Transition} from './ReactFiberTracingMarkerComponent';
14+
import type {
15+
BatchConfigTransition,
16+
Transition,
17+
} from './ReactFiberTracingMarkerComponent';
1418

15-
import {enableCache, enableTransitionTracing} from 'shared/ReactFeatureFlags';
19+
import {
20+
enableCache,
21+
enableTransitionTracing,
22+
enableAsyncActions,
23+
} from 'shared/ReactFeatureFlags';
1624
import {isPrimaryRenderer} from './ReactFiberConfig';
1725
import {createCursor, push, pop} from './ReactFiberStack';
1826
import {
@@ -26,13 +34,44 @@ import {
2634
} from './ReactFiberCacheComponent';
2735

2836
import ReactSharedInternals from 'shared/ReactSharedInternals';
37+
import {entangleAsyncAction} from './ReactFiberAsyncAction';
2938

3039
const {ReactCurrentBatchConfig} = ReactSharedInternals;
3140

3241
export const NoTransition = null;
3342

34-
export function requestCurrentTransition(): Transition | null {
35-
return ReactCurrentBatchConfig.transition;
43+
export function requestCurrentTransition(): BatchConfigTransition | null {
44+
const transition = ReactCurrentBatchConfig.transition;
45+
if (transition !== null) {
46+
// Whenever a transition update is scheduled, register a callback on the
47+
// transition object so we can get the return value of the scope function.
48+
transition._callbacks.add(handleTransitionScopeResult);
49+
}
50+
return transition;
51+
}
52+
53+
function handleTransitionScopeResult(
54+
transition: BatchConfigTransition,
55+
returnValue: mixed,
56+
): void {
57+
if (
58+
enableAsyncActions &&
59+
returnValue !== null &&
60+
typeof returnValue === 'object' &&
61+
typeof returnValue.then === 'function'
62+
) {
63+
// This is an async action.
64+
const thenable: Thenable<mixed> = (returnValue: any);
65+
entangleAsyncAction(transition, thenable);
66+
}
67+
}
68+
69+
export function notifyTransitionCallbacks(
70+
transition: BatchConfigTransition,
71+
returnValue: mixed,
72+
) {
73+
const callbacks = transition._callbacks;
74+
callbacks.forEach(callback => callback(transition, returnValue));
3675
}
3776

3877
// When retrying a Suspense/Offscreen boundary, we restore the cache that was

packages/react-reconciler/src/ReactFiberWorkLoop.js

+11-10
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ import {
161161
OffscreenLane,
162162
SyncUpdateLanes,
163163
UpdateLanes,
164+
claimNextTransitionLane,
164165
} from './ReactFiberLane';
165166
import {
166167
DiscreteEventPriority,
@@ -170,7 +171,7 @@ import {
170171
lowerEventPriority,
171172
lanesToEventPriority,
172173
} from './ReactEventPriorities';
173-
import {requestCurrentTransition, NoTransition} from './ReactFiberTransition';
174+
import {requestCurrentTransition} from './ReactFiberTransition';
174175
import {
175176
SelectiveHydrationException,
176177
beginWork as originalBeginWork,
@@ -633,15 +634,15 @@ export function requestUpdateLane(fiber: Fiber): Lane {
633634
return pickArbitraryLane(workInProgressRootRenderLanes);
634635
}
635636

636-
const isTransition = requestCurrentTransition() !== NoTransition;
637-
if (isTransition) {
638-
if (__DEV__ && ReactCurrentBatchConfig.transition !== null) {
639-
const transition = ReactCurrentBatchConfig.transition;
640-
if (!transition._updatedFibers) {
641-
transition._updatedFibers = new Set();
637+
const transition = requestCurrentTransition();
638+
if (transition !== null) {
639+
if (__DEV__) {
640+
const batchConfigTransition = ReactCurrentBatchConfig.transition;
641+
if (!batchConfigTransition._updatedFibers) {
642+
batchConfigTransition._updatedFibers = new Set();
642643
}
643644

644-
transition._updatedFibers.add(fiber);
645+
batchConfigTransition._updatedFibers.add(fiber);
645646
}
646647

647648
const actionScopeLane = peekEntangledActionLane();
@@ -651,7 +652,7 @@ export function requestUpdateLane(fiber: Fiber): Lane {
651652
: // We may or may not be inside an async action scope. If we are, this
652653
// is the first update in that scope. Either way, we need to get a
653654
// fresh transition lane.
654-
requestTransitionLane();
655+
requestTransitionLane(transition);
655656
}
656657

657658
// Updates originating inside certain React methods, like flushSync, have
@@ -712,7 +713,7 @@ export function requestDeferredLane(): Lane {
712713
workInProgressDeferredLane = OffscreenLane;
713714
} else {
714715
// Everything else is spawned as a transition.
715-
workInProgressDeferredLane = requestTransitionLane();
716+
workInProgressDeferredLane = claimNextTransitionLane();
716717
}
717718
}
718719

0 commit comments

Comments
 (0)