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

Configure Feed

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

feat: http/ssr helpers & bindings

* error handling & "smart" retries
* completed ssr engine

+2783 -133
+36 -37
ROADMAP.md
··· 7 7 | Version | State | Milestone | Summary | 8 8 | ------- | ----- | ---------------------------------------------------------- | ------------------------------------------------------------------------ | 9 9 | | ✓ | [Foundations](#foundations) | Initial project setup, tooling, and reactive signal prototype. | 10 - | | ✓ | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`dava-volt-*`) and declarative updates. | 10 + | | ✓ | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`data-volt-*`) and declarative updates. | 11 11 | | ✓ | [Actions & Effects](#actions--effects) | Event system and derived reactivity primitives. | 12 12 | | ✓ | [Plugins Framework](#plugins-framework) | Modular plugin system and first built-in plugin set. | 13 13 | | | [Streaming & Patch Engine](#streaming--patch-engine) | SSE/WebSocket JSON patch streaming. | ··· 48 48 **Goal:** Add event-driven behavior and derived reactivity. 49 49 **Outcome:** Fully functional reactive UI layer with event bindings and computed updates. 50 50 **Deliverables:** 51 - - ✓ Event binding system (`dava-volt-on-*`) 51 + - ✓ Event binding system (`data-volt-on-*`) 52 52 - ✓ `$el` and `$event` scoped references 53 53 - ✓ Derived signals (`computed`, `effect`) 54 54 - ✓ Async effects (e.g., fetch triggers) ··· 61 61 - ✓ `registerPlugin(name, fn)` API 62 62 - ✓ Context and lifecycle hooks 63 63 - ✓ Built-ins: 64 - - ✓ `dava-volt-persist` 65 - - ✓ `dava-volt-scroll` 66 - - ✓ `dava-volt-url` 64 + - ✓ `data-volt-persist` 65 + - ✓ `data-volt-scroll` 66 + - ✓ `data-volt-url` 67 67 - ✓ Registry 68 68 69 + ### Backend Integration & HTTP Actions 70 + 71 + **Goal:** Provide backend integration with declarative HTTP requests and responses. 72 + **Outcome:** Volt.js can make backend requests and update the DOM 73 + **Deliverables:** 74 + - ✓ HTTP action system (`data-volt-get`, `data-volt-post`, `data-volt-put`, `data-volt-patch`, `data-volt-delete`) 75 + - ✓ Request configuration (`data-volt-trigger`, `data-volt-target`, `data-volt-swap`) 76 + - ✓ Swap strategies (innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none) 77 + - ✓ Loading states and indicators (`data-volt-indicator`) 78 + - ✓ Error handling and retry logic 79 + - ✓ Form serialization and submission 80 + - ✓ Request/response headers customization 81 + 69 82 ## To-Do 70 83 71 84 ### Markup Based Reactivity ··· 78 91 - ✓ Binding directives for text, attributes, classes, styles, and two-way form controls (`data-volt-[bind|text|model|class:*]`). 79 92 - ✓ Control-flow directives (`data-volt-for`, `data-volt-if`, `data-volt-else`) with lifecycle-safe teardown. 80 93 - ✓ Declarative event system (`data-volt-on:*`) with helper surface for list mutations and plugin hooks. 94 + - ✓ SSR compatibility helpers 81 95 - Sandboxed expression evaluator 82 - - SSR compatibility helpers 83 - 84 - ### Backend Integration & HTTP Actions 85 - 86 - **Goal:** Provide backend integration with declarative HTTP requests and responses. 87 - **Outcome:** Volt.js can make backend requests and update the DOM 88 - **Deliverables:** 89 - - HTTP action system (`data-volt-get`, `data-volt-post`, `data-volt-put`, `data-volt-patch`, `data-volt-delete`) 90 - - Request configuration (`data-volt-trigger`, `data-volt-target`, `data-volt-swap`) 91 - - Swap strategies (innerHTML, outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, none) 92 - - Loading states and indicators (`data-volt-indicator`) 93 - - Error handling and retry logic 94 - - See [svelte](https://svelte.dev/docs/svelte/await-expressions) for inspiration for loading & errors (`#await`) 95 - - Form serialization and submission 96 - - Request/response headers customization 97 96 98 97 ### Streaming & Patch Engine 99 98 ··· 131 130 **Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control. 132 131 **Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. 133 132 **Deliverables:** 134 - - `data-x-show` — toggles element visibility via CSS rather than DOM removal (complements `data-x-if`) 135 - - `data-x-style` — binds inline styles to reactive expressions 136 - - `data-x-skip` — marks elements or subtrees to exclude from Volt’s reactive parsing 137 - - `data-x-cloak` — hides content until the Volt runtime initializes 138 - - Event options for `data-x-on-*` attributes: 133 + - `data-volt-show` — toggles element visibility via CSS rather than DOM removal (complements `data-volt-if`) 134 + - `data-volt-style` — binds inline styles to reactive expressions 135 + - `data-volt-skip` — marks elements or subtrees to exclude from Volt’s reactive parsing 136 + - `data-volt-cloak` — hides content until the Volt runtime initializes 137 + - Event options for `data-volt-on-*` attributes: 139 138 - `.prevent` — calls `preventDefault()` on the event 140 139 - `.stop` — stops propagation 141 140 - `.self` — triggers only when the event target is the bound element ··· 145 144 - `.debounce` — defers handler execution (optional milliseconds) 146 145 - `.throttle` — limits handler frequency (optional milliseconds) 147 146 - `.passive` — adds a passive event listener for scroll/touch performance 148 - - Input options for `data-x-bind` and `data-x-model`: 147 + - Input options for `data-volt-bind` and `data-volt-model`: 149 148 - `.number` — coerces values to numbers 150 149 - `.trim` — removes surrounding whitespace 151 150 - `.lazy` — syncs only on `change` instead of `input` ··· 156 155 **Goal:** Implement store/context pattern 157 156 **Outcome:** Volt.js provides intuitive global state management 158 157 **Deliverables:** 159 - - `$refs` - Scoped element references via dava-volt-ref="name". Provides an object mapping ref names to DOM nodes. 160 - - Example: `dava-volt-on-click="$refs.username.focus()"` 158 + - `$refs` - Scoped element references via data-volt-ref="name". Provides an object mapping ref names to DOM nodes. 159 + - Example: `data-volt-on-click="$refs.username.focus()"` 161 160 - `$next()` - Defers execution to the next microtask tick after DOM updates. 162 - - Example: `dava-volt-on-click="$count++; $next(() => console.log('updated'))"` 161 + - Example: `data-volt-on-click="$count++; $next(() => console.log('updated'))"` 163 162 - `$watch(expr, fn)` - Imperatively observes a reactive signal or expression within the current scope. 164 - - Example: `dava-volt-init="$watch('count', v => console.log(v))"` 163 + - Example: `data-volt-init="$watch('count', v => console.log(v))"` 165 164 - `$emit(event, detail?)` - Dispatches a native CustomEvent from the current element. 166 - - Example: `dava-volt-on-click="$emit('user:save', { id })"` 165 + - Example: `data-volt-on-click="$emit('user:save', { id })"` 167 166 - `$store` - Accesses global reactive state registered with Volt’s global store. 168 - - Example: `dava-volt-text="$store.theme"` 167 + - Example: `data-volt-text="$store.theme"` 169 168 - `$uid(name?)` - Generates a unique, deterministic ID string within the current scope. 170 - - Example: `dava-volt-id="$uid('field')"` 169 + - Example: `data-volt-id="$uid('field')"` 171 170 - `$root` - Reference to the root element of the active reactive scope. 172 171 - `$scope` - Reference to the current reactive scope object (signals + context). 173 172 ··· 189 188 **Goal:** Enable declarative background data fetching and periodic updates within the Volt.js runtime. 190 189 **Outcome:** Volt.js elements can fetch or refresh data automatically based on time, visibility, or reactive conditions. 191 190 **Deliverables:** 192 - - `dava-volt-fetch` attribute for declarative background requests 191 + - `data-volt-fetch` attribute for declarative background requests 193 192 - Configurable polling intervals, delays, and signal-based triggers 194 - - `dava-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) 193 + - `data-volt-visible` for fetching when an element enters the viewport (`IntersectionObserver`) 195 194 - Background task scheduler with priority management 196 195 - Automatic cancellation of requests when elements are unmounted 197 196 - Conditional execution tied to reactive signals ··· 202 201 **Goal:** Introduce seamless client-side navigation and stateful history control using web standards. 203 202 **Outcome:** Volt.js provides enhanced navigation behavior with minimal overhead and full accessibility support. 204 203 **Deliverables:** 205 - - `dava-volt-navigate` for intercepting link and form actions 204 + - `data-volt-navigate` for intercepting link and form actions 206 205 - Integration with the History API (`pushState`, `replaceState`, `popState`) 207 206 - Reactive synchronization of route and signal state 208 207 - Smooth page and fragment transitions coordinated with Volt’s signal system 209 208 - Native back/forward button support 210 209 - Scroll position persistence and restoration 211 210 - Optional preloading of linked resources on hover or idle 212 - - `dava-volt-url` for declarative history updates 211 + - `data-volt-url` for declarative history updates 213 212 - Optional View Transition API integration for animated route changes 214 213 215 214 ### Inspector & Developer Tools
+178
docs/lifecycle.md
··· 1 + # Server-Side Rendering & Lifecycle 2 + 3 + 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. 4 + 5 + ## When to use SSR 6 + 7 + - Content-heavy pages that benefit from SEO 8 + - Applications requiring fast initial render 9 + - Progressive web apps with offline capabilities 10 + - When you need to support users with JavaScript disabled 11 + 12 + ## When to use client-side rendering (CSR) 13 + 14 + - Highly interactive single-page applications 15 + - Applications behind authentication (no SEO needed) 16 + - Rapid prototyping and development 17 + - When server-side rendering adds unnecessary complexity 18 + 19 + ## Concepts 20 + 21 + ### Server-Side: Rendering Initial HTML 22 + 23 + The server generates HTML with `data-volt` attributes and embedded state. Volt only requires: 24 + 25 + 1. HTML elements with `data-volt-*` attributes 26 + 2. A `<script>` tag containing serialized state as JSON 27 + 28 + ### Client-Side: Hydration 29 + 30 + Instead of re-rendering the DOM, Volt.js "hydrates" the existing server-rendered HTML by: 31 + 32 + 1. Reading the embedded state from the `<script>` tag 33 + 2. Recreating reactive signals from the serialized values 34 + 3. Attaching event listeners and bindings to existing DOM nodes 35 + 4. Preserving the existing DOM structure without modifications 36 + 37 + ## State Serialization 38 + 39 + ### Server-Side Pattern 40 + 41 + Embed initial state in a `<script>` tag with a specific ID pattern: 42 + 43 + ```html 44 + <div id="app" data-volt> 45 + <script type="application/json" id="volt-state-app"> 46 + {"count": 0, "username": "alice"} 47 + </script> 48 + 49 + <p data-volt-text="count">0</p> 50 + <p data-volt-text="username">alice</p> 51 + </div> 52 + ``` 53 + 54 + - Script tag must have `type="application/json"` 55 + - ID must follow pattern: `volt-state-{element-id}` 56 + - Root element must have an `id` attribute 57 + - State must be valid JSON 58 + 59 + ### Client-Side Deserialization 60 + 61 + Use the `hydrate()` function instead of `charge()` to hydrate all `[data-volt]` roots on the page. Volt will: 62 + 63 + 1. Find all elements matching the root selector (default: `[data-volt]`) 64 + 2. Check for embedded state in `<script>` tags 65 + 3. Deserialize JSON to reactive signals 66 + 4. Mount bindings without re-rendering 67 + 5. Mark elements as hydrated to prevent double-hydration 68 + 69 + ## Avoiding Flash of Unstyled Content (FOUC) 70 + 71 + ### CSS-Based Hiding 72 + 73 + Hide content until Volt.js hydrates: 74 + 75 + ```html 76 + <style> 77 + [data-volt]:not([data-volt-hydrated]) { 78 + visibility: hidden; 79 + } 80 + 81 + [data-volt][data-volt-hydrated] { 82 + visibility: visible; 83 + } 84 + </style> 85 + 86 + <div id="app" data-volt> 87 + <!-- Content is hidden until hydrated --> 88 + </div> 89 + ``` 90 + 91 + ### Strategy 2: Loading Indicator 92 + 93 + Show a loading state during hydration: 94 + 95 + ```html 96 + <style> 97 + .loading-overlay { 98 + position: fixed; 99 + inset: 0; 100 + background: white; 101 + display: flex; 102 + align-items: center; 103 + justify-content: center; 104 + } 105 + 106 + [data-volt-hydrated] ~ .loading-overlay { 107 + display: none; 108 + } 109 + </style> 110 + 111 + <div id="app" data-volt> 112 + <!-- App content --> 113 + </div> 114 + <div class="loading-overlay">Loading...</div> 115 + 116 + <script> 117 + document.addEventListener('DOMContentLoaded', () => { 118 + Volt.hydrate(); 119 + }); 120 + </script> 121 + ``` 122 + 123 + ### Progressive Enhancement 124 + 125 + Render fully functional HTML that works without JavaScript, then enhance with interactivity: 126 + 127 + ```html 128 + <!-- Form works without JavaScript --> 129 + <form id="contact" method="POST" action="/submit" data-volt> 130 + <script type="application/json" id="volt-state-contact"> 131 + {"submitted": false} 132 + </script> 133 + 134 + <input type="email" name="email" required> 135 + 136 + <!-- Enhanced with Volt.js for client-side validation --> 137 + <p data-volt-if="submitted" data-volt-text="'Thank you!'"></p> 138 + 139 + <button type="submit">Submit</button> 140 + </form> 141 + ``` 142 + 143 + Can you believe FOUC is an [actual](https://en.wikipedia.org/wiki/Flash_of_unstyled_content) acronym? 144 + 145 + ## Guidelines/Best Practices 146 + 147 + ### When to Use SSR vs CSR 148 + 149 + **Use SSR for:** 150 + 151 + - Any page requiring SEO 152 + 153 + **Use CSR for:** 154 + 155 + - Complex, interactive and/or real-time applications 156 + 157 + ### State Management 158 + 159 + **Do:** 160 + 161 + - Keep server-rendered state minimal (only essential data) 162 + - Use computed signals for derived values (don't serialize them) 163 + - Validate and sanitize state on the server 164 + - Use consistent data structures between server and client 165 + 166 + **Don't:** 167 + 168 + - Serialize functions or complex objects 169 + - Include sensitive data in client-side state 170 + - Serialize computed signals (they're recalculated on hydration) 171 + - Embed large datasets (fetch them after hydration instead) 172 + 173 + ### Security 174 + 175 + - Escape user-generated content in server-rendered HTML 176 + - Validate state data before serialization 177 + - Use Content Security Policy (CSP) headers 178 + - Sanitize JSON to prevent XSS attacks
+2 -1
lib/package.json
··· 9 9 "build:lib": "tsc && vite build --mode lib", 10 10 "preview": "vite preview", 11 11 "test": "vitest", 12 - "test:run": "vitest run" 12 + "test:run": "vitest run", 13 + "typecheck": "tsc --noEmit" 13 14 }, 14 15 "devDependencies": { 15 16 "@testing-library/dom": "^10.4.1",
+4 -3
lib/src/core/asyncEffect.ts
··· 2 2 * Async effect system with abort, race protection, debounce, throttle, and error handling 3 3 */ 4 4 5 + import type { Optional, Timer } from "$types/helpers"; 5 6 import type { AsyncEffectFunction, AsyncEffectOptions, ComputedSignal, Signal } from "$types/volt"; 6 7 7 8 /** ··· 52 53 const { abortable = false, debounce, throttle, onError, retries = 0, retryDelay = 0 } = options; 53 54 54 55 let cleanup: (() => void) | void; 55 - let abortController: AbortController | undefined; 56 + let abortController: Optional<AbortController>; 56 57 let executionId = 0; 57 - let debounceTimer: ReturnType<typeof setTimeout> | undefined; 58 - let throttleTimer: ReturnType<typeof setTimeout> | undefined; 58 + let debounceTimer: Optional<Timer>; 59 + let throttleTimer: Optional<Timer>; 59 60 let lastExecutionTime = 0; 60 61 let pendingExecution = false; 61 62 let retryCount = 0;
+39 -17
lib/src/core/binder.ts
··· 2 2 * Binder system for mounting and managing Volt.js bindings 3 3 */ 4 4 5 + import type { Optional } from "$types/helpers"; 5 6 import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "$types/volt"; 6 7 import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 7 8 import { evaluate, extractDependencies, isSignal } from "./evaluator"; 9 + import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; 8 10 import { executeGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 9 11 import { getPlugin } from "./plugin"; 10 12 ··· 108 110 } 109 111 case "for": { 110 112 bindFor(context, value); 113 + break; 114 + } 115 + case "get": { 116 + bindGet(context, value); 117 + break; 118 + } 119 + case "post": { 120 + bindPost(context, value); 121 + break; 122 + } 123 + case "put": { 124 + bindPut(context, value); 125 + break; 126 + } 127 + case "patch": { 128 + bindPatch(context, value); 129 + break; 130 + } 131 + case "delete": { 132 + bindDelete(context, value); 111 133 break; 112 134 } 113 135 default: { ··· 384 406 /** 385 407 * Find a signal in the scope by resolving a simple property path. 386 408 */ 387 - function findSignalInScope(scope: Scope, path: string): Signal<unknown> | undefined { 409 + function findSignalInScope(scope: Scope, path: string): Optional<Signal<unknown>> { 388 410 const trimmed = path.trim(); 389 411 const parts = trimmed.split("."); 390 412 let current: unknown = scope; ··· 491 513 * Supports data-volt-else on the next sibling element. 492 514 * Subscribes to condition signal and shows/hides elements when condition changes. 493 515 * 494 - * @param context - Binding context 495 - * @param expression - Expression to evaluate as condition 516 + * @param ctx - Binding context 517 + * @param expr - Expression to evaluate as condition 496 518 */ 497 - function bindIf(context: BindingContext, expression: string): void { 498 - const ifTemplate = context.element as HTMLElement; 519 + function bindIf(ctx: BindingContext, expr: string): void { 520 + const ifTemplate = ctx.element as HTMLElement; 499 521 const parent = ifTemplate.parentElement; 500 522 501 523 if (!parent) { ··· 503 525 return; 504 526 } 505 527 506 - let elseTemplate: HTMLElement | undefined; 528 + let elseTemplate: Optional<HTMLElement>; 507 529 let nextSibling = ifTemplate.nextElementSibling; 508 530 509 531 while (nextSibling && nextSibling.nodeType !== 1) { ··· 515 537 elseTemplate.remove(); 516 538 } 517 539 518 - const placeholder = document.createComment(`if: ${expression}`); 540 + const placeholder = document.createComment(`if: ${expr}`); 519 541 ifTemplate.before(placeholder); 520 542 ifTemplate.remove(); 521 543 522 - let currentElement: Element | undefined; 523 - let currentCleanup: CleanupFunction | undefined; 524 - let currentBranch: "if" | "else" | undefined; 544 + let currentElement: Optional<Element>; 545 + let currentCleanup: Optional<CleanupFunction>; 546 + let currentBranch: Optional<"if" | "else">; 525 547 526 548 const render = () => { 527 - const condition = evaluate(expression, context.scope); 549 + const condition = evaluate(expr, ctx.scope); 528 550 const shouldShow = Boolean(condition); 529 551 530 552 const targetBranch = shouldShow ? "if" : (elseTemplate ? "else" : undefined); ··· 545 567 if (targetBranch === "if") { 546 568 currentElement = ifTemplate.cloneNode(true) as Element; 547 569 delete (currentElement as HTMLElement).dataset.voltIf; 548 - currentCleanup = mount(currentElement, context.scope); 570 + currentCleanup = mount(currentElement, ctx.scope); 549 571 placeholder.before(currentElement); 550 572 currentBranch = "if"; 551 573 } else if (targetBranch === "else" && elseTemplate) { 552 574 currentElement = elseTemplate.cloneNode(true) as Element; 553 575 delete (currentElement as HTMLElement).dataset.voltElse; 554 - currentCleanup = mount(currentElement, context.scope); 576 + currentCleanup = mount(currentElement, ctx.scope); 555 577 placeholder.before(currentElement); 556 578 currentBranch = "else"; 557 579 } else { ··· 561 583 562 584 render(); 563 585 564 - const signal = findSignalInScope(context.scope, expression); 586 + const signal = findSignalInScope(ctx.scope, expr); 565 587 if (signal) { 566 588 const unsubscribe = signal.subscribe(render); 567 - context.cleanups.push(unsubscribe); 589 + ctx.cleanups.push(unsubscribe); 568 590 } 569 591 570 - context.cleanups.push(() => { 592 + ctx.cleanups.push(() => { 571 593 if (currentCleanup) { 572 594 currentCleanup(); 573 595 } ··· 579 601 * 580 602 * Supports: "item in items" or "(item, index) in items" 581 603 */ 582 - function parseForExpression(expr: string): { itemName: string; indexName?: string; arrayPath: string } | undefined { 604 + function parseForExpression(expr: string): Optional<{ itemName: string; indexName?: string; arrayPath: string }> { 583 605 const trimmed = expr.trim(); 584 606 585 607 const withIndex = /^\((\w+)\s*,\s*(\w+)\)\s+in\s+(.+)$/.exec(trimmed);
+769
lib/src/core/http.ts
··· 1 + /** 2 + * HTTP module for declarative backend integration 3 + * 4 + * Provides HTTP request/response handling with DOM swapping capabilities for server-rendered HTML fragments and JSON responses. 5 + */ 6 + 7 + import type { Optional } from "$types/helpers"; 8 + import type { 9 + BindingContext, 10 + HttpMethod, 11 + HttpResponse, 12 + ParsedHttpConfig, 13 + PluginContext, 14 + RequestConfig, 15 + RetryConfig, 16 + Scope, 17 + SwapStrategy, 18 + } from "$types/volt"; 19 + import { evaluate } from "./evaluator"; 20 + 21 + /** 22 + * Make an HTTP request and return the parsed response 23 + * 24 + * Handles both HTML and JSON responses based on Content-Type header. 25 + * Throws an error for network failures or status >= 400 26 + * 27 + * @param conf - Request configuration 28 + * @returns Promise resolving to HttpResponse 29 + */ 30 + export async function request(conf: RequestConfig): Promise<HttpResponse> { 31 + const { method, url, headers = {}, body } = conf; 32 + 33 + try { 34 + const response = await fetch(url, { method, headers: { ...headers }, body }); 35 + 36 + const contentType = response.headers.get("content-type") || ""; 37 + const isHTML = contentType.includes("text/html"); 38 + const isJSON = contentType.includes("application/json"); 39 + 40 + let html: Optional<string>; 41 + let json: Optional<unknown>; 42 + 43 + if (isHTML) { 44 + html = await response.text(); 45 + } else if (isJSON) { 46 + json = await response.json(); 47 + } else { 48 + html = await response.text(); 49 + } 50 + 51 + return { 52 + status: response.status, 53 + statusText: response.statusText, 54 + headers: response.headers, 55 + html, 56 + json, 57 + ok: response.ok, 58 + }; 59 + } catch (error) { 60 + throw new Error(`HTTP request failed: ${error instanceof Error ? error.message : String(error)}`); 61 + } 62 + } 63 + 64 + /** 65 + * Capture state that should be preserved during DOM swap 66 + * 67 + * @param root - Root element to capture state from 68 + * @returns Captured state object 69 + */ 70 + type CapturedState = { 71 + focusPath: number[] | null; 72 + scrollPositions: Map<number[], { top: number; left: number }>; 73 + inputValues: Map<number[], string | boolean>; 74 + }; 75 + 76 + function captureState(root: Element): CapturedState { 77 + const state: CapturedState = { focusPath: null, scrollPositions: new Map(), inputValues: new Map() }; 78 + 79 + const activeEl = document.activeElement; 80 + if (activeEl && root.contains(activeEl)) { 81 + state.focusPath = getElementPath(activeEl, root); 82 + } 83 + 84 + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); 85 + let currentNode: Node | null = walker.currentNode; 86 + 87 + while (currentNode) { 88 + const el = currentNode as Element; 89 + const path = getElementPath(el, root); 90 + 91 + if (el.scrollTop > 0 || el.scrollLeft > 0) { 92 + state.scrollPositions.set(path, { top: el.scrollTop, left: el.scrollLeft }); 93 + } 94 + 95 + if (el instanceof HTMLInputElement) { 96 + if (el.type === "checkbox" || el.type === "radio") { 97 + state.inputValues.set(path, el.checked); 98 + } else { 99 + state.inputValues.set(path, el.value); 100 + } 101 + } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { 102 + state.inputValues.set(path, el.value); 103 + } 104 + 105 + currentNode = walker.nextNode(); 106 + } 107 + 108 + return state; 109 + } 110 + 111 + /** 112 + * Get the path to an element from a root element 113 + * 114 + * Returns an array of child indices representing the path from root to element. 115 + * 116 + * @param element - Target element 117 + * @param root - Root element 118 + * @returns Array of indices, or empty array if element not found 119 + */ 120 + function getElementPath(element: Element, root: Element): number[] { 121 + const path: number[] = []; 122 + let current: Element | null = element; 123 + 124 + while (current && current !== root) { 125 + const parent: Element | null = current.parentElement; 126 + if (!parent) break; 127 + 128 + const index = Array.from(parent.children).indexOf(current); 129 + if (index === -1) break; 130 + 131 + path.unshift(index); 132 + current = parent; 133 + } 134 + 135 + return path; 136 + } 137 + 138 + /** 139 + * Get element by path from root 140 + * 141 + * @param path - Array of child indices 142 + * @param root - Root element 143 + * @returns Element at path, or null if not found 144 + */ 145 + function getElementByPath(path: number[], root: Element): Element | null { 146 + let current: Element = root; 147 + 148 + for (const index of path) { 149 + const children = Array.from(current.children); 150 + if (index >= children.length) return null; 151 + current = children[index]; 152 + } 153 + 154 + return current; 155 + } 156 + 157 + /** 158 + * Restore preserved state after DOM swap 159 + * 160 + * @param root - Root element to restore state to 161 + * @param state - Previously captured state 162 + */ 163 + function restoreState(root: Element, state: CapturedState): void { 164 + if (state.focusPath) { 165 + const element = getElementByPath(state.focusPath, root); 166 + if (element instanceof HTMLElement) { 167 + element.focus(); 168 + } 169 + } 170 + 171 + for (const [path, position] of state.scrollPositions) { 172 + const element = getElementByPath(path, root); 173 + if (element) { 174 + element.scrollTop = position.top; 175 + element.scrollLeft = position.left; 176 + } 177 + } 178 + 179 + for (const [path, value] of state.inputValues) { 180 + const element = getElementByPath(path, root); 181 + if (element instanceof HTMLInputElement) { 182 + if (element.type === "checkbox" || element.type === "radio") { 183 + element.checked = value as boolean; 184 + } else { 185 + element.value = value as string; 186 + } 187 + } else if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) { 188 + element.value = value as string; 189 + } 190 + } 191 + } 192 + 193 + /** 194 + * Apply a swap strategy to update the DOM with new content 195 + * 196 + * Preserves focus, scroll position, and input state when using innerHTML or outerHTML strategies. 197 + * 198 + * @param target - Target element to update 199 + * @param content - HTML content to insert 200 + * @param strategy - Swap strategy to use 201 + */ 202 + export function swap(target: Element, content: string, strategy: SwapStrategy = "innerHTML"): void { 203 + const shouldPreserveState = strategy === "innerHTML" || strategy === "outerHTML"; 204 + const state = shouldPreserveState ? captureState(target) : null; 205 + 206 + switch (strategy) { 207 + case "innerHTML": { 208 + target.innerHTML = content; 209 + if (state) restoreState(target, state); 210 + break; 211 + } 212 + case "outerHTML": { 213 + const parent = target.parentElement; 214 + const nextSibling = target.nextElementSibling; 215 + target.outerHTML = content; 216 + 217 + if (state && parent) { 218 + const newElement = nextSibling ? nextSibling.previousElementSibling : parent.lastElementChild; 219 + if (newElement) restoreState(newElement, state); 220 + } 221 + break; 222 + } 223 + case "beforebegin": { 224 + target.insertAdjacentHTML("beforebegin", content); 225 + break; 226 + } 227 + case "afterbegin": { 228 + target.insertAdjacentHTML("afterbegin", content); 229 + break; 230 + } 231 + case "beforeend": { 232 + target.insertAdjacentHTML("beforeend", content); 233 + break; 234 + } 235 + case "afterend": { 236 + target.insertAdjacentHTML("afterend", content); 237 + break; 238 + } 239 + case "delete": { 240 + target.remove(); 241 + break; 242 + } 243 + case "none": { 244 + break; 245 + } 246 + default: { 247 + console.error(`Unknown swap strategy: ${strategy as string}`); 248 + } 249 + } 250 + } 251 + 252 + /** 253 + * Serialize a form element to FormData 254 + * 255 + * @param form - Form element to serialize 256 + * @returns FormData object containing form fields 257 + */ 258 + export function serializeForm(form: HTMLFormElement): FormData { 259 + return new FormData(form); 260 + } 261 + 262 + /** 263 + * Serialize a form element to JSON 264 + * 265 + * @param form - Form element to serialize 266 + * @returns JSON object containing form fields 267 + */ 268 + export function serializeFormToJSON(form: HTMLFormElement): Record<string, unknown> { 269 + const formData = new FormData(form); 270 + const object: Record<string, unknown> = {}; 271 + 272 + for (const [key, value] of formData.entries()) { 273 + if (Object.hasOwn(object, key)) { 274 + if (!Array.isArray(object[key])) { 275 + object[key] = [object[key]]; 276 + } 277 + (object[key] as unknown[]).push(value); 278 + } else { 279 + object[key] = value; 280 + } 281 + } 282 + 283 + return object; 284 + } 285 + 286 + /** 287 + * Parse HTTP configuration from element attributes 288 + * 289 + * Reads data-volt-trigger, data-volt-target, data-volt-swap, data-volt-headers, 290 + * data-volt-retry, data-volt-retry-delay, and data-volt-indicator from the 291 + * element's dataset and returns parsed configuration. 292 + * 293 + * @param el - Element to parse configuration from 294 + * @param scope - Scope for evaluating expressions 295 + * @returns Parsed HTTP configuration with defaults 296 + */ 297 + export function parseHttpConfig(el: Element, scope: Scope): ParsedHttpConfig { 298 + const dataset = (el as HTMLElement).dataset; 299 + 300 + const trigger = dataset.voltTrigger || getDefaultTrigger(el); 301 + 302 + let target: string | Element = el; 303 + if (dataset.voltTarget) { 304 + const trimmed = dataset.voltTarget.trim(); 305 + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { 306 + target = trimmed.slice(1, -1); 307 + } else { 308 + const targetValue = evaluate(dataset.voltTarget, scope); 309 + if (typeof targetValue === "string") { 310 + target = targetValue; 311 + } else if (targetValue instanceof Element) { 312 + target = targetValue; 313 + } 314 + } 315 + } 316 + 317 + const swap = (dataset.voltSwap as SwapStrategy) || "innerHTML"; 318 + 319 + let headers: Record<string, string> = {}; 320 + if (dataset.voltHeaders) { 321 + try { 322 + const headersValue = evaluate(dataset.voltHeaders, scope); 323 + if (typeof headersValue === "object" && headersValue !== null) { 324 + headers = headersValue as Record<string, string>; 325 + } 326 + } catch (error) { 327 + console.error("Failed to parse data-volt-headers:", error); 328 + } 329 + } 330 + 331 + let retry: Optional<RetryConfig>; 332 + if (dataset.voltRetry) { 333 + const maxAttempts = Number.parseInt(dataset.voltRetry, 10); 334 + const initialDelay = dataset.voltRetryDelay ? Number.parseInt(dataset.voltRetryDelay, 10) : 1000; 335 + 336 + if (!Number.isNaN(maxAttempts) && maxAttempts > 0) { 337 + retry = { maxAttempts, initialDelay }; 338 + } 339 + } 340 + 341 + const indicator = dataset.voltIndicator; 342 + 343 + return { trigger, target, swap, headers, retry, indicator }; 344 + } 345 + 346 + /** 347 + * Get the default trigger event for an element 348 + * 349 + * - Forms: submit 350 + * - Buttons/links: click 351 + * - Everything else: click 352 + * 353 + * @param element - Element to get default trigger for 354 + * @returns Default event name 355 + */ 356 + function getDefaultTrigger(element: Element): string { 357 + if (element instanceof HTMLFormElement) { 358 + return "submit"; 359 + } 360 + return "click"; 361 + } 362 + 363 + /** 364 + * Set loading state on an element 365 + * 366 + * Sets data-volt-loading="true" attribute to indicate ongoing request. 367 + * Shows indicator if data-volt-indicator is set. 368 + * 369 + * @param element - Element to mark as loading 370 + * @param indicator - Optional indicator selector 371 + */ 372 + export function setLoadingState(element: Element, indicator?: string): void { 373 + element.setAttribute("data-volt-loading", "true"); 374 + 375 + if (indicator) { 376 + showIndicator(indicator); 377 + } 378 + 379 + element.dispatchEvent(new CustomEvent("volt:loading", { detail: { element }, bubbles: true, cancelable: false })); 380 + } 381 + 382 + /** 383 + * Set error state on an element 384 + * 385 + * Sets data-volt-error attribute with error message. 386 + * Hides indicator if data-volt-indicator is set. 387 + * 388 + * @param element - Element to mark as errored 389 + * @param message - Error message 390 + * @param indicator - Optional indicator selector 391 + */ 392 + export function setErrorState(element: Element, message: string, indicator?: string): void { 393 + element.setAttribute("data-volt-error", message); 394 + 395 + if (indicator) { 396 + hideIndicator(indicator); 397 + } 398 + 399 + element.dispatchEvent( 400 + new CustomEvent("volt:error", { detail: { element, message }, bubbles: true, cancelable: false }), 401 + ); 402 + } 403 + 404 + /** 405 + * Clear loading and error states from an element 406 + * 407 + * Removes data-volt-loading, data-volt-error, and data-volt-retry-attempt attributes. 408 + * Hides indicator if data-volt-indicator is set. 409 + * 410 + * @param element - Element to clear states from 411 + * @param indicator - Optional indicator selector 412 + */ 413 + export function clearStates(element: Element, indicator?: string): void { 414 + element.removeAttribute("data-volt-loading"); 415 + element.removeAttribute("data-volt-error"); 416 + element.removeAttribute("data-volt-retry-attempt"); 417 + 418 + if (indicator) { 419 + hideIndicator(indicator); 420 + } 421 + 422 + element.dispatchEvent(new CustomEvent("volt:success", { detail: { element }, bubbles: true, cancelable: false })); 423 + } 424 + 425 + /** 426 + * Visibility strategy for showing/hiding indicator elements 427 + */ 428 + type IndicatorStrategy = "display" | "class"; 429 + 430 + /** 431 + * Cache for storing indicator visibility strategies 432 + */ 433 + const indicatorStrategies = new WeakMap<Element, IndicatorStrategy>(); 434 + 435 + /** 436 + * Detect the appropriate visibility strategy for an indicator element 437 + * 438 + * - If element has display: none (inline or computed), use display toggling 439 + * - If element has a class containing "hidden", use class toggling 440 + * - Otherwise, default to class toggling 441 + * 442 + * @param element - Indicator element 443 + * @returns Visibility strategy to use 444 + */ 445 + function detectIndicatorStrategy(element: Element): IndicatorStrategy { 446 + if (indicatorStrategies.has(element)) { 447 + return indicatorStrategies.get(element)!; 448 + } 449 + 450 + const htmlElement = element as HTMLElement; 451 + const inlineDisplay = htmlElement.style.display; 452 + const computedDisplay = window.getComputedStyle(htmlElement).display; 453 + 454 + if (inlineDisplay === "none" || computedDisplay === "none") { 455 + indicatorStrategies.set(element, "display"); 456 + return "display"; 457 + } 458 + 459 + const hasHiddenClass = Array.from(element.classList).some((cls) => cls.toLowerCase().includes("hidden")); 460 + if (hasHiddenClass) { 461 + indicatorStrategies.set(element, "class"); 462 + return "class"; 463 + } 464 + 465 + indicatorStrategies.set(element, "class"); 466 + return "class"; 467 + } 468 + 469 + /** 470 + * Show an indicator element using the appropriate visibility strategy 471 + * 472 + * @param element - Indicator element to show 473 + */ 474 + function showIndicatorElement(element: Element): void { 475 + const strategy = detectIndicatorStrategy(element); 476 + const htmlElement = element as HTMLElement; 477 + 478 + if (strategy === "display") { 479 + htmlElement.style.display = ""; 480 + } else { 481 + const hiddenClass = Array.from(element.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; 482 + element.classList.remove(hiddenClass); 483 + } 484 + } 485 + 486 + /** 487 + * Hide an indicator element using the appropriate visibility strategy 488 + * 489 + * @param element - Indicator element to hide 490 + */ 491 + function hideIndicatorElement(element: Element): void { 492 + const strategy = detectIndicatorStrategy(element); 493 + const htmlElement = element as HTMLElement; 494 + 495 + if (strategy === "display") { 496 + htmlElement.style.display = "none"; 497 + } else { 498 + const hiddenClass = Array.from(element.classList).find((cls) => cls.toLowerCase().includes("hidden")) || "hidden"; 499 + element.classList.add(hiddenClass); 500 + } 501 + } 502 + 503 + /** 504 + * Show loading indicator(s) specified by selector 505 + * 506 + * @param selector - CSS selector for indicator element(s) 507 + */ 508 + export function showIndicator(selector: string): void { 509 + const indicators = document.querySelectorAll(selector); 510 + for (const indicator of indicators) { 511 + showIndicatorElement(indicator); 512 + } 513 + } 514 + 515 + /** 516 + * Hide loading indicator(s) specified by selector 517 + * 518 + * @param selector - CSS selector for indicator element(s) 519 + */ 520 + export function hideIndicator(selector: string): void { 521 + const indicators = document.querySelectorAll(selector); 522 + for (const indicator of indicators) { 523 + hideIndicatorElement(indicator); 524 + } 525 + } 526 + 527 + /** 528 + * Resolve target element from configuration 529 + * 530 + * @param targetConf - Target selector or element 531 + * @param defaultEl - Default element if target is "this" or undefined 532 + * @returns Resolved target element or undefined if not found 533 + */ 534 + function resolveTarget(targetConf: string | Element, defaultEl: Element): Optional<Element> { 535 + if (targetConf instanceof Element) { 536 + return targetConf; 537 + } 538 + 539 + if (targetConf === "this" || targetConf === "") { 540 + return defaultEl; 541 + } 542 + 543 + const target = document.querySelector(targetConf); 544 + if (!target) { 545 + console.warn(`Target element not found: ${targetConf}`); 546 + return undefined; 547 + } 548 + 549 + return target; 550 + } 551 + 552 + /** 553 + * Error type classification for retry logic 554 + */ 555 + type ErrorType = "network" | "server" | "client" | "other"; 556 + 557 + /** 558 + * Classify an error for retry decision 559 + * 560 + * @param error - Error or HTTP status code 561 + * @returns Error type classification 562 + */ 563 + function classifyError(error: unknown): ErrorType { 564 + if (error instanceof Error && error.message.includes("HTTP")) { 565 + const match = error.message.match(/HTTP (\d+):/); 566 + if (match) { 567 + const status = Number.parseInt(match[1], 10); 568 + if (status >= 500 && status < 600) return "server"; 569 + if (status >= 400 && status < 500) return "client"; 570 + } 571 + } 572 + 573 + if (error instanceof Error && (error.message.includes("fetch") || error.message.includes("network"))) { 574 + return "network"; 575 + } 576 + 577 + return "other"; 578 + } 579 + 580 + /** 581 + * Determine if an error should be retried based on smart retry logic 582 + * 583 + * - Network errors: Always retry 584 + * - 5xx server errors: Always retry 585 + * - 4xx client errors: Never retry 586 + * - Other errors: Never retry 587 + * 588 + * @param error - Error to check 589 + * @returns True if error should be retried 590 + */ 591 + function shouldRetry(error: unknown): boolean { 592 + const errorType = classifyError(error); 593 + return errorType === "network" || errorType === "server"; 594 + } 595 + 596 + /** 597 + * Calculate retry delay based on error type and attempt number 598 + * 599 + * - Network errors: No delay (immediate retry) 600 + * - Server errors: Exponential backoff (initialDelay × 2^attempt) 601 + * - Other errors: No retry 602 + * 603 + * @param error - Error that occurred 604 + * @param attempt - Current attempt number (0-indexed) 605 + * @param initialDelay - Initial delay in milliseconds 606 + * @returns Delay in milliseconds before next retry 607 + */ 608 + function calculateRetryDelay(error: unknown, attempt: number, initialDelay: number): number { 609 + const errorType = classifyError(error); 610 + 611 + if (errorType === "network") { 612 + return 0; 613 + } 614 + 615 + if (errorType === "server") { 616 + return initialDelay * 2 ** attempt; 617 + } 618 + 619 + return 0; 620 + } 621 + 622 + /** 623 + * Sleep for a specified duration 624 + * 625 + * @param ms - Milliseconds to sleep 626 + */ 627 + function sleep(ms: number): Promise<void> { 628 + return new Promise((resolve) => setTimeout(resolve, ms)); 629 + } 630 + 631 + /** 632 + * Perform an HTTP request with configuration from element attributes 633 + * 634 + * Handles the full request lifecycle: loading state, request, swap, error handling, and smart retry. 635 + * 636 + * @param el - Element that triggered the request 637 + * @param method - HTTP method 638 + * @param url - Request URL 639 + * @param conf - Parsed HTTP configuration 640 + * @param body - Optional request body 641 + */ 642 + async function performRequest( 643 + el: Element, 644 + method: HttpMethod, 645 + url: string, 646 + conf: ParsedHttpConfig, 647 + body?: string | FormData, 648 + ): Promise<void> { 649 + const target = resolveTarget(conf.target, el); 650 + if (!target) { 651 + return; 652 + } 653 + 654 + setLoadingState(target, conf.indicator); 655 + 656 + let lastError: unknown; 657 + const maxAttempts = conf.retry ? conf.retry.maxAttempts + 1 : 1; 658 + const initialDelay = conf.retry?.initialDelay ?? 1000; 659 + 660 + for (let attempt = 0; attempt < maxAttempts; attempt++) { 661 + try { 662 + if (attempt > 0) { 663 + target.setAttribute("data-volt-retry-attempt", String(attempt)); 664 + target.setAttribute("data-volt-loading", "retrying"); 665 + target.dispatchEvent( 666 + new CustomEvent("volt:retry", { detail: { element: target, attempt }, bubbles: true, cancelable: false }), 667 + ); 668 + } 669 + 670 + const response = await request({ method, url, headers: conf.headers, body }); 671 + 672 + if (!response.ok) { 673 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 674 + } 675 + 676 + clearStates(target, conf.indicator); 677 + 678 + if (response.html !== undefined) { 679 + swap(target, response.html, conf.swap); 680 + } else if (response.json !== undefined) { 681 + console.warn("JSON responses are not yet integrated with signal updates. HTML response expected."); 682 + } 683 + 684 + return; 685 + } catch (error) { 686 + lastError = error; 687 + 688 + const isLastAttempt = attempt === maxAttempts - 1; 689 + const canRetry = conf.retry && shouldRetry(error); 690 + 691 + if (isLastAttempt || !canRetry) { 692 + break; 693 + } 694 + 695 + const delay = calculateRetryDelay(error, attempt, initialDelay); 696 + if (delay > 0) { 697 + await sleep(delay); 698 + } 699 + } 700 + } 701 + 702 + const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); 703 + setErrorState(target, errorMessage, conf.indicator); 704 + console.error("HTTP request failed:", lastError); 705 + } 706 + 707 + export function bindGet(context: BindingContext, url: string): void { 708 + bindHttpMethod(context, "GET", url); 709 + } 710 + 711 + export function bindPost(context: BindingContext, url: string): void { 712 + bindHttpMethod(context, "POST", url); 713 + } 714 + 715 + export function bindPut(context: BindingContext, url: string): void { 716 + bindHttpMethod(context, "PUT", url); 717 + } 718 + 719 + export function bindPatch(context: BindingContext, url: string): void { 720 + bindHttpMethod(context, "PATCH", url); 721 + } 722 + 723 + export function bindDelete(context: BindingContext, url: string): void { 724 + bindHttpMethod(context, "DELETE", url); 725 + } 726 + 727 + /** 728 + * Generic HTTP method binding handler 729 + * 730 + * Attaches an event listener that triggers an HTTP request when fired. 731 + * Automatically serializes forms for POST/PUT/PATCH methods. 732 + * 733 + * @param ctx - Plugin context 734 + * @param method - HTTP method 735 + * @param url - URL expression to evaluate 736 + */ 737 + function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, url: string): void { 738 + const config = parseHttpConfig(ctx.element, ctx.scope); 739 + const urlValue = evaluate(url, ctx.scope); 740 + const resolvedUrl = String(urlValue); 741 + 742 + const handler = async (event: Event) => { 743 + if (config.trigger === "submit" || ctx.element instanceof HTMLFormElement) { 744 + event.preventDefault(); 745 + } 746 + 747 + let body: Optional<string | FormData>; 748 + 749 + if (method !== "GET" && method !== "DELETE") { 750 + if (ctx.element instanceof HTMLFormElement) { 751 + body = serializeForm(ctx.element); 752 + } 753 + } 754 + 755 + await performRequest(ctx.element, method, resolvedUrl, config, body); 756 + }; 757 + 758 + ctx.element.addEventListener(config.trigger, handler); 759 + 760 + const cleanup = () => { 761 + ctx.element.removeEventListener(config.trigger, handler); 762 + }; 763 + 764 + if ("addCleanup" in ctx) { 765 + ctx.addCleanup(cleanup); 766 + } else { 767 + ctx.cleanups.push(cleanup); 768 + } 769 + }
+2 -1
lib/src/core/plugin.ts
··· 2 2 * Plugin system for extending Volt.js with custom bindings 3 3 */ 4 4 5 + import type { Optional } from "$types/helpers"; 5 6 import type { PluginHandler } from "$types/volt"; 6 7 7 8 const pluginRegistry = new Map<string, PluginHandler>(); ··· 40 41 * @param name - Plugin name 41 42 * @returns Plugin handler function or undefined 42 43 */ 43 - export function getPlugin(name: string): PluginHandler | undefined { 44 + export function getPlugin(name: string): Optional<PluginHandler> { 44 45 return pluginRegistry.get(name); 45 46 } 46 47
+282
lib/src/core/ssr.ts
··· 1 + /** 2 + * Server-Side Rendering (SSR) and hydration support for Volt.js 3 + * 4 + * Provides utilities for serializing scope state, embedding it in HTML, 5 + * and hydrating client-side without re-rendering. 6 + */ 7 + 8 + import type { HydrateOptions, HydrateResult, Scope, SerializedScope } from "$types/volt"; 9 + import { mount } from "./binder"; 10 + import { evaluate, extractDependencies } from "./evaluator"; 11 + import { computed, signal } from "./signal"; 12 + 13 + /** 14 + * Serialize a scope object to JSON for embedding in server-rendered HTML 15 + * 16 + * Converts all signals in the scope to their plain values. 17 + * Computed signals are serialized as their current values. 18 + * 19 + * @param scope - The reactive scope to serialize 20 + * @returns JSON string representing the scope state 21 + * 22 + * @example 23 + * ```ts 24 + * const scope = { 25 + * count: signal(0), 26 + * double: computed(() => scope.count.get() * 2, [scope.count]) 27 + * }; 28 + * const json = serializeScope(scope); 29 + * // Returns: '{"count":0,"double":0}' 30 + * ``` 31 + */ 32 + export function serializeScope(scope: Scope): string { 33 + const serialized: SerializedScope = {}; 34 + 35 + for (const [key, value] of Object.entries(scope)) { 36 + if (isSignal(value)) { 37 + serialized[key] = value.get(); 38 + } else { 39 + serialized[key] = value; 40 + } 41 + } 42 + 43 + return JSON.stringify(serialized); 44 + } 45 + 46 + /** 47 + * Deserialize JSON state data back into a reactive scope 48 + * 49 + * Recreates signals from plain values. Does not recreate computed signals 50 + * (those should be redefined with data-volt-computed attributes). 51 + * 52 + * @param data - Plain object with state values 53 + * @returns Reactive scope with signals 54 + * 55 + * @example 56 + * ```ts 57 + * const scope = deserializeScope({ count: 42, name: "Alice" }); 58 + * // Returns: { count: signal(42), name: signal("Alice") } 59 + * ``` 60 + */ 61 + export function deserializeScope(data: SerializedScope): Scope { 62 + const scope: Scope = {}; 63 + 64 + for (const [key, value] of Object.entries(data)) { 65 + scope[key] = signal(value); 66 + } 67 + 68 + return scope; 69 + } 70 + 71 + /** 72 + * Check if an element has already been hydrated 73 + * 74 + * @param element - Element to check 75 + * @returns True if element is marked as hydrated 76 + */ 77 + export function isHydrated(element: Element): boolean { 78 + return element.hasAttribute("data-volt-hydrated"); 79 + } 80 + 81 + /** 82 + * Mark an element as hydrated to prevent double-hydration 83 + * 84 + * @param element - Element to mark 85 + */ 86 + function markHydrated(element: Element): void { 87 + element.setAttribute("data-volt-hydrated", "true"); 88 + } 89 + 90 + /** 91 + * Check if an element has server-rendered state embedded 92 + * 93 + * Looks for a script tag with id="volt-state-{element-id}" containing JSON state. 94 + * 95 + * @param element - Element to check 96 + * @returns True if serialized state is found 97 + */ 98 + export function isServerRendered(element: Element): boolean { 99 + return getSerializedState(element) !== null; 100 + } 101 + 102 + /** 103 + * Extract serialized state from a server-rendered element 104 + * 105 + * Searches for a `<script type="application/json" id="volt-state-{id}">` tag 106 + * containing the serialized scope data. 107 + * 108 + * @param element - Root element to extract state from 109 + * @returns Parsed state object, or null if not found 110 + * 111 + * @example 112 + * ```html 113 + * <div id="app" data-volt> 114 + * <script type="application/json" id="volt-state-app"> 115 + * {"count": 42} 116 + * </script> 117 + * </div> 118 + * ``` 119 + */ 120 + export function getSerializedState(element: Element): SerializedScope | null { 121 + const elementId = element.id; 122 + if (!elementId) { 123 + return null; 124 + } 125 + 126 + const scriptId = `volt-state-${elementId}`; 127 + const scriptTag = element.querySelector(`script[type="application/json"]#${scriptId}`); 128 + 129 + if (!scriptTag || !scriptTag.textContent) { 130 + return null; 131 + } 132 + 133 + try { 134 + return JSON.parse(scriptTag.textContent) as SerializedScope; 135 + } catch (error) { 136 + console.error(`Failed to parse serialized state from #${scriptId}:`, error); 137 + return null; 138 + } 139 + } 140 + 141 + /** 142 + * Create a reactive scope from element, preferring server-rendered state 143 + * 144 + * This is similar to createScopeFromElement in charge.ts, but checks for 145 + * server-rendered state first before falling back to data-volt-state. 146 + * 147 + * @param element - The root element 148 + * @returns Reactive scope object with signals 149 + */ 150 + function createHydrationScope(element: Element): Scope { 151 + let scope: Scope = {}; 152 + 153 + const serializedState = getSerializedState(element); 154 + if (serializedState) { 155 + scope = deserializeScope(serializedState); 156 + } else { 157 + const stateAttr = (element as HTMLElement).dataset.voltState; 158 + if (stateAttr) { 159 + try { 160 + const stateData = JSON.parse(stateAttr); 161 + 162 + if (typeof stateData !== "object" || stateData === null || Array.isArray(stateData)) { 163 + console.error(`data-volt-state must be a JSON object, got ${typeof stateData}:`, element); 164 + } else { 165 + for (const [key, value] of Object.entries(stateData)) { 166 + scope[key] = signal(value); 167 + } 168 + } 169 + } catch (error) { 170 + console.error("Failed to parse data-volt-state JSON:", stateAttr, error); 171 + console.error("Element:", element); 172 + } 173 + } 174 + } 175 + 176 + const computedAttrs = getComputedAttributes(element); 177 + for (const [name, expression] of computedAttrs) { 178 + try { 179 + const dependencies = extractDependencies(expression, scope); 180 + scope[name] = computed(() => evaluate(expression, scope), dependencies); 181 + } catch (error) { 182 + console.error(`Failed to create computed "${name}" with expression "${expression}":`, error); 183 + } 184 + } 185 + 186 + return scope; 187 + } 188 + 189 + /** 190 + * Get all data-volt-computed:name attributes from an element 191 + * 192 + * Converts kebab-case names to camelCase to match JS conventions. 193 + */ 194 + function getComputedAttributes(element: Element): Map<string, string> { 195 + const computedMap = new Map<string, string>(); 196 + 197 + for (const attr of element.attributes) { 198 + if (attr.name.startsWith("data-volt-computed:")) { 199 + const name = attr.name.slice("data-volt-computed:".length); 200 + const camelName = kebabToCamel(name); 201 + computedMap.set(camelName, attr.value); 202 + } 203 + } 204 + 205 + return computedMap; 206 + } 207 + 208 + function kebabToCamel(str: string): string { 209 + return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase()); 210 + } 211 + 212 + /** 213 + * Check if a value is a signal-like object 214 + */ 215 + function isSignal(value: unknown): value is { get: () => unknown } { 216 + return (typeof value === "object" 217 + && value !== null 218 + && "get" in value 219 + && typeof (value as { get: unknown }).get === "function"); 220 + } 221 + 222 + /** 223 + * Hydrate server-rendered Volt roots without re-rendering 224 + * 225 + * Similar to charge(), but designed for server-rendered content. 226 + * Preserves existing DOM structure and only attaches reactive bindings. 227 + * 228 + * @param options - Hydration options 229 + * @returns HydrateResult containing mounted roots and cleanup function 230 + * 231 + * @example 232 + * Server renders: 233 + * ```html 234 + * <div id="app" data-volt> 235 + * <script type="application/json" id="volt-state-app"> 236 + * {"count": 0} 237 + * </script> 238 + * <p data-volt-text="count">0</p> 239 + * </div> 240 + * ``` 241 + * 242 + * Client hydrates: 243 + * ```ts 244 + * const { cleanup } = hydrate(); 245 + * // DOM is preserved, bindings are attached 246 + * ``` 247 + */ 248 + export function hydrate(options: HydrateOptions = {}): HydrateResult { 249 + const { rootSelector = "[data-volt]", skipHydrated = true } = options; 250 + 251 + const elements = document.querySelectorAll(rootSelector); 252 + const chargedRoots: Array<{ element: Element; scope: Scope; cleanup: () => void }> = []; 253 + 254 + for (const element of elements) { 255 + if (skipHydrated && isHydrated(element)) { 256 + continue; 257 + } 258 + 259 + try { 260 + const scope = createHydrationScope(element); 261 + const cleanup = mount(element, scope); 262 + 263 + markHydrated(element); 264 + chargedRoots.push({ element, scope, cleanup }); 265 + } catch (error) { 266 + console.error("Error hydrating Volt root:", element, error); 267 + } 268 + } 269 + 270 + return { 271 + roots: chargedRoots, 272 + cleanup: () => { 273 + for (const root of chargedRoots) { 274 + try { 275 + root.cleanup(); 276 + } catch (error) { 277 + console.error("Error cleaning up hydrated Volt root:", root.element, error); 278 + } 279 + } 280 + }, 281 + }; 282 + }
+22 -15
lib/src/index.ts
··· 4 4 * @packageDocumentation 5 5 */ 6 6 7 + export { asyncEffect } from "$core/asyncEffect"; 8 + export { mount } from "$core/binder"; 9 + export { charge } from "$core/charge"; 10 + export { parseHttpConfig, request, serializeForm, serializeFormToJSON, swap } from "$core/http"; 11 + export { 12 + clearAllGlobalHooks, 13 + clearGlobalHooks, 14 + getElementBindings, 15 + isElementMounted, 16 + registerElementHook, 17 + registerGlobalHook, 18 + unregisterGlobalHook, 19 + } from "$core/lifecycle"; 20 + export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin"; 21 + export { computed, effect, signal } from "$core/signal"; 22 + export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; 23 + export { persistPlugin, registerStorageAdapter, scrollPlugin, urlPlugin } from "$plugins"; 7 24 export type { 8 25 AsyncEffectFunction, 9 26 AsyncEffectOptions, ··· 11 28 ChargeResult, 12 29 ComputedSignal, 13 30 GlobalHookName, 31 + HydrateOptions, 32 + HydrateResult, 33 + ParsedHttpConfig, 14 34 PluginContext, 15 35 PluginHandler, 36 + RetryConfig, 37 + SerializedScope, 16 38 Signal, 17 39 } from "$types/volt"; 18 - export { asyncEffect } from "@volt/core/asyncEffect"; 19 - export { mount } from "@volt/core/binder"; 20 - export { charge } from "@volt/core/charge"; 21 - export { 22 - clearAllGlobalHooks, 23 - clearGlobalHooks, 24 - getElementBindings, 25 - isElementMounted, 26 - registerElementHook, 27 - registerGlobalHook, 28 - unregisterGlobalHook, 29 - } from "@volt/core/lifecycle"; 30 - export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin"; 31 - export { computed, effect, signal } from "@volt/core/signal"; 32 - export { persistPlugin, registerStorageAdapter, scrollPlugin, urlPlugin } from "@volt/plugins";
+2 -2
lib/src/main.ts
··· 1 - import { computed, effect, mount, registerPlugin, signal } from "@volt"; 2 - import { persistPlugin, scrollPlugin, urlPlugin } from "@volt/plugins"; 1 + import { persistPlugin, scrollPlugin, urlPlugin } from "$plugins"; 2 + import { computed, effect, mount, registerPlugin, signal } from "$volt"; 3 3 4 4 registerPlugin("persist", persistPlugin); 5 5 registerPlugin("scroll", scrollPlugin);
+3 -2
lib/src/plugins/persist.ts
··· 4 4 * Supports localStorage, sessionStorage, IndexedDB, and custom adapters 5 5 */ 6 6 7 + import type { Optional } from "$types/helpers"; 7 8 import type { PluginContext, Signal, StorageAdapter } from "$types/volt"; 8 9 9 10 /** ··· 108 109 /** 109 110 * Open or create the IndexedDB database 110 111 */ 111 - let dbPromise: Promise<IDBDatabase> | undefined; 112 + let dbPromise: Optional<Promise<IDBDatabase>>; 112 113 function openDB(): Promise<IDBDatabase> { 113 114 if (dbPromise) return dbPromise; 114 115 ··· 137 138 /** 138 139 * Get storage adapter by name 139 140 */ 140 - function getStorageAdapter(type: string): StorageAdapter | undefined { 141 + function getStorageAdapter(type: string): Optional<StorageAdapter> { 141 142 switch (type) { 142 143 case "local": { 143 144 return localStorageAdapter;
+2 -1
lib/src/plugins/url.ts
··· 3 3 * Supports one-way read, bidirectional sync, and hash-based routing 4 4 */ 5 5 6 + import type { Optional } from "$types/helpers"; 6 7 import type { PluginContext, Signal } from "$types/volt"; 7 8 8 9 /** ··· 80 81 } 81 82 82 83 let isUpdatingFromUrl = false; 83 - let updateTimeout: number | undefined; 84 + let updateTimeout: Optional<number>; 84 85 85 86 const updateUrl = (value: unknown) => { 86 87 if (isUpdatingFromUrl) return;
+5
lib/src/types/helpers.ts
··· 1 + export type Optional<T> = T | undefined; 2 + 3 + export type Nullable<T> = T | null; 4 + 5 + export type Timer = ReturnType<typeof setTimeout>;
+78 -7
lib/src/types/volt.d.ts
··· 171 171 */ 172 172 export type AsyncEffectFunction = (signal?: AbortSignal) => Promise<void | (() => void)>; 173 173 174 - /** 175 - * Lifecycle hook callback types 176 - */ 177 174 export type LifecycleHookCallback = () => void; 178 175 export type MountHookCallback = (root: Element, scope: Scope) => void; 179 176 export type UnmountHookCallback = (root: Element) => void; 180 177 export type ElementMountHookCallback = (element: Element, scope: Scope) => void; 181 178 export type ElementUnmountHookCallback = (element: Element) => void; 182 179 export type BindingHookCallback = (element: Element, bindingName: string) => void; 183 - 184 - /** 185 - * Lifecycle hook names 186 - */ 187 180 export type GlobalHookName = "beforeMount" | "afterMount" | "beforeUnmount" | "afterUnmount"; 188 181 189 182 /** ··· 210 203 */ 211 204 afterBinding: (callback: LifecycleHookCallback) => void; 212 205 } 206 + 207 + export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; 208 + 209 + /** 210 + * Strategies for swapping response content into the DOM 211 + * 212 + * - innerHTML: Replace the target's inner HTML (default) 213 + * - outerHTML: Replace the target element entirely 214 + * - beforebegin: Insert before the target element 215 + * - afterbegin: Insert at the start of the target's content 216 + * - beforeend: Insert at the end of the target's content 217 + * - afterend: Insert after the target element 218 + * - delete: Remove the target element 219 + * - none: No DOM update (for side effects only) 220 + */ 221 + export type SwapStrategy = 222 + | "innerHTML" 223 + | "outerHTML" 224 + | "beforebegin" 225 + | "afterbegin" 226 + | "beforeend" 227 + | "afterend" 228 + | "delete" 229 + | "none"; 230 + 231 + export type RequestConfig = { 232 + method: HttpMethod; 233 + url: string; 234 + headers?: Record<string, string>; 235 + body?: string | FormData; 236 + target?: string | Element; 237 + swap?: SwapStrategy; 238 + }; 239 + 240 + export type HttpResponse = { 241 + status: number; 242 + statusText: string; 243 + headers: Headers; 244 + html?: string; 245 + json?: unknown; 246 + ok: boolean; 247 + }; 248 + 249 + /** 250 + * Configuration parsed from element attributes 251 + */ 252 + export type ParsedHttpConfig = { 253 + trigger: string; 254 + target: string | Element; 255 + swap: SwapStrategy; 256 + headers: Record<string, string>; 257 + retry?: RetryConfig; 258 + indicator?: string; 259 + }; 260 + 261 + /** 262 + * Retry configuration for HTTP requests 263 + */ 264 + export type RetryConfig = { 265 + /** 266 + * Maximum number of retry attempts 267 + */ 268 + maxAttempts: number; 269 + 270 + /** 271 + * Initial delay in milliseconds before first retry 272 + */ 273 + initialDelay: number; 274 + }; 275 + 276 + export type HydrateOptions = { rootSelector?: string; skipHydrated?: boolean }; 277 + 278 + /** 279 + * Serialized scope data structure for SSR 280 + */ 281 + export type SerializedScope = Record<string, unknown>; 282 + 283 + export type HydrateResult = ChargeResult;
+2 -2
lib/test/core/asyncEffect.test.ts
··· 1 - import { asyncEffect } from "@volt/core/asyncEffect"; 2 - import { signal } from "@volt/core/signal"; 1 + import { asyncEffect } from "$core/asyncEffect"; 2 + import { signal } from "$core/signal"; 3 3 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 4 5 5 describe("asyncEffect", () => {
+2 -2
lib/test/core/binder.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { signal } from "@volt/core/signal"; 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 5 describe("binder", () => {
+1 -1
lib/test/core/charge.test.ts
··· 1 + import { charge } from "$core/charge"; 1 2 import type { Signal } from "$types/volt"; 2 - import { charge } from "@volt/core/charge"; 3 3 import { afterEach, beforeEach, describe, expect, it } from "vitest"; 4 4 5 5 describe("charge", () => {
+2 -2
lib/test/core/evaluator.test.ts
··· 1 - import { evaluate } from "@volt/core/evaluator"; 2 - import { signal } from "@volt/core/signal"; 1 + import { evaluate } from "$core/evaluator"; 2 + import { signal } from "$core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 5 describe("evaluator", () => {
+2 -2
lib/test/core/events.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { signal } from "@volt/core/signal"; 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 3 import { describe, expect, it, vi } from "vitest"; 4 4 5 5 describe("event bindings", () => {
+2 -2
lib/test/core/for-binding.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { signal } from "@volt/core/signal"; 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 5 describe("data-volt-for binding", () => {
+1020
lib/test/core/http.test.ts
··· 1 + import { mount } from "$core/binder"; 2 + import { 3 + clearStates, 4 + parseHttpConfig, 5 + request, 6 + serializeForm, 7 + serializeFormToJSON, 8 + setErrorState, 9 + setLoadingState, 10 + swap, 11 + } from "$core/http"; 12 + import { beforeEach, describe, expect, it, vi } from "vitest"; 13 + 14 + describe("http", () => { 15 + describe("swap", () => { 16 + let container: HTMLDivElement; 17 + 18 + beforeEach(() => { 19 + container = document.createElement("div"); 20 + container.innerHTML = "<div id=\"target\">Original</div>"; 21 + document.body.append(container); 22 + }); 23 + 24 + it("swaps innerHTML by default", () => { 25 + const target = container.querySelector("#target")!; 26 + swap(target, "<span>New</span>"); 27 + expect(target.innerHTML).toBe("<span>New</span>"); 28 + }); 29 + 30 + it("swaps innerHTML explicitly", () => { 31 + const target = container.querySelector("#target")!; 32 + swap(target, "<strong>Bold</strong>", "innerHTML"); 33 + expect(target.innerHTML).toBe("<strong>Bold</strong>"); 34 + }); 35 + 36 + it("swaps outerHTML", () => { 37 + const target = container.querySelector("#target")!; 38 + swap(target, "<section id=\"new\">Replaced</section>", "outerHTML"); 39 + expect(container.querySelector("#target")).toBeNull(); 40 + expect(container.querySelector("#new")?.textContent).toBe("Replaced"); 41 + }); 42 + 43 + it("inserts beforebegin", () => { 44 + const target = container.querySelector("#target")!; 45 + swap(target, "<span id=\"before\">Before</span>", "beforebegin"); 46 + expect(container.querySelector("#before")?.nextElementSibling?.id).toBe("target"); 47 + }); 48 + 49 + it("inserts afterbegin", () => { 50 + const target = container.querySelector("#target")!; 51 + swap(target, "<span id=\"first\">First</span>", "afterbegin"); 52 + expect(target.firstElementChild?.id).toBe("first"); 53 + }); 54 + 55 + it("inserts beforeend", () => { 56 + const target = container.querySelector("#target")!; 57 + swap(target, "<span id=\"last\">Last</span>", "beforeend"); 58 + expect(target.lastElementChild?.id).toBe("last"); 59 + }); 60 + 61 + it("inserts afterend", () => { 62 + const target = container.querySelector("#target")!; 63 + swap(target, "<span id=\"after\">After</span>", "afterend"); 64 + expect(container.querySelector("#target")?.nextElementSibling?.id).toBe("after"); 65 + }); 66 + 67 + it("deletes the target element", () => { 68 + const target = container.querySelector("#target")!; 69 + swap(target, "", "delete"); 70 + expect(container.querySelector("#target")).toBeNull(); 71 + }); 72 + 73 + it("does nothing with none strategy", () => { 74 + const target = container.querySelector("#target")!; 75 + const originalHTML = target.innerHTML; 76 + swap(target, "<span>Should not appear</span>", "none"); 77 + expect(target.innerHTML).toBe(originalHTML); 78 + }); 79 + 80 + describe("state preservation", () => { 81 + it("preserves focus when swapping innerHTML", () => { 82 + container.innerHTML = ` 83 + <div id="target"> 84 + <input id="input1" type="text" /> 85 + <input id="input2" type="text" /> 86 + </div> 87 + `; 88 + const target = container.querySelector("#target")!; 89 + const input2 = container.querySelector("#input2") as HTMLInputElement; 90 + input2.focus(); 91 + 92 + expect(document.activeElement).toBe(input2); 93 + 94 + swap( 95 + target, 96 + ` 97 + <input id="input1" type="text" /> 98 + <input id="input2" type="text" /> 99 + `, 100 + "innerHTML", 101 + ); 102 + 103 + const newInput2 = container.querySelector("#input2") as HTMLInputElement; 104 + expect(document.activeElement).toBe(newInput2); 105 + }); 106 + 107 + it("preserves input values when swapping innerHTML", () => { 108 + container.innerHTML = ` 109 + <div id="target"> 110 + <input id="name" type="text" value="initial" /> 111 + <textarea id="bio">initial bio</textarea> 112 + <input id="agree" type="checkbox" checked /> 113 + </div> 114 + `; 115 + const target = container.querySelector("#target")!; 116 + const nameInput = container.querySelector("#name") as HTMLInputElement; 117 + const bioInput = container.querySelector("#bio") as HTMLTextAreaElement; 118 + 119 + nameInput.value = "John Doe"; 120 + bioInput.value = "Software developer"; 121 + 122 + swap( 123 + target, 124 + ` 125 + <input id="name" type="text" value="different" /> 126 + <textarea id="bio">different bio</textarea> 127 + <input id="agree" type="checkbox" /> 128 + `, 129 + "innerHTML", 130 + ); 131 + 132 + const newNameInput = container.querySelector("#name") as HTMLInputElement; 133 + const newBioInput = container.querySelector("#bio") as HTMLTextAreaElement; 134 + const newAgreeInput = container.querySelector("#agree") as HTMLInputElement; 135 + 136 + expect(newNameInput.value).toBe("John Doe"); 137 + expect(newBioInput.value).toBe("Software developer"); 138 + expect(newAgreeInput.checked).toBe(true); 139 + }); 140 + 141 + it("preserves scroll position when swapping innerHTML", () => { 142 + container.innerHTML = ` 143 + <div id="target" style="height: 100px; overflow-y: scroll;"> 144 + <div style="height: 500px;"> 145 + <p>Scrollable content</p> 146 + </div> 147 + </div> 148 + `; 149 + const target = container.querySelector("#target")!; 150 + target.scrollTop = 50; 151 + 152 + swap( 153 + target, 154 + ` 155 + <div style="height: 500px;"> 156 + <p>New scrollable content</p> 157 + </div> 158 + `, 159 + "innerHTML", 160 + ); 161 + 162 + expect(target.scrollTop).toBe(50); 163 + }); 164 + 165 + it("preserves nested element scroll positions", () => { 166 + container.innerHTML = ` 167 + <div id="target"> 168 + <div id="nested" style="height: 100px; overflow-y: scroll;"> 169 + <div style="height: 300px;">Content</div> 170 + </div> 171 + </div> 172 + `; 173 + const target = container.querySelector("#target")!; 174 + const nested = container.querySelector("#nested")!; 175 + nested.scrollTop = 75; 176 + 177 + swap( 178 + target, 179 + ` 180 + <div id="nested" style="height: 100px; overflow-y: scroll;"> 181 + <div style="height: 300px;">New content</div> 182 + </div> 183 + `, 184 + "innerHTML", 185 + ); 186 + 187 + const newNested = container.querySelector("#nested")!; 188 + expect(newNested.scrollTop).toBe(75); 189 + }); 190 + 191 + it("preserves state when swapping outerHTML", () => { 192 + container.innerHTML = ` 193 + <div id="target"> 194 + <input id="field" type="text" value="initial" /> 195 + </div> 196 + `; 197 + const target = container.querySelector("#target")!; 198 + const field = container.querySelector("#field") as HTMLInputElement; 199 + field.value = "updated"; 200 + field.focus(); 201 + 202 + swap( 203 + target, 204 + ` 205 + <div id="target"> 206 + <input id="field" type="text" value="different" /> 207 + </div> 208 + `, 209 + "outerHTML", 210 + ); 211 + 212 + const newField = container.querySelector("#field") as HTMLInputElement; 213 + expect(newField.value).toBe("updated"); 214 + expect(document.activeElement).toBe(newField); 215 + }); 216 + 217 + it("does not attempt state preservation for insert strategies", () => { 218 + container.innerHTML = ` 219 + <div id="target"> 220 + <input id="existing" type="text" /> 221 + </div> 222 + `; 223 + const target = container.querySelector("#target")!; 224 + const existing = container.querySelector("#existing") as HTMLInputElement; 225 + existing.value = "test"; 226 + existing.focus(); 227 + 228 + swap(target, "<input id=\"new\" type=\"text\" />", "beforeend"); 229 + 230 + expect(existing.value).toBe("test"); 231 + expect(document.activeElement).toBe(existing); 232 + }); 233 + }); 234 + }); 235 + 236 + describe("serializeForm", () => { 237 + it("serializes form to FormData", () => { 238 + const form = document.createElement("form"); 239 + form.innerHTML = ` 240 + <input name="username" value="john" /> 241 + <input name="email" value="john@example.com" /> 242 + <input type="checkbox" name="subscribe" checked /> 243 + `; 244 + 245 + const formData = serializeForm(form); 246 + 247 + expect(formData.get("username")).toBe("john"); 248 + expect(formData.get("email")).toBe("john@example.com"); 249 + expect(formData.get("subscribe")).toBe("on"); 250 + }); 251 + 252 + it("handles multiple values with same name", () => { 253 + const form = document.createElement("form"); 254 + form.innerHTML = ` 255 + <input type="checkbox" name="tags" value="tag1" checked /> 256 + <input type="checkbox" name="tags" value="tag2" checked /> 257 + `; 258 + 259 + const formData = serializeForm(form); 260 + expect(formData.getAll("tags")).toEqual(["tag1", "tag2"]); 261 + }); 262 + }); 263 + 264 + describe("serializeFormToJSON", () => { 265 + it("serializes form to JSON object", () => { 266 + const form = document.createElement("form"); 267 + form.innerHTML = ` 268 + <input name="username" value="jane" /> 269 + <input name="age" value="25" /> 270 + `; 271 + 272 + const json = serializeFormToJSON(form); 273 + 274 + expect(json).toEqual({ username: "jane", age: "25" }); 275 + }); 276 + 277 + it("handles multiple values as array", () => { 278 + const form = document.createElement("form"); 279 + form.innerHTML = ` 280 + <input name="color" value="red" /> 281 + <input name="color" value="blue" /> 282 + `; 283 + 284 + const json = serializeFormToJSON(form); 285 + 286 + expect(json.color).toEqual(["red", "blue"]); 287 + }); 288 + }); 289 + 290 + describe("parseHttpConfig", () => { 291 + it("parses default configuration", () => { 292 + const element = document.createElement("button"); 293 + const config = parseHttpConfig(element, {}); 294 + 295 + expect(config.trigger).toBe("click"); 296 + expect(config.target).toBe(element); 297 + expect(config.swap).toBe("innerHTML"); 298 + expect(config.headers).toEqual({}); 299 + }); 300 + 301 + it("parses trigger from dataset", () => { 302 + const element = document.createElement("div"); 303 + element.dataset.voltTrigger = "mouseover"; 304 + const config = parseHttpConfig(element, {}); 305 + 306 + expect(config.trigger).toBe("mouseover"); 307 + }); 308 + 309 + it("parses target selector from dataset", () => { 310 + const element = document.createElement("div"); 311 + element.dataset.voltTarget = "'#result'"; 312 + const config = parseHttpConfig(element, {}); 313 + 314 + expect(config.target).toBe("#result"); 315 + }); 316 + 317 + it("parses swap strategy from dataset", () => { 318 + const element = document.createElement("div"); 319 + element.dataset.voltSwap = "outerHTML"; 320 + const config = parseHttpConfig(element, {}); 321 + 322 + expect(config.swap).toBe("outerHTML"); 323 + }); 324 + 325 + it("parses headers from dataset", () => { 326 + const element = document.createElement("div"); 327 + element.dataset.voltHeaders = "headers"; 328 + const config = parseHttpConfig(element, { headers: { Authorization: "Bearer token" } }); 329 + 330 + expect(config.headers).toEqual({ Authorization: "Bearer token" }); 331 + }); 332 + 333 + it("uses submit trigger for forms", () => { 334 + const element = document.createElement("form"); 335 + const config = parseHttpConfig(element, {}); 336 + 337 + expect(config.trigger).toBe("submit"); 338 + }); 339 + }); 340 + 341 + describe("state management", () => { 342 + let element: HTMLDivElement; 343 + 344 + beforeEach(() => { 345 + element = document.createElement("div"); 346 + }); 347 + 348 + it("sets loading state", () => { 349 + setLoadingState(element); 350 + expect(element.getAttribute("data-volt-loading")).toBe("true"); 351 + }); 352 + 353 + it("sets error state", () => { 354 + setErrorState(element, "Network error"); 355 + expect(element.getAttribute("data-volt-error")).toBe("Network error"); 356 + }); 357 + 358 + it("clears states", () => { 359 + element.setAttribute("data-volt-loading", "true"); 360 + element.setAttribute("data-volt-error", "Some error"); 361 + 362 + clearStates(element); 363 + 364 + expect(element.hasAttribute("data-volt-loading")).toBe(false); 365 + expect(element.hasAttribute("data-volt-error")).toBe(false); 366 + }); 367 + 368 + it("dispatches volt:loading event", () => { 369 + const handler = vi.fn(); 370 + element.addEventListener("volt:loading", handler); 371 + 372 + setLoadingState(element); 373 + 374 + expect(handler).toHaveBeenCalledOnce(); 375 + expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent); 376 + expect(handler.mock.calls[0][0].detail).toEqual({ element }); 377 + }); 378 + 379 + it("dispatches volt:error event", () => { 380 + const handler = vi.fn(); 381 + element.addEventListener("volt:error", handler); 382 + 383 + setErrorState(element, "Test error"); 384 + 385 + expect(handler).toHaveBeenCalledOnce(); 386 + expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent); 387 + expect(handler.mock.calls[0][0].detail).toEqual({ element, message: "Test error" }); 388 + }); 389 + 390 + it("dispatches volt:success event", () => { 391 + const handler = vi.fn(); 392 + element.addEventListener("volt:success", handler); 393 + 394 + clearStates(element); 395 + 396 + expect(handler).toHaveBeenCalledOnce(); 397 + expect(handler.mock.calls[0][0]).toBeInstanceOf(CustomEvent); 398 + expect(handler.mock.calls[0][0].detail).toEqual({ element }); 399 + }); 400 + 401 + it("events bubble up the DOM", () => { 402 + const parent = document.createElement("div"); 403 + parent.append(element); 404 + 405 + const loadingHandler = vi.fn(); 406 + const errorHandler = vi.fn(); 407 + const successHandler = vi.fn(); 408 + 409 + parent.addEventListener("volt:loading", loadingHandler); 410 + parent.addEventListener("volt:error", errorHandler); 411 + parent.addEventListener("volt:success", successHandler); 412 + 413 + setLoadingState(element); 414 + setErrorState(element, "Bubbled error"); 415 + clearStates(element); 416 + 417 + expect(loadingHandler).toHaveBeenCalledOnce(); 418 + expect(errorHandler).toHaveBeenCalledOnce(); 419 + expect(successHandler).toHaveBeenCalledOnce(); 420 + }); 421 + }); 422 + 423 + describe("request", () => { 424 + beforeEach(() => { 425 + vi.restoreAllMocks(); 426 + }); 427 + 428 + it("makes a GET request", async () => { 429 + const mockFetch = vi.fn(() => 430 + Promise.resolve( 431 + { 432 + ok: true, 433 + status: 200, 434 + statusText: "OK", 435 + headers: new Headers({ "content-type": "text/html" }), 436 + text: () => Promise.resolve("<div>Response</div>"), 437 + } as Response, 438 + ) 439 + ); 440 + vi.stubGlobal("fetch", mockFetch); 441 + 442 + const response = await request({ method: "GET", url: "/api/data" }); 443 + 444 + expect(mockFetch).toHaveBeenCalledWith("/api/data", { method: "GET", headers: {}, body: undefined }); 445 + expect(response.ok).toBe(true); 446 + expect(response.html).toBe("<div>Response</div>"); 447 + }); 448 + 449 + it("makes a POST request with body", async () => { 450 + const mockFetch = vi.fn(() => 451 + Promise.resolve( 452 + { 453 + ok: true, 454 + status: 201, 455 + statusText: "Created", 456 + headers: new Headers({ "content-type": "application/json" }), 457 + json: () => Promise.resolve({ id: 123 }), 458 + } as Response, 459 + ) 460 + ); 461 + vi.stubGlobal("fetch", mockFetch); 462 + 463 + const formData = new FormData(); 464 + formData.append("name", "Test"); 465 + 466 + const response = await request({ method: "POST", url: "/api/create", body: formData }); 467 + 468 + expect(mockFetch).toHaveBeenCalledWith("/api/create", { method: "POST", headers: {}, body: formData }); 469 + expect(response.ok).toBe(true); 470 + expect(response.json).toEqual({ id: 123 }); 471 + }); 472 + 473 + it("parses HTML response", async () => { 474 + const mockFetch = vi.fn(() => 475 + Promise.resolve( 476 + { 477 + ok: true, 478 + status: 200, 479 + statusText: "OK", 480 + headers: new Headers({ "content-type": "text/html; charset=utf-8" }), 481 + text: () => Promise.resolve("<p>HTML content</p>"), 482 + } as Response, 483 + ) 484 + ); 485 + vi.stubGlobal("fetch", mockFetch); 486 + 487 + const response = await request({ method: "GET", url: "/page" }); 488 + 489 + expect(response.html).toBe("<p>HTML content</p>"); 490 + expect(response.json).toBeUndefined(); 491 + }); 492 + 493 + it("parses JSON response", async () => { 494 + const mockFetch = vi.fn(() => 495 + Promise.resolve( 496 + { 497 + ok: true, 498 + status: 200, 499 + statusText: "OK", 500 + headers: new Headers({ "content-type": "application/json" }), 501 + json: () => Promise.resolve({ success: true }), 502 + } as Response, 503 + ) 504 + ); 505 + vi.stubGlobal("fetch", mockFetch); 506 + 507 + const response = await request({ method: "GET", url: "/api/status" }); 508 + 509 + expect(response.json).toEqual({ success: true }); 510 + expect(response.html).toBeUndefined(); 511 + }); 512 + 513 + it("throws error for network failure", async () => { 514 + const mockFetch = vi.fn(() => Promise.reject(new Error("Network error"))); 515 + vi.stubGlobal("fetch", mockFetch); 516 + 517 + await expect(request({ method: "GET", url: "/api/fail" })).rejects.toThrow("HTTP request failed: Network error"); 518 + }); 519 + }); 520 + 521 + describe("HTTP method bindings", () => { 522 + beforeEach(() => { 523 + vi.restoreAllMocks(); 524 + }); 525 + 526 + it("binds data-volt-get and makes GET request on click", async () => { 527 + const mockFetch = vi.fn(() => 528 + Promise.resolve( 529 + { 530 + ok: true, 531 + status: 200, 532 + statusText: "OK", 533 + headers: new Headers({ "content-type": "text/html" }), 534 + text: () => Promise.resolve("<div>Loaded</div>"), 535 + } as Response, 536 + ) 537 + ); 538 + vi.stubGlobal("fetch", mockFetch); 539 + 540 + const button = document.createElement("button"); 541 + button.dataset.voltGet = "'/api/data'"; 542 + document.body.append(button); 543 + 544 + mount(button, {}); 545 + 546 + button.click(); 547 + 548 + await vi.waitFor(() => { 549 + expect(mockFetch).toHaveBeenCalledWith("/api/data", expect.objectContaining({ method: "GET" })); 550 + }); 551 + }); 552 + 553 + it("binds data-volt-post and serializes form on submit", async () => { 554 + const mockFetch = vi.fn(() => 555 + Promise.resolve( 556 + { 557 + ok: true, 558 + status: 201, 559 + statusText: "Created", 560 + headers: new Headers({ "content-type": "text/html" }), 561 + text: () => Promise.resolve("<div>Created</div>"), 562 + } as Response, 563 + ) 564 + ); 565 + vi.stubGlobal("fetch", mockFetch); 566 + 567 + const form = document.createElement("form"); 568 + form.dataset.voltPost = "'/api/submit'"; 569 + form.innerHTML = "<input name=\"test\" value=\"value\" />"; 570 + document.body.append(form); 571 + 572 + mount(form, {}); 573 + 574 + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); 575 + 576 + await vi.waitFor(() => { 577 + expect(mockFetch).toHaveBeenCalledWith( 578 + "/api/submit", 579 + expect.objectContaining({ method: "POST", body: expect.any(FormData) }), 580 + ); 581 + }); 582 + }); 583 + 584 + it("updates target element with response", async () => { 585 + const mockFetch = vi.fn(() => 586 + Promise.resolve( 587 + { 588 + ok: true, 589 + status: 200, 590 + statusText: "OK", 591 + headers: new Headers({ "content-type": "text/html" }), 592 + text: () => Promise.resolve("<span>New content</span>"), 593 + } as Response, 594 + ) 595 + ); 596 + vi.stubGlobal("fetch", mockFetch); 597 + 598 + const container = document.createElement("div"); 599 + const button = document.createElement("button"); 600 + button.dataset.voltGet = "'/api/data'"; 601 + container.append(button); 602 + document.body.append(container); 603 + 604 + mount(button, {}); 605 + 606 + button.click(); 607 + 608 + await vi.waitFor(() => { 609 + expect(button.innerHTML).toBe("<span>New content</span>"); 610 + }); 611 + }); 612 + 613 + it("sets loading state during request", async () => { 614 + let resolveRequest: ((value: Response) => void) | undefined; 615 + const mockFetch = vi.fn(() => 616 + new Promise<Response>((resolve) => { 617 + resolveRequest = resolve; 618 + }) 619 + ); 620 + vi.stubGlobal("fetch", mockFetch); 621 + 622 + const button = document.createElement("button"); 623 + button.dataset.voltGet = "'/api/slow'"; 624 + document.body.append(button); 625 + 626 + mount(button, {}); 627 + button.click(); 628 + 629 + await vi.waitFor(() => { 630 + expect(button.getAttribute("data-volt-loading")).toBe("true"); 631 + }); 632 + 633 + resolveRequest?.( 634 + { 635 + ok: true, 636 + status: 200, 637 + statusText: "OK", 638 + headers: new Headers({ "content-type": "text/html" }), 639 + text: () => Promise.resolve("<div>Done</div>"), 640 + } as Response, 641 + ); 642 + 643 + await vi.waitFor(() => { 644 + expect(button.hasAttribute("data-volt-loading")).toBe(false); 645 + }); 646 + }); 647 + 648 + it("sets error state on request failure", async () => { 649 + const mockFetch = vi.fn(() => Promise.reject(new Error("Server error"))); 650 + vi.stubGlobal("fetch", mockFetch); 651 + 652 + const button = document.createElement("button"); 653 + button.dataset.voltGet = "'/api/fail'"; 654 + document.body.append(button); 655 + 656 + mount(button, {}); 657 + button.click(); 658 + 659 + await vi.waitFor(() => { 660 + expect(button.getAttribute("data-volt-error")).toContain("Server error"); 661 + }); 662 + }); 663 + }); 664 + 665 + describe("retry logic", () => { 666 + it("retries network errors immediately", async () => { 667 + let callCount = 0; 668 + const mockFetch = vi.fn(() => { 669 + callCount++; 670 + if (callCount < 3) { 671 + return Promise.reject(new Error("HTTP request failed: fetch failed")); 672 + } 673 + return Promise.resolve( 674 + { 675 + ok: true, 676 + status: 200, 677 + statusText: "OK", 678 + headers: new Headers({ "content-type": "text/html" }), 679 + text: () => Promise.resolve("<div>Success</div>"), 680 + } as Response, 681 + ); 682 + }); 683 + vi.stubGlobal("fetch", mockFetch); 684 + 685 + const button = document.createElement("button"); 686 + button.dataset.voltGet = "'/api/data'"; 687 + button.dataset.voltRetry = "3"; 688 + button.dataset.voltTarget = "'#result'"; 689 + 690 + const result = document.createElement("div"); 691 + result.id = "result"; 692 + document.body.append(button, result); 693 + 694 + mount(button, {}); 695 + button.click(); 696 + 697 + await vi.waitFor(() => { 698 + expect(result.innerHTML).toBe("<div>Success</div>"); 699 + }, { timeout: 2000 }); 700 + 701 + expect(callCount).toBe(3); 702 + }); 703 + 704 + it("retries 5xx errors with exponential backoff", async () => { 705 + let callCount = 0; 706 + const mockFetch = vi.fn(() => { 707 + callCount++; 708 + if (callCount < 3) { 709 + return Promise.resolve( 710 + { 711 + ok: false, 712 + status: 500, 713 + statusText: "Internal Server Error", 714 + headers: new Headers({ "content-type": "text/html" }), 715 + text: () => Promise.resolve(""), 716 + } as Response, 717 + ); 718 + } 719 + return Promise.resolve( 720 + { 721 + ok: true, 722 + status: 200, 723 + statusText: "OK", 724 + headers: new Headers({ "content-type": "text/html" }), 725 + text: () => Promise.resolve("<div>Success</div>"), 726 + } as Response, 727 + ); 728 + }); 729 + vi.stubGlobal("fetch", mockFetch); 730 + 731 + const button = document.createElement("button"); 732 + button.dataset.voltGet = "'/api/data'"; 733 + button.dataset.voltRetry = "3"; 734 + button.dataset.voltRetryDelay = "100"; 735 + button.dataset.voltTarget = "'#result'"; 736 + 737 + const result = document.createElement("div"); 738 + result.id = "result"; 739 + document.body.append(button, result); 740 + 741 + mount(button, {}); 742 + button.click(); 743 + 744 + await vi.waitFor(() => { 745 + expect(result.innerHTML).toBe("<div>Success</div>"); 746 + }, { timeout: 5000 }); 747 + 748 + expect(callCount).toBe(3); 749 + }); 750 + 751 + it("does not retry 4xx errors", async () => { 752 + const mockFetch = vi.fn(() => 753 + Promise.resolve( 754 + { 755 + ok: false, 756 + status: 404, 757 + statusText: "Not Found", 758 + headers: new Headers({ "content-type": "text/html" }), 759 + text: () => Promise.resolve(""), 760 + } as Response, 761 + ) 762 + ); 763 + vi.stubGlobal("fetch", mockFetch); 764 + 765 + const button = document.createElement("button"); 766 + button.dataset.voltGet = "'/api/missing'"; 767 + button.dataset.voltRetry = "3"; 768 + button.dataset.voltTarget = "'#result'"; 769 + 770 + const result = document.createElement("div"); 771 + result.id = "result"; 772 + document.body.append(button, result); 773 + 774 + mount(button, {}); 775 + button.click(); 776 + 777 + await vi.waitFor(() => { 778 + const errorAttr = result.getAttribute("data-volt-error"); 779 + expect(errorAttr).toBeTruthy(); 780 + expect(errorAttr).toContain("404"); 781 + }); 782 + 783 + expect(mockFetch).toHaveBeenCalledTimes(1); 784 + }); 785 + 786 + it("respects max retry attempts", async () => { 787 + const mockFetch = vi.fn(() => Promise.reject(new Error("HTTP request failed: network error"))); 788 + vi.stubGlobal("fetch", mockFetch); 789 + 790 + const button = document.createElement("button"); 791 + button.dataset.voltGet = "'/api/data'"; 792 + button.dataset.voltRetry = "2"; 793 + button.dataset.voltTarget = "'#result'"; 794 + 795 + const result = document.createElement("div"); 796 + result.id = "result"; 797 + document.body.append(button, result); 798 + 799 + mount(button, {}); 800 + button.click(); 801 + 802 + await vi.waitFor(() => { 803 + expect(result.getAttribute("data-volt-error")).toBeTruthy(); 804 + }); 805 + 806 + expect(mockFetch).toHaveBeenCalledTimes(3); 807 + }); 808 + 809 + it("sets retry attempt attribute and dispatches retry event", async () => { 810 + let callCount = 0; 811 + const mockFetch = vi.fn(() => { 812 + callCount++; 813 + if (callCount < 2) { 814 + return Promise.reject(new Error("HTTP request failed: network error")); 815 + } 816 + return Promise.resolve( 817 + { 818 + ok: true, 819 + status: 200, 820 + statusText: "OK", 821 + headers: new Headers({ "content-type": "text/html" }), 822 + text: () => Promise.resolve("<div>Success</div>"), 823 + } as Response, 824 + ); 825 + }); 826 + vi.stubGlobal("fetch", mockFetch); 827 + 828 + const button = document.createElement("button"); 829 + button.dataset.voltGet = "'/api/data'"; 830 + button.dataset.voltRetry = "3"; 831 + button.dataset.voltTarget = "'#result'"; 832 + 833 + const result = document.createElement("div"); 834 + result.id = "result"; 835 + 836 + let retryEventFired = false; 837 + let retryAttempt = 0; 838 + 839 + result.addEventListener( 840 + "volt:retry", 841 + ((event: CustomEvent) => { 842 + retryEventFired = true; 843 + retryAttempt = event.detail.attempt; 844 + }) as EventListener, 845 + ); 846 + 847 + document.body.append(button, result); 848 + 849 + mount(button, {}); 850 + button.click(); 851 + 852 + await vi.waitFor(() => { 853 + expect(result.innerHTML).toBe("<div>Success</div>"); 854 + }, { timeout: 2000 }); 855 + 856 + expect(retryEventFired).toBe(true); 857 + expect(retryAttempt).toBe(1); 858 + }); 859 + }); 860 + 861 + describe("loading indicators", () => { 862 + it("shows and hides indicator with display style", async () => { 863 + const mockFetch = vi.fn(() => 864 + Promise.resolve( 865 + { 866 + ok: true, 867 + status: 200, 868 + statusText: "OK", 869 + headers: new Headers({ "content-type": "text/html" }), 870 + text: () => Promise.resolve("<div>Success</div>"), 871 + } as Response, 872 + ) 873 + ); 874 + vi.stubGlobal("fetch", mockFetch); 875 + 876 + const button = document.createElement("button"); 877 + button.dataset.voltGet = "'/api/data'"; 878 + button.dataset.voltIndicator = "#spinner"; 879 + button.dataset.voltTarget = "'#result'"; 880 + 881 + const spinner = document.createElement("div"); 882 + spinner.id = "spinner"; 883 + spinner.style.display = "none"; 884 + 885 + const result = document.createElement("div"); 886 + result.id = "result"; 887 + 888 + document.body.append(button, spinner, result); 889 + 890 + expect(spinner.style.display).toBe("none"); 891 + 892 + mount(button, {}); 893 + button.click(); 894 + 895 + await vi.waitFor(() => { 896 + expect(spinner.style.display).toBe(""); 897 + }); 898 + 899 + await vi.waitFor(() => { 900 + expect(result.innerHTML).toBe("<div>Success</div>"); 901 + expect(spinner.style.display).toBe("none"); 902 + }, { timeout: 1000 }); 903 + }); 904 + 905 + it("shows and hides indicator with CSS class", async () => { 906 + const mockFetch = vi.fn(() => 907 + Promise.resolve( 908 + { 909 + ok: true, 910 + status: 200, 911 + statusText: "OK", 912 + headers: new Headers({ "content-type": "text/html" }), 913 + text: () => Promise.resolve("<div>Success</div>"), 914 + } as Response, 915 + ) 916 + ); 917 + vi.stubGlobal("fetch", mockFetch); 918 + 919 + const button = document.createElement("button"); 920 + button.dataset.voltGet = "'/api/data'"; 921 + button.dataset.voltIndicator = "#spinner"; 922 + button.dataset.voltTarget = "'#result'"; 923 + 924 + const spinner = document.createElement("div"); 925 + spinner.id = "spinner"; 926 + spinner.classList.add("hidden"); 927 + 928 + const result = document.createElement("div"); 929 + result.id = "result"; 930 + 931 + document.body.append(button, spinner, result); 932 + 933 + expect(spinner.classList.contains("hidden")).toBe(true); 934 + 935 + mount(button, {}); 936 + button.click(); 937 + 938 + await vi.waitFor(() => { 939 + expect(spinner.classList.contains("hidden")).toBe(false); 940 + }); 941 + 942 + await vi.waitFor(() => { 943 + expect(result.innerHTML).toBe("<div>Success</div>"); 944 + expect(spinner.classList.contains("hidden")).toBe(true); 945 + }, { timeout: 1000 }); 946 + }); 947 + 948 + it("hides indicator on error", async () => { 949 + const mockFetch = vi.fn(() => Promise.reject(new Error("Server error"))); 950 + vi.stubGlobal("fetch", mockFetch); 951 + 952 + const button = document.createElement("button"); 953 + button.dataset.voltGet = "'/api/fail'"; 954 + button.dataset.voltIndicator = "#spinner"; 955 + 956 + const spinner = document.createElement("div"); 957 + spinner.id = "spinner"; 958 + spinner.style.display = "none"; 959 + 960 + document.body.append(button, spinner); 961 + 962 + mount(button, {}); 963 + button.click(); 964 + 965 + await vi.waitFor(() => { 966 + expect(spinner.style.display).toBe(""); 967 + }); 968 + 969 + await vi.waitFor(() => { 970 + expect(button.getAttribute("data-volt-error")).toBeTruthy(); 971 + expect(spinner.style.display).toBe("none"); 972 + }, { timeout: 1000 }); 973 + }); 974 + 975 + it("handles multiple indicators", async () => { 976 + const mockFetch = vi.fn(() => 977 + Promise.resolve( 978 + { 979 + ok: true, 980 + status: 200, 981 + statusText: "OK", 982 + headers: new Headers({ "content-type": "text/html" }), 983 + text: () => Promise.resolve("<div>Success</div>"), 984 + } as Response, 985 + ) 986 + ); 987 + vi.stubGlobal("fetch", mockFetch); 988 + 989 + const button = document.createElement("button"); 990 + button.dataset.voltGet = "'/api/data'"; 991 + button.dataset.voltIndicator = ".spinner"; 992 + button.dataset.voltTarget = "'#result'"; 993 + 994 + const spinner1 = document.createElement("div"); 995 + spinner1.classList.add("spinner", "hidden"); 996 + 997 + const spinner2 = document.createElement("div"); 998 + spinner2.classList.add("spinner", "hidden"); 999 + 1000 + const result = document.createElement("div"); 1001 + result.id = "result"; 1002 + 1003 + document.body.append(button, spinner1, spinner2, result); 1004 + 1005 + mount(button, {}); 1006 + button.click(); 1007 + 1008 + await vi.waitFor(() => { 1009 + expect(spinner1.classList.contains("hidden")).toBe(false); 1010 + expect(spinner2.classList.contains("hidden")).toBe(false); 1011 + }); 1012 + 1013 + await vi.waitFor(() => { 1014 + expect(result.innerHTML).toBe("<div>Success</div>"); 1015 + expect(spinner1.classList.contains("hidden")).toBe(true); 1016 + expect(spinner2.classList.contains("hidden")).toBe(true); 1017 + }, { timeout: 1000 }); 1018 + }); 1019 + }); 1020 + });
+2 -2
lib/test/core/if-binding.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { signal } from "@volt/core/signal"; 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 5 describe("data-volt-if binding", () => {
+5 -5
lib/test/core/lifecycle.test.ts
··· 1 - import type { PluginContext } from "$types/volt"; 2 - import { mount } from "@volt/core/binder"; 1 + import { mount } from "$core/binder"; 3 2 import { 4 3 clearAllGlobalHooks, 5 4 clearGlobalHooks, ··· 10 9 registerElementHook, 11 10 registerGlobalHook, 12 11 unregisterGlobalHook, 13 - } from "@volt/core/lifecycle"; 14 - import { registerPlugin } from "@volt/core/plugin"; 15 - import { signal } from "@volt/core/signal"; 12 + } from "$core/lifecycle"; 13 + import { registerPlugin } from "$core/plugin"; 14 + import { signal } from "$core/signal"; 15 + import type { PluginContext } from "$types/volt"; 16 16 import { afterEach, describe, expect, it, vi } from "vitest"; 17 17 18 18 describe("lifecycle hooks", () => {
+2 -2
lib/test/core/model-binding.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { signal } from "@volt/core/signal"; 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 3 import { describe, expect, it } from "vitest"; 4 4 5 5 describe("data-volt-model binding", () => {
+1 -1
lib/test/core/plugin.test.ts
··· 1 - import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin"; 1 + import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin"; 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 3 4 4 describe("plugin system", () => {
+1 -1
lib/test/core/signal.test.ts
··· 1 - import { computed, effect, signal } from "@volt/core/signal"; 1 + import { computed, effect, signal } from "$core/signal"; 2 2 import { describe, expect, it, vi } from "vitest"; 3 3 4 4 describe("signal", () => {
+291
lib/test/core/ssr.test.ts
··· 1 + import { signal } from "$core/signal"; 2 + import { deserializeScope, getSerializedState, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; 3 + import { beforeEach, describe, expect, it } from "vitest"; 4 + 5 + describe("ssr", () => { 6 + describe("serializeScope", () => { 7 + it("serializes signals to their values", () => { 8 + const scope = { count: signal(42), name: signal("Alice") }; 9 + const json = serializeScope(scope); 10 + expect(JSON.parse(json)).toEqual({ count: 42, name: "Alice" }); 11 + }); 12 + 13 + it("handles primitives without signals", () => { 14 + const scope = { static: "value", number: 123 }; 15 + const json = serializeScope(scope); 16 + expect(JSON.parse(json)).toEqual({ static: "value", number: 123 }); 17 + }); 18 + 19 + it("handles mixed signals and primitives", () => { 20 + const scope = { reactive: signal(true), static: false }; 21 + const json = serializeScope(scope); 22 + expect(JSON.parse(json)).toEqual({ reactive: true, static: false }); 23 + }); 24 + 25 + it("handles empty scope", () => { 26 + const scope = {}; 27 + const json = serializeScope(scope); 28 + expect(JSON.parse(json)).toEqual({}); 29 + }); 30 + }); 31 + 32 + describe("deserializeScope", () => { 33 + it("creates signals from plain values", () => { 34 + const data = { count: 42, name: "Bob" }; 35 + const scope = deserializeScope(data); 36 + 37 + expect(scope.count).toBeDefined(); 38 + expect(scope.name).toBeDefined(); 39 + expect(typeof scope.count).toBe("object"); 40 + expect((scope.count as { get: () => number }).get()).toBe(42); 41 + expect((scope.name as { get: () => string }).get()).toBe("Bob"); 42 + }); 43 + 44 + it("handles empty data", () => { 45 + const scope = deserializeScope({}); 46 + expect(Object.keys(scope)).toHaveLength(0); 47 + }); 48 + 49 + it("handles various data types", () => { 50 + const data = { string: "hello", number: 123, boolean: true, nullValue: null }; 51 + 52 + const scope = deserializeScope(data); 53 + expect((scope.string as { get: () => string }).get()).toBe("hello"); 54 + expect((scope.number as { get: () => number }).get()).toBe(123); 55 + expect((scope.boolean as { get: () => boolean }).get()).toBe(true); 56 + expect((scope.nullValue as { get: () => null }).get()).toBe(null); 57 + }); 58 + }); 59 + 60 + describe("isHydrated", () => { 61 + it("returns false for non-hydrated elements", () => { 62 + const el = document.createElement("div"); 63 + expect(isHydrated(el)).toBe(false); 64 + }); 65 + 66 + it("returns true for hydrated elements", () => { 67 + const el = document.createElement("div"); 68 + el.setAttribute("data-volt-hydrated", "true"); 69 + expect(isHydrated(el)).toBe(true); 70 + }); 71 + }); 72 + 73 + describe("isServerRendered", () => { 74 + it("returns false when no serialized state exists", () => { 75 + const el = document.createElement("div"); 76 + el.id = "app"; 77 + expect(isServerRendered(el)).toBe(false); 78 + }); 79 + 80 + it("returns true when serialized state exists", () => { 81 + const el = document.createElement("div"); 82 + el.id = "app"; 83 + el.innerHTML = ` 84 + <script type="application/json" id="volt-state-app"> 85 + {"count": 0} 86 + </script> 87 + `; 88 + expect(isServerRendered(el)).toBe(true); 89 + }); 90 + 91 + it("returns false when element has no id", () => { 92 + const el = document.createElement("div"); 93 + expect(isServerRendered(el)).toBe(false); 94 + }); 95 + }); 96 + 97 + describe("getSerializedState", () => { 98 + it("extracts state from script tag", () => { 99 + const el = document.createElement("div"); 100 + el.id = "app"; 101 + el.innerHTML = ` 102 + <script type="application/json" id="volt-state-app"> 103 + {"count": 42, "name": "Charlie"} 104 + </script> 105 + `; 106 + 107 + const state = getSerializedState(el); 108 + expect(state).toEqual({ count: 42, name: "Charlie" }); 109 + }); 110 + 111 + it("returns null when no script tag exists", () => { 112 + const el = document.createElement("div"); 113 + el.id = "app"; 114 + const state = getSerializedState(el); 115 + expect(state).toBeNull(); 116 + }); 117 + 118 + it("returns null when element has no id", () => { 119 + const el = document.createElement("div"); 120 + const state = getSerializedState(el); 121 + expect(state).toBeNull(); 122 + }); 123 + 124 + it("returns null for malformed JSON", () => { 125 + const el = document.createElement("div"); 126 + el.id = "app"; 127 + el.innerHTML = ` 128 + <script type="application/json" id="volt-state-app"> 129 + {invalid json} 130 + </script> 131 + `; 132 + 133 + const state = getSerializedState(el); 134 + expect(state).toBeNull(); 135 + }); 136 + 137 + it("returns null for empty script tag", () => { 138 + const el = document.createElement("div"); 139 + el.id = "app"; 140 + el.innerHTML = ` 141 + <script type="application/json" id="volt-state-app"></script> 142 + `; 143 + 144 + const state = getSerializedState(el); 145 + expect(state).toBeNull(); 146 + }); 147 + }); 148 + 149 + describe("hydrate", () => { 150 + beforeEach(() => { 151 + document.body.innerHTML = ""; 152 + }); 153 + 154 + it("hydrates element with serialized state", () => { 155 + document.body.innerHTML = ` 156 + <div id="app" data-volt> 157 + <script type="application/json" id="volt-state-app"> 158 + {"count": 5} 159 + </script> 160 + <p data-volt-text="count">5</p> 161 + </div> 162 + `; 163 + 164 + const result = hydrate(); 165 + 166 + expect(result.roots).toHaveLength(1); 167 + expect(result.roots[0].element.id).toBe("app"); 168 + expect(result.roots[0].scope.count).toBeDefined(); 169 + expect((result.roots[0].scope.count as { get: () => number }).get()).toBe(5); 170 + }); 171 + 172 + it("marks elements as hydrated", () => { 173 + document.body.innerHTML = ` 174 + <div id="app" data-volt> 175 + <script type="application/json" id="volt-state-app"> 176 + {"count": 0} 177 + </script> 178 + </div> 179 + `; 180 + 181 + hydrate(); 182 + 183 + const el = document.getElementById("app")!; 184 + expect(isHydrated(el)).toBe(true); 185 + }); 186 + 187 + it("skips already hydrated elements", () => { 188 + document.body.innerHTML = ` 189 + <div id="app" data-volt data-volt-hydrated="true"> 190 + <script type="application/json" id="volt-state-app"> 191 + {"count": 0} 192 + </script> 193 + </div> 194 + `; 195 + 196 + const result = hydrate(); 197 + expect(result.roots).toHaveLength(0); 198 + }); 199 + 200 + it("hydrates already hydrated elements when skipHydrated is false", () => { 201 + document.body.innerHTML = ` 202 + <div id="app" data-volt data-volt-hydrated="true"> 203 + <script type="application/json" id="volt-state-app"> 204 + {"count": 0} 205 + </script> 206 + </div> 207 + `; 208 + 209 + const result = hydrate({ skipHydrated: false }); 210 + expect(result.roots).toHaveLength(1); 211 + }); 212 + 213 + it("hydrates multiple roots", () => { 214 + document.body.innerHTML = ` 215 + <div id="app1" data-volt> 216 + <script type="application/json" id="volt-state-app1"> 217 + {"count": 1} 218 + </script> 219 + </div> 220 + <div id="app2" data-volt> 221 + <script type="application/json" id="volt-state-app2"> 222 + {"count": 2} 223 + </script> 224 + </div> 225 + `; 226 + 227 + const result = hydrate(); 228 + expect(result.roots).toHaveLength(2); 229 + expect((result.roots[0].scope.count as { get: () => number }).get()).toBe(1); 230 + expect((result.roots[1].scope.count as { get: () => number }).get()).toBe(2); 231 + }); 232 + 233 + it("uses custom root selector", () => { 234 + document.body.innerHTML = ` 235 + <div id="app1" data-volt></div> 236 + <div id="app2" class="custom"></div> 237 + `; 238 + 239 + const result = hydrate({ rootSelector: ".custom" }); 240 + expect(result.roots).toHaveLength(1); 241 + expect(result.roots[0].element.id).toBe("app2"); 242 + }); 243 + 244 + it("falls back to data-volt-state when no serialized state", () => { 245 + document.body.innerHTML = ` 246 + <div id="app" data-volt data-volt-state='{"count": 10}'> 247 + <p data-volt-text="count">10</p> 248 + </div> 249 + `; 250 + 251 + const result = hydrate(); 252 + expect(result.roots).toHaveLength(1); 253 + expect((result.roots[0].scope.count as { get: () => number }).get()).toBe(10); 254 + }); 255 + 256 + it("handles data-volt-computed attributes", () => { 257 + document.body.innerHTML = ` 258 + <div id="app" data-volt data-volt-state='{"count": 5}' data-volt-computed:double="count * 2"> 259 + <p data-volt-text="double">10</p> 260 + </div> 261 + `; 262 + 263 + const result = hydrate(); 264 + expect(result.roots).toHaveLength(1); 265 + expect(result.roots[0].scope.double).toBeDefined(); 266 + expect((result.roots[0].scope.double as { get: () => number }).get()).toBe(10); 267 + }); 268 + 269 + it("cleanup unmounts all roots", () => { 270 + document.body.innerHTML = ` 271 + <div id="app" data-volt data-volt-state='{"count": 0}'></div> 272 + `; 273 + 274 + const result = hydrate(); 275 + expect(result.roots).toHaveLength(1); 276 + 277 + result.cleanup(); 278 + }); 279 + 280 + it("handles errors gracefully", () => { 281 + document.body.innerHTML = ` 282 + <div id="app1" data-volt data-volt-state='{"count": 0}'></div> 283 + <div id="app2" data-volt data-volt-state='invalid json'></div> 284 + <div id="app3" data-volt data-volt-state='{"count": 1}'></div> 285 + `; 286 + 287 + const result = hydrate(); 288 + expect(result.roots.length).toBeGreaterThan(0); 289 + }); 290 + }); 291 + });
+1 -1
lib/test/integration/list-rendering.test.ts
··· 1 - import { computed, mount, signal } from "@volt"; 1 + import { computed, mount, signal } from "$volt"; 2 2 import { describe, expect, it } from "vitest"; 3 3 4 4 describe("integration: list rendering", () => {
+1 -1
lib/test/integration/mount.test.ts
··· 1 - import { mount, signal } from "@volt"; 1 + import { mount, signal } from "$volt"; 2 2 import { describe, expect, it } from "vitest"; 3 3 4 4 describe("integration: mount", () => {
+3 -3
lib/test/integration/plugins.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { clearPlugins, registerPlugin } from "@volt/core/plugin"; 3 - import { signal } from "@volt/core/signal"; 1 + import { mount } from "$core/binder"; 2 + import { clearPlugins, registerPlugin } from "$core/plugin"; 3 + import { signal } from "$core/signal"; 4 4 import { beforeEach, describe, expect, it, vi } from "vitest"; 5 5 6 6 describe("plugin integration with binder", () => {
+4 -4
lib/test/plugins/persist.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { registerPlugin } from "@volt/core/plugin"; 3 - import { signal } from "@volt/core/signal"; 4 - import { persistPlugin, registerStorageAdapter } from "@volt/plugins/persist"; 1 + import { mount } from "$core/binder"; 2 + import { registerPlugin } from "$core/plugin"; 3 + import { signal } from "$core/signal"; 4 + import { persistPlugin, registerStorageAdapter } from "$plugins/persist"; 5 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 6 6 7 7 describe("persist plugin", () => {
+4 -4
lib/test/plugins/scroll.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { registerPlugin } from "@volt/core/plugin"; 3 - import { signal } from "@volt/core/signal"; 4 - import { scrollPlugin } from "@volt/plugins/scroll"; 1 + import { mount } from "$core/binder"; 2 + import { registerPlugin } from "$core/plugin"; 3 + import { signal } from "$core/signal"; 4 + import { scrollPlugin } from "$plugins/scroll"; 5 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 6 6 7 7 describe("scroll plugin", () => {
+4 -4
lib/test/plugins/url.test.ts
··· 1 - import { mount } from "@volt/core/binder"; 2 - import { registerPlugin } from "@volt/core/plugin"; 3 - import { signal } from "@volt/core/signal"; 4 - import { urlPlugin } from "@volt/plugins/url"; 1 + import { mount } from "$core/binder"; 2 + import { registerPlugin } from "$core/plugin"; 3 + import { signal } from "$core/signal"; 4 + import { urlPlugin } from "$plugins/url"; 5 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 6 6 7 7 describe("url plugin", () => {
+4 -5
lib/tsconfig.json
··· 20 20 "baseUrl": ".", 21 21 "paths": { 22 22 "$types/*": ["./src/types/*"], 23 - "@volt/core": ["./src/core/index.ts"], 24 - "@volt/core/*": ["./src/core/*"], 25 - "@volt/plugins": ["./src/plugins/index.ts"], 26 - "@volt/plugins/*": ["./src/plugins/*"], 27 - "@volt": ["./src/index.ts"] 23 + "$core/*": ["./src/core/*"], 24 + "$plugins": ["./src/plugins/index.ts"], 25 + "$plugins/*": ["./src/plugins/*"], 26 + "$volt": ["./src/index.ts"] 28 27 } 29 28 }, 30 29 "include": ["src", "test"]
+5 -3
lib/vite.config.ts
··· 8 8 globals: true, 9 9 watch: false, 10 10 exclude: ["**/node_modules/**", "**/dist/**", "**/cli/tests/**"], 11 + silent: true, 12 + reporters: ["dot"], 11 13 coverage: { 12 14 provider: "v8", 13 15 thresholds: { functions: 50, branches: 50 }, ··· 20 22 resolve: { 21 23 alias: { 22 24 "$types": path.resolve(__dirname, "./src/types"), 23 - "@volt": path.resolve(__dirname, "./src/index.ts"), 24 - "@volt/core": path.resolve(__dirname, "./src/core"), 25 - "@volt/plugins": path.resolve(__dirname, "./src/plugins"), 25 + "$volt": path.resolve(__dirname, "./src/index.ts"), 26 + "$core": path.resolve(__dirname, "./src/core"), 27 + "$plugins": path.resolve(__dirname, "./src/plugins"), 26 28 }, 27 29 }, 28 30 build: mode === "lib"