diff --git a/fixtures/view-transition/src/components/App.js b/fixtures/view-transition/src/components/App.js index 028f511107c8b..275e594d87a1d 100644 --- a/fixtures/view-transition/src/components/App.js +++ b/fixtures/view-transition/src/components/App.js @@ -3,6 +3,7 @@ import React, { useLayoutEffect, useEffect, useState, + unstable_addTransitionType as addTransitionType, } from 'react'; import Chrome from './Chrome'; @@ -35,11 +36,23 @@ export default function App({assets, initialURL}) { if (!event.canIntercept) { return; } + const navigationType = event.navigationType; + const previousIndex = window.navigation.currentEntry.index; const newURL = new URL(event.destination.url); event.intercept({ handler() { let promise; startTransition(() => { + addTransitionType('navigation-' + navigationType); + if (navigationType === 'traverse') { + // For traverse types it's useful to distinguish going back or forward. + const nextIndex = event.destination.index; + if (nextIndex > previousIndex) { + addTransitionType('navigation-forward'); + } else if (nextIndex < previousIndex) { + addTransitionType('navigation-back'); + } + } promise = new Promise(resolve => { setRouterState({ url: newURL.pathname + newURL.search, diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 688688bcd6c65..40f60327728fd 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -36,7 +36,7 @@ function Component() { export default function Page({url, navigate}) { const show = url === '/?b'; - function onTransition(viewTransition) { + function onTransition(viewTransition, types) { const keyframes = [ {rotate: '0deg', transformOrigin: '30px 8px'}, {rotate: '360deg', transformOrigin: '30px 8px'}, @@ -59,6 +59,16 @@ export default function Page({url, navigate}) {
+ +

{!show ? 'A' : 'B'}

+
+ +

{!show ? 'A' : 'B'}

+
{show ? (
{a} diff --git a/fixtures/view-transition/src/components/Transitions.module.css b/fixtures/view-transition/src/components/Transitions.module.css index 58f805eff82d5..8c7b66b446d44 100644 --- a/fixtures/view-transition/src/components/Transitions.module.css +++ b/fixtures/view-transition/src/components/Transitions.module.css @@ -9,7 +9,18 @@ } } -@keyframes exit-slide-left { +@keyframes enter-slide-left { + 0% { + opacity: 0; + translate: 200px 0; + } + 100% { + opacity: 1; + translate: 0 0; + } +} + +@keyframes exit-slide-right { 0% { opacity: 1; translate: 0 0; @@ -20,9 +31,51 @@ } } +@keyframes exit-slide-left { + 0% { + opacity: 1; + translate: 0 0; + } + 100% { + opacity: 0; + translate: -200px 0; + } +} + +::view-transition-new(.slide-right) { + animation: enter-slide-right ease-in 0.25s; +} +::view-transition-old(.slide-right) { + animation: exit-slide-right ease-in 0.25s; +} +::view-transition-new(.slide-left) { + animation: enter-slide-left ease-in 0.25s; +} +::view-transition-old(.slide-left) { + animation: exit-slide-left ease-in 0.25s; +} + ::view-transition-new(.enter-slide-right):only-child { animation: enter-slide-right ease-in 0.25s; } ::view-transition-old(.exit-slide-left):only-child { animation: exit-slide-left ease-in 0.25s; } + +:root:active-view-transition-type(navigation-back) { + &::view-transition-new(.slide-on-nav) { + animation: enter-slide-right ease-in 0.25s; + } + &::view-transition-old(.slide-on-nav) { + animation: exit-slide-right ease-in 0.25s; + } +} + +:root:active-view-transition-type(navigation-forward) { + &::view-transition-new(.slide-on-nav) { + animation: enter-slide-left ease-in 0.25s; + } + &::view-transition-old(.slide-on-nav) { + animation: exit-slide-left ease-in 0.25s; + } +} diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index cea03b73b59d5..42cf0a8f849d7 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -25,6 +25,7 @@ import type { PreinitScriptOptions, PreinitModuleScriptOptions, } from 'react-dom/src/shared/ReactDOMTypes'; +import type {TransitionTypes} from 'react/src/ReactTransitionType.js'; import {NotPending} from '../shared/ReactDOMFormActions'; @@ -1235,6 +1236,7 @@ const SUSPENSEY_FONT_TIMEOUT = 500; export function startViewTransition( rootContainer: Container, + transitionTypes: null | TransitionTypes, mutationCallback: () => void, layoutCallback: () => void, afterMutationCallback: () => void, @@ -1293,7 +1295,7 @@ export function startViewTransition( afterMutationCallback(); } }, - types: null, // TODO: Provide types. + types: transitionTypes, }); // $FlowFixMe[prop-missing] ownerDocument.__reactViewTransition = transition; diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 9bbe8b962a65a..f6709cab0338c 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -8,6 +8,7 @@ */ import type {InspectorData, TouchedViewDataAtPoint} from './ReactNativeTypes'; +import type {TransitionTypes} from 'react/src/ReactTransitionType.js'; // Modules provided by RN: import { @@ -582,6 +583,7 @@ export function hasInstanceAffectedParent( export function startViewTransition( rootContainer: Container, + transitionTypes: null | TransitionTypes, mutationCallback: () => void, layoutCallback: () => void, afterMutationCallback: () => void, diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index b1b4a3c9405fb..f412f90f6c87f 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -22,6 +22,7 @@ import type {UpdateQueue} from 'react-reconciler/src/ReactFiberClassUpdateQueue' import type {ReactNodeList} from 'shared/ReactTypes'; import type {RootTag} from 'react-reconciler/src/ReactRootTags'; import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities'; +import type {TransitionTypes} from 'react/src/ReactTransitionType.js'; import * as Scheduler from 'scheduler/unstable_mock'; import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; @@ -780,6 +781,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { startViewTransition( rootContainer: Container, + transitionTypes: null | TransitionTypes, mutationCallback: () => void, afterMutationCallback: () => void, layoutCallback: () => void, diff --git a/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js b/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js index 65e3ffc459148..16b5ef304974e 100644 --- a/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js +++ b/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js @@ -11,26 +11,35 @@ import type {ReactNodeList} from 'shared/ReactTypes'; import type {FiberRoot} from './ReactInternalTypes'; import type {ViewTransitionInstance} from './ReactFiberConfig'; -import {getWorkInProgressRoot} from './ReactFiberWorkLoop'; +import { + getWorkInProgressRoot, + getPendingTransitionTypes, +} from './ReactFiberWorkLoop'; import {getIsHydrating} from './ReactFiberHydrationContext'; import {getTreeId} from './ReactFiberTreeContext'; +export type ViewTransitionClassPerType = { + [transitionType: 'default' | string]: 'none' | string, +}; + +export type ViewTransitionClass = 'none' | string | ViewTransitionClassPerType; + export type ViewTransitionProps = { name?: string, children?: ReactNodeList, - className?: 'none' | string, - enter?: 'none' | string, - exit?: 'none' | string, - layout?: 'none' | string, - share?: 'none' | string, - update?: 'none' | string, - onEnter?: (instance: ViewTransitionInstance) => void, - onExit?: (instance: ViewTransitionInstance) => void, - onLayout?: (instance: ViewTransitionInstance) => void, - onShare?: (instance: ViewTransitionInstance) => void, - onUpdate?: (instance: ViewTransitionInstance) => void, + className?: ViewTransitionClass, + enter?: ViewTransitionClass, + exit?: ViewTransitionClass, + layout?: ViewTransitionClass, + share?: ViewTransitionClass, + update?: ViewTransitionClass, + onEnter?: (instance: ViewTransitionInstance, types: Array) => void, + onExit?: (instance: ViewTransitionInstance, types: Array) => void, + onLayout?: (instance: ViewTransitionInstance, types: Array) => void, + onShare?: (instance: ViewTransitionInstance, types: Array) => void, + onUpdate?: (instance: ViewTransitionInstance, types: Array) => void, }; export type ViewTransitionState = { @@ -82,17 +91,49 @@ export function getViewTransitionName( return (instance.autoName: any); } +function getClassNameByType(classByType: ?ViewTransitionClass): ?string { + if (classByType == null || typeof classByType === 'string') { + return classByType; + } + let className: ?string = null; + const activeTypes = getPendingTransitionTypes(); + if (activeTypes !== null) { + for (let i = 0; i < activeTypes.length; i++) { + const match = classByType[activeTypes[i]]; + if (match != null) { + if (match === 'none') { + // If anything matches "none" that takes precedence over any other + // type that also matches. + return 'none'; + } + if (className == null) { + className = match; + } else { + className += ' ' + match; + } + } + } + } + if (className == null) { + // We had no other matches. Match the default for this configuration. + return classByType.default; + } + return className; +} + export function getViewTransitionClassName( - className: ?string, - eventClassName: ?string, + defaultClass: ?ViewTransitionClass, + eventClass: ?ViewTransitionClass, ): ?string { + const className: ?string = getClassNameByType(defaultClass); + const eventClassName: ?string = getClassNameByType(eventClass); if (eventClassName == null) { return className; } if (eventClassName === 'none') { return eventClassName; } - if (className != null) { + if (className != null && className !== 'none') { return className + ' ' + eventClassName; } return eventClassName; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 43cba4586f00b..03f09c1dbaa5f 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -27,6 +27,7 @@ import { getViewTransitionName, type ViewTransitionState, } from './ReactFiberViewTransitionComponent'; +import type {TransitionTypes} from 'react/src/ReactTransitionType.js'; import { enableCreateEventHandleAPI, @@ -653,7 +654,9 @@ 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 pendingViewTransitionEvents: Array<(types: Array) => void> | null = + null; +let pendingTransitionTypes: null | TransitionTypes = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only @@ -695,6 +698,10 @@ export function getPendingPassiveEffectsLanes(): Lanes { return pendingEffectsLanes; } +export function getPendingTransitionTypes(): null | TransitionTypes { + return pendingTransitionTypes; +} + export function isWorkLoopSuspendedOnData(): boolean { return ( workInProgressSuspendedReason === SuspendedOnData || @@ -804,7 +811,7 @@ export function requestDeferredLane(): Lane { export function scheduleViewTransitionEvent( fiber: Fiber, - callback: ?(instance: ViewTransitionInstance) => void, + callback: ?(instance: ViewTransitionInstance, types: Array) => void, ): void { if (enableViewTransition) { if (callback != null) { @@ -3348,9 +3355,6 @@ function commitRoot( pendingEffectsRemainingLanes = remainingLanes; pendingPassiveTransitions = transitions; pendingRecoverableErrors = recoverableErrors; - if (enableViewTransition) { - pendingViewTransitionEvents = null; - } pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; if (enableProfilerTimer) { pendingEffectsRenderEndTime = completedRenderEndTime; @@ -3362,10 +3366,24 @@ function commitRoot( // might get scheduled in the commit phase. (See #16714.) // TODO: Delete all other places that schedule the passive effect callback // They're redundant. - const passiveSubtreeMask = - enableViewTransition && includesOnlyViewTransitionEligibleLanes(lanes) - ? PassiveTransitionMask - : PassiveMask; + let passiveSubtreeMask; + if (enableViewTransition) { + pendingViewTransitionEvents = null; + if (includesOnlyViewTransitionEligibleLanes(lanes)) { + // Claim any pending Transition Types for this commit. + // This means that multiple roots committing independent View Transitions + // 1) end up staggered because we can only have one at a time. + // 2) only the first one gets all the Transition Types. + pendingTransitionTypes = ReactSharedInternals.V; + ReactSharedInternals.V = null; + passiveSubtreeMask = PassiveTransitionMask; + } else { + pendingTransitionTypes = null; + passiveSubtreeMask = PassiveMask; + } + } else { + passiveSubtreeMask = PassiveMask; + } if ( // If this subtree rendered with profiling this commit, we need to visit it to log it. (enableProfilerTimer && @@ -3461,6 +3479,7 @@ function commitRoot( shouldStartViewTransition && startViewTransition( root.containerInfo, + pendingTransitionTypes, flushMutationEffects, flushLayoutEffects, flushAfterMutationEffects, @@ -3708,11 +3727,17 @@ function flushSpawnedWork(): void { // 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; + let pendingTypes = pendingTransitionTypes; + pendingTransitionTypes = null; if (pendingEvents !== null) { pendingViewTransitionEvents = null; + if (pendingTypes === null) { + // Normalize the type. This is lazily created only for events. + pendingTypes = []; + } for (let i = 0; i < pendingEvents.length; i++) { const viewTransitionEvent = pendingEvents[i]; - viewTransitionEvent(); + viewTransitionEvent(pendingTypes); } } } diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index a33590a32b2f0..aa8b34523b3b4 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -8,6 +8,7 @@ */ import type {ReactContext} from 'shared/ReactTypes'; +import type {TransitionTypes} from 'react/src/ReactTransitionType.js'; import isArray from 'shared/isArray'; import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; @@ -364,6 +365,7 @@ export function hasInstanceAffectedParent( export function startViewTransition( rootContainer: Container, + transitionTypes: null | TransitionTypes, mutationCallback: () => void, layoutCallback: () => void, afterMutationCallback: () => void, diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index 53d7cd55b9a54..49c98bb208acb 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -33,6 +33,7 @@ export { unstable_getCacheForType, unstable_SuspenseList, unstable_ViewTransition, + unstable_addTransitionType, unstable_useCacheRefresh, useId, useCallback, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 2efbac3b7d57c..77cf6bd0e0317 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -33,6 +33,7 @@ export { unstable_getCacheForType, unstable_SuspenseList, unstable_ViewTransition, + unstable_addTransitionType, unstable_useCacheRefresh, useId, useCallback, diff --git a/packages/react/index.js b/packages/react/index.js index 7522a32d182c3..711a044455257 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -52,6 +52,7 @@ export { unstable_SuspenseList, unstable_TracingMarker, unstable_ViewTransition, + unstable_addTransitionType, unstable_getCacheForType, unstable_useCacheRefresh, useId, diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 4027534f714ef..f633c7617d275 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -61,6 +61,7 @@ import { } from './ReactHooks'; import ReactSharedInternals from './ReactSharedInternalsClient'; import {startTransition} from './ReactStartTransition'; +import {addTransitionType} from './ReactTransitionType'; import {act} from './ReactAct'; import {captureOwnerStack} from './ReactOwnerStack'; import * as ReactCompilerRuntime from './ReactCompilerRuntime'; @@ -126,6 +127,7 @@ export { REACT_TRACING_MARKER_TYPE as unstable_TracingMarker, // enableViewTransition REACT_VIEW_TRANSITION_TYPE as unstable_ViewTransition, + addTransitionType as unstable_addTransitionType, useId, act, // DEV-only captureOwnerStack, // DEV-only diff --git a/packages/react/src/ReactSharedInternalsClient.js b/packages/react/src/ReactSharedInternalsClient.js index 452bd933dab06..f899cba24af1b 100644 --- a/packages/react/src/ReactSharedInternalsClient.js +++ b/packages/react/src/ReactSharedInternalsClient.js @@ -10,12 +10,14 @@ import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes'; import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes'; import type {BatchConfigTransition} from 'react-reconciler/src/ReactFiberTracingMarkerComponent'; +import type {TransitionTypes} from './ReactTransitionType'; export type SharedStateClient = { H: null | Dispatcher, // ReactCurrentDispatcher for Hooks A: null | AsyncDispatcher, // ReactCurrentCache for Cache T: null | BatchConfigTransition, // ReactCurrentBatchConfig for Transitions S: null | ((BatchConfigTransition, mixed) => void), // onStartTransitionFinish + V: null | TransitionTypes, // Pending Transition Types for the Next Transition // DEV-only @@ -45,6 +47,7 @@ const ReactSharedInternals: SharedStateClient = ({ A: null, T: null, S: null, + V: null, }: any); if (__DEV__) { diff --git a/packages/react/src/ReactTransitionType.js b/packages/react/src/ReactTransitionType.js new file mode 100644 index 0000000000000..c0c1a3a2165d3 --- /dev/null +++ b/packages/react/src/ReactTransitionType.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import ReactSharedInternals from 'shared/ReactSharedInternals'; + +export type TransitionTypes = Array; + +export function addTransitionType(type: string): void { + const pendingTransitionTypes: null | TransitionTypes = ReactSharedInternals.V; + if (pendingTransitionTypes === null) { + ReactSharedInternals.V = [type]; + } else if (pendingTransitionTypes.indexOf(type) === -1) { + pendingTransitionTypes.push(type); + } +}