a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}