Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | 1x 1x 1x 126x 126x 126x 126x 126x 126x 15x 15x 15x 126x 126x 126x 126x 126x 126x 126x 126x 126x 126x 61x 61x 9x 9x 126x 7x 7x 1x 126x 9x 9x 9x 9x 9x 9x 9x 2x 2x 9x 9x 9x 7x 7x 7x 7x 6x 6x 1x 2x 2x 2x 9x 9x 126x 2x 2x 2x 1x 1x 1x 1x 1x 1x 126x 17x 3x 3x 17x 3x 3x 126x 60x 60x 60x 60x 60x 1x 1x 60x 126x 126x 126x 126x 126x 16x 16x 16x 16x 16x 126x 16x 16x 16x 126x | import { useCallback, useEffect, useRef, useState } from 'react'
import type { SaveStatus } from './components/SaveIndicator'
// How long to wait after the last keystroke before persisting.
const SAVE_DELAY_MS = 400
// How long a request may run before we bother showing the spinner — fast saves
// finish within this window
const SPINNER_DELAY_MS = 300
// How long the green checkmark lingers before the indicator clears.
const CHECKMARK_MS = 1500
export function usePrefsAutosave<P>(options: {
save: (payload: P, keepalive?: boolean) => Promise<void>
onError: (error: unknown) => void
delay?: number
spinnerDelay?: number
checkmarkMs?: number
}) {
const {
delay = SAVE_DELAY_MS,
spinnerDelay = SPINNER_DELAY_MS,
checkmarkMs = CHECKMARK_MS,
} = options
const saveRef = useRef(options.save)
const onErrorRef = useRef(options.onError)
const [statuses, setStatuses] = useState<Record<string, SaveStatus>>({})
const statusesRef = useRef(statuses)
const apply = useCallback((field: string, status: SaveStatus) => {
const next = { ...statusesRef.current, [field]: status }
statusesRef.current = next
setStatuses(next)
}, [])
const payloadRef = useRef<P | null>(null)
const versionRef = useRef<Record<string, number>>({})
const savingVersionRef = useRef<Record<string, number>>({})
const dirtyRef = useRef<Set<string>>(new Set())
const inFlightRef = useRef(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const spinnerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fadeRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const flushRef = useRef<() => void>(() => {})
const scheduleFlush = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
debounceRef.current = null
flushRef.current()
}, delay)
}, [delay])
const scheduleFade = useCallback(
(field: string) => {
Iif (fadeRef.current[field]) clearTimeout(fadeRef.current[field])
fadeRef.current[field] = setTimeout(() => {
// Only clear if it is still showing the check (no new edit arrived).
Eif (statusesRef.current[field] === 'saved') apply(field, 'idle')
}, checkmarkMs)
},
[apply, checkmarkMs],
)
const flush = () => {
// A request is already running — it reschedules itself on settle.
Iif (inFlightRef.current || dirtyRef.current.size === 0) return
const fields = dirtyRef.current
dirtyRef.current = new Set()
fields.forEach((f) => {
savingVersionRef.current[f] = versionRef.current[f] ?? 0
})
if (spinnerTimerRef.current) clearTimeout(spinnerTimerRef.current)
spinnerTimerRef.current = setTimeout(() => {
fields.forEach((f) => {
Eif (statusesRef.current[f] !== 'resaving') apply(f, 'saving')
})
}, spinnerDelay)
const payload = payloadRef.current as P
inFlightRef.current = true
saveRef.current(payload)
.then(
() => {
Eif (spinnerTimerRef.current) clearTimeout(spinnerTimerRef.current)
fields.forEach((f) => {
const settled = (versionRef.current[f] ?? 0) === savingVersionRef.current[f]
if (settled) {
apply(f, 'saved')
scheduleFade(f)
} else {
apply(f, 'resaving')
}
})
},
(err: unknown) => {
Eif (spinnerTimerRef.current) clearTimeout(spinnerTimerRef.current)
// Surface a warning on the affected fields; the value stays in the
// input, so the next keystroke retries it (no auto-retry loop).
fields.forEach((f) => apply(f, 'error'))
onErrorRef.current(err)
},
)
.finally(() => {
inFlightRef.current = false
// Edits arrived while we were saving and the user has since stopped —
// persist them.
if (dirtyRef.current.size > 0) scheduleFlush()
})
}
// Update the indicator through a save made outside the debounced edit flow (e.g. deleting a row)
const trackSave = useCallback(
async (field: string, run: () => Promise<void>) => {
const timer = setTimeout(() => apply(field, 'saving'), spinnerDelay)
try {
await run()
} catch (err) {
clearTimeout(timer)
apply(field, 'idle')
throw err
}
clearTimeout(timer)
apply(field, 'saved')
scheduleFade(field)
},
[apply, scheduleFade, spinnerDelay],
)
const savePendingEditsNow = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
if (inFlightRef.current || dirtyRef.current.size === 0) return
dirtyRef.current = new Set()
saveRef.current(payloadRef.current as P, true).catch((err) => onErrorRef.current(err))
}, [])
// Call on every edit of `field`, passing the full latest payload.
const notifyEdit = useCallback(
(field: string, payload: P) => {
payloadRef.current = payload
versionRef.current[field] = (versionRef.current[field] ?? 0) + 1
dirtyRef.current.add(field)
// Resuming typing clears a finished check / warning right away; no spinner
// is shown until the request is actually sent.
const current = statusesRef.current[field]
if (current === 'saved' || current === 'error') {
Iif (fadeRef.current[field]) clearTimeout(fadeRef.current[field])
apply(field, 'idle')
}
scheduleFlush()
},
[apply, scheduleFlush],
)
// Point the timer-driven closures at the latest render's values. Runs after
// every commit; writing refs here (not during render) keeps react-hooks happy.
useEffect(() => {
saveRef.current = options.save
onErrorRef.current = options.onError
flushRef.current = flush
})
// Save pending edits when the user navigates
useEffect(() => {
const onPageHide = () => savePendingEditsNow()
window.addEventListener('pagehide', onPageHide)
return () => {
window.removeEventListener('pagehide', onPageHide)
savePendingEditsNow()
}
}, [savePendingEditsNow])
useEffect(
() => () => {
if (spinnerTimerRef.current) clearTimeout(spinnerTimerRef.current)
Object.values(fadeRef.current).forEach(clearTimeout)
},
[],
)
return { statuses, notifyEdit, trackSave }
}
|