Files
jellybloom/src/hooks/use-past-sentinel.ts
T

60 lines
2.0 KiB
TypeScript

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 <div ref={sentinelRef} />
*/
export function usePastSentinel(): {
sentinelRef: (el: HTMLElement | null) => void
past: boolean
} {
const [el, setEl] = useState<HTMLElement | null>(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 }
}