All files / src usePrefsAutosave.ts

96.42% Statements 108/112
80% Branches 36/45
92.59% Functions 25/27
100% Lines 96/96

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 }
}