mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
cd2b79dedd
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
509 lines
17 KiB
JavaScript
509 lines
17 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import type {FiberRoot} from './ReactInternalTypes';
|
|
import type {Lane} from './ReactFiberLane';
|
|
import type {PriorityLevel} from 'scheduler/src/SchedulerPriorities';
|
|
|
|
import {enableDeferRootSchedulingToMicrotask} from 'shared/ReactFeatureFlags';
|
|
import {
|
|
NoLane,
|
|
NoLanes,
|
|
SyncLane,
|
|
getHighestPriorityLane,
|
|
getNextLanes,
|
|
includesSyncLane,
|
|
markStarvedLanesAsExpired,
|
|
markRootEntangled,
|
|
mergeLanes,
|
|
claimNextTransitionLane,
|
|
} from './ReactFiberLane';
|
|
import {
|
|
CommitContext,
|
|
NoContext,
|
|
RenderContext,
|
|
getExecutionContext,
|
|
getWorkInProgressRoot,
|
|
getWorkInProgressRootRenderLanes,
|
|
isWorkLoopSuspendedOnData,
|
|
performConcurrentWorkOnRoot,
|
|
performSyncWorkOnRoot,
|
|
} from './ReactFiberWorkLoop';
|
|
import {LegacyRoot} from './ReactRootTags';
|
|
import {
|
|
ImmediatePriority as ImmediateSchedulerPriority,
|
|
UserBlockingPriority as UserBlockingSchedulerPriority,
|
|
NormalPriority as NormalSchedulerPriority,
|
|
IdlePriority as IdleSchedulerPriority,
|
|
cancelCallback as Scheduler_cancelCallback,
|
|
scheduleCallback as Scheduler_scheduleCallback,
|
|
now,
|
|
} from './Scheduler';
|
|
import {
|
|
DiscreteEventPriority,
|
|
ContinuousEventPriority,
|
|
DefaultEventPriority,
|
|
IdleEventPriority,
|
|
lanesToEventPriority,
|
|
} from './ReactEventPriorities';
|
|
import {
|
|
supportsMicrotasks,
|
|
scheduleMicrotask,
|
|
shouldAttemptEagerTransition,
|
|
} from './ReactFiberConfig';
|
|
|
|
import ReactSharedInternals from 'shared/ReactSharedInternals';
|
|
const {ReactCurrentActQueue} = ReactSharedInternals;
|
|
|
|
// A linked list of all the roots with pending work. In an idiomatic app,
|
|
// there's only a single root, but we do support multi root apps, hence this
|
|
// extra complexity. But this module is optimized for the single root case.
|
|
let firstScheduledRoot: FiberRoot | null = null;
|
|
let lastScheduledRoot: FiberRoot | null = null;
|
|
|
|
// Used to prevent redundant mircotasks from being scheduled.
|
|
let didScheduleMicrotask: boolean = false;
|
|
// `act` "microtasks" are scheduled on the `act` queue instead of an actual
|
|
// microtask, so we have to dedupe those separately. This wouldn't be an issue
|
|
// if we required all `act` calls to be awaited, which we might in the future.
|
|
let didScheduleMicrotask_act: boolean = false;
|
|
|
|
// Used to quickly bail out of flushSync if there's no sync work to do.
|
|
let mightHavePendingSyncWork: boolean = false;
|
|
|
|
let isFlushingWork: boolean = false;
|
|
|
|
let currentEventTransitionLane: Lane = NoLane;
|
|
|
|
export function ensureRootIsScheduled(root: FiberRoot): void {
|
|
// This function is called whenever a root receives an update. It does two
|
|
// things 1) it ensures the root is in the root schedule, and 2) it ensures
|
|
// there's a pending microtask to process the root schedule.
|
|
//
|
|
// Most of the actual scheduling logic does not happen until
|
|
// `scheduleTaskForRootDuringMicrotask` runs.
|
|
|
|
// Add the root to the schedule
|
|
if (root === lastScheduledRoot || root.next !== null) {
|
|
// Fast path. This root is already scheduled.
|
|
} else {
|
|
if (lastScheduledRoot === null) {
|
|
firstScheduledRoot = lastScheduledRoot = root;
|
|
} else {
|
|
lastScheduledRoot.next = root;
|
|
lastScheduledRoot = root;
|
|
}
|
|
}
|
|
|
|
// Any time a root received an update, we set this to true until the next time
|
|
// we process the schedule. If it's false, then we can quickly exit flushSync
|
|
// without consulting the schedule.
|
|
mightHavePendingSyncWork = true;
|
|
|
|
// At the end of the current event, go through each of the roots and ensure
|
|
// there's a task scheduled for each one at the correct priority.
|
|
if (__DEV__ && ReactCurrentActQueue.current !== null) {
|
|
// We're inside an `act` scope.
|
|
if (!didScheduleMicrotask_act) {
|
|
didScheduleMicrotask_act = true;
|
|
scheduleImmediateTask(processRootScheduleInMicrotask);
|
|
}
|
|
} else {
|
|
if (!didScheduleMicrotask) {
|
|
didScheduleMicrotask = true;
|
|
scheduleImmediateTask(processRootScheduleInMicrotask);
|
|
}
|
|
}
|
|
|
|
if (!enableDeferRootSchedulingToMicrotask) {
|
|
// While this flag is disabled, we schedule the render task immediately
|
|
// instead of waiting a microtask.
|
|
// TODO: We need to land enableDeferRootSchedulingToMicrotask ASAP to
|
|
// unblock additional features we have planned.
|
|
scheduleTaskForRootDuringMicrotask(root, now());
|
|
}
|
|
|
|
if (
|
|
__DEV__ &&
|
|
ReactCurrentActQueue.isBatchingLegacy &&
|
|
root.tag === LegacyRoot
|
|
) {
|
|
// Special `act` case: Record whenever a legacy update is scheduled.
|
|
ReactCurrentActQueue.didScheduleLegacyUpdate = true;
|
|
}
|
|
}
|
|
|
|
export function flushSyncWorkOnAllRoots() {
|
|
// This is allowed to be called synchronously, but the caller should check
|
|
// the execution context first.
|
|
flushSyncWorkAcrossRoots_impl(false);
|
|
}
|
|
|
|
export function flushSyncWorkOnLegacyRootsOnly() {
|
|
// This is allowed to be called synchronously, but the caller should check
|
|
// the execution context first.
|
|
flushSyncWorkAcrossRoots_impl(true);
|
|
}
|
|
|
|
function flushSyncWorkAcrossRoots_impl(onlyLegacy: boolean) {
|
|
if (isFlushingWork) {
|
|
// Prevent reentrancy.
|
|
// TODO: Is this overly defensive? The callers must check the execution
|
|
// context first regardless.
|
|
return;
|
|
}
|
|
|
|
if (!mightHavePendingSyncWork) {
|
|
// Fast path. There's no sync work to do.
|
|
return;
|
|
}
|
|
|
|
const workInProgressRoot = getWorkInProgressRoot();
|
|
const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();
|
|
|
|
// There may or may not be synchronous work scheduled. Let's check.
|
|
let didPerformSomeWork;
|
|
let errors: Array<mixed> | null = null;
|
|
isFlushingWork = true;
|
|
do {
|
|
didPerformSomeWork = false;
|
|
let root = firstScheduledRoot;
|
|
while (root !== null) {
|
|
if (onlyLegacy && root.tag !== LegacyRoot) {
|
|
// Skip non-legacy roots.
|
|
} else {
|
|
const nextLanes = getNextLanes(
|
|
root,
|
|
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
|
|
);
|
|
if (includesSyncLane(nextLanes)) {
|
|
// This root has pending sync work. Flush it now.
|
|
try {
|
|
// TODO: Pass nextLanes as an argument instead of computing it again
|
|
// inside performSyncWorkOnRoot.
|
|
didPerformSomeWork = true;
|
|
performSyncWorkOnRoot(root);
|
|
} catch (error) {
|
|
// Collect errors so we can rethrow them at the end
|
|
if (errors === null) {
|
|
errors = [error];
|
|
} else {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
root = root.next;
|
|
}
|
|
} while (didPerformSomeWork);
|
|
isFlushingWork = false;
|
|
|
|
// If any errors were thrown, rethrow them right before exiting.
|
|
// TODO: Consider returning these to the caller, to allow them to decide
|
|
// how/when to rethrow.
|
|
if (errors !== null) {
|
|
if (errors.length > 1) {
|
|
if (typeof AggregateError === 'function') {
|
|
// eslint-disable-next-line no-undef
|
|
throw new AggregateError(errors);
|
|
} else {
|
|
for (let i = 1; i < errors.length; i++) {
|
|
scheduleImmediateTask(throwError.bind(null, errors[i]));
|
|
}
|
|
const firstError = errors[0];
|
|
throw firstError;
|
|
}
|
|
} else {
|
|
const error = errors[0];
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function throwError(error: mixed) {
|
|
throw error;
|
|
}
|
|
|
|
function processRootScheduleInMicrotask() {
|
|
// This function is always called inside a microtask. It should never be
|
|
// called synchronously.
|
|
didScheduleMicrotask = false;
|
|
if (__DEV__) {
|
|
didScheduleMicrotask_act = false;
|
|
}
|
|
|
|
// We'll recompute this as we iterate through all the roots and schedule them.
|
|
mightHavePendingSyncWork = false;
|
|
|
|
const currentTime = now();
|
|
|
|
let prev = null;
|
|
let root = firstScheduledRoot;
|
|
while (root !== null) {
|
|
const next = root.next;
|
|
|
|
if (
|
|
currentEventTransitionLane !== NoLane &&
|
|
shouldAttemptEagerTransition()
|
|
) {
|
|
markRootEntangled(root, mergeLanes(currentEventTransitionLane, SyncLane));
|
|
}
|
|
|
|
const nextLanes = scheduleTaskForRootDuringMicrotask(root, currentTime);
|
|
if (nextLanes === NoLane) {
|
|
// This root has no more pending work. Remove it from the schedule. To
|
|
// guard against subtle reentrancy bugs, this microtask is the only place
|
|
// we do this — you can add roots to the schedule whenever, but you can
|
|
// only remove them here.
|
|
|
|
// Null this out so we know it's been removed from the schedule.
|
|
root.next = null;
|
|
if (prev === null) {
|
|
// This is the new head of the list
|
|
firstScheduledRoot = next;
|
|
} else {
|
|
prev.next = next;
|
|
}
|
|
if (next === null) {
|
|
// This is the new tail of the list
|
|
lastScheduledRoot = prev;
|
|
}
|
|
} else {
|
|
// This root still has work. Keep it in the list.
|
|
prev = root;
|
|
if (includesSyncLane(nextLanes)) {
|
|
mightHavePendingSyncWork = true;
|
|
}
|
|
}
|
|
root = next;
|
|
}
|
|
|
|
currentEventTransitionLane = NoLane;
|
|
|
|
// At the end of the microtask, flush any pending synchronous work. This has
|
|
// to come at the end, because it does actual rendering work that might throw.
|
|
flushSyncWorkOnAllRoots();
|
|
}
|
|
|
|
function scheduleTaskForRootDuringMicrotask(
|
|
root: FiberRoot,
|
|
currentTime: number,
|
|
): Lane {
|
|
// This function is always called inside a microtask, or at the very end of a
|
|
// rendering task right before we yield to the main thread. It should never be
|
|
// called synchronously.
|
|
//
|
|
// TODO: Unless enableDeferRootSchedulingToMicrotask is off. We need to land
|
|
// that ASAP to unblock additional features we have planned.
|
|
//
|
|
// This function also never performs React work synchronously; it should
|
|
// only schedule work to be performed later, in a separate task or microtask.
|
|
|
|
// Check if any lanes are being starved by other work. If so, mark them as
|
|
// expired so we know to work on those next.
|
|
markStarvedLanesAsExpired(root, currentTime);
|
|
|
|
// Determine the next lanes to work on, and their priority.
|
|
const workInProgressRoot = getWorkInProgressRoot();
|
|
const workInProgressRootRenderLanes = getWorkInProgressRootRenderLanes();
|
|
const nextLanes = getNextLanes(
|
|
root,
|
|
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
|
|
);
|
|
|
|
const existingCallbackNode = root.callbackNode;
|
|
if (
|
|
// Check if there's nothing to work on
|
|
nextLanes === NoLanes ||
|
|
// If this root is currently suspended and waiting for data to resolve, don't
|
|
// schedule a task to render it. We'll either wait for a ping, or wait to
|
|
// receive an update.
|
|
//
|
|
// Suspended render phase
|
|
(root === workInProgressRoot && isWorkLoopSuspendedOnData()) ||
|
|
// Suspended commit phase
|
|
root.cancelPendingCommit !== null
|
|
) {
|
|
// Fast path: There's nothing to work on.
|
|
if (existingCallbackNode !== null) {
|
|
cancelCallback(existingCallbackNode);
|
|
}
|
|
root.callbackNode = null;
|
|
root.callbackPriority = NoLane;
|
|
return NoLane;
|
|
}
|
|
|
|
// Schedule a new callback in the host environment.
|
|
if (includesSyncLane(nextLanes)) {
|
|
// Synchronous work is always flushed at the end of the microtask, so we
|
|
// don't need to schedule an additional task.
|
|
if (existingCallbackNode !== null) {
|
|
cancelCallback(existingCallbackNode);
|
|
}
|
|
root.callbackPriority = SyncLane;
|
|
root.callbackNode = null;
|
|
return SyncLane;
|
|
} else {
|
|
// We use the highest priority lane to represent the priority of the callback.
|
|
const existingCallbackPriority = root.callbackPriority;
|
|
const newCallbackPriority = getHighestPriorityLane(nextLanes);
|
|
|
|
if (
|
|
newCallbackPriority === existingCallbackPriority &&
|
|
// Special case related to `act`. If the currently scheduled task is a
|
|
// Scheduler task, rather than an `act` task, cancel it and re-schedule
|
|
// on the `act` queue.
|
|
!(
|
|
__DEV__ &&
|
|
ReactCurrentActQueue.current !== null &&
|
|
existingCallbackNode !== fakeActCallbackNode
|
|
)
|
|
) {
|
|
// The priority hasn't changed. We can reuse the existing task.
|
|
return newCallbackPriority;
|
|
} else {
|
|
// Cancel the existing callback. We'll schedule a new one below.
|
|
cancelCallback(existingCallbackNode);
|
|
}
|
|
|
|
let schedulerPriorityLevel;
|
|
switch (lanesToEventPriority(nextLanes)) {
|
|
case DiscreteEventPriority:
|
|
schedulerPriorityLevel = ImmediateSchedulerPriority;
|
|
break;
|
|
case ContinuousEventPriority:
|
|
schedulerPriorityLevel = UserBlockingSchedulerPriority;
|
|
break;
|
|
case DefaultEventPriority:
|
|
schedulerPriorityLevel = NormalSchedulerPriority;
|
|
break;
|
|
case IdleEventPriority:
|
|
schedulerPriorityLevel = IdleSchedulerPriority;
|
|
break;
|
|
default:
|
|
schedulerPriorityLevel = NormalSchedulerPriority;
|
|
break;
|
|
}
|
|
|
|
const newCallbackNode = scheduleCallback(
|
|
schedulerPriorityLevel,
|
|
performConcurrentWorkOnRoot.bind(null, root),
|
|
);
|
|
|
|
root.callbackPriority = newCallbackPriority;
|
|
root.callbackNode = newCallbackNode;
|
|
return newCallbackPriority;
|
|
}
|
|
}
|
|
|
|
export type RenderTaskFn = (didTimeout: boolean) => RenderTaskFn | null;
|
|
|
|
export function getContinuationForRoot(
|
|
root: FiberRoot,
|
|
originalCallbackNode: mixed,
|
|
): RenderTaskFn | null {
|
|
// This is called at the end of `performConcurrentWorkOnRoot` to determine
|
|
// if we need to schedule a continuation task.
|
|
//
|
|
// Usually `scheduleTaskForRootDuringMicrotask` only runs inside a microtask;
|
|
// however, since most of the logic for determining if we need a continuation
|
|
// versus a new task is the same, we cheat a bit and call it here. This is
|
|
// only safe to do because we know we're at the end of the browser task.
|
|
// So although it's not an actual microtask, it might as well be.
|
|
scheduleTaskForRootDuringMicrotask(root, now());
|
|
if (root.callbackNode === originalCallbackNode) {
|
|
// The task node scheduled for this root is the same one that's
|
|
// currently executed. Need to return a continuation.
|
|
return performConcurrentWorkOnRoot.bind(null, root);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const fakeActCallbackNode = {};
|
|
|
|
function scheduleCallback(
|
|
priorityLevel: PriorityLevel,
|
|
callback: RenderTaskFn,
|
|
) {
|
|
if (__DEV__ && ReactCurrentActQueue.current !== null) {
|
|
// Special case: We're inside an `act` scope (a testing utility).
|
|
// Instead of scheduling work in the host environment, add it to a
|
|
// fake internal queue that's managed by the `act` implementation.
|
|
ReactCurrentActQueue.current.push(callback);
|
|
return fakeActCallbackNode;
|
|
} else {
|
|
return Scheduler_scheduleCallback(priorityLevel, callback);
|
|
}
|
|
}
|
|
|
|
function cancelCallback(callbackNode: mixed) {
|
|
if (__DEV__ && callbackNode === fakeActCallbackNode) {
|
|
// Special `act` case: check if this is the fake callback node used by
|
|
// the `act` implementation.
|
|
} else if (callbackNode !== null) {
|
|
Scheduler_cancelCallback(callbackNode);
|
|
}
|
|
}
|
|
|
|
function scheduleImmediateTask(cb: () => mixed) {
|
|
if (__DEV__ && ReactCurrentActQueue.current !== null) {
|
|
// Special case: Inside an `act` scope, we push microtasks to the fake `act`
|
|
// callback queue. This is because we currently support calling `act`
|
|
// without awaiting the result. The plan is to deprecate that, and require
|
|
// that you always await the result so that the microtasks have a chance to
|
|
// run. But it hasn't happened yet.
|
|
ReactCurrentActQueue.current.push(() => {
|
|
cb();
|
|
return null;
|
|
});
|
|
}
|
|
|
|
// TODO: Can we land supportsMicrotasks? Which environments don't support it?
|
|
// Alternatively, can we move this check to the host config?
|
|
if (supportsMicrotasks) {
|
|
scheduleMicrotask(() => {
|
|
// In Safari, appending an iframe forces microtasks to run.
|
|
// https://github.com/facebook/react/issues/22459
|
|
// We don't support running callbacks in the middle of render
|
|
// or commit so we need to check against that.
|
|
const executionContext = getExecutionContext();
|
|
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
|
|
// Note that this would still prematurely flush the callbacks
|
|
// if this happens outside render or commit phase (e.g. in an event).
|
|
|
|
// Intentionally using a macrotask instead of a microtask here. This is
|
|
// wrong semantically but it prevents an infinite loop. The bug is
|
|
// Safari's, not ours, so we just do our best to not crash even though
|
|
// the behavior isn't completely correct.
|
|
Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
|
|
return;
|
|
}
|
|
cb();
|
|
});
|
|
} else {
|
|
// If microtasks are not supported, use Scheduler.
|
|
Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
|
|
}
|
|
}
|
|
|
|
export function requestTransitionLane(): Lane {
|
|
// The algorithm for assigning an update to a lane should be stable for all
|
|
// updates at the same priority within the same event. To do this, the
|
|
// inputs to the algorithm must be the same.
|
|
//
|
|
// The trick we use is to cache the first of each of these inputs within an
|
|
// event. Then reset the cached values once we can be sure the event is
|
|
// over. Our heuristic for that is whenever we enter a concurrent work loop.
|
|
if (currentEventTransitionLane === NoLane) {
|
|
// All transitions within the same event are assigned the same lane.
|
|
currentEventTransitionLane = claimNextTransitionLane();
|
|
}
|
|
return currentEventTransitionLane;
|
|
}
|