@samitouri / QOS-React / commits / 88479c6fc3

Rerender useSwipeTransition when direction changes (#32379)

We can only render one direction at a time with View Transitions. When the direction changes we need to do another render in the new direction (returning previous or next). To determine direction we store the position we started at and anything moving to a lower value (left/up) is "previous" direction (`false`) and anything else is "next" (`true`) direction. For the very first render we won't know which direction you're going since you're still on the initial position. It's useful to start the render to allow the view transition to take control before anything shifts around so we start from the original position. This is not guaranteed though if the render suspends. For now we start the first render by guessing the direction such as if we know that prev/next are the same as current. With the upcoming auto start mode we can guess more accurately there before we start. We can also add explicit APIs to `startGesture` but ideally it wouldn't matter. Ideally we could just start after the first change in direction from the starting point.

Sebastian Markbåge committed Feb 20, 2025 at 18:13 UTC 88479c6fc31ba2902587694338350ae95733d6b2
19 files changed +200 -32
.eslintrc.js
+1
@@ -614,6 +614,7 @@ module.exports = {
614 KeyframeAnimationOptions: 'readonly',
615 GetAnimationsOptions: 'readonly',
616 Animatable: 'readonly',
617 + ScrollTimeline: 'readonly',
618
619 spyOnDev: 'readonly',
620 spyOnDevAndProd: 'readonly',
fixtures/view-transition/src/components/Page.js
+3 -1
@@ -68,10 +68,12 @@ export default function Page({url, navigate}) {
68 activeGesture.current = null;
69 cancelGesture();
70 }
71 + // Reset scroll
72 + swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
73 }
74
75 useLayoutEffect(() => {
74 - swipeRecognizer.current.scrollLeft = show ? 0 : 10000;
76 + swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
77 }, [show]);
78
79 const exclamation = (
packages/react-art/src/ReactFiberConfigART.js
+9
@@ -508,6 +508,15 @@ export function createViewTransitionInstance(
508 return null;
509 }
510
511 +export type GestureTimeline = null;
512 +
513 +export function subscribeToGestureDirection(
514 + provider: GestureTimeline,
515 + directionCallback: (direction: boolean) => void,
516 +): () => void {
517 + throw new Error('useSwipeTransition is not yet supported in react-art.');
518 +}
519 +
520 export function clearContainer(container) {
521 // TODO Implement this
522 }
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+54
@@ -1478,6 +1478,60 @@ export function createViewTransitionInstance(
1478 };
1479 }
1480
1481 +export type GestureTimeline = AnimationTimeline; // TODO: More provider types.
1482 +
1483 +export function subscribeToGestureDirection(
1484 + provider: GestureTimeline,
1485 + directionCallback: (direction: boolean) => void,
1486 +): () => void {
1487 + const time = provider.currentTime;
1488 + if (time === null) {
1489 + throw new Error(
1490 + 'Cannot start a gesture with a disconnected AnimationTimeline.',
1491 + );
1492 + }
1493 + const startTime = typeof time === 'number' ? time : time.value;
1494 + if (
1495 + typeof ScrollTimeline === 'function' &&
1496 + provider instanceof ScrollTimeline
1497 + ) {
1498 + // For ScrollTimeline we optimize to only update the current time on scroll events.
1499 + const element = provider.source;
1500 + const scrollCallback = () => {
1501 + const newTime = provider.currentTime;
1502 + if (newTime !== null) {
1503 + directionCallback(
1504 + typeof newTime === 'number'
1505 + ? newTime > startTime
1506 + : newTime.value > startTime,
1507 + );
1508 + }
1509 + };
1510 + element.addEventListener('scroll', scrollCallback, false);
1511 + return () => {
1512 + element.removeEventListener('scroll', scrollCallback, false);
1513 + };
1514 + } else {
1515 + // For other AnimationTimelines, such as DocumentTimeline, we just update every rAF.
1516 + // TODO: Optimize ViewTimeline using an IntersectionObserver if it becomes common.
1517 + const rafCallback = () => {
1518 + const newTime = provider.currentTime;
1519 + if (newTime !== null) {
1520 + directionCallback(
1521 + typeof newTime === 'number'
1522 + ? newTime > startTime
1523 + : newTime.value > startTime,
1524 + );
1525 + }
1526 + callbackID = requestAnimationFrame(rafCallback);
1527 + };
1528 + let callbackID = requestAnimationFrame(rafCallback);
1529 + return () => {
1530 + cancelAnimationFrame(callbackID);
1531 + };
1532 + }
1533 +}
1534 +
1535 export function clearContainer(container: Container): void {
1536 const nodeType = container.nodeType;
1537 if (nodeType === DOCUMENT_NODE) {
packages/react-native-renderer/src/ReactFiberConfigNative.js
+9
@@ -605,6 +605,15 @@ export function createViewTransitionInstance(
605 return null;
606 }
607
608 +export type GestureTimeline = null;
609 +
610 +export function subscribeToGestureDirection(
611 + provider: GestureTimeline,
612 + directionCallback: (direction: boolean) => void,
613 +): () => void {
614 + throw new Error('useSwipeTransition is not yet supported in React Native.');
615 +}
616 +
617 export function clearContainer(container: Container): void {
618 // TODO Implement this for React Native
619 // UIManager does not expose a "remove all" type method.
packages/react-noop-renderer/src/createReactNoop.js
+9
@@ -95,6 +95,8 @@ export type FormInstance = Instance;
95
96 export type ViewTransitionInstance = null | {name: string, ...};
97
98 +export type GestureTimeline = null;
99 +
100 const NO_CONTEXT = {};
101 const UPPERCASE_CONTEXT = {};
102 if (__DEV__) {
@@ -794,6 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
796 return null;
797 },
798
799 + subscribeToGestureDirection(
800 + provider: GestureTimeline,
801 + directionCallback: (direction: boolean) => void,
802 + ): () => void {
803 + return () => {};
804 + },
805 +
806 resetTextContent(instance: Instance): void {
807 instance.text = null;
808 },
packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
+2
@@ -48,3 +48,5 @@ export const hasInstanceAffectedParent = shim;
48 export const startViewTransition = shim;
49 export type ViewTransitionInstance = null | {name: string, ...};
50 export const createViewTransitionInstance = shim;
51 +export type GestureTimeline = any;
52 +export const subscribeToGestureDirection = shim;
packages/react-reconciler/src/ReactFiberGestureScheduler.js
+34 -3
@@ -8,22 +8,26 @@
8 */
9
10 import type {FiberRoot} from './ReactInternalTypes';
11 -import type {GestureProvider} from 'shared/ReactTypes';
11 +import type {GestureTimeline} from './ReactFiberConfig';
12
13 import {GestureLane} from './ReactFiberLane';
14 import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
15 +import {subscribeToGestureDirection} from './ReactFiberConfig';
16
17 // This type keeps track of any scheduled or active gestures.
18 export type ScheduledGesture = {
18 - provider: GestureProvider,
19 + provider: GestureTimeline,
20 count: number, // The number of times this same provider has been started.
21 + direction: boolean, // false = previous, true = next
22 + cancel: () => void, // Cancel the subscription to direction change.
23 prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
24 next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
25 };
26
27 export function scheduleGesture(
28 root: FiberRoot,
26 - provider: GestureProvider,
29 + provider: GestureTimeline,
30 + initialDirection: boolean,
31 ): ScheduledGesture {
32 let prev = root.gestures;
33 while (prev !== null) {
@@ -39,9 +43,32 @@ export function scheduleGesture(
43 prev = next;
44 }
45 // Add new instance to the end of the queue.
46 + const cancel = subscribeToGestureDirection(provider, (direction: boolean) => {
47 + if (gesture.direction !== direction) {
48 + gesture.direction = direction;
49 + if (gesture.prev === null && root.gestures !== gesture) {
50 + // This gesture is not in the schedule, meaning it was already rendered.
51 + // We need to rerender in the new direction. Insert it into the first slot
52 + // in case other gestures are queued after the on-going one.
53 + const existing = root.gestures;
54 + gesture.next = existing;
55 + if (existing !== null) {
56 + existing.prev = gesture;
57 + }
58 + root.gestures = gesture;
59 + // Schedule the lane on the root. The Fibers will already be marked as
60 + // long as the gesture is active on that Hook.
61 + root.pendingLanes |= GestureLane;
62 + ensureRootIsScheduled(root);
63 + }
64 + // TODO: If we're currently rendering this gesture, we need to restart it.
65 + }
66 + });
67 const gesture: ScheduledGesture = {
68 provider: provider,
69 count: 1,
70 + direction: initialDirection,
71 + cancel: cancel,
72 prev: prev,
73 next: null,
74 };
@@ -60,8 +87,12 @@ export function cancelScheduledGesture(
87 ): void {
88 gesture.count--;
89 if (gesture.count === 0) {
90 + const cancelDirectionSubscription = gesture.cancel;
91 + cancelDirectionSubscription();
92 // Delete the scheduled gesture from the queue.
93 deleteScheduledGesture(root, gesture);
94 + // TODO: If we're currently rendering this gesture, we need to restart the render
95 + // on a different gesture or cancel the render..
96 }
97 }
98
packages/react-reconciler/src/ReactFiberHooks.js
+41 -26
@@ -27,7 +27,7 @@ import type {
27 import type {Lanes, Lane} from './ReactFiberLane';
28 import type {HookFlags} from './ReactHookEffectTags';
29 import type {Flags} from './ReactFiberFlags';
30 -import type {TransitionStatus} from './ReactFiberConfig';
30 +import type {TransitionStatus, GestureTimeline} from './ReactFiberConfig';
31 import type {ScheduledGesture} from './ReactFiberGestureScheduler';
32
33 import {
@@ -3981,6 +3981,7 @@ type SwipeTransitionGestureUpdate = {
3981 type SwipeTransitionUpdateQueue = {
3982 pending: null | SwipeTransitionGestureUpdate,
3983 dispatch: StartGesture,
3984 + initialDirection: boolean,
3985 };
3986
3987 function startGesture(
@@ -3996,9 +3997,14 @@ function startGesture(
3997 // Noop.
3998 };
3999 }
3999 - const scheduledGesture = scheduleGesture(root, gestureProvider);
4000 + const gestureTimeline: GestureTimeline = gestureProvider;
4001 + const scheduledGesture = scheduleGesture(
4002 + root,
4003 + gestureTimeline,
4004 + queue.initialDirection,
4005 + );
4006 // Add this particular instance to the queue.
4001 - // We add multiple of the same provider even if they get batched so
4007 + // We add multiple of the same timeline even if they get batched so
4008 // that if we cancel one but not the other we can keep track of this.
4009 // Order doesn't matter but we insert in the beginning to avoid two fields.
4010 const update: SwipeTransitionGestureUpdate = {
@@ -4041,6 +4047,7 @@ function mountSwipeTransition<T>(
4047 const queue: SwipeTransitionUpdateQueue = {
4048 pending: null,
4049 dispatch: (null: any),
4050 + initialDirection: previous === current,
4051 };
4052 const startGestureOnHook: StartGesture = (queue.dispatch = (startGesture.bind(
4053 null,
@@ -4062,31 +4069,34 @@ function updateSwipeTransition<T>(
4069 const startGestureOnHook: StartGesture = queue.dispatch;
4070 const rootRenderLanes = getWorkInProgressRootRenderLanes();
4071 let value = current;
4065 - if (isGestureRender(rootRenderLanes)) {
4066 - // We're inside a gesture render. We'll traverse the queue to see if
4067 - // this specific Hook is part of this gesture and, if so, which
4068 - // direction to render.
4069 - const root: FiberRoot | null = getWorkInProgressRoot();
4070 - if (root === null) {
4071 - throw new Error(
4072 - 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
4073 - );
4074 - }
4075 - // We assume that the currently rendering gesture is the one first in the queue.
4076 - const rootRenderGesture = root.gestures;
4077 - let update = queue.pending;
4078 - while (update !== null) {
4079 - if (rootRenderGesture === update.gesture) {
4080 - // We had a match, meaning we're currently rendering a direction of this
4081 - // hook for this gesture.
4082 - // TODO: Determine which direction this gesture is currently rendering.
4083 - value = previous;
4084 - break;
4072 + if (queue.pending !== null) {
4073 + if (isGestureRender(rootRenderLanes)) {
4074 + // We're inside a gesture render. We'll traverse the queue to see if
4075 + // this specific Hook is part of this gesture and, if so, which
4076 + // direction to render.
4077 + const root: FiberRoot | null = getWorkInProgressRoot();
4078 + if (root === null) {
4079 + throw new Error(
4080 + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
4081 + );
4082 }
4086 - update = update.next;
4083 + // We assume that the currently rendering gesture is the one first in the queue.
4084 + const rootRenderGesture = root.gestures;
4085 + if (rootRenderGesture !== null) {
4086 + let update = queue.pending;
4087 + while (update !== null) {
4088 + if (rootRenderGesture === update.gesture) {
4089 + // We had a match, meaning we're currently rendering a direction of this
4090 + // hook for this gesture.
4091 + value = rootRenderGesture.direction ? next : previous;
4092 + break;
4093 + }
4094 + update = update.next;
4095 + }
4096 + }
4097 + // This lane cannot be cleared as long as we have active gestures.
4098 + markWorkInProgressReceivedUpdate();
4099 }
4088 - }
4089 - if (queue.pending !== null) {
4100 // As long as there are any active gestures we need to leave the lane on
4101 // in case we need to render it later. Since a gesture render doesn't commit
4102 // the only time it really fully gets cleared is if something else rerenders
@@ -4096,6 +4106,11 @@ function updateSwipeTransition<T>(
4106 GestureLane,
4107 );
4108 }
4109 + // By default, we don't know which direction we should start until a movement
4110 + // has happened. However, if one direction has the same value as current we
4111 + // know that it's probably not that direction since it won't do anything anyway.
4112 + // TODO: Add an explicit option to provide this.
4113 + queue.initialDirection = previous === current;
4114 return [value, startGestureOnHook];
4115 }
4116
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+3
@@ -43,6 +43,7 @@ export opaque type FormInstance = mixed;
43 export type ViewTransitionInstance = null | {name: string, ...};
44 export opaque type InstanceMeasurement = mixed;
45 export type EventResponder = any;
46 +export type GestureTimeline = any;
47
48 export const rendererVersion = $$$config.rendererVersion;
49 export const rendererPackageName = $$$config.rendererPackageName;
@@ -144,6 +145,8 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
145 export const hasInstanceChanged = $$$config.hasInstanceChanged;
146 export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
147 export const startViewTransition = $$$config.startViewTransition;
148 +export const subscribeToGestureDirection =
149 + $$$config.subscribeToGestureDirection;
150 export const createViewTransitionInstance =
151 $$$config.createViewTransitionInstance;
152 export const clearContainer = $$$config.clearContainer;
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+9
@@ -391,6 +391,15 @@ export function getInstanceFromNode(mockNode: Object): Object | null {
391 return null;
392 }
393
394 +export type GestureTimeline = null;
395 +
396 +export function subscribeToGestureDirection(
397 + provider: GestureTimeline,
398 + directionCallback: (direction: boolean) => void,
399 +): () => void {
400 + return () => {};
401 +}
402 +
403 export function beforeActiveInstanceBlur(internalInstanceHandle: Object) {
404 // noop
405 }
packages/shared/ReactTypes.js
+1 -1
@@ -170,7 +170,7 @@ export type ReactFormState<S, ReferenceId> = [
170
171 // Intrinsic GestureProvider. This type varies by Environment whether a particular
172 // renderer supports it.
173 -export type GestureProvider = AnimationTimeline; // TODO: More provider types.
173 +export type GestureProvider = any;
174
175 export type StartGesture = (gestureProvider: GestureProvider) => () => void;
176
scripts/error-codes/codes.json
+4 -1
@@ -533,5 +533,8 @@
533 "545": "The %s tag may only be rendered once.",
534 "546": "useEffect CRUD overload is not enabled in this build of React.",
535 "547": "startGesture cannot be called during server rendering.",
536 - "548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React."
536 + "548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React.",
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 }
scripts/flow/environment.js
+12
@@ -33,6 +33,18 @@ declare interface ConsoleTask {
33 run<T>(f: () => T): T;
34 }
35
36 +type ScrollTimelineOptions = {
37 + source: Element,
38 + axis?: 'block' | 'inline' | 'x' | 'y',
39 + ...
40 +};
41 +
42 +declare class ScrollTimeline extends AnimationTimeline {
43 + constructor(options?: ScrollTimelineOptions): void;
44 + axis: 'block' | 'inline' | 'x' | 'y';
45 + source: Element;
46 +}
47 +
48 // Flow hides the props of React$Element, this overrides it to unhide
49 // them for React internals.
50 // prettier-ignore
scripts/rollup/validate/eslintrc.cjs.js
+2
@@ -34,6 +34,8 @@ module.exports = {
34
35 FinalizationRegistry: 'readonly',
36
37 + ScrollTimeline: 'readonly',
38 +
39 // Vendor specific
40 MSApp: 'readonly',
41 __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
scripts/rollup/validate/eslintrc.cjs2015.js
+1
@@ -32,6 +32,7 @@ module.exports = {
32 Reflect: 'readonly',
33 globalThis: 'readonly',
34 FinalizationRegistry: 'readonly',
35 + ScrollTimeline: 'readonly',
36 // Vendor specific
37 MSApp: 'readonly',
38 __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
scripts/rollup/validate/eslintrc.esm.js
+2
@@ -34,6 +34,8 @@ module.exports = {
34
35 FinalizationRegistry: 'readonly',
36
37 + ScrollTimeline: 'readonly',
38 +
39 // Vendor specific
40 MSApp: 'readonly',
41 __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
scripts/rollup/validate/eslintrc.fb.js
+2
@@ -34,6 +34,8 @@ module.exports = {
34
35 FinalizationRegistry: 'readonly',
36
37 + ScrollTimeline: 'readonly',
38 +
39 // Vendor specific
40 MSApp: 'readonly',
41 __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',
scripts/rollup/validate/eslintrc.rn.js
+2
@@ -34,6 +34,8 @@ module.exports = {
34
35 FinalizationRegistry: 'readonly',
36
37 + ScrollTimeline: 'readonly',
38 +
39 // Vendor specific
40 MSApp: 'readonly',
41 __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',