Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/use-attachment-hooks.md
Original file line number Diff line number Diff line change
@@ -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
67 changes: 66 additions & 1 deletion packages/core/src/gestures/__tests__/useFingerScroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <p data-testid="row">row</p>;
Expand Down Expand Up @@ -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(<Follower />);
fingerScroll(first.element, first.row);
expect(read()).toBe(true);
lift(first.element, first.row);

followed.current = second.element;
rerender(<Follower />);
// 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();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<Element | null>,
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;
},
[],
);
}
63 changes: 28 additions & 35 deletions packages/core/src/gestures/useFingerScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -45,41 +52,27 @@ function isDocument(value: unknown): value is Document {
export function useFingerScroll(
scroller: RefObject<Element | null> | 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<Element | Document | null>(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<Element | Document>(
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)
);
}, []);
}
4 changes: 2 additions & 2 deletions packages/core/src/gestures/useGestureRecognizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/gestures/usePressTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/hooks/useAttachment.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Node = Element>(
ref: RefObject<T | null>,
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;
},
[],
);
}
Loading