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: reactive object & debugging utilities

+1364 -13
+13 -13
ROADMAP.md
··· 92 92 - ✓ SSR compatibility helpers 93 93 - ✓ Sandboxed expression evaluator 94 94 95 - ## To-Do 96 - 97 95 ### Proxy-Based Reactivity Enhancements 98 96 99 - **Goal:** Leverage JavaScript Proxies to improve reactivity ergonomics and automatic dependency tracking. 97 + **Goal:** Use JavaScript Proxies to improve reactivity ergonomics and automatic dependency tracking. 100 98 **Outcome:** More intuitive API with automatic dependency tracking and optional deep reactivity for objects/arrays. 101 99 **Deliverables:** 102 100 - ✓ Automatic dependency tracking for `computed()` 103 101 - ✓ Eliminate manual dependency arrays via proxy-based tracking 104 102 - ✓ Auto-detect signal access during computation 105 103 - ✓ Track nested property access for fine-grained updates 106 - - `reactive()` primitive for deep object reactivity (optional, alongside `signal()`) 107 - - Nested property changes trigger updates automatically 108 - - Proxy-wrapped objects with transparent reactivity 109 - - Array reactivity improvements 110 - - Reactive array methods (push, pop, shift, unshift, splice, etc.) 111 - - Automatic updates on array mutations 112 - - Efficient tracking of index-based changes 113 - - Lazy signal initialization 114 - - Create signals on-demand when properties are accessed 115 - - Expose debugging utilities 104 + - ✓ `reactive()` primitive for deep object reactivity (optional, alongside `signal()`) 105 + - ✓ Nested property changes trigger updates automatically 106 + - ✓ Proxy-wrapped objects with transparent reactivity 107 + - ✓ Array reactivity improvements 108 + - ✓ Reactive array methods (push, pop, shift, unshift, splice, etc.) 109 + - ✓ Automatic updates on array mutations 110 + - ✓ Efficient tracking of index-based changes 111 + - ✓ Lazy signal initialization 112 + - ✓ Create signals on-demand when properties are accessed 113 + - ✓ Expose debugging utilities 116 114 **Notes:** 117 115 - Separate reactive() function for objects/arrays to gives users choice 118 116 - Keep .get()/.set() - explicitness is valuable for understanding reactivity (include in docs) 117 + 118 + ## To-Do 119 119 120 120 ### Streaming & Patch Engine 121 121
+200
lib/src/core/reactive.ts
··· 1 + /** 2 + * Deep reactive proxy system for objects and arrays. 3 + * 4 + * Uses JavaScript Proxies to create reactive objects where property access and mutations 5 + * automatically trigger updates. Internally uses signals for each property, created lazily 6 + * on first access. 7 + * 8 + * Unlike signal() which wraps a single value, reactive() creates a transparent proxy where 9 + * property access feels natural while maintaining reactivity. 10 + */ 11 + 12 + import type { Signal } from "$types/volt"; 13 + import { signal } from "./signal"; 14 + import { recordDep } from "./tracker"; 15 + 16 + /** 17 + * WeakMap to store the raw object for each reactive proxy. 18 + * Used by toRaw() to unwrap proxies. 19 + */ 20 + const reactiveToRaw = new WeakMap<object, object>(); 21 + 22 + /** 23 + * WeakMap to store the reactive proxy for each raw object. 24 + * Ensures we only create one proxy per object. 25 + */ 26 + const rawToReactive = new WeakMap<object, object>(); 27 + 28 + /** 29 + * WeakMap to store signals for each property of a reactive object. 30 + * Key is the target object, value is a Map of property name to signal. 31 + */ 32 + const targetToSignals = new WeakMap<object, Map<string | symbol, Signal<unknown>>>(); 33 + 34 + /** 35 + * Array methods that mutate the array and should trigger updates. 36 + */ 37 + const arrayMutators = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); 38 + 39 + /** 40 + * Check if a value is an object (not null, not primitive). 41 + */ 42 + function isObject(value: unknown): value is object { 43 + return value !== null && typeof value === "object"; 44 + } 45 + 46 + /** 47 + * Check if a value is already a reactive proxy. 48 + */ 49 + export function isReactive(value: unknown): boolean { 50 + return isObject(value) && reactiveToRaw.has(value); 51 + } 52 + 53 + /** 54 + * Get the raw (non-reactive) object from a reactive proxy. 55 + * If the value is not reactive, returns it as-is. 56 + */ 57 + export function toRaw<T>(value: T): T { 58 + if (!isObject(value)) { 59 + return value; 60 + } 61 + 62 + return (reactiveToRaw.get(value) as T) || value; 63 + } 64 + 65 + /** 66 + * Get or create a signal for a specific property on a reactive object. 67 + */ 68 + function getPropertySignal(target: object, key: string | symbol): Signal<unknown> { 69 + let signals = targetToSignals.get(target); 70 + 71 + if (!signals) { 72 + signals = new Map(); 73 + targetToSignals.set(target, signals); 74 + } 75 + 76 + let sig = signals.get(key); 77 + 78 + if (!sig) { 79 + const initialValue = Reflect.get(target, key); 80 + sig = signal(initialValue); 81 + signals.set(key, sig); 82 + } 83 + 84 + return sig; 85 + } 86 + 87 + /** 88 + * Create a reactive proxy for an object or array. 89 + * 90 + * Property access and mutations are tracked automatically. Nested objects and arrays 91 + * are recursively wrapped in proxies for deep reactivity. 92 + * 93 + * @param target - The object or array to make reactive 94 + * @returns A reactive proxy of the target 95 + * 96 + * @example 97 + * const state = reactive({ count: 0, nested: { value: 1 } }); 98 + * const doubled = computed(() => state.count * 2); 99 + * state.count++; // triggers recomputation of doubled 100 + * state.nested.value = 2; // also reactive 101 + */ 102 + export function reactive<T extends object>(target: T): T { 103 + if (!isObject(target)) { 104 + console.warn("[reactive] Can only make objects and arrays reactive, got:", typeof target); 105 + return target; 106 + } 107 + 108 + if (isReactive(target)) { 109 + return target; 110 + } 111 + 112 + const existingProxy = rawToReactive.get(target); 113 + if (existingProxy) { 114 + return existingProxy as T; 115 + } 116 + 117 + const proxy = new Proxy(target, { 118 + get(target, key, receiver) { 119 + if (key === "__v_raw") { 120 + return target; 121 + } 122 + 123 + if (key === "__v_isReactive") { 124 + return true; 125 + } 126 + 127 + if (Array.isArray(target) && arrayMutators.has(key as string)) { 128 + return function(this: unknown[], ...args: unknown[]) { 129 + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 130 + const method = Reflect.get(target, key) as Function; 131 + const oldLength = target.length; 132 + const result = method.apply(target, args); 133 + 134 + const newLength = target.length; 135 + const maxLength = Math.max(oldLength, newLength); 136 + 137 + for (let i = 0; i < maxLength; i++) { 138 + const sig = getPropertySignal(target, String(i)); 139 + sig.set(Reflect.get(target, i)); 140 + } 141 + 142 + if (oldLength !== newLength) { 143 + const lengthSig = getPropertySignal(target, "length"); 144 + lengthSig.set(newLength); 145 + } 146 + 147 + return result; 148 + }; 149 + } 150 + 151 + const sig = getPropertySignal(target, key); 152 + recordDep(sig); 153 + 154 + const value = Reflect.get(target, key, receiver); 155 + 156 + if (isObject(value)) { 157 + return reactive(value); 158 + } 159 + 160 + return sig.get(); 161 + }, 162 + 163 + set(target, key, value, receiver) { 164 + const oldValue = Reflect.get(target, key, receiver); 165 + 166 + const result = Reflect.set(target, key, value, receiver); 167 + 168 + if (oldValue !== value) { 169 + const sig = getPropertySignal(target, key); 170 + sig.set(value); 171 + } 172 + 173 + return result; 174 + }, 175 + 176 + deleteProperty(target, key) { 177 + const hadKey = Reflect.has(target, key); 178 + const result = Reflect.deleteProperty(target, key); 179 + 180 + if (hadKey && result) { 181 + const sig = getPropertySignal(target, key); 182 + sig.set(undefined); 183 + } 184 + 185 + return result; 186 + }, 187 + 188 + has(target, key) { 189 + const sig = getPropertySignal(target, key); 190 + recordDep(sig); 191 + 192 + return Reflect.has(target, key); 193 + }, 194 + }); 195 + 196 + reactiveToRaw.set(proxy, target); 197 + rawToReactive.set(target, proxy); 198 + 199 + return proxy as T; 200 + }
+173
lib/src/debug.ts
··· 1 + /** 2 + * Volt.js Debug Utilities 3 + * 4 + * Lazy-loadable debugging module for signal introspection and visualization. 5 + * Import from 'volt/debug' to access these utilities without affecting production bundle size. 6 + * 7 + * @example 8 + * ```ts 9 + * import { debugSignal, debugComputed, logAllSignals } from 'volt/debug'; 10 + * 11 + * const count = debugSignal(0, 'count'); 12 + * const doubled = debugComputed(() => count.get() * 2, 'doubled'); 13 + * 14 + * logAllSignals(); 15 + * ``` 16 + * 17 + * @module volt/debug 18 + * @packageDocumentation 19 + */ 20 + 21 + import { reactive as coreReactive } from "$core/reactive"; 22 + import { computed as coreComputed, signal as coreSignal } from "$core/signal"; 23 + import type { ComputedSignal, Signal, SignalType } from "$types/volt"; 24 + import { 25 + buildDependencyGraph, 26 + detectCircularDependencies, 27 + getDependencies, 28 + getDependents, 29 + getSignalDepth, 30 + recordDependencies, 31 + } from "./debug/graph"; 32 + import { 33 + disableGlobalTracing, 34 + enableGlobalTracing, 35 + logAllReactives, 36 + logAllSignals, 37 + logReactive, 38 + logSignal, 39 + logSignalTable, 40 + trace, 41 + watch, 42 + } from "./debug/logger"; 43 + import { 44 + clearRegistry, 45 + getAllReactives, 46 + getAllSignals, 47 + getReactiveInfo, 48 + getRegistryStats, 49 + getSignalInfo, 50 + nameReactive, 51 + nameSignal, 52 + registerReactive, 53 + registerSignal, 54 + } from "./debug/registry"; 55 + 56 + /** 57 + * Create a signal with automatic debug registration. 58 + * 59 + * @param initialValue - The initial value 60 + * @param name - Optional name for debugging 61 + * @returns A Signal with debug metadata 62 + */ 63 + export function debugSignal<T>(initialValue: T, name?: string): Signal<T> { 64 + const sig = coreSignal(initialValue); 65 + registerSignal(sig, "signal", name); 66 + return sig; 67 + } 68 + 69 + /** 70 + * Create a computed signal with automatic debug registration. 71 + * 72 + * @param compute - Computation function 73 + * @param name - Optional name for debugging 74 + * @returns A ComputedSignal with debug metadata 75 + */ 76 + export function debugComputed<T>(compute: () => T, name?: string): ComputedSignal<T> { 77 + const comp = coreComputed(compute); 78 + registerSignal(comp, "computed", name); 79 + const deps = extractComputedDeps(comp); 80 + if (deps.length > 0) { 81 + recordDependencies(comp, deps); 82 + } 83 + return comp; 84 + } 85 + 86 + /** 87 + * Create a reactive object with automatic debug registration. 88 + * 89 + * @param target - The object or array to make reactive 90 + * @param name - Optional name for debugging 91 + * @returns A reactive proxy with debug metadata 92 + */ 93 + export function debugReactive<T extends object>(target: T, name?: string): T { 94 + const proxy = coreReactive(target); 95 + registerReactive(proxy, name); 96 + return proxy; 97 + } 98 + 99 + /** 100 + * Extract dependencies from a computed signal by running it in a tracking context. 101 + * This is a helper that uses the same tracking mechanism as the core. 102 + */ 103 + function extractComputedDeps(_comp: ComputedSignal<unknown>): Array<Signal<unknown> | ComputedSignal<unknown>> { 104 + // The computed has already run and subscribed to its dependencies 105 + // TODO: We need to access the internal dependency tracking 106 + return []; 107 + } 108 + 109 + export function attachDebugger(sig: Signal<unknown> | ComputedSignal<unknown>, type: SignalType, name?: string): void { 110 + registerSignal(sig, type, name); 111 + } 112 + 113 + export const vdebugger = { 114 + signal: debugSignal, 115 + computed: debugComputed, 116 + reactive: debugReactive, 117 + attach: attachDebugger, 118 + getAllSignals, 119 + getAllReactives, 120 + getSignalInfo, 121 + getReactiveInfo, 122 + getStats: getRegistryStats, 123 + nameSignal, 124 + nameReactive, 125 + getDependencies, 126 + getDependents, 127 + buildGraph: buildDependencyGraph, 128 + detectCycles: detectCircularDependencies, 129 + getDepth: getSignalDepth, 130 + log: logSignal, 131 + logReactive, 132 + logAll: logAllSignals, 133 + logAllReactives, 134 + logTable: logSignalTable, 135 + trace, 136 + watch, 137 + enableTracing: enableGlobalTracing, 138 + disableTracing: disableGlobalTracing, 139 + clear: clearRegistry, 140 + }; 141 + 142 + export type { GraphNode, SignalMetadata, SignalType } from "$types/volt"; 143 + export { hasDependency } from "./debug/graph"; 144 + export { 145 + buildDependencyGraph, 146 + detectCircularDependencies, 147 + getDependencies, 148 + getDependents, 149 + getSignalDepth, 150 + } from "./debug/graph"; 151 + export { 152 + disableGlobalTracing, 153 + enableGlobalTracing, 154 + logAllReactives, 155 + logAllSignals, 156 + logReactive, 157 + logSignal, 158 + logSignalTable, 159 + trace, 160 + watch, 161 + } from "./debug/logger"; 162 + export { 163 + clearRegistry, 164 + getAllReactives, 165 + getAllSignals, 166 + getReactiveInfo, 167 + getReactiveMetadata, 168 + getRegistryStats, 169 + getSignalInfo, 170 + getSignalMetadata, 171 + nameReactive, 172 + } from "./debug/registry"; 173 + export { nameSignal } from "./debug/registry";
+165
lib/src/debug/graph.ts
··· 1 + /** 2 + * Dependency graph tracking and querying for debugging. 3 + * 4 + * Tracks signal dependency relationships to enable visualization 5 + * and debugging of reactive data flow. 6 + */ 7 + 8 + import type { AnySignal, Dep, DepGraph, GraphNode } from "$types/volt"; 9 + import { getSignalMetadata } from "./registry"; 10 + 11 + const dependencies = new WeakMap<AnySignal, Set<Dep>>(); 12 + const dependents = new WeakMap<Dep, Set<AnySignal>>(); 13 + 14 + /** 15 + * Record that a signal/computed depends on certain dependencies. 16 + * Should be called after tracking is complete. 17 + */ 18 + export function recordDependencies(signal: AnySignal, deps: Dep[]): void { 19 + const existingDeps = dependencies.get(signal) || new Set(); 20 + 21 + for (const dep of deps) { 22 + existingDeps.add(dep); 23 + 24 + const existingDependents = dependents.get(dep) || new Set(); 25 + existingDependents.add(signal); 26 + dependents.set(dep, existingDependents); 27 + } 28 + 29 + dependencies.set(signal, existingDeps); 30 + } 31 + 32 + /** 33 + * Get all signals that this signal/computed depends on. 34 + */ 35 + export function getDependencies(signal: AnySignal): Dep[] { 36 + const deps = dependencies.get(signal); 37 + return deps ? [...deps] : []; 38 + } 39 + 40 + /** 41 + * Get all signals/computeds that depend on this signal. 42 + */ 43 + export function getDependents(signal: Dep): AnySignal[] { 44 + const deps = dependents.get(signal); 45 + return deps ? [...deps] : []; 46 + } 47 + 48 + /** 49 + * Check if there's a dependency relationship between two signals. 50 + */ 51 + export function hasDependency(dependent: AnySignal, dependency: Dep): boolean { 52 + const deps = dependencies.get(dependent); 53 + return deps ? deps.has(dependency) : false; 54 + } 55 + 56 + export function buildDependencyGraph(signals: AnySignal[]): DepGraph { 57 + const nodes: GraphNode[] = []; 58 + const edges: Array<{ from: string; to: string }> = []; 59 + const signalToId = new Map<AnySignal, string>(); 60 + 61 + for (const signal of signals) { 62 + const metadata = getSignalMetadata(signal); 63 + if (!metadata) continue; 64 + 65 + signalToId.set(signal, metadata.id); 66 + 67 + const deps = getDependencies(signal); 68 + const depIds = deps.map((dep) => { 69 + const depMetadata = getSignalMetadata(dep as AnySignal); 70 + return depMetadata?.id; 71 + }).filter((id): id is string => id !== undefined); 72 + 73 + const depnts = getDependents(signal); 74 + const depntIds = depnts.map((depnt) => { 75 + const depntMetadata = getSignalMetadata(depnt); 76 + return depntMetadata?.id; 77 + }).filter((id): id is string => id !== undefined); 78 + 79 + nodes.push({ 80 + signal, 81 + id: metadata.id, 82 + name: metadata.name, 83 + type: metadata.type, 84 + value: signal.get(), 85 + dependencies: depIds, 86 + dependents: depntIds, 87 + }); 88 + } 89 + 90 + for (const node of nodes) { 91 + for (const depId of node.dependencies) { 92 + edges.push({ from: depId, to: node.id }); 93 + } 94 + } 95 + 96 + return { nodes, edges }; 97 + } 98 + 99 + export function detectCircularDependencies(signal: AnySignal): AnySignal[] | null { 100 + const visited = new Set<AnySignal>(); 101 + const path = new Set<AnySignal>(); 102 + 103 + function dfs(current: AnySignal): AnySignal[] | null { 104 + if (path.has(current)) { 105 + return [current]; 106 + } 107 + 108 + if (visited.has(current)) { 109 + return null; 110 + } 111 + 112 + visited.add(current); 113 + path.add(current); 114 + 115 + const deps = getDependencies(current); 116 + for (const dep of deps) { 117 + const cycle = dfs(dep as AnySignal); 118 + if (cycle) { 119 + cycle.push(current); 120 + return cycle; 121 + } 122 + } 123 + 124 + path.delete(current); 125 + return null; 126 + } 127 + 128 + return dfs(signal); 129 + } 130 + 131 + /** 132 + * Get the depth of a signal in the dependency tree. 133 + * Signals with no dependencies have depth 0. 134 + */ 135 + export function getSignalDepth(signal: AnySignal): number { 136 + const visited = new Set<AnySignal>(); 137 + 138 + function calculateDepth(current: AnySignal): number { 139 + if (visited.has(current)) { 140 + return 0; 141 + } 142 + 143 + visited.add(current); 144 + 145 + const deps = getDependencies(current); 146 + if (deps.length === 0) { 147 + return 0; 148 + } 149 + 150 + let maxDepth = 0; 151 + for (const dep of deps) { 152 + const depth = calculateDepth(dep as AnySignal); 153 + maxDepth = Math.max(maxDepth, depth); 154 + } 155 + 156 + return maxDepth + 1; 157 + } 158 + 159 + return calculateDepth(signal); 160 + } 161 + 162 + export function clearDependencyGraph(): void { 163 + // WeakMaps don't have a clear method, but we can't do much here 164 + // The GC will clean up when signals are no longer referenced 165 + }
+193
lib/src/debug/logger.ts
··· 1 + /** 2 + * Console logging utilities for debugging signals. 3 + * 4 + * Provides pretty-printed output and update tracing. 5 + */ 6 + 7 + import type { AnySignal } from "$types/volt"; 8 + import { getDependencies, getDependents } from "./graph"; 9 + import { getAllReactives, getAllSignals, getReactiveInfo, getSignalInfo } from "./registry"; 10 + 11 + const trackedSignals = new WeakSet<AnySignal>(); 12 + const traceListeners = new WeakMap<AnySignal, (value: unknown) => void>(); 13 + 14 + /** 15 + * Pretty-print a signal's information to the console. 16 + */ 17 + export function logSignal(signal: AnySignal): void { 18 + const info = getSignalInfo(signal); 19 + if (!info) { 20 + console.log("[Volt Debug] Unregistered signal"); 21 + return; 22 + } 23 + 24 + const deps = getDependencies(signal); 25 + const depnts = getDependents(signal); 26 + 27 + console.group(`[Volt Signal] ${info.name || info.id}`); 28 + console.log("Type:", info.type); 29 + console.log("Value:", info.value); 30 + console.log("Age:", `${(info.age / 1000).toFixed(2)}s`); 31 + console.log("Dependencies:", deps.length); 32 + console.log("Dependents:", depnts.length); 33 + 34 + if (deps.length > 0) { 35 + console.group("Depends on:"); 36 + for (const dep of deps) { 37 + const depInfo = getSignalInfo(dep as AnySignal); 38 + if (depInfo) { 39 + console.log(` - ${depInfo.name || depInfo.id} = ${depInfo.value}`); 40 + } 41 + } 42 + console.groupEnd(); 43 + } 44 + 45 + if (depnts.length > 0) { 46 + console.group("Dependents:"); 47 + for (const depnt of depnts) { 48 + const depntInfo = getSignalInfo(depnt); 49 + if (depntInfo) { 50 + console.log(` - ${depntInfo.name || depntInfo.id}`); 51 + } 52 + } 53 + console.groupEnd(); 54 + } 55 + 56 + console.groupEnd(); 57 + } 58 + 59 + export function logAllSignals(): void { 60 + const signals = getAllSignals(); 61 + console.group(`[Volt Debug] All Signals (${signals.length})`); 62 + 63 + for (const signal of signals) { 64 + const info = getSignalInfo(signal); 65 + if (info) { 66 + console.log(`${info.id.padEnd(15)} ${(info.name || "unnamed").padEnd(20)} ${String(info.value)}`); 67 + } 68 + } 69 + 70 + console.groupEnd(); 71 + } 72 + 73 + /** 74 + * Pretty-print a reactive object's information to the console. 75 + */ 76 + export function logReactive(obj: object): void { 77 + const info = getReactiveInfo(obj); 78 + if (!info) { 79 + console.log("[Volt Debug] Unregistered reactive object"); 80 + return; 81 + } 82 + 83 + console.group(`[Volt Reactive] ${info.name || info.id}`); 84 + console.log("Type:", info.type); 85 + console.log("Value:", info.value); 86 + console.log("Age:", `${(info.age / 1000).toFixed(2)}s`); 87 + console.groupEnd(); 88 + } 89 + 90 + export function logAllReactives(): void { 91 + const reactives = getAllReactives(); 92 + console.group(`[Volt Debug] All Reactive Objects (${reactives.length})`); 93 + 94 + for (const obj of reactives) { 95 + const info = getReactiveInfo(obj); 96 + if (info) { 97 + console.log(`${info.id.padEnd(15)} ${(info.name || "unnamed").padEnd(20)} ${JSON.stringify(info.value)}`); 98 + } 99 + } 100 + 101 + console.groupEnd(); 102 + } 103 + 104 + export function trace(signal: AnySignal, enabled = true): void { 105 + if (!enabled) { 106 + const listener = traceListeners.get(signal); 107 + if (listener) { 108 + // Can't unsubscribe without keeping the unsubscribe function 109 + // TODO: we need to store unsubscribe functions 110 + trackedSignals.delete(signal); 111 + traceListeners.delete(signal); 112 + } 113 + return; 114 + } 115 + 116 + if (trackedSignals.has(signal)) { 117 + return; 118 + } 119 + 120 + const info = getSignalInfo(signal); 121 + const name = info?.name || info?.id || "unknown"; 122 + 123 + const listener = (value: unknown) => { 124 + const stack = new Error("Listener").stack; 125 + const caller = stack?.split("\n")[3]?.trim(); 126 + 127 + console.log(`[Volt Trace] ${name} changed:`, value, caller ? `(from ${caller})` : ""); 128 + }; 129 + 130 + signal.subscribe(listener); 131 + traceListeners.set(signal, listener); 132 + trackedSignals.add(signal); 133 + 134 + console.log(`[Volt Debug] Tracing enabled for ${name}`); 135 + } 136 + 137 + export function enableGlobalTracing(): void { 138 + const signals = getAllSignals(); 139 + console.log(`[Volt Debug] Enabling global tracing for ${signals.length} signals`); 140 + 141 + for (const signal of signals) { 142 + trace(signal, true); 143 + } 144 + } 145 + 146 + export function disableGlobalTracing(): void { 147 + const signals = getAllSignals(); 148 + for (const signal of signals) { 149 + trace(signal, false); 150 + } 151 + 152 + console.log("[Volt Debug] Global tracing disabled"); 153 + } 154 + 155 + export function logSignalTable(): void { 156 + const signals = getAllSignals(); 157 + const data = signals.map((signal) => { 158 + const info = getSignalInfo(signal); 159 + if (!info) return null; 160 + 161 + return { 162 + ID: info.id, 163 + Name: info.name || "(unnamed)", 164 + Type: info.type, 165 + Value: String(info.value).slice(0, 50), 166 + "Age (s)": (info.age / 1000).toFixed(2), 167 + Dependencies: getDependencies(signal).length, 168 + Dependents: getDependents(signal).length, 169 + }; 170 + }).filter((row): row is NonNullable<typeof row> => row !== null); 171 + 172 + console.table(data); 173 + } 174 + 175 + export function watch(signal: AnySignal): () => void { 176 + const info = getSignalInfo(signal); 177 + const name = info?.name || info?.id || "unknown"; 178 + 179 + console.log(`[Volt Debug] Watching ${name}`); 180 + 181 + const unsubscribe = signal.subscribe((value) => { 182 + const timestamp = new Date().toISOString(); 183 + console.group(`[Volt Watch] ${name} updated at ${timestamp}`); 184 + console.log("New value:", value); 185 + logSignal(signal); 186 + console.groupEnd(); 187 + }); 188 + 189 + return () => { 190 + console.log(`[Volt Debug] Stopped watching ${name}`); 191 + unsubscribe(); 192 + }; 193 + }
+175
lib/src/debug/registry.ts
··· 1 + /** 2 + * Signal registry for debugging and introspection. 3 + * 4 + * Tracks all signals with metadata for development tooling. 5 + * Uses WeakMap to avoid memory leaks - signals can be garbage collected. 6 + */ 7 + 8 + import type { Optional } from "$types/helpers"; 9 + import type { ComputedSignal, Signal, SignalMetadata, SignalType } from "$types/volt"; 10 + 11 + type SignalInfo = { id: string; type: SignalType; name?: string; value: unknown; createdAt: number; age: number }; 12 + 13 + type ReactiveInfo = { id: string; type: SignalType; name?: string; value: unknown; createdAt: number; age: number }; 14 + 15 + type RegistryStats = { totalSignals: number; regularSignals: number; computedSignals: number; reactiveObjects: number }; 16 + 17 + let nextId = 1; 18 + const signalMetadata = new WeakMap<Signal<unknown> | ComputedSignal<unknown>, SignalMetadata>(); 19 + const allSignals = new Set<WeakRef<Signal<unknown> | ComputedSignal<unknown>>>(); 20 + const reactiveMetadata = new WeakMap<object, SignalMetadata>(); 21 + const allReactives = new Set<WeakRef<object>>(); 22 + 23 + /** 24 + * Register a signal in the debug registry. Should be called when a signal or computed is created. 25 + */ 26 + export function registerSignal(sig: Signal<unknown> | ComputedSignal<unknown>, type: SignalType, name?: string): void { 27 + if (signalMetadata.has(sig)) { 28 + return; 29 + } 30 + 31 + const metadata: SignalMetadata = { id: `${type}-${nextId++}`, type, name, createdAt: Date.now() }; 32 + 33 + signalMetadata.set(sig, metadata); 34 + allSignals.add(new WeakRef(sig)); 35 + } 36 + 37 + export function getSignalMetadata(sig: Signal<unknown> | ComputedSignal<unknown>): SignalMetadata | undefined { 38 + return signalMetadata.get(sig); 39 + } 40 + 41 + /** 42 + * Get all currently tracked signals. 43 + * Automatically cleans up garbage-collected signals. 44 + */ 45 + export function getAllSignals(): Array<Signal<unknown> | ComputedSignal<unknown>> { 46 + const active: Array<Signal<unknown> | ComputedSignal<unknown>> = []; 47 + const toDelete: Array<WeakRef<Signal<unknown> | ComputedSignal<unknown>>> = []; 48 + 49 + for (const ref of allSignals) { 50 + const sig = ref.deref(); 51 + if (sig) { 52 + active.push(sig); 53 + } else { 54 + toDelete.push(ref); 55 + } 56 + } 57 + 58 + for (const ref of toDelete) { 59 + allSignals.delete(ref); 60 + } 61 + 62 + return active; 63 + } 64 + 65 + export function getSignalInfo(sig: Signal<unknown> | ComputedSignal<unknown>): Optional<SignalInfo> { 66 + const metadata = signalMetadata.get(sig); 67 + if (!metadata) { 68 + return undefined; 69 + } 70 + 71 + return { 72 + id: metadata.id, 73 + type: metadata.type, 74 + name: metadata.name, 75 + value: sig.get(), 76 + createdAt: metadata.createdAt, 77 + age: Date.now() - metadata.createdAt, 78 + }; 79 + } 80 + 81 + export function nameSignal(sig: Signal<unknown> | ComputedSignal<unknown>, name: string): void { 82 + const metadata = signalMetadata.get(sig); 83 + if (metadata) { 84 + metadata.name = name; 85 + } 86 + } 87 + 88 + export function clearRegistry(): void { 89 + allSignals.clear(); 90 + allReactives.clear(); 91 + nextId = 1; 92 + } 93 + 94 + export function getRegistryStats(): RegistryStats { 95 + const signals = getAllSignals(); 96 + let regularSignals = 0; 97 + let computedSignals = 0; 98 + 99 + for (const sig of signals) { 100 + const metadata = signalMetadata.get(sig); 101 + if (metadata) { 102 + if (metadata.type === "signal") { 103 + regularSignals++; 104 + } else { 105 + computedSignals++; 106 + } 107 + } 108 + } 109 + 110 + const reactives = getAllReactives(); 111 + 112 + return { totalSignals: signals.length, regularSignals, computedSignals, reactiveObjects: reactives.length }; 113 + } 114 + 115 + export function registerReactive(obj: object, name?: string): void { 116 + if (reactiveMetadata.has(obj)) { 117 + return; 118 + } 119 + 120 + const metadata: SignalMetadata = { id: `reactive-${nextId++}`, type: "reactive", name, createdAt: Date.now() }; 121 + 122 + reactiveMetadata.set(obj, metadata); 123 + allReactives.add(new WeakRef(obj)); 124 + } 125 + 126 + export function getReactiveMetadata(obj: object): SignalMetadata | undefined { 127 + return reactiveMetadata.get(obj); 128 + } 129 + 130 + /** 131 + * Get all currently tracked reactive objects. 132 + * Automatically cleans up garbage-collected objects. 133 + */ 134 + export function getAllReactives(): object[] { 135 + const active: object[] = []; 136 + const toDelete: Array<WeakRef<object>> = []; 137 + 138 + for (const ref of allReactives) { 139 + const obj = ref.deref(); 140 + if (obj) { 141 + active.push(obj); 142 + } else { 143 + toDelete.push(ref); 144 + } 145 + } 146 + 147 + for (const ref of toDelete) { 148 + allReactives.delete(ref); 149 + } 150 + 151 + return active; 152 + } 153 + 154 + export function getReactiveInfo(obj: object): Optional<ReactiveInfo> { 155 + const metadata = reactiveMetadata.get(obj); 156 + if (!metadata) { 157 + return undefined; 158 + } 159 + 160 + return { 161 + id: metadata.id, 162 + type: metadata.type, 163 + name: metadata.name, 164 + value: obj, 165 + createdAt: metadata.createdAt, 166 + age: Date.now() - metadata.createdAt, 167 + }; 168 + } 169 + 170 + export function nameReactive(obj: object, name: string): void { 171 + const metadata = reactiveMetadata.get(obj); 172 + if (metadata) { 173 + metadata.name = name; 174 + } 175 + }
+4
lib/src/index.ts
··· 18 18 unregisterGlobalHook, 19 19 } from "$core/lifecycle"; 20 20 export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin"; 21 + export { isReactive, reactive, toRaw } from "$core/reactive"; 21 22 export { computed, effect, signal } from "$core/signal"; 22 23 export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; 23 24 export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; ··· 32 33 GlobalHookName, 33 34 HydrateOptions, 34 35 HydrateResult, 36 + IsReactive, 35 37 ParsedHttpConfig, 36 38 PluginContext, 37 39 PluginHandler, 40 + ReactiveArray, 38 41 RetryConfig, 39 42 SerializedScope, 40 43 Signal, 44 + UnwrapReactive, 41 45 } from "$types/volt";
+34
lib/src/types/volt.d.ts
··· 96 96 } 97 97 98 98 /** 99 + * Utility type to unwrap reactive proxies and get the original type. 100 + * Since reactive() returns a transparent proxy, this is mostly for documentation. 101 + */ 102 + export type UnwrapReactive<T> = T extends object ? T : never; 103 + 104 + /** 105 + * Utility type for reactive arrays with enhanced type safety. 106 + */ 107 + export type ReactiveArray<T> = T[]; 108 + 109 + /** 110 + * Utility type to check if a value is reactive (has the __v_isReactive marker). 111 + */ 112 + export type IsReactive<T> = T extends { __v_isReactive: true } ? true : false; 113 + 114 + /** 99 115 * Storage adapter interface for custom persistence backends 100 116 */ 101 117 export interface StorageAdapter { ··· 291 307 onMount: Set<() => void>; 292 308 onUnmount: Set<() => void>; 293 309 }; 310 + 311 + export type AnySignal = Signal<unknown> | ComputedSignal<unknown>; 312 + 313 + export type GraphNode = { 314 + signal: AnySignal; 315 + id: string; 316 + name?: string; 317 + type: SignalType; 318 + value: unknown; 319 + dependencies: string[]; 320 + dependents: string[]; 321 + }; 322 + 323 + export type DepGraph = { nodes: GraphNode[]; edges: Array<{ from: string; to: string }> }; 324 + 325 + export type SignalType = "signal" | "computed" | "reactive"; 326 + 327 + export type SignalMetadata = { id: string; type: SignalType; name?: string; createdAt: number; stackTrace?: string };
+407
lib/test/core/reactive.test.ts
··· 1 + /* eslint-disable @typescript-eslint/no-unused-expressions */ 2 + import { isReactive, reactive, toRaw } from "$core/reactive"; 3 + import { computed, effect, signal } from "$core/signal"; 4 + import { describe, expect, it, vi } from "vitest"; 5 + 6 + describe("reactive", () => { 7 + describe("basic reactivity", () => { 8 + it("creates a reactive object", () => { 9 + const obj = reactive({ count: 0 }); 10 + expect(obj.count).toBe(0); 11 + }); 12 + 13 + it("allows setting properties", () => { 14 + const obj = reactive({ count: 0 }); 15 + obj.count = 5; 16 + expect(obj.count).toBe(5); 17 + }); 18 + 19 + it("triggers effects when properties change", () => { 20 + const obj = reactive({ count: 0 }); 21 + const effectFn = vi.fn(() => { 22 + obj.count; 23 + }); 24 + 25 + effect(effectFn); 26 + expect(effectFn).toHaveBeenCalledTimes(1); 27 + 28 + obj.count = 5; 29 + expect(effectFn).toHaveBeenCalledTimes(2); 30 + }); 31 + 32 + it("works with computed signals", () => { 33 + const obj = reactive({ count: 5 }); 34 + const doubled = computed(() => obj.count * 2); 35 + 36 + expect(doubled.get()).toBe(10); 37 + 38 + obj.count = 10; 39 + expect(doubled.get()).toBe(20); 40 + }); 41 + 42 + it("supports multiple properties", () => { 43 + const obj = reactive({ a: 1, b: 2 }); 44 + const sum = computed(() => obj.a + obj.b); 45 + 46 + expect(sum.get()).toBe(3); 47 + 48 + obj.a = 5; 49 + expect(sum.get()).toBe(7); 50 + 51 + obj.b = 10; 52 + expect(sum.get()).toBe(15); 53 + }); 54 + 55 + it("does not trigger effects when value is the same", () => { 56 + const obj = reactive({ count: 0 }); 57 + const effectFn = vi.fn(() => { 58 + obj.count; 59 + }); 60 + 61 + effect(effectFn); 62 + expect(effectFn).toHaveBeenCalledTimes(1); 63 + 64 + obj.count = 0; 65 + expect(effectFn).toHaveBeenCalledTimes(1); 66 + }); 67 + 68 + it("handles property deletion", () => { 69 + const obj = reactive({ count: 5 as number | undefined, name: "test" }); 70 + const effectFn = vi.fn(() => { 71 + obj.count; 72 + }); 73 + 74 + effect(effectFn); 75 + expect(effectFn).toHaveBeenCalledTimes(1); 76 + 77 + delete obj.count; 78 + expect(effectFn).toHaveBeenCalledTimes(2); 79 + expect(obj.count).toBeUndefined(); 80 + }); 81 + 82 + it("handles 'in' operator", () => { 83 + const obj = reactive({ count: 5 }); 84 + expect("count" in obj).toBe(true); 85 + expect("missing" in obj).toBe(false); 86 + }); 87 + }); 88 + 89 + describe("nested reactivity", () => { 90 + it("makes nested objects reactive", () => { 91 + const obj = reactive({ nested: { count: 0 } }); 92 + const effectFn = vi.fn(() => { 93 + obj.nested.count; 94 + }); 95 + 96 + effect(effectFn); 97 + expect(effectFn).toHaveBeenCalledTimes(1); 98 + 99 + obj.nested.count = 5; 100 + expect(effectFn).toHaveBeenCalledTimes(2); 101 + }); 102 + 103 + it("handles deeply nested objects", () => { 104 + const obj = reactive({ a: { b: { c: 0 } } }); 105 + const effectFn = vi.fn(() => { 106 + obj.a.b.c; 107 + }); 108 + 109 + effect(effectFn); 110 + expect(effectFn).toHaveBeenCalledTimes(1); 111 + 112 + obj.a.b.c = 5; 113 + expect(effectFn).toHaveBeenCalledTimes(2); 114 + }); 115 + 116 + it("replaces nested objects reactively", () => { 117 + const obj = reactive({ nested: { count: 0 } }); 118 + const effectFn = vi.fn(() => { 119 + obj.nested.count; 120 + }); 121 + 122 + effect(effectFn); 123 + expect(effectFn).toHaveBeenCalledTimes(1); 124 + 125 + obj.nested = { count: 10 }; 126 + expect(effectFn).toHaveBeenCalledTimes(2); 127 + expect(obj.nested.count).toBe(10); 128 + }); 129 + }); 130 + 131 + describe("array reactivity", () => { 132 + it("creates a reactive array", () => { 133 + const arr = reactive([1, 2, 3]); 134 + expect(arr[0]).toBe(1); 135 + expect(arr.length).toBe(3); 136 + }); 137 + 138 + it("triggers effects on array index changes", () => { 139 + const arr = reactive([1, 2, 3]); 140 + const effectFn = vi.fn(() => { 141 + arr[0]; 142 + }); 143 + 144 + effect(effectFn); 145 + expect(effectFn).toHaveBeenCalledTimes(1); 146 + 147 + arr[0] = 10; 148 + expect(effectFn).toHaveBeenCalledTimes(2); 149 + }); 150 + 151 + it("triggers effects on push", () => { 152 + const arr = reactive([1, 2, 3]); 153 + const effectFn = vi.fn(() => { 154 + arr.length; 155 + }); 156 + 157 + effect(effectFn); 158 + expect(effectFn).toHaveBeenCalledTimes(1); 159 + 160 + arr.push(4); 161 + expect(effectFn).toHaveBeenCalledTimes(2); 162 + expect(arr.length).toBe(4); 163 + expect(arr[3]).toBe(4); 164 + }); 165 + 166 + it("triggers effects on pop", () => { 167 + const arr = reactive([1, 2, 3]); 168 + const effectFn = vi.fn(() => { 169 + arr.length; 170 + }); 171 + 172 + effect(effectFn); 173 + expect(effectFn).toHaveBeenCalledTimes(1); 174 + 175 + const popped = arr.pop(); 176 + expect(popped).toBe(3); 177 + expect(effectFn).toHaveBeenCalledTimes(2); 178 + expect(arr.length).toBe(2); 179 + }); 180 + 181 + it("triggers effects on shift", () => { 182 + const arr = reactive([1, 2, 3]); 183 + const effectFn = vi.fn(() => { 184 + arr.length; 185 + }); 186 + 187 + effect(effectFn); 188 + expect(effectFn).toHaveBeenCalledTimes(1); 189 + 190 + const shifted = arr.shift(); 191 + expect(shifted).toBe(1); 192 + expect(effectFn).toHaveBeenCalledTimes(2); 193 + expect(arr.length).toBe(2); 194 + }); 195 + 196 + it("triggers effects on unshift", () => { 197 + const arr = reactive([1, 2, 3]); 198 + const effectFn = vi.fn(() => { 199 + arr.length; 200 + }); 201 + 202 + effect(effectFn); 203 + expect(effectFn).toHaveBeenCalledTimes(1); 204 + 205 + arr.unshift(0); 206 + expect(effectFn).toHaveBeenCalledTimes(2); 207 + expect(arr.length).toBe(4); 208 + expect(arr[0]).toBe(0); 209 + }); 210 + 211 + it("triggers effects on splice", () => { 212 + const arr = reactive([1, 2, 3, 4, 5]); 213 + const effectFn = vi.fn(() => { 214 + arr[2]; 215 + }); 216 + 217 + effect(effectFn); 218 + expect(effectFn).toHaveBeenCalledTimes(1); 219 + 220 + arr.splice(2, 1, 10); 221 + expect(effectFn).toHaveBeenCalledTimes(2); 222 + expect(arr).toEqual([1, 2, 10, 4, 5]); 223 + }); 224 + 225 + it("triggers effects on sort", () => { 226 + const arr = reactive([3, 1, 2]); 227 + const effectFn = vi.fn(() => { 228 + arr[0]; 229 + }); 230 + 231 + effect(effectFn); 232 + expect(effectFn).toHaveBeenCalledTimes(1); 233 + 234 + arr.sort(); 235 + expect(effectFn).toHaveBeenCalledTimes(2); 236 + expect(arr).toEqual([1, 2, 3]); 237 + }); 238 + 239 + it("triggers effects on reverse", () => { 240 + const arr = reactive([1, 2, 3]); 241 + const effectFn = vi.fn(() => { 242 + arr[0]; 243 + }); 244 + 245 + effect(effectFn); 246 + expect(effectFn).toHaveBeenCalledTimes(1); 247 + 248 + arr.reverse(); 249 + expect(effectFn).toHaveBeenCalledTimes(2); 250 + expect(arr).toEqual([3, 2, 1]); 251 + }); 252 + 253 + it("handles nested arrays", () => { 254 + const arr = reactive([[1, 2], [3, 4]]); 255 + const effectFn = vi.fn(() => { 256 + arr[0][0]; 257 + }); 258 + 259 + effect(effectFn); 260 + expect(effectFn).toHaveBeenCalledTimes(1); 261 + 262 + arr[0][0] = 10; 263 + expect(effectFn).toHaveBeenCalledTimes(2); 264 + }); 265 + }); 266 + 267 + describe("isReactive and toRaw", () => { 268 + it("identifies reactive objects", () => { 269 + const obj = reactive({ count: 0 }); 270 + expect(isReactive(obj)).toBe(true); 271 + }); 272 + 273 + it("returns false for non-reactive objects", () => { 274 + const obj = { count: 0 }; 275 + expect(isReactive(obj)).toBe(false); 276 + }); 277 + 278 + it("returns false for primitives", () => { 279 + expect(isReactive(5)).toBe(false); 280 + expect(isReactive("test")).toBe(false); 281 + expect(isReactive(null)).toBe(false); 282 + expect(isReactive(void 0)).toBe(false); 283 + }); 284 + 285 + it("returns the raw object from a reactive proxy", () => { 286 + const original = { count: 0 }; 287 + const obj = reactive(original); 288 + const raw = toRaw(obj); 289 + 290 + expect(raw).toBe(original); 291 + expect(isReactive(raw)).toBe(false); 292 + }); 293 + 294 + it("returns the value as-is for non-reactive values", () => { 295 + const obj = { count: 0 }; 296 + expect(toRaw(obj)).toBe(obj); 297 + expect(toRaw(5)).toBe(5); 298 + }); 299 + }); 300 + 301 + describe("edge cases", () => { 302 + it("returns the same proxy for the same object", () => { 303 + const original = { count: 0 }; 304 + const proxy1 = reactive(original); 305 + const proxy2 = reactive(original); 306 + 307 + expect(proxy1).toBe(proxy2); 308 + }); 309 + 310 + it("does not double-wrap reactive objects", () => { 311 + const obj = reactive({ count: 0 }); 312 + const obj2 = reactive(obj); 313 + 314 + expect(obj).toBe(obj2); 315 + }); 316 + 317 + it("handles null and undefined gracefully", () => { 318 + expect(reactive(null as unknown as object)).toBe(null); 319 + expect(reactive(undefined as unknown as object)).toBe(undefined); 320 + }); 321 + 322 + it("handles primitive values gracefully", () => { 323 + expect(reactive(5 as unknown as object)).toBe(5); 324 + expect(reactive("test" as unknown as object)).toBe("test"); 325 + }); 326 + 327 + it("works with mixed signal and reactive", () => { 328 + const sig = signal(5); 329 + const obj = reactive({ count: 0 }); 330 + 331 + const total = computed(() => sig.get() + obj.count); 332 + 333 + expect(total.get()).toBe(5); 334 + 335 + sig.set(10); 336 + expect(total.get()).toBe(10); 337 + 338 + obj.count = 5; 339 + expect(total.get()).toBe(15); 340 + }); 341 + 342 + it("handles circular references", () => { 343 + const obj: { self?: unknown } = reactive({ self: undefined }); 344 + obj.self = obj; 345 + 346 + expect(obj.self).toBe(obj); 347 + }); 348 + 349 + it("supports non-enumerable properties", () => { 350 + const original: { hidden?: number } = {}; 351 + Object.defineProperty(original, "hidden", { value: 42, enumerable: false }); 352 + 353 + const obj = reactive(original); 354 + expect(obj.hidden).toBe(42); 355 + }); 356 + }); 357 + 358 + describe("integration with existing reactivity", () => { 359 + it("works in computed chains", () => { 360 + const obj = reactive({ a: 1, b: 2 }); 361 + const sum = computed(() => obj.a + obj.b); 362 + const doubled = computed(() => sum.get() * 2); 363 + 364 + expect(doubled.get()).toBe(6); 365 + 366 + obj.a = 5; 367 + expect(sum.get()).toBe(7); 368 + expect(doubled.get()).toBe(14); 369 + }); 370 + 371 + it("works with multiple effects", () => { 372 + const obj = reactive({ count: 0 }); 373 + const effect1 = vi.fn(() => { 374 + obj.count; 375 + }); 376 + const effect2 = vi.fn(() => { 377 + obj.count; 378 + }); 379 + 380 + effect(effect1); 381 + effect(effect2); 382 + 383 + expect(effect1).toHaveBeenCalledTimes(1); 384 + expect(effect2).toHaveBeenCalledTimes(1); 385 + 386 + obj.count = 5; 387 + 388 + expect(effect1).toHaveBeenCalledTimes(2); 389 + expect(effect2).toHaveBeenCalledTimes(2); 390 + }); 391 + 392 + it("allows unsubscribing from effects", () => { 393 + const obj = reactive({ count: 0 }); 394 + const effectFn = vi.fn(() => { 395 + obj.count; 396 + }); 397 + 398 + const cleanup = effect(effectFn); 399 + expect(effectFn).toHaveBeenCalledTimes(1); 400 + 401 + cleanup(); 402 + 403 + obj.count = 5; 404 + expect(effectFn).toHaveBeenCalledTimes(1); 405 + }); 406 + }); 407 + });