{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-page-visibility",
  "title": "usePageVisibility",
  "description": "Subscribes to page visibility via useSyncExternalStore so polling, timers and video pause when the tab is hidden, with onVisible/onHidden callbacks and the away duration.",
  "files": [
    {
      "path": "src/registry/hooks/use-page-visibility.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport interface PageVisibilityState {\n  /** Whether the page is visible right now (the boolean projection of `visibilityState === \"visible\"`). */\n  isVisible: boolean\n  /** The raw `document.visibilityState`, for when you need the states beyond `\"visible\"`. */\n  visibilityState: DocumentVisibilityState\n  /** Timestamp (`Date.now()`) of the last time the page was switched away from. **Kept after\n   *  returning**, so you can still show \"you left at …\" once back; `null` until the first\n   *  switch away since mount. */\n  hiddenSince: number | null\n  /** How many times the page has become visible. If it was already visible at mount, that counts as 1. */\n  visibleCount: number\n}\n\nexport interface UsePageVisibilityOptions {\n  /** Called when the page goes from hidden back to visible, with how long it was hidden in ms. Not called on mount (mount is not a switch). */\n  onVisible?: (hiddenDurationMs: number) => void\n  /** Called when the page is switched away from, with the timestamp (`Date.now()`). Not called on mount. */\n  onHidden?: (hiddenAt: number) => void\n}\n\ntype StoreListener = (next: PageVisibilityState, prev: PageVisibilityState, at: number) => void\n\n/** Fallback snapshot for SSR and the first frame: assume visible. There is no `document` on the\n *  server, and nearly every first paint happens in a visible foreground tab — assuming visible\n *  causes far fewer \"content wrongly paused\" hydration flashes than assuming hidden.\n *  Reusing one constant object gives `getServerSnapshot` a stable identity, so it can't loop. */\nconst SERVER_STATE: PageVisibilityState = {\n  isVisible: true,\n  visibilityState: \"visible\",\n  hiddenSince: null,\n  visibleCount: 0,\n}\n\n// Module-level singleton store: N usePageVisibility call sites on a page still share one\n// visibilitychange listener and one snapshot (visibleCount doesn't fork per component).\nlet state: PageVisibilityState = SERVER_STATE\nconst listeners = new Set<StoreListener>()\n\n// The snapshot must be a cached object: `useSyncExternalStore` calls getSnapshot during render,\n// and returning a fresh object each time reads as \"the external store keeps changing\" and loops\n// forever. The object is rebuilt only on a real visibilitychange; render reads neither\n// `document` nor the clock.\nconst getSnapshot = () => state\nconst getServerSnapshot = () => SERVER_STATE\n\n/** How hiddenSince advances — one rule shared by the event and by re-subscription:\n *  - visible: keep the previously recorded value (still readable after returning);\n *  - just went visible → hidden: take this event's timestamp;\n *  - already hidden (duplicate event / re-subscribed while hidden): keep it, don't reset the start. */\nfunction nextHiddenSince(prev: PageVisibilityState, isVisible: boolean, at: number) {\n  if (isVisible) return prev.hiddenSince\n  if (prev.isVisible) return at\n  return prev.hiddenSince ?? at\n}\n\n/** On subscribe, align the snapshot with the real `document` (runs in an effect, not render).\n *  A silent update: no listeners notified, no onVisible/onHidden — mount is not a \"switch\".\n *  React re-reads the snapshot after subscribe returns and re-renders if it changed. */\nfunction syncFromDocument(at: number) {\n  const visibilityState = document.visibilityState\n  const isVisible = visibilityState === \"visible\"\n  const prev = state\n  state = {\n    isVisible,\n    visibilityState,\n    // Switches that happened while nobody was subscribed left no event, so the honest answer\n    // is to treat the moment we started observing as the start of hidden (the browser won't\n    // tell you when the tab actually went to the background).\n    hiddenSince: nextHiddenSince(prev, isVisible, at),\n    // last recorded hidden but now visible → a return happened in between, count it;\n    // visible on first mount counts as the 1st.\n    visibleCount: isVisible\n      ? prev.isVisible\n        ? Math.max(prev.visibleCount, 1)\n        : prev.visibleCount + 1\n      : prev.visibleCount,\n  }\n}\n\nfunction handleVisibilityChange() {\n  const at = Date.now()\n  const visibilityState = document.visibilityState\n  // drop same-value events: some browsers re-dispatch on window focus/blur, and without this\n  // dedupe one switch would be counted twice in hiddenSince and visibleCount.\n  if (visibilityState === state.visibilityState) return\n\n  const prev = state\n  const isVisible = visibilityState === \"visible\"\n  state = {\n    isVisible,\n    visibilityState,\n    hiddenSince: nextHiddenSince(prev, isVisible, at),\n    visibleCount: isVisible && !prev.isVisible ? prev.visibleCount + 1 : prev.visibleCount,\n  }\n\n  // iterate a copy: a listener that unsubscribes (component unmounts inside the callback)\n  // must not disturb this pass.\n  for (const listener of [...listeners]) listener(state, prev, at)\n}\n\nfunction subscribeToPageVisibility(listener: StoreListener) {\n  if (typeof document === \"undefined\") return () => {}\n\n  listeners.add(listener)\n  if (listeners.size === 1) {\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange)\n    syncFromDocument(Date.now())\n  }\n\n  return () => {\n    listeners.delete(listener)\n    if (listeners.size === 0) {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange)\n    }\n  }\n}\n\n/**\n * Subscribe to page visibility (tab switched away / back) to pause polling, video, animations\n * and timers, and resume on return.\n *\n * Built on `useSyncExternalStore` over `document`'s `visibilitychange`: subscribe attaches the\n * listener, getSnapshot returns the module-level cached snapshot, getServerSnapshot falls back\n * to \"visible\" on the server. No `useEffect` + `setState` anywhere, and render reads neither\n * `document` nor the clock.\n *\n * `onVisible` / `onHidden` go through latest-refs: the callbacks live in refs and never enter a\n * dependency array, so inline arrows at the call site don't tear the listener down and back up.\n * Both fire only on a **real switch** — never on mount or unmount.\n *\n * **Honest boundary**: `visibilitychange` answers \"is this document invisible to the user\"\n * (tab switch, minimise, lock screen, backgrounded on mobile). It is **not** \"the window lost\n * focus\" — drag the browser aside or switch to another app while the page stays on screen and\n * most desktop browsers still report `visible`; for pause-on-blur, listen to `window`'s\n * `blur`/`focus` as well. It is also **not** \"the element scrolled out of the viewport\" — that\n * is `IntersectionObserver`'s job.\n */\nexport function usePageVisibility(options: UsePageVisibilityOptions = {}): PageVisibilityState {\n  const { onVisible, onHidden } = options\n\n  // latest-ref: sync the newest callbacks into refs after each render; subscribe only reads ref.current.\n  const onVisibleRef = React.useRef(onVisible)\n  const onHiddenRef = React.useRef(onHidden)\n  React.useEffect(() => {\n    onVisibleRef.current = onVisible\n    onHiddenRef.current = onHidden\n  })\n\n  // empty deps → subscribe keeps one identity → React never re-attaches the listener.\n  const subscribe = React.useCallback(\n    (onStoreChange: () => void) =>\n      subscribeToPageVisibility((next, prev, at) => {\n        onStoreChange()\n        if (next.isVisible && !prev.isVisible) {\n          // reading the clock in a callback is safe (not render); prev.hiddenSince is always\n          // set in practice — the 0 fallback just keeps null from leaking to consumers.\n          onVisibleRef.current?.(prev.hiddenSince === null ? 0 : at - prev.hiddenSince)\n        } else if (!next.isVisible && prev.isVisible) {\n          onHiddenRef.current?.(at)\n        }\n      }),\n    [],\n  )\n\n  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n}\n\nexport default usePageVisibility\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}