From dc8886fedecbd3f8aff3f55c411985093ba3f5bb Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 1 Nov 2026 04:44:53 -0800 Subject: [PATCH 1/2] refactor(core): useFingerScroll keeps its watch through useAttachment useFingerScroll kept its own copy of the attachment lifecycle the other gesture hooks share: follow the node after each commit, release the replaced node and attach the new one, release at unmount. It now calls useAttachment, which takes a node ref (an element's, or the document's) with no change for its callers, so a fix to that lifecycle reaches all three hooks. A new useFingerScroll case pins that the watch lets go of a node it stops following and of its last node at unmount; it fails with either release removed from useAttachment. No behavior changes. --- .../__tests__/useFingerScroll.test.tsx | 67 ++++++++++++++++++- packages/core/src/gestures/useAttachment.ts | 27 ++++---- packages/core/src/gestures/useFingerScroll.ts | 63 ++++++++--------- 3 files changed, 109 insertions(+), 48 deletions(-) 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/useAttachment.ts index d467687efc348..6d72f4cc3d9df 100644 --- a/packages/core/src/gestures/useAttachment.ts +++ b/packages/core/src/gestures/useAttachment.ts @@ -4,13 +4,16 @@ /** * @file useAttachment.ts - * @input An element ref, an attach function and a key + * @input A node ref (an element's, or the document's), 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 - * usePressTracker, so each hook imports only what it uses + * @position Internal React helpers shared by useGestureRecognizer, + * usePressTracker and useFingerScroll, so each hook imports only what it + * uses * - * SYNC: When modified, update useGestureRecognizer.test.tsx. + * SYNC: When modified, update useGestureRecognizer.test.tsx and + * useFingerScroll.test.tsx. */ import {useRef, type RefObject} from 'react'; @@ -60,18 +63,18 @@ export function useIsUnmountedRef(): {readonly current: boolean} { } /** - * 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). + * 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: Element) => () => void, +export function useAttachment( + ref: RefObject, + attach: (element: T) => () => void, key: unknown, ): void { const attachmentRef = useRef<{ - element: Element; + element: T; key: unknown; release: () => void; } | null>(null); diff --git a/packages/core/src/gestures/useFingerScroll.ts b/packages/core/src/gestures/useFingerScroll.ts index 2ce09e2aa82e6..659286fb6ec4e 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 (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 './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) + ); }, []); } From 4ea3e24cd42f672f845e5347d13bd0f2aa8ea099 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 1 Nov 2026 08:46:52 -0800 Subject: [PATCH 2/2] refactor(core): useAttachment moves to hooks/, where other hooks can use it useAttachment keeps an attach function bound to the node a ref holds across commits: release the replaced node, attach the new one, release at unmount. It is a plain React helper, so it moves from gestures/ to hooks/useAttachment.ts (not exported from hooks/index.ts), where hooks outside gestures/ can use it instead of copying it. The gesture-sync depth and useIsUnmountedRef, which only the gesture hooks read, stay in gestures/gestureSync.ts. useGestureRecognizer, usePressTracker and useFingerScroll import from the new homes. No behavior changes; no interaction spec bundles these hooks, so the WebKit job's scope is unchanged. --- .changeset/use-attachment-hooks.md | 7 ++ packages/core/src/gestures/gestureSync.ts | 60 +++++++++++ packages/core/src/gestures/useAttachment.ts | 102 ------------------ packages/core/src/gestures/useFingerScroll.ts | 4 +- .../core/src/gestures/useGestureRecognizer.ts | 4 +- packages/core/src/gestures/usePressTracker.ts | 4 +- packages/core/src/hooks/useAttachment.ts | 59 ++++++++++ 7 files changed, 132 insertions(+), 108 deletions(-) create mode 100644 .changeset/use-attachment-hooks.md create mode 100644 packages/core/src/gestures/gestureSync.ts delete mode 100644 packages/core/src/gestures/useAttachment.ts create mode 100644 packages/core/src/hooks/useAttachment.ts 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/gestureSync.ts b/packages/core/src/gestures/gestureSync.ts new file mode 100644 index 0000000000000..4f1526685e22b --- /dev/null +++ b/packages/core/src/gestures/gestureSync.ts @@ -0,0 +1,60 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @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 __tests__/useGestureRecognizer.test.tsx. + */ + +import {useRef} from 'react'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; + +/** + * How many hooks are driving a recognizer from an Effect right now: an + * `isEnabled` sync, or the detach of a replaced node that cancels a gesture + * in flight. While one runs no hook's listener calls its caller's callback + * (react-component-runtime INV2, INV3): the recognizer it stops is cancelled + * silently, and what that stop unblocks (a recognizer that required it to + * fail) resolves in the arena's next pass, a microtask later, where its + * listener reports as usual (`deferringResolution` in useGestureRecognizer). + * A press tracker's callbacks wait for a microtask after it (usePressTracker). + */ +let syncingDepth = 0; + +/** Runs `body` as a gesture sync (see `syncingDepth`). */ +export function syncingGestures(body: () => T): T { + syncingDepth += 1; + try { + return body(); + } finally { + syncingDepth -= 1; + } +} + +/** True while any gesture hook's sync runs. */ +export function isSyncingGestures(): boolean { + return syncingDepth > 0; +} + +/** + * A flag that turns true when the component unmounts, before the cleanups of + * the effects declared after it run, so nothing reports from those cleanups. + */ +export function useIsUnmountedRef(): {readonly current: boolean} { + const isUnmountedRef = useRef(false); + // Syncs the flag with the component's mount. + useIsomorphicLayoutEffect(() => { + isUnmountedRef.current = false; + return () => { + isUnmountedRef.current = true; + }; + }, []); + return isUnmountedRef; +} diff --git a/packages/core/src/gestures/useAttachment.ts b/packages/core/src/gestures/useAttachment.ts deleted file mode 100644 index 6d72f4cc3d9df..0000000000000 --- a/packages/core/src/gestures/useAttachment.ts +++ /dev/null @@ -1,102 +0,0 @@ -// 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, useIsUnmountedRef, and the gesture-sync - * depth (syncingGestures, isSyncingGestures) both hooks read - * @position Internal React helpers shared by useGestureRecognizer, - * usePressTracker and useFingerScroll, so each hook imports only what it - * uses - * - * SYNC: When modified, update useGestureRecognizer.test.tsx and - * useFingerScroll.test.tsx. - */ - -import {useRef, type RefObject} from 'react'; -import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; - -/** - * How many hooks are driving a recognizer from an Effect right now: an - * `isEnabled` sync, or the detach of a replaced node that cancels a gesture - * in flight. While one runs no hook's listener calls its caller's callback - * (react-component-runtime INV2, INV3): the recognizer it stops is cancelled - * silently, and what that stop unblocks (a recognizer that required it to - * fail) resolves in the arena's next pass, a microtask later, where its - * listener reports as usual (`deferringResolution` in useGestureRecognizer). - * A press tracker's callbacks wait for a microtask after it (usePressTracker). - */ -let syncingDepth = 0; - -/** Runs `body` as a gesture sync (see `syncingDepth`). */ -export function syncingGestures(body: () => T): T { - syncingDepth += 1; - try { - return body(); - } finally { - syncingDepth -= 1; - } -} - -/** True while any gesture hook's sync runs. */ -export function isSyncingGestures(): boolean { - return syncingDepth > 0; -} - -/** - * A flag that turns true when the component unmounts, before the cleanups of - * the effects declared after it run, so nothing reports from those cleanups. - */ -export function useIsUnmountedRef(): {readonly current: boolean} { - const isUnmountedRef = useRef(false); - // Syncs the flag with the component's mount. - useIsomorphicLayoutEffect(() => { - isUnmountedRef.current = false; - return () => { - isUnmountedRef.current = true; - }; - }, []); - return isUnmountedRef; -} - -/** - * 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; - }, - [], - ); -} diff --git a/packages/core/src/gestures/useFingerScroll.ts b/packages/core/src/gestures/useFingerScroll.ts index 659286fb6ec4e..51224e7f89e3e 100644 --- a/packages/core/src/gestures/useFingerScroll.ts +++ b/packages/core/src/gestures/useFingerScroll.ts @@ -10,7 +10,7 @@ * finger's, read at any moment * @position Gesture hook of packages/core/src/gestures, over the * finger-scroll watch (fingerScroll.ts), kept on the scroller's node by - * the gesture hooks' shared attachment (useAttachment.ts). An internal + * 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 @@ -32,7 +32,7 @@ import {useCallback, useRef, type RefObject} from 'react'; import {isFingerScroll, watchFingerScrolls} from './fingerScroll'; import {getGestureHost} from './GestureHost'; -import {useAttachment} from './useAttachment'; +import {useAttachment} from '../hooks/useAttachment'; function isDocument(value: unknown): value is Document { return typeof Document !== 'undefined' && value instanceof Document; 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; + }, + [], + ); +}