Skip to content
Draft
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
130 changes: 62 additions & 68 deletions src/hooks/useAdaptivePoll.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,55 @@
import { useEffect, useRef, useState } from "react"
import { sleep } from "../utils/sleep"

type AdaptivePollLoopOptions = {
fetchFn: () => Promise<void>
signal: AbortSignal
minIntervalMs: number
maxIntervalMs: number
sampleSize?: number
multiplier?: number
skipInitialFetch?: boolean
onIntervalChange?: (intervalMs: number) => void
}

export const runAdaptivePollLoop = async ({
fetchFn,
signal,
minIntervalMs,
maxIntervalMs,
sampleSize = 3,
multiplier = 2,
skipInitialFetch = false,
onIntervalChange,
}: AdaptivePollLoopOptions): Promise<void> => {
let samples: number[] = []
let skip = skipInitialFetch
while (!signal.aborted) {
let nextInterval = minIntervalMs
if (skip) {
// Data was just transferred in — wait one interval before the first
// background refresh instead of re-querying it immediately.
skip = false
} else {
const start = performance.now()
try {
await fetchFn()
} catch {
// fetchFn handles its own errors; never let one kill the loop
}
if (signal.aborted) break
samples = [...samples, performance.now() - start].slice(-sampleSize)
const avg = samples.reduce((a, b) => a + b, 0) / samples.length
nextInterval = Math.min(
maxIntervalMs,
Math.max(minIntervalMs, Math.round(avg * multiplier)),
)
onIntervalChange?.(nextInterval)
}
const aborted = await sleep(nextInterval, signal)
if (aborted) break
}
}

type AdaptivePollOptions = {
fetchFn: () => Promise<void>
Expand Down Expand Up @@ -29,85 +80,28 @@ export const useAdaptivePoll = (
getSkipInitialFetch,
} = options

const samplesRef = useRef<number[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const [currentInterval, setCurrentInterval] = useState(minIntervalMs)

useEffect(() => {
samplesRef.current = []
setCurrentInterval(minIntervalMs)

// Abort any previous polling loop
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current?.abort()

if (!enabled) return

const abortController = new AbortController()
abortControllerRef.current = abortController

const calculateInterval = (samples: number[]): number => {
if (samples.length === 0) return minIntervalMs

const avg = samples.reduce((a, b) => a + b, 0) / samples.length
const calculated = avg * multiplier

return Math.min(
maxIntervalMs,
Math.max(minIntervalMs, Math.round(calculated)),
)
}

const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(resolve, ms)
signal.addEventListener("abort", () => {
clearTimeout(timeoutId)
reject(new DOMException("Aborted", "AbortError"))
})
})

const runPollingLoop = async () => {
let skipFetch = getSkipInitialFetch?.() ?? false
while (!abortController.signal.aborted) {
let nextInterval = minIntervalMs

if (skipFetch) {
// Data was just transferred in — wait one interval before the first
// background refresh instead of re-querying it immediately.
skipFetch = false
} else {
const startTime = performance.now()

try {
await fetchFn()
} catch (error) {
// Silently handle errors - the fetchFn should handle its own errors
}

// Check if we should stop after the fetch completed
if (abortController.signal.aborted) break

const responseTime = performance.now() - startTime
samplesRef.current = [...samplesRef.current, responseTime].slice(
-sampleSize,
)

nextInterval = calculateInterval(samplesRef.current)
setCurrentInterval(nextInterval)
}

try {
await sleep(nextInterval, abortController.signal)
} catch (error) {
// Sleep was aborted, exit the loop
break
}
}
}

void runPollingLoop()
void runAdaptivePollLoop({
fetchFn,
signal: abortController.signal,
minIntervalMs,
maxIntervalMs,
sampleSize,
multiplier,
skipInitialFetch: getSkipInitialFetch?.() ?? false,
onIntervalChange: setCurrentInterval,
})

return () => {
abortController.abort()
Expand Down
Loading
Loading