codeburn/app/renderer/hooks/usePolled.test.ts
iamtoruk e4bbcaaba7 fix(app): zero background work when hidden; currency applies instantly
Energy (field report: ~3x Chrome drain):
- polls skip entirely while the window is hidden/minimized, with one
  catch-up refresh on return when data is stale; visible-but-unfocused
  keeps polling (second-monitor case)
- all looping animations pause under html.page-hidden (the sidebar
  flame flicker was a perpetual compositor drain)
- default cadence 60s; an explicit stored choice is always honored

Currency (field report: set USD, still saw EUR):
- memo-served payloads re-applied their embedded stale currency; config
  mutations now purge the renderer memo and force-refresh, and a
  switching payload can never overwrite the applied currency
- USD default verified end to end

Measured: hidden window = 0 new CLI spawns over 5 cadences (was: full
polling forever). App 340/340, build + package green.
2026-07-16 18:41:28 -07:00

246 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { createElement, type ReactNode } from 'react'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { RefreshCadenceContext, type RefreshCadence } from '../lib/refreshCadence'
import { clearPolledMemo, hasPolledMemo, primePolledMemo, usePolled } from './usePolled'
function cadenceWrapper(intervalMs: number | null) {
const value: RefreshCadence = { value: 'x', intervalMs, setValue: () => {} }
return ({ children }: { children: ReactNode }) => createElement(RefreshCadenceContext.Provider, { value }, children)
}
/** Override document visibility for the hidden-polling tests. jsdom defaults to
* 'visible'; the own-property override is torn down in afterEach. */
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
}
afterEach(() => {
delete (document as unknown as { visibilityState?: unknown }).visibilityState
delete (document as unknown as { hidden?: unknown }).hidden
})
describe('usePolled', () => {
it('discards a stale in-flight fetch that resolves after a newer one (epoch guard)', async () => {
// A fetcher we resolve by hand, one deferred per call, so we can force a
// SLOW deps-A fetch to resolve AFTER a FAST deps-B fetch.
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
const { result, rerender } = renderHook(
({ p }: { p: string }) => usePolled(fetcher, [p]),
{ initialProps: { p: 'A' } },
)
// #0 mount fetch (deps A) — resolve it to establish a known baseline.
await act(async () => { resolvers[0]!('A0') })
expect(fetcher).toHaveBeenCalledTimes(1)
expect(result.current.data).toBe('A0')
// #1 refresh() while deps are still A → an in-flight SLOW fetch whose cancel
// handle the hook discards. Leave it unresolved for now.
act(() => { result.current.refresh() })
expect(fetcher).toHaveBeenCalledTimes(2)
// #2 deps change A→B → a FAST fetch that resolves first with fresh data.
rerender({ p: 'B' })
expect(fetcher).toHaveBeenCalledTimes(3)
await act(async () => { resolvers[2]!('B-fresh') })
expect(result.current.data).toBe('B-fresh')
// #1 (the slow deps-A fetch) now resolves LATE. It must NOT clobber B.
await act(async () => { resolvers[1]!('A-stale') })
expect(result.current.data).toBe('B-fresh')
})
it('does not fetch while disabled, then fires once enabled flips true', async () => {
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
const { result, rerender } = renderHook(
({ enabled }: { enabled: boolean }) => usePolled(fetcher, ['x'], { enabled }),
{ initialProps: { enabled: false } },
)
// Gated: no spawn, still in the initial loading state (splash/skeleton stays).
expect(fetcher).not.toHaveBeenCalled()
expect(result.current.loading).toBe(true)
expect(result.current.data).toBeNull()
// Gate opens (first overview resolved): the fetch fires exactly once.
rerender({ enabled: true })
expect(fetcher).toHaveBeenCalledTimes(1)
await act(async () => { resolvers[0]!('ready') })
expect(result.current.data).toBe('ready')
})
it('keeps last-good data and exposes the error when a background reload fails', async () => {
const calls: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
const fetcher = vi.fn(() => new Promise<string>((resolve, reject) => { calls.push({ resolve, reject }) }))
const { result } = renderHook(() => usePolled(fetcher, []))
// Establish last-good data.
await act(async () => { calls[0]!.resolve('good') })
expect(result.current.data).toBe('good')
expect(result.current.error).toBeNull()
// A reload clears the error up front; if it fails, data is retained and the
// error is surfaced alongside it (the StaleBanner condition).
act(() => { result.current.refresh() })
expect(result.current.error).toBeNull()
await act(async () => { calls[1]!.reject({ kind: 'nonzero', message: 'boom' }) })
expect(result.current.data).toBe('good')
expect(result.current.error).toMatchObject({ kind: 'nonzero', message: 'boom' })
})
it('serves last-good data instantly on switch-back and flags `switching` while it refreshes', async () => {
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
// Mount on key kA, resolve to A0 → memoized under kA.
const { result, rerender } = renderHook(
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }),
{ initialProps: { k: 'kA' } },
)
await act(async () => { resolvers[0]!('A0') })
expect(result.current.data).toBe('A0')
expect(result.current.switching).toBe(false)
// Switch to a fresh key kB, resolve to B0 → memoized under kB.
rerender({ k: 'kB' })
await act(async () => { resolvers[1]!('B0') })
expect(result.current.data).toBe('B0')
// Switch BACK to kA: the memoized A0 paints in the same commit (no blank, no
// B0 freeze) and `switching` is true while the fresh fetch runs behind it.
rerender({ k: 'kA' })
expect(result.current.data).toBe('A0')
expect(result.current.switching).toBe(true)
expect(result.current.loading).toBe(true)
// The fresh fetch resolves → new data, switching clears.
await act(async () => { resolvers[2]!('A1') })
expect(result.current.data).toBe('A1')
expect(result.current.switching).toBe(false)
})
it('clears stale data on a switch to an unmemoized key (skeleton, never the prior filter)', async () => {
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
const { result, rerender } = renderHook(
({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }),
{ initialProps: { k: 'miss-A' } },
)
await act(async () => { resolvers[0]!('A0') })
expect(result.current.data).toBe('A0')
// Switch to a brand-new key with nothing memoized: data must drop to null so
// the section paints its skeleton, NOT the previous filter's numbers. This is
// the "old numbers for 2-3s on switch" fix — no cache hit, no stale hold.
rerender({ k: 'miss-B' })
expect(result.current.data).toBeNull()
expect(result.current.switching).toBe(false)
expect(result.current.loading).toBe(true)
await act(async () => { resolvers[1]!('B0') })
expect(result.current.data).toBe('B0')
// A background re-poll on the SAME key (its last result is memoized) must keep
// showing data — the clear-on-miss must never blank a plain refresh.
act(() => { result.current.refresh() })
expect(result.current.data).toBe('B0')
})
it('manual cadence (null interval) polls only on mount + refresh, never on a timer', async () => {
vi.useFakeTimers()
try {
const fetcher = vi.fn().mockResolvedValue('x')
const { result } = renderHook(() => usePolled(fetcher, [], { intervalMs: null }))
expect(fetcher).toHaveBeenCalledTimes(1) // mount
await act(async () => { await vi.advanceTimersByTimeAsync(600_000) })
expect(fetcher).toHaveBeenCalledTimes(1) // no interval fired
act(() => { result.current.refresh() })
expect(fetcher).toHaveBeenCalledTimes(2) // manual refresh still works
} finally {
vi.useRealTimers()
}
})
it('skips interval polls while the document is hidden, then catches up on return to visible', async () => {
vi.useFakeTimers()
try {
setVisibility('visible')
const fetcher = vi.fn().mockResolvedValue('x')
renderHook(() => usePolled(fetcher, [], { intervalMs: 1000 }))
expect(fetcher).toHaveBeenCalledTimes(1) // mount fetch
// Let the mount fetch resolve so lastSuccess is recorded.
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
// Hidden (minimized/occluded): five interval ticks must spawn NOTHING.
setVisibility('hidden')
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(fetcher).toHaveBeenCalledTimes(1)
// Back to visible with the last success now older than a full cadence:
// exactly one immediate catch-up fetch, not a wait for the next tick.
setVisibility('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(fetcher).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('does not fire a catch-up when returning to visible within one cadence', async () => {
vi.useFakeTimers()
try {
setVisibility('visible')
const fetcher = vi.fn().mockResolvedValue('x')
renderHook(() => usePolled(fetcher, [], { intervalMs: 10_000 }))
expect(fetcher).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(0) })
// Hidden only briefly (well under the 10s cadence), then visible again:
// the last success is still fresh, so no catch-up fetch.
setVisibility('hidden')
await act(async () => { await vi.advanceTimersByTimeAsync(500) })
setVisibility('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(fetcher).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('clearPolledMemo empties the instant-switch memo', () => {
primePolledMemo('k1', 'v1')
primePolledMemo('k2', 'v2')
expect(hasPolledMemo('k1')).toBe(true)
expect(hasPolledMemo('k2')).toBe(true)
clearPolledMemo()
expect(hasPolledMemo('k1')).toBe(false)
expect(hasPolledMemo('k2')).toBe(false)
})
it('defaults the interval to the RefreshCadence context, and Manual disables the timer', async () => {
vi.useFakeTimers()
try {
const timed = vi.fn().mockResolvedValue('x')
renderHook(() => usePolled(timed, []), { wrapper: cadenceWrapper(60_000) })
expect(timed).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(60_000) })
expect(timed).toHaveBeenCalledTimes(2) // context interval fired
const manual = vi.fn().mockResolvedValue('x')
renderHook(() => usePolled(manual, []), { wrapper: cadenceWrapper(null) })
expect(manual).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(600_000) })
expect(manual).toHaveBeenCalledTimes(1) // Manual context → no timer
} finally {
vi.useRealTimers()
}
})
})