diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 8a3638b01f4b3..8204643a3ec2f 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -1,8 +1,6 @@ import React, { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, - useRef, - useLayoutEffect, } from 'react'; import './Page.css'; @@ -37,21 +35,17 @@ function Component() { } export default function Page({url, navigate}) { - const ref = useRef(); const show = url === '/?b'; - useLayoutEffect(() => { - const viewTransition = ref.current; - requestAnimationFrame(() => { - const keyframes = [ - {rotate: '0deg', transformOrigin: '30px 8px'}, - {rotate: '360deg', transformOrigin: '30px 8px'}, - ]; - viewTransition.old.animate(keyframes, 300); - viewTransition.new.animate(keyframes, 300); - }); - }, [show]); + function onTransition(viewTransition) { + const keyframes = [ + {rotate: '0deg', transformOrigin: '30px 8px'}, + {rotate: '360deg', transformOrigin: '30px 8px'}, + ]; + viewTransition.old.animate(keyframes, 250); + viewTransition.new.animate(keyframes, 250); + } const exclamation = ( - + ! ); @@ -76,7 +70,7 @@ export default function Page({url, navigate}) { {a} )} - + {show ?
hello{exclamation}
:
Loading
}

scroll me

diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 8b4baead09b89..8e98b763b7afb 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -186,6 +186,7 @@ import { addMarkerIncompleteCallbackToPendingTransition, addMarkerCompleteCallbackToPendingTransition, retryDehydratedSuspenseBoundary, + scheduleViewTransitionEvent, } from './ReactFiberWorkLoop'; import { HasEffect as HookHasEffect, @@ -649,6 +650,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { if (child.tag === OffscreenComponent && child.memoizedState === null) { // This tree was already hidden so we skip it. } else { + commitAppearingPairViewTransitions(child); if ( child.tag === ViewTransitionComponent && (child.flags & ViewTransitionNamedStatic) !== NoFlags @@ -682,7 +684,6 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { } } } - commitAppearingPairViewTransitions(child); } child = child.sibling; } @@ -701,12 +702,18 @@ function commitEnterViewTransitions(placement: Fiber): void { false, ); if (!inViewport) { + // TODO: If this was part of a pair we will still run the onShare callback. // Revert the transition names. This boundary is not in the viewport // so we won't bother animating it. restoreViewTransitionOnHostInstances(placement.child, false); // TODO: Should we still visit the children in case a named one was in the viewport? } else { commitAppearingPairViewTransitions(placement); + + const state: ViewTransitionState = placement.stateNode; + if (!state.paired) { + scheduleViewTransitionEvent(placement, props.onEnter); + } } } else if ((placement.subtreeFlags & ViewTransitionStatic) !== NoFlags) { let child = placement.child; @@ -764,6 +771,9 @@ function commitDeletedPairViewTransitions( const oldinstance: ViewTransitionState = child.stateNode; const newInstance: ViewTransitionState = pair; newInstance.paired = oldinstance; + // Note: If the other side ends up outside the viewport, we'll still run this. + // Therefore it's possible for onShare to be called with only an old snapshot. + scheduleViewTransitionEvent(child, props.onShare); } // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). @@ -811,9 +821,16 @@ function commitExitViewTransitions( // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). appearingViewTransitions.delete(name); + // Note: If the other side ends up outside the viewport, we'll still run this. + // Therefore it's possible for onShare to be called with only an old snapshot. + scheduleViewTransitionEvent(deletion, props.onShare); + } else { + scheduleViewTransitionEvent(deletion, props.onExit); } // Look for more pairs deeper in the tree. commitDeletedPairViewTransitions(deletion, appearingViewTransitions); + } else { + scheduleViewTransitionEvent(deletion, props.onExit); } } else if ((deletion.subtreeFlags & ViewTransitionStatic) !== NoFlags) { let child = deletion.child; @@ -1118,6 +1135,8 @@ function measureNestedViewTransitions(changedParent: Fiber): void { child.memoizedState, false, ); + const props: ViewTransitionProps = child.memoizedProps; + scheduleViewTransitionEvent(child, props.onLayout); } } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) { measureNestedViewTransitions(child); @@ -3075,6 +3094,8 @@ function commitAfterMutationEffectsOnFiber( (Placement | Update | ChildDeletion | ContentReset | Visibility)) !== NoFlags ) { + const wasMutated = (finishedWork.flags & Update) !== NoFlags; + const prevContextChanged = viewTransitionContextChanged; const prevCancelableChildren = viewTransitionCancelableChildren; viewTransitionContextChanged = false; @@ -3103,7 +3124,17 @@ function commitAfterMutationEffectsOnFiber( ); viewTransitionCancelableChildren = prevCancelableChildren; } + // TODO: If this doesn't end up canceled, because a parent animates, + // then we should probably issue an event since this instance is part of it. } else { + const props: ViewTransitionProps = finishedWork.memoizedProps; + scheduleViewTransitionEvent( + finishedWork, + wasMutated || viewTransitionContextChanged + ? props.onUpdate + : props.onLayout, + ); + // If this boundary did update, we cannot cancel its children so those are dropped. viewTransitionCancelableChildren = prevCancelableChildren; } diff --git a/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js b/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js index 008a6992f275d..bfbc1b0b2e337 100644 --- a/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js +++ b/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js @@ -21,6 +21,11 @@ export type ViewTransitionProps = { name?: string, className?: string, children?: ReactNodeList, + onEnter?: (instance: ViewTransitionInstance) => void, + onExit?: (instance: ViewTransitionInstance) => void, + onLayout?: (instance: ViewTransitionInstance) => void, + onShare?: (instance: ViewTransitionInstance) => void, + onUpdate?: (instance: ViewTransitionInstance) => void, }; export type ViewTransitionState = { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f3ba210bf9704..43cba4586f00b 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -21,9 +21,12 @@ import type { TransitionAbort, } from './ReactFiberTracingMarkerComponent'; import type {OffscreenInstance} from './ReactFiberActivityComponent'; -import type {Resource} from './ReactFiberConfig'; +import type {Resource, ViewTransitionInstance} from './ReactFiberConfig'; import type {RootState} from './ReactFiberRoot'; -import type {ViewTransitionState} from './ReactFiberViewTransitionComponent'; +import { + getViewTransitionName, + type ViewTransitionState, +} from './ReactFiberViewTransitionComponent'; import { enableCreateEventHandleAPI, @@ -95,6 +98,7 @@ import { resolveUpdatePriority, trackSchedulerEvent, startViewTransition, + createViewTransitionInstance, } from './ReactFiberConfig'; import {createWorkInProgress, resetWorkInProgress} from './ReactFiber'; @@ -649,6 +653,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes; let pendingEffectsRenderEndTime: number = -0; // Profiling-only let pendingPassiveTransitions: Array | null = null; let pendingRecoverableErrors: null | Array> = null; +let pendingViewTransitionEvents: Array<() => void> | null = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only @@ -797,6 +802,27 @@ export function requestDeferredLane(): Lane { return workInProgressDeferredLane; } +export function scheduleViewTransitionEvent( + fiber: Fiber, + callback: ?(instance: ViewTransitionInstance) => void, +): void { + if (enableViewTransition) { + if (callback != null) { + const state: ViewTransitionState = fiber.stateNode; + let instance = state.ref; + if (instance === null) { + instance = state.ref = createViewTransitionInstance( + getViewTransitionName(fiber.memoizedProps, state), + ); + } + if (pendingViewTransitionEvents === null) { + pendingViewTransitionEvents = []; + } + pendingViewTransitionEvents.push(callback.bind(null, instance)); + } + } +} + export function peekDeferredLane(): Lane { return workInProgressDeferredLane; } @@ -3322,6 +3348,9 @@ function commitRoot( pendingEffectsRemainingLanes = remainingLanes; pendingPassiveTransitions = transitions; pendingRecoverableErrors = recoverableErrors; + if (enableViewTransition) { + pendingViewTransitionEvents = null; + } pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; if (enableProfilerTimer) { pendingEffectsRenderEndTime = completedRenderEndTime; @@ -3673,6 +3702,21 @@ function flushSpawnedWork(): void { } } + if (enableViewTransition) { + // We should now be after the startViewTransition's .ready call which is late enough + // to start animating any pseudo-elements. We do this before flushing any passive + // effects or spawned sync work since this is still part of the previous commit. + // Even though conceptually it's like its own task between layout effets and passive. + const pendingEvents = pendingViewTransitionEvents; + if (pendingEvents !== null) { + pendingViewTransitionEvents = null; + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + viewTransitionEvent(); + } + } + } + // If the passive effects are the result of a discrete render, flush them // synchronously at the end of the current task so that the result is // immediately observable. Otherwise, we assume that they are not