All files / hooks/use-scroll-position/src useScrollPosition.ts

100% Statements 40/40
100% Branches 16/16
100% Functions 7/7
100% Lines 38/38

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 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161                              1x                                                                                                                             46x   46x         46x                 46x 17x 17x       16x 32x 32x       16x       16x 16x   16x 19x     19x 8x 8x     11x 11x   11x   6x 1x 1x   6x 6x 5x     4x 2x 2x 2x         16x   16x 16x 16x 1x 1x               46x    
import { useState } from "react";
import { useIsomorphicLayoutEffect } from "@usefy/use-isomorphic-layout-effect";
import { useLatest } from "@usefy/use-latest";
import type {
  UseScrollPositionOptions,
  UseScrollPositionReturn,
} from "./types";
import {
  arePositionsEqual,
  getScrollPosition,
  resolveScrollTarget,
  ZERO_SCROLL_POSITION,
} from "./utils";
 
/** Default throttle interval, in milliseconds. */
const DEFAULT_THROTTLE_MS = 100;
 
/**
 * Track the throttled scroll offset (`{ x, y }`) of the window or a given
 * element, re-rendering as the target scrolls.
 *
 * By default the hook tracks the window/document scroll. Pass an `element` (a
 * raw `HTMLElement` or a `RefObject<HTMLElement>`) to track that element's own
 * scroll instead. The listener is attached with `{ passive: true }` and the
 * handler is throttled — it fires on the leading edge and always once more on
 * the trailing edge, so the settled resting position is never dropped.
 *
 * Behaviour notes:
 * - **Synchronous initial read**: the current position is read in a layout
 *   effect on mount (via `useIsomorphicLayoutEffect`), so the first commit after
 *   mount reflects the real offset rather than `0, 0`.
 * - **SSR-safe**: no `window`/`document` access on the server. It returns
 *   `{ x: 0, y: 0 }` and attaches nothing until it runs on the client.
 * - **StrictMode / concurrent-safe**: the listener and any pending trailing
 *   timer are cleaned up on unmount and whenever the target element changes, so
 *   double-mount/unmount never leaks a listener or a timer.
 * - **Stable throttle**: changing `throttleMs` between renders does not
 *   re-subscribe the listener — the latest value is read from a ref on each
 *   scroll event.
 *
 * @param options - Optional `element` (target, defaults to the window) and
 *   `throttleMs` (throttle interval; `0` disables throttling).
 * @returns The current {@link ScrollPosition} `{ x, y }`. The returned object
 *   keeps a stable identity across scroll events that don't change the offset.
 *
 * @example
 * ```tsx
 * // Track the window scroll (throttled to 100ms by default).
 * import { useScrollPosition } from "@usefy/use-scroll-position";
 *
 * function ScrollIndicator() {
 *   const { x, y } = useScrollPosition();
 *   return <div>Scrolled to {x}, {y}</div>;
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Track a scrollable element via a ref, updating on every frame.
 * import { useRef } from "react";
 * import { useScrollPosition } from "@usefy/use-scroll-position";
 *
 * function Pane() {
 *   const ref = useRef<HTMLDivElement>(null);
 *   const { y } = useScrollPosition({ element: ref, throttleMs: 0 });
 *
 *   return (
 *     <div ref={ref} style={{ overflow: "auto", height: 200 }}>
 *       <p>scrollTop: {y}</p>
 *       <div style={{ height: 2000 }} />
 *     </div>
 *   );
 * }
 * ```
 */
export function useScrollPosition(
  options: UseScrollPositionOptions = {}
): UseScrollPositionReturn {
  const { element, throttleMs = DEFAULT_THROTTLE_MS } = options;
 
  const [position, setPosition] =
    useState<UseScrollPositionReturn>(ZERO_SCROLL_POSITION);
 
  // Keep the latest throttle interval in a ref so changing it never re-subscribes
  // the scroll listener — the handler reads the fresh value on each event.
  const throttleRef = useLatest(throttleMs);
 
  // A single layout effect owns the whole lifecycle: it reads the initial
  // position synchronously, attaches the throttled listener, and tears both the
  // listener and any pending trailing timer down on unmount / target change.
  //
  // The effect re-runs when `element` changes. A `RefObject`'s identity is
  // stable, so a ref target subscribes once (mirroring `useEventListener`); a
  // raw element re-subscribes when its identity changes.
  useIsomorphicLayoutEffect(() => {
    const target = resolveScrollTarget(element);
    if (target === null) return;
 
    // Commit the current offset, bailing out of the re-render when unchanged so
    // no-op scrolls don't allocate a new object or re-render.
    const commit = () => {
      const next = getScrollPosition(target);
      setPosition((prev) => (arePositionsEqual(prev, next) ? prev : next));
    };
 
    // Synchronous initial read so the first commit reflects the real position.
    commit();
 
    // Timestamp-based leading+trailing throttle. `-Infinity` guarantees the very
    // first scroll fires on the leading edge regardless of the current clock.
    let lastRun = Number.NEGATIVE_INFINITY;
    let trailingTimer: ReturnType<typeof setTimeout> | null = null;
 
    const handleScroll = () => {
      const interval = throttleRef.current;
 
      // No throttle: commit on every scroll event.
      if (interval <= 0) {
        commit();
        return;
      }
 
      const now = Date.now();
      const elapsed = now - lastRun;
 
      if (elapsed >= interval) {
        // Leading edge (or a full interval has elapsed) — commit now.
        if (trailingTimer !== null) {
          clearTimeout(trailingTimer);
          trailingTimer = null;
        }
        lastRun = now;
        commit();
      } else if (trailingTimer === null) {
        // Within the throttle window — schedule a single trailing commit so the
        // final resting position is always captured.
        trailingTimer = setTimeout(() => {
          lastRun = Date.now();
          trailingTimer = null;
          commit();
        }, interval - elapsed);
      }
    };
 
    target.addEventListener("scroll", handleScroll, { passive: true });
 
    return () => {
      target.removeEventListener("scroll", handleScroll);
      if (trailingTimer !== null) {
        clearTimeout(trailingTimer);
        trailingTimer = null;
      }
    };
    // `throttleMs` is intentionally excluded — it's read live from `throttleRef`,
    // so it must not re-subscribe the listener.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [element]);
 
  return position;
}