@samitouri / QOS-React / commits / 028c8e6cf5

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.

Sebastian Markbåge committed Jan 21, 2025 at 15:00 UTC 028c8e6cf5ce2a87147a7e03e503ce94c7a7a0cf
15 files changed +207 -28
fixtures/view-transition/src/components/App.js
+13
@@ -3,6 +3,7 @@ import React, {
3 useLayoutEffect,
4 useEffect,
5 useState,
6 + unstable_addTransitionType as addTransitionType,
7 } from 'react';
8
9 import Chrome from './Chrome';
@@ -35,11 +36,23 @@ export default function App({assets, initialURL}) {
36 if (!event.canIntercept) {
37 return;
38 }
39 + const navigationType = event.navigationType;
40 + const previousIndex = window.navigation.currentEntry.index;
41 const newURL = new URL(event.destination.url);
42 event.intercept({
43 handler() {
44 let promise;
45 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 + }
56 promise = new Promise(resolve => {
57 setRouterState({
58 url: newURL.pathname + newURL.search,
fixtures/view-transition/src/components/Page.js
+11 -1
@@ -36,7 +36,7 @@ function Component() {
36
37 export default function Page({url, navigate}) {
38 const show = url === '/?b';
39 - function onTransition(viewTransition) {
39 + function onTransition(viewTransition, types) {
40 const keyframes = [
41 {rotate: '0deg', transformOrigin: '30px 8px'},
42 {rotate: '360deg', transformOrigin: '30px 8px'},
@@ -59,6 +59,16 @@ export default function Page({url, navigate}) {
59 </button>
60 <ViewTransition className="none">
61 <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>
72 {show ? (
73 <div>
74 {a}
fixtures/view-transition/src/components/Transitions.module.css
+54 -1
@@ -9,7 +9,18 @@
9 }
10 }
11
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 {
24 0% {
25 opacity: 1;
26 translate: 0 0;
@@ -20,9 +31,51 @@
31 }
32 }
33
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 +
58 ::view-transition-new(.enter-slide-right):only-child {
59 animation: enter-slide-right ease-in 0.25s;
60 }
61 ::view-transition-old(.exit-slide-left):only-child {
62 animation: exit-slide-left ease-in 0.25s;
63 }
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
@@ -25,6 +25,7 @@ import type {
25 PreinitScriptOptions,
26 PreinitModuleScriptOptions,
27 } from 'react-dom/src/shared/ReactDOMTypes';
28 +import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
29
30 import {NotPending} from '../shared/ReactDOMFormActions';
31
@@ -1235,6 +1236,7 @@ const SUSPENSEY_FONT_TIMEOUT = 500;
1236
1237 export function startViewTransition(
1238 rootContainer: Container,
1239 + transitionTypes: null | TransitionTypes,
1240 mutationCallback: () => void,
1241 layoutCallback: () => void,
1242 afterMutationCallback: () => void,
@@ -1293,7 +1295,7 @@ export function startViewTransition(
1295 afterMutationCallback();
1296 }
1297 },
1296 - types: null, // TODO: Provide types.
1298 + types: transitionTypes,
1299 });
1300 // $FlowFixMe[prop-missing]
1301 ownerDocument.__reactViewTransition = transition;
packages/react-native-renderer/src/ReactFiberConfigNative.js
+2
@@ -8,6 +8,7 @@
8 */
9
10 import type {InspectorData, TouchedViewDataAtPoint} from './ReactNativeTypes';
11 +import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
12
13 // Modules provided by RN:
14 import {
@@ -582,6 +583,7 @@ export function hasInstanceAffectedParent(
583
584 export function startViewTransition(
585 rootContainer: Container,
586 + transitionTypes: null | TransitionTypes,
587 mutationCallback: () => void,
588 layoutCallback: () => void,
589 afterMutationCallback: () => void,
packages/react-noop-renderer/src/createReactNoop.js
+2
@@ -22,6 +22,7 @@ import type {UpdateQueue} from 'react-reconciler/src/ReactFiberClassUpdateQueue'
22 import type {ReactNodeList} from 'shared/ReactTypes';
23 import type {RootTag} from 'react-reconciler/src/ReactRootTags';
24 import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities';
25 +import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
26
27 import * as Scheduler from 'scheduler/unstable_mock';
28 import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
@@ -780,6 +781,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
781
782 startViewTransition(
783 rootContainer: Container,
784 + transitionTypes: null | TransitionTypes,
785 mutationCallback: () => void,
786 afterMutationCallback: () => void,
787 layoutCallback: () => void,
packages/react-reconciler/src/ReactFiberViewTransitionComponent.js
+56 -15
@@ -11,26 +11,35 @@ import type {ReactNodeList} from 'shared/ReactTypes';
11 import type {FiberRoot} from './ReactInternalTypes';
12 import type {ViewTransitionInstance} from './ReactFiberConfig';
13
14 -import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
14 +import {
15 + getWorkInProgressRoot,
16 + getPendingTransitionTypes,
17 +} from './ReactFiberWorkLoop';
18
19 import {getIsHydrating} from './ReactFiberHydrationContext';
20
21 import {getTreeId} from './ReactFiberTreeContext';
22
23 +export type ViewTransitionClassPerType = {
24 + [transitionType: 'default' | string]: 'none' | string,
25 +};
26 +
27 +export type ViewTransitionClass = 'none' | string | ViewTransitionClassPerType;
28 +
29 export type ViewTransitionProps = {
30 name?: string,
31 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,
43 };
44
45 export type ViewTransitionState = {
@@ -82,17 +91,49 @@ export function getViewTransitionName(
91 return (instance.autoName: any);
92 }
93
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 +
124 export function getViewTransitionClassName(
86 - className: ?string,
87 - eventClassName: ?string,
125 + defaultClass: ?ViewTransitionClass,
126 + eventClass: ?ViewTransitionClass,
127 ): ?string {
128 + const className: ?string = getClassNameByType(defaultClass);
129 + const eventClassName: ?string = getClassNameByType(eventClass);
130 if (eventClassName == null) {
131 return className;
132 }
133 if (eventClassName === 'none') {
134 return eventClassName;
135 }
95 - if (className != null) {
136 + if (className != null && className !== 'none') {
137 return className + ' ' + eventClassName;
138 }
139 return eventClassName;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+35 -10
@@ -27,6 +27,7 @@ import {
27 getViewTransitionName,
28 type ViewTransitionState,
29 } from './ReactFiberViewTransitionComponent';
30 +import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
31
32 import {
33 enableCreateEventHandleAPI,
@@ -653,7 +654,9 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
654 let pendingEffectsRenderEndTime: number = -0; // Profiling-only
655 let pendingPassiveTransitions: Array<Transition> | null = null;
656 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;
660 let pendingDidIncludeRenderPhaseUpdate: boolean = false;
661 let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
662
@@ -695,6 +698,10 @@ export function getPendingPassiveEffectsLanes(): Lanes {
698 return pendingEffectsLanes;
699 }
700
701 +export function getPendingTransitionTypes(): null | TransitionTypes {
702 + return pendingTransitionTypes;
703 +}
704 +
705 export function isWorkLoopSuspendedOnData(): boolean {
706 return (
707 workInProgressSuspendedReason === SuspendedOnData ||
@@ -804,7 +811,7 @@ export function requestDeferredLane(): Lane {
811
812 export function scheduleViewTransitionEvent(
813 fiber: Fiber,
807 - callback: ?(instance: ViewTransitionInstance) => void,
814 + callback: ?(instance: ViewTransitionInstance, types: Array<string>) => void,
815 ): void {
816 if (enableViewTransition) {
817 if (callback != null) {
@@ -3348,9 +3355,6 @@ function commitRoot(
3355 pendingEffectsRemainingLanes = remainingLanes;
3356 pendingPassiveTransitions = transitions;
3357 pendingRecoverableErrors = recoverableErrors;
3351 - if (enableViewTransition) {
3352 - pendingViewTransitionEvents = null;
3353 - }
3358 pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate;
3359 if (enableProfilerTimer) {
3360 pendingEffectsRenderEndTime = completedRenderEndTime;
@@ -3362,10 +3366,24 @@ function commitRoot(
3366 // might get scheduled in the commit phase. (See #16714.)
3367 // TODO: Delete all other places that schedule the passive effect callback
3368 // 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 + }
3387 if (
3388 // If this subtree rendered with profiling this commit, we need to visit it to log it.
3389 (enableProfilerTimer &&
@@ -3461,6 +3479,7 @@ function commitRoot(
3479 shouldStartViewTransition &&
3480 startViewTransition(
3481 root.containerInfo,
3482 + pendingTransitionTypes,
3483 flushMutationEffects,
3484 flushLayoutEffects,
3485 flushAfterMutationEffects,
@@ -3708,11 +3727,17 @@ function flushSpawnedWork(): void {
3727 // effects or spawned sync work since this is still part of the previous commit.
3728 // Even though conceptually it's like its own task between layout effets and passive.
3729 const pendingEvents = pendingViewTransitionEvents;
3730 + let pendingTypes = pendingTransitionTypes;
3731 + pendingTransitionTypes = null;
3732 if (pendingEvents !== null) {
3733 pendingViewTransitionEvents = null;
3734 + if (pendingTypes === null) {
3735 + // Normalize the type. This is lazily created only for events.
3736 + pendingTypes = [];
3737 + }
3738 for (let i = 0; i < pendingEvents.length; i++) {
3739 const viewTransitionEvent = pendingEvents[i];
3715 - viewTransitionEvent();
3740 + viewTransitionEvent(pendingTypes);
3741 }
3742 }
3743 }
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+2
@@ -8,6 +8,7 @@
8 */
9
10 import type {ReactContext} from 'shared/ReactTypes';
11 +import type {TransitionTypes} from 'react/src/ReactTransitionType.js';
12
13 import isArray from 'shared/isArray';
14 import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
@@ -364,6 +365,7 @@ export function hasInstanceAffectedParent(
365
366 export function startViewTransition(
367 rootContainer: Container,
368 + transitionTypes: null | TransitionTypes,
369 mutationCallback: () => void,
370 layoutCallback: () => void,
371 afterMutationCallback: () => void,
packages/react/index.experimental.development.js
+1
@@ -33,6 +33,7 @@ export {
33 unstable_getCacheForType,
34 unstable_SuspenseList,
35 unstable_ViewTransition,
36 + unstable_addTransitionType,
37 unstable_useCacheRefresh,
38 useId,
39 useCallback,
packages/react/index.experimental.js
+1
@@ -33,6 +33,7 @@ export {
33 unstable_getCacheForType,
34 unstable_SuspenseList,
35 unstable_ViewTransition,
36 + unstable_addTransitionType,
37 unstable_useCacheRefresh,
38 useId,
39 useCallback,
packages/react/index.js
+1
@@ -52,6 +52,7 @@ export {
52 unstable_SuspenseList,
53 unstable_TracingMarker,
54 unstable_ViewTransition,
55 + unstable_addTransitionType,
56 unstable_getCacheForType,
57 unstable_useCacheRefresh,
58 useId,
packages/react/src/ReactClient.js
+2
@@ -61,6 +61,7 @@ import {
61 } from './ReactHooks';
62 import ReactSharedInternals from './ReactSharedInternalsClient';
63 import {startTransition} from './ReactStartTransition';
64 +import {addTransitionType} from './ReactTransitionType';
65 import {act} from './ReactAct';
66 import {captureOwnerStack} from './ReactOwnerStack';
67 import * as ReactCompilerRuntime from './ReactCompilerRuntime';
@@ -126,6 +127,7 @@ export {
127 REACT_TRACING_MARKER_TYPE as unstable_TracingMarker,
128 // enableViewTransition
129 REACT_VIEW_TRANSITION_TYPE as unstable_ViewTransition,
130 + addTransitionType as unstable_addTransitionType,
131 useId,
132 act, // DEV-only
133 captureOwnerStack, // DEV-only
packages/react/src/ReactSharedInternalsClient.js
+3
@@ -10,12 +10,14 @@
10 import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
11 import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes';
12 import type {BatchConfigTransition} from 'react-reconciler/src/ReactFiberTracingMarkerComponent';
13 +import type {TransitionTypes} from './ReactTransitionType';
14
15 export type SharedStateClient = {
16 H: null | Dispatcher, // ReactCurrentDispatcher for Hooks
17 A: null | AsyncDispatcher, // ReactCurrentCache for Cache
18 T: null | BatchConfigTransition, // ReactCurrentBatchConfig for Transitions
19 S: null | ((BatchConfigTransition, mixed) => void), // onStartTransitionFinish
20 + V: null | TransitionTypes, // Pending Transition Types for the Next Transition
21
22 // DEV-only
23
@@ -45,6 +47,7 @@ const ReactSharedInternals: SharedStateClient = ({
47 A: null,
48 T: null,
49 S: null,
50 + V: null,
51 }: any);
52
53 if (__DEV__) {
packages/react/src/ReactTransitionType.js new
+21
@@ -0,0 +1,21 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import ReactSharedInternals from 'shared/ReactSharedInternals';
11 +
12 +export type TransitionTypes = Array<string>;
13 +
14 +export function addTransitionType(type: string): void {
15 + const pendingTransitionTypes: null | TransitionTypes = ReactSharedInternals.V;
16 + if (pendingTransitionTypes === null) {
17 + ReactSharedInternals.V = [type];
18 + } else if (pendingTransitionTypes.indexOf(type) === -1) {
19 + pendingTransitionTypes.push(type);
20 + }
21 +}