/** * useNearViewport — module-level shared IntersectionObserver in hook form. * * Single IO instance per `rootMargin` value, shared across every component * that mounts the hook. Reduces overhead vs. one IO per component on * grid/list pages where many subscribers observe the viewport with the same * margin. Promoted from the inline singleton at * `multi-platform-hub/components/shared/video-bites-display.tsx:21-43`, * which is the only IO pattern in either repo today. * * Usage: * ```tsx * function MyCard() { * const { ref, isNear } = useNearViewport('500px'); * return
{isNear ? : }
; * } * ``` * * StrictMode safety: cleanup uses an identity check on the registered * callback so React's dev double-mount (mount → cleanup → re-mount) does * not drop the second mount's freshly-set subscription. The IO callback * also checks `subscribers.get(target)` before invoking so a fire that * races with unmount cannot crash on a torn-down component. * * The hook fires once — on first intersection it sets `isNear=true` and * unobserves the element. Callers that need re-observation should * unmount and remount (or fork the hook for two-way behavior). */ import { useEffect, useRef, useState, useCallback } from 'react'; // Per-(rootMargin, threshold) IO map. Multiple call sites with different // margins/thresholds each get their own singleton observer. const observers = new Map(); const subscribers = new WeakMap void>(); // Slack for the explicit ratio check: an element sized to exactly meet the // threshold can report a ratio a hair under it (sub-pixel / float rounding), so // accept `ratio >= threshold - EPSILON`. With `threshold: 0` this is a no-op // (ratio is always ≥ 0 > -EPSILON) — the `isIntersecting` check alone gates. const THRESHOLD_EPSILON = 0.01; /** Stable map key for an observer config. */ function observerKey(rootMargin: string, threshold: number): string { return `${rootMargin}|${threshold}`; } function getObserverFor(rootMargin: string, threshold: number): IntersectionObserver { const key = observerKey(rootMargin, threshold); const existing = observers.get(key); if (existing) return existing; const io = new IntersectionObserver( (entries) => { entries.forEach((entry) => { // Enforce the threshold EXPLICITLY. `isIntersecting` is spec-defined as // `intersectionRatio > 0` (any overlap), so the initial `observe()` // firing can report `true` at, say, 20% visible even with // `threshold: 0.5` — which would prematurely pass the gate. Require both // an intersection AND the ratio to actually meet the threshold (minus a // small epsilon for the float rounding where the ratio lands a hair // under the exact value). if (!entry.isIntersecting) return; if (entry.intersectionRatio < threshold - THRESHOLD_EPSILON) return; // Race-safe: re-read the callback at fire time. A late IO firing // after cleanup must not invoke a stale callback. const cb = subscribers.get(entry.target); if (cb) { cb(); io.unobserve(entry.target); subscribers.delete(entry.target); } }); }, { rootMargin, threshold } ); observers.set(key, io); return io; } /** * Default near-viewport lookahead — the SSOT for "mount media on approach". * Shared by this hook's default AND the raw two-way IntersectionObservers in * `cards-strip.tsx` / `video-bites-strip.tsx` (which need mount+unmount, not * this hook's fire-once semantics, but must agree on the distance). */ export const NEAR_VIEWPORT_ROOT_MARGIN = '500px'; export interface UseNearViewportResult { /** Ref to attach to the element you want to gate on visibility. */ ref: (node: T | null) => void; /** Flips to `true` once the element enters within `rootMargin` of the viewport. Never flips back. */ isNear: boolean; } /** * @param rootMargin Margin around the viewport (CSS-style string). * '500px' = element starts mounting 500px before scroll-in. * '1000px' = a full viewport's worth of lookahead. * '0px' = strict on-screen detection. * @param threshold Fraction of the element that must be visible before firing * (0 = any pixel — the default; 0.5 = at least half on-screen). */ export function useNearViewport( rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN, threshold: number = 0 ): UseNearViewportResult { const [isNear, setIsNear] = useState(false); const elRef = useRef(null); const key = observerKey(rootMargin, threshold); // Subscribe/unsubscribe on element change. const ref = useCallback( (node: T | null) => { const prev = elRef.current; // Unsubscribe previous, if any. Identity-check the callback so a // StrictMode re-mount that has already re-registered keeps its sub. if (prev) { const stillOurs = subscribers.get(prev); if (stillOurs) { subscribers.delete(prev); observers.get(key)?.unobserve(prev); } } elRef.current = node; if (!node) return; const cb = () => setIsNear(true); subscribers.set(node, cb); getObserverFor(rootMargin, threshold).observe(node); }, [key, rootMargin, threshold] ); // Unsubscribe on unmount. Identity check guards the StrictMode race. useEffect(() => { return () => { const el = elRef.current; if (!el) return; if (subscribers.get(el)) { subscribers.delete(el); observers.get(key)?.unobserve(el); } }; }, [key]); return { ref, isNear }; }