···6677import type { ChargedRoot, ChargeResult, Scope } from "$types/volt";
88import { mount } from "./binder";
99-import { evaluate, extractDeps } from "./evaluator";
99+import { evaluate } from "./evaluator";
1010import { getComputedAttributes } from "./shared";
1111import { computed, signal } from "./signal";
1212···8686 const computedAttrs = getComputedAttributes(el);
8787 for (const [name, expression] of computedAttrs) {
8888 try {
8989- const dependencies = extractDeps(expression, scope);
9090-9191- scope[name] = computed(() => evaluate(expression, scope), dependencies);
8989+ scope[name] = computed(() => evaluate(expression, scope));
9290 } catch (error) {
9391 console.error(`Failed to create computed "${name}" with expression "${expression}":`, error);
9492 }
+118-31
lib/src/core/signal.ts
···11import type { ComputedSignal, Signal } from "$types/volt";
22+import { recordDep, startTracking, stopTracking } from "./tracker";
2334/**
45 * Creates a new signal with the given initial value.
66+ *
77+ * Signals are reactive primitives that notify subscribers when their value changes.
88+ * When accessed inside a computed() or effect(), they are automatically tracked as dependencies.
59 *
610 * @param initialValue - The initial value of the signal
711 * @returns A Signal object with get, set, and subscribe methods
···1620 const subscribers = new Set<(value: T) => void>();
17211822 const notify = () => {
1919- for (const callback of subscribers) {
2323+ const snapshot = [...subscribers];
2424+ for (const callback of snapshot) {
2025 try {
2126 callback(value);
2227 } catch (error) {
···2530 }
2631 };
27322828- return {
3333+ const sig: Signal<T> = {
2934 get() {
3535+ recordDep(sig);
3036 return value;
3137 },
3238···4753 };
4854 },
4955 };
5656+5757+ return sig;
5058}
51595260/**
5361 * Creates a computed signal that derives its value from other signals.
5454- * The computation function is re-run whenever any of its dependencies change.
6262+ *
6363+ * Dependencies are automatically tracked by detecting which signals are accessed
6464+ * during the computation function execution. The computation is re-run whenever
6565+ * any of its dependencies change.
5566 *
5667 * @param compute - Function that computes the derived value
5757- * @param dps - Array of signals this computation depends on
5868 * @returns A ComputedSignal with get and subscribe methods
5969 *
6070 * @example
6171 * const count = signal(5);
6262- * const doubled = computed(() => count.get() * 2, [count]);
7272+ * const doubled = computed(() => count.get() * 2);
6373 * doubled.get(); // 10
6474 * count.set(10);
6575 * doubled.get(); // 20
6676 */
6767-export function computed<T>(
6868- compute: () => T,
6969- dps: Array<Signal<unknown> | ComputedSignal<unknown>>,
7070-): ComputedSignal<T> {
7171- let value = compute();
7777+export function computed<T>(compute: () => T): ComputedSignal<T> {
7878+ let value: T;
7979+ let isInitialized = false;
8080+ let isRecomputing = false;
7281 const subs = new Set<(value: T) => void>();
8282+ const unsubscribers: Array<() => void> = [];
73837484 const notify = () => {
7575- for (const cb of subs) {
8585+ const snapshot = [...subs];
8686+ for (const cb of snapshot) {
7687 try {
7788 cb(value);
7889 } catch (error) {
···8293 };
83948495 const recompute = () => {
8585- const newValue = compute();
8686- if (value !== newValue) {
8787- value = newValue;
9696+ if (isRecomputing) {
9797+ throw new Error("Circular dependency detected in computed signal");
9898+ }
9999+100100+ isRecomputing = true;
101101+ let shouldNotify = false;
102102+103103+ try {
104104+ for (const unsub of unsubscribers) {
105105+ unsub();
106106+ }
107107+ unsubscribers.length = 0;
108108+109109+ startTracking(comp);
110110+ try {
111111+ const newValue = compute();
112112+113113+ if (!isInitialized || value !== newValue) {
114114+ value = newValue;
115115+ isInitialized = true;
116116+ shouldNotify = subs.size > 0;
117117+ }
118118+ } catch (error) {
119119+ console.error("Error in computed:", error);
120120+ throw error;
121121+ } finally {
122122+ const deps = stopTracking();
123123+124124+ for (const dep of deps) {
125125+ const unsub = dep.subscribe(recompute);
126126+ unsubscribers.push(unsub);
127127+ }
128128+ }
129129+ } finally {
130130+ isRecomputing = false;
131131+ }
132132+133133+ if (shouldNotify) {
88134 notify();
89135 }
90136 };
911379292- for (const dep of dps) {
9393- dep.subscribe(recompute);
9494- }
9595-9696- return {
138138+ const comp: ComputedSignal<T> = {
97139 get() {
140140+ if (!isInitialized) {
141141+ recompute();
142142+ }
143143+144144+ recordDep(comp);
98145 return value;
99146 },
100147101148 subscribe(callback: (value: T) => void) {
149149+ if (!isInitialized) {
150150+ recompute();
151151+ }
152152+102153 subs.add(callback);
103154104155 return () => {
···106157 };
107158 },
108159 };
160160+161161+ return comp;
109162}
110163111164/**
112165 * Creates a side effect that runs when dependencies change.
113166 *
114114- * @param cb - Function to run as a side effect
115115- * @param deps - Array of signals this effect depends on
167167+ * Dependencies are automatically tracked by detecting which signals are accessed
168168+ * during the effect function execution. The effect is re-run whenever any of its
169169+ * dependencies change.
170170+ *
171171+ * @param cb - Function to run as a side effect. Can return a cleanup function.
116172 * @returns Cleanup function to stop the effect
117173 *
118174 * @example
119175 * const count = signal(0);
120176 * const cleanup = effect(() => {
121177 * console.log('Count changed:', count.get());
122122- * }, [count]);
178178+ * });
123179 */
124124-export function effect(
125125- cb: () => void | (() => void),
126126- deps: Array<Signal<unknown> | ComputedSignal<unknown>>,
127127-): () => void {
180180+export function effect(cb: () => void | (() => void)): () => void {
128181 let cleanup: (() => void) | void;
182182+ const unsubscribers: Array<() => void> = [];
183183+ let isDisposed = false;
129184130185 const runEffect = () => {
186186+ if (isDisposed) {
187187+ return;
188188+ }
189189+190190+ for (const unsub of unsubscribers) {
191191+ unsub();
192192+ }
193193+ unsubscribers.length = 0;
194194+131195 if (cleanup) {
132132- cleanup();
196196+ try {
197197+ cleanup();
198198+ } catch (error) {
199199+ console.error("Error in effect cleanup:", error);
200200+ }
201201+ cleanup = undefined;
133202 }
203203+204204+ startTracking();
134205 try {
135206 cleanup = cb();
136207 } catch (error) {
137208 console.error("Error in effect:", error);
209209+ } finally {
210210+ const deps = stopTracking();
211211+212212+ for (const dep of deps) {
213213+ const unsub = dep.subscribe(runEffect);
214214+ unsubscribers.push(unsub);
215215+ }
138216 }
139217 };
140218141219 runEffect();
142220143143- const unsubscribers = deps.map((dependency) => dependency.subscribe(runEffect));
144144-145221 return () => {
222222+ isDisposed = true;
223223+146224 if (cleanup) {
147147- cleanup();
225225+ try {
226226+ cleanup();
227227+ } catch (error) {
228228+ console.error("Error in effect cleanup:", error);
229229+ }
148230 }
231231+149232 for (const unsubscribe of unsubscribers) {
150150- unsubscribe();
233233+ try {
234234+ unsubscribe();
235235+ } catch (error) {
236236+ console.error("Error unsubscribing effect:", error);
237237+ }
151238 }
152239 };
153240}
+5-6
lib/src/core/ssr.ts
···88import type { Nullable } from "$types/helpers";
99import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt";
1010import { mount } from "./binder";
1111-import { evaluate, extractDeps } from "./evaluator";
1111+import { evaluate } from "./evaluator";
1212import { getComputedAttributes, isSignal } from "./shared";
1313import { computed, signal } from "./signal";
1414···2525 * ```ts
2626 * const scope = {
2727 * count: signal(0),
2828- * double: computed(() => scope.count.get() * 2, [scope.count])
2828+ * double: computed(() => scope.count.get() * 2)
2929 * };
3030 * const json = serializeScope(scope);
3131 * // Returns: '{"count":0,"double":0}'
···7777 * @returns True if element is marked as hydrated
7878 */
7979export function isHydrated(el: Element): boolean {
8080- return el.hasAttribute("data-volt-hydrated");
8080+ return Object.hasOwn((el as HTMLElement).dataset, "voltHydrated");
8181}
82828383/**
···8686 * @param el - Element to mark
8787 */
8888function markHydrated(el: Element): void {
8989- el.setAttribute("data-volt-hydrated", "true");
8989+ (el as HTMLElement).dataset.voltHydrated = "true";
9090}
91919292/**
···178178 const computedAttrs = getComputedAttributes(el);
179179 for (const [name, expression] of computedAttrs) {
180180 try {
181181- const dependencies = extractDeps(expression, scope);
182182- scope[name] = computed(() => evaluate(expression, scope), dependencies);
181181+ scope[name] = computed(() => evaluate(expression, scope));
183182 } catch (error) {
184183 console.error(`Failed to create computed "${name}" with expression "${expression}":`, error);
185184 }
+91
lib/src/core/tracker.ts
···11+/**
22+ * Dependency tracking system for automatic signal dependency detection.
33+ *
44+ * Uses a stack-based tracking context to record signal accesses during computations.
55+ * When a computed signal or effect runs, it pushes a tracking context onto the stack.
66+ * Any signal.get() calls during execution are recorded as dependencies.
77+ */
88+99+import type { Dep } from "$types/volt";
1010+1111+/**
1212+ * Holds the set of dependencies discovered during this tracking session and the source being tracked (to prevent cycles)
1313+ */
1414+type TrackingContext = { deps: Set<Dep>; source?: Dep };
1515+1616+/**
1717+ * Global stack of active tracking contexts.
1818+ * When nested computeds run, multiple contexts can be active simultaneously.
1919+ */
2020+const trackingStack: TrackingContext[] = [];
2121+2222+/**
2323+ * Get the currently active tracking context, if any.
2424+ */
2525+function getActiveContext(): TrackingContext | undefined {
2626+ return trackingStack.at(-1);
2727+}
2828+2929+/**
3030+ * Start tracking signal dependencies.
3131+ * Should be called before executing a computation function.
3232+ *
3333+ * @param source - Optional source signal for cycle detection
3434+ * @returns The tracking context
3535+ */
3636+export function startTracking(source?: Dep): TrackingContext {
3737+ const context: TrackingContext = { deps: new Set(), source };
3838+3939+ trackingStack.push(context);
4040+ return context;
4141+}
4242+4343+/**
4444+ * Stop tracking and return the collected dependencies.
4545+ * Should be called after executing a computation function.
4646+ *
4747+ * @returns Array of signals that were accessed during tracking
4848+ */
4949+export function stopTracking(): Dep[] {
5050+ const context = trackingStack.pop();
5151+ if (!context) {
5252+ console.warn("stopTracking called without matching startTracking");
5353+ return [];
5454+ }
5555+5656+ return [...context.deps];
5757+}
5858+5959+/**
6060+ * Record a signal access as a dependency.
6161+ * Called by signal.get() when inside a tracking context.
6262+ *
6363+ * @param dep - The signal being accessed
6464+ */
6565+export function recordDep(dep: Dep): void {
6666+ const context = getActiveContext();
6767+ if (!context) {
6868+ return;
6969+ }
7070+7171+ if (context.source === dep) {
7272+ throw new Error("Circular dependency detected: a signal cannot depend on itself");
7373+ }
7474+7575+ context.deps.add(dep);
7676+}
7777+7878+/**
7979+ * Check if currently inside a tracking context.
8080+ * Useful for conditional behavior in signal.get()
8181+ */
8282+export function isTracking(): boolean {
8383+ return trackingStack.length > 0;
8484+}
8585+8686+/**
8787+ * Get current tracking depth (for debugging).
8888+ */
8989+export function getTrackingDepth(): number {
9090+ return trackingStack.length;
9191+}