diff --git a/src/hooks/use-now-ticker.test.ts b/src/hooks/use-now-ticker.test.ts index 68945f42..2c31c49a 100644 --- a/src/hooks/use-now-ticker.test.ts +++ b/src/hooks/use-now-ticker.test.ts @@ -1,9 +1,20 @@ import { renderHook, act } from "@testing-library/react" -import { afterEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest" import { useNowTicker } from "./use-now-ticker" describe("useNowTicker", () => { + let originalDocumentHiddenDescriptor: PropertyDescriptor | undefined + + beforeAll(() => { + originalDocumentHiddenDescriptor = Object.getOwnPropertyDescriptor(document, "hidden") + }) + afterEach(() => { + if (originalDocumentHiddenDescriptor) { + Object.defineProperty(document, "hidden", originalDocumentHiddenDescriptor) + } else { + Reflect.deleteProperty(document, "hidden") + } vi.useRealTimers() }) @@ -29,10 +40,113 @@ describe("useNowTicker", () => { expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z")) act(() => { - vi.setSystemTime(new Date("2026-02-03T00:00:05.000Z")) vi.advanceTimersByTime(5_000) }) expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z")) }) + + it("pauses ticks while the document is hidden", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-02-03T00:00:00.000Z")) + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }) + + const { result } = renderHook(() => useNowTicker({ intervalMs: 1000 })) + expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z")) + + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z")) + + const visibleNow = Date.now() + act(() => { + Object.defineProperty(document, "hidden", { + configurable: true, + value: false, + }) + document.dispatchEvent(new Event("visibilitychange")) + }) + expect(result.current).toBe(visibleNow) + }) + + it("does not refresh when disabled and the document becomes visible", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-02-03T00:00:00.000Z")) + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }) + + const { result } = renderHook(() => useNowTicker({ enabled: false, intervalMs: 1000 })) + expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z")) + + vi.setSystemTime(new Date("2026-02-03T00:00:05.000Z")) + act(() => { + Object.defineProperty(document, "hidden", { + configurable: true, + value: false, + }) + document.dispatchEvent(new Event("visibilitychange")) + }) + + expect(result.current).toBe(Date.parse("2026-02-03T00:00:00.000Z")) + }) + + it("stops an active ticker when the document becomes hidden", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-02-03T00:00:00.000Z")) + + const { result } = renderHook(() => useNowTicker({ intervalMs: 1000 })) + + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current).toBe(Date.parse("2026-02-03T00:00:01.000Z")) + + act(() => { + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }) + document.dispatchEvent(new Event("visibilitychange")) + }) + + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(result.current).toBe(Date.parse("2026-02-03T00:00:01.000Z")) + + const visibleNow = Date.now() + act(() => { + Object.defineProperty(document, "hidden", { + configurable: true, + value: false, + }) + document.dispatchEvent(new Event("visibilitychange")) + }) + expect(result.current).toBe(visibleNow) + }) + + it("keeps ticking while hidden when pauseWhenHidden is false", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-02-03T00:00:00.000Z")) + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }) + + const { result } = renderHook(() => + useNowTicker({ intervalMs: 1000, pauseWhenHidden: false }) + ) + + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(result.current).toBe(Date.parse("2026-02-03T00:00:01.000Z")) + }) }) diff --git a/src/hooks/use-now-ticker.ts b/src/hooks/use-now-ticker.ts index 06b971c1..fd995bce 100644 --- a/src/hooks/use-now-ticker.ts +++ b/src/hooks/use-now-ticker.ts @@ -4,19 +4,48 @@ type UseNowTickerOptions = { enabled?: boolean intervalMs?: number stopAfterMs?: number | null + pauseWhenHidden?: boolean resetKey?: unknown } +function isDocumentVisible() { + if (typeof document === "undefined") return true + return !document.hidden +} + export function useNowTicker({ enabled = true, intervalMs = 1000, stopAfterMs = null, + pauseWhenHidden = true, resetKey, }: UseNowTickerOptions = {}) { const [now, setNow] = useState(() => Date.now()) + const [documentVisible, setDocumentVisible] = useState(() => + pauseWhenHidden ? isDocumentVisible() : true + ) + + useEffect(() => { + if (!pauseWhenHidden || typeof document === "undefined") { + setDocumentVisible(true) + return undefined + } + + const handleVisibilityChange = () => { + const visible = isDocumentVisible() + setDocumentVisible(visible) + if (visible && enabled) { + setNow(Date.now()) + } + } + + handleVisibilityChange() + document.addEventListener("visibilitychange", handleVisibilityChange) + return () => document.removeEventListener("visibilitychange", handleVisibilityChange) + }, [enabled, pauseWhenHidden]) useEffect(() => { - if (!enabled) return undefined + if (!enabled || !documentVisible) return undefined setNow(Date.now()) const interval = window.setInterval(() => setNow(Date.now()), intervalMs) @@ -35,7 +64,7 @@ export function useNowTicker({ window.clearInterval(interval) window.clearTimeout(timeout) } - }, [enabled, intervalMs, stopAfterMs, resetKey]) + }, [enabled, intervalMs, stopAfterMs, resetKey, documentVisible]) return now }