Skip to content

Commit

Permalink
Rerender useSwipeTransition when direction changes (#32379)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored Feb 20, 2025
1 parent 70f1d76 commit 88479c6
Show file tree
Hide file tree
Showing 19 changed files with 200 additions and 32 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ module.exports = {
KeyframeAnimationOptions: 'readonly',
GetAnimationsOptions: 'readonly',
Animatable: 'readonly',
ScrollTimeline: 'readonly',

spyOnDev: 'readonly',
spyOnDevAndProd: 'readonly',
Expand Down
4 changes: 3 additions & 1 deletion fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ export default function Page({url, navigate}) {
activeGesture.current = null;
cancelGesture();
}
// Reset scroll
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
}

useLayoutEffect(() => {
swipeRecognizer.current.scrollLeft = show ? 0 : 10000;
swipeRecognizer.current.scrollLeft = !show ? 0 : 10000;
}, [show]);

const exclamation = (
Expand Down
9 changes: 9 additions & 0 deletions packages/react-art/src/ReactFiberConfigART.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,15 @@ export function createViewTransitionInstance(
return null;
}

export type GestureTimeline = null;

export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
throw new Error('useSwipeTransition is not yet supported in react-art.');
}

export function clearContainer(container) {
// TODO Implement this
}
Expand Down
54 changes: 54 additions & 0 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -1478,6 +1478,60 @@ export function createViewTransitionInstance(
};
}

export type GestureTimeline = AnimationTimeline; // TODO: More provider types.

export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
const time = provider.currentTime;
if (time === null) {
throw new Error(
'Cannot start a gesture with a disconnected AnimationTimeline.',
);
}
const startTime = typeof time === 'number' ? time : time.value;
if (
typeof ScrollTimeline === 'function' &&
provider instanceof ScrollTimeline
) {
// For ScrollTimeline we optimize to only update the current time on scroll events.
const element = provider.source;
const scrollCallback = () => {
const newTime = provider.currentTime;
if (newTime !== null) {
directionCallback(
typeof newTime === 'number'
? newTime > startTime
: newTime.value > startTime,
);
}
};
element.addEventListener('scroll', scrollCallback, false);
return () => {
element.removeEventListener('scroll', scrollCallback, false);
};
} else {
// For other AnimationTimelines, such as DocumentTimeline, we just update every rAF.
// TODO: Optimize ViewTimeline using an IntersectionObserver if it becomes common.
const rafCallback = () => {
const newTime = provider.currentTime;
if (newTime !== null) {
directionCallback(
typeof newTime === 'number'
? newTime > startTime
: newTime.value > startTime,
);
}
callbackID = requestAnimationFrame(rafCallback);
};
let callbackID = requestAnimationFrame(rafCallback);
return () => {
cancelAnimationFrame(callbackID);
};
}
}

export function clearContainer(container: Container): void {
const nodeType = container.nodeType;
if (nodeType === DOCUMENT_NODE) {
Expand Down
9 changes: 9 additions & 0 deletions packages/react-native-renderer/src/ReactFiberConfigNative.js
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,15 @@ export function createViewTransitionInstance(
return null;
}

export type GestureTimeline = null;

export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
throw new Error('useSwipeTransition is not yet supported in React Native.');
}

export function clearContainer(container: Container): void {
// TODO Implement this for React Native
// UIManager does not expose a "remove all" type method.
Expand Down
9 changes: 9 additions & 0 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export type FormInstance = Instance;

export type ViewTransitionInstance = null | {name: string, ...};

export type GestureTimeline = null;

const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
if (__DEV__) {
Expand Down Expand Up @@ -794,6 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return null;
},

subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
return () => {};
},

resetTextContent(instance: Instance): void {
instance.text = null;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ export const hasInstanceAffectedParent = shim;
export const startViewTransition = shim;
export type ViewTransitionInstance = null | {name: string, ...};
export const createViewTransitionInstance = shim;
export type GestureTimeline = any;
export const subscribeToGestureDirection = shim;
37 changes: 34 additions & 3 deletions packages/react-reconciler/src/ReactFiberGestureScheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,26 @@
*/

import type {FiberRoot} from './ReactInternalTypes';
import type {GestureProvider} from 'shared/ReactTypes';
import type {GestureTimeline} from './ReactFiberConfig';

import {GestureLane} from './ReactFiberLane';
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
import {subscribeToGestureDirection} from './ReactFiberConfig';

// This type keeps track of any scheduled or active gestures.
export type ScheduledGesture = {
provider: GestureProvider,
provider: GestureTimeline,
count: number, // The number of times this same provider has been started.
direction: boolean, // false = previous, true = next
cancel: () => void, // Cancel the subscription to direction change.
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(
root: FiberRoot,
provider: GestureProvider,
provider: GestureTimeline,
initialDirection: boolean,
): ScheduledGesture {
let prev = root.gestures;
while (prev !== null) {
Expand All @@ -39,9 +43,32 @@ export function scheduleGesture(
prev = next;
}
// Add new instance to the end of the queue.
const cancel = subscribeToGestureDirection(provider, (direction: boolean) => {
if (gesture.direction !== direction) {
gesture.direction = direction;
if (gesture.prev === null && root.gestures !== gesture) {
// This gesture is not in the schedule, meaning it was already rendered.
// We need to rerender in the new direction. Insert it into the first slot
// in case other gestures are queued after the on-going one.
const existing = root.gestures;
gesture.next = existing;
if (existing !== null) {
existing.prev = gesture;
}
root.gestures = gesture;
// Schedule the lane on the root. The Fibers will already be marked as
// long as the gesture is active on that Hook.
root.pendingLanes |= GestureLane;
ensureRootIsScheduled(root);
}
// TODO: If we're currently rendering this gesture, we need to restart it.
}
});
const gesture: ScheduledGesture = {
provider: provider,
count: 1,
direction: initialDirection,
cancel: cancel,
prev: prev,
next: null,
};
Expand All @@ -60,8 +87,12 @@ export function cancelScheduledGesture(
): void {
gesture.count--;
if (gesture.count === 0) {
const cancelDirectionSubscription = gesture.cancel;
cancelDirectionSubscription();
// Delete the scheduled gesture from the queue.
deleteScheduledGesture(root, gesture);
// TODO: If we're currently rendering this gesture, we need to restart the render
// on a different gesture or cancel the render..
}
}

Expand Down
67 changes: 41 additions & 26 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type {
import type {Lanes, Lane} from './ReactFiberLane';
import type {HookFlags} from './ReactHookEffectTags';
import type {Flags} from './ReactFiberFlags';
import type {TransitionStatus} from './ReactFiberConfig';
import type {TransitionStatus, GestureTimeline} from './ReactFiberConfig';
import type {ScheduledGesture} from './ReactFiberGestureScheduler';

import {
Expand Down Expand Up @@ -3981,6 +3981,7 @@ type SwipeTransitionGestureUpdate = {
type SwipeTransitionUpdateQueue = {
pending: null | SwipeTransitionGestureUpdate,
dispatch: StartGesture,
initialDirection: boolean,
};

function startGesture(
Expand All @@ -3996,9 +3997,14 @@ function startGesture(
// Noop.
};
}
const scheduledGesture = scheduleGesture(root, gestureProvider);
const gestureTimeline: GestureTimeline = gestureProvider;
const scheduledGesture = scheduleGesture(
root,
gestureTimeline,
queue.initialDirection,
);
// Add this particular instance to the queue.
// We add multiple of the same provider even if they get batched so
// We add multiple of the same timeline even if they get batched so
// that if we cancel one but not the other we can keep track of this.
// Order doesn't matter but we insert in the beginning to avoid two fields.
const update: SwipeTransitionGestureUpdate = {
Expand Down Expand Up @@ -4041,6 +4047,7 @@ function mountSwipeTransition<T>(
const queue: SwipeTransitionUpdateQueue = {
pending: null,
dispatch: (null: any),
initialDirection: previous === current,
};
const startGestureOnHook: StartGesture = (queue.dispatch = (startGesture.bind(
null,
Expand All @@ -4062,31 +4069,34 @@ function updateSwipeTransition<T>(
const startGestureOnHook: StartGesture = queue.dispatch;
const rootRenderLanes = getWorkInProgressRootRenderLanes();
let value = current;
if (isGestureRender(rootRenderLanes)) {
// We're inside a gesture render. We'll traverse the queue to see if
// this specific Hook is part of this gesture and, if so, which
// direction to render.
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.
const rootRenderGesture = root.gestures;
let update = queue.pending;
while (update !== null) {
if (rootRenderGesture === update.gesture) {
// We had a match, meaning we're currently rendering a direction of this
// hook for this gesture.
// TODO: Determine which direction this gesture is currently rendering.
value = previous;
break;
if (queue.pending !== null) {
if (isGestureRender(rootRenderLanes)) {
// We're inside a gesture render. We'll traverse the queue to see if
// this specific Hook is part of this gesture and, if so, which
// direction to render.
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.',
);
}
update = update.next;
// We assume that the currently rendering gesture is the one first in the queue.
const rootRenderGesture = root.gestures;
if (rootRenderGesture !== null) {
let update = queue.pending;
while (update !== null) {
if (rootRenderGesture === update.gesture) {
// We had a match, meaning we're currently rendering a direction of this
// hook for this gesture.
value = rootRenderGesture.direction ? next : previous;
break;
}
update = update.next;
}
}
// This lane cannot be cleared as long as we have active gestures.
markWorkInProgressReceivedUpdate();
}
}
if (queue.pending !== null) {
// As long as there are any active gestures we need to leave the lane on
// in case we need to render it later. Since a gesture render doesn't commit
// the only time it really fully gets cleared is if something else rerenders
Expand All @@ -4096,6 +4106,11 @@ function updateSwipeTransition<T>(
GestureLane,
);
}
// By default, we don't know which direction we should start until a movement
// has happened. However, if one direction has the same value as current we
// know that it's probably not that direction since it won't do anything anyway.
// TODO: Add an explicit option to provide this.
queue.initialDirection = previous === current;
return [value, startGestureOnHook];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export opaque type FormInstance = mixed;
export type ViewTransitionInstance = null | {name: string, ...};
export opaque type InstanceMeasurement = mixed;
export type EventResponder = any;
export type GestureTimeline = any;

export const rendererVersion = $$$config.rendererVersion;
export const rendererPackageName = $$$config.rendererPackageName;
Expand Down Expand Up @@ -144,6 +145,8 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport;
export const hasInstanceChanged = $$$config.hasInstanceChanged;
export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
export const startViewTransition = $$$config.startViewTransition;
export const subscribeToGestureDirection =
$$$config.subscribeToGestureDirection;
export const createViewTransitionInstance =
$$$config.createViewTransitionInstance;
export const clearContainer = $$$config.clearContainer;
Expand Down
9 changes: 9 additions & 0 deletions packages/react-test-renderer/src/ReactFiberConfigTestHost.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,15 @@ export function getInstanceFromNode(mockNode: Object): Object | null {
return null;
}

export type GestureTimeline = null;

export function subscribeToGestureDirection(
provider: GestureTimeline,
directionCallback: (direction: boolean) => void,
): () => void {
return () => {};
}

export function beforeActiveInstanceBlur(internalInstanceHandle: Object) {
// noop
}
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/ReactTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export type ReactFormState<S, ReferenceId> = [

// Intrinsic GestureProvider. This type varies by Environment whether a particular
// renderer supports it.
export type GestureProvider = AnimationTimeline; // TODO: More provider types.
export type GestureProvider = any;

export type StartGesture = (gestureProvider: GestureProvider) => () => void;

Expand Down
5 changes: 4 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -533,5 +533,8 @@
"545": "The %s tag may only be rendered once.",
"546": "useEffect CRUD overload is not enabled in this build of React.",
"547": "startGesture cannot be called during server rendering.",
"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."
"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.",
"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."
}
Loading

0 comments on commit 88479c6

Please sign in to comment.