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.
This commit is contained in:
Sebastian Markbåge
2025-03-31 20:05:50 -04:00
committed by GitHub
parent d3b8ff6e58
commit b286430c8a
14 changed files with 382 additions and 33 deletions

View File

@@ -1,13 +1,14 @@
import React, {
unstable_ViewTransition as ViewTransition,
unstable_Activity as Activity,
unstable_useSwipeTransition as useSwipeTransition,
useLayoutEffect,
useEffect,
useState,
useId,
useOptimistic,
startTransition,
} from 'react';
import {createPortal} from 'react-dom';
import SwipeRecognizer from './SwipeRecognizer';
@@ -49,7 +50,12 @@ function Id() {
}
export default function Page({url, navigate}) {
const [renderedUrl, startGesture] = useSwipeTransition('/?a', url, '/?b');
const [renderedUrl, optimisticNavigate] = useOptimistic(
url,
(state, direction) => {
return direction === 'left' ? '/?a' : '/?b';
}
);
const show = renderedUrl === '/?b';
function onTransition(viewTransition, types) {
const keyframes = [
@@ -107,7 +113,7 @@ export default function Page({url, navigate}) {
<div className="swipe-recognizer">
<SwipeRecognizer
action={swipeAction}
gesture={startGesture}
gesture={optimisticNavigate}
direction={show ? 'left' : 'right'}>
<button
className="button"

View File

@@ -1,4 +1,9 @@
import React, {useRef, useEffect, startTransition} from 'react';
import React, {
useRef,
useEffect,
startTransition,
unstable_startGestureTransition as startGestureTransition,
} from 'react';
// Example of a Component that can recognize swipe gestures using a ScrollTimeline
// without scrolling its own content. Allowing it to be used as an inert gesture
@@ -28,9 +33,15 @@ export default function SwipeRecognizer({
source: scrollRef.current,
axis: axis,
});
activeGesture.current = gesture(scrollTimeline, {
range: [0, direction === 'left' || direction === 'up' ? 100 : 0, 100],
});
activeGesture.current = startGestureTransition(
scrollTimeline,
() => {
gesture(direction);
},
{
range: [0, direction === 'left' || direction === 'up' ? 100 : 0, 100],
}
);
}
function onScrollEnd() {
let changed;

View File

@@ -8,6 +8,7 @@
*/
import type {FiberRoot} from './ReactInternalTypes';
import type {GestureOptions} from 'shared/ReactTypes';
import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig';
import {
@@ -18,6 +19,7 @@ import {
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
import {
subscribeToGestureDirection,
getCurrentGestureOffset,
stopViewTransition,
} from './ReactFiberConfig';
@@ -29,13 +31,14 @@ export type ScheduledGesture = {
rangePrevious: number, // The end along the timeline where the previous state is reached.
rangeCurrent: number, // The starting offset along the timeline.
rangeNext: number, // The end along the timeline where the next state is reached.
cancel: () => void, // Cancel the subscription to direction change.
cancel: () => void, // Cancel the subscription to direction change. // TODO: Delete this.
running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
};
export function scheduleGesture(
// TODO: Delete this when deleting useSwipeTransition.
export function scheduleGestureLegacy(
root: FiberRoot,
provider: GestureTimeline,
initialDirection: boolean,
@@ -107,6 +110,100 @@ export function scheduleGesture(
return gesture;
}
export function scheduleGesture(
root: FiberRoot,
provider: GestureTimeline,
): ScheduledGesture {
let prev = root.pendingGestures;
while (prev !== null) {
if (prev.provider === provider) {
// Existing instance found.
return prev;
}
const next = prev.next;
if (next === null) {
break;
}
prev = next;
}
const gesture: ScheduledGesture = {
provider: provider,
count: 0,
direction: false,
rangePrevious: -1,
rangeCurrent: -1,
rangeNext: -1,
cancel: () => {}, // TODO: Delete this with useSwipeTransition.
running: null,
prev: prev,
next: null,
};
if (prev === null) {
root.pendingGestures = gesture;
} else {
prev.next = gesture;
}
ensureRootIsScheduled(root);
return gesture;
}
export function startScheduledGesture(
root: FiberRoot,
gestureTimeline: GestureTimeline,
gestureOptions: ?GestureOptions,
): null | ScheduledGesture {
const currentOffset = getCurrentGestureOffset(gestureTimeline);
const range = gestureOptions && gestureOptions.range;
const rangePrevious: number = range ? range[0] : 0; // If no range is provider we assume it's the starting point of the range.
const rangeCurrent: number = range ? range[1] : currentOffset;
const rangeNext: number = range ? range[2] : 100; // If no range is provider we assume it's the starting point of the range.
if (__DEV__) {
if (
(rangePrevious > rangeCurrent && rangeNext > rangeCurrent) ||
(rangePrevious < rangeCurrent && rangeNext < rangeCurrent)
) {
console.error(
'The range of a gesture needs "previous" and "next" to be on either side of ' +
'the "current" offset. Both cannot be above current and both cannot be below current.',
);
}
}
const isFlippedDirection = rangePrevious > rangeNext;
const initialDirection =
// If a range is specified we can imply initial direction if it's not the current
// value such as if the gesture starts after it has already moved.
currentOffset < rangeCurrent
? isFlippedDirection
: currentOffset > rangeCurrent
? !isFlippedDirection
: // Otherwise, look for an explicit option.
gestureOptions
? gestureOptions.direction === 'next'
: false;
let prev = root.pendingGestures;
while (prev !== null) {
if (prev.provider === gestureTimeline) {
// Existing instance found.
prev.count++;
// Update the options.
prev.direction = initialDirection;
prev.rangePrevious = rangePrevious;
prev.rangeCurrent = rangeCurrent;
prev.rangeNext = rangeNext;
return prev;
}
const next = prev.next;
if (next === null) {
break;
}
prev = next;
}
// No scheduled gestures. It must mean nothing for this renderer updated but
// some other renderer might have updated.
return null;
}
export function cancelScheduledGesture(
root: FiberRoot,
gesture: ScheduledGesture,

View File

@@ -163,6 +163,7 @@ import {callComponentInDEV} from './ReactFiberCallUserSpace';
import {
scheduleGesture,
scheduleGestureLegacy,
cancelScheduledGesture,
} from './ReactFiberGestureScheduler';
@@ -173,6 +174,7 @@ export type Update<S, A> = {
hasEagerState: boolean,
eagerState: S | null,
next: Update<S, A>,
gesture: null | ScheduledGesture, // enableSwipeTransition
};
export type UpdateQueue<S, A> = {
@@ -1377,10 +1379,35 @@ function updateReducerImpl<S, A>(
// Check if this update was made while the tree was hidden. If so, then
// it's not a "base" update and we should disregard the extra base lanes
// that were added to renderLanes when we entered the Offscreen tree.
const shouldSkipUpdate = isHiddenUpdate
let shouldSkipUpdate = isHiddenUpdate
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
: !isSubsetOfLanes(renderLanes, updateLane);
if (enableSwipeTransition && updateLane === GestureLane) {
// This is a gesture optimistic update. It should only be considered as part of the
// rendered state while rendering the gesture lane and if the rendering the associated
// ScheduledGesture.
const scheduledGesture = update.gesture;
if (scheduledGesture !== null) {
if (scheduledGesture.count === 0) {
// This gesture has already been cancelled. We can clean up this update.
update = update.next;
continue;
} else if (!isGestureRender(renderLanes)) {
shouldSkipUpdate = true;
} else {
const root: FiberRoot | null = getWorkInProgressRoot();
if (root === null) {
throw new Error(
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
);
}
// We assume that the currently rendering gesture is the one first in the queue.
shouldSkipUpdate = root.pendingGestures !== scheduledGesture;
}
}
}
if (shouldSkipUpdate) {
// Priority is insufficient. Skip this update. If this is the first
// skipped update, the previous update/state is the new base
@@ -1388,6 +1415,7 @@ function updateReducerImpl<S, A>(
const clone: Update<S, A> = {
lane: updateLane,
revertLane: update.revertLane,
gesture: update.gesture,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
@@ -1423,6 +1451,7 @@ function updateReducerImpl<S, A>(
// this will never be skipped by the check above.
lane: NoLane,
revertLane: NoLane,
gesture: null,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
@@ -1466,6 +1495,7 @@ function updateReducerImpl<S, A>(
// Reuse the same revertLane so we know when the transition
// has finished.
revertLane: update.revertLane,
gesture: null, // If it commits, it's no longer a gesture update.
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
@@ -2138,6 +2168,9 @@ function runActionStateAction<S, P>(
// This is a fork of startTransition
const prevTransition = ReactSharedInternals.T;
const currentTransition: Transition = ({}: any);
if (enableSwipeTransition) {
currentTransition.gesture = null;
}
if (enableTransitionTracing) {
currentTransition.name = null;
currentTransition.startTime = -1;
@@ -3017,6 +3050,9 @@ function startTransition<S>(
const prevTransition = ReactSharedInternals.T;
const currentTransition: Transition = ({}: any);
if (enableSwipeTransition) {
currentTransition.gesture = null;
}
if (enableTransitionTracing) {
currentTransition.name =
options !== undefined && options.name !== undefined ? options.name : null;
@@ -3226,8 +3262,8 @@ function ensureFormComponentIsStateful(formFiber: Fiber) {
export function requestFormReset(formFiber: Fiber) {
const transition = requestCurrentTransition();
if (__DEV__) {
if (transition === null) {
if (transition === null) {
if (__DEV__) {
// An optimistic update occurred, but startTransition is not on the stack.
// The form reset will be scheduled at default (sync) priority, which
// is probably not what the user intended. Most likely because the
@@ -3242,6 +3278,13 @@ export function requestFormReset(formFiber: Fiber) {
'fix, move to an action, or wrap with startTransition.',
);
}
} else if (enableSwipeTransition && transition.gesture) {
throw new Error(
'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.',
);
}
const stateHook = ensureFormComponentIsStateful(formFiber);
@@ -3441,6 +3484,7 @@ function dispatchReducerAction<S, A>(
const update: Update<S, A> = {
lane,
revertLane: NoLane,
gesture: null,
action,
hasEagerState: false,
eagerState: null,
@@ -3500,6 +3544,7 @@ function dispatchSetStateInternal<S, A>(
const update: Update<S, A> = {
lane,
revertLane: NoLane,
gesture: null,
action,
hasEagerState: false,
eagerState: null,
@@ -3607,12 +3652,18 @@ function dispatchOptimisticSetState<S, A>(
}
}
// For regular Transitions an optimistic update commits synchronously.
// For gesture Transitions an optimistic update commits on the GestureLane.
const lane =
enableSwipeTransition && transition !== null && transition.gesture
? GestureLane
: SyncLane;
const update: Update<S, A> = {
// An optimistic update commits synchronously.
lane: SyncLane,
lane: lane,
// After committing, the optimistic update is "reverted" using the same
// lane as the transition it's associated with.
revertLane: requestTransitionLane(transition),
gesture: null,
action,
hasEagerState: false,
eagerState: null,
@@ -3635,20 +3686,28 @@ function dispatchOptimisticSetState<S, A>(
}
}
} else {
const root = enqueueConcurrentHookUpdate(fiber, queue, update, SyncLane);
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
// NOTE: The optimistic update implementation assumes that the transition
// will never be attempted before the optimistic update. This currently
// holds because the optimistic update is always synchronous. If we ever
// change that, we'll need to account for this.
startUpdateTimerByLane(SyncLane);
scheduleUpdateOnFiber(root, fiber, SyncLane);
startUpdateTimerByLane(lane);
scheduleUpdateOnFiber(root, fiber, lane);
// Optimistic updates are always synchronous, so we don't need to call
// entangleTransitionUpdate here.
if (enableSwipeTransition && transition !== null) {
const provider = transition.gesture;
if (provider !== null) {
// If this was a gesture, ensure we have a scheduled gesture and that
// we associate this update with this specific gesture instance.
update.gesture = scheduleGesture(root, provider);
}
}
}
}
markUpdateInDevTools(fiber, SyncLane, action);
markUpdateInDevTools(fiber, lane, action);
}
function isRenderPhaseUpdate(fiber: Fiber): boolean {
@@ -3769,7 +3828,7 @@ function startGesture(
? false
: // If no option is specified, imply from the values specified.
queue.initialDirection;
const scheduledGesture = scheduleGesture(
const scheduledGesture = scheduleGestureLegacy(
root,
gestureTimeline,
initialDirection,

View File

@@ -83,7 +83,7 @@ import {
// A linked list of all the roots with pending work. In an idiomatic app,
// there's only a single root, but we do support multi root apps, hence this
// extra complexity. But this module is optimized for the single root case.
let firstScheduledRoot: FiberRoot | null = null;
export let firstScheduledRoot: FiberRoot | null = null;
let lastScheduledRoot: FiberRoot | null = null;
// Used to prevent redundant mircotasks from being scheduled.

View File

@@ -7,13 +7,21 @@
* @flow
*/
import type {Fiber, FiberRoot} from './ReactInternalTypes';
import type {Thenable} from 'shared/ReactTypes';
import type {
Thenable,
GestureProvider,
GestureOptions,
} from 'shared/ReactTypes';
import type {Lanes} from './ReactFiberLane';
import type {StackCursor} from './ReactFiberStack';
import type {Cache, SpawnedCachePool} from './ReactFiberCacheComponent';
import type {Transition} from 'react/src/ReactStartTransition';
import type {ScheduledGesture} from './ReactFiberGestureScheduler';
import {enableTransitionTracing} from 'shared/ReactFeatureFlags';
import {
enableTransitionTracing,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import {isPrimaryRenderer} from './ReactFiberConfig';
import {createCursor, push, pop} from './ReactFiberStack';
import {
@@ -29,6 +37,11 @@ import {
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {entangleAsyncAction} from './ReactFiberAsyncAction';
import {startAsyncTransitionTimer} from './ReactProfilerTimer';
import {firstScheduledRoot} from './ReactFiberRootScheduler';
import {
startScheduledGesture,
cancelScheduledGesture,
} from './ReactFiberGestureScheduler';
export const NoTransition = null;
@@ -78,6 +91,61 @@ ReactSharedInternals.S = function onStartTransitionFinishForReconciler(
}
};
function chainGestureCancellation(
root: FiberRoot,
scheduledGesture: ScheduledGesture,
prevCancel: null | (() => void),
): () => void {
return function cancelGesture(): void {
if (scheduledGesture !== null) {
cancelScheduledGesture(root, scheduledGesture);
}
if (prevCancel !== null) {
prevCancel();
}
};
}
if (enableSwipeTransition) {
const prevOnStartGestureTransitionFinish = ReactSharedInternals.G;
ReactSharedInternals.G = function onStartGestureTransitionFinishForReconciler(
transition: Transition,
provider: GestureProvider,
options: ?GestureOptions,
): () => void {
let cancel = null;
if (prevOnStartGestureTransitionFinish !== null) {
cancel = prevOnStartGestureTransitionFinish(
transition,
provider,
options,
);
}
// For every root that has work scheduled, check if there's a ScheduledGesture
// matching this provider and if so, increase its ref count so its retained by
// this cancellation callback. We could add the roots to a temporary array as
// we schedule them inside the callback to keep track of them. There's a slight
// nuance here which is that if there's more than one root scheduled with the
// same provider, but it doesn't update in this callback, then we still update
// its options and retain it until this cancellation releases. The idea being
// that it's conceptually started globally.
let root = firstScheduledRoot;
while (root !== null) {
const scheduledGesture = startScheduledGesture(root, provider, options);
if (scheduledGesture !== null) {
cancel = chainGestureCancellation(root, scheduledGesture, cancel);
}
root = root.next;
}
if (cancel !== null) {
return cancel;
}
return function cancelGesture(): void {
// Nothing was scheduled but it could've been scheduled by another renderer.
};
};
}
export function requestCurrentTransition(): Transition | null {
return ReactSharedInternals.T;
}

View File

@@ -753,6 +753,16 @@ export function requestUpdateLane(fiber: Fiber): Lane {
const transition = requestCurrentTransition();
if (transition !== null) {
if (enableSwipeTransition) {
if (transition.gesture) {
throw new Error(
'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.',
);
}
}
if (__DEV__) {
if (!transition._updatedFibers) {
transition._updatedFibers = new Set();

View File

@@ -33,6 +33,7 @@ export {
unstable_getCacheForType,
unstable_SuspenseList,
unstable_ViewTransition,
unstable_startGestureTransition,
unstable_useSwipeTransition,
unstable_addTransitionType,
unstable_useCacheRefresh,

View File

@@ -33,6 +33,7 @@ export {
unstable_getCacheForType,
unstable_SuspenseList,
unstable_ViewTransition,
unstable_startGestureTransition,
unstable_useSwipeTransition,
unstable_addTransitionType,
unstable_useCacheRefresh,

View File

@@ -60,7 +60,7 @@ import {
useSwipeTransition,
} from './ReactHooks';
import ReactSharedInternals from './ReactSharedInternalsClient';
import {startTransition} from './ReactStartTransition';
import {startTransition, startGestureTransition} from './ReactStartTransition';
import {addTransitionType} from './ReactTransitionType';
import {act} from './ReactAct';
import {captureOwnerStack} from './ReactOwnerStack';
@@ -128,6 +128,7 @@ export {
REACT_VIEW_TRANSITION_TYPE as unstable_ViewTransition,
addTransitionType as unstable_addTransitionType,
// enableSwipeTransition
startGestureTransition as unstable_startGestureTransition,
useSwipeTransition as unstable_useSwipeTransition,
// DEV-only
useId,

View File

@@ -11,12 +11,19 @@ import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes';
import type {Transition} from './ReactStartTransition';
import type {TransitionTypes} from './ReactTransitionType';
import type {GestureProvider, GestureOptions} from 'shared/ReactTypes';
import {
enableViewTransition,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
export type SharedStateClient = {
H: null | Dispatcher, // ReactCurrentDispatcher for Hooks
A: null | AsyncDispatcher, // ReactCurrentCache for Cache
T: null | Transition, // ReactCurrentBatchConfig for Transitions
S: null | ((Transition, mixed) => void), // onStartTransitionFinish
G: null | ((Transition, GestureProvider, ?GestureOptions) => () => void), // onStartGestureTransitionFinish
V: null | TransitionTypes, // Pending Transition Types for the Next Transition
// DEV-only
@@ -50,8 +57,13 @@ const ReactSharedInternals: SharedStateClient = ({
A: null,
T: null,
S: null,
V: null,
}: any);
if (enableSwipeTransition) {
ReactSharedInternals.G = null;
}
if (enableViewTransition) {
ReactSharedInternals.V = null;
}
if (__DEV__) {
ReactSharedInternals.actQueue = null;

View File

@@ -7,16 +7,24 @@
* @flow
*/
import type {StartTransitionOptions} from 'shared/ReactTypes';
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {
StartTransitionOptions,
GestureProvider,
GestureOptions,
} from 'shared/ReactTypes';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {enableTransitionTracing} from 'shared/ReactFeatureFlags';
import {
enableTransitionTracing,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import reportGlobalError from 'shared/reportGlobalError';
export type Transition = {
gesture: null | GestureProvider, // enableSwipeTransition
name: null | string, // enableTransitionTracing only
startTime: number, // enableTransitionTracing only
_updatedFibers: Set<Fiber>, // DEV-only
@@ -26,9 +34,12 @@ export type Transition = {
export function startTransition(
scope: () => void,
options?: StartTransitionOptions,
) {
): void {
const prevTransition = ReactSharedInternals.T;
const currentTransition: Transition = ({}: any);
if (enableSwipeTransition) {
currentTransition.gesture = null;
}
if (enableTransitionTracing) {
currentTransition.name =
options !== undefined && options.name !== undefined ? options.name : null;
@@ -60,6 +71,71 @@ export function startTransition(
}
}
export function startGestureTransition(
provider: GestureProvider,
scope: () => void,
options?: GestureOptions & StartTransitionOptions,
): () => void {
if (!enableSwipeTransition) {
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'startGestureTransition should not be exported when the enableSwipeTransition flag is off.',
);
}
if (provider == null) {
// We enforce this at runtime even though the type also enforces it since we
// use null as a signal internally so it would lead it to be treated as a
// regular transition otherwise.
throw new Error(
'A Timeline is required as the first argument to startGestureTransition.',
);
}
const prevTransition = ReactSharedInternals.T;
const currentTransition: Transition = ({}: any);
if (enableSwipeTransition) {
currentTransition.gesture = provider;
}
if (enableTransitionTracing) {
currentTransition.name =
options !== undefined && options.name !== undefined ? options.name : null;
currentTransition.startTime = -1; // TODO: This should read the timestamp.
}
if (__DEV__) {
currentTransition._updatedFibers = new Set();
}
ReactSharedInternals.T = currentTransition;
try {
const returnValue = scope();
if (__DEV__) {
if (
typeof returnValue === 'object' &&
returnValue !== null &&
typeof returnValue.then === 'function'
) {
console.error(
'Cannot use an async function in startGestureTransition. It must be able to start immediately.',
);
}
}
const onStartGestureTransitionFinish = ReactSharedInternals.G;
if (onStartGestureTransitionFinish !== null) {
return onStartGestureTransitionFinish(
currentTransition,
provider,
options,
);
}
} catch (error) {
reportGlobalError(error);
} finally {
ReactSharedInternals.T = prevTransition;
}
return function cancelGesture() {
// Noop
};
}
function warnAboutTransitionSubscriptions(
prevTransition: Transition | null,
currentTransition: Transition,

View File

@@ -8,14 +8,18 @@
*/
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {enableViewTransition} from 'shared/ReactFeatureFlags';
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);
if (enableViewTransition) {
const pendingTransitionTypes: null | TransitionTypes =
ReactSharedInternals.V;
if (pendingTransitionTypes === null) {
ReactSharedInternals.V = [type];
} else if (pendingTransitionTypes.indexOf(type) === -1) {
pendingTransitionTypes.push(type);
}
}
}

View File

@@ -537,5 +537,8 @@
"549": "Cannot start a gesture with a disconnected AnimationTimeline.",
"550": "useSwipeTransition is not yet supported in react-art.",
"551": "useSwipeTransition is not yet supported in React Native.",
"552": "Cannot use a useSwipeTransition() in a detached root."
"552": "Cannot use a useSwipeTransition() in a detached root.",
"553": "A Timeline is required as the first argument to startGestureTransition.",
"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.",
"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."
}