Skip to content

Commit a5297ec

Browse files
authored
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.
1 parent 2541146 commit a5297ec

File tree

11 files changed

+110
-46
lines changed

11 files changed

+110
-46
lines changed

fixtures/view-transition/src/components/Page.js

+11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, {
22
unstable_ViewTransition as ViewTransition,
33
unstable_Activity as Activity,
44
unstable_useSwipeTransition as useSwipeTransition,
5+
useLayoutEffect,
56
useEffect,
67
useState,
78
useId,
@@ -68,6 +69,16 @@ export default function Page({url, navigate}) {
6869
return () => clearInterval(timer);
6970
}, []);
7071

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+
7182
const exclamation = (
7283
<ViewTransition name="exclamation" onShare={onTransition}>
7384
<span>!</span>

packages/react-art/src/ReactFiberConfigART.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -538,14 +538,16 @@ export function hasInstanceAffectedParent(
538538
}
539539

540540
export function startViewTransition() {
541-
return false;
541+
return null;
542542
}
543543

544-
export type RunningGestureTransition = null;
544+
export type RunningViewTransition = null;
545545

546-
export function startGestureTransition() {}
546+
export function startGestureTransition() {
547+
return null;
548+
}
547549

548-
export function stopGestureTransition(transition: RunningGestureTransition) {}
550+
export function stopViewTransition(transition: RunningViewTransition) {}
549551

550552
export type ViewTransitionInstance = null | {name: string, ...};
551553

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+12-6
Original file line numberDiff line numberDiff line change
@@ -1687,7 +1687,7 @@ export function startViewTransition(
16871687
spawnedWorkCallback: () => void,
16881688
passiveCallback: () => mixed,
16891689
errorCallback: mixed => void,
1690-
): boolean {
1690+
): null | RunningViewTransition {
16911691
const ownerDocument: Document =
16921692
rootContainer.nodeType === DOCUMENT_NODE
16931693
? (rootContainer: any)
@@ -1764,19 +1764,25 @@ export function startViewTransition(
17641764
}
17651765
passiveCallback();
17661766
});
1767-
return true;
1767+
return transition;
17681768
} catch (x) {
17691769
// We use the error as feature detection.
17701770
// The only thing that should throw is if startViewTransition is missing
17711771
// or if it doesn't accept the object form. Other errors are async.
17721772
// I.e. it's before the View Transitions v2 spec. We only support View
17731773
// Transitions v2 otherwise we fallback to not animating to ensure that
17741774
// 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;
17761782
}
17771783
}
17781784

1779-
export type RunningGestureTransition = {
1785+
export type RunningViewTransition = {
17801786
skipTransition(): void,
17811787
...
17821788
};
@@ -1900,7 +1906,7 @@ export function startGestureTransition(
19001906
mutationCallback: () => void,
19011907
animateCallback: () => void,
19021908
errorCallback: mixed => void,
1903-
): null | RunningGestureTransition {
1909+
): null | RunningViewTransition {
19041910
const ownerDocument: Document =
19051911
rootContainer.nodeType === DOCUMENT_NODE
19061912
? (rootContainer: any)
@@ -2072,7 +2078,7 @@ export function startGestureTransition(
20722078
}
20732079
}
20742080

2075-
export function stopGestureTransition(transition: RunningGestureTransition) {
2081+
export function stopViewTransition(transition: RunningViewTransition) {
20762082
transition.skipTransition();
20772083
}
20782084

packages/react-native-renderer/src/ReactFiberConfigNative.js

+10-5
Original file line numberDiff line numberDiff line change
@@ -653,11 +653,16 @@ export function startViewTransition(
653653
spawnedWorkCallback: () => void,
654654
passiveCallback: () => mixed,
655655
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;
658663
}
659664

660-
export type RunningGestureTransition = null;
665+
export type RunningViewTransition = null;
661666

662667
export function startGestureTransition(
663668
rootContainer: Container,
@@ -668,13 +673,13 @@ export function startGestureTransition(
668673
mutationCallback: () => void,
669674
animateCallback: () => void,
670675
errorCallback: mixed => void,
671-
): RunningGestureTransition {
676+
): null | RunningViewTransition {
672677
mutationCallback();
673678
animateCallback();
674679
return null;
675680
}
676681

677-
export function stopGestureTransition(transition: RunningGestureTransition) {}
682+
export function stopViewTransition(transition: RunningViewTransition) {}
678683

679684
export type ViewTransitionInstance = null | {name: string, ...};
680685

packages/react-noop-renderer/src/createReactNoop.js

+12-6
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export type TransitionStatus = mixed;
9393

9494
export type FormInstance = Instance;
9595

96-
export type RunningGestureTransition = null;
96+
export type RunningViewTransition = null;
9797

9898
export type ViewTransitionInstance = null | {name: string, ...};
9999

@@ -826,12 +826,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
826826
rootContainer: Container,
827827
transitionTypes: null | TransitionTypes,
828828
mutationCallback: () => void,
829-
afterMutationCallback: () => void,
830829
layoutCallback: () => void,
830+
afterMutationCallback: () => void,
831+
spawnedWorkCallback: () => void,
831832
passiveCallback: () => mixed,
832833
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;
835841
},
836842

837843
startGestureTransition(
@@ -843,13 +849,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
843849
mutationCallback: () => void,
844850
animateCallback: () => void,
845851
errorCallback: mixed => void,
846-
): RunningGestureTransition {
852+
): null | RunningViewTransition {
847853
mutationCallback();
848854
animateCallback();
849855
return null;
850856
},
851857

852-
stopGestureTransition(transition: RunningGestureTransition) {},
858+
stopViewTransition(transition: RunningViewTransition) {},
853859

854860
createViewTransitionInstance(name: string): ViewTransitionInstance {
855861
return null;

packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ export const wasInstanceInViewport = shim;
5151
export const hasInstanceChanged = shim;
5252
export const hasInstanceAffectedParent = shim;
5353
export const startViewTransition = shim;
54-
export type RunningGestureTransition = null;
54+
export type RunningViewTransition = null;
5555
export const startGestureTransition = shim;
56-
export const stopGestureTransition = shim;
56+
export const stopViewTransition = shim;
5757
export type ViewTransitionInstance = null | {name: string, ...};
5858
export const createViewTransitionInstance = shim;
5959
export type GestureTimeline = any;

packages/react-reconciler/src/ReactFiberGestureScheduler.js

+5-8
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@
88
*/
99

1010
import type {FiberRoot} from './ReactInternalTypes';
11-
import type {
12-
GestureTimeline,
13-
RunningGestureTransition,
14-
} from './ReactFiberConfig';
11+
import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig';
1512

1613
import {
1714
GestureLane,
@@ -21,7 +18,7 @@ import {
2118
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
2219
import {
2320
subscribeToGestureDirection,
24-
stopGestureTransition,
21+
stopViewTransition,
2522
} from './ReactFiberConfig';
2623

2724
// This type keeps track of any scheduled or active gestures.
@@ -33,7 +30,7 @@ export type ScheduledGesture = {
3330
rangeCurrent: number, // The starting offset along the timeline.
3431
rangeNext: number, // The end along the timeline where the next state is reached.
3532
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.
3734
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
3835
next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
3936
};
@@ -144,7 +141,7 @@ export function cancelScheduledGesture(
144141
} else {
145142
gesture.running = null;
146143
// If there's no work scheduled so we can stop the View Transition right away.
147-
stopGestureTransition(runningTransition);
144+
stopViewTransition(runningTransition);
148145
}
149146
}
150147
}
@@ -183,7 +180,7 @@ export function stopCompletedGestures(root: FiberRoot) {
183180
root.stoppingGestures = null;
184181
while (gesture !== null) {
185182
if (gesture.running !== null) {
186-
stopGestureTransition(gesture.running);
183+
stopViewTransition(gesture.running);
187184
gesture.running = null;
188185
}
189186
const nextGesture = gesture.next;

packages/react-reconciler/src/ReactFiberRootScheduler.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,12 @@ function processRootScheduleInMicrotask() {
310310

311311
// At the end of the microtask, flush any pending synchronous work. This has
312312
// 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+
}
314319
}
315320

316321
function scheduleTaskForRootDuringMicrotask(

packages/react-reconciler/src/ReactFiberWorkLoop.js

+34-7
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import type {
2121
TransitionAbort,
2222
} from './ReactFiberTracingMarkerComponent';
2323
import type {OffscreenInstance} from './ReactFiberActivityComponent';
24-
import type {Resource, ViewTransitionInstance} from './ReactFiberConfig';
24+
import type {
25+
Resource,
26+
ViewTransitionInstance,
27+
RunningViewTransition,
28+
} from './ReactFiberConfig';
2529
import type {RootState} from './ReactFiberRoot';
2630
import {
2731
getViewTransitionName,
@@ -102,6 +106,7 @@ import {
102106
trackSchedulerEvent,
103107
startViewTransition,
104108
startGestureTransition,
109+
stopViewTransition,
105110
createViewTransitionInstance,
106111
} from './ReactFiberConfig';
107112

@@ -665,6 +670,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
665670
let pendingEffectsRenderEndTime: number = -0; // Profiling-only
666671
let pendingPassiveTransitions: Array<Transition> | null = null;
667672
let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
673+
let pendingViewTransition: null | RunningViewTransition = null;
668674
let pendingViewTransitionEvents: Array<(types: Array<string>) => void> | null =
669675
null;
670676
let pendingTransitionTypes: null | TransitionTypes = null;
@@ -3503,10 +3509,8 @@ function commitRoot(
35033509
}
35043510

35053511
pendingEffectsStatus = PENDING_MUTATION_PHASE;
3506-
const startedViewTransition =
3507-
enableViewTransition &&
3508-
willStartViewTransition &&
3509-
startViewTransition(
3512+
if (enableViewTransition && willStartViewTransition) {
3513+
pendingViewTransition = startViewTransition(
35103514
root.containerInfo,
35113515
pendingTransitionTypes,
35123516
flushMutationEffects,
@@ -3516,7 +3520,7 @@ function commitRoot(
35163520
flushPassiveEffects,
35173521
reportViewTransitionError,
35183522
);
3519-
if (!startedViewTransition) {
3523+
} else {
35203524
// Flush synchronously.
35213525
flushMutationEffects();
35223526
flushLayoutEffects();
@@ -3646,6 +3650,8 @@ function flushSpawnedWork(): void {
36463650
}
36473651
pendingEffectsStatus = NO_PENDING_EFFECTS;
36483652

3653+
pendingViewTransition = null; // The view transition has now fully started.
3654+
36493655
// Tell Scheduler to yield at the end of the frame, so the browser has an
36503656
// opportunity to paint.
36513657
requestPaint();
@@ -3915,7 +3921,7 @@ function commitGestureOnRoot(
39153921
pendingTransitionTypes = null;
39163922
pendingEffectsStatus = PENDING_GESTURE_MUTATION_PHASE;
39173923

3918-
finishedGesture.running = startGestureTransition(
3924+
pendingViewTransition = finishedGesture.running = startGestureTransition(
39193925
root.containerInfo,
39203926
finishedGesture.provider,
39213927
finishedGesture.rangeCurrent,
@@ -3975,6 +3981,8 @@ function flushGestureAnimations(): void {
39753981
pendingFinishedWork = (null: any); // Clear for GC purposes.
39763982
pendingEffectsLanes = NoLanes;
39773983

3984+
pendingViewTransition = null; // The view transition has now fully started.
3985+
39783986
const prevTransition = ReactSharedInternals.T;
39793987
ReactSharedInternals.T = null;
39803988
const previousPriority = getCurrentUpdatePriority();
@@ -4025,8 +4033,27 @@ function releaseRootPooledCache(root: FiberRoot, remainingLanes: Lanes) {
40254033
}
40264034
}
40274035

4036+
let didWarnAboutInterruptedViewTransitions = false;
4037+
40284038
export function flushPendingEffects(wasDelayedCommit?: boolean): boolean {
40294039
// 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+
}
40304057
flushGestureMutations();
40314058
flushGestureAnimations();
40324059
flushMutationEffects();

packages/react-reconciler/src/forks/ReactFiberConfig.custom.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export opaque type NoTimeout = mixed;
4040
export opaque type RendererInspectionConfig = mixed;
4141
export opaque type TransitionStatus = mixed;
4242
export opaque type FormInstance = mixed;
43-
export type RunningGestureTransition = mixed;
43+
export type RunningViewTransition = mixed;
4444
export type ViewTransitionInstance = null | {name: string, ...};
4545
export opaque type InstanceMeasurement = mixed;
4646
export type EventResponder = any;
@@ -155,7 +155,7 @@ export const hasInstanceChanged = $$$config.hasInstanceChanged;
155155
export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
156156
export const startViewTransition = $$$config.startViewTransition;
157157
export const startGestureTransition = $$$config.startGestureTransition;
158-
export const stopGestureTransition = $$$config.stopGestureTransition;
158+
export const stopViewTransition = $$$config.stopViewTransition;
159159
export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset;
160160
export const subscribeToGestureDirection =
161161
$$$config.subscribeToGestureDirection;

0 commit comments

Comments
 (0)