Skip to content

Commit

Permalink
Add Transition Types (#32105)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored Jan 21, 2025
1 parent 18eaf51 commit 028c8e6
Show file tree
Hide file tree
Showing 15 changed files with 207 additions and 28 deletions.
13 changes: 13 additions & 0 deletions fixtures/view-transition/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {
useLayoutEffect,
useEffect,
useState,
unstable_addTransitionType as addTransitionType,
} from 'react';

import Chrome from './Chrome';
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand All @@ -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}
Expand Down
55 changes: 54 additions & 1 deletion fixtures/view-transition/src/components/Transitions.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -1235,6 +1236,7 @@ const SUSPENSEY_FONT_TIMEOUT = 500;

export function startViewTransition(
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
layoutCallback: () => void,
afterMutationCallback: () => void,
Expand Down Expand Up @@ -1293,7 +1295,7 @@ export function startViewTransition(
afterMutationCallback();
}
},
types: null, // TODO: Provide types.
types: transitionTypes,
});
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = transition;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-native-renderer/src/ReactFiberConfigNative.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import type {InspectorData, TouchedViewDataAtPoint} from './ReactNativeTypes';
import type {TransitionTypes} from 'react/src/ReactTransitionType.js';

// Modules provided by RN:
import {
Expand Down Expand Up @@ -582,6 +583,7 @@ export function hasInstanceAffectedParent(

export function startViewTransition(
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
layoutCallback: () => void,
afterMutationCallback: () => void,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -780,6 +781,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {

startViewTransition(
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
afterMutationCallback: () => void,
layoutCallback: () => void,
Expand Down
71 changes: 56 additions & 15 deletions packages/react-reconciler/src/ReactFiberViewTransitionComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 35 additions & 10 deletions packages/react-reconciler/src/ReactFiberWorkLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getViewTransitionName,
type ViewTransitionState,
} from './ReactFiberViewTransitionComponent';
import type {TransitionTypes} from 'react/src/ReactTransitionType.js';

import {
enableCreateEventHandleAPI,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -695,6 +698,10 @@ export function getPendingPassiveEffectsLanes(): Lanes {
return pendingEffectsLanes;
}

export function getPendingTransitionTypes(): null | TransitionTypes {
return pendingTransitionTypes;
}

export function isWorkLoopSuspendedOnData(): boolean {
return (
workInProgressSuspendedReason === SuspendedOnData ||
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -3348,9 +3355,6 @@ function commitRoot(
pendingEffectsRemainingLanes = remainingLanes;
pendingPassiveTransitions = transitions;
pendingRecoverableErrors = recoverableErrors;
if (enableViewTransition) {
pendingViewTransitionEvents = null;
}
pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate;
if (enableProfilerTimer) {
pendingEffectsRenderEndTime = completedRenderEndTime;
Expand All @@ -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 &&
Expand Down Expand Up @@ -3461,6 +3479,7 @@ function commitRoot(
shouldStartViewTransition &&
startViewTransition(
root.containerInfo,
pendingTransitionTypes,
flushMutationEffects,
flushLayoutEffects,
flushAfterMutationEffects,
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
Loading

0 comments on commit 028c8e6

Please sign in to comment.