Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 116 additions & 2 deletions src/hooks/use-now-ticker.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Comment thread
zergzorg marked this conversation as resolved.

Expand All @@ -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"))
})
})
33 changes: 31 additions & 2 deletions src/hooks/use-now-ticker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Comment thread
zergzorg marked this conversation as resolved.

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)
Expand All @@ -35,7 +64,7 @@ export function useNowTicker({
window.clearInterval(interval)
window.clearTimeout(timeout)
}
}, [enabled, intervalMs, stopAfterMs, resetKey])
}, [enabled, intervalMs, stopAfterMs, resetKey, documentVisible])

return now
}
Loading