From ec140022cc99f9663c1b0b94fd98d68feecdb405 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 26 Mar 2025 12:28:24 -0400 Subject: [PATCH 1/4] Return a ViewTransition instance from startViewTransition This unifies the pattern with startGestureTransition that already did this so that we can cancel it if needed. --- packages/react-art/src/ReactFiberConfigART.js | 10 ++++++---- .../src/client/ReactFiberConfigDOM.js | 18 ++++++++++++------ .../src/ReactFiberConfigNative.js | 15 ++++++++++----- .../react-noop-renderer/src/createReactNoop.js | 18 ++++++++++++------ .../src/ReactFiberConfigWithNoMutation.js | 4 ++-- .../src/ReactFiberGestureScheduler.js | 13 +++++-------- .../react-reconciler/src/ReactFiberWorkLoop.js | 6 ++---- .../src/forks/ReactFiberConfig.custom.js | 4 ++-- .../src/ReactFiberConfigTestHost.js | 15 ++++++++++----- 9 files changed, 61 insertions(+), 42 deletions(-) diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index a168975221f34..e9d18a3081010 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -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, ...}; diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index f01817ce240fd..f3f458e3bd73b 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -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(); } diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index b15a6f9f76b02..876540a42de06 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -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, ...}; diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index efb5f955cae73..d452e8e10e378 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js index 74e30da88c96e..bb347defe8463 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index 33d477f07daec..18887528c0bcf 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 3da001f34ca1e..5072c42bae9bc 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -3503,9 +3503,7 @@ function commitRoot( } pendingEffectsStatus = PENDING_MUTATION_PHASE; - const startedViewTransition = - enableViewTransition && - willStartViewTransition && + if (enableViewTransition && willStartViewTransition) { startViewTransition( root.containerInfo, pendingTransitionTypes, @@ -3516,7 +3514,7 @@ function commitRoot( flushPassiveEffects, reportViewTransitionError, ); - if (!startedViewTransition) { + } else { // Flush synchronously. flushMutationEffects(); flushLayoutEffects(); diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index f22b6a580e7d9..b4a025678b0de 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -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; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 2df5f0157195c..f3701d3063b75 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -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, ...}; From a2fa3220657cb92b642af8ef14909f03e0678416 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 26 Mar 2025 12:52:57 -0400 Subject: [PATCH 2/4] Stop a view transition if it gets interrupted before .ready This ensures that we don't play a partial animation when we're interrupted. --- .../src/ReactFiberWorkLoop.js | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 5072c42bae9bc..c86a5f084ea8d 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -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 | null = null; let pendingRecoverableErrors: null | Array> = null; +let pendingViewTransition: null | RunningViewTransition = null; let pendingViewTransitionEvents: Array<(types: Array) => void> | null = null; let pendingTransitionTypes: null | TransitionTypes = null; @@ -3504,7 +3510,7 @@ function commitRoot( pendingEffectsStatus = PENDING_MUTATION_PHASE; if (enableViewTransition && willStartViewTransition) { - startViewTransition( + pendingViewTransition = startViewTransition( root.containerInfo, pendingTransitionTypes, flushMutationEffects, @@ -3644,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(); @@ -3913,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, @@ -3973,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(); @@ -4023,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(); From 340c94f4381a3de810d64f287badec8f0146ed22 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 26 Mar 2025 13:08:41 -0400 Subject: [PATCH 3/4] Don't flush synchronous work if we're in the middle of a ViewTransition async sequence --- fixtures/view-transition/src/components/Page.js | 11 +++++++++++ .../react-reconciler/src/ReactFiberRootScheduler.js | 8 +++++++- packages/react-reconciler/src/ReactFiberWorkLoop.js | 4 ++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index ee4b95331f759..e51beeec0df9f 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -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 = ( ! diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index 293992e40618d..a81e974af4fc8 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -48,6 +48,7 @@ import { hasPendingCommitEffects, isWorkLoopSuspendedOnData, performWorkOnRoot, + isPreparingViewTransition, } from './ReactFiberWorkLoop'; import {LegacyRoot} from './ReactRootTags'; import { @@ -310,7 +311,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 (!isPreparingViewTransition()) { + flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false); + } } function scheduleTaskForRootDuringMicrotask( diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index c86a5f084ea8d..29d32b46c30f1 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -677,6 +677,10 @@ let pendingTransitionTypes: null | TransitionTypes = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only +export function isPreparingViewTransition(): boolean { + return enableViewTransition && pendingViewTransition !== null; +} + // Use these to prevent an infinite loop of nested updates const NESTED_UPDATE_LIMIT = 50; let nestedUpdateCount: number = 0; From 0da7164023b1ff4b94b5c393a1e831360783a364 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 26 Mar 2025 14:35:05 -0400 Subject: [PATCH 4/4] isPreparingViewTransition -> hasPendingCommitEffects We already had an equivalent helper that we already used in root scheduler. --- packages/react-reconciler/src/ReactFiberRootScheduler.js | 3 +-- packages/react-reconciler/src/ReactFiberWorkLoop.js | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index a81e974af4fc8..e7e61fb6cd1cf 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -48,7 +48,6 @@ import { hasPendingCommitEffects, isWorkLoopSuspendedOnData, performWorkOnRoot, - isPreparingViewTransition, } from './ReactFiberWorkLoop'; import {LegacyRoot} from './ReactRootTags'; import { @@ -314,7 +313,7 @@ function processRootScheduleInMicrotask() { // 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 (!isPreparingViewTransition()) { + if (!hasPendingCommitEffects()) { flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false); } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 29d32b46c30f1..c86a5f084ea8d 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -677,10 +677,6 @@ let pendingTransitionTypes: null | TransitionTypes = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only -export function isPreparingViewTransition(): boolean { - return enableViewTransition && pendingViewTransition !== null; -} - // Use these to prevent an infinite loop of nested updates const NESTED_UPDATE_LIMIT = 50; let nestedUpdateCount: number = 0;