···77| Version | State | Milestone | Summary |
88| ------- | ----- | ---------------------------------------------------------- | ------------------------------------------------------------------------ |
99| | ✓ | [Foundations](#foundations) | Initial project setup, tooling, and reactive signal prototype. |
1010-| | ✓ | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`dava-volt-*`) and declarative updates. |
1010+| | ✓ | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`data-volt-*`) and declarative updates. |
1111| | ✓ | [Actions & Effects](#actions--effects) | Event system and derived reactivity primitives. |
1212| | ✓ | [Plugins Framework](#plugins-framework) | Modular plugin system and first built-in plugin set. |
1313| | | [Streaming & Patch Engine](#streaming--patch-engine) | SSE/WebSocket JSON patch streaming. |
···4848**Goal:** Add event-driven behavior and derived reactivity.
4949**Outcome:** Fully functional reactive UI layer with event bindings and computed updates.
5050**Deliverables:**
5151- - ✓ Event binding system (`dava-volt-on-*`)
5151+ - ✓ Event binding system (`data-volt-on-*`)
5252 - ✓ `$el` and `$event` scoped references
5353 - ✓ Derived signals (`computed`, `effect`)
5454 - ✓ Async effects (e.g., fetch triggers)
···6161 - ✓ `registerPlugin(name, fn)` API
6262 - ✓ Context and lifecycle hooks
6363 - ✓ Built-ins:
6464- - ✓ `dava-volt-persist`
6565- - ✓ `dava-volt-scroll`
6666- - ✓ `dava-volt-url`
6464+ - ✓ `data-volt-persist`
6565+ - ✓ `data-volt-scroll`
6666+ - ✓ `data-volt-url`
6767 - ✓ Registry
68686969+### Backend Integration & HTTP Actions
7070+7171+**Goal:** Provide backend integration with declarative HTTP requests and responses.
7272+**Outcome:** Volt.js can make backend requests and update the DOM
7373+**Deliverables:**
7474+ - ✓ HTTP action system (`data-volt-get`, `data-volt-post`, `data-volt-put`, `data-volt-patch`, `data-volt-delete`)
7575+ - ✓ Request configuration (`data-volt-trigger`, `data-volt-target`, `data-volt-swap`)
7676+ - ✓ Swap strategies (innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none)
7777+ - ✓ Loading states and indicators (`data-volt-indicator`)
7878+ - ✓ Error handling and retry logic
7979+ - ✓ Form serialization and submission
8080+ - ✓ Request/response headers customization
8181+6982## To-Do
70837184### Markup Based Reactivity
···7891 - ✓ Binding directives for text, attributes, classes, styles, and two-way form controls (`data-volt-[bind|text|model|class:*]`).
7992 - ✓ Control-flow directives (`data-volt-for`, `data-volt-if`, `data-volt-else`) with lifecycle-safe teardown.
8093 - ✓ Declarative event system (`data-volt-on:*`) with helper surface for list mutations and plugin hooks.
9494+ - ✓ SSR compatibility helpers
8195 - Sandboxed expression evaluator
8282- - SSR compatibility helpers
8383-8484-### Backend Integration & HTTP Actions
8585-8686-**Goal:** Provide backend integration with declarative HTTP requests and responses.
8787-**Outcome:** Volt.js can make backend requests and update the DOM
8888-**Deliverables:**
8989- - HTTP action system (`data-volt-get`, `data-volt-post`, `data-volt-put`, `data-volt-patch`, `data-volt-delete`)
9090- - Request configuration (`data-volt-trigger`, `data-volt-target`, `data-volt-swap`)
9191- - Swap strategies (innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none)
9292- - Loading states and indicators (`data-volt-indicator`)
9393- - Error handling and retry logic
9494- - See [svelte](https://svelte.dev/docs/svelte/await-expressions) for inspiration for loading & errors (`#await`)
9595- - Form serialization and submission
9696- - Request/response headers customization
97969897### Streaming & Patch Engine
9998···131130**Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control.
132131**Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs.
133132**Deliverables:**
134134- - `data-x-show` — toggles element visibility via CSS rather than DOM removal (complements `data-x-if`)
135135- - `data-x-style` — binds inline styles to reactive expressions
136136- - `data-x-skip` — marks elements or subtrees to exclude from Volt’s reactive parsing
137137- - `data-x-cloak` — hides content until the Volt runtime initializes
138138- - Event options for `data-x-on-*` attributes:
133133+ - `data-volt-show` — toggles element visibility via CSS rather than DOM removal (complements `data-volt-if`)
134134+ - `data-volt-style` — binds inline styles to reactive expressions
135135+ - `data-volt-skip` — marks elements or subtrees to exclude from Volt’s reactive parsing
136136+ - `data-volt-cloak` — hides content until the Volt runtime initializes
137137+ - Event options for `data-volt-on-*` attributes:
139138 - `.prevent` — calls `preventDefault()` on the event
140139 - `.stop` — stops propagation
141140 - `.self` — triggers only when the event target is the bound element
···145144 - `.debounce` — defers handler execution (optional milliseconds)
146145 - `.throttle` — limits handler frequency (optional milliseconds)
147146 - `.passive` — adds a passive event listener for scroll/touch performance
148148- - Input options for `data-x-bind` and `data-x-model`:
147147+ - Input options for `data-volt-bind` and `data-volt-model`:
149148 - `.number` — coerces values to numbers
150149 - `.trim` — removes surrounding whitespace
151150 - `.lazy` — syncs only on `change` instead of `input`
···156155**Goal:** Implement store/context pattern
157156**Outcome:** Volt.js provides intuitive global state management
158157**Deliverables:**
159159- - `$refs` - Scoped element references via dava-volt-ref="name". Provides an object mapping ref names to DOM nodes.
160160- - Example: `dava-volt-on-click="$refs.username.focus()"`
158158+ - `$refs` - Scoped element references via data-volt-ref="name". Provides an object mapping ref names to DOM nodes.
159159+ - Example: `data-volt-on-click="$refs.username.focus()"`
161160 - `$next()` - Defers execution to the next microtask tick after DOM updates.
162162- - Example: `dava-volt-on-click="$count++; $next(() => console.log('updated'))"`
161161+ - Example: `data-volt-on-click="$count++; $next(() => console.log('updated'))"`
163162 - `$watch(expr, fn)` - Imperatively observes a reactive signal or expression within the current scope.
164164- - Example: `dava-volt-init="$watch('count', v => console.log(v))"`
163163+ - Example: `data-volt-init="$watch('count', v => console.log(v))"`
165164 - `$emit(event, detail?)` - Dispatches a native CustomEvent from the current element.
166166- - Example: `dava-volt-on-click="$emit('user:save', { id })"`
165165+ - Example: `data-volt-on-click="$emit('user:save', { id })"`
167166 - `$store` - Accesses global reactive state registered with Volt’s global store.
168168- - Example: `dava-volt-text="$store.theme"`
167167+ - Example: `data-volt-text="$store.theme"`
169168 - `$uid(name?)` - Generates a unique, deterministic ID string within the current scope.
170170- - Example: `dava-volt-id="$uid('field')"`
169169+ - Example: `data-volt-id="$uid('field')"`
171170 - `$root` - Reference to the root element of the active reactive scope.
172171 - `$scope` - Reference to the current reactive scope object (signals + context).
173172···189188**Goal:** Enable declarative background data fetching and periodic updates within the Volt.js runtime.
190189**Outcome:** Volt.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions.
191190**Deliverables:**
192192- - `dava-volt-fetch` attribute for declarative background requests
191191+ - `data-volt-fetch` attribute for declarative background requests
193192 - Configurable polling intervals, delays, and signal-based triggers
194194- - `dava-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`)
193193+ - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`)
195194 - Background task scheduler with priority management
196195 - Automatic cancellation of requests when elements are unmounted
197196 - Conditional execution tied to reactive signals
···202201**Goal:** Introduce seamless client-side navigation and stateful history control using web standards.
203202**Outcome:** Volt.js provides enhanced navigation behavior with minimal overhead and full accessibility support.
204203**Deliverables:**
205205- - `dava-volt-navigate` for intercepting link and form actions
204204+ - `data-volt-navigate` for intercepting link and form actions
206205 - Integration with the History API (`pushState`, `replaceState`, `popState`)
207206 - Reactive synchronization of route and signal state
208207 - Smooth page and fragment transitions coordinated with Volt’s signal system
209208 - Native back/forward button support
210209 - Scroll position persistence and restoration
211210 - Optional preloading of linked resources on hover or idle
212212- - `dava-volt-url` for declarative history updates
211211+ - `data-volt-url` for declarative history updates
213212 - Optional View Transition API integration for animated route changes
214213215214### Inspector & Developer Tools
+178
docs/lifecycle.md
···11+# Server-Side Rendering & Lifecycle
22+33+Server-Side Rendering (SSR) with Volt.js enables you to render initial HTML on the server and seamlessly hydrate it on the client without re-rendering or flash of unstyled content.
44+55+## When to use SSR
66+77+- Content-heavy pages that benefit from SEO
88+- Applications requiring fast initial render
99+- Progressive web apps with offline capabilities
1010+- When you need to support users with JavaScript disabled
1111+1212+## When to use client-side rendering (CSR)
1313+1414+- Highly interactive single-page applications
1515+- Applications behind authentication (no SEO needed)
1616+- Rapid prototyping and development
1717+- When server-side rendering adds unnecessary complexity
1818+1919+## Concepts
2020+2121+### Server-Side: Rendering Initial HTML
2222+2323+The server generates HTML with `data-volt` attributes and embedded state. Volt only requires:
2424+2525+1. HTML elements with `data-volt-*` attributes
2626+2. A `<script>` tag containing serialized state as JSON
2727+2828+### Client-Side: Hydration
2929+3030+Instead of re-rendering the DOM, Volt.js "hydrates" the existing server-rendered HTML by:
3131+3232+1. Reading the embedded state from the `<script>` tag
3333+2. Recreating reactive signals from the serialized values
3434+3. Attaching event listeners and bindings to existing DOM nodes
3535+4. Preserving the existing DOM structure without modifications
3636+3737+## State Serialization
3838+3939+### Server-Side Pattern
4040+4141+Embed initial state in a `<script>` tag with a specific ID pattern:
4242+4343+```html
4444+<div id="app" data-volt>
4545+ <script type="application/json" id="volt-state-app">
4646+ {"count": 0, "username": "alice"}
4747+ </script>
4848+4949+ <p data-volt-text="count">0</p>
5050+ <p data-volt-text="username">alice</p>
5151+</div>
5252+```
5353+5454+- Script tag must have `type="application/json"`
5555+- ID must follow pattern: `volt-state-{element-id}`
5656+- Root element must have an `id` attribute
5757+- State must be valid JSON
5858+5959+### Client-Side Deserialization
6060+6161+Use the `hydrate()` function instead of `charge()` to hydrate all `[data-volt]` roots on the page. Volt will:
6262+6363+1. Find all elements matching the root selector (default: `[data-volt]`)
6464+2. Check for embedded state in `<script>` tags
6565+3. Deserialize JSON to reactive signals
6666+4. Mount bindings without re-rendering
6767+5. Mark elements as hydrated to prevent double-hydration
6868+6969+## Avoiding Flash of Unstyled Content (FOUC)
7070+7171+### CSS-Based Hiding
7272+7373+Hide content until Volt.js hydrates:
7474+7575+```html
7676+<style>
7777+ [data-volt]:not([data-volt-hydrated]) {
7878+ visibility: hidden;
7979+ }
8080+8181+ [data-volt][data-volt-hydrated] {
8282+ visibility: visible;
8383+ }
8484+</style>
8585+8686+<div id="app" data-volt>
8787+ <!-- Content is hidden until hydrated -->
8888+</div>
8989+```
9090+9191+### Strategy 2: Loading Indicator
9292+9393+Show a loading state during hydration:
9494+9595+```html
9696+<style>
9797+ .loading-overlay {
9898+ position: fixed;
9999+ inset: 0;
100100+ background: white;
101101+ display: flex;
102102+ align-items: center;
103103+ justify-content: center;
104104+ }
105105+106106+ [data-volt-hydrated] ~ .loading-overlay {
107107+ display: none;
108108+ }
109109+</style>
110110+111111+<div id="app" data-volt>
112112+ <!-- App content -->
113113+</div>
114114+<div class="loading-overlay">Loading...</div>
115115+116116+<script>
117117+ document.addEventListener('DOMContentLoaded', () => {
118118+ Volt.hydrate();
119119+ });
120120+</script>
121121+```
122122+123123+### Progressive Enhancement
124124+125125+Render fully functional HTML that works without JavaScript, then enhance with interactivity:
126126+127127+```html
128128+<!-- Form works without JavaScript -->
129129+<form id="contact" method="POST" action="/submit" data-volt>
130130+ <script type="application/json" id="volt-state-contact">
131131+ {"submitted": false}
132132+ </script>
133133+134134+ <input type="email" name="email" required>
135135+136136+ <!-- Enhanced with Volt.js for client-side validation -->
137137+ <p data-volt-if="submitted" data-volt-text="'Thank you!'"></p>
138138+139139+ <button type="submit">Submit</button>
140140+</form>
141141+```
142142+143143+Can you believe FOUC is an [actual](https://en.wikipedia.org/wiki/Flash_of_unstyled_content) acronym?
144144+145145+## Guidelines/Best Practices
146146+147147+### When to Use SSR vs CSR
148148+149149+**Use SSR for:**
150150+151151+- Any page requiring SEO
152152+153153+**Use CSR for:**
154154+155155+- Complex, interactive and/or real-time applications
156156+157157+### State Management
158158+159159+**Do:**
160160+161161+- Keep server-rendered state minimal (only essential data)
162162+- Use computed signals for derived values (don't serialize them)
163163+- Validate and sanitize state on the server
164164+- Use consistent data structures between server and client
165165+166166+**Don't:**
167167+168168+- Serialize functions or complex objects
169169+- Include sensitive data in client-side state
170170+- Serialize computed signals (they're recalculated on hydration)
171171+- Embed large datasets (fetch them after hydration instead)
172172+173173+### Security
174174+175175+- Escape user-generated content in server-rendered HTML
176176+- Validate state data before serialization
177177+- Use Content Security Policy (CSP) headers
178178+- Sanitize JSON to prevent XSS attacks
···22 * Async effect system with abort, race protection, debounce, throttle, and error handling
33 */
4455+import type { Optional, Timer } from "$types/helpers";
56import type { AsyncEffectFunction, AsyncEffectOptions, ComputedSignal, Signal } from "$types/volt";
6778/**
···5253 const { abortable = false, debounce, throttle, onError, retries = 0, retryDelay = 0 } = options;
53545455 let cleanup: (() => void) | void;
5555- let abortController: AbortController | undefined;
5656+ let abortController: Optional<AbortController>;
5657 let executionId = 0;
5757- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
5858- let throttleTimer: ReturnType<typeof setTimeout> | undefined;
5858+ let debounceTimer: Optional<Timer>;
5959+ let throttleTimer: Optional<Timer>;
5960 let lastExecutionTime = 0;
6061 let pendingExecution = false;
6162 let retryCount = 0;
+39-17
lib/src/core/binder.ts
···22 * Binder system for mounting and managing Volt.js bindings
33 */
4455+import type { Optional } from "$types/helpers";
56import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "$types/volt";
67import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
78import { evaluate, extractDependencies, isSignal } from "./evaluator";
99+import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http";
810import { executeGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle";
911import { getPlugin } from "./plugin";
1012···108110 }
109111 case "for": {
110112 bindFor(context, value);
113113+ break;
114114+ }
115115+ case "get": {
116116+ bindGet(context, value);
117117+ break;
118118+ }
119119+ case "post": {
120120+ bindPost(context, value);
121121+ break;
122122+ }
123123+ case "put": {
124124+ bindPut(context, value);
125125+ break;
126126+ }
127127+ case "patch": {
128128+ bindPatch(context, value);
129129+ break;
130130+ }
131131+ case "delete": {
132132+ bindDelete(context, value);
111133 break;
112134 }
113135 default: {
···384406/**
385407 * Find a signal in the scope by resolving a simple property path.
386408 */
387387-function findSignalInScope(scope: Scope, path: string): Signal<unknown> | undefined {
409409+function findSignalInScope(scope: Scope, path: string): Optional<Signal<unknown>> {
388410 const trimmed = path.trim();
389411 const parts = trimmed.split(".");
390412 let current: unknown = scope;
···491513 * Supports data-volt-else on the next sibling element.
492514 * Subscribes to condition signal and shows/hides elements when condition changes.
493515 *
494494- * @param context - Binding context
495495- * @param expression - Expression to evaluate as condition
516516+ * @param ctx - Binding context
517517+ * @param expr - Expression to evaluate as condition
496518 */
497497-function bindIf(context: BindingContext, expression: string): void {
498498- const ifTemplate = context.element as HTMLElement;
519519+function bindIf(ctx: BindingContext, expr: string): void {
520520+ const ifTemplate = ctx.element as HTMLElement;
499521 const parent = ifTemplate.parentElement;
500522501523 if (!parent) {
···503525 return;
504526 }
505527506506- let elseTemplate: HTMLElement | undefined;
528528+ let elseTemplate: Optional<HTMLElement>;
507529 let nextSibling = ifTemplate.nextElementSibling;
508530509531 while (nextSibling && nextSibling.nodeType !== 1) {
···515537 elseTemplate.remove();
516538 }
517539518518- const placeholder = document.createComment(`if: ${expression}`);
540540+ const placeholder = document.createComment(`if: ${expr}`);
519541 ifTemplate.before(placeholder);
520542 ifTemplate.remove();
521543522522- let currentElement: Element | undefined;
523523- let currentCleanup: CleanupFunction | undefined;
524524- let currentBranch: "if" | "else" | undefined;
544544+ let currentElement: Optional<Element>;
545545+ let currentCleanup: Optional<CleanupFunction>;
546546+ let currentBranch: Optional<"if" | "else">;
525547526548 const render = () => {
527527- const condition = evaluate(expression, context.scope);
549549+ const condition = evaluate(expr, ctx.scope);
528550 const shouldShow = Boolean(condition);
529551530552 const targetBranch = shouldShow ? "if" : (elseTemplate ? "else" : undefined);
···545567 if (targetBranch === "if") {
546568 currentElement = ifTemplate.cloneNode(true) as Element;
547569 delete (currentElement as HTMLElement).dataset.voltIf;
548548- currentCleanup = mount(currentElement, context.scope);
570570+ currentCleanup = mount(currentElement, ctx.scope);
549571 placeholder.before(currentElement);
550572 currentBranch = "if";
551573 } else if (targetBranch === "else" && elseTemplate) {
552574 currentElement = elseTemplate.cloneNode(true) as Element;
553575 delete (currentElement as HTMLElement).dataset.voltElse;
554554- currentCleanup = mount(currentElement, context.scope);
576576+ currentCleanup = mount(currentElement, ctx.scope);
555577 placeholder.before(currentElement);
556578 currentBranch = "else";
557579 } else {
···561583562584 render();
563585564564- const signal = findSignalInScope(context.scope, expression);
586586+ const signal = findSignalInScope(ctx.scope, expr);
565587 if (signal) {
566588 const unsubscribe = signal.subscribe(render);
567567- context.cleanups.push(unsubscribe);
589589+ ctx.cleanups.push(unsubscribe);
568590 }
569591570570- context.cleanups.push(() => {
592592+ ctx.cleanups.push(() => {
571593 if (currentCleanup) {
572594 currentCleanup();
573595 }
···579601 *
580602 * Supports: "item in items" or "(item, index) in items"
581603 */
582582-function parseForExpression(expr: string): { itemName: string; indexName?: string; arrayPath: string } | undefined {
604604+function parseForExpression(expr: string): Optional<{ itemName: string; indexName?: string; arrayPath: string }> {
583605 const trimmed = expr.trim();
584606585607 const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed);
+769
lib/src/core/http.ts
···11+/**
22+ * HTTP module for declarative backend integration
33+ *
44+ * Provides HTTP request/response handling with DOM swapping capabilities for server-rendered HTML fragments and JSON responses.
55+ */
66+77+import type { Optional } from "$types/helpers";
88+import type {
99+ BindingContext,
1010+ HttpMethod,
1111+ HttpResponse,
1212+ ParsedHttpConfig,
1313+ PluginContext,
1414+ RequestConfig,
1515+ RetryConfig,
1616+ Scope,
1717+ SwapStrategy,
1818+} from "$types/volt";
1919+import { evaluate } from "./evaluator";
2020+2121+/**
2222+ * Make an HTTP request and return the parsed response
2323+ *
2424+ * Handles both HTML and JSON responses based on Content-Type header.
2525+ * Throws an error for network failures or status >= 400
2626+ *
2727+ * @param conf - Request configuration
2828+ * @returns Promise resolving to HttpResponse
2929+ */
3030+export async function request(conf: RequestConfig): Promise<HttpResponse> {
3131+ const { method, url, headers = {}, body } = conf;
3232+3333+ try {
3434+ const response = await fetch(url, { method, headers: { ...headers }, body });
3535+3636+ const contentType = response.headers.get("content-type") || "";
3737+ const isHTML = contentType.includes("text/html");
3838+ const isJSON = contentType.includes("application/json");
3939+4040+ let html: Optional<string>;
4141+ let json: Optional<unknown>;
4242+4343+ if (isHTML) {
4444+ html = await response.text();
4545+ } else if (isJSON) {
4646+ json = await response.json();
4747+ } else {
4848+ html = await response.text();
4949+ }
5050+5151+ return {
5252+ status: response.status,
5353+ statusText: response.statusText,
5454+ headers: response.headers,
5555+ html,
5656+ json,
5757+ ok: response.ok,
5858+ };
5959+ } catch (error) {
6060+ throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`);
6161+ }
6262+}
6363+6464+/**
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+ */
7070+type CapturedState = {
7171+ focusPath: number[] | null;
7272+ scrollPositions: Map<number[], { top: number; left: number }>;
7373+ inputValues: Map<number[], string | boolean>;
7474+};
7575+7676+function captureState(root: Element): CapturedState {
7777+ const state: CapturedState = { focusPath: null, scrollPositions: new Map(), inputValues: new Map() };
7878+7979+ const activeEl = document.activeElement;
8080+ if (activeEl && root.contains(activeEl)) {
8181+ state.focusPath = getElementPath(activeEl, root);
8282+ }
8383+8484+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
8585+ let currentNode: Node | null = walker.currentNode;
8686+8787+ while (currentNode) {
8888+ const el = currentNode as Element;
8989+ const path = getElementPath(el, root);
9090+9191+ if (el.scrollTop > 0 || el.scrollLeft > 0) {
9292+ state.scrollPositions.set(path, { top: el.scrollTop, left: el.scrollLeft });
9393+ }
9494+9595+ if (el instanceof HTMLInputElement) {
9696+ if (el.type === "checkbox" || el.type === "radio") {
9797+ state.inputValues.set(path, el.checked);
9898+ } else {
9999+ state.inputValues.set(path, el.value);
100100+ }
101101+ } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
102102+ state.inputValues.set(path, el.value);
103103+ }
104104+105105+ currentNode = walker.nextNode();
106106+ }
107107+108108+ return state;
109109+}
110110+111111+/**
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
119119+ */
120120+function getElementPath(element: Element, root: Element): number[] {
121121+ const path: number[] = [];
122122+ let current: Element | null = element;
123123+124124+ while (current && current !== root) {
125125+ const parent: Element | null = current.parentElement;
126126+ if (!parent) break;
127127+128128+ const index = Array.from(parent.children).indexOf(current);
129129+ if (index === -1) break;
130130+131131+ path.unshift(index);
132132+ current = parent;
133133+ }
134134+135135+ return path;
136136+}
137137+138138+/**
139139+ * 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
144144+ */
145145+function getElementByPath(path: number[], root: Element): Element | null {
146146+ let current: Element = root;
147147+148148+ for (const index of path) {
149149+ const children = Array.from(current.children);
150150+ if (index >= children.length) return null;
151151+ current = children[index];
152152+ }
153153+154154+ return current;
155155+}
156156+157157+/**
158158+ * Restore preserved state after DOM swap
159159+ *
160160+ * @param root - Root element to restore state to
161161+ * @param state - Previously captured state
162162+ */
163163+function restoreState(root: Element, state: CapturedState): void {
164164+ if (state.focusPath) {
165165+ const element = getElementByPath(state.focusPath, root);
166166+ if (element instanceof HTMLElement) {
167167+ element.focus();
168168+ }
169169+ }
170170+171171+ for (const [path, position] of state.scrollPositions) {
172172+ const element = getElementByPath(path, root);
173173+ if (element) {
174174+ element.scrollTop = position.top;
175175+ element.scrollLeft = position.left;
176176+ }
177177+ }
178178+179179+ for (const [path, value] of state.inputValues) {
180180+ const element = getElementByPath(path, root);
181181+ if (element instanceof HTMLInputElement) {
182182+ if (element.type === "checkbox" || element.type === "radio") {
183183+ element.checked = value as boolean;
184184+ } else {
185185+ element.value = value as string;
186186+ }
187187+ } else if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
188188+ element.value = value as string;
189189+ }
190190+ }
191191+}
192192+193193+/**
194194+ * Apply a swap strategy to update the DOM with new content
195195+ *
196196+ * Preserves focus, scroll position, and input state when using innerHTML or outerHTML strategies.
197197+ *
198198+ * @param target - Target element to update
199199+ * @param content - HTML content to insert
200200+ * @param strategy - Swap strategy to use
201201+ */
202202+export function swap(target: Element, content: string, strategy: SwapStrategy = "innerHTML"): void {
203203+ const shouldPreserveState = strategy === "innerHTML" || strategy === "outerHTML";
204204+ const state = shouldPreserveState ? captureState(target) : null;
205205+206206+ switch (strategy) {
207207+ case "innerHTML": {
208208+ target.innerHTML = content;
209209+ if (state) restoreState(target, state);
210210+ break;
211211+ }
212212+ case "outerHTML": {
213213+ const parent = target.parentElement;
214214+ const nextSibling = target.nextElementSibling;
215215+ target.outerHTML = content;
216216+217217+ if (state && parent) {
218218+ const newElement = nextSibling ? nextSibling.previousElementSibling : parent.lastElementChild;
219219+ if (newElement) restoreState(newElement, state);
220220+ }
221221+ break;
222222+ }
223223+ case "beforebegin": {
224224+ target.insertAdjacentHTML("beforebegin", content);
225225+ break;
226226+ }
227227+ case "afterbegin": {
228228+ target.insertAdjacentHTML("afterbegin", content);
229229+ break;
230230+ }
231231+ case "beforeend": {
232232+ target.insertAdjacentHTML("beforeend", content);
233233+ break;
234234+ }
235235+ case "afterend": {
236236+ target.insertAdjacentHTML("afterend", content);
237237+ break;
238238+ }
239239+ case "delete": {
240240+ target.remove();
241241+ break;
242242+ }
243243+ case "none": {
244244+ break;
245245+ }
246246+ default: {
247247+ console.error(`Unknown swap strategy: ${strategy as string}`);
248248+ }
249249+ }
250250+}
251251+252252+/**
253253+ * Serialize a form element to FormData
254254+ *
255255+ * @param form - Form element to serialize
256256+ * @returns FormData object containing form fields
257257+ */
258258+export function serializeForm(form: HTMLFormElement): FormData {
259259+ return new FormData(form);
260260+}
261261+262262+/**
263263+ * Serialize a form element to JSON
264264+ *
265265+ * @param form - Form element to serialize
266266+ * @returns JSON object containing form fields
267267+ */
268268+export function serializeFormToJSON(form: HTMLFormElement): Record<string, unknown> {
269269+ const formData = new FormData(form);
270270+ const object: Record<string, unknown> = {};
271271+272272+ for (const [key, value] of formData.entries()) {
273273+ if (Object.hasOwn(object, key)) {
274274+ if (!Array.isArray(object[key])) {
275275+ object[key] = [object[key]];
276276+ }
277277+ (object[key] as unknown[]).push(value);
278278+ } else {
279279+ object[key] = value;
280280+ }
281281+ }
282282+283283+ return object;
284284+}
285285+286286+/**
287287+ * Parse HTTP configuration from element attributes
288288+ *
289289+ * Reads data-volt-trigger, data-volt-target, data-volt-swap, data-volt-headers,
290290+ * data-volt-retry, data-volt-retry-delay, and data-volt-indicator from the
291291+ * element's dataset and returns parsed configuration.
292292+ *
293293+ * @param el - Element to parse configuration from
294294+ * @param scope - Scope for evaluating expressions
295295+ * @returns Parsed HTTP configuration with defaults
296296+ */
297297+export function parseHttpConfig(el: Element, scope: Scope): ParsedHttpConfig {
298298+ const dataset = (el as HTMLElement).dataset;
299299+300300+ const trigger = dataset.voltTrigger || getDefaultTrigger(el);
301301+302302+ let target: string | Element = el;
303303+ if (dataset.voltTarget) {
304304+ const trimmed = dataset.voltTarget.trim();
305305+ if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
306306+ target = trimmed.slice(1, -1);
307307+ } else {
308308+ const targetValue = evaluate(dataset.voltTarget, scope);
309309+ if (typeof targetValue === "string") {
310310+ target = targetValue;
311311+ } else if (targetValue instanceof Element) {
312312+ target = targetValue;
313313+ }
314314+ }
315315+ }
316316+317317+ const swap = (dataset.voltSwap as SwapStrategy) || "innerHTML";
318318+319319+ let headers: Record<string, string> = {};
320320+ if (dataset.voltHeaders) {
321321+ try {
322322+ const headersValue = evaluate(dataset.voltHeaders, scope);
323323+ if (typeof headersValue === "object" && headersValue !== null) {
324324+ headers = headersValue as Record<string, string>;
325325+ }
326326+ } catch (error) {
327327+ console.error("Failed to parse data-volt-headers:", error);
328328+ }
329329+ }
330330+331331+ let retry: Optional<RetryConfig>;
332332+ if (dataset.voltRetry) {
333333+ const maxAttempts = Number.parseInt(dataset.voltRetry, 10);
334334+ const initialDelay = dataset.voltRetryDelay ? Number.parseInt(dataset.voltRetryDelay, 10) : 1000;
335335+336336+ if (!Number.isNaN(maxAttempts) && maxAttempts > 0) {
337337+ retry = { maxAttempts, initialDelay };
338338+ }
339339+ }
340340+341341+ const indicator = dataset.voltIndicator;
342342+343343+ return { trigger, target, swap, headers, retry, indicator };
344344+}
345345+346346+/**
347347+ * 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
355355+ */
356356+function getDefaultTrigger(element: Element): string {
357357+ if (element instanceof HTMLFormElement) {
358358+ return "submit";
359359+ }
360360+ return "click";
361361+}
362362+363363+/**
364364+ * Set loading state on an element
365365+ *
366366+ * Sets data-volt-loading="true" attribute to indicate ongoing request.
367367+ * Shows indicator if data-volt-indicator is set.
368368+ *
369369+ * @param element - Element to mark as loading
370370+ * @param indicator - Optional indicator selector
371371+ */
372372+export function setLoadingState(element: Element, indicator?: string): void {
373373+ element.setAttribute("data-volt-loading", "true");
374374+375375+ if (indicator) {
376376+ showIndicator(indicator);
377377+ }
378378+379379+ element.dispatchEvent(new CustomEvent("volt:loading", { detail: { element }, bubbles: true, cancelable: false }));
380380+}
381381+382382+/**
383383+ * Set error state on an element
384384+ *
385385+ * Sets data-volt-error attribute with error message.
386386+ * Hides indicator if data-volt-indicator is set.
387387+ *
388388+ * @param element - Element to mark as errored
389389+ * @param message - Error message
390390+ * @param indicator - Optional indicator selector
391391+ */
392392+export function setErrorState(element: Element, message: string, indicator?: string): void {
393393+ element.setAttribute("data-volt-error", message);
394394+395395+ if (indicator) {
396396+ hideIndicator(indicator);
397397+ }
398398+399399+ element.dispatchEvent(
400400+ new CustomEvent("volt:error", { detail: { element, message }, bubbles: true, cancelable: false }),
401401+ );
402402+}
403403+404404+/**
405405+ * Clear loading and error states from an element
406406+ *
407407+ * Removes data-volt-loading, data-volt-error, and data-volt-retry-attempt attributes.
408408+ * Hides indicator if data-volt-indicator is set.
409409+ *
410410+ * @param element - Element to clear states from
411411+ * @param indicator - Optional indicator selector
412412+ */
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");
417417+418418+ if (indicator) {
419419+ hideIndicator(indicator);
420420+ }
421421+422422+ element.dispatchEvent(new CustomEvent("volt:success", { detail: { element }, bubbles: true, cancelable: false }));
423423+}
424424+425425+/**
426426+ * Visibility strategy for showing/hiding indicator elements
427427+ */
428428+type IndicatorStrategy = "display" | "class";
429429+430430+/**
431431+ * Cache for storing indicator visibility strategies
432432+ */
433433+const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>();
434434+435435+/**
436436+ * Detect the appropriate visibility strategy for an indicator element
437437+ *
438438+ * - If element has display: none (inline or computed), use display toggling
439439+ * - If element has a class containing "hidden", use class toggling
440440+ * - Otherwise, default to class toggling
441441+ *
442442+ * @param element - Indicator element
443443+ * @returns Visibility strategy to use
444444+ */
445445+function detectIndicatorStrategy(element: Element): IndicatorStrategy {
446446+ if (indicatorStrategies.has(element)) {
447447+ return indicatorStrategies.get(element)!;
448448+ }
449449+450450+ const htmlElement = element as HTMLElement;
451451+ const inlineDisplay = htmlElement.style.display;
452452+ const computedDisplay = window.getComputedStyle(htmlElement).display;
453453+454454+ if (inlineDisplay === "none" || computedDisplay === "none") {
455455+ indicatorStrategies.set(element, "display");
456456+ return "display";
457457+ }
458458+459459+ const hasHiddenClass = Array.from(element.classList).some((cls) => cls.toLowerCase().includes("hidden"));
460460+ if (hasHiddenClass) {
461461+ indicatorStrategies.set(element, "class");
462462+ return "class";
463463+ }
464464+465465+ indicatorStrategies.set(element, "class");
466466+ return "class";
467467+}
468468+469469+/**
470470+ * Show an indicator element using the appropriate visibility strategy
471471+ *
472472+ * @param element - Indicator element to show
473473+ */
474474+function showIndicatorElement(element: Element): void {
475475+ const strategy = detectIndicatorStrategy(element);
476476+ const htmlElement = element as HTMLElement;
477477+478478+ if (strategy === "display") {
479479+ htmlElement.style.display = "";
480480+ } else {
481481+ const hiddenClass = Array.from(element.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
482482+ element.classList.remove(hiddenClass);
483483+ }
484484+}
485485+486486+/**
487487+ * Hide an indicator element using the appropriate visibility strategy
488488+ *
489489+ * @param element - Indicator element to hide
490490+ */
491491+function hideIndicatorElement(element: Element): void {
492492+ const strategy = detectIndicatorStrategy(element);
493493+ const htmlElement = element as HTMLElement;
494494+495495+ if (strategy === "display") {
496496+ htmlElement.style.display = "none";
497497+ } else {
498498+ const hiddenClass = Array.from(element.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden";
499499+ element.classList.add(hiddenClass);
500500+ }
501501+}
502502+503503+/**
504504+ * Show loading indicator(s) specified by selector
505505+ *
506506+ * @param selector - CSS selector for indicator element(s)
507507+ */
508508+export function showIndicator(selector: string): void {
509509+ const indicators = document.querySelectorAll(selector);
510510+ for (const indicator of indicators) {
511511+ showIndicatorElement(indicator);
512512+ }
513513+}
514514+515515+/**
516516+ * Hide loading indicator(s) specified by selector
517517+ *
518518+ * @param selector - CSS selector for indicator element(s)
519519+ */
520520+export function hideIndicator(selector: string): void {
521521+ const indicators = document.querySelectorAll(selector);
522522+ for (const indicator of indicators) {
523523+ hideIndicatorElement(indicator);
524524+ }
525525+}
526526+527527+/**
528528+ * Resolve target element from configuration
529529+ *
530530+ * @param targetConf - Target selector or element
531531+ * @param defaultEl - Default element if target is "this" or undefined
532532+ * @returns Resolved target element or undefined if not found
533533+ */
534534+function resolveTarget(targetConf: string | Element, defaultEl: Element): Optional<Element> {
535535+ if (targetConf instanceof Element) {
536536+ return targetConf;
537537+ }
538538+539539+ if (targetConf === "this" || targetConf === "") {
540540+ return defaultEl;
541541+ }
542542+543543+ const target = document.querySelector(targetConf);
544544+ if (!target) {
545545+ console.warn(`Target element not found: ${targetConf}`);
546546+ return undefined;
547547+ }
548548+549549+ return target;
550550+}
551551+552552+/**
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 {
564564+ if (error instanceof Error && error.message.includes("HTTP")) {
565565+ const match = error.message.match(/HTTP (\d+):/);
566566+ if (match) {
567567+ const status = Number.parseInt(match[1], 10);
568568+ if (status >= 500 && status < 600) return "server";
569569+ if (status >= 400 && status < 500) return "client";
570570+ }
571571+ }
572572+573573+ if (error instanceof Error && (error.message.includes("fetch") || error.message.includes("network"))) {
574574+ return "network";
575575+ }
576576+577577+ return "other";
578578+}
579579+580580+/**
581581+ * Determine if an error should be retried based on smart retry logic
582582+ *
583583+ * - Network errors: Always retry
584584+ * - 5xx server errors: Always retry
585585+ * - 4xx client errors: Never retry
586586+ * - Other errors: Never retry
587587+ *
588588+ * @param error - Error to check
589589+ * @returns True if error should be retried
590590+ */
591591+function shouldRetry(error: unknown): boolean {
592592+ const errorType = classifyError(error);
593593+ return errorType === "network" || errorType === "server";
594594+}
595595+596596+/**
597597+ * Calculate retry delay based on error type and attempt number
598598+ *
599599+ * - Network errors: No delay (immediate retry)
600600+ * - Server errors: Exponential backoff (initialDelay × 2^attempt)
601601+ * - 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
607607+ */
608608+function calculateRetryDelay(error: unknown, attempt: number, initialDelay: number): number {
609609+ const errorType = classifyError(error);
610610+611611+ if (errorType === "network") {
612612+ return 0;
613613+ }
614614+615615+ if (errorType === "server") {
616616+ return initialDelay * 2 ** attempt;
617617+ }
618618+619619+ 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));
629629+}
630630+631631+/**
632632+ * Perform an HTTP request with configuration from element attributes
633633+ *
634634+ * Handles the full request lifecycle: loading state, request, swap, error handling, and smart retry.
635635+ *
636636+ * @param el - Element that triggered the request
637637+ * @param method - HTTP method
638638+ * @param url - Request URL
639639+ * @param conf - Parsed HTTP configuration
640640+ * @param body - Optional request body
641641+ */
642642+async function performRequest(
643643+ el: Element,
644644+ method: HttpMethod,
645645+ url: string,
646646+ conf: ParsedHttpConfig,
647647+ body?: string | FormData,
648648+): Promise<void> {
649649+ const target = resolveTarget(conf.target, el);
650650+ if (!target) {
651651+ return;
652652+ }
653653+654654+ setLoadingState(target, conf.indicator);
655655+656656+ let lastError: unknown;
657657+ const maxAttempts = conf.retry ? conf.retry.maxAttempts + 1 : 1;
658658+ const initialDelay = conf.retry?.initialDelay ?? 1000;
659659+660660+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
661661+ try {
662662+ if (attempt > 0) {
663663+ target.setAttribute("data-volt-retry-attempt", String(attempt));
664664+ target.setAttribute("data-volt-loading", "retrying");
665665+ target.dispatchEvent(
666666+ new CustomEvent("volt:retry", { detail: { element: target, attempt }, bubbles: true, cancelable: false }),
667667+ );
668668+ }
669669+670670+ const response = await request({ method, url, headers: conf.headers, body });
671671+672672+ if (!response.ok) {
673673+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
674674+ }
675675+676676+ clearStates(target, conf.indicator);
677677+678678+ if (response.html !== undefined) {
679679+ swap(target, response.html, conf.swap);
680680+ } else if (response.json !== undefined) {
681681+ console.warn("JSON responses are not yet integrated with signal updates. HTML response expected.");
682682+ }
683683+684684+ return;
685685+ } catch (error) {
686686+ lastError = error;
687687+688688+ const isLastAttempt = attempt === maxAttempts - 1;
689689+ const canRetry = conf.retry && shouldRetry(error);
690690+691691+ if (isLastAttempt || !canRetry) {
692692+ break;
693693+ }
694694+695695+ const delay = calculateRetryDelay(error, attempt, initialDelay);
696696+ if (delay > 0) {
697697+ await sleep(delay);
698698+ }
699699+ }
700700+ }
701701+702702+ const errorMessage = lastError instanceof Error ? lastError.message : String(lastError);
703703+ setErrorState(target, errorMessage, conf.indicator);
704704+ console.error("HTTP request failed:", lastError);
705705+}
706706+707707+export function bindGet(context: BindingContext, url: string): void {
708708+ bindHttpMethod(context, "GET", url);
709709+}
710710+711711+export function bindPost(context: BindingContext, url: string): void {
712712+ bindHttpMethod(context, "POST", url);
713713+}
714714+715715+export function bindPut(context: BindingContext, url: string): void {
716716+ bindHttpMethod(context, "PUT", url);
717717+}
718718+719719+export function bindPatch(context: BindingContext, url: string): void {
720720+ bindHttpMethod(context, "PATCH", url);
721721+}
722722+723723+export function bindDelete(context: BindingContext, url: string): void {
724724+ bindHttpMethod(context, "DELETE", url);
725725+}
726726+727727+/**
728728+ * Generic HTTP method binding handler
729729+ *
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
736736+ */
737737+function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, url: string): void {
738738+ const config = parseHttpConfig(ctx.element, ctx.scope);
739739+ const urlValue = evaluate(url, ctx.scope);
740740+ const resolvedUrl = String(urlValue);
741741+742742+ const handler = async (event: Event) => {
743743+ if (config.trigger === "submit" || ctx.element instanceof HTMLFormElement) {
744744+ event.preventDefault();
745745+ }
746746+747747+ let body: Optional<string | FormData>;
748748+749749+ if (method !== "GET" && method !== "DELETE") {
750750+ if (ctx.element instanceof HTMLFormElement) {
751751+ body = serializeForm(ctx.element);
752752+ }
753753+ }
754754+755755+ await performRequest(ctx.element, method, resolvedUrl, config, body);
756756+ };
757757+758758+ ctx.element.addEventListener(config.trigger, handler);
759759+760760+ const cleanup = () => {
761761+ ctx.element.removeEventListener(config.trigger, handler);
762762+ };
763763+764764+ if ("addCleanup" in ctx) {
765765+ ctx.addCleanup(cleanup);
766766+ } else {
767767+ ctx.cleanups.push(cleanup);
768768+ }
769769+}
+2-1
lib/src/core/plugin.ts
···22 * Plugin system for extending Volt.js with custom bindings
33 */
4455+import type { Optional } from "$types/helpers";
56import type { PluginHandler } from "$types/volt";
6778const pluginRegistry = new Map<string, PluginHandler>();
···4041 * @param name - Plugin name
4142 * @returns Plugin handler function or undefined
4243 */
4343-export function getPlugin(name: string): PluginHandler | undefined {
4444+export function getPlugin(name: string): Optional<PluginHandler> {
4445 return pluginRegistry.get(name);
4546}
4647
+282
lib/src/core/ssr.ts
···11+/**
22+ * Server-Side Rendering (SSR) and hydration support for Volt.js
33+ *
44+ * Provides utilities for serializing scope state, embedding it in HTML,
55+ * and hydrating client-side without re-rendering.
66+ */
77+88+import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt";
99+import { mount } from "./binder";
1010+import { evaluate, extractDependencies } from "./evaluator";
1111+import { computed, signal } from "./signal";
1212+1313+/**
1414+ * Serialize a scope object to JSON for embedding in server-rendered HTML
1515+ *
1616+ * Converts all signals in the scope to their plain values.
1717+ * Computed signals are serialized as their current values.
1818+ *
1919+ * @param scope - The reactive scope to serialize
2020+ * @returns JSON string representing the scope state
2121+ *
2222+ * @example
2323+ * ```ts
2424+ * const scope = {
2525+ * count: signal(0),
2626+ * double: computed(() => scope.count.get() * 2, [scope.count])
2727+ * };
2828+ * const json = serializeScope(scope);
2929+ * // Returns: '{"count":0,"double":0}'
3030+ * ```
3131+ */
3232+export function serializeScope(scope: Scope): string {
3333+ const serialized: SerializedScope = {};
3434+3535+ for (const [key, value] of Object.entries(scope)) {
3636+ if (isSignal(value)) {
3737+ serialized[key] = value.get();
3838+ } else {
3939+ serialized[key] = value;
4040+ }
4141+ }
4242+4343+ return JSON.stringify(serialized);
4444+}
4545+4646+/**
4747+ * Deserialize JSON state data back into a reactive scope
4848+ *
4949+ * Recreates signals from plain values. Does not recreate computed signals
5050+ * (those should be redefined with data-volt-computed attributes).
5151+ *
5252+ * @param data - Plain object with state values
5353+ * @returns Reactive scope with signals
5454+ *
5555+ * @example
5656+ * ```ts
5757+ * const scope = deserializeScope({ count: 42, name: "Alice" });
5858+ * // Returns: { count: signal(42), name: signal("Alice") }
5959+ * ```
6060+ */
6161+export function deserializeScope(data: SerializedScope): Scope {
6262+ const scope: Scope = {};
6363+6464+ for (const [key, value] of Object.entries(data)) {
6565+ scope[key] = signal(value);
6666+ }
6767+6868+ return scope;
6969+}
7070+7171+/**
7272+ * Check if an element has already been hydrated
7373+ *
7474+ * @param element - Element to check
7575+ * @returns True if element is marked as hydrated
7676+ */
7777+export function isHydrated(element: Element): boolean {
7878+ return element.hasAttribute("data-volt-hydrated");
7979+}
8080+8181+/**
8282+ * Mark an element as hydrated to prevent double-hydration
8383+ *
8484+ * @param element - Element to mark
8585+ */
8686+function markHydrated(element: Element): void {
8787+ element.setAttribute("data-volt-hydrated", "true");
8888+}
8989+9090+/**
9191+ * Check if an element has server-rendered state embedded
9292+ *
9393+ * Looks for a script tag with id="volt-state-{element-id}" containing JSON state.
9494+ *
9595+ * @param element - Element to check
9696+ * @returns True if serialized state is found
9797+ */
9898+export function isServerRendered(element: Element): boolean {
9999+ return getSerializedState(element) !== null;
100100+}
101101+102102+/**
103103+ * Extract serialized state from a server-rendered element
104104+ *
105105+ * Searches for a `<script type="application/json" id="volt-state-{id}">` tag
106106+ * containing the serialized scope data.
107107+ *
108108+ * @param element - Root element to extract state from
109109+ * @returns Parsed state object, or null if not found
110110+ *
111111+ * @example
112112+ * ```html
113113+ * <div id="app" data-volt>
114114+ * <script type="application/json" id="volt-state-app">
115115+ * {"count": 42}
116116+ * </script>
117117+ * </div>
118118+ * ```
119119+ */
120120+export function getSerializedState(element: Element): SerializedScope | null {
121121+ const elementId = element.id;
122122+ if (!elementId) {
123123+ return null;
124124+ }
125125+126126+ const scriptId = `volt-state-${elementId}`;
127127+ const scriptTag = element.querySelector(`script[type="application/json"]#${scriptId}`);
128128+129129+ if (!scriptTag || !scriptTag.textContent) {
130130+ return null;
131131+ }
132132+133133+ try {
134134+ return JSON.parse(scriptTag.textContent) as SerializedScope;
135135+ } catch (error) {
136136+ console.error(`Failed to parse serialized state from #${scriptId}:`, error);
137137+ return null;
138138+ }
139139+}
140140+141141+/**
142142+ * Create a reactive scope from element, preferring server-rendered state
143143+ *
144144+ * This is similar to createScopeFromElement in charge.ts, but checks for
145145+ * server-rendered state first before falling back to data-volt-state.
146146+ *
147147+ * @param element - The root element
148148+ * @returns Reactive scope object with signals
149149+ */
150150+function createHydrationScope(element: Element): Scope {
151151+ let scope: Scope = {};
152152+153153+ const serializedState = getSerializedState(element);
154154+ if (serializedState) {
155155+ scope = deserializeScope(serializedState);
156156+ } else {
157157+ const stateAttr = (element as HTMLElement).dataset.voltState;
158158+ if (stateAttr) {
159159+ try {
160160+ const stateData = JSON.parse(stateAttr);
161161+162162+ if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) {
163163+ console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, element);
164164+ } else {
165165+ for (const [key, value] of Object.entries(stateData)) {
166166+ scope[key] = signal(value);
167167+ }
168168+ }
169169+ } catch (error) {
170170+ console.error("Failed to parse data-volt-state JSON:", stateAttr, error);
171171+ console.error("Element:", element);
172172+ }
173173+ }
174174+ }
175175+176176+ const computedAttrs = getComputedAttributes(element);
177177+ for (const [name, expression] of computedAttrs) {
178178+ try {
179179+ const dependencies = extractDependencies(expression, scope);
180180+ scope[name] = computed(() => evaluate(expression, scope), dependencies);
181181+ } catch (error) {
182182+ console.error(`Failed to create computed "${name}" with expression "${expression}":`, error);
183183+ }
184184+ }
185185+186186+ 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");
220220+}
221221+222222+/**
223223+ * Hydrate server-rendered Volt roots without re-rendering
224224+ *
225225+ * Similar to charge(), but designed for server-rendered content.
226226+ * Preserves existing DOM structure and only attaches reactive bindings.
227227+ *
228228+ * @param options - Hydration options
229229+ * @returns HydrateResult containing mounted roots and cleanup function
230230+ *
231231+ * @example
232232+ * Server renders:
233233+ * ```html
234234+ * <div id="app" data-volt>
235235+ * <script type="application/json" id="volt-state-app">
236236+ * {"count": 0}
237237+ * </script>
238238+ * <p data-volt-text="count">0</p>
239239+ * </div>
240240+ * ```
241241+ *
242242+ * Client hydrates:
243243+ * ```ts
244244+ * const { cleanup } = hydrate();
245245+ * // DOM is preserved, bindings are attached
246246+ * ```
247247+ */
248248+export function hydrate(options: HydrateOptions = {}): HydrateResult {
249249+ const { rootSelector = "[data-volt]", skipHydrated = true } = options;
250250+251251+ const elements = document.querySelectorAll(rootSelector);
252252+ const chargedRoots: Array<{ element: Element; scope: Scope; cleanup: () => void }> = [];
253253+254254+ for (const element of elements) {
255255+ if (skipHydrated && isHydrated(element)) {
256256+ continue;
257257+ }
258258+259259+ try {
260260+ const scope = createHydrationScope(element);
261261+ const cleanup = mount(element, scope);
262262+263263+ markHydrated(element);
264264+ chargedRoots.push({ element, scope, cleanup });
265265+ } catch (error) {
266266+ console.error("Error hydrating Volt root:", element, error);
267267+ }
268268+ }
269269+270270+ return {
271271+ roots: chargedRoots,
272272+ cleanup: () => {
273273+ for (const root of chargedRoots) {
274274+ try {
275275+ root.cleanup();
276276+ } catch (error) {
277277+ console.error("Error cleaning up hydrated Volt root:", root.element, error);
278278+ }
279279+ }
280280+ },
281281+ };
282282+}
+22-15
lib/src/index.ts
···44 * @packageDocumentation
55 */
6677+export { asyncEffect } from "$core/asyncEffect";
88+export { mount } from "$core/binder";
99+export { charge } from "$core/charge";
1010+export { parseHttpConfig, request, serializeForm, serializeFormToJSON, swap } from "$core/http";
1111+export {
1212+ clearAllGlobalHooks,
1313+ clearGlobalHooks,
1414+ getElementBindings,
1515+ isElementMounted,
1616+ registerElementHook,
1717+ registerGlobalHook,
1818+ unregisterGlobalHook,
1919+} from "$core/lifecycle";
2020+export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin";
2121+export { computed, effect, signal } from "$core/signal";
2222+export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr";
2323+export { persistPlugin, registerStorageAdapter, scrollPlugin, urlPlugin } from "$plugins";
724export type {
825 AsyncEffectFunction,
926 AsyncEffectOptions,
···1128 ChargeResult,
1229 ComputedSignal,
1330 GlobalHookName,
3131+ HydrateOptions,
3232+ HydrateResult,
3333+ ParsedHttpConfig,
1434 PluginContext,
1535 PluginHandler,
3636+ RetryConfig,
3737+ SerializedScope,
1638 Signal,
1739} from "$types/volt";
1818-export { asyncEffect } from "@volt/core/asyncEffect";
1919-export { mount } from "@volt/core/binder";
2020-export { charge } from "@volt/core/charge";
2121-export {
2222- clearAllGlobalHooks,
2323- clearGlobalHooks,
2424- getElementBindings,
2525- isElementMounted,
2626- registerElementHook,
2727- registerGlobalHook,
2828- unregisterGlobalHook,
2929-} from "@volt/core/lifecycle";
3030-export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin";
3131-export { computed, effect, signal } from "@volt/core/signal";
3232-export { persistPlugin, registerStorageAdapter, scrollPlugin, urlPlugin } from "@volt/plugins";
+2-2
lib/src/main.ts
···11-import { computed, effect, mount, registerPlugin, signal } from "@volt";
22-import { persistPlugin, scrollPlugin, urlPlugin } from "@volt/plugins";
11+import { persistPlugin, scrollPlugin, urlPlugin } from "$plugins";
22+import { computed, effect, mount, registerPlugin, signal } from "$volt";
3344registerPlugin("persist", persistPlugin);
55registerPlugin("scroll", scrollPlugin);
+3-2
lib/src/plugins/persist.ts
···44 * Supports localStorage, sessionStorage, IndexedDB, and custom adapters
55 */
6677+import type { Optional } from "$types/helpers";
78import type { PluginContext, Signal, StorageAdapter } from "$types/volt";
89910/**
···108109/**
109110 * Open or create the IndexedDB database
110111 */
111111-let dbPromise: Promise<IDBDatabase> | undefined;
112112+let dbPromise: Optional<Promise<IDBDatabase>>;
112113function openDB(): Promise<IDBDatabase> {
113114 if (dbPromise) return dbPromise;
114115···137138/**
138139 * Get storage adapter by name
139140 */
140140-function getStorageAdapter(type: string): StorageAdapter | undefined {
141141+function getStorageAdapter(type: string): Optional<StorageAdapter> {
141142 switch (type) {
142143 case "local": {
143144 return localStorageAdapter;
+2-1
lib/src/plugins/url.ts
···33 * Supports one-way read, bidirectional sync, and hash-based routing
44 */
5566+import type { Optional } from "$types/helpers";
67import type { PluginContext, Signal } from "$types/volt";
7889/**
···8081 }
81828283 let isUpdatingFromUrl = false;
8383- let updateTimeout: number | undefined;
8484+ let updateTimeout: Optional<number>;
84858586 const updateUrl = (value: unknown) => {
8687 if (isUpdatingFromUrl) return;
+5
lib/src/types/helpers.ts
···11+export type Optional<T> = T | undefined;
22+33+export type Nullable<T> = T | null;
44+55+export type Timer = ReturnType<typeof setTimeout>;
+78-7
lib/src/types/volt.d.ts
···171171 */
172172export type AsyncEffectFunction = (signal?: AbortSignal) => Promise<void | (() => void)>;
173173174174-/**
175175- * Lifecycle hook callback types
176176- */
177174export type LifecycleHookCallback = () => void;
178175export type MountHookCallback = (root: Element, scope: Scope) => void;
179176export type UnmountHookCallback = (root: Element) => void;
180177export type ElementMountHookCallback = (element: Element, scope: Scope) => void;
181178export type ElementUnmountHookCallback = (element: Element) => void;
182179export type BindingHookCallback = (element: Element, bindingName: string) => void;
183183-184184-/**
185185- * Lifecycle hook names
186186- */
187180export type GlobalHookName = "beforeMount" | "afterMount" | "beforeUnmount" | "afterUnmount";
188181189182/**
···210203 */
211204 afterBinding: (callback: LifecycleHookCallback) => void;
212205}
206206+207207+export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
208208+209209+/**
210210+ * Strategies for swapping response content into the DOM
211211+ *
212212+ * - innerHTML: Replace the target's inner HTML (default)
213213+ * - outerHTML: Replace the target element entirely
214214+ * - beforebegin: Insert before the target element
215215+ * - afterbegin: Insert at the start of the target's content
216216+ * - beforeend: Insert at the end of the target's content
217217+ * - afterend: Insert after the target element
218218+ * - delete: Remove the target element
219219+ * - none: No DOM update (for side effects only)
220220+ */
221221+export type SwapStrategy =
222222+ | "innerHTML"
223223+ | "outerHTML"
224224+ | "beforebegin"
225225+ | "afterbegin"
226226+ | "beforeend"
227227+ | "afterend"
228228+ | "delete"
229229+ | "none";
230230+231231+export type RequestConfig = {
232232+ method: HttpMethod;
233233+ url: string;
234234+ headers?: Record<string, string>;
235235+ body?: string | FormData;
236236+ target?: string | Element;
237237+ swap?: SwapStrategy;
238238+};
239239+240240+export type HttpResponse = {
241241+ status: number;
242242+ statusText: string;
243243+ headers: Headers;
244244+ html?: string;
245245+ json?: unknown;
246246+ ok: boolean;
247247+};
248248+249249+/**
250250+ * Configuration parsed from element attributes
251251+ */
252252+export type ParsedHttpConfig = {
253253+ trigger: string;
254254+ target: string | Element;
255255+ swap: SwapStrategy;
256256+ headers: Record<string, string>;
257257+ retry?: RetryConfig;
258258+ indicator?: string;
259259+};
260260+261261+/**
262262+ * Retry configuration for HTTP requests
263263+ */
264264+export type RetryConfig = {
265265+ /**
266266+ * Maximum number of retry attempts
267267+ */
268268+ maxAttempts: number;
269269+270270+ /**
271271+ * Initial delay in milliseconds before first retry
272272+ */
273273+ initialDelay: number;
274274+};
275275+276276+export type HydrateOptions = { rootSelector?: string; skipHydrated?: boolean };
277277+278278+/**
279279+ * Serialized scope data structure for SSR
280280+ */
281281+export type SerializedScope = Record<string, unknown>;
282282+283283+export type HydrateResult = ChargeResult;
+2-2
lib/test/core/asyncEffect.test.ts
···11-import { asyncEffect } from "@volt/core/asyncEffect";
22-import { signal } from "@volt/core/signal";
11+import { asyncEffect } from "$core/asyncEffect";
22+import { signal } from "$core/signal";
33import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4455describe("asyncEffect", () => {
+2-2
lib/test/core/binder.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { signal } from "@volt/core/signal";
11+import { mount } from "$core/binder";
22+import { signal } from "$core/signal";
33import { describe, expect, it } from "vitest";
4455describe("binder", () => {
+1-1
lib/test/core/charge.test.ts
···11+import { charge } from "$core/charge";
12import type { Signal } from "$types/volt";
22-import { charge } from "@volt/core/charge";
33import { afterEach, beforeEach, describe, expect, it } from "vitest";
4455describe("charge", () => {
+2-2
lib/test/core/evaluator.test.ts
···11-import { evaluate } from "@volt/core/evaluator";
22-import { signal } from "@volt/core/signal";
11+import { evaluate } from "$core/evaluator";
22+import { signal } from "$core/signal";
33import { describe, expect, it } from "vitest";
4455describe("evaluator", () => {
+2-2
lib/test/core/events.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { signal } from "@volt/core/signal";
11+import { mount } from "$core/binder";
22+import { signal } from "$core/signal";
33import { describe, expect, it, vi } from "vitest";
4455describe("event bindings", () => {
+2-2
lib/test/core/for-binding.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { signal } from "@volt/core/signal";
11+import { mount } from "$core/binder";
22+import { signal } from "$core/signal";
33import { describe, expect, it } from "vitest";
4455describe("data-volt-for binding", () => {
···11-import { mount } from "@volt/core/binder";
22-import { signal } from "@volt/core/signal";
11+import { mount } from "$core/binder";
22+import { signal } from "$core/signal";
33import { describe, expect, it } from "vitest";
4455describe("data-volt-if binding", () => {
+5-5
lib/test/core/lifecycle.test.ts
···11-import type { PluginContext } from "$types/volt";
22-import { mount } from "@volt/core/binder";
11+import { mount } from "$core/binder";
32import {
43 clearAllGlobalHooks,
54 clearGlobalHooks,
···109 registerElementHook,
1110 registerGlobalHook,
1211 unregisterGlobalHook,
1313-} from "@volt/core/lifecycle";
1414-import { registerPlugin } from "@volt/core/plugin";
1515-import { signal } from "@volt/core/signal";
1212+} from "$core/lifecycle";
1313+import { registerPlugin } from "$core/plugin";
1414+import { signal } from "$core/signal";
1515+import type { PluginContext } from "$types/volt";
1616import { afterEach, describe, expect, it, vi } from "vitest";
17171818describe("lifecycle hooks", () => {
+2-2
lib/test/core/model-binding.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { signal } from "@volt/core/signal";
11+import { mount } from "$core/binder";
22+import { signal } from "$core/signal";
33import { describe, expect, it } from "vitest";
4455describe("data-volt-model binding", () => {
+1-1
lib/test/core/plugin.test.ts
···11-import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin";
11+import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin";
22import { beforeEach, describe, expect, it, vi } from "vitest";
3344describe("plugin system", () => {
+1-1
lib/test/core/signal.test.ts
···11-import { computed, effect, signal } from "@volt/core/signal";
11+import { computed, effect, signal } from "$core/signal";
22import { describe, expect, it, vi } from "vitest";
3344describe("signal", () => {
···11-import { computed, mount, signal } from "@volt";
11+import { computed, mount, signal } from "$volt";
22import { describe, expect, it } from "vitest";
3344describe("integration: list rendering", () => {
+1-1
lib/test/integration/mount.test.ts
···11-import { mount, signal } from "@volt";
11+import { mount, signal } from "$volt";
22import { describe, expect, it } from "vitest";
3344describe("integration: mount", () => {
+3-3
lib/test/integration/plugins.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { clearPlugins, registerPlugin } from "@volt/core/plugin";
33-import { signal } from "@volt/core/signal";
11+import { mount } from "$core/binder";
22+import { clearPlugins, registerPlugin } from "$core/plugin";
33+import { signal } from "$core/signal";
44import { beforeEach, describe, expect, it, vi } from "vitest";
5566describe("plugin integration with binder", () => {
+4-4
lib/test/plugins/persist.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { registerPlugin } from "@volt/core/plugin";
33-import { signal } from "@volt/core/signal";
44-import { persistPlugin, registerStorageAdapter } from "@volt/plugins/persist";
11+import { mount } from "$core/binder";
22+import { registerPlugin } from "$core/plugin";
33+import { signal } from "$core/signal";
44+import { persistPlugin, registerStorageAdapter } from "$plugins/persist";
55import { beforeEach, describe, expect, it, vi } from "vitest";
6677describe("persist plugin", () => {
+4-4
lib/test/plugins/scroll.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { registerPlugin } from "@volt/core/plugin";
33-import { signal } from "@volt/core/signal";
44-import { scrollPlugin } from "@volt/plugins/scroll";
11+import { mount } from "$core/binder";
22+import { registerPlugin } from "$core/plugin";
33+import { signal } from "$core/signal";
44+import { scrollPlugin } from "$plugins/scroll";
55import { beforeEach, describe, expect, it, vi } from "vitest";
6677describe("scroll plugin", () => {
+4-4
lib/test/plugins/url.test.ts
···11-import { mount } from "@volt/core/binder";
22-import { registerPlugin } from "@volt/core/plugin";
33-import { signal } from "@volt/core/signal";
44-import { urlPlugin } from "@volt/plugins/url";
11+import { mount } from "$core/binder";
22+import { registerPlugin } from "$core/plugin";
33+import { signal } from "$core/signal";
44+import { urlPlugin } from "$plugins/url";
55import { beforeEach, describe, expect, it, vi } from "vitest";
6677describe("url plugin", () => {