All files / hooks/use-idle/src useIdle.ts

97.5% Statements 39/40
80% Branches 16/20
100% Functions 7/7
97.5% Lines 39/40

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 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183                                                                                                                                                                        44x   44x       44x   44x         21x 21x           21x             21x   21x   13x     21x 13x 13x 13x   13x     21x 14x 14x 2x   12x 12x     21x       2x 1x       21x   21x 130x 18x 18x       18x             112x 112x         21x   21x 21x 21x   21x 130x             44x    
import { useEffect, useState } from "react";
import type { IdleEventTarget, UseIdleOptions, UseIdleReturn } from "./types";
import {
  ACTIVITY_THROTTLE_MS,
  DEFAULT_IDLE_EVENTS,
  VISIBILITY_CHANGE_EVENT,
  isDocumentAvailable,
  resolveIdleTarget,
} from "./utils";
 
/**
 * Track whether the user has been **inactive** for a given timeout.
 *
 * Returns `false` while the user is active and flips to `true` once no listened
 * activity (mouse, keyboard, touch, wheel, resize, tab focus) has occurred for
 * `timeout` milliseconds. The very next activity flips it back to `false` and
 * restarts the timer.
 *
 * ### Throttled activity
 * High-frequency events (`mousemove`, `wheel`, `resize`) are throttled with a
 * leading-edge guard so the idle timer is reset at most once every
 * ~{@link ACTIVITY_THROTTLE_MS}ms — enough to keep the timer alive during
 * continuous activity without re-running state work on every pointer move.
 *
 * ### Visibility awareness
 * `"visibilitychange"` is handled specially (and always bound to `document`):
 * **returning** to a backgrounded tab counts as activity and resets the timer,
 * while **backgrounding** the tab is *not* treated as activity — the timer keeps
 * running, so a user who switches away is allowed to fall idle. This is the
 * convention used by `react-use` and `@mantine/hooks`.
 *
 * ### SSR & concurrency
 * SSR-safe: on the server (no `window`) no listeners are attached and the hook
 * returns `initialState` inertly, avoiding hydration mismatches. StrictMode /
 * concurrent-safe: state is only ever set from event handlers and timers (never
 * from a render), and every listener + timer is torn down on unmount or when the
 * `timeout`/`events`/`element` inputs change, so there are no leaks or double
 * timers.
 *
 * @param timeout - Inactivity threshold in milliseconds before the user is
 * considered idle. Defaults to `60_000` (one minute).
 * @param options - Optional configuration (activity `events`, `initialState`,
 * target `element`).
 * @returns `true` once the user has been idle for `timeout` ms, `false` while
 * active.
 *
 * @example
 * ```tsx
 * // Basic: consider the user idle after one minute of inactivity
 * function AwayBadge() {
 *   const idle = useIdle(60_000);
 *   return <span>{idle ? "💤 Away" : "🟢 Active"}</span>;
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Log the user out after 5 minutes of inactivity
 * function SessionGuard() {
 *   const idle = useIdle(5 * 60_000);
 *   useEffect(() => {
 *     if (idle) logout();
 *   }, [idle]);
 *   return null;
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Only listen to keyboard activity, start in the idle state
 * const idle = useIdle(30_000, {
 *   events: ["keydown"],
 *   initialState: true,
 * });
 * ```
 */
export function useIdle(
  timeout: number = 60_000,
  options: UseIdleOptions = {}
): UseIdleReturn {
  const {
    events = DEFAULT_IDLE_EVENTS,
    initialState = false,
    element,
  } = options;
 
  const [idle, setIdle] = useState<boolean>(initialState);
 
  // Serialize the event list into a stable primitive dependency so passing a
  // fresh array of the same events each render does not re-subscribe.
  const eventsKey = events.join(",");
 
  useEffect(() => {
    // Resolve the activity target. Under SSR (no `window`) with no explicit
    // `element`, this is `undefined` and no listeners are attached — the hook
    // returns `initialState` inertly. (Effects never run during SSR anyway; this
    // guard covers exotic client environments without a `window`.)
    const target = resolveIdleTarget(element);
    Iif (!target) {
      return;
    }
 
    let timer: ReturnType<typeof setTimeout> | undefined;
    // Leading-edge throttle bookkeeping (per effect instance).
    let lastActivity = 0;
    // Keep the throttle window strictly below the timeout so continuous
    // activity always resets the timer before it can fire. Capping at the
    // timeout itself is not enough: with `throttleMs === timeout` the earliest
    // allowed reset lands exactly when the pending idle timer fires, so a
    // dropped event inside that window lets `idle` flip true mid-activity.
    // Halving leaves a non-empty reset window even for sub-throttle timeouts.
    const throttleMs = Math.min(ACTIVITY_THROTTLE_MS, Math.floor(timeout / 2));
 
    const markIdle = () => {
      // No-op skip: React bails out when the value is unchanged.
      setIdle(true);
    };
 
    const markActive = () => {
      setIdle(false);
      Eif (timer !== undefined) {
        clearTimeout(timer);
      }
      timer = setTimeout(markIdle, timeout);
    };
 
    const handleActivity = () => {
      const now = Date.now();
      if (now - lastActivity < throttleMs) {
        return;
      }
      lastActivity = now;
      markActive();
    };
 
    const handleVisibility = () => {
      // Returning to a visible tab is activity; backgrounding is not (the timer
      // keeps running so the user can fall idle). Coerce strictly: only a
      // genuine `document.hidden === true` counts as backgrounded.
      if (isDocumentAvailable() && document.hidden !== true) {
        markActive();
      }
    };
 
    const attached: Array<[IdleEventTarget, string, EventListener]> = [];
 
    for (const event of events) {
      if (event === VISIBILITY_CHANGE_EVENT) {
        Eif (isDocumentAvailable()) {
          document.addEventListener(
            VISIBILITY_CHANGE_EVENT,
            handleVisibility
          );
          attached.push([
            document,
            VISIBILITY_CHANGE_EVENT,
            handleVisibility as EventListener,
          ]);
        }
      } else {
        target.addEventListener(event, handleActivity);
        attached.push([target, event, handleActivity as EventListener]);
      }
    }
 
    // Start the inactivity timer on mount / whenever inputs change.
    timer = setTimeout(markIdle, timeout);
 
    return () => {
      Eif (timer !== undefined) {
        clearTimeout(timer);
      }
      for (const [t, event, listener] of attached) {
        t.removeEventListener(event, listener);
      }
    };
    // `events` is captured via its serialized `eventsKey`.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [timeout, eventsKey, element]);
 
  return idle;
}