refactor(hooks): move generic scroll/pagination/dnd UI hooks into lib/hooks

This commit is contained in:
Psychotoxical
2026-06-30 19:34:28 +02:00
parent 31bc24a178
commit 1f93984482
28 changed files with 41 additions and 41 deletions
@@ -0,0 +1,47 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useAsyncInpagePagination } from '@/lib/hooks/useAsyncInpagePagination';
describe('useAsyncInpagePagination', () => {
it('blocks concurrent page requests', async () => {
let resolveLoad: (() => void) | undefined;
const onPage = vi.fn(
() =>
new Promise<void>(resolve => {
resolveLoad = resolve;
}),
);
const { result } = renderHook(() => useAsyncInpagePagination(30));
act(() => {
expect(result.current.requestNextPage(onPage)).toBe(true);
expect(result.current.requestNextPage(onPage)).toBe(false);
});
expect(onPage).toHaveBeenCalledTimes(1);
expect(onPage).toHaveBeenCalledWith(30);
await act(async () => {
resolveLoad?.();
});
expect(result.current.loading).toBe(false);
expect(result.current.page).toBe(1);
});
it('resetPage clears guards and page index', () => {
const { result } = renderHook(() => useAsyncInpagePagination(30));
act(() => {
result.current.pageRef.current = 3;
result.current.loadPendingRef.current = true;
result.current.setPage(3);
result.current.resetPage();
});
expect(result.current.page).toBe(0);
expect(result.current.pageRef.current).toBe(0);
expect(result.current.loadPendingRef.current).toBe(false);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react';
type UseAsyncInpagePaginationOptions = {
/** Initial `loading` state (e.g. true when the first fetch runs on mount). */
initialLoading?: boolean;
};
/** Sync guards for offset-based server pagination inside in-page scroll areas. */
export function useAsyncInpagePagination(
pageSize: number,
options?: UseAsyncInpagePaginationOptions,
) {
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(options?.initialLoading ?? false);
const pageRef = useRef(0);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
useEffect(() => {
pageRef.current = page;
}, [page]);
const isBlocked = useCallback(
() => loadingRef.current || loadPendingRef.current,
[],
);
const resetPage = useCallback(() => {
pageRef.current = 0;
loadPendingRef.current = false;
setPage(0);
}, []);
const runLoad = useCallback(async (fn: () => Promise<void>) => {
loadingRef.current = true;
loadPendingRef.current = true;
setLoading(true);
try {
await fn();
} finally {
loadingRef.current = false;
loadPendingRef.current = false;
setLoading(false);
}
}, []);
const requestNextPage = useCallback(
(onPage: (offset: number) => void | Promise<void>) => {
if (isBlocked()) return false;
loadPendingRef.current = true;
const next = pageRef.current + 1;
pageRef.current = next;
setPage(next);
void onPage(next * pageSize);
return true;
},
[isBlocked, pageSize],
);
return {
page,
setPage,
loading,
setLoading,
pageRef,
loadingRef,
loadPendingRef,
isBlocked,
resetPage,
runLoad,
requestNextPage,
};
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { useAsyncInpagePagination } from '@/lib/hooks/useAsyncInpagePagination';
import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel';
export type UseAsyncInpageScrollArgs = {
pageSize: number;
active: boolean;
hasMore: boolean;
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
onLoadOffset: (offset: number, append: boolean) => void | Promise<void>;
};
/**
* Offset-based server pagination with stable in-page sentinel wiring.
*/
export function useAsyncInpageScroll({
pageSize,
active,
hasMore,
getScrollRoot,
scrollRootEl,
rootMargin,
onLoadOffset,
}: UseAsyncInpageScrollArgs) {
const paging = useAsyncInpagePagination(pageSize);
const loadMore = useCallback(() => {
if (!active || !hasMore || paging.isBlocked()) return;
paging.requestNextPage(offset => onLoadOffset(offset, true));
}, [active, hasMore, onLoadOffset, paging]);
const bindSentinel = useInpageScrollSentinel({
active: active && hasMore,
getScrollRoot,
scrollRootEl,
rootMargin,
onIntersect: loadMore,
});
return {
...paging,
bindSentinel,
loadMore,
};
}
@@ -0,0 +1,31 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useClientSliceInfiniteScroll } from '@/lib/hooks/useClientSliceInfiniteScroll';
vi.mock('@/lib/hooks/useInpageScrollSentinel', () => ({
useInpageScrollSentinel: () => vi.fn(),
}));
describe('useClientSliceInfiniteScroll', () => {
it('grows visibleCount by pageSize and clears loadingMore', () => {
const { result, rerender } = renderHook(
({ filter }: { filter: string }) =>
useClientSliceInfiniteScroll({
pageSize: 50,
resetDeps: [filter],
}),
{ initialProps: { filter: '' } },
);
expect(result.current.visibleCount).toBe(50);
act(() => {
result.current.loadMore();
});
expect(result.current.visibleCount).toBe(100);
rerender({ filter: 'a' });
expect(result.current.visibleCount).toBe(50);
expect(result.current.loadingMore).toBe(false);
});
});
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel';
export type UseClientSliceInfiniteScrollArgs = {
pageSize: number;
resetDeps: ReadonlyArray<unknown>;
getScrollRoot?: () => HTMLElement | null;
scrollRootEl?: HTMLElement | null;
rootMargin?: string;
/** One-shot bootstrap when restoring browse scroll (All Albums back navigation). */
restoreDisplayCount?: number;
};
function sliceVisibleCount(pageSize: number, restoreDisplayCount?: number): number {
if (restoreDisplayCount == null || restoreDisplayCount <= 0) return pageSize;
return Math.max(pageSize, restoreDisplayCount);
}
export type UseClientSliceInfiniteScrollResult = {
visibleCount: number;
loadingMore: boolean;
bindSentinel: ReturnType<typeof useInpageScrollSentinel>;
/** @deprecated Use `bindSentinel`. */
observerTarget: ReturnType<typeof useInpageScrollSentinel>;
loadMore: () => void;
};
/**
* Client-side infinite scroll: grow a visible slice from an in-memory list.
* Used by Artists and Composers browse grids.
*/
export function useClientSliceInfiniteScroll({
pageSize,
resetDeps,
getScrollRoot,
scrollRootEl,
rootMargin = '200px',
restoreDisplayCount,
}: UseClientSliceInfiniteScrollArgs): UseClientSliceInfiniteScrollResult {
const [visibleCount, setVisibleCount] = useState(() =>
sliceVisibleCount(pageSize, restoreDisplayCount),
);
const [loadingMore, setLoadingMore] = useState(false);
const loadPendingRef = useRef(false);
const loadMore = useCallback(() => {
if (loadPendingRef.current) return;
loadPendingRef.current = true;
setLoadingMore(true);
setVisibleCount(prev => prev + pageSize);
}, [pageSize]);
useEffect(() => {
loadPendingRef.current = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoadingMore(false);
}, [visibleCount]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVisibleCount(sliceVisibleCount(pageSize, restoreDisplayCount));
// resetDeps is intentionally spread into the dep array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, restoreDisplayCount, ...resetDeps]);
const bindSentinel = useInpageScrollSentinel({
active: true,
getScrollRoot: () =>
getScrollRoot?.() ?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
scrollRootEl,
rootMargin,
onIntersect: loadMore,
});
return {
visibleCount,
loadingMore,
bindSentinel,
observerTarget: bindSentinel,
loadMore,
};
}
@@ -0,0 +1,84 @@
import { useEffect } from 'react';
/** `Event.target` may be a Text node — only Elements expose `closest` / `tagName`. */
function eventTargetElement(e: Event): Element | null {
const t = e.target;
if (!t) return null;
if (t instanceof Element) return t;
if (!(t instanceof Node)) return null;
const parent = t.parentNode;
return parent instanceof Element ? parent : null;
}
function isTextInputElement(el: Element): boolean {
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable;
}
/**
* Globally tame Linux/WebKitGTK + Wayland behaviour that the page-level
* CSS / Tauri config can't reach:
*
* - dragover/dragenter: WebKitGTK shows a permanent "forbidden" cursor
* for external drops unless we preventDefault and force dropEffect.
* - drop on document: block so an OS-file-manager drag doesn't navigate
* the webview away from the app.
* - dragstart (capture): cancel native drags from in-page elements (e.g.
* SVG grips) — on Wayland these can leave a stuck GTK drag-proxy.
* In-app moves go through psy-drag (mouse events), unaffected.
* - Ctrl/Cmd+A: WebKit ignores `user-select: none` for keyboard
* shortcuts. Still allow select-all inside real text fields.
* - selectstart: same story for mouse-drag selection. Allow inside
* inputs / textareas / contentEditable and explicit `[data-selectable]`
* regions (cover info, etc.).
*
* Harmless on Windows/macOS.
*/
export function useGlobalDndAndSelectionBlockers(): void {
useEffect(() => {
const allow = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
const blockDrop = (e: DragEvent) => { e.preventDefault(); };
const blockSelectAll = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
const el = eventTargetElement(e);
if (el && isTextInputElement(el)) return;
e.preventDefault();
}
};
const blockSelectStart = (e: Event) => {
const el = eventTargetElement(e);
if (!el) {
e.preventDefault();
return;
}
if (isTextInputElement(el)) return;
if (el.closest('[data-selectable]')) return;
e.preventDefault();
};
const blockDragStart = (e: DragEvent) => {
e.preventDefault();
};
document.addEventListener('dragover', allow);
document.addEventListener('dragenter', allow);
document.addEventListener('drop', blockDrop);
document.addEventListener('dragstart', blockDragStart, true);
document.addEventListener('keydown', blockSelectAll, true);
document.addEventListener('selectstart', blockSelectStart);
return () => {
document.removeEventListener('dragover', allow);
document.removeEventListener('dragenter', allow);
document.removeEventListener('drop', blockDrop);
document.removeEventListener('dragstart', blockDragStart, true);
document.removeEventListener('keydown', blockSelectAll, true);
document.removeEventListener('selectstart', blockSelectStart);
};
}, []);
}
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel';
describe('useInpageScrollSentinel', () => {
it('returns a callback ref function', () => {
const onIntersect = vi.fn();
const { result } = renderHook(() =>
useInpageScrollSentinel({
active: true,
onIntersect,
}),
);
expect(typeof result.current).toBe('function');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef, type MutableRefObject, type RefCallback } from 'react';
const DEFAULT_ROOT_MARGIN = '400px';
export type UseInpageScrollSentinelArgs = {
/** When false, disconnect and ignore the sentinel. */
active: boolean;
getScrollRoot?: () => HTMLElement | null;
/** Rebind when the in-page scroll viewport mounts (callback-ref body). */
scrollRootEl?: HTMLElement | null;
onIntersect: () => void;
rootMargin?: string;
/** Re-fire `onIntersect` when this changes and the sentinel is still visible. */
drainSignal?: unknown;
/** Updated when the sentinel enters/leaves the scroll root viewport. */
intersectingRef?: MutableRefObject<boolean>;
};
/**
* Stable IntersectionObserver callback ref for in-page infinite scroll.
* Matches {@link useClientSliceInfiniteScroll} — avoids reconnect storms when
* `onIntersect` / `loadMore` identities change every render.
*/
export function useInpageScrollSentinel({
active,
getScrollRoot,
scrollRootEl,
onIntersect,
rootMargin = DEFAULT_ROOT_MARGIN,
drainSignal,
intersectingRef,
}: UseInpageScrollSentinelArgs): RefCallback<HTMLDivElement | null> {
const onIntersectRef = useRef(onIntersect);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
onIntersectRef.current = onIntersect;
const setIntersecting = useCallback((hit: boolean) => {
if (intersectingRef) intersectingRef.current = hit;
}, [intersectingRef]);
const observerInst = useRef<IntersectionObserver | null>(null);
const bindSentinel = useCallback((node: HTMLDivElement | null) => {
observerInst.current?.disconnect();
observerInst.current = null;
if (!node) {
setIntersecting(false);
return;
}
if (!active) {
setIntersecting(false);
return;
}
const rootEl = getScrollRoot?.() ?? null;
const observer = new IntersectionObserver(
entries => {
const hit = Boolean(entries[0]?.isIntersecting);
setIntersecting(hit);
if (hit) onIntersectRef.current();
},
{
root: rootEl instanceof HTMLElement ? rootEl : null,
rootMargin,
},
);
observer.observe(node);
observerInst.current = observer;
// scrollRootEl is an intentional re-create trigger: when the resolved scroll
// root element changes the sentinel must re-bind its observer to the new root,
// even though the body reads it via getScrollRoot() rather than directly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, getScrollRoot, scrollRootEl, rootMargin, setIntersecting]);
useEffect(() => {
const observer = observerInst.current;
if (!observer || !active) return;
for (const entry of observer.takeRecords()) {
const hit = entry.isIntersecting;
setIntersecting(hit);
if (hit) onIntersectRef.current();
}
}, [active, drainSignal, setIntersecting]);
useEffect(() => () => {
observerInst.current?.disconnect();
observerInst.current = null;
}, []);
return bindSentinel;
}
+13
View File
@@ -0,0 +1,13 @@
import { useCallback, useRef, useState } from 'react';
/** In-page overlay scroll viewport ref + state for IntersectionObserver roots. */
export function useInpageScrollViewport() {
const scrollBodyRef = useRef<HTMLDivElement | null>(null);
const [scrollBodyEl, setScrollBodyEl] = useState<HTMLDivElement | null>(null);
const bindScrollBody = useCallback((el: HTMLDivElement | null) => {
scrollBodyRef.current = el;
setScrollBodyEl(el);
}, []);
const getScrollRoot = useCallback(() => scrollBodyRef.current, []);
return { scrollBodyRef, scrollBodyEl, bindScrollBody, getScrollRoot };
}
+91
View File
@@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDragDrop } from '@/lib/dnd/DragDropContext';
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
interface Options {
/** Payload discriminator the drag source emits, e.g. `'lyrics_source_reorder'`. */
type: string;
/**
* Apply the move. Receives the dragged row id and the resolved drop target.
* Consumers own the actual list mutation (store-specific). Memoise this
* (`useCallback`) so the drop listener does not re-bind every render.
*/
apply: (draggedId: string, target: ListReorderDropTarget) => void;
}
interface Result {
isDragging: boolean;
/** Attach to the rows' container: `ref={setContainer}`. */
setContainer: (el: HTMLElement | null) => void;
/** Attach to the rows' container: `onMouseMove={onMouseMove}`. */
onMouseMove: (e: React.MouseEvent) => void;
/** Which edge (if any) of row `id` should show the drop indicator. */
dropEdge: (id: string) => 'before' | 'after' | null;
}
/**
* Drag-to-reorder wiring shared by the customizer panels. Tracks the hovered
* drop target, listens for the `psy-drop` event, and resolves the target by
* **stable id** (read from `data-reorder-id`, with optional
* `data-reorder-section`). The actual reorder is delegated to `apply`, keeping
* this hook list-agnostic. Pair with `ReorderGripHandle` on each row and
* `applyListReorderById` (or a section-aware variant) inside `apply`.
*/
export function useListReorderDnd({ type, apply }: Options): Result {
const { isDragging } = useDragDrop();
const [container, setContainer] = useState<HTMLElement | null>(null);
const [dropTarget, setDropTarget] = useState<ListReorderDropTarget | null>(null);
const dropTargetRef = useRef<ListReorderDropTarget | null>(null);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers; not render data.
// eslint-disable-next-line react-hooks/refs
dropTargetRef.current = dropTarget;
// Clear the drop indicator as soon as any drag ends.
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with the drag input.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!isDragging) { dropTargetRef.current = null; setDropTarget(null); }
}, [isDragging]);
useEffect(() => {
if (!container) return;
const onPsyDrop = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: { type?: string; id?: string; section?: string };
try { parsed = JSON.parse(detail.data as string); } catch { return; }
if (parsed.type !== type || !parsed.id) return;
const target = dropTargetRef.current;
dropTargetRef.current = null; setDropTarget(null);
if (!target) return;
apply(parsed.id, target);
};
container.addEventListener('psy-drop', onPsyDrop);
return () => container.removeEventListener('psy-drop', onPsyDrop);
}, [container, type, apply]);
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging || !container) return;
const rows = container.querySelectorAll<HTMLElement>('[data-reorder-id]');
let target: ListReorderDropTarget | null = null;
for (const row of rows) {
const id = row.dataset.reorderId;
if (!id) continue;
const section = row.dataset.reorderSection;
const rect = row.getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
target = section ? { id, before, section } : { id, before };
if (before) break;
}
dropTargetRef.current = target;
setDropTarget(target);
}, [isDragging, container]);
const dropEdge = useCallback((id: string): 'before' | 'after' | null => {
if (!isDragging || dropTarget?.id !== id) return null;
return dropTarget.before ? 'before' : 'after';
}, [isDragging, dropTarget]);
return { isDragging, setContainer, onMouseMove, dropEdge };
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
const TIGHT_AFTER_PX = 10;
const LOOSE_BELOW_PX = 2;
/**
* Compact the browse toolbar when the in-page overlay viewport scrolls down,
* same thresholds as Artists (`> 10` tight, `< 2` loose).
*/
export function useMainstageInpageHeaderTight(
scrollBodyEl: HTMLElement | null,
resetDeps: ReadonlyArray<unknown>,
): boolean {
const [tight, setTight] = useState(false);
useEffect(() => {
if (!scrollBodyEl) return;
const el = scrollBodyEl;
const onScroll = () => {
const y = el.scrollTop;
setTight(prev => {
if (y > TIGHT_AFTER_PX) return true;
if (y < LOOSE_BELOW_PX) return false;
return prev;
});
};
el.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => el.removeEventListener('scroll', onScroll);
}, [scrollBodyEl]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTight(false);
// Spread values so deps track filter keys, not a new array identity each render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...resetDeps]);
return tight;
}