···99 * Creates an async side effect that runs when dependencies change.
1010 * Supports abort signals, race protection, debouncing, throttling, and error handling.
1111 *
1212- * @param effectFunction - Async function to run as a side effect
1313- * @param dependencies - Array of signals this effect depends on
1414- * @param options - Configuration options for async behavior
1212+ * @param effectFn - Async function to run as a side effect
1313+ * @param deps - Array of signals this effect depends on
1414+ * @param opts - Configuration options for async behavior
1515 * @returns Cleanup function to stop the effect
1616 *
1717 * @example
···4646 * });
4747 */
4848export function asyncEffect(
4949- effectFunction: AsyncEffectFunction,
5050- dependencies: Array<Signal<unknown> | ComputedSignal<unknown>>,
5151- options: AsyncEffectOptions = {},
4949+ effectFn: AsyncEffectFunction,
5050+ deps: Array<Signal<unknown> | ComputedSignal<unknown>>,
5151+ opts: AsyncEffectOptions = {},
5252): () => void {
5353- const { abortable = false, debounce, throttle, onError, retries = 0, retryDelay = 0 } = options;
5353+ const { abortable = false, debounce, throttle, onError, retries = 0, retryDelay = 0 } = opts;
54545555 let cleanup: (() => void) | void;
5656 let abortController: Optional<AbortController>;
···8383 }
84848585 try {
8686- const result = await effectFunction(abortController?.signal);
8686+ const result = await effectFn(abortController?.signal);
87878888 if (currentExecutionId !== executionId) {
8989 return;
···128128 }
129129 };
130130131131- /**
132132- * Schedule effect execution with debounce/throttle logic
133133- */
134131 const scheduleExecution = () => {
135132 const currentExecutionId = ++executionId;
136133···173170174171 scheduleExecution();
175172176176- const unsubscribers = dependencies.map((dependency) =>
177177- dependency.subscribe(() => {
173173+ const unsubscribers = deps.map((dep) =>
174174+ dep.subscribe(() => {
178175 scheduleExecution();
179176 })
180177 );
+116-181
lib/src/core/binder.ts
···4455import type { Optional } from "$types/helpers";
66import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "$types/volt";
77-import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
88-import { evaluate, extractDependencies, isSignal } from "./evaluator";
77+import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
88+import { evaluate, extractDeps } from "./evaluator";
99import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http";
1010-import { executeGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle";
1010+import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle";
1111import { getPlugin } from "./plugin";
1212+import { findScopedSignal } from "./shared";
12131314/**
1415 * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
1515- * Returns a cleanup function to unmount and dispose all bindings.
1616 *
1717 * @param root - Root element to mount on
1818 * @param scope - Scope object containing signals and data
1919- * @returns Cleanup function to unmount
1919+ * @returns Cleanup function to unmount and dispose all bindings.
2020 */
2121export function mount(root: Element, scope: Scope): CleanupFunction {
2222- executeGlobalHooks("beforeMount", root, scope);
2222+ execGlobalHooks("beforeMount", root, scope);
23232424 const elements = walkDOM(root);
2525 const allCleanups: CleanupFunction[] = [];
2626 const mountedElements: Element[] = [];
27272828 for (const element of elements) {
2929- const attributes = getVoltAttributes(element);
2929+ const attributes = getVoltAttrs(element);
3030 const context: BindingContext = { element, scope, cleanups: [] };
31313232 if (attributes.has("for")) {
···4949 allCleanups.push(...context.cleanups);
5050 }
51515252- executeGlobalHooks("afterMount", root, scope);
5252+ execGlobalHooks("afterMount", root, scope);
53535454 return () => {
5555- executeGlobalHooks("beforeUnmount", root);
5555+ execGlobalHooks("beforeUnmount", root);
56565757 for (const element of mountedElements) {
5858 notifyElementUnmounted(element);
···6666 }
6767 }
68686969- executeGlobalHooks("afterUnmount", root);
6969+ execGlobalHooks("afterUnmount", root);
7070 };
7171}
72727373/**
7474 * Bind a single data-volt-* attribute to an element.
7575 * Routes to the appropriate binding handler.
7676- *
7777- * @param context - Binding context
7878- * @param name - Attribute name (without data-volt- prefix)
7979- * @param value - Attribute value (expression)
8076 */
8181-function bindAttribute(context: BindingContext, name: string, value: string): void {
7777+function bindAttribute(ctx: BindingContext, name: string, value: string): void {
8278 if (name.startsWith("on-")) {
8379 const eventName = name.slice(3);
8484- bindEvent(context, eventName, value);
8080+ bindEvent(ctx, eventName, value);
8581 return;
8682 }
87838884 if (name.startsWith("bind:")) {
8985 const attrName = name.slice(5);
9090- bindAttr(context, attrName, value);
8686+ bindAttr(ctx, attrName, value);
9187 return;
9288 }
93899490 switch (name) {
9591 case "text": {
9696- bindText(context, value);
9292+ bindText(ctx, value);
9793 break;
9894 }
9995 case "html": {
100100- bindHTML(context, value);
9696+ bindHTML(ctx, value);
10197 break;
10298 }
10399 case "class": {
104104- bindClass(context, value);
100100+ bindClass(ctx, value);
105101 break;
106102 }
107103 case "model": {
108108- bindModel(context, value);
104104+ bindModel(ctx, value);
109105 break;
110106 }
111107 case "for": {
112112- bindFor(context, value);
108108+ bindFor(ctx, value);
113109 break;
114110 }
115111 case "get": {
116116- bindGet(context, value);
112112+ bindGet(ctx, value);
117113 break;
118114 }
119115 case "post": {
120120- bindPost(context, value);
116116+ bindPost(ctx, value);
121117 break;
122118 }
123119 case "put": {
124124- bindPut(context, value);
120120+ bindPut(ctx, value);
125121 break;
126122 }
127123 case "patch": {
128128- bindPatch(context, value);
124124+ bindPatch(ctx, value);
129125 break;
130126 }
131127 case "delete": {
132132- bindDelete(context, value);
128128+ bindDelete(ctx, value);
133129 break;
134130 }
135131 default: {
136132 const plugin = getPlugin(name);
137133 if (plugin) {
138138- const pluginContext = createPluginContext(context);
134134+ const pluginContext = createPluginCtx(ctx);
139135 try {
140136 plugin(pluginContext, value);
141137 } catch (error) {
···151147/**
152148 * Bind data-volt-text to update element's text content.
153149 * Subscribes to signals in the expression and updates on change.
154154- *
155155- * @param context - Binding context
156156- * @param expression - Expression to evaluate
157150 */
158158-function bindText(context: BindingContext, expression: string): void {
151151+function bindText(ctx: BindingContext, expr: string): void {
159152 const update = () => {
160160- const value = evaluate(expression, context.scope);
161161- setText(context.element, value);
153153+ const value = evaluate(expr, ctx.scope);
154154+ setText(ctx.element, value);
162155 };
163156164157 update();
165158166166- const signal = findSignalInScope(context.scope, expression);
167167- if (signal) {
168168- const unsubscribe = signal.subscribe(update);
169169- context.cleanups.push(unsubscribe);
159159+ const deps = extractDeps(expr, ctx.scope);
160160+ for (const dep of deps) {
161161+ const unsubscribe = dep.subscribe(update);
162162+ ctx.cleanups.push(unsubscribe);
170163 }
171164}
172165173166/**
174167 * Bind data-volt-html to update element's HTML content.
175175- *
176168 * Subscribes to signals in the expression and updates on change.
177169 */
178178-function bindHTML(context: BindingContext, expression: string): void {
170170+function bindHTML(ctx: BindingContext, expr: string): void {
179171 const update = () => {
180180- const value = evaluate(expression, context.scope);
181181- setHTML(context.element, String(value ?? ""));
172172+ const value = evaluate(expr, ctx.scope);
173173+ setHTML(ctx.element, String(value ?? ""));
182174 };
183175184176 update();
185177186186- const signal = findSignalInScope(context.scope, expression);
187187- if (signal) {
188188- const unsubscribe = signal.subscribe(update);
189189- context.cleanups.push(unsubscribe);
178178+ const dependencies = extractDeps(expr, ctx.scope);
179179+ for (const dependency of dependencies) {
180180+ const unsubscribe = dependency.subscribe(update);
181181+ ctx.cleanups.push(unsubscribe);
190182 }
191183}
192184193185/**
194194- * Bind data-volt-class to toggle CSS classes.
195195- * Supports both string and object notation.
186186+ * Bind data-volt-class to toggle CSS classes. Supports both string and object notation.
196187 * Subscribes to signals in the expression and updates on change.
197197- *
198198- * @param context - Binding context
199199- * @param expression - Expression to evaluate
200188 */
201201-function bindClass(context: BindingContext, expression: string): void {
202202- let previousClasses = new Map<string, boolean>();
189189+function bindClass(ctx: BindingContext, expr: string): void {
190190+ let prevClasses = new Map<string, boolean>();
203191204192 const update = () => {
205205- const value = evaluate(expression, context.scope);
193193+ const value = evaluate(expr, ctx.scope);
206194 const classes = parseClassBinding(value);
207195208208- for (const [className] of previousClasses) {
196196+ for (const [className] of prevClasses) {
209197 if (!classes.has(className)) {
210210- toggleClass(context.element, className, false);
198198+ toggleClass(ctx.element, className, false);
211199 }
212200 }
213201214202 for (const [className, shouldAdd] of classes) {
215215- toggleClass(context.element, className, shouldAdd);
203203+ toggleClass(ctx.element, className, shouldAdd);
216204 }
217205218218- previousClasses = classes;
206206+ prevClasses = classes;
219207 };
220208221209 update();
222210223223- const signal = findSignalInScope(context.scope, expression);
224224- if (signal) {
225225- const unsubscribe = signal.subscribe(update);
226226- context.cleanups.push(unsubscribe);
211211+ const deps = extractDeps(expr, ctx.scope);
212212+ for (const dep of deps) {
213213+ const unsubscribe = dep.subscribe(update);
214214+ ctx.cleanups.push(unsubscribe);
227215 }
228216}
229217230218/**
231219 * Bind data-volt-on-* to attach event listeners.
232220 * Provides $el and $event in the scope for the event handler.
233233- *
234234- * @param context - Binding context
235235- * @param eventName - Event name (e.g., "click", "input")
236236- * @param expression - Expression to evaluate when event fires
237221 */
238238-function bindEvent(context: BindingContext, eventName: string, expression: string): void {
222222+function bindEvent(ctx: BindingContext, eventName: string, expr: string): void {
239223 const handler = (event: Event) => {
240240- const eventScope: Scope = { ...context.scope, $el: context.element, $event: event };
224224+ const eventScope: Scope = { ...ctx.scope, $el: ctx.element, $event: event };
241225242226 try {
243243- const result = evaluate(expression, eventScope);
227227+ const result = evaluate(expr, eventScope);
244228 if (typeof result === "function") {
245229 result(event);
246230 }
···249233 }
250234 };
251235252252- context.element.addEventListener(eventName, handler);
236236+ ctx.element.addEventListener(eventName, handler);
253237254254- context.cleanups.push(() => {
255255- context.element.removeEventListener(eventName, handler);
238238+ ctx.cleanups.push(() => {
239239+ ctx.element.removeEventListener(eventName, handler);
256240 });
257241}
258242259243/**
260244 * Bind data-volt-model for two-way data binding on form elements.
261245 * Syncs the signal value with the input value bidirectionally.
262262- *
263263- * @param context - Binding context
264264- * @param signalPath - Path to the signal in scope
265246 */
266247function bindModel(context: BindingContext, signalPath: string): void {
267267- const signal = findSignalInScope(context.scope, signalPath);
248248+ const signal = findScopedSignal(context.scope, signalPath);
268249 if (!signal) {
269250 console.error(`Signal "${signalPath}" not found for data-volt-model`);
270251 return;
···294275 });
295276}
296277297297-/**
298298- * Set element value based on type
299299- */
300278function setElementValue(
301301- element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
279279+ el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
302280 value: unknown,
303281 type: string | null,
304282): void {
305305- if (element instanceof HTMLInputElement) {
283283+ if (el instanceof HTMLInputElement) {
306284 switch (type) {
307285 case "checkbox": {
308308- element.checked = Boolean(value);
286286+ el.checked = Boolean(value);
309287310288 break;
311289 }
312290 case "radio": {
313313- element.checked = element.value === String(value);
291291+ el.checked = el.value === String(value);
314292 break;
315293 }
316294 case "number": {
317317- element.value = String(value ?? "");
295295+ el.value = String(value ?? "");
318296 break;
319297 }
320298 default: {
321321- element.value = String(value ?? "");
299299+ el.value = String(value ?? "");
322300 }
323301 }
324324- } else if (element instanceof HTMLSelectElement) {
325325- element.value = String(value ?? "");
326326- } else if (element instanceof HTMLTextAreaElement) {
327327- element.value = String(value ?? "");
302302+ } else if (el instanceof HTMLSelectElement) {
303303+ el.value = String(value ?? "");
304304+ } else if (el instanceof HTMLTextAreaElement) {
305305+ el.value = String(value ?? "");
328306 }
329307}
330308331331-/**
332332- * Get element value based on type
333333- */
334334-function getElementValue(
335335- element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
336336- type: string | null,
337337-): unknown {
338338- if (element instanceof HTMLInputElement) {
309309+function getElementValue(el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, type: string | null): unknown {
310310+ if (el instanceof HTMLInputElement) {
339311 if (type === "checkbox") {
340340- return element.checked;
312312+ return el.checked;
341313 }
342314 if (type === "number") {
343343- return element.valueAsNumber;
315315+ return el.valueAsNumber;
344316 }
345345- return element.value;
317317+ return el.value;
346318 }
347319348348- if (element instanceof HTMLSelectElement) {
349349- return element.value;
320320+ if (el instanceof HTMLSelectElement) {
321321+ return el.value;
350322 }
351323352352- if (element instanceof HTMLTextAreaElement) {
353353- return element.value;
324324+ if (el instanceof HTMLTextAreaElement) {
325325+ return el.value;
354326 }
355327356328 return "";
···358330359331/**
360332 * Bind data-volt-bind:attr for generic attribute binding.
361361- *
362333 * Updates any HTML attribute reactively based on expression value.
363334 */
364364-function bindAttr(context: BindingContext, attrName: string, expression: string): void {
335335+function bindAttr(ctx: BindingContext, attrName: string, expr: string): void {
365336 const update = () => {
366366- const value = evaluate(expression, context.scope);
337337+ const value = evaluate(expr, ctx.scope);
367338368339 const booleanAttrs = new Set([
369340 "disabled",
···381352382353 if (booleanAttrs.has(attrName)) {
383354 if (value) {
384384- context.element.setAttribute(attrName, "");
355355+ ctx.element.setAttribute(attrName, "");
385356 } else {
386386- context.element.removeAttribute(attrName);
357357+ ctx.element.removeAttribute(attrName);
387358 }
388359 } else {
389360 if (value === null || value === undefined || value === false) {
390390- context.element.removeAttribute(attrName);
361361+ ctx.element.removeAttribute(attrName);
391362 } else {
392392- context.element.setAttribute(attrName, String(value));
363363+ ctx.element.setAttribute(attrName, String(value));
393364 }
394365 }
395366 };
396367397368 update();
398369399399- const dependencies = extractDependencies(expression, context.scope);
400400- for (const dependency of dependencies) {
401401- const unsubscribe = dependency.subscribe(update);
402402- context.cleanups.push(unsubscribe);
403403- }
404404-}
405405-406406-/**
407407- * Find a signal in the scope by resolving a simple property path.
408408- */
409409-function findSignalInScope(scope: Scope, path: string): Optional<Signal<unknown>> {
410410- const trimmed = path.trim();
411411- const parts = trimmed.split(".");
412412- let current: unknown = scope;
413413-414414- for (const part of parts) {
415415- if (current === null || current === undefined) {
416416- return undefined;
417417- }
418418-419419- if (typeof current === "object" && part in (current as Record<string, unknown>)) {
420420- current = (current as Record<string, unknown>)[part];
421421- } else {
422422- return undefined;
423423- }
424424- }
425425-426426- if (isSignal(current)) {
427427- return current as Signal<unknown>;
370370+ const deps = extractDeps(expr, ctx.scope);
371371+ for (const dep of deps) {
372372+ const unsubscribe = dep.subscribe(update);
373373+ ctx.cleanups.push(unsubscribe);
428374 }
429429-430430- return undefined;
431375}
432376433377/**
434378 * Bind data-volt-for to render a list of items.
435379 * Subscribes to array signal and re-renders when array changes.
436436- *
437437- * @param context - Binding context
438438- * @param expression - Expression like "item in items" or "(item, index) in items"
439380 */
440440-function bindFor(context: BindingContext, expression: string): void {
441441- const parsed = parseForExpression(expression);
381381+function bindFor(ctx: BindingContext, expr: string): void {
382382+ const parsed = parseForExpr(expr);
442383 if (!parsed) {
443443- console.error(`Invalid data-volt-for expression: "${expression}"`);
384384+ console.error(`Invalid data-volt-for expression: "${expr}"`);
444385 return;
445386 }
446387447388 const { itemName, indexName, arrayPath } = parsed;
448448- const template = context.element as HTMLElement;
389389+ const template = ctx.element as HTMLElement;
449390 const parent = template.parentElement;
450391451392 if (!parent) {
···453394 return;
454395 }
455396456456- const placeholder = document.createComment(`for: ${expression}`);
397397+ const placeholder = document.createComment(`for: ${expr}`);
457398 template.before(placeholder);
458399 template.remove();
459400···471412 }
472413 renderedElements.length = 0;
473414474474- const arrayValue = evaluate(arrayPath, context.scope);
415415+ const arrayValue = evaluate(arrayPath, ctx.scope);
475416 if (!Array.isArray(arrayValue)) {
476417 return;
477418 }
···480421 const clone = template.cloneNode(true) as Element;
481422 delete (clone as HTMLElement).dataset.voltFor;
482423483483- const itemScope: Scope = { ...context.scope, [itemName]: item };
424424+ const itemScope: Scope = { ...ctx.scope, [itemName]: item };
484425 if (indexName) {
485426 itemScope[indexName] = index;
486427 }
···495436496437 render();
497438498498- const signal = findSignalInScope(context.scope, arrayPath);
499499- if (signal) {
500500- const unsubscribe = signal.subscribe(render);
501501- context.cleanups.push(unsubscribe);
439439+ const deps = extractDeps(arrayPath, ctx.scope);
440440+ for (const dep of deps) {
441441+ const unsubscribe = dep.subscribe(render);
442442+ ctx.cleanups.push(unsubscribe);
502443 }
503444504504- context.cleanups.push(() => {
445445+ ctx.cleanups.push(() => {
505446 for (const cleanup of renderedCleanups) {
506447 cleanup();
507448 }
···509450}
510451511452/**
512512- * Bind data-volt-if to conditionally render an element.
513513- * Supports data-volt-else on the next sibling element.
453453+ * Bind data-volt-if to conditionally render an element. Supports data-volt-else on the next sibling element.
514454 * Subscribes to condition signal and shows/hides elements when condition changes.
515515- *
516516- * @param ctx - Binding context
517517- * @param expr - Expression to evaluate as condition
518455 */
519456function bindIf(ctx: BindingContext, expr: string): void {
520457 const ifTemplate = ctx.element as HTMLElement;
···583520584521 render();
585522586586- const signal = findSignalInScope(ctx.scope, expr);
587587- if (signal) {
588588- const unsubscribe = signal.subscribe(render);
523523+ const deps = extractDeps(expr, ctx.scope);
524524+ for (const dep of deps) {
525525+ const unsubscribe = dep.subscribe(render);
589526 ctx.cleanups.push(unsubscribe);
590527 }
591528···598535599536/**
600537 * Parse a data-volt-for expression
601601- *
602538 * Supports: "item in items" or "(item, index) in items"
603539 */
604604-function parseForExpression(expr: string): Optional<{ itemName: string; indexName?: string; arrayPath: string }> {
540540+function parseForExpr(expr: string): Optional<{ itemName: string; indexName?: string; arrayPath: string }> {
605541 const trimmed = expr.trim();
606542607543 const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed);
···619555620556/**
621557 * Create a plugin context from a binding context.
622622- *
623558 * Provides the plugin with access to utilities and cleanup registration.
624559 */
625625-function createPluginContext(bindingContext: BindingContext): PluginContext {
560560+function createPluginCtx(ctx: BindingContext): PluginContext {
626561 const mountCallbacks: Array<() => void> = [];
627562 const unmountCallbacks: Array<() => void> = [];
628563 const beforeBindingCallbacks: Array<() => void> = [];
629564 const afterBindingCallbacks: Array<() => void> = [];
630565631566 const lifecycle = {
632632- onMount: (callback: () => void) => {
633633- mountCallbacks.push(callback);
567567+ onMount: (cb: () => void) => {
568568+ mountCallbacks.push(cb);
634569 try {
635635- callback();
570570+ cb();
636571 } catch (error) {
637572 console.error("Error in plugin onMount hook:", error);
638573 }
···648583 console.error("Error in plugin beforeBinding hook:", error);
649584 }
650585 },
651651- afterBinding: (callback: () => void) => {
652652- afterBindingCallbacks.push(callback);
586586+ afterBinding: (cb: () => void) => {
587587+ afterBindingCallbacks.push(cb);
653588 queueMicrotask(() => {
654589 try {
655655- callback();
590590+ cb();
656591 } catch (error) {
657592 console.error("Error in plugin afterBinding hook:", error);
658593 }
···660595 },
661596 };
662597663663- bindingContext.cleanups.push(() => {
598598+ ctx.cleanups.push(() => {
664599 for (const cb of unmountCallbacks) {
665600 try {
666601 cb();
···671606 });
672607673608 return {
674674- element: bindingContext.element,
675675- scope: bindingContext.scope,
609609+ element: ctx.element,
610610+ scope: ctx.scope,
676611 addCleanup: (fn) => {
677677- bindingContext.cleanups.push(fn);
612612+ ctx.cleanups.push(fn);
678613 },
679679- findSignal: (path) => findSignalInScope(bindingContext.scope, path),
680680- evaluate: (expr) => evaluate(expr, bindingContext.scope),
614614+ findSignal: (path) => findScopedSignal(ctx.scope, path),
615615+ evaluate: (expr) => evaluate(expr, ctx.scope),
681616 lifecycle,
682617 };
683618}
+8-33
lib/src/core/charge.ts
···6677import type { ChargedRoot, ChargeResult, Scope } from "$types/volt";
88import { mount } from "./binder";
99-import { evaluate, extractDependencies } from "./evaluator";
99+import { evaluate, extractDeps } from "./evaluator";
1010+import { getComputedAttributes } from "./shared";
1011import { computed, signal } from "./signal";
11121213/**
···60616162/**
6263 * Create a reactive scope from element's data-volt-state and data-volt-computed attributes
6363- *
6464- * @param element - The root element
6565- * @returns Reactive scope object with signals
6664 */
6767-function createScopeFromElement(element: Element): Scope {
6565+function createScopeFromElement(el: Element): Scope {
6866 const scope: Scope = {};
69677070- const stateAttr = (element as HTMLElement).dataset.voltState;
6868+ const stateAttr = (el as HTMLElement).dataset.voltState;
7169 if (stateAttr) {
7270 try {
7371 const stateData = JSON.parse(stateAttr);
74727573 if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) {
7676- console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, element);
7474+ console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el);
7775 } else {
7876 for (const [key, value] of Object.entries(stateData)) {
7977 scope[key] = signal(value);
···8179 }
8280 } catch (error) {
8381 console.error("Failed to parse data-volt-state JSON:", stateAttr, error);
8484- console.error("Element:", element);
8282+ console.error("Element:", el);
8583 }
8684 }
87858888- const computedAttrs = getComputedAttributes(element);
8686+ const computedAttrs = getComputedAttributes(el);
8987 for (const [name, expression] of computedAttrs) {
9088 try {
9191- const dependencies = extractDependencies(expression, scope);
8989+ const dependencies = extractDeps(expression, scope);
92909391 scope[name] = computed(() => evaluate(expression, scope), dependencies);
9492 } catch (error) {
···98969997 return scope;
10098}
101101-102102-/**
103103- * Get all data-volt-computed:name attributes from an element.
104104- *
105105- * Converts kebab-case names to camelCase to match JS conventions.
106106- */
107107-function getComputedAttributes(element: Element): Map<string, string> {
108108- const computed = new Map<string, string>();
109109-110110- for (const attr of element.attributes) {
111111- if (attr.name.startsWith("data-volt-computed:")) {
112112- const name = attr.name.slice("data-volt-computed:".length);
113113- const camelName = kebabToCamel(name);
114114- computed.set(camelName, attr.value);
115115- }
116116- }
117117-118118- return computed;
119119-}
120120-121121-function kebabToCamel(str: string): string {
122122- return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
123123-}
+17-17
lib/src/core/dom.ts
···1414 const elements: Element[] = [];
15151616 function walk(element: Element): void {
1717- if (hasVoltAttribute(element)) {
1717+ if (hasVoltAttr(element)) {
1818 elements.push(element);
19192020 if (
···3838/**
3939 * Check if an element has any data-volt-* attributes.
4040 *
4141- * @param element - Element to check
4141+ * @param el - Element to check
4242 * @returns true if element has any Volt attributes
4343 */
4444-export function hasVoltAttribute(element: Element): boolean {
4545- return [...element.attributes].some((attribute) => attribute.name.startsWith("data-volt-"));
4444+export function hasVoltAttr(el: Element): boolean {
4545+ return [...el.attributes].some((attribute) => attribute.name.startsWith("data-volt-"));
4646}
47474848/**
4949 * Get all data-volt-* attributes from an element.
5050 * Excludes charge metadata attributes (state, computed:*) that are processed separately.
5151 *
5252- * @param element - Element to get attributes from
5252+ * @param el - Element to get attributes from
5353 * @returns Map of attribute names to values (without the data-volt- prefix)
5454 */
5555-export function getVoltAttributes(element: Element): Map<string, string> {
5555+export function getVoltAttrs(el: Element): Map<string, string> {
5656 const attributes = new Map<string, string>();
57575858- for (const attribute of element.attributes) {
5858+ for (const attribute of el.attributes) {
5959 if (attribute.name.startsWith("data-volt-")) {
6060 const name = attribute.name.slice(10);
6161···7474/**
7575 * Set the text content of an element safely.
7676 *
7777- * @param element - Element to update
7777+ * @param el - Element to update
7878 * @param value - Text value to set
7979 */
8080-export function setText(element: Element, value: unknown): void {
8181- element.textContent = String(value ?? "");
8080+export function setText(el: Element, value: unknown): void {
8181+ el.textContent = String(value ?? "");
8282}
83838484/**
8585 * Set the HTML content of an element safely.
8686 * Note: This trusts the input HTML and should only be used with sanitized content.
8787 *
8888- * @param element - Element to update
8888+ * @param el - Element to update
8989 * @param value - HTML string to set
9090 */
9191-export function setHTML(element: Element, value: string): void {
9292- element.innerHTML = value;
9191+export function setHTML(el: Element, value: string): void {
9292+ el.innerHTML = value;
9393}
94949595/**
9696 * Add or remove a CSS class from an element.
9797 *
9898- * @param element - Element to update
9999- * @param className - Class name to toggle
9898+ * @param el - Element to update
9999+ * @param cls - Class name to toggle
100100 * @param add - Whether to add (true) or remove (false) the class
101101 */
102102-export function toggleClass(element: Element, className: string, add: boolean): void {
103103- element.classList.toggle(className, add);
102102+export function toggleClass(el: Element, cls: string, add: boolean): void {
103103+ el.classList.toggle(cls, add);
104104}
105105106106/**
+30-33
lib/src/core/evaluator.ts
···66 */
7788import type { Dep, Scope } from "$types/volt";
99+import { findScopedSignal, isSignal } from "./shared";
9101010-/**
1111- * Blocked properties to prevent prototype pollution and sandbox escape
1212- */
1311const DANGEROUS_PROPERTIES = new Set(["__proto__", "prototype", "constructor"]);
14121513const SAFE_GLOBALS = new Set([
···4038 "exports",
4139]);
42404343-/**
4444- * Validates that a property name is safe to access
4545- */
4641function isSafeProp(key: unknown): boolean {
4742 if (typeof key !== "string" && typeof key !== "number") {
4843 return true;
···5247 return !DANGEROUS_PROPERTIES.has(keyStr);
5348}
54495555-/**
5656- * Validates that accessing a property on an object is safe
5757- */
5850function isSafeAccess(object: unknown, key: unknown): boolean {
5951 if (!isSafeProp(key)) {
6052 return false;
···503495 this.advance();
504496 const args = this.parseArgumentList();
505497 this.consume("RPAREN", "Expected ')' after arguments");
506506- object = this.callMethod(object, prop.value as string, args);
498498+ const propName = prop.value as string;
499499+ const isSignalMethod = isSignal(object)
500500+ && (propName === "get" || propName === "set" || propName === "subscribe");
501501+ const unwrappedObject = !isSignalMethod && isSignal(object) ? object.get() : object;
502502+ object = this.callMethod(unwrappedObject, propName, args);
507503 } else {
508504 object = propValue;
509505 }
···877873 }
878874}
879875880880-export function isSignal(value: unknown): value is Dep {
881881- return (typeof value === "object"
882882- && value !== null
883883- && "get" in value
884884- && "subscribe" in value
885885- && typeof value.get === "function"
886886- && typeof (value as { subscribe: unknown }).subscribe === "function");
887887-}
888888-889876/**
890877 * Evaluate an expression against a scope object.
891878 *
···907894}
908895909896/**
910910- * Extract all signal dependencies from an expression by finding identifiers
911911- * that correspond to signals in the scope.
897897+ * Extract all signal dependencies from an expression by finding identifiers that correspond to signals in the scope.
898898+ *
899899+ * This function handles both simple property paths (e.g., "todo.title") and complex expressions (e.g., "email.length > 0 && emailValid").
912900 *
913901 * @param expr - The expression to analyze
914902 * @param scope - The scope containing potential signal dependencies
915903 * @returns Array of signals found in the expression
916904 */
917917-export function extractDependencies(expr: string, scope: Scope): Array<Dep> {
918918- const dependencies: Array<Dep> = [];
919919- const identifierRegex = /\b([a-zA-Z_$][\w$]*)\b/g;
920920- const matches = expr.matchAll(identifierRegex);
905905+export function extractDeps(expr: string, scope: Scope): Array<Dep> {
906906+ const deps: Array<Dep> = [];
921907 const seen = new Set<string>();
922908909909+ const identifierRegex = /\b([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\b/g;
910910+ const matches = expr.matchAll(identifierRegex);
911911+923912 for (const match of matches) {
924924- const identifier = match[1];
913913+ const path = match[1];
925914926926- if (["true", "false", "null", "undefined"].includes(identifier)) {
915915+ if (["true", "false", "null", "undefined"].includes(path)) {
927916 continue;
928917 }
929918930930- if (seen.has(identifier)) {
919919+ if (seen.has(path)) {
931920 continue;
932921 }
933922934934- seen.add(identifier);
923923+ seen.add(path);
924924+925925+ const signal = findScopedSignal(scope, path);
926926+ if (signal) {
927927+ deps.push(signal);
928928+ continue;
929929+ }
935930936936- const value = scope[identifier];
937937- if (isSignal(value)) {
938938- dependencies.push(value);
931931+ const parts = path.split(".");
932932+ const topLevel = parts[0];
933933+ const value = scope[topLevel];
934934+ if (isSignal(value) && !deps.includes(value)) {
935935+ deps.push(value);
939936 }
940937 }
941938942942- return dependencies;
939939+ return deps;
943940}
+55-123
lib/src/core/http.ts
···1717 SwapStrategy,
1818} from "$types/volt";
1919import { evaluate } from "./evaluator";
2020+import { sleep } from "./shared";
20212122/**
2223 * Make an HTTP request and return the parsed response
···6162 }
6263}
63646464-/**
6565- * Capture state that should be preserved during DOM swap
6666- *
6767- * @param root - Root element to capture state from
6868- * @returns Captured state object
6969- */
7065type CapturedState = {
7166 focusPath: number[] | null;
7267 scrollPositions: Map<number[], { top: number; left: number }>;
7368 inputValues: Map<number[], string | boolean>;
7469};
75707171+/**
7272+ * Capture state that should be preserved during DOM swap
7373+ */
7674function captureState(root: Element): CapturedState {
7775 const state: CapturedState = { focusPath: null, scrollPositions: new Map(), inputValues: new Map() };
7876···109107}
110108111109/**
112112- * Get the path to an element from a root element
113113- *
114114- * Returns an array of child indices representing the path from root to element.
115115- *
116116- * @param element - Target element
117117- * @param root - Root element
118118- * @returns Array of indices, or empty array if element not found
110110+ * Get the path to an element from a root element as an array of child indices representing the path from root to element.
119111 */
120120-function getElementPath(element: Element, root: Element): number[] {
112112+function getElementPath(el: Element, root: Element): number[] {
121113 const path: number[] = [];
122122- let current: Element | null = element;
114114+ let current: Element | null = el;
123115124116 while (current && current !== root) {
125117 const parent: Element | null = current.parentElement;
···137129138130/**
139131 * Get element by path from root
140140- *
141141- * @param path - Array of child indices
142142- * @param root - Root element
143143- * @returns Element at path, or null if not found
144132 */
145133function getElementByPath(path: number[], root: Element): Element | null {
146134 let current: Element = root;
···156144157145/**
158146 * Restore preserved state after DOM swap
159159- *
160160- * @param root - Root element to restore state to
161161- * @param state - Previously captured state
162147 */
163148function restoreState(root: Element, state: CapturedState): void {
164149 if (state.focusPath) {
···345330346331/**
347332 * Get the default trigger event for an element
348348- *
349349- * - Forms: submit
350350- * - Buttons/links: click
351351- * - Everything else: click
352352- *
353353- * @param element - Element to get default trigger for
354354- * @returns Default event name
355333 */
356356-function getDefaultTrigger(element: Element): string {
357357- if (element instanceof HTMLFormElement) {
334334+function getDefaultTrigger(el: Element): string {
335335+ if (el instanceof HTMLFormElement) {
358336 return "submit";
359337 }
360338 return "click";
···366344 * Sets data-volt-loading="true" attribute to indicate ongoing request.
367345 * Shows indicator if data-volt-indicator is set.
368346 *
369369- * @param element - Element to mark as loading
347347+ * @param el - Element to mark as loading
370348 * @param indicator - Optional indicator selector
371349 */
372372-export function setLoadingState(element: Element, indicator?: string): void {
373373- element.setAttribute("data-volt-loading", "true");
350350+export function setLoadingState(el: Element, indicator?: string): void {
351351+ el.setAttribute("data-volt-loading", "true");
374352375353 if (indicator) {
376354 showIndicator(indicator);
377355 }
378356379379- element.dispatchEvent(new CustomEvent("volt:loading", { detail: { element }, bubbles: true, cancelable: false }));
357357+ el.dispatchEvent(new CustomEvent("volt:loading", { detail: { element: el }, bubbles: true, cancelable: false }));
380358}
381359382360/**
···385363 * Sets data-volt-error attribute with error message.
386364 * Hides indicator if data-volt-indicator is set.
387365 *
388388- * @param element - Element to mark as errored
389389- * @param message - Error message
366366+ * @param el - Element to mark as errored
367367+ * @param msg - Error message
390368 * @param indicator - Optional indicator selector
391369 */
392392-export function setErrorState(element: Element, message: string, indicator?: string): void {
393393- element.setAttribute("data-volt-error", message);
370370+export function setErrorState(el: Element, msg: string, indicator?: string): void {
371371+ el.setAttribute("data-volt-error", msg);
394372395373 if (indicator) {
396374 hideIndicator(indicator);
397375 }
398376399399- element.dispatchEvent(
400400- new CustomEvent("volt:error", { detail: { element, message }, bubbles: true, cancelable: false }),
377377+ el.dispatchEvent(
378378+ new CustomEvent("volt:error", { detail: { element: el, message: msg }, bubbles: true, cancelable: false }),
401379 );
402380}
403381···407385 * Removes data-volt-loading, data-volt-error, and data-volt-retry-attempt attributes.
408386 * Hides indicator if data-volt-indicator is set.
409387 *
410410- * @param element - Element to clear states from
388388+ * @param el - Element to clear states from
411389 * @param indicator - Optional indicator selector
412390 */
413413-export function clearStates(element: Element, indicator?: string): void {
414414- element.removeAttribute("data-volt-loading");
415415- element.removeAttribute("data-volt-error");
416416- element.removeAttribute("data-volt-retry-attempt");
391391+export function clearStates(el: Element, indicator?: string): void {
392392+ el.removeAttribute("data-volt-loading");
393393+ el.removeAttribute("data-volt-error");
394394+ el.removeAttribute("data-volt-retry-attempt");
417395418396 if (indicator) {
419397 hideIndicator(indicator);
420398 }
421399422422- element.dispatchEvent(new CustomEvent("volt:success", { detail: { element }, bubbles: true, cancelable: false }));
400400+ el.dispatchEvent(new CustomEvent("volt:success", { detail: { element: el }, bubbles: true, cancelable: false }));
423401}
424402425425-/**
426426- * Visibility strategy for showing/hiding indicator elements
427427- */
428403type IndicatorStrategy = "display" | "class";
429404430430-/**
431431- * Cache for storing indicator visibility strategies
432432- */
433405const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>();
434406435407/**
···438410 * - If element has display: none (inline or computed), use display toggling
439411 * - If element has a class containing "hidden", use class toggling
440412 * - Otherwise, default to class toggling
441441- *
442442- * @param element - Indicator element
443443- * @returns Visibility strategy to use
444413 */
445445-function detectIndicatorStrategy(element: Element): IndicatorStrategy {
446446- if (indicatorStrategies.has(element)) {
447447- return indicatorStrategies.get(element)!;
414414+function detectIndicatorStrategy(el: Element): IndicatorStrategy {
415415+ if (indicatorStrategies.has(el)) {
416416+ return indicatorStrategies.get(el)!;
448417 }
449418450450- const htmlElement = element as HTMLElement;
419419+ const htmlElement = el as HTMLElement;
451420 const inlineDisplay = htmlElement.style.display;
452421 const computedDisplay = window.getComputedStyle(htmlElement).display;
453422454423 if (inlineDisplay === "none" || computedDisplay === "none") {
455455- indicatorStrategies.set(element, "display");
424424+ indicatorStrategies.set(el, "display");
456425 return "display";
457426 }
458427459459- const hasHiddenClass = Array.from(element.classList).some((cls) => cls.toLowerCase().includes("hidden"));
428428+ const hasHiddenClass = Array.from(el.classList).some((cls) => cls.toLowerCase().includes("hidden"));
460429 if (hasHiddenClass) {
461461- indicatorStrategies.set(element, "class");
430430+ indicatorStrategies.set(el, "class");
462431 return "class";
463432 }
464433465465- indicatorStrategies.set(element, "class");
434434+ indicatorStrategies.set(el, "class");
466435 return "class";
467436}
468437469438/**
470439 * Show an indicator element using the appropriate visibility strategy
471471- *
472472- * @param element - Indicator element to show
473440 */
474474-function showIndicatorElement(element: Element): void {
475475- const strategy = detectIndicatorStrategy(element);
476476- const htmlElement = element as HTMLElement;
441441+function showIndicatorElement(el: Element): void {
442442+ const strategy = detectIndicatorStrategy(el);
443443+ const htmlElement = el as HTMLElement;
477444478445 if (strategy === "display") {
479446 htmlElement.style.display = "";
480447 } else {
481481- const hiddenClass = Array.from(element.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
482482- element.classList.remove(hiddenClass);
448448+ const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
449449+ el.classList.remove(hiddenClass);
483450 }
484451}
485452486453/**
487454 * Hide an indicator element using the appropriate visibility strategy
488488- *
489489- * @param element - Indicator element to hide
490455 */
491491-function hideIndicatorElement(element: Element): void {
492492- const strategy = detectIndicatorStrategy(element);
493493- const htmlElement = element as HTMLElement;
456456+function hideIndicatorElement(el: Element): void {
457457+ const strategy = detectIndicatorStrategy(el);
458458+ const htmlElement = el as HTMLElement;
494459495460 if (strategy === "display") {
496461 htmlElement.style.display = "none";
497462 } else {
498498- const hiddenClass = Array.from(element.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
499499- element.classList.add(hiddenClass);
463463+ const hiddenClass = Array.from(el.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
464464+ el.classList.add(hiddenClass);
500465 }
501466}
502467···549514 return target;
550515}
551516552552-/**
553553- * Error type classification for retry logic
554554- */
555555-type ErrorType = "network" | "server" | "client" | "other";
556556-557557-/**
558558- * Classify an error for retry decision
559559- *
560560- * @param error - Error or HTTP status code
561561- * @returns Error type classification
562562- */
563563-function classifyError(error: unknown): ErrorType {
517517+function classifyError(error: unknown): "network" | "server" | "client" | "other" {
564518 if (error instanceof Error && error.message.includes("HTTP")) {
565519 const match = error.message.match(/HTTP (\d+):/);
566520 if (match) {
···584538 * - 5xx server errors: Always retry
585539 * - 4xx client errors: Never retry
586540 * - Other errors: Never retry
587587- *
588588- * @param error - Error to check
589589- * @returns True if error should be retried
590541 */
591542function shouldRetry(error: unknown): boolean {
592543 const errorType = classifyError(error);
···599550 * - Network errors: No delay (immediate retry)
600551 * - Server errors: Exponential backoff (initialDelay × 2^attempt)
601552 * - Other errors: No retry
602602- *
603603- * @param error - Error that occurred
604604- * @param attempt - Current attempt number (0-indexed)
605605- * @param initialDelay - Initial delay in milliseconds
606606- * @returns Delay in milliseconds before next retry
607553 */
608554function calculateRetryDelay(error: unknown, attempt: number, initialDelay: number): number {
609555 const errorType = classifyError(error);
···617563 }
618564619565 return 0;
620620-}
621621-622622-/**
623623- * Sleep for a specified duration
624624- *
625625- * @param ms - Milliseconds to sleep
626626- */
627627-function sleep(ms: number): Promise<void> {
628628- return new Promise((resolve) => setTimeout(resolve, ms));
629566}
630567631568/**
···704641 console.error("HTTP request failed:", lastError);
705642}
706643707707-export function bindGet(context: BindingContext, url: string): void {
708708- bindHttpMethod(context, "GET", url);
644644+export function bindGet(ctx: BindingContext, url: string): void {
645645+ bindHttpMethod(ctx, "GET", url);
709646}
710647711711-export function bindPost(context: BindingContext, url: string): void {
712712- bindHttpMethod(context, "POST", url);
648648+export function bindPost(ctx: BindingContext, url: string): void {
649649+ bindHttpMethod(ctx, "POST", url);
713650}
714651715715-export function bindPut(context: BindingContext, url: string): void {
716716- bindHttpMethod(context, "PUT", url);
652652+export function bindPut(ctx: BindingContext, url: string): void {
653653+ bindHttpMethod(ctx, "PUT", url);
717654}
718655719719-export function bindPatch(context: BindingContext, url: string): void {
720720- bindHttpMethod(context, "PATCH", url);
656656+export function bindPatch(ctx: BindingContext, url: string): void {
657657+ bindHttpMethod(ctx, "PATCH", url);
721658}
722659723723-export function bindDelete(context: BindingContext, url: string): void {
724724- bindHttpMethod(context, "DELETE", url);
660660+export function bindDelete(ctx: BindingContext, url: string): void {
661661+ bindHttpMethod(ctx, "DELETE", url);
725662}
726663727664/**
728665 * Generic HTTP method binding handler
729666 *
730730- * Attaches an event listener that triggers an HTTP request when fired.
731731- * Automatically serializes forms for POST/PUT/PATCH methods.
732732- *
733733- * @param ctx - Plugin context
734734- * @param method - HTTP method
735735- * @param url - URL expression to evaluate
667667+ * Attaches an event listener that triggers an HTTP request when fired & automatically serializes forms for POST/PUT/PATCH methods.
736668 */
737669function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, url: string): void {
738670 const config = parseHttpConfig(ctx.element, ctx.scope);
+9-19
lib/src/core/lifecycle.ts
···33 * Provides beforeMount, afterMount, beforeUnmount, and afterUnmount hooks
44 */
5566-import type { GlobalHookName, MountHookCallback, Scope, UnmountHookCallback } from "$types/volt";
66+import type { ElementLifecycleState, GlobalHookName, MountHookCallback, Scope, UnmountHookCallback } from "$types/volt";
7788/**
99 * Global lifecycle hooks registry
···106106 * @param root - The root element being mounted/unmounted
107107 * @param scope - The scope object (only for mount hooks)
108108 */
109109-export function executeGlobalHooks(hookName: GlobalHookName, root: Element, scope?: Scope): void {
109109+export function execGlobalHooks(hookName: GlobalHookName, root: Element, scope?: Scope): void {
110110 const hooks = lifecycleHooks.get(hookName);
111111 if (!hooks || hooks.size === 0) {
112112 return;
···126126 }
127127 }
128128}
129129-130130-/**
131131- * Element-level lifecycle tracking for per-element hooks
132132- */
133133-type ElementLifecycleState = {
134134- isMounted: boolean;
135135- bindings: Set<string>;
136136- onMount: Set<() => void>;
137137- onUnmount: Set<() => void>;
138138-};
139129140130const elementLifecycleStates = new WeakMap<Element, ElementLifecycleState>();
141131···176166 * Notify that an element has been mounted.
177167 * Executes all registered onMount callbacks for the element.
178168 *
179179- * @param element - The mounted element
169169+ * @param el - The mounted element
180170 */
181181-export function notifyElementMounted(element: Element): void {
182182- const state = getElementLifecycleState(element);
171171+export function notifyElementMounted(el: Element): void {
172172+ const state = getElementLifecycleState(el);
183173184174 if (state.isMounted) {
185175 return;
···200190 * Notify that an element is being unmounted.
201191 * Executes all registered onUnmount callbacks for the element.
202192 *
203203- * @param element - The element being unmounted
193193+ * @param el - The element being unmounted
204194 */
205205-export function notifyElementUnmounted(element: Element): void {
206206- const state = getElementLifecycleState(element);
195195+export function notifyElementUnmounted(el: Element): void {
196196+ const state = getElementLifecycleState(el);
207197208198 if (!state.isMounted) {
209199 return;
···219209 }
220210 }
221211222222- elementLifecycleStates.delete(element);
212212+ elementLifecycleStates.delete(el);
223213}
224214225215/**
+64
lib/src/core/shared.ts
···11+import type { Optional } from "$types/helpers";
22+import type { Dep, Scope, Signal } from "$types/volt";
33+44+export function kebabToCamel(str: string): string {
55+ return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
66+}
77+88+export function isSignal(value: unknown): value is Dep {
99+ return (typeof value === "object"
1010+ && value !== null
1111+ && "get" in value
1212+ && "subscribe" in value
1313+ && typeof value.get === "function"
1414+ && typeof (value as { subscribe: unknown }).subscribe === "function");
1515+}
1616+1717+export function findScopedSignal(scope: Scope, path: string): Optional<Signal<unknown>> {
1818+ const trimmed = path.trim();
1919+ const parts = trimmed.split(".");
2020+ let current: unknown = scope;
2121+2222+ for (const part of parts) {
2323+ if (current === null || current === undefined) {
2424+ return undefined;
2525+ }
2626+2727+ if (typeof current === "object" && part in (current as Record<string, unknown>)) {
2828+ current = (current as Record<string, unknown>)[part];
2929+ } else {
3030+ return undefined;
3131+ }
3232+ }
3333+3434+ if (isSignal(current)) {
3535+ return current as Signal<unknown>;
3636+ }
3737+3838+ return undefined;
3939+}
4040+4141+/**
4242+ * Get all data-volt-computed:name attributes from an element.
4343+ * Converts kebab-case names to camelCase to match JS conventions.
4444+ */
4545+export function getComputedAttributes(el: Element): Map<string, string> {
4646+ const computed = new Map<string, string>();
4747+4848+ for (const attr of el.attributes) {
4949+ if (attr.name.startsWith("data-volt-computed:")) {
5050+ const name = attr.name.slice("data-volt-computed:".length);
5151+ const camelName = kebabToCamel(name);
5252+ computed.set(camelName, attr.value);
5353+ }
5454+ }
5555+5656+ return computed;
5757+}
5858+5959+/**
6060+ * Sleep for a specified duration
6161+ */
6262+export function sleep(ms: number): Promise<void> {
6363+ return new Promise((resolve) => setTimeout(resolve, ms));
6464+}
+15-15
lib/src/core/signal.ts
···5454 * The computation function is re-run whenever any of its dependencies change.
5555 *
5656 * @param compute - Function that computes the derived value
5757- * @param dependencies - Array of signals this computation depends on
5757+ * @param dps - Array of signals this computation depends on
5858 * @returns A ComputedSignal with get and subscribe methods
5959 *
6060 * @example
···6666 */
6767export function computed<T>(
6868 compute: () => T,
6969- dependencies: Array<Signal<unknown> | ComputedSignal<unknown>>,
6969+ dps: Array<Signal<unknown> | ComputedSignal<unknown>>,
7070): ComputedSignal<T> {
7171 let value = compute();
7272- const subscribers = new Set<(value: T) => void>();
7272+ const subs = new Set<(value: T) => void>();
73737474 const notify = () => {
7575- for (const callback of subscribers) {
7575+ for (const cb of subs) {
7676 try {
7777- callback(value);
7777+ cb(value);
7878 } catch (error) {
7979 console.error("Error in computed subscriber:", error);
8080 }
···8989 }
9090 };
91919292- for (const dependency of dependencies) {
9393- dependency.subscribe(recompute);
9292+ for (const dep of dps) {
9393+ dep.subscribe(recompute);
9494 }
95959696 return {
···9999 },
100100101101 subscribe(callback: (value: T) => void) {
102102- subscribers.add(callback);
102102+ subs.add(callback);
103103104104 return () => {
105105- subscribers.delete(callback);
105105+ subs.delete(callback);
106106 };
107107 },
108108 };
···111111/**
112112 * Creates a side effect that runs when dependencies change.
113113 *
114114- * @param effectFunction - Function to run as a side effect
115115- * @param dependencies - Array of signals this effect depends on
114114+ * @param cb - Function to run as a side effect
115115+ * @param deps - Array of signals this effect depends on
116116 * @returns Cleanup function to stop the effect
117117 *
118118 * @example
···122122 * }, [count]);
123123 */
124124export function effect(
125125- effectFunction: () => void | (() => void),
126126- dependencies: Array<Signal<unknown> | ComputedSignal<unknown>>,
125125+ cb: () => void | (() => void),
126126+ deps: Array<Signal<unknown> | ComputedSignal<unknown>>,
127127): () => void {
128128 let cleanup: (() => void) | void;
129129···132132 cleanup();
133133 }
134134 try {
135135- cleanup = effectFunction();
135135+ cleanup = cb();
136136 } catch (error) {
137137 console.error("Error in effect:", error);
138138 }
···140140141141 runEffect();
142142143143- const unsubscribers = dependencies.map((dependency) => dependency.subscribe(runEffect));
143143+ const unsubscribers = deps.map((dependency) => dependency.subscribe(runEffect));
144144145145 return () => {
146146 if (cleanup) {
+24-55
lib/src/core/ssr.ts
···55 * and hydrating client-side without re-rendering.
66 */
7788+import type { Nullable } from "$types/helpers";
89import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt";
910import { mount } from "./binder";
1010-import { evaluate, extractDependencies } from "./evaluator";
1111+import { evaluate, extractDeps } from "./evaluator";
1212+import { getComputedAttributes, isSignal } from "./shared";
1113import { computed, signal } from "./signal";
12141315/**
···7173/**
7274 * Check if an element has already been hydrated
7375 *
7474- * @param element - Element to check
7676+ * @param el - Element to check
7577 * @returns True if element is marked as hydrated
7678 */
7777-export function isHydrated(element: Element): boolean {
7878- return element.hasAttribute("data-volt-hydrated");
7979+export function isHydrated(el: Element): boolean {
8080+ return el.hasAttribute("data-volt-hydrated");
7981}
80828183/**
8284 * Mark an element as hydrated to prevent double-hydration
8385 *
8484- * @param element - Element to mark
8686+ * @param el - Element to mark
8587 */
8686-function markHydrated(element: Element): void {
8787- element.setAttribute("data-volt-hydrated", "true");
8888+function markHydrated(el: Element): void {
8989+ el.setAttribute("data-volt-hydrated", "true");
8890}
89919092/**
···9294 *
9395 * Looks for a script tag with id="volt-state-{element-id}" containing JSON state.
9496 *
9595- * @param element - Element to check
9797+ * @param el - Element to check
9698 * @returns True if serialized state is found
9799 */
9898-export function isServerRendered(element: Element): boolean {
9999- return getSerializedState(element) !== null;
100100+export function isServerRendered(el: Element): boolean {
101101+ return getSerializedState(el) !== null;
100102}
101103102104/**
···105107 * Searches for a `<script type="application/json" id="volt-state-{id}">` tag
106108 * containing the serialized scope data.
107109 *
108108- * @param element - Root element to extract state from
110110+ * @param el - Root element to extract state from
109111 * @returns Parsed state object, or null if not found
110112 *
111113 * @example
···117119 * </div>
118120 * ```
119121 */
120120-export function getSerializedState(element: Element): SerializedScope | null {
121121- const elementId = element.id;
122122+export function getSerializedState(el: Element): Nullable<SerializedScope> {
123123+ const elementId = el.id;
122124 if (!elementId) {
123125 return null;
124126 }
125127126128 const scriptId = `volt-state-${elementId}`;
127127- const scriptTag = element.querySelector(`script[type="application/json"]#${scriptId}`);
129129+ const scriptTag = el.querySelector(`script[type="application/json"]#${scriptId}`);
128130129131 if (!scriptTag || !scriptTag.textContent) {
130132 return null;
···144146 * This is similar to createScopeFromElement in charge.ts, but checks for
145147 * server-rendered state first before falling back to data-volt-state.
146148 *
147147- * @param element - The root element
149149+ * @param el - The root element
148150 * @returns Reactive scope object with signals
149151 */
150150-function createHydrationScope(element: Element): Scope {
152152+function createHydrationScope(el: Element): Scope {
151153 let scope: Scope = {};
152154153153- const serializedState = getSerializedState(element);
155155+ const serializedState = getSerializedState(el);
154156 if (serializedState) {
155157 scope = deserializeScope(serializedState);
156158 } else {
157157- const stateAttr = (element as HTMLElement).dataset.voltState;
159159+ const stateAttr = (el as HTMLElement).dataset.voltState;
158160 if (stateAttr) {
159161 try {
160162 const stateData = JSON.parse(stateAttr);
161163162164 if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) {
163163- console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, element);
165165+ console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, el);
164166 } else {
165167 for (const [key, value] of Object.entries(stateData)) {
166168 scope[key] = signal(value);
···168170 }
169171 } catch (error) {
170172 console.error("Failed to parse data-volt-state JSON:", stateAttr, error);
171171- console.error("Element:", element);
173173+ console.error("Element:", el);
172174 }
173175 }
174176 }
175177176176- const computedAttrs = getComputedAttributes(element);
178178+ const computedAttrs = getComputedAttributes(el);
177179 for (const [name, expression] of computedAttrs) {
178180 try {
179179- const dependencies = extractDependencies(expression, scope);
181181+ const dependencies = extractDeps(expression, scope);
180182 scope[name] = computed(() => evaluate(expression, scope), dependencies);
181183 } catch (error) {
182184 console.error(`Failed to create computed "${name}" with expression "${expression}":`, error);
···184186 }
185187186188 return scope;
187187-}
188188-189189-/**
190190- * Get all data-volt-computed:name attributes from an element
191191- *
192192- * Converts kebab-case names to camelCase to match JS conventions.
193193- */
194194-function getComputedAttributes(element: Element): Map<string, string> {
195195- const computedMap = new Map<string, string>();
196196-197197- for (const attr of element.attributes) {
198198- if (attr.name.startsWith("data-volt-computed:")) {
199199- const name = attr.name.slice("data-volt-computed:".length);
200200- const camelName = kebabToCamel(name);
201201- computedMap.set(camelName, attr.value);
202202- }
203203- }
204204-205205- return computedMap;
206206-}
207207-208208-function kebabToCamel(str: string): string {
209209- return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase());
210210-}
211211-212212-/**
213213- * Check if a value is a signal-like object
214214- */
215215-function isSignal(value: unknown): value is { get: () => unknown } {
216216- return (typeof value === "object"
217217- && value !== null
218218- && "get" in value
219219- && typeof (value as { get: unknown }).get === "function");
220189}
221190222191/**
+4-7
lib/src/plugins/persist.ts
···109109let dbPromise: Optional<Promise<IDBDatabase>>;
110110111111/**
112112- * Open or create the IndexedDB database
112112+ * Open or create the IndexedDB database ({@link IDBDatabase})
113113 */
114114function openDB(): Promise<IDBDatabase> {
115115 if (dbPromise) return dbPromise;
···136136 return dbPromise;
137137}
138138139139-/**
140140- * Get storage adapter by name
141141- */
142139function getStorageAdapter(type: string): Optional<StorageAdapter> {
143140 switch (type) {
144141 case "local": {
···167164 * - data-volt-persist="userData:indexeddb"
168165 * - data-volt-persist="settings:customAdapter"
169166 */
170170-export function persistPlugin(context: PluginContext, value: string): void {
167167+export function persistPlugin(ctx: PluginContext, value: string): void {
171168 const parts = value.split(":");
172169 if (parts.length !== 2) {
173170 console.error(`Invalid persist binding: "${value}". Expected format: "signalPath:storageType"`);
···175172 }
176173177174 const [signalPath, storageType] = parts;
178178- const signal = context.findSignal(signalPath.trim());
175175+ const signal = ctx.findSignal(signalPath.trim());
179176180177 if (!signal) {
181178 console.error(`Signal "${signalPath}" not found in scope for persist binding`);
···220217 }
221218 });
222219223223- context.addCleanup(unsubscribe);
220220+ ctx.addCleanup(unsubscribe);
224221}
+23-26
lib/src/plugins/scroll.ts
···66import type { PluginContext, Signal } from "$types/volt";
7788/**
99- * Scroll plugin handler.
1010- * Manages various scroll-related behaviors.
99+ * Scroll plugin handler to manage various scroll-related behaviors.
1110 *
1211 * Syntax: data-volt-scroll="mode:signalPath"
1312 * Modes:
···1615 * - spy:signalPath - Update signal when element is visible
1716 * - smooth:signalPath - Enable smooth scrolling behavior
1817 */
1919-export function scrollPlugin(context: PluginContext, value: string): void {
1818+export function scrollPlugin(ctx: PluginContext, value: string): void {
2019 const parts = value.split(":");
2120 if (parts.length !== 2) {
2221 console.error(`Invalid scroll binding: "${value}". Expected format: "mode:signalPath"`);
···27262827 switch (mode) {
2928 case "restore": {
3030- handleScrollRestore(context, signalPath);
2929+ handleScrollRestore(ctx, signalPath);
3130 break;
3231 }
3332 case "scrollTo": {
3434- handleScrollTo(context, signalPath);
3333+ handleScrollTo(ctx, signalPath);
3534 break;
3635 }
3736 case "spy": {
3838- handleScrollSpy(context, signalPath);
3737+ handleScrollSpy(ctx, signalPath);
3938 break;
4039 }
4140 case "smooth": {
4242- handleSmoothScroll(context, signalPath);
4141+ handleSmoothScroll(ctx, signalPath);
4342 break;
4443 }
4544 default: {
···5150/**
5251 * Saves current scroll position to signal on scroll events; Restores scroll position from signal on mount.
5352 */
5454-function handleScrollRestore(context: PluginContext, signalPath: string): void {
5555- const signal = context.findSignal(signalPath);
5353+function handleScrollRestore(ctx: PluginContext, signalPath: string): void {
5454+ const signal = ctx.findSignal(signalPath);
5655 if (!signal) {
5756 console.error(`Signal "${signalPath}" not found for scroll restore`);
5857 return;
5958 }
60596161- const element = context.element as HTMLElement;
6060+ const element = ctx.element as HTMLElement;
6261 const savedPosition = signal.get();
6362 if (typeof savedPosition === "number") {
6463 element.scrollTop = savedPosition;
···70697170 element.addEventListener("scroll", savePosition, { passive: true });
72717373- context.addCleanup(() => {
7272+ ctx.addCleanup(() => {
7473 element.removeEventListener("scroll", savePosition);
7574 });
7675}
77767877/**
7978 * Scroll to element when signal value matches element's ID or selector.
8080- *
8179 * Listens for changes to the target signal to determine position
8280 */
8383-function handleScrollTo(context: PluginContext, signalPath: string): void {
8484- const signal = context.findSignal(signalPath);
8181+function handleScrollTo(ctx: PluginContext, signalPath: string): void {
8282+ const signal = ctx.findSignal(signalPath);
8583 if (!signal) {
8684 console.error(`Signal "${signalPath}" not found for scrollTo`);
8785 return;
8886 }
89879090- const element = context.element as HTMLElement;
8888+ const element = ctx.element as HTMLElement;
9189 const elementId = element.id;
92909391 const checkAndScroll = (target: unknown) => {
···9997 checkAndScroll(signal.get());
1009810199 const unsubscribe = signal.subscribe(checkAndScroll);
102102- context.addCleanup(unsubscribe);
100100+ ctx.addCleanup(unsubscribe);
103101}
104102105103/**
106104 * Update signal when element enters or exits viewport.
107107- *
108108- * Uses Intersection Observer to track visibility.
105105+ * Uses {@link IntersectionObserver} to track visibility.
109106 */
110110-function handleScrollSpy(context: PluginContext, signalPath: string): void {
111111- const signal = context.findSignal(signalPath);
107107+function handleScrollSpy(ctx: PluginContext, signalPath: string): void {
108108+ const signal = ctx.findSignal(signalPath);
112109 if (!signal) {
113110 console.error(`Signal "${signalPath}" not found for scroll spy`);
114111 return;
115112 }
116113117117- const element = context.element as HTMLElement;
114114+ const element = ctx.element as HTMLElement;
118115119116 const observer = new IntersectionObserver((entries) => {
120117 for (const entry of entries) {
···126123127124 observer.observe(element);
128125129129- context.addCleanup(() => {
126126+ ctx.addCleanup(() => {
130127 observer.disconnect();
131128 });
132129}
···134131/**
135132 * Enable smooth scrolling behavior and apply based on signal value.
136133 */
137137-function handleSmoothScroll(context: PluginContext, signalPath: string): void {
138138- const signal = context.findSignal(signalPath);
134134+function handleSmoothScroll(ctx: PluginContext, signalPath: string): void {
135135+ const signal = ctx.findSignal(signalPath);
139136 if (!signal) {
140137 console.error(`Signal "${signalPath}" not found for smooth scroll`);
141138 return;
142139 }
143140144144- const element = context.element as HTMLElement;
141141+ const element = ctx.element as HTMLElement;
145142146143 const applyBehavior = (value: unknown) => {
147144 if (value === true || value === "smooth") {
···155152156153 const unsubscribe = signal.subscribe(applyBehavior);
157154158158- context.addCleanup(() => {
155155+ ctx.addCleanup(() => {
159156 unsubscribe();
160157 element.style.scrollBehavior = "";
161158 });
+12-13
lib/src/plugins/url.ts
···1616 * - sync:signalPath - Bidirectional sync between signal and URL param
1717 * - hash:signalPath - Sync with hash portion for routing
1818 */
1919-export function urlPlugin(context: PluginContext, value: string): void {
1919+export function urlPlugin(ctx: PluginContext, value: string): void {
2020 const parts = value.split(":");
2121 if (parts.length !== 2) {
2222 console.error(`Invalid url binding: "${value}". Expected format: "mode:signalPath"`);
···27272828 switch (mode) {
2929 case "read": {
3030- handleUrlRead(context, signalPath);
3030+ handleUrlRead(ctx, signalPath);
3131 break;
3232 }
3333 case "sync": {
3434- handleUrlSync(context, signalPath);
3434+ handleUrlSync(ctx, signalPath);
3535 break;
3636 }
3737 case "hash": {
3838- handleHashRouting(context, signalPath);
3838+ handleHashRouting(ctx, signalPath);
3939 break;
4040 }
4141 default: {
···4848 * Read URL parameter into signal on mount (one-way).
4949 * Signal changes do not update URL.
5050 */
5151-function handleUrlRead(context: PluginContext, signalPath: string): void {
5252- const signal = context.findSignal(signalPath);
5151+function handleUrlRead(ctx: PluginContext, signalPath: string): void {
5252+ const signal = ctx.findSignal(signalPath);
5353 if (!signal) {
5454 console.error(`Signal "${signalPath}" not found for url read`);
5555 return;
···6767 * Bidirectional sync between signal and URL parameter.
6868 * Changes to either the signal or URL update the other.
6969 */
7070-function handleUrlSync(context: PluginContext, signalPath: string): void {
7171- const signal = context.findSignal(signalPath);
7070+function handleUrlSync(ctx: PluginContext, signalPath: string): void {
7171+ const signal = ctx.findSignal(signalPath);
7272 if (!signal) {
7373 console.error(`Signal "${signalPath}" not found for url sync`);
7474 return;
···123123 const unsubscribe = signal.subscribe(updateUrl);
124124 globalThis.addEventListener("popstate", handlePopState);
125125126126- context.addCleanup(() => {
126126+ ctx.addCleanup(() => {
127127 unsubscribe();
128128 globalThis.removeEventListener("popstate", handlePopState);
129129 if (updateTimeout) {
···136136 * Sync signal with hash portion of URL for client-side routing.
137137 * Bidirectional sync between signal and window.location.hash.
138138 */
139139-function handleHashRouting(context: PluginContext, signalPath: string): void {
140140- const signal = context.findSignal(signalPath);
139139+function handleHashRouting(ctx: PluginContext, signalPath: string): void {
140140+ const signal = ctx.findSignal(signalPath);
141141 if (!signal) {
142142 console.error(`Signal "${signalPath}" not found for hash routing`);
143143 return;
···171171 const unsubscribe = signal.subscribe(updateHash);
172172 globalThis.addEventListener("hashchange", handleHashChange);
173173174174- context.addCleanup(() => {
174174+ ctx.addCleanup(() => {
175175 unsubscribe();
176176 globalThis.removeEventListener("hashchange", handleHashChange);
177177 });
···179179180180/**
181181 * Serialize a value for URL parameter storage.
182182- *
183182 * Handles strings, numbers, booleans, and No Value (null/undefined).
184183 */
185184function serializeValue(value: unknown): string {