Skip to content

Commit 028c8e6

Browse files
authored
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.
1 parent 18eaf51 commit 028c8e6

File tree

15 files changed

+207
-28
lines changed

15 files changed

+207
-28
lines changed

fixtures/view-transition/src/components/App.js

+13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, {
33
useLayoutEffect,
44
useEffect,
55
useState,
6+
unstable_addTransitionType as addTransitionType,
67
} from 'react';
78

89
import Chrome from './Chrome';
@@ -35,11 +36,23 @@ export default function App({assets, initialURL}) {
3536
if (!event.canIntercept) {
3637
return;
3738
}
39+
const navigationType = event.navigationType;
40+
const previousIndex = window.navigation.currentEntry.index;
3841
const newURL = new URL(event.destination.url);
3942
event.intercept({
4043
handler() {
4144
let promise;
4245
startTransition(() => {
46+
addTransitionType('navigation-' + navigationType);
47+
if (navigationType === 'traverse') {
48+
// For traverse types it's useful to distinguish going back or forward.
49+
const nextIndex = event.destination.index;
50+
if (nextIndex > previousIndex) {
51+
addTransitionType('navigation-forward');
52+
} else if (nextIndex < previousIndex) {
53+
addTransitionType('navigation-back');
54+
}
55+
}
4356
promise = new Promise(resolve => {
4457
setRouterState({
4558
url: newURL.pathname + newURL.search,

fixtures/view-transition/src/components/Page.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function Component() {
3636

3737
export default function Page({url, navigate}) {
3838
const show = url === '/?b';
39-
function onTransition(viewTransition) {
39+
function onTransition(viewTransition, types) {
4040
const keyframes = [
4141
{rotate: '0deg', transformOrigin: '30px 8px'},
4242
{rotate: '360deg', transformOrigin: '30px 8px'},
@@ -59,6 +59,16 @@ export default function Page({url, navigate}) {
5959
</button>
6060
<ViewTransition className="none">
6161
<div>
62+
<ViewTransition className={transitions['slide-on-nav']}>
63+
<h1>{!show ? 'A' : 'B'}</h1>
64+
</ViewTransition>
65+
<ViewTransition
66+
className={{
67+
'navigation-back': transitions['slide-right'],
68+
'navigation-forward': transitions['slide-left'],
69+
}}>
70+
<h1>{!show ? 'A' : 'B'}</h1>
71+
</ViewTransition>
6272
{show ? (
6373
<div>
6474
{a}

fixtures/view-transition/src/components/Transitions.module.css

+54-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@
99
}
1010
}
1111

12-
@keyframes exit-slide-left {
12+
@keyframes enter-slide-left {
13+
0% {
14+
opacity: 0;
15+
translate: 200px 0;
16+
}
17+
100% {
18+
opacity: 1;
19+
translate: 0 0;
20+
}
21+
}
22+
23+
@keyframes exit-slide-right {
1324
0% {
1425
opacity: 1;
1526
translate: 0 0;
@@ -20,9 +31,51 @@
2031
}
2132
}
2233

34+
@keyframes exit-slide-left {
35+
0% {
36+
opacity: 1;
37+
translate: 0 0;
38+
}
39+
100% {
40+
opacity: 0;
41+
translate: -200px 0;
42+
}
43+
}
44+
45+
::view-transition-new(.slide-right) {
46+
animation: enter-slide-right ease-in 0.25s;
47+
}
48+
::view-transition-old(.slide-right) {
49+
animation: exit-slide-right ease-in 0.25s;
50+
}
51+
::view-transition-new(.slide-left) {
52+
animation: enter-slide-left ease-in 0.25s;
53+
}
54+
::view-transition-old(.slide-left) {
55+
animation: exit-slide-left ease-in 0.25s;
56+
}
57+
2358
::view-transition-new(.enter-slide-right):only-child {
2459
animation: enter-slide-right ease-in 0.25s;
2560
}
2661
::view-transition-old(.exit-slide-left):only-child {
2762
animation: exit-slide-left ease-in 0.25s;
2863
}
64+
65+
:root:active-view-transition-type(navigation-back) {
66+
&::view-transition-new(.slide-on-nav) {
67+
animation: enter-slide-right ease-in 0.25s;
68+
}
69+
&::view-transition-old(.slide-on-nav) {
70+
animation: exit-slide-right ease-in 0.25s;
71+
}
72+
}
73+
74+
:root:active-view-transition-type(navigation-forward) {
75+
&::view-transition-new(.slide-on-nav) {
76+
animation: enter-slide-left ease-in 0.25s;
77+
}
78+
&::view-transition-old(.slide-on-nav) {
79+
animation: exit-slide-left ease-in 0.25s;
80+
}
81+
}

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
PreinitScriptOptions,
2626
PreinitModuleScriptOptions,
2727
} from 'react-dom/src/shared/ReactDOMTypes';
28+
import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
2829

2930
import {NotPending} from '../shared/ReactDOMFormActions';
3031

@@ -1235,6 +1236,7 @@ const SUSPENSEY_FONT_TIMEOUT = 500;
12351236

12361237
export function startViewTransition(
12371238
rootContainer: Container,
1239+
transitionTypes: null | TransitionTypes,
12381240
mutationCallback: () => void,
12391241
layoutCallback: () => void,
12401242
afterMutationCallback: () => void,
@@ -1293,7 +1295,7 @@ export function startViewTransition(
12931295
afterMutationCallback();
12941296
}
12951297
},
1296-
types: null, // TODO: Provide types.
1298+
types: transitionTypes,
12971299
});
12981300
// $FlowFixMe[prop-missing]
12991301
ownerDocument.__reactViewTransition = transition;

packages/react-native-renderer/src/ReactFiberConfigNative.js

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

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

1213
// Modules provided by RN:
1314
import {
@@ -582,6 +583,7 @@ export function hasInstanceAffectedParent(
582583

583584
export function startViewTransition(
584585
rootContainer: Container,
586+
transitionTypes: null | TransitionTypes,
585587
mutationCallback: () => void,
586588
layoutCallback: () => void,
587589
afterMutationCallback: () => void,

packages/react-noop-renderer/src/createReactNoop.js

+2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {UpdateQueue} from 'react-reconciler/src/ReactFiberClassUpdateQueue'
2222
import type {ReactNodeList} from 'shared/ReactTypes';
2323
import type {RootTag} from 'react-reconciler/src/ReactRootTags';
2424
import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities';
25+
import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
2526

2627
import * as Scheduler from 'scheduler/unstable_mock';
2728
import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
@@ -780,6 +781,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
780781

781782
startViewTransition(
782783
rootContainer: Container,
784+
transitionTypes: null | TransitionTypes,
783785
mutationCallback: () => void,
784786
afterMutationCallback: () => void,
785787
layoutCallback: () => void,

packages/react-reconciler/src/ReactFiberViewTransitionComponent.js

+56-15
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,35 @@ import type {ReactNodeList} from 'shared/ReactTypes';
1111
import type {FiberRoot} from './ReactInternalTypes';
1212
import type {ViewTransitionInstance} from './ReactFiberConfig';
1313

14-
import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
14+
import {
15+
getWorkInProgressRoot,
16+
getPendingTransitionTypes,
17+
} from './ReactFiberWorkLoop';
1518

1619
import {getIsHydrating} from './ReactFiberHydrationContext';
1720

1821
import {getTreeId} from './ReactFiberTreeContext';
1922

23+
export type ViewTransitionClassPerType = {
24+
[transitionType: 'default' | string]: 'none' | string,
25+
};
26+
27+
export type ViewTransitionClass = 'none' | string | ViewTransitionClassPerType;
28+
2029
export type ViewTransitionProps = {
2130
name?: string,
2231
children?: ReactNodeList,
23-
className?: 'none' | string,
24-
enter?: 'none' | string,
25-
exit?: 'none' | string,
26-
layout?: 'none' | string,
27-
share?: 'none' | string,
28-
update?: 'none' | string,
29-
onEnter?: (instance: ViewTransitionInstance) => void,
30-
onExit?: (instance: ViewTransitionInstance) => void,
31-
onLayout?: (instance: ViewTransitionInstance) => void,
32-
onShare?: (instance: ViewTransitionInstance) => void,
33-
onUpdate?: (instance: ViewTransitionInstance) => void,
32+
className?: ViewTransitionClass,
33+
enter?: ViewTransitionClass,
34+
exit?: ViewTransitionClass,
35+
layout?: ViewTransitionClass,
36+
share?: ViewTransitionClass,
37+
update?: ViewTransitionClass,
38+
onEnter?: (instance: ViewTransitionInstance, types: Array<string>) => void,
39+
onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void,
40+
onLayout?: (instance: ViewTransitionInstance, types: Array<string>) => void,
41+
onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
42+
onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void,
3443
};
3544

3645
export type ViewTransitionState = {
@@ -82,17 +91,49 @@ export function getViewTransitionName(
8291
return (instance.autoName: any);
8392
}
8493

94+
function getClassNameByType(classByType: ?ViewTransitionClass): ?string {
95+
if (classByType == null || typeof classByType === 'string') {
96+
return classByType;
97+
}
98+
let className: ?string = null;
99+
const activeTypes = getPendingTransitionTypes();
100+
if (activeTypes !== null) {
101+
for (let i = 0; i < activeTypes.length; i++) {
102+
const match = classByType[activeTypes[i]];
103+
if (match != null) {
104+
if (match === 'none') {
105+
// If anything matches "none" that takes precedence over any other
106+
// type that also matches.
107+
return 'none';
108+
}
109+
if (className == null) {
110+
className = match;
111+
} else {
112+
className += ' ' + match;
113+
}
114+
}
115+
}
116+
}
117+
if (className == null) {
118+
// We had no other matches. Match the default for this configuration.
119+
return classByType.default;
120+
}
121+
return className;
122+
}
123+
85124
export function getViewTransitionClassName(
86-
className: ?string,
87-
eventClassName: ?string,
125+
defaultClass: ?ViewTransitionClass,
126+
eventClass: ?ViewTransitionClass,
88127
): ?string {
128+
const className: ?string = getClassNameByType(defaultClass);
129+
const eventClassName: ?string = getClassNameByType(eventClass);
89130
if (eventClassName == null) {
90131
return className;
91132
}
92133
if (eventClassName === 'none') {
93134
return eventClassName;
94135
}
95-
if (className != null) {
136+
if (className != null && className !== 'none') {
96137
return className + ' ' + eventClassName;
97138
}
98139
return eventClassName;

packages/react-reconciler/src/ReactFiberWorkLoop.js

+35-10
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
getViewTransitionName,
2828
type ViewTransitionState,
2929
} from './ReactFiberViewTransitionComponent';
30+
import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
3031

3132
import {
3233
enableCreateEventHandleAPI,
@@ -653,7 +654,9 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
653654
let pendingEffectsRenderEndTime: number = -0; // Profiling-only
654655
let pendingPassiveTransitions: Array<Transition> | null = null;
655656
let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
656-
let pendingViewTransitionEvents: Array<() => void> | null = null;
657+
let pendingViewTransitionEvents: Array<(types: Array<string>) => void> | null =
658+
null;
659+
let pendingTransitionTypes: null | TransitionTypes = null;
657660
let pendingDidIncludeRenderPhaseUpdate: boolean = false;
658661
let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
659662

@@ -695,6 +698,10 @@ export function getPendingPassiveEffectsLanes(): Lanes {
695698
return pendingEffectsLanes;
696699
}
697700

701+
export function getPendingTransitionTypes(): null | TransitionTypes {
702+
return pendingTransitionTypes;
703+
}
704+
698705
export function isWorkLoopSuspendedOnData(): boolean {
699706
return (
700707
workInProgressSuspendedReason === SuspendedOnData ||
@@ -804,7 +811,7 @@ export function requestDeferredLane(): Lane {
804811

805812
export function scheduleViewTransitionEvent(
806813
fiber: Fiber,
807-
callback: ?(instance: ViewTransitionInstance) => void,
814+
callback: ?(instance: ViewTransitionInstance, types: Array<string>) => void,
808815
): void {
809816
if (enableViewTransition) {
810817
if (callback != null) {
@@ -3348,9 +3355,6 @@ function commitRoot(
33483355
pendingEffectsRemainingLanes = remainingLanes;
33493356
pendingPassiveTransitions = transitions;
33503357
pendingRecoverableErrors = recoverableErrors;
3351-
if (enableViewTransition) {
3352-
pendingViewTransitionEvents = null;
3353-
}
33543358
pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate;
33553359
if (enableProfilerTimer) {
33563360
pendingEffectsRenderEndTime = completedRenderEndTime;
@@ -3362,10 +3366,24 @@ function commitRoot(
33623366
// might get scheduled in the commit phase. (See #16714.)
33633367
// TODO: Delete all other places that schedule the passive effect callback
33643368
// They're redundant.
3365-
const passiveSubtreeMask =
3366-
enableViewTransition && includesOnlyViewTransitionEligibleLanes(lanes)
3367-
? PassiveTransitionMask
3368-
: PassiveMask;
3369+
let passiveSubtreeMask;
3370+
if (enableViewTransition) {
3371+
pendingViewTransitionEvents = null;
3372+
if (includesOnlyViewTransitionEligibleLanes(lanes)) {
3373+
// Claim any pending Transition Types for this commit.
3374+
// This means that multiple roots committing independent View Transitions
3375+
// 1) end up staggered because we can only have one at a time.
3376+
// 2) only the first one gets all the Transition Types.
3377+
pendingTransitionTypes = ReactSharedInternals.V;
3378+
ReactSharedInternals.V = null;
3379+
passiveSubtreeMask = PassiveTransitionMask;
3380+
} else {
3381+
pendingTransitionTypes = null;
3382+
passiveSubtreeMask = PassiveMask;
3383+
}
3384+
} else {
3385+
passiveSubtreeMask = PassiveMask;
3386+
}
33693387
if (
33703388
// If this subtree rendered with profiling this commit, we need to visit it to log it.
33713389
(enableProfilerTimer &&
@@ -3461,6 +3479,7 @@ function commitRoot(
34613479
shouldStartViewTransition &&
34623480
startViewTransition(
34633481
root.containerInfo,
3482+
pendingTransitionTypes,
34643483
flushMutationEffects,
34653484
flushLayoutEffects,
34663485
flushAfterMutationEffects,
@@ -3708,11 +3727,17 @@ function flushSpawnedWork(): void {
37083727
// effects or spawned sync work since this is still part of the previous commit.
37093728
// Even though conceptually it's like its own task between layout effets and passive.
37103729
const pendingEvents = pendingViewTransitionEvents;
3730+
let pendingTypes = pendingTransitionTypes;
3731+
pendingTransitionTypes = null;
37113732
if (pendingEvents !== null) {
37123733
pendingViewTransitionEvents = null;
3734+
if (pendingTypes === null) {
3735+
// Normalize the type. This is lazily created only for events.
3736+
pendingTypes = [];
3737+
}
37133738
for (let i = 0; i < pendingEvents.length; i++) {
37143739
const viewTransitionEvent = pendingEvents[i];
3715-
viewTransitionEvent();
3740+
viewTransitionEvent(pendingTypes);
37163741
}
37173742
}
37183743
}

0 commit comments

Comments
 (0)