@samitouri / QOS-React-2 / commits / b286430c8a

Add startGestureTransition API (#32785)

Stacked on #32783. This will replace [the `useSwipeTransition` API](https://github.com/facebook/react/pull/32373). Instead, of a special Hook, you can make updates to `useOptimistic` Hooks within the `startGestureTransition` scope. ``` import {unstable_startGestureTransition as startGestureTransition} from 'react'; const cancel = startGestureTransition(timeline, () => { setOptimistic(...); }, options); ``` There are some downsides to this like you can't define two directions as once and there's no "standard" direction protocol. It's instead up to libraries to come up with their own conventions (although we can suggest some). The convention is still that a gesture recognizer has two props `action` and `gesture`. The `gesture` prop is a Gesture concept which now behaves more like an Action but 1) it can't be async 2) it shouldn't have side-effects. For example you can't call `setState()` in it except on `useOptimistic` since those can be reverted if needed. The `action` is invoked with whatever side-effects you want after the gesture fulfills. This is isomorphic and not associated with a specific renderer nor root so it's a bit more complicated. To implement this I unify with the `ReactSharedInternal.T` property to contain a regular Transition or a Gesture Transition (the `gesture` field). The benefit of this unification means that every time we override this based on some scope like entering `flushSync` we also override the `startGestureTransition` scope. We just have to be careful when we read it to check the `gesture` field to know which one it is. (E.g. I error for setState / requestFormReset.) The other thing that's unique is the `cancel` return value to know when to stop the gesture. That cancellation is no longer associated with any particular Hook. It's more associated with the scope of the `startGestureTransition`. Since the schedule of whether a particular gesture has rendered or committed is associated with a root, we need to somehow associate any scheduled gestures with a root. We could track which roots we update inside the scope but instead, I went with a model where I check all the roots and see if there's a scheduled gesture matching the timeline. This means that you could "retain" a gesture across roots. Meaning this wouldn't cancel until both are cancelled: ``` const cancelA = startGestureTransition(timeline, () => { setOptimisticOnRootA(...); }, options); const cancelB = startGestureTransition(timeline, () => { setOptimisticOnRootB(...); }, options); ``` It's more like it's a global transition than associated with the roots that were updated. Optimistic updates mostly just work but I now associate them with a specific "ScheduledGesture" instance since we can only render one at a time and so if it's not the current one, we leave it for later. Clean up of optimistic updates is now lazy rather than when we cancel. Allowing the cancel closure not to have to be associated with each particular update.

Sebastian Markbåge committed Mar 31, 2025 at 20:05 UTC b286430c8a585dc2e2e3cc023e7c455ec2b34ab7
14 files changed +382 -33
fixtures/view-transition/src/components/Page.js
+9 -3
@@ -1,13 +1,14 @@
1 import React, {
2 unstable_ViewTransition as ViewTransition,
3 unstable_Activity as Activity,
4 - unstable_useSwipeTransition as useSwipeTransition,
4 useLayoutEffect,
5 useEffect,
6 useState,
7 useId,
8 + useOptimistic,
9 startTransition,
10 } from 'react';
11 +
12 import {createPortal} from 'react-dom';
13
14 import SwipeRecognizer from './SwipeRecognizer';
@@ -49,7 +50,12 @@ function Id() {
50 }
51
52 export default function Page({url, navigate}) {
52 - const [renderedUrl, startGesture] = useSwipeTransition('/?a', url, '/?b');
53 + const [renderedUrl, optimisticNavigate] = useOptimistic(
54 + url,
55 + (state, direction) => {
56 + return direction === 'left' ? '/?a' : '/?b';
57 + }
58 + );
59 const show = renderedUrl === '/?b';
60 function onTransition(viewTransition, types) {
61 const keyframes = [
@@ -107,7 +113,7 @@ export default function Page({url, navigate}) {
113 <div className="swipe-recognizer">
114 <SwipeRecognizer
115 action={swipeAction}
110 - gesture={startGesture}
116 + gesture={optimisticNavigate}
117 direction={show ? 'left' : 'right'}>
118 <button
119 className="button"
fixtures/view-transition/src/components/SwipeRecognizer.js
+15 -4
@@ -1,4 +1,9 @@
1 -import React, {useRef, useEffect, startTransition} from 'react';
1 +import React, {
2 + useRef,
3 + useEffect,
4 + startTransition,
5 + unstable_startGestureTransition as startGestureTransition,
6 +} from 'react';
7
8 // Example of a Component that can recognize swipe gestures using a ScrollTimeline
9 // without scrolling its own content. Allowing it to be used as an inert gesture
@@ -28,9 +33,15 @@ export default function SwipeRecognizer({
33 source: scrollRef.current,
34 axis: axis,
35 });
31 - activeGesture.current = gesture(scrollTimeline, {
32 - range: [0, direction === 'left' || direction === 'up' ? 100 : 0, 100],
33 - });
36 + activeGesture.current = startGestureTransition(
37 + scrollTimeline,
38 + () => {
39 + gesture(direction);
40 + },
41 + {
42 + range: [0, direction === 'left' || direction === 'up' ? 100 : 0, 100],
43 + }
44 + );
45 }
46 function onScrollEnd() {
47 let changed;
packages/react-reconciler/src/ReactFiberGestureScheduler.js
+99 -2
@@ -8,6 +8,7 @@
8 */
9
10 import type {FiberRoot} from './ReactInternalTypes';
11 +import type {GestureOptions} from 'shared/ReactTypes';
12 import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig';
13
14 import {
@@ -18,6 +19,7 @@ import {
19 import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
20 import {
21 subscribeToGestureDirection,
22 + getCurrentGestureOffset,
23 stopViewTransition,
24 } from './ReactFiberConfig';
25
@@ -29,13 +31,14 @@ export type ScheduledGesture = {
31 rangePrevious: number, // The end along the timeline where the previous state is reached.
32 rangeCurrent: number, // The starting offset along the timeline.
33 rangeNext: number, // The end along the timeline where the next state is reached.
32 - cancel: () => void, // Cancel the subscription to direction change.
34 + cancel: () => void, // Cancel the subscription to direction change. // TODO: Delete this.
35 running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
36 prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
37 next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
38 };
39
38 -export function scheduleGesture(
40 +// TODO: Delete this when deleting useSwipeTransition.
41 +export function scheduleGestureLegacy(
42 root: FiberRoot,
43 provider: GestureTimeline,
44 initialDirection: boolean,
@@ -107,6 +110,100 @@ export function scheduleGesture(
110 return gesture;
111 }
112
113 +export function scheduleGesture(
114 + root: FiberRoot,
115 + provider: GestureTimeline,
116 +): ScheduledGesture {
117 + let prev = root.pendingGestures;
118 + while (prev !== null) {
119 + if (prev.provider === provider) {
120 + // Existing instance found.
121 + return prev;
122 + }
123 + const next = prev.next;
124 + if (next === null) {
125 + break;
126 + }
127 + prev = next;
128 + }
129 + const gesture: ScheduledGesture = {
130 + provider: provider,
131 + count: 0,
132 + direction: false,
133 + rangePrevious: -1,
134 + rangeCurrent: -1,
135 + rangeNext: -1,
136 + cancel: () => {}, // TODO: Delete this with useSwipeTransition.
137 + running: null,
138 + prev: prev,
139 + next: null,
140 + };
141 + if (prev === null) {
142 + root.pendingGestures = gesture;
143 + } else {
144 + prev.next = gesture;
145 + }
146 + ensureRootIsScheduled(root);
147 + return gesture;
148 +}
149 +
150 +export function startScheduledGesture(
151 + root: FiberRoot,
152 + gestureTimeline: GestureTimeline,
153 + gestureOptions: ?GestureOptions,
154 +): null | ScheduledGesture {
155 + const currentOffset = getCurrentGestureOffset(gestureTimeline);
156 + const range = gestureOptions && gestureOptions.range;
157 + const rangePrevious: number = range ? range[0] : 0; // If no range is provider we assume it's the starting point of the range.
158 + const rangeCurrent: number = range ? range[1] : currentOffset;
159 + const rangeNext: number = range ? range[2] : 100; // If no range is provider we assume it's the starting point of the range.
160 + if (__DEV__) {
161 + if (
162 + (rangePrevious > rangeCurrent && rangeNext > rangeCurrent) ||
163 + (rangePrevious < rangeCurrent && rangeNext < rangeCurrent)
164 + ) {
165 + console.error(
166 + 'The range of a gesture needs "previous" and "next" to be on either side of ' +
167 + 'the "current" offset. Both cannot be above current and both cannot be below current.',
168 + );
169 + }
170 + }
171 + const isFlippedDirection = rangePrevious > rangeNext;
172 + const initialDirection =
173 + // If a range is specified we can imply initial direction if it's not the current
174 + // value such as if the gesture starts after it has already moved.
175 + currentOffset < rangeCurrent
176 + ? isFlippedDirection
177 + : currentOffset > rangeCurrent
178 + ? !isFlippedDirection
179 + : // Otherwise, look for an explicit option.
180 + gestureOptions
181 + ? gestureOptions.direction === 'next'
182 + : false;
183 +
184 + let prev = root.pendingGestures;
185 + while (prev !== null) {
186 + if (prev.provider === gestureTimeline) {
187 + // Existing instance found.
188 + prev.count++;
189 + // Update the options.
190 + prev.direction = initialDirection;
191 + prev.rangePrevious = rangePrevious;
192 + prev.rangeCurrent = rangeCurrent;
193 + prev.rangeNext = rangeNext;
194 + return prev;
195 + }
196 + const next = prev.next;
197 + if (next === null) {
198 + break;
199 + }
200 + prev = next;
201 + }
202 + // No scheduled gestures. It must mean nothing for this renderer updated but
203 + // some other renderer might have updated.
204 + return null;
205 +}
206 +
207 export function cancelScheduledGesture(
208 root: FiberRoot,
209 gesture: ScheduledGesture,
packages/react-reconciler/src/ReactFiberHooks.js
+69 -10
@@ -163,6 +163,7 @@ import {callComponentInDEV} from './ReactFiberCallUserSpace';
163
164 import {
165 scheduleGesture,
166 + scheduleGestureLegacy,
167 cancelScheduledGesture,
168 } from './ReactFiberGestureScheduler';
169
@@ -173,6 +174,7 @@ export type Update<S, A> = {
174 hasEagerState: boolean,
175 eagerState: S | null,
176 next: Update<S, A>,
177 + gesture: null | ScheduledGesture, // enableSwipeTransition
178 };
179
180 export type UpdateQueue<S, A> = {
@@ -1377,10 +1379,35 @@ function updateReducerImpl<S, A>(
1379 // Check if this update was made while the tree was hidden. If so, then
1380 // it's not a "base" update and we should disregard the extra base lanes
1381 // that were added to renderLanes when we entered the Offscreen tree.
1380 - const shouldSkipUpdate = isHiddenUpdate
1382 + let shouldSkipUpdate = isHiddenUpdate
1383 ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
1384 : !isSubsetOfLanes(renderLanes, updateLane);
1385
1386 + if (enableSwipeTransition && updateLane === GestureLane) {
1387 + // This is a gesture optimistic update. It should only be considered as part of the
1388 + // rendered state while rendering the gesture lane and if the rendering the associated
1389 + // ScheduledGesture.
1390 + const scheduledGesture = update.gesture;
1391 + if (scheduledGesture !== null) {
1392 + if (scheduledGesture.count === 0) {
1393 + // This gesture has already been cancelled. We can clean up this update.
1394 + update = update.next;
1395 + continue;
1396 + } else if (!isGestureRender(renderLanes)) {
1397 + shouldSkipUpdate = true;
1398 + } else {
1399 + const root: FiberRoot | null = getWorkInProgressRoot();
1400 + if (root === null) {
1401 + throw new Error(
1402 + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
1403 + );
1404 + }
1405 + // We assume that the currently rendering gesture is the one first in the queue.
1406 + shouldSkipUpdate = root.pendingGestures !== scheduledGesture;
1407 + }
1408 + }
1409 + }
1410 +
1411 if (shouldSkipUpdate) {
1412 // Priority is insufficient. Skip this update. If this is the first
1413 // skipped update, the previous update/state is the new base
@@ -1388,6 +1415,7 @@ function updateReducerImpl<S, A>(
1415 const clone: Update<S, A> = {
1416 lane: updateLane,
1417 revertLane: update.revertLane,
1418 + gesture: update.gesture,
1419 action: update.action,
1420 hasEagerState: update.hasEagerState,
1421 eagerState: update.eagerState,
@@ -1423,6 +1451,7 @@ function updateReducerImpl<S, A>(
1451 // this will never be skipped by the check above.
1452 lane: NoLane,
1453 revertLane: NoLane,
1454 + gesture: null,
1455 action: update.action,
1456 hasEagerState: update.hasEagerState,
1457 eagerState: update.eagerState,
@@ -1466,6 +1495,7 @@ function updateReducerImpl<S, A>(
1495 // Reuse the same revertLane so we know when the transition
1496 // has finished.
1497 revertLane: update.revertLane,
1498 + gesture: null, // If it commits, it's no longer a gesture update.
1499 action: update.action,
1500 hasEagerState: update.hasEagerState,
1501 eagerState: update.eagerState,
@@ -2138,6 +2168,9 @@ function runActionStateAction<S, P>(
2168 // This is a fork of startTransition
2169 const prevTransition = ReactSharedInternals.T;
2170 const currentTransition: Transition = ({}: any);
2171 + if (enableSwipeTransition) {
2172 + currentTransition.gesture = null;
2173 + }
2174 if (enableTransitionTracing) {
2175 currentTransition.name = null;
2176 currentTransition.startTime = -1;
@@ -3017,6 +3050,9 @@ function startTransition<S>(
3050
3051 const prevTransition = ReactSharedInternals.T;
3052 const currentTransition: Transition = ({}: any);
3053 + if (enableSwipeTransition) {
3054 + currentTransition.gesture = null;
3055 + }
3056 if (enableTransitionTracing) {
3057 currentTransition.name =
3058 options !== undefined && options.name !== undefined ? options.name : null;
@@ -3226,8 +3262,8 @@ function ensureFormComponentIsStateful(formFiber: Fiber) {
3262 export function requestFormReset(formFiber: Fiber) {
3263 const transition = requestCurrentTransition();
3264
3229 - if (__DEV__) {
3230 - if (transition === null) {
3265 + if (transition === null) {
3266 + if (__DEV__) {
3267 // An optimistic update occurred, but startTransition is not on the stack.
3268 // The form reset will be scheduled at default (sync) priority, which
3269 // is probably not what the user intended. Most likely because the
@@ -3242,6 +3278,13 @@ export function requestFormReset(formFiber: Fiber) {
3278 'fix, move to an action, or wrap with startTransition.',
3279 );
3280 }
3281 + } else if (enableSwipeTransition && transition.gesture) {
3282 + throw new Error(
3283 + 'Cannot requestFormReset() inside a startGestureTransition. ' +
3284 + 'There should be no side-effects associated with starting a ' +
3285 + 'Gesture until its Action is invoked. Move side-effects to the ' +
3286 + 'Action instead.',
3287 + );
3288 }
3289
3290 const stateHook = ensureFormComponentIsStateful(formFiber);
@@ -3441,6 +3484,7 @@ function dispatchReducerAction<S, A>(
3484 const update: Update<S, A> = {
3485 lane,
3486 revertLane: NoLane,
3487 + gesture: null,
3488 action,
3489 hasEagerState: false,
3490 eagerState: null,
@@ -3500,6 +3544,7 @@ function dispatchSetStateInternal<S, A>(
3544 const update: Update<S, A> = {
3545 lane,
3546 revertLane: NoLane,
3547 + gesture: null,
3548 action,
3549 hasEagerState: false,
3550 eagerState: null,
@@ -3607,12 +3652,18 @@ function dispatchOptimisticSetState<S, A>(
3652 }
3653 }
3654
3655 + // For regular Transitions an optimistic update commits synchronously.
3656 + // For gesture Transitions an optimistic update commits on the GestureLane.
3657 + const lane =
3658 + enableSwipeTransition && transition !== null && transition.gesture
3659 + ? GestureLane
3660 + : SyncLane;
3661 const update: Update<S, A> = {
3611 - // An optimistic update commits synchronously.
3612 - lane: SyncLane,
3662 + lane: lane,
3663 // After committing, the optimistic update is "reverted" using the same
3664 // lane as the transition it's associated with.
3665 revertLane: requestTransitionLane(transition),
3666 + gesture: null,
3667 action,
3668 hasEagerState: false,
3669 eagerState: null,
@@ -3635,20 +3686,28 @@ function dispatchOptimisticSetState<S, A>(
3686 }
3687 }
3688 } else {
3638 - const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
3689 + const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
3690 if (root !== null) {
3691 // NOTE: The optimistic update implementation assumes that the transition
3692 // will never be attempted before the optimistic update. This currently
3693 // holds because the optimistic update is always synchronous. If we ever
3694 // change that, we'll need to account for this.
3644 - startUpdateTimerByLane(SyncLane);
3645 - scheduleUpdateOnFiber(root, fiber, SyncLane);
3695 + startUpdateTimerByLane(lane);
3696 + scheduleUpdateOnFiber(root, fiber, lane);
3697 // Optimistic updates are always synchronous, so we don't need to call
3698 // entangleTransitionUpdate here.
3699 + if (enableSwipeTransition && transition !== null) {
3700 + const provider = transition.gesture;
3701 + if (provider !== null) {
3702 + // If this was a gesture, ensure we have a scheduled gesture and that
3703 + // we associate this update with this specific gesture instance.
3704 + update.gesture = scheduleGesture(root, provider);
3705 + }
3706 + }
3707 }
3708 }
3709
3651 - markUpdateInDevTools(fiber, SyncLane, action);
3710 + markUpdateInDevTools(fiber, lane, action);
3711 }
3712
3713 function isRenderPhaseUpdate(fiber: Fiber): boolean {
@@ -3769,7 +3828,7 @@ function startGesture(
3828 ? false
3829 : // If no option is specified, imply from the values specified.
3830 queue.initialDirection;
3772 - const scheduledGesture = scheduleGesture(
3831 + const scheduledGesture = scheduleGestureLegacy(
3832 root,
3833 gestureTimeline,
3834 initialDirection,
packages/react-reconciler/src/ReactFiberRootScheduler.js
+1 -1
@@ -83,7 +83,7 @@ import {
83 // A linked list of all the roots with pending work. In an idiomatic app,
84 // there's only a single root, but we do support multi root apps, hence this
85 // extra complexity. But this module is optimized for the single root case.
86 -let firstScheduledRoot: FiberRoot | null = null;
86 +export let firstScheduledRoot: FiberRoot | null = null;
87 let lastScheduledRoot: FiberRoot | null = null;
88
89 // Used to prevent redundant mircotasks from being scheduled.
packages/react-reconciler/src/ReactFiberTransition.js
+70 -2
@@ -7,13 +7,21 @@
7 * @flow
8 */
9 import type {Fiber, FiberRoot} from './ReactInternalTypes';
10 -import type {Thenable} from 'shared/ReactTypes';
10 +import type {
11 + Thenable,
12 + GestureProvider,
13 + GestureOptions,
14 +} from 'shared/ReactTypes';
15 import type {Lanes} from './ReactFiberLane';
16 import type {StackCursor} from './ReactFiberStack';
17 import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent';
18 import type {Transition} from 'react/src/ReactStartTransition';
19 +import type {ScheduledGesture} from './ReactFiberGestureScheduler';
20
16 -import {enableTransitionTracing} from 'shared/ReactFeatureFlags';
21 +import {
22 + enableTransitionTracing,
23 + enableSwipeTransition,
24 +} from 'shared/ReactFeatureFlags';
25 import {isPrimaryRenderer} from './ReactFiberConfig';
26 import {createCursor, push, pop} from './ReactFiberStack';
27 import {
@@ -29,6 +37,11 @@ import {
37 import ReactSharedInternals from 'shared/ReactSharedInternals';
38 import {entangleAsyncAction} from './ReactFiberAsyncAction';
39 import {startAsyncTransitionTimer} from './ReactProfilerTimer';
40 +import {firstScheduledRoot} from './ReactFiberRootScheduler';
41 +import {
42 + startScheduledGesture,
43 + cancelScheduledGesture,
44 +} from './ReactFiberGestureScheduler';
45
46 export const NoTransition = null;
47
@@ -78,6 +91,61 @@ ReactSharedInternals.S = function onStartTransitionFinishForReconciler(
91 }
92 };
93
94 +function chainGestureCancellation(
95 + root: FiberRoot,
96 + scheduledGesture: ScheduledGesture,
97 + prevCancel: null | (() => void),
98 +): () => void {
99 + return function cancelGesture(): void {
100 + if (scheduledGesture !== null) {
101 + cancelScheduledGesture(root, scheduledGesture);
102 + }
103 + if (prevCancel !== null) {
104 + prevCancel();
105 + }
106 + };
107 +}
108 +
109 +if (enableSwipeTransition) {
110 + const prevOnStartGestureTransitionFinish = ReactSharedInternals.G;
111 + ReactSharedInternals.G = function onStartGestureTransitionFinishForReconciler(
112 + transition: Transition,
113 + provider: GestureProvider,
114 + options: ?GestureOptions,
115 + ): () => void {
116 + let cancel = null;
117 + if (prevOnStartGestureTransitionFinish !== null) {
118 + cancel = prevOnStartGestureTransitionFinish(
119 + transition,
120 + provider,
121 + options,
122 + );
123 + }
124 + // For every root that has work scheduled, check if there's a ScheduledGesture
125 + // matching this provider and if so, increase its ref count so its retained by
126 + // this cancellation callback. We could add the roots to a temporary array as
127 + // we schedule them inside the callback to keep track of them. There's a slight
128 + // nuance here which is that if there's more than one root scheduled with the
129 + // same provider, but it doesn't update in this callback, then we still update
130 + // its options and retain it until this cancellation releases. The idea being
131 + // that it's conceptually started globally.
132 + let root = firstScheduledRoot;
133 + while (root !== null) {
134 + const scheduledGesture = startScheduledGesture(root, provider, options);
135 + if (scheduledGesture !== null) {
136 + cancel = chainGestureCancellation(root, scheduledGesture, cancel);
137 + }
138 + root = root.next;
139 + }
140 + if (cancel !== null) {
141 + return cancel;
142 + }
143 + return function cancelGesture(): void {
144 + // Nothing was scheduled but it could've been scheduled by another renderer.
145 + };
146 + };
147 +}
148 +
149 export function requestCurrentTransition(): Transition | null {
150 return ReactSharedInternals.T;
151 }
packages/react-reconciler/src/ReactFiberWorkLoop.js
+10
@@ -753,6 +753,16 @@ export function requestUpdateLane(fiber: Fiber): Lane {
753
754 const transition = requestCurrentTransition();
755 if (transition !== null) {
756 + if (enableSwipeTransition) {
757 + if (transition.gesture) {
758 + throw new Error(
759 + 'Cannot setState on regular state inside a startGestureTransition. ' +
760 + 'Gestures can only update the useOptimistic() hook. There should be no ' +
761 + 'side-effects associated with starting a Gesture until its Action is ' +
762 + 'invoked. Move side-effects to the Action instead.',
763 + );
764 + }
765 + }
766 if (__DEV__) {
767 if (!transition._updatedFibers) {
768 transition._updatedFibers = new Set();
packages/react/index.experimental.development.js
+1
@@ -33,6 +33,7 @@ export {
33 unstable_getCacheForType,
34 unstable_SuspenseList,
35 unstable_ViewTransition,
36 + unstable_startGestureTransition,
37 unstable_useSwipeTransition,
38 unstable_addTransitionType,
39 unstable_useCacheRefresh,
packages/react/index.experimental.js
+1
@@ -33,6 +33,7 @@ export {
33 unstable_getCacheForType,
34 unstable_SuspenseList,
35 unstable_ViewTransition,
36 + unstable_startGestureTransition,
37 unstable_useSwipeTransition,
38 unstable_addTransitionType,
39 unstable_useCacheRefresh,
packages/react/src/ReactClient.js
+2 -1
@@ -60,7 +60,7 @@ import {
60 useSwipeTransition,
61 } from './ReactHooks';
62 import ReactSharedInternals from './ReactSharedInternalsClient';
63 -import {startTransition} from './ReactStartTransition';
63 +import {startTransition, startGestureTransition} from './ReactStartTransition';
64 import {addTransitionType} from './ReactTransitionType';
65 import {act} from './ReactAct';
66 import {captureOwnerStack} from './ReactOwnerStack';
@@ -128,6 +128,7 @@ export {
128 REACT_VIEW_TRANSITION_TYPE as unstable_ViewTransition,
129 addTransitionType as unstable_addTransitionType,
130 // enableSwipeTransition
131 + startGestureTransition as unstable_startGestureTransition,
132 useSwipeTransition as unstable_useSwipeTransition,
133 // DEV-only
134 useId,
packages/react/src/ReactSharedInternalsClient.js
+13 -1
@@ -11,12 +11,19 @@ import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
11 import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes';
12 import type {Transition} from './ReactStartTransition';
13 import type {TransitionTypes} from './ReactTransitionType';
14 +import type {GestureProvider, GestureOptions} from 'shared/ReactTypes';
15 +
16 +import {
17 + enableViewTransition,
18 + enableSwipeTransition,
19 +} from 'shared/ReactFeatureFlags';
20
21 export type SharedStateClient = {
22 H: null | Dispatcher, // ReactCurrentDispatcher for Hooks
23 A: null | AsyncDispatcher, // ReactCurrentCache for Cache
24 T: null | Transition, // ReactCurrentBatchConfig for Transitions
25 S: null | ((Transition, mixed) => void), // onStartTransitionFinish
26 + G: null | ((Transition, GestureProvider, ?GestureOptions) => () => void), // onStartGestureTransitionFinish
27 V: null | TransitionTypes, // Pending Transition Types for the Next Transition
28
29 // DEV-only
@@ -50,8 +57,13 @@ const ReactSharedInternals: SharedStateClient = ({
57 A: null,
58 T: null,
59 S: null,
53 - V: null,
60 }: any);
61 +if (enableSwipeTransition) {
62 + ReactSharedInternals.G = null;
63 +}
64 +if (enableViewTransition) {
65 + ReactSharedInternals.V = null;
66 +}
67
68 if (__DEV__) {
69 ReactSharedInternals.actQueue = null;
packages/react/src/ReactStartTransition.js
+79 -3
@@ -7,16 +7,24 @@
7 * @flow
8 */
9
10 -import type {StartTransitionOptions} from 'shared/ReactTypes';
10 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
11 +import type {
12 + StartTransitionOptions,
13 + GestureProvider,
14 + GestureOptions,
15 +} from 'shared/ReactTypes';
16
17 import ReactSharedInternals from 'shared/ReactSharedInternals';
18
15 -import {enableTransitionTracing} from 'shared/ReactFeatureFlags';
19 +import {
20 + enableTransitionTracing,
21 + enableSwipeTransition,
22 +} from 'shared/ReactFeatureFlags';
23
24 import reportGlobalError from 'shared/reportGlobalError';
25
26 export type Transition = {
27 + gesture: null | GestureProvider, // enableSwipeTransition
28 name: null | string, // enableTransitionTracing only
29 startTime: number, // enableTransitionTracing only
30 _updatedFibers: Set<Fiber>, // DEV-only
@@ -26,9 +34,12 @@ export type Transition = {
34 export function startTransition(
35 scope: () => void,
36 options?: StartTransitionOptions,
29 -) {
37 +): void {
38 const prevTransition = ReactSharedInternals.T;
39 const currentTransition: Transition = ({}: any);
40 + if (enableSwipeTransition) {
41 + currentTransition.gesture = null;
42 + }
43 if (enableTransitionTracing) {
44 currentTransition.name =
45 options !== undefined && options.name !== undefined ? options.name : null;
@@ -60,6 +71,71 @@ export function startTransition(
71 }
72 }
73
74 +export function startGestureTransition(
75 + provider: GestureProvider,
76 + scope: () => void,
77 + options?: GestureOptions & StartTransitionOptions,
78 +): () => void {
79 + if (!enableSwipeTransition) {
80 + // eslint-disable-next-line react-internal/prod-error-codes
81 + throw new Error(
82 + 'startGestureTransition should not be exported when the enableSwipeTransition flag is off.',
83 + );
84 + }
85 + if (provider == null) {
86 + // We enforce this at runtime even though the type also enforces it since we
87 + // use null as a signal internally so it would lead it to be treated as a
88 + // regular transition otherwise.
89 + throw new Error(
90 + 'A Timeline is required as the first argument to startGestureTransition.',
91 + );
92 + }
93 + const prevTransition = ReactSharedInternals.T;
94 + const currentTransition: Transition = ({}: any);
95 + if (enableSwipeTransition) {
96 + currentTransition.gesture = provider;
97 + }
98 + if (enableTransitionTracing) {
99 + currentTransition.name =
100 + options !== undefined && options.name !== undefined ? options.name : null;
101 + currentTransition.startTime = -1; // TODO: This should read the timestamp.
102 + }
103 + if (__DEV__) {
104 + currentTransition._updatedFibers = new Set();
105 + }
106 + ReactSharedInternals.T = currentTransition;
107 +
108 + try {
109 + const returnValue = scope();
110 + if (__DEV__) {
111 + if (
112 + typeof returnValue === 'object' &&
113 + returnValue !== null &&
114 + typeof returnValue.then === 'function'
115 + ) {
116 + console.error(
117 + 'Cannot use an async function in startGestureTransition. It must be able to start immediately.',
118 + );
119 + }
120 + }
121 + const onStartGestureTransitionFinish = ReactSharedInternals.G;
122 + if (onStartGestureTransitionFinish !== null) {
123 + return onStartGestureTransitionFinish(
124 + currentTransition,
125 + provider,
126 + options,
127 + );
128 + }
129 + } catch (error) {
130 + reportGlobalError(error);
131 + } finally {
132 + ReactSharedInternals.T = prevTransition;
133 + }
134 + return function cancelGesture() {
135 + // Noop
136 + };
137 +}
138 +
139 function warnAboutTransitionSubscriptions(
140 prevTransition: Transition | null,
141 currentTransition: Transition,
packages/react/src/ReactTransitionType.js
+9 -5
@@ -8,14 +8,18 @@
8 */
9
10 import ReactSharedInternals from 'shared/ReactSharedInternals';
11 +import {enableViewTransition} from 'shared/ReactFeatureFlags';
12
13 export type TransitionTypes = Array<string>;
14
15 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);
16 + if (enableViewTransition) {
17 + const pendingTransitionTypes: null | TransitionTypes =
18 + ReactSharedInternals.V;
19 + if (pendingTransitionTypes === null) {
20 + ReactSharedInternals.V = [type];
21 + } else if (pendingTransitionTypes.indexOf(type) === -1) {
22 + pendingTransitionTypes.push(type);
23 + }
24 }
25 }
scripts/error-codes/codes.json
+4 -1
@@ -537,5 +537,8 @@
537 "549": "Cannot start a gesture with a disconnected AnimationTimeline.",
538 "550": "useSwipeTransition is not yet supported in react-art.",
539 "551": "useSwipeTransition is not yet supported in React Native.",
540 - "552": "Cannot use a useSwipeTransition() in a detached root."
540 + "552": "Cannot use a useSwipeTransition() in a detached root.",
541 + "553": "A Timeline is required as the first argument to startGestureTransition.",
542 + "554": "Cannot setState on regular state inside a startGestureTransition. Gestures can only update the useOptimistic() hook. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead.",
543 + "555": "Cannot requestFormReset() inside a startGestureTransition. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead."
544 }