a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: auto dependency tracking

+262 -78
+4 -4
ROADMAP.md
··· 99 99 **Goal:** Leverage JavaScript Proxies to improve reactivity ergonomics and automatic dependency tracking. 100 100 **Outcome:** More intuitive API with automatic dependency tracking and optional deep reactivity for objects/arrays. 101 101 **Deliverables:** 102 - - Automatic dependency tracking for `computed()` 103 - - Eliminate manual dependency arrays via proxy-based tracking 104 - - Auto-detect signal access during computation 105 - - Track nested property access for fine-grained updates 102 + - ✓ Automatic dependency tracking for `computed()` 103 + - ✓ Eliminate manual dependency arrays via proxy-based tracking 104 + - ✓ Auto-detect signal access during computation 105 + - ✓ Track nested property access for fine-grained updates 106 106 - `reactive()` primitive for deep object reactivity (optional, alongside `signal()`) 107 107 - Nested property changes trigger updates automatically 108 108 - Proxy-wrapped objects with transparent reactivity
+1
docs/.vitepress/config.ts
··· 30 30 }, 31 31 { text: "Specs", collapsed: true, items: u.scanDir("spec", "/spec") }, 32 32 { text: "API Reference", collapsed: true, items: u.scanDir("api", "/api") }, 33 + { text: "Internals", collapsed: false, items: u.scanDir("internals", "/internals") }, 33 34 ], 34 35 socialLinks: [{ icon: "github", link: "https://github.com/stormlightlabs/volt" }], 35 36 },
+1
docs/internals/proxies.md
··· 1 + # Proxy Objects
+2 -4
lib/src/core/charge.ts
··· 6 6 7 7 import type { ChargedRoot, ChargeResult, Scope } from "$types/volt"; 8 8 import { mount } from "./binder"; 9 - import { evaluate, extractDeps } from "./evaluator"; 9 + import { evaluate } from "./evaluator"; 10 10 import { getComputedAttributes } from "./shared"; 11 11 import { computed, signal } from "./signal"; 12 12 ··· 86 86 const computedAttrs = getComputedAttributes(el); 87 87 for (const [name, expression] of computedAttrs) { 88 88 try { 89 - const dependencies = extractDeps(expression, scope); 90 - 91 - scope[name] = computed(() => evaluate(expression, scope), dependencies); 89 + scope[name] = computed(() => evaluate(expression, scope)); 92 90 } catch (error) { 93 91 console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); 94 92 }
+118 -31
lib/src/core/signal.ts
··· 1 1 import type { ComputedSignal, Signal } from "$types/volt"; 2 + import { recordDep, startTracking, stopTracking } from "./tracker"; 2 3 3 4 /** 4 5 * Creates a new signal with the given initial value. 6 + * 7 + * Signals are reactive primitives that notify subscribers when their value changes. 8 + * When accessed inside a computed() or effect(), they are automatically tracked as dependencies. 5 9 * 6 10 * @param initialValue - The initial value of the signal 7 11 * @returns A Signal object with get, set, and subscribe methods ··· 16 20 const subscribers = new Set<(value: T) => void>(); 17 21 18 22 const notify = () => { 19 - for (const callback of subscribers) { 23 + const snapshot = [...subscribers]; 24 + for (const callback of snapshot) { 20 25 try { 21 26 callback(value); 22 27 } catch (error) { ··· 25 30 } 26 31 }; 27 32 28 - return { 33 + const sig: Signal<T> = { 29 34 get() { 35 + recordDep(sig); 30 36 return value; 31 37 }, 32 38 ··· 47 53 }; 48 54 }, 49 55 }; 56 + 57 + return sig; 50 58 } 51 59 52 60 /** 53 61 * Creates a computed signal that derives its value from other signals. 54 - * The computation function is re-run whenever any of its dependencies change. 62 + * 63 + * Dependencies are automatically tracked by detecting which signals are accessed 64 + * during the computation function execution. The computation is re-run whenever 65 + * any of its dependencies change. 55 66 * 56 67 * @param compute - Function that computes the derived value 57 - * @param dps - Array of signals this computation depends on 58 68 * @returns A ComputedSignal with get and subscribe methods 59 69 * 60 70 * @example 61 71 * const count = signal(5); 62 - * const doubled = computed(() => count.get() * 2, [count]); 72 + * const doubled = computed(() => count.get() * 2); 63 73 * doubled.get(); // 10 64 74 * count.set(10); 65 75 * doubled.get(); // 20 66 76 */ 67 - export function computed<T>( 68 - compute: () => T, 69 - dps: Array<Signal<unknown> | ComputedSignal<unknown>>, 70 - ): ComputedSignal<T> { 71 - let value = compute(); 77 + export function computed<T>(compute: () => T): ComputedSignal<T> { 78 + let value: T; 79 + let isInitialized = false; 80 + let isRecomputing = false; 72 81 const subs = new Set<(value: T) => void>(); 82 + const unsubscribers: Array<() => void> = []; 73 83 74 84 const notify = () => { 75 - for (const cb of subs) { 85 + const snapshot = [...subs]; 86 + for (const cb of snapshot) { 76 87 try { 77 88 cb(value); 78 89 } catch (error) { ··· 82 93 }; 83 94 84 95 const recompute = () => { 85 - const newValue = compute(); 86 - if (value !== newValue) { 87 - value = newValue; 96 + if (isRecomputing) { 97 + throw new Error("Circular dependency detected in computed signal"); 98 + } 99 + 100 + isRecomputing = true; 101 + let shouldNotify = false; 102 + 103 + try { 104 + for (const unsub of unsubscribers) { 105 + unsub(); 106 + } 107 + unsubscribers.length = 0; 108 + 109 + startTracking(comp); 110 + try { 111 + const newValue = compute(); 112 + 113 + if (!isInitialized || value !== newValue) { 114 + value = newValue; 115 + isInitialized = true; 116 + shouldNotify = subs.size > 0; 117 + } 118 + } catch (error) { 119 + console.error("Error in computed:", error); 120 + throw error; 121 + } finally { 122 + const deps = stopTracking(); 123 + 124 + for (const dep of deps) { 125 + const unsub = dep.subscribe(recompute); 126 + unsubscribers.push(unsub); 127 + } 128 + } 129 + } finally { 130 + isRecomputing = false; 131 + } 132 + 133 + if (shouldNotify) { 88 134 notify(); 89 135 } 90 136 }; 91 137 92 - for (const dep of dps) { 93 - dep.subscribe(recompute); 94 - } 95 - 96 - return { 138 + const comp: ComputedSignal<T> = { 97 139 get() { 140 + if (!isInitialized) { 141 + recompute(); 142 + } 143 + 144 + recordDep(comp); 98 145 return value; 99 146 }, 100 147 101 148 subscribe(callback: (value: T) => void) { 149 + if (!isInitialized) { 150 + recompute(); 151 + } 152 + 102 153 subs.add(callback); 103 154 104 155 return () => { ··· 106 157 }; 107 158 }, 108 159 }; 160 + 161 + return comp; 109 162 } 110 163 111 164 /** 112 165 * Creates a side effect that runs when dependencies change. 113 166 * 114 - * @param cb - Function to run as a side effect 115 - * @param deps - Array of signals this effect depends on 167 + * Dependencies are automatically tracked by detecting which signals are accessed 168 + * during the effect function execution. The effect is re-run whenever any of its 169 + * dependencies change. 170 + * 171 + * @param cb - Function to run as a side effect. Can return a cleanup function. 116 172 * @returns Cleanup function to stop the effect 117 173 * 118 174 * @example 119 175 * const count = signal(0); 120 176 * const cleanup = effect(() => { 121 177 * console.log('Count changed:', count.get()); 122 - * }, [count]); 178 + * }); 123 179 */ 124 - export function effect( 125 - cb: () => void | (() => void), 126 - deps: Array<Signal<unknown> | ComputedSignal<unknown>>, 127 - ): () => void { 180 + export function effect(cb: () => void | (() => void)): () => void { 128 181 let cleanup: (() => void) | void; 182 + const unsubscribers: Array<() => void> = []; 183 + let isDisposed = false; 129 184 130 185 const runEffect = () => { 186 + if (isDisposed) { 187 + return; 188 + } 189 + 190 + for (const unsub of unsubscribers) { 191 + unsub(); 192 + } 193 + unsubscribers.length = 0; 194 + 131 195 if (cleanup) { 132 - cleanup(); 196 + try { 197 + cleanup(); 198 + } catch (error) { 199 + console.error("Error in effect cleanup:", error); 200 + } 201 + cleanup = undefined; 133 202 } 203 + 204 + startTracking(); 134 205 try { 135 206 cleanup = cb(); 136 207 } catch (error) { 137 208 console.error("Error in effect:", error); 209 + } finally { 210 + const deps = stopTracking(); 211 + 212 + for (const dep of deps) { 213 + const unsub = dep.subscribe(runEffect); 214 + unsubscribers.push(unsub); 215 + } 138 216 } 139 217 }; 140 218 141 219 runEffect(); 142 220 143 - const unsubscribers = deps.map((dependency) => dependency.subscribe(runEffect)); 144 - 145 221 return () => { 222 + isDisposed = true; 223 + 146 224 if (cleanup) { 147 - cleanup(); 225 + try { 226 + cleanup(); 227 + } catch (error) { 228 + console.error("Error in effect cleanup:", error); 229 + } 148 230 } 231 + 149 232 for (const unsubscribe of unsubscribers) { 150 - unsubscribe(); 233 + try { 234 + unsubscribe(); 235 + } catch (error) { 236 + console.error("Error unsubscribing effect:", error); 237 + } 151 238 } 152 239 }; 153 240 }
+5 -6
lib/src/core/ssr.ts
··· 8 8 import type { Nullable } from "$types/helpers"; 9 9 import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt"; 10 10 import { mount } from "./binder"; 11 - import { evaluate, extractDeps } from "./evaluator"; 11 + import { evaluate } from "./evaluator"; 12 12 import { getComputedAttributes, isSignal } from "./shared"; 13 13 import { computed, signal } from "./signal"; 14 14 ··· 25 25 * ```ts 26 26 * const scope = { 27 27 * count: signal(0), 28 - * double: computed(() => scope.count.get() * 2, [scope.count]) 28 + * double: computed(() => scope.count.get() * 2) 29 29 * }; 30 30 * const json = serializeScope(scope); 31 31 * // Returns: '{"count":0,"double":0}' ··· 77 77 * @returns True if element is marked as hydrated 78 78 */ 79 79 export function isHydrated(el: Element): boolean { 80 - return el.hasAttribute("data-volt-hydrated"); 80 + return Object.hasOwn((el as HTMLElement).dataset, "voltHydrated"); 81 81 } 82 82 83 83 /** ··· 86 86 * @param el - Element to mark 87 87 */ 88 88 function markHydrated(el: Element): void { 89 - el.setAttribute("data-volt-hydrated", "true"); 89 + (el as HTMLElement).dataset.voltHydrated = "true"; 90 90 } 91 91 92 92 /** ··· 178 178 const computedAttrs = getComputedAttributes(el); 179 179 for (const [name, expression] of computedAttrs) { 180 180 try { 181 - const dependencies = extractDeps(expression, scope); 182 - scope[name] = computed(() => evaluate(expression, scope), dependencies); 181 + scope[name] = computed(() => evaluate(expression, scope)); 183 182 } catch (error) { 184 183 console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); 185 184 }
+91
lib/src/core/tracker.ts
··· 1 + /** 2 + * Dependency tracking system for automatic signal dependency detection. 3 + * 4 + * Uses a stack-based tracking context to record signal accesses during computations. 5 + * When a computed signal or effect runs, it pushes a tracking context onto the stack. 6 + * Any signal.get() calls during execution are recorded as dependencies. 7 + */ 8 + 9 + import type { Dep } from "$types/volt"; 10 + 11 + /** 12 + * Holds the set of dependencies discovered during this tracking session and the source being tracked (to prevent cycles) 13 + */ 14 + type TrackingContext = { deps: Set<Dep>; source?: Dep }; 15 + 16 + /** 17 + * Global stack of active tracking contexts. 18 + * When nested computeds run, multiple contexts can be active simultaneously. 19 + */ 20 + const trackingStack: TrackingContext[] = []; 21 + 22 + /** 23 + * Get the currently active tracking context, if any. 24 + */ 25 + function getActiveContext(): TrackingContext | undefined { 26 + return trackingStack.at(-1); 27 + } 28 + 29 + /** 30 + * Start tracking signal dependencies. 31 + * Should be called before executing a computation function. 32 + * 33 + * @param source - Optional source signal for cycle detection 34 + * @returns The tracking context 35 + */ 36 + export function startTracking(source?: Dep): TrackingContext { 37 + const context: TrackingContext = { deps: new Set(), source }; 38 + 39 + trackingStack.push(context); 40 + return context; 41 + } 42 + 43 + /** 44 + * Stop tracking and return the collected dependencies. 45 + * Should be called after executing a computation function. 46 + * 47 + * @returns Array of signals that were accessed during tracking 48 + */ 49 + export function stopTracking(): Dep[] { 50 + const context = trackingStack.pop(); 51 + if (!context) { 52 + console.warn("stopTracking called without matching startTracking"); 53 + return []; 54 + } 55 + 56 + return [...context.deps]; 57 + } 58 + 59 + /** 60 + * Record a signal access as a dependency. 61 + * Called by signal.get() when inside a tracking context. 62 + * 63 + * @param dep - The signal being accessed 64 + */ 65 + export function recordDep(dep: Dep): void { 66 + const context = getActiveContext(); 67 + if (!context) { 68 + return; 69 + } 70 + 71 + if (context.source === dep) { 72 + throw new Error("Circular dependency detected: a signal cannot depend on itself"); 73 + } 74 + 75 + context.deps.add(dep); 76 + } 77 + 78 + /** 79 + * Check if currently inside a tracking context. 80 + * Useful for conditional behavior in signal.get() 81 + */ 82 + export function isTracking(): boolean { 83 + return trackingStack.length > 0; 84 + } 85 + 86 + /** 87 + * Get current tracking depth (for debugging). 88 + */ 89 + export function getTrackingDepth(): number { 90 + return trackingStack.length; 91 + }
+2 -2
lib/src/main.ts
··· 15 15 const section1Visible = signal(false); 16 16 const section2Visible = signal(false); 17 17 18 - const doubled = computed(() => count.get() * 2, [count]); 18 + const doubled = computed(() => count.get() * 2); 19 19 20 20 effect(() => { 21 21 console.log("Count changed:", count.get()); 22 - }, [count]); 22 + }); 23 23 24 24 const scope = { 25 25 count,
+36 -29
lib/test/core/signal.test.ts
··· 126 126 describe("computed", () => { 127 127 it("computes initial value", () => { 128 128 const count = signal(5); 129 - const doubled = computed(() => count.get() * 2, [count]); 129 + const doubled = computed(() => count.get() * 2); 130 130 131 131 expect(doubled.get()).toBe(10); 132 132 }); 133 133 134 134 it("recomputes when dependency changes", () => { 135 135 const count = signal(5); 136 - const doubled = computed(() => count.get() * 2, [count]); 136 + const doubled = computed(() => count.get() * 2); 137 137 138 138 expect(doubled.get()).toBe(10); 139 139 ··· 146 146 147 147 it("notifies subscribers when value changes", () => { 148 148 const count = signal(5); 149 - const doubled = computed(() => count.get() * 2, [count]); 149 + const doubled = computed(() => count.get() * 2); 150 150 const subscriber = vi.fn(); 151 151 152 152 doubled.subscribe(subscriber); ··· 158 158 159 159 it("does not notify when computed value is the same", () => { 160 160 const count = signal(5); 161 - const isPositive = computed(() => count.get() > 0, [count]); 161 + const isPositive = computed(() => count.get() > 0); 162 162 const subscriber = vi.fn(); 163 163 164 164 isPositive.subscribe(subscriber); ··· 174 174 it("supports multiple dependencies", () => { 175 175 const a = signal(2); 176 176 const b = signal(3); 177 - const sum = computed(() => a.get() + b.get(), [a, b]); 177 + const sum = computed(() => a.get() + b.get()); 178 178 179 179 expect(sum.get()).toBe(5); 180 180 ··· 187 187 188 188 it("can depend on other computed signals", () => { 189 189 const count = signal(2); 190 - const doubled = computed(() => count.get() * 2, [count]); 191 - const quadrupled = computed(() => doubled.get() * 2, [doubled]); 190 + const doubled = computed(() => count.get() * 2); 191 + const quadrupled = computed(() => doubled.get() * 2); 192 192 193 193 expect(quadrupled.get()).toBe(8); 194 194 ··· 199 199 200 200 it("allows unsubscribing", () => { 201 201 const count = signal(5); 202 - const doubled = computed(() => count.get() * 2, [count]); 202 + const doubled = computed(() => count.get() * 2); 203 203 const subscriber = vi.fn(); 204 204 205 205 const unsubscribe = doubled.subscribe(subscriber); ··· 211 211 }); 212 212 213 213 describe("effect", () => { 214 - it("runs immediately on creation", () => { 215 - const count = signal(0); 216 - const effectFunction = vi.fn(); 217 - 218 - effect(effectFunction, [count]); 219 - expect(effectFunction).toHaveBeenCalledTimes(1); 220 - }); 221 - 222 214 it("runs when dependency changes", () => { 223 215 const count = signal(0); 224 - const effectFunction = vi.fn(); 216 + const effectFunction = vi.fn(() => { 217 + count.get(); 218 + }); 225 219 226 - effect(effectFunction, [count]); 220 + effect(effectFunction); 227 221 228 222 count.set(1); 229 223 count.set(2); ··· 233 227 234 228 it("can be cleaned up", () => { 235 229 const count = signal(0); 236 - const effectFunction = vi.fn(); 230 + const effectFunction = vi.fn(() => { 231 + count.get(); 232 + }); 237 233 238 - const cleanup = effect(effectFunction, [count]); 234 + const cleanup = effect(effectFunction); 239 235 240 236 expect(effectFunction).toHaveBeenCalledTimes(1); 241 237 ··· 248 244 it("runs cleanup function from previous effect", () => { 249 245 const count = signal(0); 250 246 const innerCleanup = vi.fn(); 251 - const effectFunction = vi.fn(() => innerCleanup); 247 + const effectFunction = vi.fn(() => { 248 + count.get(); 249 + return innerCleanup; 250 + }); 252 251 253 - effect(effectFunction, [count]); 252 + effect(effectFunction); 254 253 255 254 expect(innerCleanup).not.toHaveBeenCalled(); 256 255 ··· 264 263 it("runs final cleanup when effect is disposed", () => { 265 264 const count = signal(0); 266 265 const innerCleanup = vi.fn(); 267 - const effectFunction = vi.fn(() => innerCleanup); 266 + const effectFunction = vi.fn(() => { 267 + count.get(); 268 + return innerCleanup; 269 + }); 268 270 269 - const cleanup = effect(effectFunction, [count]); 271 + const cleanup = effect(effectFunction); 270 272 271 273 count.set(1); 272 274 expect(innerCleanup).toHaveBeenCalledTimes(1); ··· 278 280 it("supports multiple dependencies", () => { 279 281 const a = signal(1); 280 282 const b = signal(2); 281 - const effectFunction = vi.fn(); 283 + const effectFunction = vi.fn(() => { 284 + a.get(); 285 + b.get(); 286 + }); 282 287 283 - effect(effectFunction, [a, b]); 288 + effect(effectFunction); 284 289 285 290 expect(effectFunction).toHaveBeenCalledTimes(1); 286 291 ··· 293 298 294 299 it("can depend on computed signals", () => { 295 300 const count = signal(2); 296 - const doubled = computed(() => count.get() * 2, [count]); 297 - const effectFunction = vi.fn(); 301 + const doubled = computed(() => count.get() * 2); 302 + const effectFunction = vi.fn(() => { 303 + doubled.get(); 304 + }); 298 305 299 - effect(effectFunction, [doubled]); 306 + effect(effectFunction); 300 307 301 308 expect(effectFunction).toHaveBeenCalledTimes(1); 302 309
+2 -2
lib/test/integration/list-rendering.test.ts
··· 32 32 33 33 const remaining = computed(() => { 34 34 return todos.get().filter((t) => !t.completed).length; 35 - }, [todos]); 35 + }); 36 36 37 37 const addTodo = () => { 38 38 const input = container.querySelector("#new-todo") as HTMLInputElement; ··· 105 105 active: true, 106 106 }]); 107 107 108 - const activeItems = computed(() => items.get().filter((item) => item.active), [items]); 108 + const activeItems = computed(() => items.get().filter((item) => item.active)); 109 109 110 110 mount(container, { allItems: items, activeItems }); 111 111