/**
* 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
;
* }
* ```
*
* 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 };
}