Skip to content

Commit 3607f48

Browse files
authored
Add Commit Scaffolding for Gestures (#32451)
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.
1 parent 5eb20b3 commit 3607f48

File tree

14 files changed

+401
-50
lines changed

14 files changed

+401
-50
lines changed

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,6 @@ export default function SwipeRecognizer({
3333
});
3434
}
3535
function onScrollEnd() {
36-
if (activeGesture.current !== null) {
37-
const cancelGesture = activeGesture.current;
38-
activeGesture.current = null;
39-
cancelGesture();
40-
}
4136
let changed;
4237
const scrollElement = scrollRef.current;
4338
if (axis === 'x') {
@@ -60,6 +55,11 @@ export default function SwipeRecognizer({
6055
// Trigger side-effects
6156
startTransition(action);
6257
}
58+
if (activeGesture.current !== null) {
59+
const cancelGesture = activeGesture.current;
60+
activeGesture.current = null;
61+
cancelGesture();
62+
}
6363
}
6464

6565
useEffect(() => {

packages/react-art/src/ReactFiberConfigART.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,12 @@ export function startViewTransition() {
500500
return false;
501501
}
502502

503+
export type RunningGestureTransition = null;
504+
505+
export function startGestureTransition() {}
506+
507+
export function stopGestureTransition(transition: RunningGestureTransition) {}
508+
503509
export type ViewTransitionInstance = null | {name: string, ...};
504510

505511
export function createViewTransitionInstance(

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1394,7 +1394,10 @@ export function startViewTransition(
13941394
transition.ready.then(spawnedWorkCallback, spawnedWorkCallback);
13951395
transition.finished.then(() => {
13961396
// $FlowFixMe[prop-missing]
1397-
ownerDocument.__reactViewTransition = null;
1397+
if (ownerDocument.__reactViewTransition === transition) {
1398+
// $FlowFixMe[prop-missing]
1399+
ownerDocument.__reactViewTransition = null;
1400+
}
13981401
passiveCallback();
13991402
});
14001403
return true;
@@ -1409,6 +1412,75 @@ export function startViewTransition(
14091412
}
14101413
}
14111414

1415+
export type RunningGestureTransition = {
1416+
skipTransition(): void,
1417+
...
1418+
};
1419+
1420+
export function startGestureTransition(
1421+
rootContainer: Container,
1422+
transitionTypes: null | TransitionTypes,
1423+
mutationCallback: () => void,
1424+
animateCallback: () => void,
1425+
): null | RunningGestureTransition {
1426+
const ownerDocument: Document =
1427+
rootContainer.nodeType === DOCUMENT_NODE
1428+
? (rootContainer: any)
1429+
: rootContainer.ownerDocument;
1430+
try {
1431+
// $FlowFixMe[prop-missing]
1432+
const transition = ownerDocument.startViewTransition({
1433+
update: mutationCallback,
1434+
types: transitionTypes,
1435+
});
1436+
// $FlowFixMe[prop-missing]
1437+
ownerDocument.__reactViewTransition = transition;
1438+
let blockingAnim = null;
1439+
const readyCallback = () => {
1440+
// View Transitions with ScrollTimeline has a quirk where they end if the
1441+
// ScrollTimeline ever reaches 100% but that doesn't mean we're done because
1442+
// you can swipe back again. We can prevent this by adding a paused Animation
1443+
// that never stops. This seems to keep all running Animations alive until
1444+
// we explicitly abort (or something forces the View Transition to cancel).
1445+
const documentElement: Element = (ownerDocument.documentElement: any);
1446+
blockingAnim = documentElement.animate([{}, {}], {
1447+
pseudoElement: '::view-transition',
1448+
duration: 1,
1449+
});
1450+
blockingAnim.pause();
1451+
animateCallback();
1452+
};
1453+
transition.ready.then(readyCallback, readyCallback);
1454+
transition.finished.then(() => {
1455+
if (blockingAnim !== null) {
1456+
// In Safari, we need to manually clear this or it'll block future transitions.
1457+
blockingAnim.cancel();
1458+
}
1459+
// $FlowFixMe[prop-missing]
1460+
if (ownerDocument.__reactViewTransition === transition) {
1461+
// $FlowFixMe[prop-missing]
1462+
ownerDocument.__reactViewTransition = null;
1463+
}
1464+
});
1465+
return transition;
1466+
} catch (x) {
1467+
// We use the error as feature detection.
1468+
// The only thing that should throw is if startViewTransition is missing
1469+
// or if it doesn't accept the object form. Other errors are async.
1470+
// I.e. it's before the View Transitions v2 spec. We only support View
1471+
// Transitions v2 otherwise we fallback to not animating to ensure that
1472+
// we're not animating with the wrong animation mapped.
1473+
// Run through the sequence to put state back into a consistent state.
1474+
mutationCallback();
1475+
animateCallback();
1476+
return null;
1477+
}
1478+
}
1479+
1480+
export function stopGestureTransition(transition: RunningGestureTransition) {
1481+
transition.skipTransition();
1482+
}
1483+
14121484
interface ViewTransitionPseudoElementType extends Animatable {
14131485
_scope: HTMLElement;
14141486
_selector: string;

packages/react-native-renderer/src/ReactFiberConfigNative.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,21 @@ export function startViewTransition(
597597
return false;
598598
}
599599

600+
export type RunningGestureTransition = null;
601+
602+
export function startGestureTransition(
603+
rootContainer: Container,
604+
transitionTypes: null | TransitionTypes,
605+
mutationCallback: () => void,
606+
animateCallback: () => void,
607+
): RunningGestureTransition {
608+
mutationCallback();
609+
animateCallback();
610+
return null;
611+
}
612+
613+
export function stopGestureTransition(transition: RunningGestureTransition) {}
614+
600615
export type ViewTransitionInstance = null | {name: string, ...};
601616

602617
export function createViewTransitionInstance(

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export type TransitionStatus = mixed;
9393

9494
export type FormInstance = Instance;
9595

96+
export type RunningGestureTransition = null;
97+
9698
export type ViewTransitionInstance = null | {name: string, ...};
9799

98100
export type GestureTimeline = null;
@@ -792,6 +794,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
792794
return false;
793795
},
794796

797+
startGestureTransition(
798+
rootContainer: Container,
799+
transitionTypes: null | TransitionTypes,
800+
mutationCallback: () => void,
801+
animateCallback: () => void,
802+
): RunningGestureTransition {
803+
mutationCallback();
804+
animateCallback();
805+
return null;
806+
},
807+
808+
stopGestureTransition(transition: RunningGestureTransition) {},
809+
795810
createViewTransitionInstance(name: string): ViewTransitionInstance {
796811
return null;
797812
},
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Fiber, FiberRoot} from './ReactInternalTypes';
11+
12+
import {
13+
cancelRootViewTransitionName,
14+
restoreRootViewTransitionName,
15+
} from './ReactFiberConfig';
16+
17+
// Clone View Transition boundaries that have any mutations or might have had their
18+
// layout affected by child insertions.
19+
export function insertDestinationClones(
20+
root: FiberRoot,
21+
finishedWork: Fiber,
22+
): void {
23+
// TODO
24+
}
25+
26+
// Revert insertions and apply view transition names to the "new" (current) state.
27+
export function applyDepartureTransitions(
28+
root: FiberRoot,
29+
finishedWork: Fiber,
30+
): void {
31+
// TODO
32+
cancelRootViewTransitionName(root.containerInfo);
33+
}
34+
35+
// Revert transition names and start/adjust animations on the started View Transition.
36+
export function startGestureAnimations(
37+
root: FiberRoot,
38+
finishedWork: Fiber,
39+
): void {
40+
// TODO
41+
restoreRootViewTransitionName(root.containerInfo);
42+
}

packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export const wasInstanceInViewport = shim;
4646
export const hasInstanceChanged = shim;
4747
export const hasInstanceAffectedParent = shim;
4848
export const startViewTransition = shim;
49+
export type RunningGestureTransition = null;
50+
export const startGestureTransition = shim;
51+
export const stopGestureTransition = shim;
4952
export type ViewTransitionInstance = null | {name: string, ...};
5053
export const createViewTransitionInstance = shim;
5154
export type GestureTimeline = any;

packages/react-reconciler/src/ReactFiberGestureScheduler.js

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,21 @@
88
*/
99

1010
import type {FiberRoot} from './ReactInternalTypes';
11-
import type {GestureTimeline} from './ReactFiberConfig';
11+
import type {
12+
GestureTimeline,
13+
RunningGestureTransition,
14+
} from './ReactFiberConfig';
1215

13-
import {GestureLane} from './ReactFiberLane';
16+
import {
17+
GestureLane,
18+
includesBlockingLane,
19+
includesTransitionLane,
20+
} from './ReactFiberLane';
1421
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
15-
import {subscribeToGestureDirection} from './ReactFiberConfig';
22+
import {
23+
subscribeToGestureDirection,
24+
stopGestureTransition,
25+
} from './ReactFiberConfig';
1626

1727
// This type keeps track of any scheduled or active gestures.
1828
export type ScheduledGesture = {
@@ -23,6 +33,7 @@ export type ScheduledGesture = {
2333
rangeCurrent: number, // The starting offset along the timeline.
2434
rangeNext: number, // The end along the timeline where the next state is reached.
2535
cancel: () => void, // Cancel the subscription to direction change.
36+
running: null | RunningGestureTransition, // Used to cancel the running transition after we're done.
2637
prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
2738
next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root.
2839
};
@@ -35,7 +46,7 @@ export function scheduleGesture(
3546
rangeCurrent: number,
3647
rangeNext: number,
3748
): ScheduledGesture {
38-
let prev = root.gestures;
49+
let prev = root.pendingGestures;
3950
while (prev !== null) {
4051
if (prev.provider === provider) {
4152
// Existing instance found.
@@ -59,16 +70,16 @@ export function scheduleGesture(
5970
}
6071
if (gesture.direction !== direction) {
6172
gesture.direction = direction;
62-
if (gesture.prev === null && root.gestures !== gesture) {
73+
if (gesture.prev === null && root.pendingGestures !== gesture) {
6374
// This gesture is not in the schedule, meaning it was already rendered.
6475
// We need to rerender in the new direction. Insert it into the first slot
6576
// in case other gestures are queued after the on-going one.
66-
const existing = root.gestures;
77+
const existing = root.pendingGestures;
6778
gesture.next = existing;
6879
if (existing !== null) {
6980
existing.prev = gesture;
7081
}
71-
root.gestures = gesture;
82+
root.pendingGestures = gesture;
7283
// Schedule the lane on the root. The Fibers will already be marked as
7384
// long as the gesture is active on that Hook.
7485
root.pendingLanes |= GestureLane;
@@ -86,11 +97,12 @@ export function scheduleGesture(
8697
rangeCurrent: rangeCurrent,
8798
rangeNext: rangeNext,
8899
cancel: cancel,
100+
running: null,
89101
prev: prev,
90102
next: null,
91103
};
92104
if (prev === null) {
93-
root.gestures = gesture;
105+
root.pendingGestures = gesture;
94106
} else {
95107
prev.next = gesture;
96108
}
@@ -106,10 +118,35 @@ export function cancelScheduledGesture(
106118
if (gesture.count === 0) {
107119
const cancelDirectionSubscription = gesture.cancel;
108120
cancelDirectionSubscription();
109-
// Delete the scheduled gesture from the queue.
121+
// Delete the scheduled gesture from the pending queue.
110122
deleteScheduledGesture(root, gesture);
111123
// TODO: If we're currently rendering this gesture, we need to restart the render
112124
// on a different gesture or cancel the render..
125+
// TODO: We might want to pause the View Transition at this point since you should
126+
// no longer be able to update the position of anything but it might be better to
127+
// just commit the gesture state.
128+
const runningTransition = gesture.running;
129+
if (runningTransition !== null) {
130+
const pendingLanesExcludingGestureLane = root.pendingLanes & ~GestureLane;
131+
if (
132+
includesBlockingLane(pendingLanesExcludingGestureLane) ||
133+
includesTransitionLane(pendingLanesExcludingGestureLane)
134+
) {
135+
// If we have pending work we schedule the gesture to be stopped at the next commit.
136+
// This ensures that we don't snap back to the previous state until we have
137+
// had a chance to commit any resulting updates.
138+
const existing = root.stoppingGestures;
139+
if (existing !== null) {
140+
gesture.next = existing;
141+
existing.prev = gesture;
142+
}
143+
root.stoppingGestures = gesture;
144+
} else {
145+
gesture.running = null;
146+
// If there's no work scheduled so we can stop the View Transition right away.
147+
stopGestureTransition(runningTransition);
148+
}
149+
}
113150
}
114151
}
115152

@@ -118,15 +155,19 @@ export function deleteScheduledGesture(
118155
gesture: ScheduledGesture,
119156
): void {
120157
if (gesture.prev === null) {
121-
if (root.gestures === gesture) {
122-
root.gestures = gesture.next;
123-
if (root.gestures === null) {
158+
if (root.pendingGestures === gesture) {
159+
root.pendingGestures = gesture.next;
160+
if (root.pendingGestures === null) {
124161
// Gestures don't clear their lanes while the gesture is still active but it
125162
// might not be scheduled to do any more renders and so we shouldn't schedule
126163
// any more gesture lane work until a new gesture is scheduled.
127164
root.pendingLanes &= ~GestureLane;
128165
}
129166
}
167+
if (root.stoppingGestures === gesture) {
168+
// This should not really happen the way we use it now but just in case we start.
169+
root.stoppingGestures = gesture.next;
170+
}
130171
} else {
131172
gesture.prev.next = gesture.next;
132173
if (gesture.next !== null) {
@@ -136,3 +177,18 @@ export function deleteScheduledGesture(
136177
gesture.next = null;
137178
}
138179
}
180+
181+
export function stopCompletedGestures(root: FiberRoot) {
182+
let gesture = root.stoppingGestures;
183+
root.stoppingGestures = null;
184+
while (gesture !== null) {
185+
if (gesture.running !== null) {
186+
stopGestureTransition(gesture.running);
187+
gesture.running = null;
188+
}
189+
const nextGesture = gesture.next;
190+
gesture.next = null;
191+
gesture.prev = null;
192+
gesture = nextGesture;
193+
}
194+
}

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4126,7 +4126,7 @@ function updateSwipeTransition<T>(
41264126
);
41274127
}
41284128
// We assume that the currently rendering gesture is the one first in the queue.
4129-
const rootRenderGesture = root.gestures;
4129+
const rootRenderGesture = root.pendingGestures;
41304130
if (rootRenderGesture !== null) {
41314131
let update = queue.pending;
41324132
while (update !== null) {

0 commit comments

Comments
 (0)