All files / hooks/use-scroll-lock/src useScrollLock.ts

100% Statements 28/28
100% Branches 10/10
100% Functions 8/8
100% Lines 25/25

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                                                                                                                                        40x   40x       40x   40x 14x 13x 13x 13x     40x 9x 8x 8x 8x           40x 20x 4x 4x 4x               40x 18x 18x 5x 5x         40x 37x        
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useIsomorphicLayoutEffect } from "@usefy/use-isomorphic-layout-effect";
import { lockBodyScroll, unlockBodyScroll } from "./scrollLockManager";
import type { UseScrollLockOptions, UseScrollLockReturn } from "./types";
 
/**
 * Lock and unlock the page (`document.body`) scroll — the reliable way to keep
 * the background from scrolling behind a modal, drawer, or menu.
 *
 * Locking sets `overflow: hidden` on the body and adds `padding-right` equal to
 * the scrollbar width so the content never jumps sideways when the scrollbar
 * disappears. On iOS/Safari — where `overflow: hidden` does not stop touch
 * scrolling — it instead pins the body with `position: fixed` and restores the
 * scroll position with `window.scrollTo` on unlock.
 *
 * Behaviour notes:
 * - **Nested / reference-counted**: a single module-level counter is shared by
 *   every `useScrollLock` instance, so N stacked locks apply the body styles
 *   once and only restore the body when the **last** lock is released. The
 *   original inline body styles (and scroll position) are captured on `0 → 1`
 *   and written back exactly on `1 → 0`.
 * - **Idempotent per instance**: calling `lock()` twice on the same instance
 *   (or `unlock()` when it isn't locked) is a no-op, so an instance can never
 *   double-count against the shared counter.
 * - **StrictMode / concurrent-safe**: an unmount always releases any lock this
 *   instance still holds, so React 18's double mount→unmount→mount never leaks
 *   a lock or leaves the body stuck.
 * - **SSR-safe**: no `window`/`document` access on the server — `isLocked` is
 *   `false` and `lock`/`unlock` are no-ops until it runs on the client.
 *
 * @param options - Optional `enabled` flag to lock automatically for as long as
 *   it is `true` (locks on mount, releases on unmount). Omit it to lock
 *   imperatively via the returned `lock` / `unlock`.
 * @returns `{ lock, unlock, isLocked }` — stable `lock`/`unlock` callbacks and a
 *   boolean reflecting whether **this** instance currently holds a lock.
 *
 * @example
 * ```tsx
 * // Imperative — lock while a modal is open.
 * import { useScrollLock } from "@usefy/use-scroll-lock";
 *
 * function Modal({ open, onClose }: { open: boolean; onClose: () => void }) {
 *   const { lock, unlock } = useScrollLock();
 *
 *   useEffect(() => {
 *     if (open) lock();
 *     else unlock();
 *   }, [open, lock, unlock]);
 *
 *   if (!open) return null;
 *   return <div role="dialog">…</div>;
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Declarative — let `enabled` own the lock for the drawer's lifetime.
 * import { useScrollLock } from "@usefy/use-scroll-lock";
 *
 * function Drawer({ open }: { open: boolean }) {
 *   useScrollLock({ enabled: open });
 *   return open ? <aside>…</aside> : null;
 * }
 * ```
 */
export function useScrollLock(
  options: UseScrollLockOptions = {}
): UseScrollLockReturn {
  const { enabled = false } = options;
 
  const [isLocked, setIsLocked] = useState(false);
  // Mirror of `isLocked` that updates synchronously and survives into cleanup,
  // so idempotency checks and the unmount safety net don't depend on a stale
  // render's state.
  const lockedRef = useRef(false);
 
  const lock = useCallback(() => {
    if (lockedRef.current) return;
    lockedRef.current = true;
    lockBodyScroll();
    setIsLocked(true);
  }, []);
 
  const unlock = useCallback(() => {
    if (!lockedRef.current) return;
    lockedRef.current = false;
    unlockBodyScroll();
    setIsLocked(false);
  }, []);
 
  // `enabled` convenience: hold a lock for as long as it is true. A layout
  // effect so the lock lands before paint. `lock`/`unlock` are stable, so this
  // only re-runs when `enabled` itself changes.
  useIsomorphicLayoutEffect(() => {
    if (!enabled) return;
    lock();
    return () => {
      unlock();
    };
  }, [enabled, lock, unlock]);
 
  // Unmount safety net: if this instance is still holding a lock (e.g. an
  // imperative `lock()` that was never paired with `unlock()`), release it so
  // the shared counter and the body can't leak. Decrement directly rather than
  // calling `unlock()` to avoid a needless state update on an unmounting tree.
  useEffect(() => {
    return () => {
      if (lockedRef.current) {
        lockedRef.current = false;
        unlockBodyScroll();
      }
    };
  }, []);
 
  return useMemo<UseScrollLockReturn>(
    () => ({ lock, unlock, isLocked }),
    [lock, unlock, isLocked]
  );
}