a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 5.5 kB view raw
1/** 2 * Async effect system with abort, race protection, debounce, throttle, and error handling 3 */ 4 5import type { Optional, Timer } from "$types/helpers"; 6import type { AsyncEffectFunction, AsyncEffectOptions, ComputedSignal, Signal } from "$types/volt"; 7import { report } from "./error"; 8 9/** 10 * Creates an async side effect that runs when dependencies change. 11 * Supports abort signals, race protection, debouncing, throttling, and error handling. 12 * 13 * @param effectFn - Async function to run as a side effect 14 * @param deps - Array of signals this effect depends on 15 * @param opts - Configuration options for async behavior 16 * @returns Cleanup function to stop the effect 17 * 18 * @example 19 * // Fetch with abort on cleanup 20 * const query = signal(''); 21 * const cleanup = asyncEffect(async (signal) => { 22 * const response = await fetch(`/api/search?q=${query.get()}`, { signal }); 23 * const data = await response.json(); 24 * results.set(data); 25 * }, [query], { abortable: true }); 26 * 27 * @example 28 * // Debounced search 29 * asyncEffect(async () => { 30 * const response = await fetch(`/api/search?q=${searchQuery.get()}`); 31 * results.set(await response.json()); 32 * }, [searchQuery], { debounce: 300 }); 33 * 34 * @example 35 * // Error handling with retries 36 * asyncEffect(async () => { 37 * const response = await fetch('/api/data'); 38 * if (!response.ok) throw new Error('Failed to fetch'); 39 * data.set(await response.json()); 40 * }, [refreshTrigger], { 41 * retries: 3, 42 * retryDelay: 1000, 43 * onError: (error, retry) => { 44 * console.error('Fetch failed:', error); 45 * // Optionally call retry() to retry immediately 46 * } 47 * }); 48 */ 49export function asyncEffect( 50 effectFn: AsyncEffectFunction, 51 deps: Array<Signal<unknown> | ComputedSignal<unknown>>, 52 opts: AsyncEffectOptions = {}, 53): () => void { 54 const { abortable = false, debounce, throttle, onError, retries = 0, retryDelay = 0 } = opts; 55 56 let cleanup: (() => void) | void; 57 let abortController: Optional<AbortController>; 58 let executionId = 0; 59 let debounceTimer: Optional<Timer>; 60 let throttleTimer: Optional<Timer>; 61 let lastExecutionTime = 0; 62 let pendingExecution = false; 63 let retryCount = 0; 64 65 /** 66 * Execute the async effect with error handling and retries 67 */ 68 const executeEffect = async (currentExecutionId: number) => { 69 if (abortController) { 70 abortController.abort(); 71 } 72 73 if (cleanup) { 74 try { 75 cleanup(); 76 } catch (error) { 77 report(error as Error, { source: "effect" }); 78 } 79 cleanup = undefined; 80 } 81 82 if (abortable) { 83 abortController = new AbortController(); 84 } 85 86 try { 87 const result = await effectFn(abortController?.signal); 88 89 if (currentExecutionId !== executionId) { 90 return; 91 } 92 93 if (typeof result === "function") { 94 cleanup = result; 95 } 96 97 retryCount = 0; 98 } catch (error) { 99 if (currentExecutionId !== executionId) { 100 return; 101 } 102 103 if (abortController?.signal.aborted) { 104 return; 105 } 106 107 const err = error instanceof Error ? error : new Error(String(error)); 108 109 if (retryCount < retries) { 110 retryCount++; 111 if (retryDelay > 0) { 112 await new Promise((resolve) => setTimeout(resolve, retryDelay)); 113 } 114 115 if (currentExecutionId === executionId) { 116 await executeEffect(currentExecutionId); 117 } 118 } else { 119 report(err as Error, { source: "effect" }); 120 121 if (onError) { 122 const retry = () => { 123 retryCount = 0; 124 scheduleExecution(); 125 }; 126 onError(err, retry); 127 } 128 } 129 } 130 }; 131 132 const scheduleExecution = () => { 133 const currentExecutionId = ++executionId; 134 135 if (debounceTimer) { 136 clearTimeout(debounceTimer); 137 debounceTimer = undefined; 138 } 139 140 if (debounce !== undefined && debounce > 0) { 141 debounceTimer = setTimeout(() => { 142 debounceTimer = undefined; 143 executeEffect(currentExecutionId); 144 }, debounce); 145 return; 146 } 147 148 if (throttle !== undefined && throttle > 0) { 149 const now = Date.now(); 150 const timeSinceLastExecution = now - lastExecutionTime; 151 152 if (timeSinceLastExecution >= throttle) { 153 lastExecutionTime = now; 154 executeEffect(currentExecutionId); 155 } else if (!pendingExecution) { 156 pendingExecution = true; 157 const remainingTime = throttle - timeSinceLastExecution; 158 159 throttleTimer = setTimeout(() => { 160 throttleTimer = undefined; 161 pendingExecution = false; 162 lastExecutionTime = Date.now(); 163 executeEffect(currentExecutionId); 164 }, remainingTime); 165 } 166 return; 167 } 168 169 executeEffect(currentExecutionId); 170 }; 171 172 scheduleExecution(); 173 174 const unsubscribers = deps.map((dep) => 175 dep.subscribe(() => { 176 scheduleExecution(); 177 }) 178 ); 179 180 return () => { 181 executionId++; 182 183 if (debounceTimer) { 184 clearTimeout(debounceTimer); 185 debounceTimer = undefined; 186 } 187 188 if (throttleTimer) { 189 clearTimeout(throttleTimer); 190 throttleTimer = undefined; 191 } 192 193 if (abortController) { 194 abortController.abort(); 195 abortController = undefined; 196 } 197 198 if (cleanup) { 199 try { 200 cleanup(); 201 } catch (error) { 202 report(error as Error, { source: "effect" }); 203 } 204 cleanup = undefined; 205 } 206 207 for (const unsubscribe of unsubscribers) { 208 unsubscribe(); 209 } 210 }; 211}