diff --git a/fixtures/view-transition/src/components/Page.css b/fixtures/view-transition/src/components/Page.css index 5afcc33a8f..a5a9373194 100644 --- a/fixtures/view-transition/src/components/Page.css +++ b/fixtures/view-transition/src/components/Page.css @@ -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%; +} diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 40f6032772..1c7a9bfeb3 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -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 = ( ! @@ -90,6 +120,13 @@ export default function Page({url, navigate}) {

+
+
Swipe me
+

{show ? null : ( diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 114080d03e..798cdec438 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -14,6 +14,7 @@ import type { Usable, Thenable, ReactDebugInfo, + StartGesture, } from 'shared/ReactTypes'; import type { ContextDependency, @@ -131,6 +132,9 @@ function getPrimitiveStackCache(): Map> { 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) => mixed>(callback: F): F { return callback; } +function useSwipeTransition( + 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 diff --git a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js index 5413db10f6..859e553ccb 100644 --- a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js +++ b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.js @@ -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 { diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js new file mode 100644 index 0000000000..7664316b67 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -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; + } +} diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 2ab41770bd..06e00441c3 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -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 = { lane: Lane, revertLane: Lane, @@ -3960,6 +3972,133 @@ function markUpdateInDevTools(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( + 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( + 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( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + mountHookTypesDev(); + return mountSwipeTransition(previous, current, next); + }; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -4479,6 +4646,18 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableSwipeTransition) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useSwipeTransition = + function useSwipeTransition( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + updateHookTypesDev(); + return updateSwipeTransition(previous, current, next); + }; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4662,6 +4841,18 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableSwipeTransition) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useSwipeTransition = + function useSwipeTransition( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + updateHookTypesDev(); + return updateSwipeTransition(previous, current, next); + }; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4845,6 +5036,18 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableSwipeTransition) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useSwipeTransition = + function useSwipeTransition( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + updateHookTypesDev(); + return updateSwipeTransition(previous, current, next); + }; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -5053,6 +5256,19 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableSwipeTransition) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useSwipeTransition = + function useSwipeTransition( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountSwipeTransition(previous, current, next); + }; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -5261,6 +5477,19 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableSwipeTransition) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useSwipeTransition = + function useSwipeTransition( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateSwipeTransition(previous, current, next); + }; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -5469,4 +5698,17 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableSwipeTransition) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useSwipeTransition = + function useSwipeTransition( + previous: T, + current: T, + next: T, + ): [T, StartGesture] { + currentHookNameInDev = 'useSwipeTransition'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateSwipeTransition(previous, current, next); + }; + } } diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 922fe2f977..22bf99624d 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -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'; } diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 4971bb4c2b..03ddde7a5a 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberRootScheduler.js b/packages/react-reconciler/src/ReactFiberRootScheduler.js index 41daa6b806..293992e406 100644 --- a/packages/react-reconciler/src/ReactFiberRootScheduler.js +++ b/packages/react-reconciler/src/ReactFiberRootScheduler.js @@ -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; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 5f17ca31b3..b3d1e5371c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -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>, + 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, diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 98e7d4deef..3479eba1b9 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -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 = { context: ReactContext, @@ -279,6 +282,9 @@ type BaseFiberRootProperties = { ) => void, formState: ReactFormState | 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, permalink?: string, ) => [Awaited, (P) => void, boolean], + // TODO: Non-nullable once `enableSwipeTransition` is on everywhere. + useSwipeTransition?: ( + previous: T, + current: T, + next: T, + ) => [T, StartGesture], }; export type AsyncDispatcher = { diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index a0ec1c7414..8ae00568ae 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -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 { return data; } +function unsupportedStartGesture() { + throw new Error('startGesture cannot be called during server rendering.'); +} + +function useSwipeTransition( + 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( diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index d0351e38c8..f1d31f3e48 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -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(nextCreate: () => T): T { - return nextCreate(); - }, + readContext: (unsupportedContext: any), + + use, useCallback(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(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) => void { - return unsupportedRefresh; - }, + useOptimistic: (unsupportedHook: any), useMemoCache(size: number): Array { const data = new Array(size); for (let i = 0; i < size; i++) { @@ -92,8 +95,16 @@ export const HooksDispatcher: Dispatcher = { } return data; }, - use, + useCacheRefresh(): (?() => 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.'); diff --git a/packages/react/index.experimental.development.js b/packages/react/index.experimental.development.js index 6074b683b7..e8ebc23613 100644 --- a/packages/react/index.experimental.development.js +++ b/packages/react/index.experimental.development.js @@ -33,6 +33,7 @@ export { unstable_getCacheForType, unstable_SuspenseList, unstable_ViewTransition, + unstable_useSwipeTransition, unstable_addTransitionType, unstable_useCacheRefresh, useId, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index 77cf6bd0e0..a3c6f4a10f 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -33,6 +33,7 @@ export { unstable_getCacheForType, unstable_SuspenseList, unstable_ViewTransition, + unstable_useSwipeTransition, unstable_addTransitionType, unstable_useCacheRefresh, useId, diff --git a/packages/react/src/ReactClient.js b/packages/react/src/ReactClient.js index 715ea8ab47..33175a9a77 100644 --- a/packages/react/src/ReactClient.js +++ b/packages/react/src/ReactClient.js @@ -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, }; diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index ba6b0ffbcb..605337480f 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -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; type Dispatch = A => void; @@ -261,3 +265,16 @@ export function useActionState( const dispatcher = resolveDispatcher(); return dispatcher.useActionState(action, initialState, permalink); } + +export function useSwipeTransition( + 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); +} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 844b2a2f6a..ea09d0a3c7 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -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. */ diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 735f96a36f..0402f9aac1 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -168,6 +168,12 @@ export type ReactFormState = [ 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 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. diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 2eff113ae4..470e148959 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -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); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index bcd8b375ec..361ebde413 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index ef3c300602..5945416050 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 743ba39a86..18172fdfe5 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 26747d3fb6..628a834133 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -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; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 8da74c54bd..5932c6eddf 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -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); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 6ab654f1d3..f001b660bd 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -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." }