'use client'; import { useEffect } from 'react'; import { getHashTargetElement, normalizeHashFragment } from '../utils/same-page-hash-nav'; import { scrollElementIntoView } from '../utils/scroll-into-view'; /** ~1s at 60fps — long enough to outlast Radix accordion expand + SWR * mount, short enough that a missed anchor doesn't hang the page. */ const MAX_POLL_FRAMES = 60; export interface UseScrollToHashOptions { /** Pixels to subtract for sticky chrome. */ headerOffset?: number; } /** * Scroll the page to `window.location.hash` once `readyDep` resolves * to a truthy value. Polls via rAF for ~1s so lazy-mounted rows (Radix * accordion, SWR fetch) have time to render. Re-runs on `readyDep` * reference change AND on `hashchange` (browser back/forward + the * synthetic event `navigateSamePageHash` dispatches). * * Skipped when `readyDep == null || readyDep === false`. Default * `true` makes the hook run on mount for pages whose target is in the * initial SSR render. */ export function useScrollToHash(readyDep: unknown = true, options?: UseScrollToHashOptions): void { const headerOffset = options?.headerOffset ?? 0; useEffect(() => { if (typeof window === 'undefined') return undefined; if (readyDep === null || readyDep === false) return undefined; let rafId: number | null = null; const cancelPoll = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }; const tryScrollToHash = () => { // `normalizeHashFragment` heals a malformed multi-fragment hash // so `getElementById` resolves on deep-link entries that bypass // `navigateSamePageHash`'s own normalize. const hash = normalizeHashFragment(window.location.hash).slice(1); if (!hash) return; // Cancel any in-flight poll from a prior invocation so two // concurrent ticks can't both call scrollElementIntoView. cancelPoll(); let frames = 0; const tick = () => { // Raw-then-decoded lookup — encoded fragments from href // composers must match the unencoded ids rows render. const el = getHashTargetElement(hash); if (el) { rafId = null; scrollElementIntoView(el, { headerOffset }); return; } if (frames++ < MAX_POLL_FRAMES) { rafId = requestAnimationFrame(tick); } else { rafId = null; } }; tick(); }; tryScrollToHash(); window.addEventListener('hashchange', tryScrollToHash); return () => { window.removeEventListener('hashchange', tryScrollToHash); cancelPoll(); }; }, [readyDep, headerOffset]); }