@samitouri / QOS-React-2 / commits / a5297ece62

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.

Sebastian Markbåge committed Mar 26, 2025 at 14:40 UTC a5297ece6217f5495cbe38ba58f928b2697b0f99
11 files changed +110 -46
fixtures/view-transition/src/components/Page.js
+11
@@ -2,6 +2,7 @@ import React, {
2 unstable_ViewTransition as ViewTransition,
3 unstable_Activity as Activity,
4 unstable_useSwipeTransition as useSwipeTransition,
5 + useLayoutEffect,
6 useEffect,
7 useState,
8 useId,
@@ -68,6 +69,16 @@ export default function Page({url, navigate}) {
69 return () => clearInterval(timer);
70 }, []);
71
72 + useLayoutEffect(() => {
73 + // Calling a default update should not interrupt ViewTransitions but
74 + // a flushSync will.
75 + // Promise.resolve().then(() => {
76 + // flushSync(() => {
77 + setCounter(c => c + 10);
78 + // });
79 + // });
80 + }, [show]);
81 +
82 const exclamation = (
83 <ViewTransition name="exclamation" onShare={onTransition}>
84 <span>!</span>
packages/react-art/src/ReactFiberConfigART.js
+6 -4
@@ -538,14 +538,16 @@ export function hasInstanceAffectedParent(
538 }
539
540 export function startViewTransition() {
541 - return false;
541 + return null;
542 }
543
544 -export type RunningGestureTransition = null;
544 +export type RunningViewTransition = null;
545
546 -export function startGestureTransition() {}
546 +export function startGestureTransition() {
547 + return null;
548 +}
549
548 -export function stopGestureTransition(transition: RunningGestureTransition) {}
550 +export function stopViewTransition(transition: RunningViewTransition) {}
551
552 export type ViewTransitionInstance = null | {name: string, ...};
553
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+12 -6
@@ -1687,7 +1687,7 @@ export function startViewTransition(
1687 spawnedWorkCallback: () => void,
1688 passiveCallback: () => mixed,
1689 errorCallback: mixed => void,
1690 -): boolean {
1690 +): null | RunningViewTransition {
1691 const ownerDocument: Document =
1692 rootContainer.nodeType === DOCUMENT_NODE
1693 ? (rootContainer: any)
@@ -1764,7 +1764,7 @@ export function startViewTransition(
1764 }
1765 passiveCallback();
1766 });
1767 - return true;
1767 + return transition;
1768 } catch (x) {
1769 // We use the error as feature detection.
1770 // The only thing that should throw is if startViewTransition is missing
@@ -1772,11 +1772,17 @@ export function startViewTransition(
1772 // I.e. it's before the View Transitions v2 spec. We only support View
1773 // Transitions v2 otherwise we fallback to not animating to ensure that
1774 // we're not animating with the wrong animation mapped.
1775 - return false;
1775 + // Flush remaining work synchronously.
1776 + mutationCallback();
1777 + layoutCallback();
1778 + // Skip afterMutationCallback(). We don't need it since we're not animating.
1779 + spawnedWorkCallback();
1780 + // Skip passiveCallback(). Spawned work will schedule a task.
1781 + return null;
1782 }
1783 }
1784
1779 -export type RunningGestureTransition = {
1785 +export type RunningViewTransition = {
1786 skipTransition(): void,
1787 ...
1788 };
@@ -1900,7 +1906,7 @@ export function startGestureTransition(
1906 mutationCallback: () => void,
1907 animateCallback: () => void,
1908 errorCallback: mixed => void,
1903 -): null | RunningGestureTransition {
1909 +): null | RunningViewTransition {
1910 const ownerDocument: Document =
1911 rootContainer.nodeType === DOCUMENT_NODE
1912 ? (rootContainer: any)
@@ -2072,7 +2078,7 @@ export function startGestureTransition(
2078 }
2079 }
2080
2075 -export function stopGestureTransition(transition: RunningGestureTransition) {
2081 +export function stopViewTransition(transition: RunningViewTransition) {
2082 transition.skipTransition();
2083 }
2084
packages/react-native-renderer/src/ReactFiberConfigNative.js
+10 -5
@@ -653,11 +653,16 @@ export function startViewTransition(
653 spawnedWorkCallback: () => void,
654 passiveCallback: () => mixed,
655 errorCallback: mixed => void,
656 -): boolean {
657 - return false;
656 +): null | RunningViewTransition {
657 + mutationCallback();
658 + layoutCallback();
659 + // Skip afterMutationCallback(). We don't need it since we're not animating.
660 + spawnedWorkCallback();
661 + // Skip passiveCallback(). Spawned work will schedule a task.
662 + return null;
663 }
664
660 -export type RunningGestureTransition = null;
665 +export type RunningViewTransition = null;
666
667 export function startGestureTransition(
668 rootContainer: Container,
@@ -668,13 +673,13 @@ export function startGestureTransition(
673 mutationCallback: () => void,
674 animateCallback: () => void,
675 errorCallback: mixed => void,
671 -): RunningGestureTransition {
676 +): null | RunningViewTransition {
677 mutationCallback();
678 animateCallback();
679 return null;
680 }
681
677 -export function stopGestureTransition(transition: RunningGestureTransition) {}
682 +export function stopViewTransition(transition: RunningViewTransition) {}
683
684 export type ViewTransitionInstance = null | {name: string, ...};
685
packages/react-noop-renderer/src/createReactNoop.js
+12 -6
@@ -93,7 +93,7 @@ export type TransitionStatus = mixed;
93
94 export type FormInstance = Instance;
95
96 -export type RunningGestureTransition = null;
96 +export type RunningViewTransition = null;
97
98 export type ViewTransitionInstance = null | {name: string, ...};
99
@@ -826,12 +826,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
826 rootContainer: Container,
827 transitionTypes: null | TransitionTypes,
828 mutationCallback: () => void,
829 - afterMutationCallback: () => void,
829 layoutCallback: () => void,
830 + afterMutationCallback: () => void,
831 + spawnedWorkCallback: () => void,
832 passiveCallback: () => mixed,
833 errorCallback: mixed => void,
833 - ): boolean {
834 - return false;
834 + ): null | RunningViewTransition {
835 + mutationCallback();
836 + layoutCallback();
837 + // Skip afterMutationCallback(). We don't need it since we're not animating.
838 + spawnedWorkCallback();
839 + // Skip passiveCallback(). Spawned work will schedule a task.
840 + return null;
841 },
842
843 startGestureTransition(
@@ -843,13 +849,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
849 mutationCallback: () => void,
850 animateCallback: () => void,
851 errorCallback: mixed => void,
846 - ): RunningGestureTransition {
852 + ): null | RunningViewTransition {
853 mutationCallback();
854 animateCallback();
855 return null;
856 },
857
852 - stopGestureTransition(transition: RunningGestureTransition) {},
858 + stopViewTransition(transition: RunningViewTransition) {},
859
860 createViewTransitionInstance(name: string): ViewTransitionInstance {
861 return null;
packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
+2 -2
@@ -51,9 +51,9 @@ export const wasInstanceInViewport = shim;
51 export const hasInstanceChanged = shim;
52 export const hasInstanceAffectedParent = shim;
53 export const startViewTransition = shim;
54 -export type RunningGestureTransition = null;
54 +export type RunningViewTransition = null;
55 export const startGestureTransition = shim;
56 -export const stopGestureTransition = shim;
56 +export const stopViewTransition = shim;
57 export type ViewTransitionInstance = null | {name: string, ...};
58 export const createViewTransitionInstance = shim;
59 export type GestureTimeline = any;
packages/react-reconciler/src/ReactFiberGestureScheduler.js
+5 -8
@@ -8,10 +8,7 @@
8 */
9
10 import type {FiberRoot} from './ReactInternalTypes';
11 -import type {
12 - GestureTimeline,
13 - RunningGestureTransition,
14 -} from './ReactFiberConfig';
11 +import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig';
12
13 import {
14 GestureLane,
@@ -21,7 +18,7 @@ import {
18 import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
19 import {
20 subscribeToGestureDirection,
24 - stopGestureTransition,
21 + stopViewTransition,
22 } from './ReactFiberConfig';
23
24 // This type keeps track of any scheduled or active gestures.
@@ -33,7 +30,7 @@ export type ScheduledGesture = {
30 rangeCurrent: number, // The starting offset along the timeline.
31 rangeNext: number, // The end along the timeline where the next state is reached.
32 cancel: () => void, // Cancel the subscription to direction change.
36 - running: null | RunningGestureTransition, // Used to cancel the running transition after we're done.
33 + running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
34 prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
35 next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
36 };
@@ -144,7 +141,7 @@ export function cancelScheduledGesture(
141 } else {
142 gesture.running = null;
143 // If there's no work scheduled so we can stop the View Transition right away.
147 - stopGestureTransition(runningTransition);
144 + stopViewTransition(runningTransition);
145 }
146 }
147 }
@@ -183,7 +180,7 @@ export function stopCompletedGestures(root: FiberRoot) {
180 root.stoppingGestures = null;
181 while (gesture !== null) {
182 if (gesture.running !== null) {
186 - stopGestureTransition(gesture.running);
183 + stopViewTransition(gesture.running);
184 gesture.running = null;
185 }
186 const nextGesture = gesture.next;
packages/react-reconciler/src/ReactFiberRootScheduler.js
+6 -1
@@ -310,7 +310,12 @@ function processRootScheduleInMicrotask() {
310
311 // At the end of the microtask, flush any pending synchronous work. This has
312 // to come at the end, because it does actual rendering work that might throw.
313 - flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
313 + // If we're in the middle of a View Transition async sequence, we don't want to
314 + // interrupt that sequence. Instead, we'll flush any remaining work when it
315 + // completes.
316 + if (!hasPendingCommitEffects()) {
317 + flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
318 + }
319 }
320
321 function scheduleTaskForRootDuringMicrotask(
packages/react-reconciler/src/ReactFiberWorkLoop.js
+34 -7
@@ -21,7 +21,11 @@ import type {
21 TransitionAbort,
22 } from './ReactFiberTracingMarkerComponent';
23 import type {OffscreenInstance} from './ReactFiberActivityComponent';
24 -import type {Resource, ViewTransitionInstance} from './ReactFiberConfig';
24 +import type {
25 + Resource,
26 + ViewTransitionInstance,
27 + RunningViewTransition,
28 +} from './ReactFiberConfig';
29 import type {RootState} from './ReactFiberRoot';
30 import {
31 getViewTransitionName,
@@ -102,6 +106,7 @@ import {
106 trackSchedulerEvent,
107 startViewTransition,
108 startGestureTransition,
109 + stopViewTransition,
110 createViewTransitionInstance,
111 } from './ReactFiberConfig';
112
@@ -665,6 +670,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
670 let pendingEffectsRenderEndTime: number = -0; // Profiling-only
671 let pendingPassiveTransitions: Array<Transition> | null = null;
672 let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
673 +let pendingViewTransition: null | RunningViewTransition = null;
674 let pendingViewTransitionEvents: Array<(types: Array<string>) => void> | null =
675 null;
676 let pendingTransitionTypes: null | TransitionTypes = null;
@@ -3503,10 +3509,8 @@ function commitRoot(
3509 }
3510
3511 pendingEffectsStatus = PENDING_MUTATION_PHASE;
3506 - const startedViewTransition =
3507 - enableViewTransition &&
3508 - willStartViewTransition &&
3509 - startViewTransition(
3512 + if (enableViewTransition && willStartViewTransition) {
3513 + pendingViewTransition = startViewTransition(
3514 root.containerInfo,
3515 pendingTransitionTypes,
3516 flushMutationEffects,
@@ -3516,7 +3520,7 @@ function commitRoot(
3520 flushPassiveEffects,
3521 reportViewTransitionError,
3522 );
3519 - if (!startedViewTransition) {
3523 + } else {
3524 // Flush synchronously.
3525 flushMutationEffects();
3526 flushLayoutEffects();
@@ -3646,6 +3650,8 @@ function flushSpawnedWork(): void {
3650 }
3651 pendingEffectsStatus = NO_PENDING_EFFECTS;
3652
3653 + pendingViewTransition = null; // The view transition has now fully started.
3654 +
3655 // Tell Scheduler to yield at the end of the frame, so the browser has an
3656 // opportunity to paint.
3657 requestPaint();
@@ -3915,7 +3921,7 @@ function commitGestureOnRoot(
3921 pendingTransitionTypes = null;
3922 pendingEffectsStatus = PENDING_GESTURE_MUTATION_PHASE;
3923
3918 - finishedGesture.running = startGestureTransition(
3924 + pendingViewTransition = finishedGesture.running = startGestureTransition(
3925 root.containerInfo,
3926 finishedGesture.provider,
3927 finishedGesture.rangeCurrent,
@@ -3975,6 +3981,8 @@ function flushGestureAnimations(): void {
3981 pendingFinishedWork = (null: any); // Clear for GC purposes.
3982 pendingEffectsLanes = NoLanes;
3983
3984 + pendingViewTransition = null; // The view transition has now fully started.
3985 +
3986 const prevTransition = ReactSharedInternals.T;
3987 ReactSharedInternals.T = null;
3988 const previousPriority = getCurrentUpdatePriority();
@@ -4025,8 +4033,27 @@ function releaseRootPooledCache(root: FiberRoot, remainingLanes: Lanes) {
4033 }
4034 }
4035
4036 +let didWarnAboutInterruptedViewTransitions = false;
4037 +
4038 export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
4039 // Returns whether passive effects were flushed.
4040 + if (enableViewTransition && pendingViewTransition !== null) {
4041 + // If we forced a flush before the View Transition full started then we skip it.
4042 + // This ensures that we're not running a partial animation.
4043 + stopViewTransition(pendingViewTransition);
4044 + if (__DEV__) {
4045 + if (!didWarnAboutInterruptedViewTransitions) {
4046 + didWarnAboutInterruptedViewTransitions = true;
4047 + console.warn(
4048 + 'A flushSync update cancelled a View Transition because it was called ' +
4049 + 'while the View Transition was still preparing. To preserve the synchronous ' +
4050 + 'semantics, React had to skip the View Transition. If you can, try to avoid ' +
4051 + "flushSync() in a scenario that's likely to interfere.",
4052 + );
4053 + }
4054 + }
4055 + pendingViewTransition = null;
4056 + }
4057 flushGestureMutations();
4058 flushGestureAnimations();
4059 flushMutationEffects();
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+2 -2
@@ -40,7 +40,7 @@ export opaque type NoTimeout = mixed;
40 export opaque type RendererInspectionConfig = mixed;
41 export opaque type TransitionStatus = mixed;
42 export opaque type FormInstance = mixed;
43 -export type RunningGestureTransition = mixed;
43 +export type RunningViewTransition = mixed;
44 export type ViewTransitionInstance = null | {name: string, ...};
45 export opaque type InstanceMeasurement = mixed;
46 export type EventResponder = any;
@@ -155,7 +155,7 @@ export const hasInstanceChanged = $$$config.hasInstanceChanged;
155 export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
156 export const startViewTransition = $$$config.startViewTransition;
157 export const startGestureTransition = $$$config.startGestureTransition;
158 -export const stopGestureTransition = $$$config.stopGestureTransition;
158 +export const stopViewTransition = $$$config.stopViewTransition;
159 export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset;
160 export const subscribeToGestureDirection =
161 $$$config.subscribeToGestureDirection;
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+10 -5
@@ -422,11 +422,16 @@ export function startViewTransition(
422 spawnedWorkCallback: () => void,
423 passiveCallback: () => mixed,
424 errorCallback: mixed => void,
425 -): boolean {
426 - return false;
425 +): null | RunningViewTransition {
426 + mutationCallback();
427 + layoutCallback();
428 + // Skip afterMutationCallback(). We don't need it since we're not animating.
429 + spawnedWorkCallback();
430 + // Skip passiveCallback(). Spawned work will schedule a task.
431 + return null;
432 }
433
429 -export type RunningGestureTransition = null;
434 +export type RunningViewTransition = null;
435
436 export function startGestureTransition(
437 rootContainer: Container,
@@ -437,13 +442,13 @@ export function startGestureTransition(
442 mutationCallback: () => void,
443 animateCallback: () => void,
444 errorCallback: mixed => void,
440 -): RunningGestureTransition {
445 +): null | RunningViewTransition {
446 mutationCallback();
447 animateCallback();
448 return null;
449 }
450
446 -export function stopGestureTransition(transition: RunningGestureTransition) {}
451 +export function stopViewTransition(transition: RunningViewTransition) {}
452
453 export type ViewTransitionInstance = null | {name: string, ...};
454