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 `<Suspense>`
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
<ViewTransition className={{
  'my-navigation-type': 'hello',
  'default': 'world',
}}>
```

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
<ViewTransition enter={{
  'navigation-back': 'enter-right',
  'navigation-forward': 'enter-left',
}}
exit={{
  'navigation-back': 'exit-right',
  'navigation-forward': 'exit-left',
}}>
```

## 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
<ViewTransition onUpdate={(inst, types) => {
  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.
This commit is contained in:
Sebastian Markbåge
2025-01-21 15:00:02 -05:00
committed by GitHub
parent 18eaf51bd5
commit 028c8e6cf5
15 changed files with 207 additions and 28 deletions
@@ -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,
@@ -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}) {
</button>
<ViewTransition className="none">
<div>
<ViewTransition className={transitions['slide-on-nav']}>
<h1>{!show ? 'A' : 'B'}</h1>
</ViewTransition>
<ViewTransition
className={{
'navigation-back': transitions['slide-right'],
'navigation-forward': transitions['slide-left'],
}}>
<h1>{!show ? 'A' : 'B'}</h1>
</ViewTransition>
{show ? (
<div>
{a}
@@ -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;
}
}
@@ -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;
@@ -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,
+2
View File
@@ -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,
@@ -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<string>) => void,
onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void,
onLayout?: (instance: ViewTransitionInstance, types: Array<string>) => void,
onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => 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;
+35 -10
View File
@@ -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<Transition> | null = null;
let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
let pendingViewTransitionEvents: Array<() => void> | null = null;
let pendingViewTransitionEvents: Array<(types: Array<string>) => 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<string>) => 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);
}
}
}
@@ -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,
@@ -33,6 +33,7 @@ export {
unstable_getCacheForType,
unstable_SuspenseList,
unstable_ViewTransition,
unstable_addTransitionType,
unstable_useCacheRefresh,
useId,
useCallback,
+1
View File
@@ -33,6 +33,7 @@ export {
unstable_getCacheForType,
unstable_SuspenseList,
unstable_ViewTransition,
unstable_addTransitionType,
unstable_useCacheRefresh,
useId,
useCallback,
+1
View File
@@ -52,6 +52,7 @@ export {
unstable_SuspenseList,
unstable_TracingMarker,
unstable_ViewTransition,
unstable_addTransitionType,
unstable_getCacheForType,
unstable_useCacheRefresh,
useId,
+2
View File
@@ -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
@@ -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__) {
+21
View File
@@ -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<string>;
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);
}
}