import { useCallback, useEffect, useState } from 'react' /** * Detects whether a sentinel element has scrolled above the top of the * nearest scroll container (the AppShell's `main.content-scroll`). * Returns `true` when the user has moved past the sentinel, `false` * while the sentinel is still in or below the viewport. * * Uses a ref-callback API rather than a `useRef` so the listener * actually attaches when the sentinel mounts. With a plain useRef the * setup effect runs once on first commit - which on pages that show a * skeleton until data loads is BEFORE the real sentinel div renders, * leaving `ref.current` null and the listener never wired up. * * Usage: * const { sentinelRef, past } = usePastSentinel() * return
*/ export function usePastSentinel(): { sentinelRef: (el: HTMLElement | null) => void past: boolean } { const [el, setEl] = useState(null) const [past, setPast] = useState(false) // Wrap the state setter so React doesn't change the callback identity // every render - the `ref` prop sees a stable function. const sentinelRef = useCallback((next: HTMLElement | null) => { setEl(next) }, []) useEffect(() => { if (!el) return const root = (document.querySelector('main.content-scroll') as HTMLElement | null) || null let raf = 0 const update = () => { raf = 0 const rect = el.getBoundingClientRect() const rootTop = root ? root.getBoundingClientRect().top : 0 setPast(rect.top <= rootTop + 1) } const schedule = () => { if (raf) return raf = requestAnimationFrame(update) } schedule() const target: EventTarget = root || window target.addEventListener('scroll', schedule, { passive: true }) const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null ro?.observe(el) return () => { if (raf) cancelAnimationFrame(raf) target.removeEventListener('scroll', schedule) ro?.disconnect() } }, [el]) return { sentinelRef, past } }