Skip to content

Commit b286430

Browse files
authored
Add startGestureTransition API (#32785)
Stacked on #32783. This will replace [the `useSwipeTransition` API](#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.
1 parent d3b8ff6 commit b286430

14 files changed

+382
-33
lines changed

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

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import React, {
22
unstable_ViewTransition as ViewTransition,
33
unstable_Activity as Activity,
4-
unstable_useSwipeTransition as useSwipeTransition,
54
useLayoutEffect,
65
useEffect,
76
useState,
87
useId,
8+
useOptimistic,
99
startTransition,
1010
} from 'react';
11+
1112
import {createPortal} from 'react-dom';
1213

1314
import SwipeRecognizer from './SwipeRecognizer';
@@ -49,7 +50,12 @@ function Id() {
4950
}
5051

5152
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+
);
5359
const show = renderedUrl === '/?b';
5460
function onTransition(viewTransition, types) {
5561
const keyframes = [
@@ -107,7 +113,7 @@ export default function Page({url, navigate}) {
107113
<div className="swipe-recognizer">
108114
<SwipeRecognizer
109115
action={swipeAction}
110-
gesture={startGesture}
116+
gesture={optimisticNavigate}
111117
direction={show ? 'left' : 'right'}>
112118
<button
113119
className="button"

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

+15-4
Original file line numberDiff line numberDiff line change
@@ -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';
27

38
// Example of a Component that can recognize swipe gestures using a ScrollTimeline
49
// without scrolling its own content. Allowing it to be used as an inert gesture
@@ -28,9 +33,15 @@ export default function SwipeRecognizer({
2833
source: scrollRef.current,
2934
axis: axis,
3035
});
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+
);
3445
}
3546
function onScrollEnd() {
3647
let changed;

packages/react-reconciler/src/ReactFiberGestureScheduler.js

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

1010
import type {FiberRoot} from './ReactInternalTypes';
11+
import type {GestureOptions} from 'shared/ReactTypes';
1112
import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig';
1213

1314
import {
@@ -18,6 +19,7 @@ import {
1819
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
1920
import {
2021
subscribeToGestureDirection,
22+
getCurrentGestureOffset,
2123
stopViewTransition,
2224
} from './ReactFiberConfig';
2325

@@ -29,13 +31,14 @@ export type ScheduledGesture = {
2931
rangePrevious: number, // The end along the timeline where the previous state is reached.
3032
rangeCurrent: number, // The starting offset along the timeline.
3133
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.
3335
running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
3436
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
3537
next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
3638
};
3739

38-
export function scheduleGesture(
40+
// TODO: Delete this when deleting useSwipeTransition.
41+
export function scheduleGestureLegacy(
3942
root: FiberRoot,
4043
provider: GestureTimeline,
4144
initialDirection: boolean,
@@ -107,6 +110,100 @@ export function scheduleGesture(
107110
return gesture;
108111
}
109112

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+
110207
export function cancelScheduledGesture(
111208
root: FiberRoot,
112209
gesture: ScheduledGesture,

0 commit comments

Comments
 (0)