a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import type { ComputedSignal, Signal } from "$types/volt";
2import { report } from "./error";
3import { recordDep, startTracking, stopTracking } from "./tracker";
4
5/**
6 * Creates a new signal with the given initial value.
7 *
8 * Signals are reactive primitives that notify subscribers when their value changes.
9 * When accessed inside a computed() or effect(), they are automatically tracked as dependencies.
10 *
11 * @param initialValue - The initial value of the signal
12 * @returns A Signal object with get, set, and subscribe methods
13 *
14 * @example
15 * const count = signal(0);
16 * count.subscribe(value => console.log('Count:', value));
17 * count.set(1); // Logs: Count: 1
18 */
19export function signal<T>(initialValue: T): Signal<T> {
20 let value = initialValue;
21 const subscribers = new Set<(value: T) => void>();
22
23 const notify = () => {
24 const snapshot = [...subscribers];
25 for (const callback of snapshot) {
26 try {
27 callback(value);
28 } catch (error) {
29 report(error as Error, { source: "effect" });
30 }
31 }
32 };
33
34 const sig: Signal<T> = {
35 get() {
36 recordDep(sig);
37 return value;
38 },
39
40 set(newValue: T) {
41 if (value === newValue) {
42 return;
43 }
44
45 value = newValue;
46 notify();
47 },
48
49 subscribe(callback: (value: T) => void) {
50 subscribers.add(callback);
51
52 return () => {
53 subscribers.delete(callback);
54 };
55 },
56 };
57
58 return sig;
59}
60
61/**
62 * Creates a computed signal that derives its value from other signals.
63 *
64 * Dependencies are automatically tracked by detecting which signals are accessed
65 * during the computation function execution. The computation is re-run whenever
66 * any of its dependencies change.
67 *
68 * @param compute - Function that computes the derived value
69 * @returns A ComputedSignal with get and subscribe methods
70 *
71 * @example
72 * const count = signal(5);
73 * const doubled = computed(() => count.get() * 2);
74 * doubled.get(); // 10
75 * count.set(10);
76 * doubled.get(); // 20
77 */
78export function computed<T>(compute: () => T): ComputedSignal<T> {
79 let value: T;
80 let isInitialized = false;
81 let isRecomputing = false;
82 const subs = new Set<(value: T) => void>();
83 const unsubscribers: Array<() => void> = [];
84
85 const notify = () => {
86 const snapshot = [...subs];
87 for (const cb of snapshot) {
88 try {
89 cb(value);
90 } catch (error) {
91 report(error as Error, { source: "effect" });
92 }
93 }
94 };
95
96 const recompute = () => {
97 if (isRecomputing) {
98 throw new Error("Circular dependency detected in computed signal");
99 }
100
101 isRecomputing = true;
102 let shouldNotify = false;
103
104 try {
105 for (const unsub of unsubscribers) {
106 unsub();
107 }
108 unsubscribers.length = 0;
109
110 startTracking(comp);
111 try {
112 const newValue = compute();
113
114 if (!isInitialized || value !== newValue) {
115 value = newValue;
116 isInitialized = true;
117 shouldNotify = subs.size > 0;
118 }
119 } catch (error) {
120 report(error as Error, { source: "effect" });
121 throw error;
122 } finally {
123 const deps = stopTracking();
124
125 for (const dep of deps) {
126 const unsub = dep.subscribe(recompute);
127 unsubscribers.push(unsub);
128 }
129 }
130 } finally {
131 isRecomputing = false;
132 }
133
134 if (shouldNotify) {
135 notify();
136 }
137 };
138
139 const comp: ComputedSignal<T> = {
140 get() {
141 if (!isInitialized) {
142 recompute();
143 }
144
145 recordDep(comp);
146 return value;
147 },
148
149 subscribe(callback: (value: T) => void) {
150 if (!isInitialized) {
151 recompute();
152 }
153
154 subs.add(callback);
155
156 return () => {
157 subs.delete(callback);
158 };
159 },
160 };
161
162 return comp;
163}
164
165/**
166 * Creates a side effect that runs when dependencies change.
167 *
168 * Dependencies are automatically tracked by detecting which signals are accessed
169 * during the effect function execution. The effect is re-run whenever any of its
170 * dependencies change.
171 *
172 * @param cb - Function to run as a side effect. Can return a cleanup function.
173 * @returns Cleanup function to stop the effect
174 *
175 * @example
176 * const count = signal(0);
177 * const cleanup = effect(() => {
178 * console.log('Count changed:', count.get());
179 * });
180 */
181export function effect(cb: () => void | (() => void)): () => void {
182 let cleanup: (() => void) | void;
183 const unsubscribers: Array<() => void> = [];
184 let isDisposed = false;
185
186 const runEffect = () => {
187 if (isDisposed) {
188 return;
189 }
190
191 for (const unsub of unsubscribers) {
192 unsub();
193 }
194 unsubscribers.length = 0;
195
196 if (cleanup) {
197 try {
198 cleanup();
199 } catch (error) {
200 report(error as Error, { source: "effect" });
201 }
202 cleanup = undefined;
203 }
204
205 startTracking();
206 try {
207 cleanup = cb();
208 } catch (error) {
209 report(error as Error, { source: "effect" });
210 } finally {
211 const deps = stopTracking();
212
213 for (const dep of deps) {
214 const unsub = dep.subscribe(runEffect);
215 unsubscribers.push(unsub);
216 }
217 }
218 };
219
220 runEffect();
221
222 return () => {
223 isDisposed = true;
224
225 if (cleanup) {
226 try {
227 cleanup();
228 } catch (error) {
229 report(error as Error, { source: "effect" });
230 }
231 }
232
233 for (const unsubscribe of unsubscribers) {
234 try {
235 unsubscribe();
236 } catch (error) {
237 report(error as Error, { source: "effect" });
238 }
239 }
240 };
241}