Add useSwipeTransition Hook Behind Experimental Flag (#32373)

This Hook will be used to drive a View Transition based on a gesture.

```js
const [value, startGesture] = useSwipeTransition(prev, current, next);
```

The `enableSwipeTransition` flag will depend on `enableViewTransition`
flag but we may decide to ship them independently. This PR doesn't do
anything interesting yet. There will be a lot more PRs to build out the
actual functionality. This is just wiring up the plumbing for the new
Hook.

This first PR is mainly concerned with how the whole starts (and stops).
The core API is the `startGesture` function (although there will be
other conveniences added in the future). You can call this to start a
gesture with a source provider. You can call this multiple times in one
event to batch multiple Hooks listening to the same provider. However,
each render can only handle one source provider at a time and so it does
one render per scheduled gesture provider.

This uses a separate `GestureLane` to drive gesture renders by marking
the Hook as having an update on that lane. Then schedule a render. These
renders should be blocking and in the same microtask as the
`startGesture` to ensure it can block the paint. So it's similar to
sync.

It may not be possible to finish it synchronously e.g. if something
suspends. If so, it just tries again later when it can like any other
render. This can also happen because it also may not be possible to
drive more than one gesture at a time like if we're limited to one View
Transition per document. So right now you can only run one gesture at a
time in practice.

These renders never commit. This means that we can't clear the
`GestureLane` the normal way. Instead, we have to clear only the root's
`pendingLanes` if we don't have any new renders scheduled. Then wait
until something else updates the Fiber after all gestures on it have
stopped before it really clears.
This commit is contained in:
Sebastian Markbåge 2025-02-13 16:06:01 -05:00 committed by GitHub
parent 32b0cad8f7
commit a53da6abe1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 647 additions and 68 deletions

View File

@ -6,3 +6,14 @@
font-variation-settings:
"wdth" 100;
}
.swipe-recognizer {
width: 200px;
overflow-x: scroll;
border: 1px solid #333333;
border-radius: 10px;
}
.swipe-overscroll {
width: 200%;
}

View File

@ -1,6 +1,9 @@
import React, {
unstable_ViewTransition as ViewTransition,
unstable_Activity as Activity,
unstable_useSwipeTransition as useSwipeTransition,
useRef,
useLayoutEffect,
} from 'react';
import './Page.css';
@ -35,7 +38,8 @@ function Component() {
}
export default function Page({url, navigate}) {
const show = url === '/?b';
const [renderedUrl, startGesture] = useSwipeTransition('/?a', url, '/?b');
const show = renderedUrl === '/?b';
function onTransition(viewTransition, types) {
const keyframes = [
{rotate: '0deg', transformOrigin: '30px 8px'},
@ -44,6 +48,32 @@ export default function Page({url, navigate}) {
viewTransition.old.animate(keyframes, 250);
viewTransition.new.animate(keyframes, 250);
}
const swipeRecognizer = useRef(null);
const activeGesture = useRef(null);
function onScroll() {
if (activeGesture.current !== null) {
return;
}
// eslint-disable-next-line no-undef
const scrollTimeline = new ScrollTimeline({
source: swipeRecognizer.current,
axis: 'x',
});
activeGesture.current = startGesture(scrollTimeline);
}
function onScrollEnd() {
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
}
useLayoutEffect(() => {
swipeRecognizer.current.scrollLeft = show ? 0 : 10000;
}, [show]);
const exclamation = (
<ViewTransition name="exclamation" onShare={onTransition}>
<span>!</span>
@ -90,6 +120,13 @@ export default function Page({url, navigate}) {
<p></p>
<p></p>
<p></p>
<div
className="swipe-recognizer"
onScroll={onScroll}
onScrollEnd={onScrollEnd}
ref={swipeRecognizer}>
<div className="swipe-overscroll">Swipe me</div>
</div>
<p></p>
<p></p>
{show ? null : (

View File

@ -14,6 +14,7 @@ import type {
Usable,
Thenable,
ReactDebugInfo,
StartGesture,
} from 'shared/ReactTypes';
import type {
ContextDependency,
@ -131,6 +132,9 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
if (typeof Dispatcher.useEffectEvent === 'function') {
Dispatcher.useEffectEvent((args: empty) => {});
}
if (typeof Dispatcher.useSwipeTransition === 'function') {
Dispatcher.useSwipeTransition(null, null, null);
}
} finally {
readHookLog = hookLog;
hookLog = [];
@ -752,31 +756,50 @@ function useEffectEvent<Args, F: (...Array<Args>) => mixed>(callback: F): F {
return callback;
}
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
nextHook();
hookLog.push({
displayName: null,
primitive: 'SwipeTransition',
stackError: new Error(),
value: current,
debugInfo: null,
dispatcherHookName: 'SwipeTransition',
});
return [current, () => () => {}];
}
const Dispatcher: DispatcherType = {
use,
readContext,
useCacheRefresh,
use,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useDebugValue,
useLayoutEffect,
useInsertionEffect,
useMemo,
useMemoCache,
useOptimistic,
useReducer,
useRef,
useState,
useDebugValue,
useDeferredValue,
useTransition,
useSyncExternalStore,
useDeferredValue,
useId,
useHostTransitionStatus,
useFormState,
useActionState,
useHostTransitionStatus,
useOptimistic,
useMemoCache,
useCacheRefresh,
useEffectEvent,
useSwipeTransition,
};
// create a proxy to throw a custom error

View File

@ -24,7 +24,14 @@ import {
throwIfInfiniteUpdateLoopDetected,
getWorkInProgressRoot,
} from './ReactFiberWorkLoop';
import {NoLane, NoLanes, mergeLanes, markHiddenUpdate} from './ReactFiberLane';
import {
NoLane,
NoLanes,
mergeLanes,
markHiddenUpdate,
markRootUpdated,
GestureLane,
} from './ReactFiberLane';
import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
import {HostRoot, OffscreenComponent} from './ReactWorkTags';
import {OffscreenVisible} from './ReactFiberActivityComponent';
@ -169,6 +176,25 @@ export function enqueueConcurrentRenderForLane(
return getRootForUpdatedFiber(fiber);
}
export function enqueueGestureRender(fiber: Fiber): FiberRoot | null {
// We can't use the concurrent queuing for these so this is basically just a
// short cut for marking the lane on the parent path. It is possible for a
// gesture render to suspend and then in the gap get another gesture starting.
// However, marking the lane doesn't make much different in this case because
// it would have to call startGesture with the same exact provider as was
// already rendering. Because otherwise it has no effect on the Hook itself.
// TODO: We could potentially solve this case by popping a ScheduledGesture
// off the root's queue while we're rendering it so that it can't dedupe
// and so new startGesture with the same provider would create a new
// ScheduledGesture which goes into a separate render pass anyway.
// This is such an edge case it probably doesn't matter much.
const root = markUpdateLaneFromFiberToRoot(fiber, null, GestureLane);
if (root !== null) {
markRootUpdated(root, GestureLane);
}
return root;
}
// Calling this function outside this module should only be done for backwards
// compatibility and should always be accompanied by a warning.
export function unsafe_markUpdateLaneFromFiberToRoot(
@ -189,7 +215,7 @@ function markUpdateLaneFromFiberToRoot(
sourceFiber: Fiber,
update: ConcurrentUpdate | null,
lane: Lane,
): void {
): null | FiberRoot {
// Update the source fiber's lanes
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
let alternate = sourceFiber.alternate;
@ -238,10 +264,14 @@ function markUpdateLaneFromFiberToRoot(
parent = parent.return;
}
if (isHidden && update !== null && node.tag === HostRoot) {
if (node.tag === HostRoot) {
const root: FiberRoot = node.stateNode;
markHiddenUpdate(root, update, lane);
if (isHidden && update !== null) {
markHiddenUpdate(root, update, lane);
}
return root;
}
return null;
}
function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null {

View File

@ -0,0 +1,90 @@
/**
* 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 {FiberRoot} from './ReactInternalTypes';
import type {GestureProvider} from 'shared/ReactTypes';
import {GestureLane} from './ReactFiberLane';
import {ensureRootIsScheduled} from './ReactFiberRootScheduler';
// This type keeps track of any scheduled or active gestures.
export type ScheduledGesture = {
provider: GestureProvider,
count: number, // The number of times this same provider has been started.
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,
): ScheduledGesture {
let prev = root.gestures;
while (prev !== null) {
if (prev.provider === provider) {
// Existing instance found.
prev.count++;
return prev;
}
const next = prev.next;
if (next === null) {
break;
}
prev = next;
}
// Add new instance to the end of the queue.
const gesture: ScheduledGesture = {
provider: provider,
count: 1,
prev: prev,
next: null,
};
if (prev === null) {
root.gestures = gesture;
} else {
prev.next = gesture;
}
ensureRootIsScheduled(root);
return gesture;
}
export function cancelScheduledGesture(
root: FiberRoot,
gesture: ScheduledGesture,
): void {
gesture.count--;
if (gesture.count === 0) {
// Delete the scheduled gesture from the queue.
deleteScheduledGesture(root, gesture);
}
}
export function deleteScheduledGesture(
root: FiberRoot,
gesture: ScheduledGesture,
): void {
if (gesture.prev === null) {
if (root.gestures === gesture) {
root.gestures = gesture.next;
if (root.gestures === 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;
}
}
} else {
gesture.prev.next = gesture.next;
if (gesture.next !== null) {
gesture.next.prev = gesture.prev;
}
gesture.prev = null;
gesture.next = null;
}
}

View File

@ -14,6 +14,8 @@ import type {
Thenable,
RejectedThenable,
Awaited,
StartGesture,
GestureProvider,
} from 'shared/ReactTypes';
import type {
Fiber,
@ -26,6 +28,7 @@ import type {Lanes, Lane} from './ReactFiberLane';
import type {HookFlags} from './ReactHookEffectTags';
import type {Flags} from './ReactFiberFlags';
import type {TransitionStatus} from './ReactFiberConfig';
import type {ScheduledGesture} from './ReactFiberGestureScheduler';
import {
HostTransitionContext,
@ -42,6 +45,7 @@ import {
enableLegacyCache,
disableLegacyMode,
enableNoCloningMemoCache,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import {
REACT_CONTEXT_TYPE,
@ -70,6 +74,8 @@ import {
isTransitionLane,
markRootEntangled,
includesSomeLane,
isGestureRender,
GestureLane,
} from './ReactFiberLane';
import {
ContinuousEventPriority,
@ -130,6 +136,7 @@ import {
enqueueConcurrentHookUpdate,
enqueueConcurrentHookUpdateAndEagerlyBailout,
enqueueConcurrentRenderForLane,
enqueueGestureRender,
} from './ReactFiberConcurrentUpdates';
import {getTreeId} from './ReactFiberTreeContext';
import {now} from './Scheduler';
@ -153,6 +160,11 @@ import {requestCurrentTransition} from './ReactFiberTransition';
import {callComponentInDEV} from './ReactFiberCallUserSpace';
import {
scheduleGesture,
cancelScheduledGesture,
} from './ReactFiberGestureScheduler';
export type Update<S, A> = {
lane: Lane,
revertLane: Lane,
@ -3960,6 +3972,133 @@ function markUpdateInDevTools<A>(fiber: Fiber, lane: Lane, action: A): void {
}
}
type SwipeTransitionGestureUpdate = {
gesture: ScheduledGesture,
prev: SwipeTransitionGestureUpdate | null,
next: SwipeTransitionGestureUpdate | null,
};
type SwipeTransitionUpdateQueue = {
pending: null | SwipeTransitionGestureUpdate,
dispatch: StartGesture,
};
function startGesture(
fiber: Fiber,
queue: SwipeTransitionUpdateQueue,
gestureProvider: GestureProvider,
): () => void {
const root = enqueueGestureRender(fiber);
if (root === null) {
// Already unmounted.
// TODO: Should we warn here about starting on an unmounted Fiber?
return function cancelGesture() {
// Noop.
};
}
const scheduledGesture = scheduleGesture(root, gestureProvider);
// Add this particular instance to the queue.
// We add multiple of the same provider 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 = {
gesture: scheduledGesture,
prev: null,
next: queue.pending,
};
if (queue.pending !== null) {
queue.pending.prev = update;
}
queue.pending = update;
return function cancelGesture(): void {
if (update.prev === null) {
if (queue.pending === update) {
queue.pending = update.next;
} else {
// This was already cancelled. Avoid double decrementing if someone calls this twice by accident.
// TODO: Should we warn here about double cancelling?
return;
}
} else {
update.prev.next = update.next;
if (update.next !== null) {
update.next.prev = update.prev;
}
update.prev = null;
update.next = null;
}
const cancelledGestured = update.gesture;
// Decrement ref count of the root schedule.
cancelScheduledGesture(root, cancelledGestured);
};
}
function mountSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
const queue: SwipeTransitionUpdateQueue = {
pending: null,
dispatch: (null: any),
};
const startGestureOnHook: StartGesture = (queue.dispatch = (startGesture.bind(
null,
currentlyRenderingFiber,
queue,
): any));
const hook = mountWorkInProgressHook();
hook.queue = queue;
return [current, startGestureOnHook];
}
function updateSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
const hook = updateWorkInProgressHook();
const queue: SwipeTransitionUpdateQueue = hook.queue;
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;
}
update = update.next;
}
}
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
// this component after all the active gestures has cleared.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
GestureLane,
);
}
return [value, startGestureOnHook];
}
export const ContextOnlyDispatcher: Dispatcher = {
readContext,
@ -3989,6 +4128,10 @@ export const ContextOnlyDispatcher: Dispatcher = {
if (enableUseEffectEventHook) {
(ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError;
}
if (enableSwipeTransition) {
(ContextOnlyDispatcher: Dispatcher).useSwipeTransition =
throwInvalidHookError;
}
const HooksDispatcherOnMount: Dispatcher = {
readContext,
@ -4019,6 +4162,10 @@ const HooksDispatcherOnMount: Dispatcher = {
if (enableUseEffectEventHook) {
(HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
}
if (enableSwipeTransition) {
(HooksDispatcherOnMount: Dispatcher).useSwipeTransition =
mountSwipeTransition;
}
const HooksDispatcherOnUpdate: Dispatcher = {
readContext,
@ -4049,6 +4196,10 @@ const HooksDispatcherOnUpdate: Dispatcher = {
if (enableUseEffectEventHook) {
(HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
}
if (enableSwipeTransition) {
(HooksDispatcherOnUpdate: Dispatcher).useSwipeTransition =
updateSwipeTransition;
}
const HooksDispatcherOnRerender: Dispatcher = {
readContext,
@ -4079,6 +4230,10 @@ const HooksDispatcherOnRerender: Dispatcher = {
if (enableUseEffectEventHook) {
(HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent;
}
if (enableSwipeTransition) {
(HooksDispatcherOnRerender: Dispatcher).useSwipeTransition =
updateSwipeTransition;
}
let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
@ -4296,6 +4451,18 @@ if (__DEV__) {
return mountEvent(callback);
};
}
if (enableSwipeTransition) {
(HooksDispatcherOnMountInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
mountHookTypesDev();
return mountSwipeTransition(previous, current, next);
};
}
HooksDispatcherOnMountWithHookTypesInDEV = {
readContext<T>(context: ReactContext<T>): T {
@ -4479,6 +4646,18 @@ if (__DEV__) {
return mountEvent(callback);
};
}
if (enableSwipeTransition) {
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
updateHookTypesDev();
return updateSwipeTransition(previous, current, next);
};
}
HooksDispatcherOnUpdateInDEV = {
readContext<T>(context: ReactContext<T>): T {
@ -4662,6 +4841,18 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableSwipeTransition) {
(HooksDispatcherOnUpdateInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
updateHookTypesDev();
return updateSwipeTransition(previous, current, next);
};
}
HooksDispatcherOnRerenderInDEV = {
readContext<T>(context: ReactContext<T>): T {
@ -4845,6 +5036,18 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableSwipeTransition) {
(HooksDispatcherOnRerenderInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
updateHookTypesDev();
return updateSwipeTransition(previous, current, next);
};
}
InvalidNestedHooksDispatcherOnMountInDEV = {
readContext<T>(context: ReactContext<T>): T {
@ -5053,6 +5256,19 @@ if (__DEV__) {
return mountEvent(callback);
};
}
if (enableSwipeTransition) {
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
warnInvalidHookAccess();
mountHookTypesDev();
return mountSwipeTransition(previous, current, next);
};
}
InvalidNestedHooksDispatcherOnUpdateInDEV = {
readContext<T>(context: ReactContext<T>): T {
@ -5261,6 +5477,19 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableSwipeTransition) {
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
warnInvalidHookAccess();
updateHookTypesDev();
return updateSwipeTransition(previous, current, next);
};
}
InvalidNestedHooksDispatcherOnRerenderInDEV = {
readContext<T>(context: ReactContext<T>): T {
@ -5469,4 +5698,17 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableSwipeTransition) {
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useSwipeTransition =
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
currentHookNameInDev = 'useSwipeTransition';
warnInvalidHookAccess();
updateHookTypesDev();
return updateSwipeTransition(previous, current, next);
};
}
}

View File

@ -54,23 +54,24 @@ export const DefaultLane: Lane = /* */ 0b0000000000000000000
export const SyncUpdateLanes: Lane =
SyncLane | InputContinuousLane | DefaultLane;
const TransitionHydrationLane: Lane = /* */ 0b0000000000000000000000001000000;
const TransitionLanes: Lanes = /* */ 0b0000000001111111111111110000000;
const TransitionLane1: Lane = /* */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /* */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /* */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /* */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /* */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /* */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /* */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /* */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /* */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /* */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /* */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /* */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /* */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /* */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /* */ 0b0000000001000000000000000000000;
export const GestureLane: Lane = /* */ 0b0000000000000000000000001000000;
const TransitionHydrationLane: Lane = /* */ 0b0000000000000000000000010000000;
const TransitionLanes: Lanes = /* */ 0b0000000001111111111111100000000;
const TransitionLane1: Lane = /* */ 0b0000000000000000000000100000000;
const TransitionLane2: Lane = /* */ 0b0000000000000000000001000000000;
const TransitionLane3: Lane = /* */ 0b0000000000000000000010000000000;
const TransitionLane4: Lane = /* */ 0b0000000000000000000100000000000;
const TransitionLane5: Lane = /* */ 0b0000000000000000001000000000000;
const TransitionLane6: Lane = /* */ 0b0000000000000000010000000000000;
const TransitionLane7: Lane = /* */ 0b0000000000000000100000000000000;
const TransitionLane8: Lane = /* */ 0b0000000000000001000000000000000;
const TransitionLane9: Lane = /* */ 0b0000000000000010000000000000000;
const TransitionLane10: Lane = /* */ 0b0000000000000100000000000000000;
const TransitionLane11: Lane = /* */ 0b0000000000001000000000000000000;
const TransitionLane12: Lane = /* */ 0b0000000000010000000000000000000;
const TransitionLane13: Lane = /* */ 0b0000000000100000000000000000000;
const TransitionLane14: Lane = /* */ 0b0000000001000000000000000000000;
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000;
@ -175,6 +176,8 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
return DefaultHydrationLane;
case DefaultLane:
return DefaultLane;
case GestureLane:
return GestureLane;
case TransitionHydrationLane:
return TransitionHydrationLane;
case TransitionLane1:
@ -191,7 +194,6 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
case TransitionLane12:
case TransitionLane13:
case TransitionLane14:
case TransitionLane15:
return lanes & TransitionLanes;
case RetryLane1:
case RetryLane2:
@ -459,6 +461,7 @@ function computeExpirationTime(lane: Lane, currentTime: number) {
case SyncLane:
case InputContinuousHydrationLane:
case InputContinuousLane:
case GestureLane:
// User interactions should expire slightly more quickly.
//
// NOTE: This is set to the corresponding constant as in Scheduler.js.
@ -486,7 +489,6 @@ function computeExpirationTime(lane: Lane, currentTime: number) {
case TransitionLane12:
case TransitionLane13:
case TransitionLane14:
case TransitionLane15:
return currentTime + transitionLaneExpirationMs;
case RetryLane1:
case RetryLane2:
@ -640,7 +642,8 @@ export function includesBlockingLane(lanes: Lanes): boolean {
InputContinuousHydrationLane |
InputContinuousLane |
DefaultHydrationLane |
DefaultLane;
DefaultLane |
GestureLane;
return (lanes & SyncDefaultLanes) !== NoLanes;
}
@ -663,6 +666,11 @@ export function isTransitionLane(lane: Lane): boolean {
return (lane & TransitionLanes) !== NoLanes;
}
export function isGestureRender(lanes: Lanes): boolean {
// This should render only the one lane.
return lanes === GestureLane;
}
export function claimNextTransitionLane(): Lane {
// Cycle through the lanes, assigning each new transition to the next lane.
// In most cases, this means every transition gets its own lane, until we
@ -1053,7 +1061,6 @@ export function getBumpedLaneForHydrationByLane(lane: Lane): Lane {
case TransitionLane12:
case TransitionLane13:
case TransitionLane14:
case TransitionLane15:
case RetryLane1:
case RetryLane2:
case RetryLane3:
@ -1197,7 +1204,8 @@ export function getGroupNameOfHighestPriorityLane(lanes: Lanes): string {
InputContinuousHydrationLane |
InputContinuousLane |
DefaultHydrationLane |
DefaultLane)
DefaultLane |
GestureLane)
) {
return 'Blocking';
}

View File

@ -33,6 +33,7 @@ import {
enableUpdaterTracking,
enableTransitionTracing,
disableLegacyMode,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import {initializeUpdateQueue} from './ReactFiberClassUpdateQueue';
import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
@ -97,6 +98,10 @@ function FiberRootNode(
this.formState = formState;
if (enableSwipeTransition) {
this.gestures = null;
}
this.incompleteTransitions = new Map();
if (enableTransitionTracing) {
this.transitionCallbacks = null;

View File

@ -20,6 +20,7 @@ import {
enableComponentPerformanceTrack,
enableSiblingPrerendering,
enableYieldingBeforePassive,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import {
NoLane,
@ -32,6 +33,7 @@ import {
claimNextTransitionLane,
getNextLanesToFlushSync,
checkIfRootIsPrerendering,
isGestureRender,
} from './ReactFiberLane';
import {
CommitContext,
@ -211,7 +213,8 @@ function flushSyncWorkAcrossRoots_impl(
rootHasPendingCommit,
);
if (
includesSyncLane(nextLanes) &&
(includesSyncLane(nextLanes) ||
(enableSwipeTransition && isGestureRender(nextLanes))) &&
!checkIfRootIsPrerendering(root, nextLanes)
) {
// This root has pending sync work. Flush it now.
@ -296,7 +299,8 @@ function processRootScheduleInMicrotask() {
syncTransitionLanes !== NoLanes ||
// Common case: we're not treating any extra lanes as synchronous, so we
// can just check if the next lanes are sync.
includesSyncLane(nextLanes)
includesSyncLane(nextLanes) ||
(enableSwipeTransition && isGestureRender(nextLanes))
) {
mightHavePendingSyncWork = true;
}

View File

@ -47,6 +47,7 @@ import {
enableYieldingBeforePassive,
enableThrottledScheduling,
enableViewTransition,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import is from 'shared/objectIs';
@ -184,6 +185,8 @@ import {
claimNextTransitionLane,
checkIfRootIsPrerendering,
includesOnlyViewTransitionEligibleLanes,
isGestureRender,
GestureLane,
} from './ReactFiberLane';
import {
DiscreteEventPriority,
@ -338,6 +341,7 @@ import {
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
import {peekEntangledActionLane} from './ReactFiberAsyncAction';
import {logUncaughtError} from './ReactFiberErrorLogger';
import {deleteScheduledGesture} from './ReactFiberGestureScheduler';
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@ -3287,6 +3291,13 @@ function commitRoot(
const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes();
remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes);
if (enableSwipeTransition && root.gestures === 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.
remainingLanes &= ~GestureLane;
}
markRootFinished(
root,
lanes,
@ -3310,6 +3321,21 @@ function commitRoot(
// times out.
}
if (enableSwipeTransition && isGestureRender(lanes)) {
// This is a special kind of render that doesn't commit regular effects.
commitGestureOnRoot(
root,
finishedWork,
recoverableErrors,
enableProfilerTimer
? suspendedCommitReason === IMMEDIATE_COMMIT
? completedRenderEndTime
: commitStartTime
: 0,
);
return;
}
// workInProgressX might be overwritten, so we want
// to store it in pendingPassiveX until they get processed
// We need to pass this through as an argument to commitRoot
@ -3802,6 +3828,24 @@ function flushSpawnedWork(): void {
}
}
function commitGestureOnRoot(
root: FiberRoot,
finishedWork: null | Fiber,
recoverableErrors: null | Array<CapturedValue<mixed>>,
renderEndTime: number, // Profiling-only
): void {
// We assume that the gesture we just rendered was the first one in the queue.
const finishedGesture = root.gestures;
if (finishedGesture === null) {
throw new Error(
'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.',
);
}
deleteScheduledGesture(root, finishedGesture);
// TODO: Run the gesture
}
function makeErrorInfo(componentStack: ?string) {
const errorInfo = {
componentStack,

View File

@ -17,6 +17,7 @@ import type {
Awaited,
ReactComponentInfo,
ReactDebugInfo,
StartGesture,
} from 'shared/ReactTypes';
import type {WorkTag} from './ReactWorkTags';
import type {TypeOfMode} from './ReactTypeOfMode';
@ -38,6 +39,7 @@ import type {
import type {ConcurrentUpdate} from './ReactFiberConcurrentUpdates';
import type {ComponentStackNode} from 'react-server/src/ReactFizzComponentStack';
import type {ThenableState} from './ReactFiberThenable';
import type {ScheduledGesture} from './ReactFiberGestureScheduler';
// Unwind Circular: moved from ReactFiberHooks.old
export type HookType =
@ -60,7 +62,8 @@ export type HookType =
| 'useCacheRefresh'
| 'useOptimistic'
| 'useFormState'
| 'useActionState';
| 'useActionState'
| 'useSwipeTransition';
export type ContextDependency<T> = {
context: ReactContext<T>,
@ -279,6 +282,9 @@ type BaseFiberRootProperties = {
) => void,
formState: ReactFormState<any, any> | null,
// enableSwipeTransition only
gestures: null | ScheduledGesture,
};
// The following attributes are only used by DevTools and are only present in DEV builds.
@ -442,6 +448,12 @@ export type Dispatcher = {
initialState: Awaited<S>,
permalink?: string,
) => [Awaited<S>, (P) => void, boolean],
// TODO: Non-nullable once `enableSwipeTransition` is on everywhere.
useSwipeTransition?: <T>(
previous: T,
current: T,
next: T,
) => [T, StartGesture],
};
export type AsyncDispatcher = {

View File

@ -16,6 +16,7 @@ import type {
Usable,
ReactCustomFormAction,
Awaited,
StartGesture,
} from 'shared/ReactTypes';
import type {ResumableState} from './ReactFizzConfig';
@ -38,7 +39,10 @@ import {
} from './ReactFizzConfig';
import {createFastHash} from './ReactServerStreamConfig';
import {enableUseEffectEventHook} from 'shared/ReactFeatureFlags';
import {
enableUseEffectEventHook,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
import is from 'shared/objectIs';
import {
REACT_CONTEXT_TYPE,
@ -795,6 +799,19 @@ function useMemoCache(size: number): Array<mixed> {
return data;
}
function unsupportedStartGesture() {
throw new Error('startGesture cannot be called during server rendering.');
}
function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
resolveCurrentlyRenderingComponent();
return [current, unsupportedStartGesture];
}
function noop(): void {}
function clientHookNotSupported() {
@ -837,25 +854,25 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs
: {
readContext,
use,
useCallback,
useContext,
useEffect: clientHookNotSupported,
useImperativeHandle: clientHookNotSupported,
useInsertionEffect: clientHookNotSupported,
useLayoutEffect: clientHookNotSupported,
useMemo,
useReducer: clientHookNotSupported,
useRef: clientHookNotSupported,
useState: clientHookNotSupported,
useInsertionEffect: clientHookNotSupported,
useLayoutEffect: clientHookNotSupported,
useCallback,
useImperativeHandle: clientHookNotSupported,
useEffect: clientHookNotSupported,
useDebugValue: noop,
useDeferredValue: clientHookNotSupported,
useTransition: clientHookNotSupported,
useId,
useSyncExternalStore: clientHookNotSupported,
useOptimistic,
useActionState,
useFormState: useActionState,
useId,
useHostTransitionStatus,
useFormState: useActionState,
useActionState,
useOptimistic,
useMemoCache,
useCacheRefresh,
};
@ -863,6 +880,11 @@ export const HooksDispatcher: Dispatcher = supportsClientAPIs
if (enableUseEffectEventHook) {
HooksDispatcher.useEffectEvent = useEffectEvent;
}
if (enableSwipeTransition) {
HooksDispatcher.useSwipeTransition = supportsClientAPIs
? useSwipeTransition
: clientHookNotSupported;
}
export let currentResumableState: null | ResumableState = (null: any);
export function setCurrentResumableState(

View File

@ -17,6 +17,10 @@ import {
} from 'shared/ReactSymbols';
import {createThenableState, trackUsedThenable} from './ReactFlightThenable';
import {isClientReference} from './ReactFlightServerConfig';
import {
enableUseEffectEventHook,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
let currentRequest = null;
let thenableIndexCounter = 0;
@ -58,33 +62,32 @@ export function getThenableStateAfterSuspending(): ThenableState {
}
export const HooksDispatcher: Dispatcher = {
useMemo<T>(nextCreate: () => T): T {
return nextCreate();
},
readContext: (unsupportedContext: any),
use,
useCallback<T>(callback: T): T {
return callback;
},
useDebugValue(): void {},
useDeferredValue: (unsupportedHook: any),
useTransition: (unsupportedHook: any),
readContext: (unsupportedContext: any),
useContext: (unsupportedContext: any),
useEffect: (unsupportedHook: any),
useImperativeHandle: (unsupportedHook: any),
useLayoutEffect: (unsupportedHook: any),
useInsertionEffect: (unsupportedHook: any),
useMemo<T>(nextCreate: () => T): T {
return nextCreate();
},
useReducer: (unsupportedHook: any),
useRef: (unsupportedHook: any),
useState: (unsupportedHook: any),
useInsertionEffect: (unsupportedHook: any),
useLayoutEffect: (unsupportedHook: any),
useImperativeHandle: (unsupportedHook: any),
useEffect: (unsupportedHook: any),
useDebugValue(): void {},
useDeferredValue: (unsupportedHook: any),
useTransition: (unsupportedHook: any),
useSyncExternalStore: (unsupportedHook: any),
useId,
useHostTransitionStatus: (unsupportedHook: any),
useOptimistic: (unsupportedHook: any),
useFormState: (unsupportedHook: any),
useActionState: (unsupportedHook: any),
useSyncExternalStore: (unsupportedHook: any),
useCacheRefresh(): <T>(?() => T, ?T) => void {
return unsupportedRefresh;
},
useOptimistic: (unsupportedHook: any),
useMemoCache(size: number): Array<any> {
const data = new Array<any>(size);
for (let i = 0; i < size; i++) {
@ -92,8 +95,16 @@ export const HooksDispatcher: Dispatcher = {
}
return data;
},
use,
useCacheRefresh(): <T>(?() => T, ?T) => void {
return unsupportedRefresh;
},
};
if (enableUseEffectEventHook) {
HooksDispatcher.useEffectEvent = (unsupportedHook: any);
}
if (enableSwipeTransition) {
HooksDispatcher.useSwipeTransition = (unsupportedHook: any);
}
function unsupportedHook(): void {
throw new Error('This Hook is not supported in Server Components.');

View File

@ -33,6 +33,7 @@ export {
unstable_getCacheForType,
unstable_SuspenseList,
unstable_ViewTransition,
unstable_useSwipeTransition,
unstable_addTransitionType,
unstable_useCacheRefresh,
useId,

View File

@ -33,6 +33,7 @@ export {
unstable_getCacheForType,
unstable_SuspenseList,
unstable_ViewTransition,
unstable_useSwipeTransition,
unstable_addTransitionType,
unstable_useCacheRefresh,
useId,

View File

@ -57,6 +57,7 @@ import {
use,
useOptimistic,
useActionState,
useSwipeTransition,
} from './ReactHooks';
import ReactSharedInternals from './ReactSharedInternalsClient';
import {startTransition} from './ReactStartTransition';
@ -126,7 +127,10 @@ export {
// enableViewTransition
REACT_VIEW_TRANSITION_TYPE as unstable_ViewTransition,
addTransitionType as unstable_addTransitionType,
// enableSwipeTransition
useSwipeTransition as unstable_useSwipeTransition,
// DEV-only
useId,
act, // DEV-only
captureOwnerStack, // DEV-only
act,
captureOwnerStack,
};

View File

@ -13,12 +13,16 @@ import type {
StartTransitionOptions,
Usable,
Awaited,
StartGesture,
} from 'shared/ReactTypes';
import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {enableUseEffectCRUDOverload} from 'shared/ReactFeatureFlags';
import {
enableUseEffectCRUDOverload,
enableSwipeTransition,
} from 'shared/ReactFeatureFlags';
type BasicStateAction<S> = (S => S) | S;
type Dispatch<A> = A => void;
@ -261,3 +265,16 @@ export function useActionState<S, P>(
const dispatcher = resolveDispatcher();
return dispatcher.useActionState(action, initialState, permalink);
}
export function useSwipeTransition<T>(
previous: T,
current: T,
next: T,
): [T, StartGesture] {
if (!enableSwipeTransition) {
throw new Error('Not implemented.');
}
const dispatcher = resolveDispatcher();
// $FlowFixMe[not-a-function] This is unstable, thus optional
return dispatcher.useSwipeTransition(previous, current, next);
}

View File

@ -92,6 +92,8 @@ export const enableHalt = __EXPERIMENTAL__;
export const enableViewTransition = __EXPERIMENTAL__;
export const enableSwipeTransition = __EXPERIMENTAL__;
/**
* Switches Fiber creation to a simple object instead of a constructor.
*/

View File

@ -168,6 +168,12 @@ export type ReactFormState<S, ReferenceId> = [
number /* number of bound arguments */,
];
// Intrinsic GestureProvider. This type varies by Environment whether a particular
// renderer supports it.
export type GestureProvider = AnimationTimeline; // TODO: More provider types.
export type StartGesture = (gestureProvider: GestureProvider) => () => void;
export type Awaited<T> = T extends null | void
? T // special case for `null | undefined` when not in `--strictNullChecks` mode
: T extends Object // `await` only unwraps object types with a callable then. Non-object types are not unwrapped.

View File

@ -82,6 +82,7 @@ export const enableHydrationLaneScheduling = true;
export const enableYieldingBeforePassive = false;
export const enableThrottledScheduling = false;
export const enableViewTransition = false;
export const enableSwipeTransition = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

View File

@ -71,6 +71,7 @@ export const enableYieldingBeforePassive = false;
export const enableThrottledScheduling = false;
export const enableViewTransition = false;
export const enableSwipeTransition = false;
export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;

View File

@ -70,6 +70,7 @@ export const enableYieldingBeforePassive = true;
export const enableThrottledScheduling = false;
export const enableViewTransition = false;
export const enableSwipeTransition = false;
export const enableFastAddPropertiesInDiffing = true;
export const enableLazyPublicInstanceInFabric = false;

View File

@ -67,6 +67,7 @@ export const enableHydrationLaneScheduling = true;
export const enableYieldingBeforePassive = false;
export const enableThrottledScheduling = false;
export const enableViewTransition = false;
export const enableSwipeTransition = false;
export const enableRemoveConsolePatches = false;
export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;

View File

@ -82,6 +82,7 @@ export const enableYieldingBeforePassive = false;
export const enableThrottledScheduling = false;
export const enableViewTransition = false;
export const enableSwipeTransition = false;
export const enableRemoveConsolePatches = false;
export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;

View File

@ -112,5 +112,7 @@ export const enableShallowPropDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableSwipeTransition = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

View File

@ -531,5 +531,7 @@
"543": "Expected a ResourceEffectUpdate to be pushed together with ResourceEffectIdentity. This is a bug in React.",
"544": "Found a pair with an auto name. This is a bug in React.",
"545": "The %s tag may only be rendered once.",
"546": "useEffect CRUD overload is not enabled in this build of React."
"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."
}