Don't flush synchronous work if we're in the middle of a ViewTransition async sequence (#32760)

Starting a View Transition is an async sequence. Since React can get a
sync update in the middle of sequence we sometimes interrupt that
sequence.

Currently, we don't actually cancel the View Transition so it can just
run as a partial. This ensures that we fully skip it when that happens,
as well as warn.

However, it's very easy to trigger this with just a setState in
useLayoutEffect right now. Therefore if we're inside the preparing
sequence of a startViewTransition, this delays work that would've
normally flushed in a microtask. ~Maybe we want to do the same for
Default work already scheduled through a scheduler Task.~ Edit: This was
already done.

`flushSync` currently will still lead to an interrupted View Transition
(with a warning). There's a tradeoff here whether we want to try our
best to preserve the guarantees of `flushSync` or favor the animation.
It's already possible to suspend at the root with `flushSync` which
means it's not always 100% guaranteed to commit anyway. We could treat
it as suspended. But let's see how much this is a problem in practice.
This commit is contained in:
Sebastian Markbåge 2025-03-26 14:40:23 -04:00 committed by GitHub
parent 254114616a
commit a5297ece62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 110 additions and 46 deletions

View File

@ -2,6 +2,7 @@ import React, {
unstable_ViewTransition as ViewTransition,
unstable_Activity as Activity,
unstable_useSwipeTransition as useSwipeTransition,
useLayoutEffect,
useEffect,
useState,
useId,
@ -68,6 +69,16 @@ export default function Page({url, navigate}) {
return () => clearInterval(timer);
}, []);
useLayoutEffect(() => {
// Calling a default update should not interrupt ViewTransitions but
// a flushSync will.
// Promise.resolve().then(() => {
// flushSync(() => {
setCounter(c => c + 10);
// });
// });
}, [show]);
const exclamation = (
<ViewTransition name="exclamation" onShare={onTransition}>
<span>!</span>

View File

@ -538,14 +538,16 @@ export function hasInstanceAffectedParent(
}
export function startViewTransition() {
return false;
return null;
}
export type RunningGestureTransition = null;
export type RunningViewTransition = null;
export function startGestureTransition() {}
export function startGestureTransition() {
return null;
}
export function stopGestureTransition(transition: RunningGestureTransition) {}
export function stopViewTransition(transition: RunningViewTransition) {}
export type ViewTransitionInstance = null | {name: string, ...};

View File

@ -1687,7 +1687,7 @@ export function startViewTransition(
spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
errorCallback: mixed => void,
): boolean {
): null | RunningViewTransition {
const ownerDocument: Document =
rootContainer.nodeType === DOCUMENT_NODE
? (rootContainer: any)
@ -1764,7 +1764,7 @@ export function startViewTransition(
}
passiveCallback();
});
return true;
return transition;
} catch (x) {
// We use the error as feature detection.
// The only thing that should throw is if startViewTransition is missing
@ -1772,11 +1772,17 @@ export function startViewTransition(
// I.e. it's before the View Transitions v2 spec. We only support View
// Transitions v2 otherwise we fallback to not animating to ensure that
// we're not animating with the wrong animation mapped.
return false;
// Flush remaining work synchronously.
mutationCallback();
layoutCallback();
// Skip afterMutationCallback(). We don't need it since we're not animating.
spawnedWorkCallback();
// Skip passiveCallback(). Spawned work will schedule a task.
return null;
}
}
export type RunningGestureTransition = {
export type RunningViewTransition = {
skipTransition(): void,
...
};
@ -1900,7 +1906,7 @@ export function startGestureTransition(
mutationCallback: () => void,
animateCallback: () => void,
errorCallback: mixed => void,
): null | RunningGestureTransition {
): null | RunningViewTransition {
const ownerDocument: Document =
rootContainer.nodeType === DOCUMENT_NODE
? (rootContainer: any)
@ -2072,7 +2078,7 @@ export function startGestureTransition(
}
}
export function stopGestureTransition(transition: RunningGestureTransition) {
export function stopViewTransition(transition: RunningViewTransition) {
transition.skipTransition();
}

View File

@ -653,11 +653,16 @@ export function startViewTransition(
spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
errorCallback: mixed => void,
): boolean {
return false;
): null | RunningViewTransition {
mutationCallback();
layoutCallback();
// Skip afterMutationCallback(). We don't need it since we're not animating.
spawnedWorkCallback();
// Skip passiveCallback(). Spawned work will schedule a task.
return null;
}
export type RunningGestureTransition = null;
export type RunningViewTransition = null;
export function startGestureTransition(
rootContainer: Container,
@ -668,13 +673,13 @@ export function startGestureTransition(
mutationCallback: () => void,
animateCallback: () => void,
errorCallback: mixed => void,
): RunningGestureTransition {
): null | RunningViewTransition {
mutationCallback();
animateCallback();
return null;
}
export function stopGestureTransition(transition: RunningGestureTransition) {}
export function stopViewTransition(transition: RunningViewTransition) {}
export type ViewTransitionInstance = null | {name: string, ...};

View File

@ -93,7 +93,7 @@ export type TransitionStatus = mixed;
export type FormInstance = Instance;
export type RunningGestureTransition = null;
export type RunningViewTransition = null;
export type ViewTransitionInstance = null | {name: string, ...};
@ -826,12 +826,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
afterMutationCallback: () => void,
layoutCallback: () => void,
afterMutationCallback: () => void,
spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
errorCallback: mixed => void,
): boolean {
return false;
): null | RunningViewTransition {
mutationCallback();
layoutCallback();
// Skip afterMutationCallback(). We don't need it since we're not animating.
spawnedWorkCallback();
// Skip passiveCallback(). Spawned work will schedule a task.
return null;
},
startGestureTransition(
@ -843,13 +849,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
mutationCallback: () => void,
animateCallback: () => void,
errorCallback: mixed => void,
): RunningGestureTransition {
): null | RunningViewTransition {
mutationCallback();
animateCallback();
return null;
},
stopGestureTransition(transition: RunningGestureTransition) {},
stopViewTransition(transition: RunningViewTransition) {},
createViewTransitionInstance(name: string): ViewTransitionInstance {
return null;

View File

@ -51,9 +51,9 @@ export const wasInstanceInViewport = shim;
export const hasInstanceChanged = shim;
export const hasInstanceAffectedParent = shim;
export const startViewTransition = shim;
export type RunningGestureTransition = null;
export type RunningViewTransition = null;
export const startGestureTransition = shim;
export const stopGestureTransition = shim;
export const stopViewTransition = shim;
export type ViewTransitionInstance = null | {name: string, ...};
export const createViewTransitionInstance = shim;
export type GestureTimeline = any;

View File

@ -8,10 +8,7 @@
*/
import type {FiberRoot} from './ReactInternalTypes';
import type {
GestureTimeline,
RunningGestureTransition,
} from './ReactFiberConfig';
import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig';
import {
GestureLane,
@ -21,7 +18,7 @@ import {
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
import {
subscribeToGestureDirection,
stopGestureTransition,
stopViewTransition,
} from './ReactFiberConfig';
// This type keeps track of any scheduled or active gestures.
@ -33,7 +30,7 @@ export type ScheduledGesture = {
rangeCurrent: number, // The starting offset along the timeline.
rangeNext: number, // The end along the timeline where the next state is reached.
cancel: () => void, // Cancel the subscription to direction change.
running: null | RunningGestureTransition, // Used to cancel the running transition after we're done.
running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
};
@ -144,7 +141,7 @@ export function cancelScheduledGesture(
} else {
gesture.running = null;
// If there's no work scheduled so we can stop the View Transition right away.
stopGestureTransition(runningTransition);
stopViewTransition(runningTransition);
}
}
}
@ -183,7 +180,7 @@ export function stopCompletedGestures(root: FiberRoot) {
root.stoppingGestures = null;
while (gesture !== null) {
if (gesture.running !== null) {
stopGestureTransition(gesture.running);
stopViewTransition(gesture.running);
gesture.running = null;
}
const nextGesture = gesture.next;

View File

@ -310,7 +310,12 @@ function processRootScheduleInMicrotask() {
// 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.
flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
// If we're in the middle of a View Transition async sequence, we don't want to
// interrupt that sequence. Instead, we'll flush any remaining work when it
// completes.
if (!hasPendingCommitEffects()) {
flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
}
}
function scheduleTaskForRootDuringMicrotask(

View File

@ -21,7 +21,11 @@ import type {
TransitionAbort,
} from './ReactFiberTracingMarkerComponent';
import type {OffscreenInstance} from './ReactFiberActivityComponent';
import type {Resource, ViewTransitionInstance} from './ReactFiberConfig';
import type {
Resource,
ViewTransitionInstance,
RunningViewTransition,
} from './ReactFiberConfig';
import type {RootState} from './ReactFiberRoot';
import {
getViewTransitionName,
@ -102,6 +106,7 @@ import {
trackSchedulerEvent,
startViewTransition,
startGestureTransition,
stopViewTransition,
createViewTransitionInstance,
} from './ReactFiberConfig';
@ -665,6 +670,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
let pendingEffectsRenderEndTime: number = -0; // Profiling-only
let pendingPassiveTransitions: Array<Transition> | null = null;
let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
let pendingViewTransition: null | RunningViewTransition = null;
let pendingViewTransitionEvents: Array<(types: Array<string>) => void> | null =
null;
let pendingTransitionTypes: null | TransitionTypes = null;
@ -3503,10 +3509,8 @@ function commitRoot(
}
pendingEffectsStatus = PENDING_MUTATION_PHASE;
const startedViewTransition =
enableViewTransition &&
willStartViewTransition &&
startViewTransition(
if (enableViewTransition && willStartViewTransition) {
pendingViewTransition = startViewTransition(
root.containerInfo,
pendingTransitionTypes,
flushMutationEffects,
@ -3516,7 +3520,7 @@ function commitRoot(
flushPassiveEffects,
reportViewTransitionError,
);
if (!startedViewTransition) {
} else {
// Flush synchronously.
flushMutationEffects();
flushLayoutEffects();
@ -3646,6 +3650,8 @@ function flushSpawnedWork(): void {
}
pendingEffectsStatus = NO_PENDING_EFFECTS;
pendingViewTransition = null; // The view transition has now fully started.
// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
@ -3915,7 +3921,7 @@ function commitGestureOnRoot(
pendingTransitionTypes = null;
pendingEffectsStatus = PENDING_GESTURE_MUTATION_PHASE;
finishedGesture.running = startGestureTransition(
pendingViewTransition = finishedGesture.running = startGestureTransition(
root.containerInfo,
finishedGesture.provider,
finishedGesture.rangeCurrent,
@ -3975,6 +3981,8 @@ function flushGestureAnimations(): void {
pendingFinishedWork = (null: any); // Clear for GC purposes.
pendingEffectsLanes = NoLanes;
pendingViewTransition = null; // The view transition has now fully started.
const prevTransition = ReactSharedInternals.T;
ReactSharedInternals.T = null;
const previousPriority = getCurrentUpdatePriority();
@ -4025,8 +4033,27 @@ function releaseRootPooledCache(root: FiberRoot, remainingLanes: Lanes) {
}
}
let didWarnAboutInterruptedViewTransitions = false;
export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
// Returns whether passive effects were flushed.
if (enableViewTransition && pendingViewTransition !== null) {
// If we forced a flush before the View Transition full started then we skip it.
// This ensures that we're not running a partial animation.
stopViewTransition(pendingViewTransition);
if (__DEV__) {
if (!didWarnAboutInterruptedViewTransitions) {
didWarnAboutInterruptedViewTransitions = true;
console.warn(
'A flushSync update cancelled a View Transition because it was called ' +
'while the View Transition was still preparing. To preserve the synchronous ' +
'semantics, React had to skip the View Transition. If you can, try to avoid ' +
"flushSync() in a scenario that's likely to interfere.",
);
}
}
pendingViewTransition = null;
}
flushGestureMutations();
flushGestureAnimations();
flushMutationEffects();

View File

@ -40,7 +40,7 @@ export opaque type NoTimeout = mixed;
export opaque type RendererInspectionConfig = mixed;
export opaque type TransitionStatus = mixed;
export opaque type FormInstance = mixed;
export type RunningGestureTransition = mixed;
export type RunningViewTransition = mixed;
export type ViewTransitionInstance = null | {name: string, ...};
export opaque type InstanceMeasurement = mixed;
export type EventResponder = any;
@ -155,7 +155,7 @@ export const hasInstanceChanged = $$$config.hasInstanceChanged;
export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
export const startViewTransition = $$$config.startViewTransition;
export const startGestureTransition = $$$config.startGestureTransition;
export const stopGestureTransition = $$$config.stopGestureTransition;
export const stopViewTransition = $$$config.stopViewTransition;
export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset;
export const subscribeToGestureDirection =
$$$config.subscribeToGestureDirection;

View File

@ -422,11 +422,16 @@ export function startViewTransition(
spawnedWorkCallback: () => void,
passiveCallback: () => mixed,
errorCallback: mixed => void,
): boolean {
return false;
): null | RunningViewTransition {
mutationCallback();
layoutCallback();
// Skip afterMutationCallback(). We don't need it since we're not animating.
spawnedWorkCallback();
// Skip passiveCallback(). Spawned work will schedule a task.
return null;
}
export type RunningGestureTransition = null;
export type RunningViewTransition = null;
export function startGestureTransition(
rootContainer: Container,
@ -437,13 +442,13 @@ export function startGestureTransition(
mutationCallback: () => void,
animateCallback: () => void,
errorCallback: mixed => void,
): RunningGestureTransition {
): null | RunningViewTransition {
mutationCallback();
animateCallback();
return null;
}
export function stopGestureTransition(transition: RunningGestureTransition) {}
export function stopViewTransition(transition: RunningViewTransition) {}
export type ViewTransitionInstance = null | {name: string, ...};