diff --git a/.eslintrc.js b/.eslintrc.js index 360bd5ba76..ae108a37e3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -614,6 +614,7 @@ module.exports = { KeyframeAnimationOptions: 'readonly', GetAnimationsOptions: 'readonly', Animatable: 'readonly', + ScrollTimeline: 'readonly', spyOnDev: 'readonly', spyOnDevAndProd: 'readonly', diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 1c7a9bfeb3..589d09a79a 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -68,10 +68,12 @@ export default function Page({url, navigate}) { activeGesture.current = null; cancelGesture(); } + // Reset scroll + swipeRecognizer.current.scrollLeft = !show ? 0 : 10000; } useLayoutEffect(() => { - swipeRecognizer.current.scrollLeft = show ? 0 : 10000; + swipeRecognizer.current.scrollLeft = !show ? 0 : 10000; }, [show]); const exclamation = ( diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index 95acc9e541..18cc84b18e 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -508,6 +508,15 @@ export function createViewTransitionInstance( return null; } +export type GestureTimeline = null; + +export function subscribeToGestureDirection( + provider: GestureTimeline, + directionCallback: (direction: boolean) => void, +): () => void { + throw new Error('useSwipeTransition is not yet supported in react-art.'); +} + export function clearContainer(container) { // TODO Implement this } diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index e96810122d..626231a1fa 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -1478,6 +1478,60 @@ export function createViewTransitionInstance( }; } +export type GestureTimeline = AnimationTimeline; // TODO: More provider types. + +export function subscribeToGestureDirection( + provider: GestureTimeline, + directionCallback: (direction: boolean) => void, +): () => void { + const time = provider.currentTime; + if (time === null) { + throw new Error( + 'Cannot start a gesture with a disconnected AnimationTimeline.', + ); + } + const startTime = typeof time === 'number' ? time : time.value; + if ( + typeof ScrollTimeline === 'function' && + provider instanceof ScrollTimeline + ) { + // For ScrollTimeline we optimize to only update the current time on scroll events. + const element = provider.source; + const scrollCallback = () => { + const newTime = provider.currentTime; + if (newTime !== null) { + directionCallback( + typeof newTime === 'number' + ? newTime > startTime + : newTime.value > startTime, + ); + } + }; + element.addEventListener('scroll', scrollCallback, false); + return () => { + element.removeEventListener('scroll', scrollCallback, false); + }; + } else { + // For other AnimationTimelines, such as DocumentTimeline, we just update every rAF. + // TODO: Optimize ViewTimeline using an IntersectionObserver if it becomes common. + const rafCallback = () => { + const newTime = provider.currentTime; + if (newTime !== null) { + directionCallback( + typeof newTime === 'number' + ? newTime > startTime + : newTime.value > startTime, + ); + } + callbackID = requestAnimationFrame(rafCallback); + }; + let callbackID = requestAnimationFrame(rafCallback); + return () => { + cancelAnimationFrame(callbackID); + }; + } +} + export function clearContainer(container: Container): void { const nodeType = container.nodeType; if (nodeType === DOCUMENT_NODE) { diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index b565e89e9c..7929ed7bd8 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -605,6 +605,15 @@ export function createViewTransitionInstance( return null; } +export type GestureTimeline = null; + +export function subscribeToGestureDirection( + provider: GestureTimeline, + directionCallback: (direction: boolean) => void, +): () => void { + throw new Error('useSwipeTransition is not yet supported in React Native.'); +} + export function clearContainer(container: Container): void { // TODO Implement this for React Native // UIManager does not expose a "remove all" type method. diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index f412f90f6c..985c715bd4 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -95,6 +95,8 @@ export type FormInstance = Instance; export type ViewTransitionInstance = null | {name: string, ...}; +export type GestureTimeline = null; + const NO_CONTEXT = {}; const UPPERCASE_CONTEXT = {}; if (__DEV__) { @@ -794,6 +796,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return null; }, + subscribeToGestureDirection( + provider: GestureTimeline, + directionCallback: (direction: boolean) => void, + ): () => void { + return () => {}; + }, + resetTextContent(instance: Instance): void { instance.text = null; }, diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js index 5e54757807..26231df8bf 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js @@ -48,3 +48,5 @@ export const hasInstanceAffectedParent = shim; export const startViewTransition = shim; export type ViewTransitionInstance = null | {name: string, ...}; export const createViewTransitionInstance = shim; +export type GestureTimeline = any; +export const subscribeToGestureDirection = shim; diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index 7664316b67..acbc6ab765 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -8,22 +8,26 @@ */ import type {FiberRoot} from './ReactInternalTypes'; -import type {GestureProvider} from 'shared/ReactTypes'; +import type {GestureTimeline} from './ReactFiberConfig'; import {GestureLane} from './ReactFiberLane'; import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; +import {subscribeToGestureDirection} from './ReactFiberConfig'; // This type keeps track of any scheduled or active gestures. export type ScheduledGesture = { - provider: GestureProvider, + provider: GestureTimeline, count: number, // The number of times this same provider has been started. + direction: boolean, // false = previous, true = next + cancel: () => void, // Cancel the subscription to direction change. prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root. }; export function scheduleGesture( root: FiberRoot, - provider: GestureProvider, + provider: GestureTimeline, + initialDirection: boolean, ): ScheduledGesture { let prev = root.gestures; while (prev !== null) { @@ -39,9 +43,32 @@ export function scheduleGesture( prev = next; } // Add new instance to the end of the queue. + const cancel = subscribeToGestureDirection(provider, (direction: boolean) => { + if (gesture.direction !== direction) { + gesture.direction = direction; + if (gesture.prev === null && root.gestures !== gesture) { + // This gesture is not in the schedule, meaning it was already rendered. + // We need to rerender in the new direction. Insert it into the first slot + // in case other gestures are queued after the on-going one. + const existing = root.gestures; + gesture.next = existing; + if (existing !== null) { + existing.prev = gesture; + } + root.gestures = gesture; + // Schedule the lane on the root. The Fibers will already be marked as + // long as the gesture is active on that Hook. + root.pendingLanes |= GestureLane; + ensureRootIsScheduled(root); + } + // TODO: If we're currently rendering this gesture, we need to restart it. + } + }); const gesture: ScheduledGesture = { provider: provider, count: 1, + direction: initialDirection, + cancel: cancel, prev: prev, next: null, }; @@ -60,8 +87,12 @@ export function cancelScheduledGesture( ): void { gesture.count--; if (gesture.count === 0) { + const cancelDirectionSubscription = gesture.cancel; + cancelDirectionSubscription(); // Delete the scheduled gesture from the queue. deleteScheduledGesture(root, gesture); + // TODO: If we're currently rendering this gesture, we need to restart the render + // on a different gesture or cancel the render.. } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 06e00441c3..16ceb16768 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -27,7 +27,7 @@ import type { import type {Lanes, Lane} from './ReactFiberLane'; import type {HookFlags} from './ReactHookEffectTags'; import type {Flags} from './ReactFiberFlags'; -import type {TransitionStatus} from './ReactFiberConfig'; +import type {TransitionStatus, GestureTimeline} from './ReactFiberConfig'; import type {ScheduledGesture} from './ReactFiberGestureScheduler'; import { @@ -3981,6 +3981,7 @@ type SwipeTransitionGestureUpdate = { type SwipeTransitionUpdateQueue = { pending: null | SwipeTransitionGestureUpdate, dispatch: StartGesture, + initialDirection: boolean, }; function startGesture( @@ -3996,9 +3997,14 @@ function startGesture( // Noop. }; } - const scheduledGesture = scheduleGesture(root, gestureProvider); + const gestureTimeline: GestureTimeline = gestureProvider; + const scheduledGesture = scheduleGesture( + root, + gestureTimeline, + queue.initialDirection, + ); // Add this particular instance to the queue. - // We add multiple of the same provider even if they get batched so + // We add multiple of the same timeline even if they get batched so // that if we cancel one but not the other we can keep track of this. // Order doesn't matter but we insert in the beginning to avoid two fields. const update: SwipeTransitionGestureUpdate = { @@ -4041,6 +4047,7 @@ function mountSwipeTransition( const queue: SwipeTransitionUpdateQueue = { pending: null, dispatch: (null: any), + initialDirection: previous === current, }; const startGestureOnHook: StartGesture = (queue.dispatch = (startGesture.bind( null, @@ -4062,31 +4069,34 @@ function updateSwipeTransition( 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) { + 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; + if (rootRenderGesture !== null) { + let update = queue.pending; + while (update !== null) { + if (rootRenderGesture === update.gesture) { + // We had a match, meaning we're currently rendering a direction of this + // hook for this gesture. + value = rootRenderGesture.direction ? next : previous; + break; + } + update = update.next; + } + } + // This lane cannot be cleared as long as we have active gestures. + markWorkInProgressReceivedUpdate(); + } // 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 @@ -4096,6 +4106,11 @@ function updateSwipeTransition( GestureLane, ); } + // By default, we don't know which direction we should start until a movement + // has happened. However, if one direction has the same value as current we + // know that it's probably not that direction since it won't do anything anyway. + // TODO: Add an explicit option to provide this. + queue.initialDirection = previous === current; return [value, startGestureOnHook]; } diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index ee5f40ad82..3a5cccc6d3 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -43,6 +43,7 @@ export opaque type FormInstance = mixed; export type ViewTransitionInstance = null | {name: string, ...}; export opaque type InstanceMeasurement = mixed; export type EventResponder = any; +export type GestureTimeline = any; export const rendererVersion = $$$config.rendererVersion; export const rendererPackageName = $$$config.rendererPackageName; @@ -144,6 +145,8 @@ export const wasInstanceInViewport = $$$config.wasInstanceInViewport; export const hasInstanceChanged = $$$config.hasInstanceChanged; export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent; export const startViewTransition = $$$config.startViewTransition; +export const subscribeToGestureDirection = + $$$config.subscribeToGestureDirection; export const createViewTransitionInstance = $$$config.createViewTransitionInstance; export const clearContainer = $$$config.clearContainer; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index aa8b34523b..922155f35b 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -391,6 +391,15 @@ export function getInstanceFromNode(mockNode: Object): Object | null { return null; } +export type GestureTimeline = null; + +export function subscribeToGestureDirection( + provider: GestureTimeline, + directionCallback: (direction: boolean) => void, +): () => void { + return () => {}; +} + export function beforeActiveInstanceBlur(internalInstanceHandle: Object) { // noop } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 0402f9aac1..fbf4e9b06f 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -170,7 +170,7 @@ export type ReactFormState = [ // Intrinsic GestureProvider. This type varies by Environment whether a particular // renderer supports it. -export type GestureProvider = AnimationTimeline; // TODO: More provider types. +export type GestureProvider = any; export type StartGesture = (gestureProvider: GestureProvider) => () => void; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index f001b660bd..a55d7505ce 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -533,5 +533,8 @@ "545": "The %s tag may only be rendered once.", "546": "useEffect CRUD overload is not enabled in this build of React.", "547": "startGesture cannot be called during server rendering.", - "548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React." + "548": "Finished rendering the gesture lane but there were no pending gestures. React should not have started a render in this case. This is a bug in React.", + "549": "Cannot start a gesture with a disconnected AnimationTimeline.", + "550": "useSwipeTransition is not yet supported in react-art.", + "551": "useSwipeTransition is not yet supported in React Native." } diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index a083223ba1..96784a8353 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -33,6 +33,18 @@ declare interface ConsoleTask { run(f: () => T): T; } +type ScrollTimelineOptions = { + source: Element, + axis?: 'block' | 'inline' | 'x' | 'y', + ... +}; + +declare class ScrollTimeline extends AnimationTimeline { + constructor(options?: ScrollTimelineOptions): void; + axis: 'block' | 'inline' | 'x' | 'y'; + source: Element; +} + // Flow hides the props of React$Element, this overrides it to unhide // them for React internals. // prettier-ignore diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index b974ecee0d..88d17772d7 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -34,6 +34,8 @@ module.exports = { FinalizationRegistry: 'readonly', + ScrollTimeline: 'readonly', + // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index 6efc8838f0..8e87c8dbe0 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -32,6 +32,7 @@ module.exports = { Reflect: 'readonly', globalThis: 'readonly', FinalizationRegistry: 'readonly', + ScrollTimeline: 'readonly', // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.esm.js b/scripts/rollup/validate/eslintrc.esm.js index 20b5341a82..8b4bba3579 100644 --- a/scripts/rollup/validate/eslintrc.esm.js +++ b/scripts/rollup/validate/eslintrc.esm.js @@ -34,6 +34,8 @@ module.exports = { FinalizationRegistry: 'readonly', + ScrollTimeline: 'readonly', + // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index 9606d00b35..f0602e79e5 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -34,6 +34,8 @@ module.exports = { FinalizationRegistry: 'readonly', + ScrollTimeline: 'readonly', + // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly', diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 941b1d1872..052edabdc0 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -34,6 +34,8 @@ module.exports = { FinalizationRegistry: 'readonly', + ScrollTimeline: 'readonly', + // Vendor specific MSApp: 'readonly', __REACT_DEVTOOLS_GLOBAL_HOOK__: 'readonly',