From 028c8e6cf5ce2a87147a7e03e503ce94c7a7a0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 21 Jan 2025 15:00:02 -0500 Subject: [PATCH] Add Transition Types (#32105) This adds an isomorphic API to add Transition Types, which represent the cause, to the current Transition. This is currently mainly for View Transitions but as a concept it's broader and we might expand it to more features and object types in the future. ```js import { unstable_addTransitionType as addTransitionType } from 'react'; startTransition(() => { addTransitionType('my-transition-type'); setState(...); }); ``` If multiple transitions get entangled this is additive and all Transition Types are collected. You can also add more than one type to a Transition (hence the `add` prefix). Transition Types are reset after each commit. Meaning that `` revealing after a `startTransition` does not get any View Transition types associated with it. Note that the scoping rules for this is a little "wrong" in this implementation. Ideally it would be scoped to the nearest outer `startTransition` and grouped with any `setState` inside of it. Including Actions. However, since we currently don't have AsyncContext on the client, it would be too easy to drop a Transition Type if there were no other `setState` in the same `await` task. Multiple Transitions are entangled together anyway right now as a result. So this just tracks a global of all pending Transition Types for the next Transition. An inherent tricky bit with this API is that you could update multiple roots. In that case it should ideally be associated with each root. Transition Tracing solves this by associating a Transition with any updates that are later collected but this suffers from the problem mentioned above. Therefore, I just associate Transition Types with one root - the first one to commit. Since the View Transitions across roots are sequential anyway it kind of makes sense that only one really is the cause and the other one is subsequent. Transition Types can be used to apply different animations based on what caused the Transition. You have three different ways to choose from for how to use them: ## CSS It integrates with [View Transition Types](https://www.w3.org/TR/css-view-transitions-2/#active-view-transition-pseudo-examples) so you can match different animations based on CSS scopes: ```css :root:active-view-transition-type(my-transition-type) { &::view-transition-...(...) { ... } } ``` This is kind of a PITA to write though and if you have a CSS library that provide View Transition Classes it's difficult to import those into these scopes. ## Class per Type This PR also adds an object-as-map form that can be passed to all `className` properties: ```js ``` If multiple types match, then they're joined together. If no types match then the special `"default"` entry is used instead. If any type has the value `"none"` then that wins and the ViewTransition is disabled (not assigned a name). These can be combined with `enter`/`exit`/`update`/`layout`/`share` props to match based on kind of trigger and Transition Type. ```js ``` ## Events In addition, you can also observe the types in the View Transition Event callbacks as the second argument. That way you can pick different imperative Animations based on the cause. ```js { if (types.includes('navigation-back')) { ... } else if (types.includes('navigation-forward')) { ... } else { ... } }}> ``` ## Future In the future we might expose types to `useEffect` for more general purpose usage. This would also allow non-View Transition based Animations such as existing libraries to use this same feature to coordinate the same concept. We might also allow richer objects to be passed along here. Only the strings would apply to View Transitions but the imperative code and effects could do something else with them. --- .../view-transition/src/components/App.js | 13 ++++ .../view-transition/src/components/Page.js | 12 +++- .../src/components/Transitions.module.css | 55 +++++++++++++- .../src/client/ReactFiberConfigDOM.js | 4 +- .../src/ReactFiberConfigNative.js | 2 + .../src/createReactNoop.js | 2 + .../src/ReactFiberViewTransitionComponent.js | 71 +++++++++++++++---- .../src/ReactFiberWorkLoop.js | 45 +++++++++--- .../src/ReactFiberConfigTestHost.js | 2 + .../react/index.experimental.development.js | 1 + packages/react/index.experimental.js | 1 + packages/react/index.js | 1 + packages/react/src/ReactClient.js | 2 + .../react/src/ReactSharedInternalsClient.js | 3 + packages/react/src/ReactTransitionType.js | 21 ++++++ 15 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 packages/react/src/ReactTransitionType.js 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); + } +}