Skip to content

Commit

Permalink
Add Commit Scaffolding for Gestures (#32451)
Browse files Browse the repository at this point in the history
This adds a `ReactFiberApplyGesture` which is basically intended to be a
fork of the phases in `ReactFiberCommitWork` except for the fake commit
that `useSwipeTransition` does. So far none of the phases are actually
implemented yet. This is just the scaffolding around them so I can fill
them in later.

The important bit is that we call `startViewTransition` (via the
`startGestureTransition` Config) when a gesture starts. We add a paused
animation to prevent the transition from committing (even if the
ScrollTimeline goes to 100%). This also locks the documents so that we
can't commit any other Transitions until it completes.

When the gesture completes (scroll end) then we stop the gesture View
Transition. If there's no new work scheduled we do that immediately but
if there was any new work already scheduled, then we assume that this
will potentially commit the new state. So we wait for that to finish.
This lets us lock the animation in its state instead of snapping back
and then applying the real update.

Using this technique we can't actually run a View Transition from the
current state to the actual committed state because it would snap back
to the beginning and then run the View Transition from there. Therefore
any new commit needs to skip View Transitions even if it should've
technically animated to that state. We assume that the new state is the
same as the optimistic state you already swiped to. An alternative to
this technique could be to commit the optimistic state when we cancel
and then apply any new updates o top of that. I might explore that in
the future.

Regardless it's important that the `action` associated with the swipe
schedules some work before we cancel. Otherwise it risks reverting
first. So I had to update this in the fixture.
  • Loading branch information
sebmarkbage authored Feb 27, 2025
1 parent 5eb20b3 commit 3607f48
Show file tree
Hide file tree
Showing 14 changed files with 401 additions and 50 deletions.
10 changes: 5 additions & 5 deletions fixtures/view-transition/src/components/SwipeRecognizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@ export default function SwipeRecognizer({
});
}
function onScrollEnd() {
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
let changed;
const scrollElement = scrollRef.current;
if (axis === 'x') {
Expand All @@ -60,6 +55,11 @@ export default function SwipeRecognizer({
// Trigger side-effects
startTransition(action);
}
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
}

useEffect(() => {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-art/src/ReactFiberConfigART.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,12 @@ export function startViewTransition() {
return false;
}

export type RunningGestureTransition = null;

export function startGestureTransition() {}

export function stopGestureTransition(transition: RunningGestureTransition) {}

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

export function createViewTransitionInstance(
Expand Down
74 changes: 73 additions & 1 deletion packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -1394,7 +1394,10 @@ export function startViewTransition(
transition.ready.then(spawnedWorkCallback, spawnedWorkCallback);
transition.finished.then(() => {
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = null;
if (ownerDocument.__reactViewTransition === transition) {
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = null;
}
passiveCallback();
});
return true;
Expand All @@ -1409,6 +1412,75 @@ export function startViewTransition(
}
}

export type RunningGestureTransition = {
skipTransition(): void,
...
};

export function startGestureTransition(
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
animateCallback: () => void,
): null | RunningGestureTransition {
const ownerDocument: Document =
rootContainer.nodeType === DOCUMENT_NODE
? (rootContainer: any)
: rootContainer.ownerDocument;
try {
// $FlowFixMe[prop-missing]
const transition = ownerDocument.startViewTransition({
update: mutationCallback,
types: transitionTypes,
});
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = transition;
let blockingAnim = null;
const readyCallback = () => {
// View Transitions with ScrollTimeline has a quirk where they end if the
// ScrollTimeline ever reaches 100% but that doesn't mean we're done because
// you can swipe back again. We can prevent this by adding a paused Animation
// that never stops. This seems to keep all running Animations alive until
// we explicitly abort (or something forces the View Transition to cancel).
const documentElement: Element = (ownerDocument.documentElement: any);
blockingAnim = documentElement.animate([{}, {}], {
pseudoElement: '::view-transition',
duration: 1,
});
blockingAnim.pause();
animateCallback();
};
transition.ready.then(readyCallback, readyCallback);
transition.finished.then(() => {
if (blockingAnim !== null) {
// In Safari, we need to manually clear this or it'll block future transitions.
blockingAnim.cancel();
}
// $FlowFixMe[prop-missing]
if (ownerDocument.__reactViewTransition === transition) {
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = null;
}
});
return transition;
} catch (x) {
// We use the error as feature detection.
// The only thing that should throw is if startViewTransition is missing
// or if it doesn't accept the object form. Other errors are async.
// I.e. it's before the View Transitions v2 spec. We only support View
// Transitions v2 otherwise we fallback to not animating to ensure that
// we're not animating with the wrong animation mapped.
// Run through the sequence to put state back into a consistent state.
mutationCallback();
animateCallback();
return null;
}
}

export function stopGestureTransition(transition: RunningGestureTransition) {
transition.skipTransition();
}

interface ViewTransitionPseudoElementType extends Animatable {
_scope: HTMLElement;
_selector: string;
Expand Down
15 changes: 15 additions & 0 deletions packages/react-native-renderer/src/ReactFiberConfigNative.js
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,21 @@ export function startViewTransition(
return false;
}

export type RunningGestureTransition = null;

export function startGestureTransition(
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
animateCallback: () => void,
): RunningGestureTransition {
mutationCallback();
animateCallback();
return null;
}

export function stopGestureTransition(transition: RunningGestureTransition) {}

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

export function createViewTransitionInstance(
Expand Down
15 changes: 15 additions & 0 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export type TransitionStatus = mixed;

export type FormInstance = Instance;

export type RunningGestureTransition = null;

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

export type GestureTimeline = null;
Expand Down Expand Up @@ -792,6 +794,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return false;
},

startGestureTransition(
rootContainer: Container,
transitionTypes: null | TransitionTypes,
mutationCallback: () => void,
animateCallback: () => void,
): RunningGestureTransition {
mutationCallback();
animateCallback();
return null;
},

stopGestureTransition(transition: RunningGestureTransition) {},

createViewTransitionInstance(name: string): ViewTransitionInstance {
return null;
},
Expand Down
42 changes: 42 additions & 0 deletions packages/react-reconciler/src/ReactFiberApplyGesture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import type {Fiber, FiberRoot} from './ReactInternalTypes';

import {
cancelRootViewTransitionName,
restoreRootViewTransitionName,
} from './ReactFiberConfig';

// Clone View Transition boundaries that have any mutations or might have had their
// layout affected by child insertions.
export function insertDestinationClones(
root: FiberRoot,
finishedWork: Fiber,
): void {
// TODO
}

// Revert insertions and apply view transition names to the "new" (current) state.
export function applyDepartureTransitions(
root: FiberRoot,
finishedWork: Fiber,
): void {
// TODO
cancelRootViewTransitionName(root.containerInfo);
}

// Revert transition names and start/adjust animations on the started View Transition.
export function startGestureAnimations(
root: FiberRoot,
finishedWork: Fiber,
): void {
// TODO
restoreRootViewTransitionName(root.containerInfo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const wasInstanceInViewport = shim;
export const hasInstanceChanged = shim;
export const hasInstanceAffectedParent = shim;
export const startViewTransition = shim;
export type RunningGestureTransition = null;
export const startGestureTransition = shim;
export const stopGestureTransition = shim;
export type ViewTransitionInstance = null | {name: string, ...};
export const createViewTransitionInstance = shim;
export type GestureTimeline = any;
Expand Down
80 changes: 68 additions & 12 deletions packages/react-reconciler/src/ReactFiberGestureScheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@
*/

import type {FiberRoot} from './ReactInternalTypes';
import type {GestureTimeline} from './ReactFiberConfig';
import type {
GestureTimeline,
RunningGestureTransition,
} from './ReactFiberConfig';

import {GestureLane} from './ReactFiberLane';
import {
GestureLane,
includesBlockingLane,
includesTransitionLane,
} from './ReactFiberLane';
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
import {subscribeToGestureDirection} from './ReactFiberConfig';
import {
subscribeToGestureDirection,
stopGestureTransition,
} from './ReactFiberConfig';

// This type keeps track of any scheduled or active gestures.
export type ScheduledGesture = {
Expand All @@ -23,6 +33,7 @@ export type ScheduledGesture = {
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.
running: null | RunningGestureTransition, // 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.
};
Expand All @@ -35,7 +46,7 @@ export function scheduleGesture(
rangeCurrent: number,
rangeNext: number,
): ScheduledGesture {
let prev = root.gestures;
let prev = root.pendingGestures;
while (prev !== null) {
if (prev.provider === provider) {
// Existing instance found.
Expand All @@ -59,16 +70,16 @@ export function scheduleGesture(
}
if (gesture.direction !== direction) {
gesture.direction = direction;
if (gesture.prev === null && root.gestures !== gesture) {
if (gesture.prev === null && root.pendingGestures !== 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;
const existing = root.pendingGestures;
gesture.next = existing;
if (existing !== null) {
existing.prev = gesture;
}
root.gestures = gesture;
root.pendingGestures = 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;
Expand All @@ -86,11 +97,12 @@ export function scheduleGesture(
rangeCurrent: rangeCurrent,
rangeNext: rangeNext,
cancel: cancel,
running: null,
prev: prev,
next: null,
};
if (prev === null) {
root.gestures = gesture;
root.pendingGestures = gesture;
} else {
prev.next = gesture;
}
Expand All @@ -106,10 +118,35 @@ export function cancelScheduledGesture(
if (gesture.count === 0) {
const cancelDirectionSubscription = gesture.cancel;
cancelDirectionSubscription();
// Delete the scheduled gesture from the queue.
// Delete the scheduled gesture from the pending 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..
// TODO: We might want to pause the View Transition at this point since you should
// no longer be able to update the position of anything but it might be better to
// just commit the gesture state.
const runningTransition = gesture.running;
if (runningTransition !== null) {
const pendingLanesExcludingGestureLane = root.pendingLanes & ~GestureLane;
if (
includesBlockingLane(pendingLanesExcludingGestureLane) ||
includesTransitionLane(pendingLanesExcludingGestureLane)
) {
// If we have pending work we schedule the gesture to be stopped at the next commit.
// This ensures that we don't snap back to the previous state until we have
// had a chance to commit any resulting updates.
const existing = root.stoppingGestures;
if (existing !== null) {
gesture.next = existing;
existing.prev = gesture;
}
root.stoppingGestures = gesture;
} else {
gesture.running = null;
// If there's no work scheduled so we can stop the View Transition right away.
stopGestureTransition(runningTransition);
}
}
}
}

Expand All @@ -118,15 +155,19 @@ export function deleteScheduledGesture(
gesture: ScheduledGesture,
): void {
if (gesture.prev === null) {
if (root.gestures === gesture) {
root.gestures = gesture.next;
if (root.gestures === null) {
if (root.pendingGestures === gesture) {
root.pendingGestures = gesture.next;
if (root.pendingGestures === null) {
// Gestures don't clear their lanes while the gesture is still active but it
// might not be scheduled to do any more renders and so we shouldn't schedule
// any more gesture lane work until a new gesture is scheduled.
root.pendingLanes &= ~GestureLane;
}
}
if (root.stoppingGestures === gesture) {
// This should not really happen the way we use it now but just in case we start.
root.stoppingGestures = gesture.next;
}
} else {
gesture.prev.next = gesture.next;
if (gesture.next !== null) {
Expand All @@ -136,3 +177,18 @@ export function deleteScheduledGesture(
gesture.next = null;
}
}

export function stopCompletedGestures(root: FiberRoot) {
let gesture = root.stoppingGestures;
root.stoppingGestures = null;
while (gesture !== null) {
if (gesture.running !== null) {
stopGestureTransition(gesture.running);
gesture.running = null;
}
const nextGesture = gesture.next;
gesture.next = null;
gesture.prev = null;
gesture = nextGesture;
}
}
2 changes: 1 addition & 1 deletion packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -4126,7 +4126,7 @@ function updateSwipeTransition<T>(
);
}
// We assume that the currently rendering gesture is the one first in the queue.
const rootRenderGesture = root.gestures;
const rootRenderGesture = root.pendingGestures;
if (rootRenderGesture !== null) {
let update = queue.pending;
while (update !== null) {
Expand Down
Loading

0 comments on commit 3607f48

Please sign in to comment.