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;
+ },
+ [],
+ );
+}