All files / spotlight-tour/src/hooks useTargetRect.ts

97.61% Statements 41/42
90.9% Branches 20/22
100% Functions 10/10
100% Lines 37/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125              38x                                                                                             858x 858x 858x     858x 182x 182x 1x 1x   181x 181x 188x       858x 9x 9x 8x 6x 6x           858x           858x 359x 183x 183x   176x                   858x 359x   176x   176x 176x   176x 176x 176x 176x 176x 3x 3x         858x    
import { useCallback, useEffect, useRef, useState } from "react";
import { useIsomorphicLayoutEffect } from "@usefy/use-isomorphic-layout-effect";
import { useLatest } from "@usefy/use-latest";
import { useResizeObserver } from "@usefy/use-resize-observer";
import type { Rect } from "../types";
 
function rectsEqual(a: Rect, b: Rect): boolean {
  return (
    a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
  );
}
 
/**
 * Track an element's **viewport rect** live: re-measures on element resize
 * (via `ResizeObserver`), window resize, and any scroll (capture-phase, so
 * nested scroll containers are covered too).
 *
 * Measurements are **rAF-batched** — a burst of scroll/resize events per frame
 * collapses into a single `getBoundingClientRect` call — and state only
 * commits when the rect actually changed, so consumers don't re-render every
 * frame while nothing moves.
 *
 * SSR-safe: measuring happens in an isomorphic layout effect and no
 * `window`/`document` is touched at module scope.
 *
 * @param element - The element to track, or `null` to pause tracking (the
 * returned rect resets to `null`).
 * @returns The element's current viewport rect, or `null` when there is no
 * element (or it hasn't been measured yet on the server).
 *
 * @example
 * ```tsx
 * import { useTargetRect } from "@usefy/spotlight-tour/headless";
 *
 * function Highlight({ element }: { element: Element | null }) {
 *   const rect = useTargetRect(element);
 *   if (!rect) return null;
 *   return (
 *     <div
 *       style={{
 *         position: "fixed",
 *         left: rect.x,
 *         top: rect.y,
 *         width: rect.width,
 *         height: rect.height,
 *         outline: "2px solid hotpink",
 *         pointerEvents: "none",
 *       }}
 *     />
 *   );
 * }
 * ```
 */
export function useTargetRect(element: Element | null): Rect | null {
  const [rect, setRect] = useState<Rect | null>(null);
  const rafRef = useRef<number | null>(null);
  const elementRef = useLatest(element);
 
  /** Measure now and commit only if the rect actually changed. */
  const measure = useCallback(() => {
    const el = elementRef.current;
    if (!el) {
      setRect((prev) => (prev === null ? prev : null));
      return;
    }
    const b = el.getBoundingClientRect();
    const next: Rect = { x: b.x, y: b.y, width: b.width, height: b.height };
    setRect((prev) => (prev && rectsEqual(prev, next) ? prev : next));
  }, [elementRef]);
 
  /** Schedule a measure on the next animation frame (at most one pending). */
  const schedule = useCallback(() => {
    Iif (typeof window === "undefined") return;
    if (rafRef.current !== null) return;
    rafRef.current = window.requestAnimationFrame(() => {
      rafRef.current = null;
      measure();
    });
  }, [measure]);
 
  // Element resize → rAF-batched re-measure. State updates are disabled on the
  // observer itself; it exists purely to trigger our own measurement.
  const { observe, unobserve } = useResizeObserver({
    updateState: false,
    onResize: schedule,
  });
 
  // Initial synchronous measure (before paint); reset when the element goes.
  useIsomorphicLayoutEffect(() => {
    if (!element) {
      setRect(null);
      return;
    }
    measure();
  }, [element, measure]);
 
  // Subscription wiring lives in a *passive* effect on purpose: it must run
  // AFTER use-resize-observer's own mount effect (a passive effect that
  // replaces its internal observer instance). Observing from a layout effect
  // would attach to a pre-mount instance that gets orphaned — the element
  // would stay observed forever and fire into an unmounted hook. Hook order
  // guarantees the right sequence: useResizeObserver above registers its
  // effect before this one.
  useEffect(() => {
    if (!element) return;
 
    observe(element);
    // Capture-phase scroll catches nested scroll containers, not just window.
    window.addEventListener("scroll", schedule, { passive: true, capture: true });
    window.addEventListener("resize", schedule, { passive: true });
 
    return () => {
      unobserve(element);
      window.removeEventListener("scroll", schedule, { capture: true });
      window.removeEventListener("resize", schedule);
      if (rafRef.current !== null) {
        window.cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
    };
  }, [element, schedule, observe, unobserve]);
 
  return rect;
}