diff --git a/.changeset/use-attachment-hooks.md b/.changeset/use-attachment-hooks.md new file mode 100644 index 0000000000000..4172186774883 --- /dev/null +++ b/.changeset/use-attachment-hooks.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture hooks' attach-and-release lifecycle, `useAttachment`, moves to `hooks/` (not exported), where other hooks can use it, and `useFingerScroll` keeps its finger-scroll watch through it instead of its own copy of that lifecycle. The gesture-sync helpers stay in `gestures/gestureSync.ts`. No behavior changes. + +@vjeux diff --git a/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx b/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx index deff82ea60586..ea484425d0d02 100644 --- a/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx +++ b/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx @@ -6,13 +6,15 @@ * document's gesture host * @output A finger's scroll reads true until the scroller's scrollend; a * scroll with no finger down reads false; the page's own scroll through - * the document; the watch follows the ref's node and ends on unmount + * the document; the watch follows the ref's node and ends on unmount, and + * lets go of a node it stops following * @position Unit tests of packages/core/src/gestures/useFingerScroll.ts */ import {render} from '@testing-library/react'; import {useRef} from 'react'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {isFingerScroll} from '../fingerScroll'; import {getGestureHost} from '../GestureHost'; import {useFingerScroll} from '../useFingerScroll'; import {coarsePrimaryPointer} from './pointerQuery'; @@ -41,6 +43,34 @@ function List({other = false}: {other?: boolean}) { ); } +/** A scroller the test keeps in the page while the hook moves off it. */ +function scroller(): {element: HTMLElement; row: HTMLElement} { + const element = document.createElement('div'); + const row = document.createElement('p'); + element.append(row); + document.body.append(element); + return {element, row}; +} + +/** A finger scrolling `element` from `row`, left resting. */ +function fingerScroll(element: Element, row: Element): void { + touch('touchstart', row, true); + element.dispatchEvent(new Event('scroll')); +} + +/** The finger lifted and the scroll ended. */ +function lift(element: Element, row: Element): void { + touch('touchend', row, false); + element.dispatchEvent(new Event('scrollend')); +} + +const followed: {current: Element | null} = {current: null}; + +function Follower() { + read = useFingerScroll(followed); + return null; +} + function Page() { read = useFingerScroll(document); return

row

; @@ -119,4 +149,39 @@ describe('useFingerScroll', () => { unmount(); expect(reader()).toBe(false); }); + + it('lets go of the node it stops following, and of its last one at unmount', () => { + const first = scroller(); + const second = scroller(); + const host = getGestureHost(document); + try { + followed.current = first.element; + const {rerender, unmount} = render(); + fingerScroll(first.element, first.row); + expect(read()).toBe(true); + lift(first.element, first.row); + + followed.current = second.element; + rerender(); + // The first scroller is no longer watched: its finger scroll counts + // for nothing. + fingerScroll(first.element, first.row); + expect(isFingerScroll(host, first.element)).toBe(false); + lift(first.element, first.row); + fingerScroll(second.element, second.row); + expect(read()).toBe(true); + lift(second.element, second.row); + + const reader = read; + unmount(); + fingerScroll(second.element, second.row); + expect(isFingerScroll(host, second.element)).toBe(false); + expect(reader()).toBe(false); + lift(second.element, second.row); + } finally { + followed.current = null; + first.element.remove(); + second.element.remove(); + } + }); }); diff --git a/packages/core/src/gestures/useAttachment.ts b/packages/core/src/gestures/gestureSync.ts similarity index 52% rename from packages/core/src/gestures/useAttachment.ts rename to packages/core/src/gestures/gestureSync.ts index d467687efc348..4f1526685e22b 100644 --- a/packages/core/src/gestures/useAttachment.ts +++ b/packages/core/src/gestures/gestureSync.ts @@ -3,17 +3,17 @@ 'use client'; /** - * @file useAttachment.ts - * @input An element ref, an attach function and a key - * @output Exports useAttachment, useIsUnmountedRef, and the gesture-sync - * depth (syncingGestures, isSyncingGestures) both hooks read - * @position Internal React helpers shared by useGestureRecognizer and + * @file gestureSync.ts + * @input Nothing: module state and a React ref + * @output Exports useIsUnmountedRef, and the gesture-sync depth + * (syncingGestures, isSyncingGestures) the gesture hooks read + * @position Internal helpers shared by useGestureRecognizer and * usePressTracker, so each hook imports only what it uses * - * SYNC: When modified, update useGestureRecognizer.test.tsx. + * SYNC: When modified, update __tests__/useGestureRecognizer.test.tsx. */ -import {useRef, type RefObject} from 'react'; +import {useRef} from 'react'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; /** @@ -58,42 +58,3 @@ export function useIsUnmountedRef(): {readonly current: boolean} { }, []); return isUnmountedRef; } - -/** - * Keep `attach` bound to the element `ref` holds after each commit. A - * replaced node is released and the new one attached, a changed `key` - * re-attaches, and the last attachment is released on unmount; a commit that - * changes neither does nothing (react-component-runtime INV3). - */ -export function useAttachment( - ref: RefObject, - attach: (element: Element) => () => void, - key: unknown, -): void { - const attachmentRef = useRef<{ - element: Element; - key: unknown; - release: () => void; - } | null>(null); - - // Syncs the attachment with the DOM node `ref` holds after this commit. - useIsomorphicLayoutEffect(() => { - const element = ref.current; - const current = attachmentRef.current; - if (current?.element === element && current?.key === key) { - return; - } - current?.release(); - attachmentRef.current = - element == null ? null : {element, key, release: attach(element)}; - }); - - // Releases the attachment at unmount. - useIsomorphicLayoutEffect( - () => () => { - attachmentRef.current?.release(); - attachmentRef.current = null; - }, - [], - ); -} diff --git a/packages/core/src/gestures/useFingerScroll.ts b/packages/core/src/gestures/useFingerScroll.ts index 2ce09e2aa82e6..51224e7f89e3e 100644 --- a/packages/core/src/gestures/useFingerScroll.ts +++ b/packages/core/src/gestures/useFingerScroll.ts @@ -9,7 +9,9 @@ * @output useFingerScroll: whether the scroller's current scroll is a * finger's, read at any moment * @position Gesture hook of packages/core/src/gestures, over the - * finger-scroll watch (fingerScroll.ts). An internal piece of the touch + * finger-scroll watch (fingerScroll.ts), kept on the scroller's node by + * the gesture hooks' shared attachment (hooks/useAttachment.ts). An internal + * piece of the touch * module with no public API, not in the gestures barrel: a scroll-driven * component (a tab bar that minimizes, a header that collapses, a picker * that commits at rest) imports it from here, so it ships only where used @@ -28,14 +30,19 @@ */ import {useCallback, useRef, type RefObject} from 'react'; -import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {isFingerScroll, watchFingerScrolls} from './fingerScroll'; import {getGestureHost} from './GestureHost'; +import {useAttachment} from '../hooks/useAttachment'; function isDocument(value: unknown): value is Document { return typeof Document !== 'undefined' && value instanceof Document; } +/** The document a scroller is in: itself, for the page's own scroll. */ +function documentOf(node: Element | Document): Document { + return isDocument(node) ? node : node.ownerDocument; +} + /** * Watch a scroller's finger scrolls. Pass a ref to a scrolling element, or * the document for the page's own scroll. Returns a reader, stable across @@ -45,41 +52,27 @@ function isDocument(value: unknown): value is Document { export function useFingerScroll( scroller: RefObject | Document, ): () => boolean { - const attachedRef = useRef<{node: Node; detach: () => void} | null>(null); - // Follows the node on every commit: a ref that now points at another - // element moves the watch to it. - useIsomorphicLayoutEffect(() => { - const node = isDocument(scroller) ? scroller : scroller.current; - if (attachedRef.current?.node === node) { - return; - } - attachedRef.current?.detach(); - attachedRef.current = - node == null - ? null - : { - node, - detach: watchFingerScrolls( - getGestureHost(isDocument(node) ? node : node.ownerDocument), - node, - ), - }; - }); - // Synchronizes with the gesture host's finger-scroll watch: unmounting - // releases the watch this hook attached. - useIsomorphicLayoutEffect( - () => () => { - attachedRef.current?.detach(); - attachedRef.current = null; + /** The node the watch is on now, for the reader. */ + const watchedRef = useRef(null); + // Keeps the finger-scroll watch on the node the scroller names after each + // commit (useAttachment): a ref that now points at another element moves + // the watch to it, and unmounting releases it. + useAttachment( + isDocument(scroller) ? {current: scroller} : scroller, + node => { + watchedRef.current = node; + const stop = watchFingerScrolls(getGestureHost(documentOf(node)), node); + return () => { + stop(); + watchedRef.current = null; + }; }, - [], + null, ); return useCallback(() => { - const node = attachedRef.current?.node; - if (node == null) { - return false; - } - const doc = isDocument(node) ? node : node.ownerDocument; - return doc != null && isFingerScroll(getGestureHost(doc), node); + const node = watchedRef.current; + return ( + node != null && isFingerScroll(getGestureHost(documentOf(node)), node) + ); }, []); } diff --git a/packages/core/src/gestures/useGestureRecognizer.ts b/packages/core/src/gestures/useGestureRecognizer.ts index 046fc7af1bc92..76b6d51e6e11a 100644 --- a/packages/core/src/gestures/useGestureRecognizer.ts +++ b/packages/core/src/gestures/useGestureRecognizer.ts @@ -27,12 +27,12 @@ import {useEffect, useRef, useState, type RefObject} from 'react'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {getGestureHost} from './GestureHost'; import type {GestureRecognizer} from './GestureRecognizer'; +import {useAttachment} from '../hooks/useAttachment'; import { isSyncingGestures, syncingGestures, - useAttachment, useIsUnmountedRef, -} from './useAttachment'; +} from './gestureSync'; import {internals} from './recognizerInternals'; export interface UseGestureRecognizerOptions { diff --git a/packages/core/src/gestures/usePressTracker.ts b/packages/core/src/gestures/usePressTracker.ts index 05bbdbdb29ece..94f8250fcb64c 100644 --- a/packages/core/src/gestures/usePressTracker.ts +++ b/packages/core/src/gestures/usePressTracker.ts @@ -28,12 +28,12 @@ import { type PressTracker, type PressTrackerOptions, } from './PressTracker'; +import {useAttachment} from '../hooks/useAttachment'; import { isSyncingGestures, syncingGestures, - useAttachment, useIsUnmountedRef, -} from './useAttachment'; +} from './gestureSync'; export type UsePressTrackerOptions = Omit< PressTrackerOptions, diff --git a/packages/core/src/hooks/useAttachment.ts b/packages/core/src/hooks/useAttachment.ts new file mode 100644 index 0000000000000..9bfe67fa64c79 --- /dev/null +++ b/packages/core/src/hooks/useAttachment.ts @@ -0,0 +1,59 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useAttachment.ts + * @input A node ref (an element's, or the document's), an attach function + * and a key + * @output Exports useAttachment: keeps the attach function bound to the node + * the ref holds across commits + * @position Internal React helper, not in hooks/index.ts: the one attach + * and release lifecycle of the gesture hooks (useGestureRecognizer, + * usePressTracker, useFingerScroll) + * + * SYNC: When modified, update gestures/__tests__/useGestureRecognizer.test.tsx + * and gestures/__tests__/useFingerScroll.test.tsx. + */ + +import {useRef, type RefObject} from 'react'; +import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; + +/** + * Keep `attach` bound to the node `ref` holds after each commit: an element, + * or the document. A replaced node is released and the new one attached, a + * changed `key` re-attaches, and the last attachment is released on unmount; + * a commit that changes neither does nothing (react-component-runtime INV3). + */ +export function useAttachment( + ref: RefObject, + attach: (element: T) => () => void, + key: unknown, +): void { + const attachmentRef = useRef<{ + element: T; + key: unknown; + release: () => void; + } | null>(null); + + // Syncs the attachment with the DOM node `ref` holds after this commit. + useIsomorphicLayoutEffect(() => { + const element = ref.current; + const current = attachmentRef.current; + if (current?.element === element && current?.key === key) { + return; + } + current?.release(); + attachmentRef.current = + element == null ? null : {element, key, release: attach(element)}; + }); + + // Releases the attachment at unmount. + useIsomorphicLayoutEffect( + () => () => { + attachmentRef.current?.release(); + attachmentRef.current = null; + }, + [], + ); +}