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

feat: async effects & lifecycle hooks

+7 -8
ROADMAP.md
··· 4 4 | ------- | ----- | ---------------------------------------------------------- | ------------------------------------------------------------------------ | 5 5 | | ✓ | [Foundations](#foundations) | Initial project setup, tooling, and reactive signal prototype. | 6 6 | | ✓ | [Reactivity & Bindings](#reactivity--bindings) | Core DOM bindings (`data-x-*`) and declarative updates. | 7 - | | | [Actions & Effects](#actions--effects) | Event system and derived reactivity primitives. | 7 + | | ✓ | [Actions & Effects](#actions--effects) | Event system and derived reactivity primitives. | 8 8 | | | [Plugins Framework](#plugins-framework) | Modular plugin system and first built-in plugin set. | 9 9 | | | [Streaming & Patch Engine](#streaming--patch-engine) | SSE/WebSocket JSON patch streaming. | 10 10 | | | [Persistence & Offline](#persistence--offline) | State persistence, storage sync, and fallback behaviors. | ··· 45 45 **Outcome:** Fully functional reactive UI layer with event bindings and computed updates. 46 46 **Deliverables:** 47 47 - ✓ Event binding system (`data-x-on-*`) 48 - - `$el` and `$event` scoped references 48 + - ✓ `$el` and `$event` scoped references 49 49 - ✓ Derived signals (`computed`, `effect`) 50 - - Async effects (e.g., fetch triggers) 50 + - ✓ Async effects (e.g., fetch triggers) 51 51 52 52 ### Plugins Framework 53 53 ··· 55 55 **Outcome:** Stable plugin API enabling community-driven extensions. 56 56 **Deliverables:** 57 57 - ✓ `registerPlugin(name, fn)` API 58 - - Context and lifecycle hooks 58 + - ✓ Context and lifecycle hooks 59 59 - ✓ Built-ins: 60 60 - ✓ `data-x-persist` 61 61 - ✓ `data-x-scroll` 62 62 - ✓ `data-x-url` 63 63 - ✓ Tests & registry 64 - - Example in docs/examples/plugins.md 65 64 - ✓ Setup test coverage with generous thresholds (~50%) 65 + - Example in docs/examples/plugins.md 66 66 - End-to-end examples (counter, form, live field updates) 67 67 - `docs/examples/reactivity.md` 68 68 - `actions`, `effects`, `signals` ··· 73 73 **Outcome:** Volt.js can receive and apply live updates from the server 74 74 **Deliverables:** 75 75 - JSON Patch parser and DOM applier 76 - - `data-x-stream` attribute 76 + - `data-volt-stream` attribute 77 77 - Reconnection/backoff logic 78 78 - Raise test coverage threshold to 60% 79 79 - Integration test with mock SSE server ··· 146 146 - ✓ Binding directives for text, attributes, classes, styles, and two-way form controls (`data-volt-[bind|text|model|class:*]`). 147 147 - ✓ Control-flow directives (`data-volt-for`, `data-volt-if`, `data-volt-else`) with lifecycle-safe teardown. 148 148 - ✓ Declarative event system (`data-volt-on:*`) with helper surface for list mutations and plugin hooks. 149 - - SSR compatibility helpers and sandboxed expression evaluator per the security contract. 150 - - Integration tests covering TodoMVC and hydration edge cases. 149 + - SSR compatibility helpers and sandboxed expression evaluator 151 150 152 151 ## Examples 153 152
+76
docs/api/events.md
··· 1 + --- 2 + version: 1.0 3 + updated: 2025-10-18 4 + --- 5 + 6 + # Event Handling 7 + 8 + Volt.js provides declarative event handling through `data-volt-on-*` attributes with automatic access to special scoped references. 9 + 10 + ## Event Binding Syntax 11 + 12 + Event handlers are attached using the `data-volt-on-{eventName}` attribute 13 + 14 + The attribute value can be: 15 + 16 + - A function reference from the scope: `handleClick` 17 + - An inline expression: `count.set(count.get() + 1)` 18 + - A method call: `myObject.method()` 19 + 20 + ## Scoped References 21 + 22 + Event handlers have access to two special scoped references that are automatically injected: 23 + 24 + ### `$el` - The Target Element 25 + 26 + The `$el` reference provides access to the DOM element that the event handler is bound to. 27 + 28 + **Type:** [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element) 29 + 30 + ### `$event` - The Event Object 31 + 32 + The `$event` reference provides access to the native browser event object. 33 + 34 + **Type:** [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event) (or specific event type like `MouseEvent`, `KeyboardEvent`, etc.) 35 + 36 + ## Event Types 37 + 38 + Volt.js aims to support all standard DOM events through `data-volt-on-*`: 39 + 40 + **Mouse Events:** 41 + 42 + - `click`, `dblclick` 43 + - `mousedown`, `mouseup` 44 + - `mouseover`, `mouseout`, `mouseenter`, `mouseleave` 45 + - `mousemove` 46 + 47 + **Keyboard Events:** 48 + 49 + - `keydown`, `keyup`, `keypress` 50 + 51 + **Form Events:** 52 + 53 + - `submit`, `reset` 54 + - `input`, `change` 55 + - `focus`, `blur` 56 + 57 + **Touch Events:** 58 + 59 + - `touchstart`, `touchend`, `touchmove`, `touchcancel` 60 + 61 + **Other Events:** 62 + 63 + - `scroll`, `resize` 64 + - `load`, `error` 65 + - Any custom events 66 + 67 + ## Implementation Details 68 + 69 + When an event handler is bound, Volt.js: 70 + 71 + 1. Creates a new scope that extends the component scope 72 + 2. Injects `$el` (the bound element) and `$event` (the event object) into this scope 73 + 3. Evaluates the expression in this enhanced scope 74 + 4. If the expression returns a function, calls it with the event 75 + 76 + The event listener is automatically cleaned up when the element is unmounted.
+212
lib/src/core/asyncEffect.ts
··· 1 + /** 2 + * Async effect system with abort, race protection, debounce, throttle, and error handling 3 + */ 4 + 5 + import type { AsyncEffectFunction, AsyncEffectOptions, ComputedSignal, Signal } from "../types/volt"; 6 + 7 + /** 8 + * Creates an async side effect that runs when dependencies change. 9 + * Supports abort signals, race protection, debouncing, throttling, and error handling. 10 + * 11 + * @param effectFunction - Async function to run as a side effect 12 + * @param dependencies - Array of signals this effect depends on 13 + * @param options - Configuration options for async behavior 14 + * @returns Cleanup function to stop the effect 15 + * 16 + * @example 17 + * // Fetch with abort on cleanup 18 + * const query = signal(''); 19 + * const cleanup = asyncEffect(async (signal) => { 20 + * const response = await fetch(`/api/search?q=${query.get()}`, { signal }); 21 + * const data = await response.json(); 22 + * results.set(data); 23 + * }, [query], { abortable: true }); 24 + * 25 + * @example 26 + * // Debounced search 27 + * asyncEffect(async () => { 28 + * const response = await fetch(`/api/search?q=${searchQuery.get()}`); 29 + * results.set(await response.json()); 30 + * }, [searchQuery], { debounce: 300 }); 31 + * 32 + * @example 33 + * // Error handling with retries 34 + * asyncEffect(async () => { 35 + * const response = await fetch('/api/data'); 36 + * if (!response.ok) throw new Error('Failed to fetch'); 37 + * data.set(await response.json()); 38 + * }, [refreshTrigger], { 39 + * retries: 3, 40 + * retryDelay: 1000, 41 + * onError: (error, retry) => { 42 + * console.error('Fetch failed:', error); 43 + * // Optionally call retry() to retry immediately 44 + * } 45 + * }); 46 + */ 47 + export function asyncEffect( 48 + effectFunction: AsyncEffectFunction, 49 + dependencies: Array<Signal<unknown> | ComputedSignal<unknown>>, 50 + options: AsyncEffectOptions = {}, 51 + ): () => void { 52 + const { abortable = false, debounce, throttle, onError, retries = 0, retryDelay = 0 } = options; 53 + 54 + let cleanup: (() => void) | void; 55 + let abortController: AbortController | undefined; 56 + let executionId = 0; 57 + let debounceTimer: ReturnType<typeof setTimeout> | undefined; 58 + let throttleTimer: ReturnType<typeof setTimeout> | undefined; 59 + let lastExecutionTime = 0; 60 + let pendingExecution = false; 61 + let retryCount = 0; 62 + 63 + /** 64 + * Execute the async effect with error handling and retries 65 + */ 66 + const executeEffect = async (currentExecutionId: number) => { 67 + if (abortController) { 68 + abortController.abort(); 69 + } 70 + 71 + if (cleanup) { 72 + try { 73 + cleanup(); 74 + } catch (error) { 75 + console.error("Error in async effect cleanup:", error); 76 + } 77 + cleanup = undefined; 78 + } 79 + 80 + if (abortable) { 81 + abortController = new AbortController(); 82 + } 83 + 84 + try { 85 + const result = await effectFunction(abortController?.signal); 86 + 87 + if (currentExecutionId !== executionId) { 88 + return; 89 + } 90 + 91 + if (typeof result === "function") { 92 + cleanup = result; 93 + } 94 + 95 + retryCount = 0; 96 + } catch (error) { 97 + if (currentExecutionId !== executionId) { 98 + return; 99 + } 100 + 101 + if (abortController?.signal.aborted) { 102 + return; 103 + } 104 + 105 + const err = error instanceof Error ? error : new Error(String(error)); 106 + 107 + if (retryCount < retries) { 108 + retryCount++; 109 + if (retryDelay > 0) { 110 + await new Promise((resolve) => setTimeout(resolve, retryDelay)); 111 + } 112 + 113 + if (currentExecutionId === executionId) { 114 + await executeEffect(currentExecutionId); 115 + } 116 + } else { 117 + console.error("Error in async effect:", err); 118 + 119 + if (onError) { 120 + const retry = () => { 121 + retryCount = 0; 122 + scheduleExecution(); 123 + }; 124 + onError(err, retry); 125 + } 126 + } 127 + } 128 + }; 129 + 130 + /** 131 + * Schedule effect execution with debounce/throttle logic 132 + */ 133 + const scheduleExecution = () => { 134 + const currentExecutionId = ++executionId; 135 + 136 + if (debounceTimer) { 137 + clearTimeout(debounceTimer); 138 + debounceTimer = undefined; 139 + } 140 + 141 + if (debounce !== undefined && debounce > 0) { 142 + debounceTimer = setTimeout(() => { 143 + debounceTimer = undefined; 144 + executeEffect(currentExecutionId); 145 + }, debounce); 146 + return; 147 + } 148 + 149 + if (throttle !== undefined && throttle > 0) { 150 + const now = Date.now(); 151 + const timeSinceLastExecution = now - lastExecutionTime; 152 + 153 + if (timeSinceLastExecution >= throttle) { 154 + lastExecutionTime = now; 155 + executeEffect(currentExecutionId); 156 + } else if (!pendingExecution) { 157 + pendingExecution = true; 158 + const remainingTime = throttle - timeSinceLastExecution; 159 + 160 + throttleTimer = setTimeout(() => { 161 + throttleTimer = undefined; 162 + pendingExecution = false; 163 + lastExecutionTime = Date.now(); 164 + executeEffect(currentExecutionId); 165 + }, remainingTime); 166 + } 167 + return; 168 + } 169 + 170 + executeEffect(currentExecutionId); 171 + }; 172 + 173 + scheduleExecution(); 174 + 175 + const unsubscribers = dependencies.map((dependency) => 176 + dependency.subscribe(() => { 177 + scheduleExecution(); 178 + }) 179 + ); 180 + 181 + return () => { 182 + executionId++; 183 + 184 + if (debounceTimer) { 185 + clearTimeout(debounceTimer); 186 + debounceTimer = undefined; 187 + } 188 + 189 + if (throttleTimer) { 190 + clearTimeout(throttleTimer); 191 + throttleTimer = undefined; 192 + } 193 + 194 + if (abortController) { 195 + abortController.abort(); 196 + abortController = undefined; 197 + } 198 + 199 + if (cleanup) { 200 + try { 201 + cleanup(); 202 + } catch (error) { 203 + console.error("Error during async effect unmount:", error); 204 + } 205 + cleanup = undefined; 206 + } 207 + 208 + for (const unsubscribe of unsubscribers) { 209 + unsubscribe(); 210 + } 211 + }; 212 + }
+68 -1
lib/src/core/binder.ts
··· 5 5 import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "../types/volt"; 6 6 import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 7 7 import { evaluate, extractDependencies, isSignal } from "./evaluator"; 8 + import { executeGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 8 9 import { getPlugin } from "./plugin"; 9 10 10 11 /** ··· 16 17 * @returns Cleanup function to unmount 17 18 */ 18 19 export function mount(root: Element, scope: Scope): CleanupFunction { 20 + executeGlobalHooks("beforeMount", root, scope); 21 + 19 22 const elements = walkDOM(root); 20 23 const allCleanups: CleanupFunction[] = []; 24 + const mountedElements: Element[] = []; 21 25 22 26 for (const element of elements) { 23 27 const attributes = getVoltAttributes(element); ··· 26 30 if (attributes.has("for")) { 27 31 const forExpression = attributes.get("for")!; 28 32 bindFor(context, forExpression); 33 + notifyBindingCreated(element, "for"); 29 34 } else if (attributes.has("if")) { 30 35 const ifExpression = attributes.get("if")!; 31 36 bindIf(context, ifExpression); 37 + notifyBindingCreated(element, "if"); 32 38 } else { 33 39 for (const [name, value] of attributes) { 34 40 bindAttribute(context, name, value); 41 + notifyBindingCreated(element, name); 35 42 } 36 43 } 37 44 45 + notifyElementMounted(element); 46 + mountedElements.push(element); 38 47 allCleanups.push(...context.cleanups); 39 48 } 40 49 50 + executeGlobalHooks("afterMount", root, scope); 51 + 41 52 return () => { 53 + executeGlobalHooks("beforeUnmount", root); 54 + 55 + for (const element of mountedElements) { 56 + notifyElementUnmounted(element); 57 + } 58 + 42 59 for (const cleanup of allCleanups) { 43 60 try { 44 61 cleanup(); ··· 46 63 console.error("Error during unmount:", error); 47 64 } 48 65 } 66 + 67 + executeGlobalHooks("afterUnmount", root); 49 68 }; 50 69 } 51 70 ··· 582 601 * Provides the plugin with access to utilities and cleanup registration. 583 602 */ 584 603 function createPluginContext(bindingContext: BindingContext): PluginContext { 604 + const mountCallbacks: Array<() => void> = []; 605 + const unmountCallbacks: Array<() => void> = []; 606 + const beforeBindingCallbacks: Array<() => void> = []; 607 + const afterBindingCallbacks: Array<() => void> = []; 608 + 609 + const lifecycle = { 610 + onMount: (callback: () => void) => { 611 + mountCallbacks.push(callback); 612 + try { 613 + callback(); 614 + } catch (error) { 615 + console.error("Error in plugin onMount hook:", error); 616 + } 617 + }, 618 + onUnmount: (cb: () => void) => { 619 + unmountCallbacks.push(cb); 620 + }, 621 + beforeBinding: (cb: () => void) => { 622 + beforeBindingCallbacks.push(cb); 623 + try { 624 + cb(); 625 + } catch (error) { 626 + console.error("Error in plugin beforeBinding hook:", error); 627 + } 628 + }, 629 + afterBinding: (callback: () => void) => { 630 + afterBindingCallbacks.push(callback); 631 + queueMicrotask(() => { 632 + try { 633 + callback(); 634 + } catch (error) { 635 + console.error("Error in plugin afterBinding hook:", error); 636 + } 637 + }); 638 + }, 639 + }; 640 + 641 + bindingContext.cleanups.push(() => { 642 + for (const cb of unmountCallbacks) { 643 + try { 644 + cb(); 645 + } catch (error) { 646 + console.error("Error in plugin onUnmount hook:", error); 647 + } 648 + } 649 + }); 650 + 585 651 return { 586 652 element: bindingContext.element, 587 653 scope: bindingContext.scope, ··· 589 655 bindingContext.cleanups.push(fn); 590 656 }, 591 657 findSignal: (path) => findSignalInScope(bindingContext.scope, path), 592 - evaluate: (expression) => evaluate(expression, bindingContext.scope), 658 + evaluate: (expr) => evaluate(expr, bindingContext.scope), 659 + lifecycle, 593 660 }; 594 661 }
+269
lib/src/core/lifecycle.ts
··· 1 + /** 2 + * Global lifecycle hook system for Volt.js 3 + * Provides beforeMount, afterMount, beforeUnmount, and afterUnmount hooks 4 + */ 5 + 6 + import type { GlobalHookName, MountHookCallback, Scope, UnmountHookCallback } from "$types/volt"; 7 + 8 + /** 9 + * Global lifecycle hooks registry 10 + */ 11 + const lifecycleHooks = new Map<GlobalHookName, Set<MountHookCallback | UnmountHookCallback>>([ 12 + ["beforeMount", new Set()], 13 + ["afterMount", new Set()], 14 + ["beforeUnmount", new Set()], 15 + ["afterUnmount", new Set()], 16 + ]); 17 + 18 + /** 19 + * Register a global lifecycle hook. 20 + * Global hooks run for every mount/unmount operation in the application. 21 + * 22 + * @param name - Name of the lifecycle hook 23 + * @param cb - Callback function to execute 24 + * @returns Unregister function 25 + * 26 + * @example 27 + * // Log every mount operation 28 + * registerGlobalHook('beforeMount', (root, scope) => { 29 + * console.log('Mounting', root, 'with scope', scope); 30 + * }); 31 + * 32 + * @example 33 + * // Track mounted elements 34 + * const mountedElements = new Set<Element>(); 35 + * registerGlobalHook('afterMount', (root) => { 36 + * mountedElements.add(root); 37 + * }); 38 + * registerGlobalHook('beforeUnmount', (root) => { 39 + * mountedElements.delete(root); 40 + * }); 41 + */ 42 + export function registerGlobalHook(name: GlobalHookName, cb: MountHookCallback | UnmountHookCallback): () => void { 43 + const hooks = lifecycleHooks.get(name); 44 + if (!hooks) { 45 + throw new Error(`Unknown lifecycle hook: ${name}`); 46 + } 47 + 48 + hooks.add(cb); 49 + 50 + return () => { 51 + hooks.delete(cb); 52 + }; 53 + } 54 + 55 + /** 56 + * Unregister a global lifecycle hook. 57 + * 58 + * @param name - Name of the lifecycle hook 59 + * @param cb - Callback function to remove 60 + * @returns true if the hook was removed, false if it wasn't registered 61 + */ 62 + export function unregisterGlobalHook(name: GlobalHookName, cb: MountHookCallback | UnmountHookCallback): boolean { 63 + const hooks = lifecycleHooks.get(name); 64 + if (!hooks) { 65 + return false; 66 + } 67 + 68 + return hooks.delete(cb); 69 + } 70 + 71 + /** 72 + * Clear all global hooks for a specific lifecycle event. 73 + * 74 + * @param name - Name of the lifecycle hook to clear 75 + */ 76 + export function clearGlobalHooks(name: GlobalHookName): void { 77 + const hooks = lifecycleHooks.get(name); 78 + if (hooks) { 79 + hooks.clear(); 80 + } 81 + } 82 + 83 + export function clearAllGlobalHooks(): void { 84 + for (const hooks of lifecycleHooks.values()) { 85 + hooks.clear(); 86 + } 87 + } 88 + 89 + /** 90 + * Get all registered hooks for a specific lifecycle event. 91 + * Used internally by the binder system. 92 + * 93 + * @param name - Name of the lifecycle hook 94 + * @returns Array of registered callbacks 95 + */ 96 + export function getGlobalHooks(name: GlobalHookName): Array<MountHookCallback | UnmountHookCallback> { 97 + const hooks = lifecycleHooks.get(name); 98 + return hooks ? [...hooks] : []; 99 + } 100 + 101 + /** 102 + * Execute all registered hooks for a lifecycle event. 103 + * Used internally by the binder system. 104 + * 105 + * @param hookName - Name of the lifecycle hook to execute 106 + * @param root - The root element being mounted/unmounted 107 + * @param scope - The scope object (only for mount hooks) 108 + */ 109 + export function executeGlobalHooks(hookName: GlobalHookName, root: Element, scope?: Scope): void { 110 + const hooks = lifecycleHooks.get(hookName); 111 + if (!hooks || hooks.size === 0) { 112 + return; 113 + } 114 + 115 + for (const callback of hooks) { 116 + try { 117 + if (hookName === "beforeMount" || hookName === "afterMount") { 118 + if (scope !== undefined) { 119 + (callback as MountHookCallback)(root, scope); 120 + } 121 + } else { 122 + (callback as UnmountHookCallback)(root); 123 + } 124 + } catch (error) { 125 + console.error(`Error in global ${hookName} hook:`, error); 126 + } 127 + } 128 + } 129 + 130 + /** 131 + * Element-level lifecycle tracking for per-element hooks 132 + */ 133 + type ElementLifecycleState = { 134 + isMounted: boolean; 135 + bindings: Set<string>; 136 + onMount: Set<() => void>; 137 + onUnmount: Set<() => void>; 138 + }; 139 + 140 + const elementLifecycleStates = new WeakMap<Element, ElementLifecycleState>(); 141 + 142 + /** 143 + * Get or create lifecycle state for an element. 144 + * 145 + * @param element - The element to track 146 + * @returns The lifecycle state object 147 + */ 148 + function getElementLifecycleState(element: Element): ElementLifecycleState { 149 + let state = elementLifecycleStates.get(element); 150 + if (!state) { 151 + state = { isMounted: false, bindings: new Set(), onMount: new Set(), onUnmount: new Set() }; 152 + elementLifecycleStates.set(element, state); 153 + } 154 + return state; 155 + } 156 + 157 + /** 158 + * Register a per-element lifecycle hook. 159 + * These hooks are specific to individual elements. 160 + * 161 + * @param element - The element to attach the hook to 162 + * @param hookType - Type of hook ('mount' or 'unmount') 163 + * @param cb - Callback to execute 164 + */ 165 + export function registerElementHook(element: Element, hookType: "mount" | "unmount", cb: () => void): void { 166 + const state = getElementLifecycleState(element); 167 + 168 + if (hookType === "mount") { 169 + state.onMount.add(cb); 170 + } else { 171 + state.onUnmount.add(cb); 172 + } 173 + } 174 + 175 + /** 176 + * Notify that an element has been mounted. 177 + * Executes all registered onMount callbacks for the element. 178 + * 179 + * @param element - The mounted element 180 + */ 181 + export function notifyElementMounted(element: Element): void { 182 + const state = getElementLifecycleState(element); 183 + 184 + if (state.isMounted) { 185 + return; 186 + } 187 + 188 + state.isMounted = true; 189 + 190 + for (const callback of state.onMount) { 191 + try { 192 + callback(); 193 + } catch (error) { 194 + console.error("Error in element onMount hook:", error); 195 + } 196 + } 197 + } 198 + 199 + /** 200 + * Notify that an element is being unmounted. 201 + * Executes all registered onUnmount callbacks for the element. 202 + * 203 + * @param element - The element being unmounted 204 + */ 205 + export function notifyElementUnmounted(element: Element): void { 206 + const state = getElementLifecycleState(element); 207 + 208 + if (!state.isMounted) { 209 + return; 210 + } 211 + 212 + state.isMounted = false; 213 + 214 + for (const callback of state.onUnmount) { 215 + try { 216 + callback(); 217 + } catch (error) { 218 + console.error("Error in element onUnmount hook:", error); 219 + } 220 + } 221 + 222 + elementLifecycleStates.delete(element); 223 + } 224 + 225 + /** 226 + * Notify that a binding has been created on an element. 227 + * 228 + * @param element - The element the binding is on 229 + * @param name - Name of the binding (e.g., 'text', 'class', 'on-click') 230 + */ 231 + export function notifyBindingCreated(element: Element, name: string): void { 232 + const state = getElementLifecycleState(element); 233 + state.bindings.add(name); 234 + } 235 + 236 + /** 237 + * Notify that a binding has been destroyed on an element. 238 + * 239 + * @param element - The element the binding was on 240 + * @param name - Name of the binding 241 + */ 242 + export function notifyBindingDestroyed(element: Element, name: string): void { 243 + const state = elementLifecycleStates.get(element); 244 + if (state) { 245 + state.bindings.delete(name); 246 + } 247 + } 248 + 249 + /** 250 + * Check if an element is currently mounted. 251 + * 252 + * @param element - The element to check 253 + * @returns true if the element is mounted 254 + */ 255 + export function isElementMounted(element: Element): boolean { 256 + const state = elementLifecycleStates.get(element); 257 + return state?.isMounted ?? false; 258 + } 259 + 260 + /** 261 + * Get all bindings on an element. 262 + * 263 + * @param element - The element to query 264 + * @returns Array of binding names 265 + */ 266 + export function getElementBindings(element: Element): string[] { 267 + const state = elementLifecycleStates.get(element); 268 + return state ? [...state.bindings] : []; 269 + }
+21 -1
lib/src/index.ts
··· 4 4 * @packageDocumentation 5 5 */ 6 6 7 - export type { ChargedRoot, ChargeResult, ComputedSignal, PluginContext, PluginHandler, Signal } from "$types/volt"; 7 + export type { 8 + AsyncEffectFunction, 9 + AsyncEffectOptions, 10 + ChargedRoot, 11 + ChargeResult, 12 + ComputedSignal, 13 + GlobalHookName, 14 + PluginContext, 15 + PluginHandler, 16 + Signal, 17 + } from "$types/volt"; 18 + export { asyncEffect } from "@volt/core/asyncEffect"; 8 19 export { mount } from "@volt/core/binder"; 9 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"; 10 30 export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin"; 11 31 export { computed, effect, signal } from "@volt/core/signal"; 12 32 export { persistPlugin, registerStorageAdapter, scrollPlugin, urlPlugin } from "@volt/plugins/index";
+27 -89
lib/src/styles/base.css
··· 11 11 * Inspired by: magick.css, latex-css, sakura, matcha, mvp.css 12 12 */ 13 13 14 - /* ========================================================================== 15 - CSS Custom Properties - Design Tokens 16 - ========================================================================== */ 14 + @import url('https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap'); 17 15 18 16 /** 19 17 * Root-level CSS variables define the design system. ··· 30 28 --font-size-4xl: 2.027rem; /* 36.5px */ 31 29 --font-size-5xl: 2.566rem; /* 46.2px */ 32 30 33 - /* Font Families - Sans-serif with personality */ 34 - /* System fonts for performance, fallback to serif for character */ 35 - --font-sans: "Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 36 - --font-serif: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif; 37 - --font-mono: "SF Mono", "Cascadia Code", "Fira Code", "Roboto Mono", Consolas, monospace; 31 + --font-sans: "Inter", sans-serif; 32 + --font-serif: "Libre Baskerville", serif; 33 + --font-mono: "Google Sans Code", monospace; 38 34 39 35 /* Spacing Scale - Based on 0.5rem increments */ 40 36 --space-xs: 0.25rem; /* 4px */ ··· 45 41 --space-2xl: 3rem; /* 48px */ 46 42 --space-3xl: 4rem; /* 64px */ 47 43 48 - /* Line Heights - Optimized for readability */ 44 + /* Line Heights */ 49 45 --line-height-tight: 1.25; 50 46 --line-height-base: 1.6; 51 47 --line-height-relaxed: 1.8; ··· 69 65 --color-warning: #bf8700; 70 66 --color-error: #cb2431; 71 67 72 - /* Shadows - Subtle depth */ 73 68 --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); 74 69 --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07); 75 70 --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); ··· 79 74 --radius-md: 6px; 80 75 --radius-lg: 8px; 81 76 82 - /* Transitions */ 83 77 --transition-fast: 150ms ease-in-out; 84 78 --transition-base: 250ms ease-in-out; 85 79 } 86 80 87 81 /** 88 82 * Dark Theme Overrides 83 + * 89 84 * Automatically applied when user prefers dark color scheme 90 85 */ 91 86 @media (prefers-color-scheme: dark) { ··· 109 104 } 110 105 } 111 106 112 - /* ========================================================================== 113 - CSS Reset & Base Styles 114 - ========================================================================== */ 115 - 116 107 /** 117 - * Modern CSS reset with sensible defaults 108 + * CSS reset 118 109 */ 119 110 *, *::before, *::after { 120 111 box-sizing: border-box; ··· 153 144 margin: 0 auto; 154 145 padding: var(--space-2xl) var(--space-lg); 155 146 } 156 - 157 - /* ========================================================================== 158 - Typography - Hierarchy & Rhythm 159 - ========================================================================== */ 160 147 161 148 /** 162 149 * Headings hierarchy ··· 215 202 216 203 /** 217 204 * First paragraph after headings - No top margin 218 - * Common convention in academic typography 205 + * 206 + * Inspired by tufte.css 219 207 */ 220 208 h1 + p, h2 + p, h3 + p, h4 + p, h5 + p, h6 + p { 221 209 margin-top: 0; 222 210 } 223 211 224 212 /** 225 - * Links - Accessible and distinctive 213 + * Links 214 + * 226 215 * Uses accent color with underline for clarity 227 216 */ 228 217 a { ··· 265 254 266 255 /** 267 256 * Subscript and superscript 257 + * 268 258 * Prevents them from affecting line height 269 259 */ 270 260 sub, sup { ··· 291 281 color: var(--color-text-muted); 292 282 } 293 283 294 - /* ========================================================================== 295 - Lists - Ordered & Unordered 296 - ========================================================================== */ 297 - 298 284 /** 299 285 * List spacing and indentation 300 - * Nested lists inherit proper spacing 286 + * 287 + * Nested lists inherit spacing 301 288 */ 302 289 ul, ol { 303 290 margin-bottom: var(--space-lg); ··· 344 331 color: var(--color-text-muted); 345 332 } 346 333 347 - /* ========================================================================== 348 - Tufte-Style Sidenotes 349 - ========================================================================== */ 350 - 351 334 /** 352 335 * Sidenotes using <small> elements 353 336 * On desktop: positioned in right margin ··· 431 414 } 432 415 433 416 blockquote cite::before { 417 + /* TODO: fix */ 434 418 content: " "; 435 419 } 436 420 437 - /* ========================================================================== 438 - Code & Preformatted Text 439 - ========================================================================== */ 440 - 441 421 /** 442 422 * Inline code 443 - * Monospace font with subtle background for distinction 423 + * 424 + * Monospace font with subtle background 444 425 */ 445 426 code { 446 427 font-family: var(--font-mono); ··· 453 434 454 435 /** 455 436 * Keyboard input 437 + * 456 438 * Styled like keys on a keyboard 457 439 */ 458 440 kbd { ··· 484 466 485 467 /** 486 468 * Preformatted code blocks 469 + * 487 470 * Horizontal scrolling for overflow, no word wrap 488 471 */ 489 472 pre { ··· 504 487 font-size: 0.875rem; 505 488 } 506 489 507 - /* ========================================================================== 508 - Horizontal Rules 509 - ========================================================================== */ 510 490 511 491 /** 512 492 * Section dividers 513 - * Centered decorative element with breathing room 493 + * 494 + * Centered decorative element 514 495 */ 515 496 hr { 516 497 margin: var(--space-3xl) auto; ··· 518 499 border-top: 1px solid var(--color-border); 519 500 max-width: 50%; 520 501 } 521 - 522 - /* ========================================================================== 523 - Tables 524 - ========================================================================== */ 525 502 526 503 /** 527 504 * Table container for horizontal scrolling on small screens ··· 537 514 538 515 /** 539 516 * Table header styling 540 - * Bold text with bottom border for separation 517 + * 518 + * Bold text with bottom border 541 519 */ 542 520 thead { 543 521 background-color: var(--color-bg-alt); ··· 574 552 transition: background-color var(--transition-fast); 575 553 } 576 554 577 - /* ========================================================================== 578 - Forms & Input Elements 579 - ========================================================================== */ 580 - 581 555 /** 582 556 * Form container spacing 583 557 */ ··· 604 578 605 579 /** 606 580 * Labels 581 + * 607 582 * Block display for better touch targets 608 583 */ 609 584 label { ··· 625 600 626 601 /** 627 602 * Text inputs and textareas 628 - * Consistent sizing and interaction states 629 603 */ 630 604 input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]), 631 605 select, ··· 645 619 646 620 /** 647 621 * Focus states for inputs 622 + * 648 623 * Clear visual feedback for keyboard navigation 649 624 */ 650 625 input:focus, ··· 714 689 overflow: hidden; 715 690 } 716 691 717 - /* ========================================================================== 718 - Buttons 719 - ========================================================================== */ 720 - 721 692 /** 722 693 * Button styling 723 694 * Primary action style with hover and active states ··· 789 760 outline-offset: 2px; 790 761 } 791 762 792 - /* ========================================================================== 793 - Media Elements 794 - ========================================================================== */ 795 - 796 763 /** 797 764 * Images 765 + * 798 766 * Responsive by default, maintains aspect ratio 799 767 */ 800 768 img { ··· 823 791 824 792 /** 825 793 * Video and audio 826 - * Responsive and accessible 827 794 */ 828 795 video, audio { 829 796 max-width: 100%; 830 797 margin: var(--space-xl) 0; 831 798 } 832 799 833 - /** 834 - * Canvas and SVG 835 - */ 836 800 canvas, svg { 837 801 max-width: 100%; 838 802 height: auto; 839 803 } 840 804 841 - /* ========================================================================== 842 - Embedded Content 843 - ========================================================================== */ 844 - 845 - /** 846 - * iframe - Responsive wrapper 847 - */ 848 805 iframe { 849 806 max-width: 100%; 850 807 border: 1px solid var(--color-border); 851 808 border-radius: var(--radius-md); 852 809 margin: var(--space-xl) 0; 853 810 } 854 - 855 - /* ========================================================================== 856 - Semantic HTML5 Elements 857 - ========================================================================== */ 858 811 859 812 /** 860 813 * Article and Section ··· 894 847 color: var(--color-text-muted); 895 848 } 896 849 897 - /** 898 - * Nav 899 - * Navigation menus 900 - */ 901 850 nav { 902 851 margin: var(--space-lg) 0; 903 852 } ··· 916 865 917 866 /** 918 867 * Details and Summary 868 + * 919 869 * Disclosure widget for expandable content 920 870 */ 921 871 details { ··· 944 894 border-bottom: 1px solid var(--color-border); 945 895 } 946 896 947 - /* ========================================================================== 948 - Utility Classes (Minimal, for framework integration) 949 - ========================================================================== */ 950 - 951 897 /** 952 898 * Screen reader only 953 899 * Hides content visually but keeps it accessible to assistive technology ··· 964 910 border-width: 0; 965 911 } 966 912 967 - /* ========================================================================== 968 - Print Styles 969 - ========================================================================== */ 970 - 971 913 /** 972 914 * Print-specific optimizations 973 915 */ ··· 1009 951 max-width: 100% !important; 1010 952 } 1011 953 } 1012 - 1013 - /* ========================================================================== 1014 - Responsive Breakpoints 1015 - ========================================================================== */ 1016 954 1017 955 /** 1018 956 * Tablet and below - Reduce spacing
+93
lib/src/types/volt.d.ts
··· 39 39 * Handles simple property paths, literals, and signal unwrapping. 40 40 */ 41 41 evaluate(expression: string): unknown; 42 + 43 + /** 44 + * Lifecycle hooks for plugin-specific mount/unmount behavior 45 + */ 46 + lifecycle: PluginLifecycle; 42 47 } 43 48 44 49 /** ··· 117 122 export type ChargeResult = { roots: ChargedRoot[]; cleanup: CleanupFunction }; 118 123 119 124 export type Dep = { get: () => unknown; subscribe: (callback: (value: unknown) => void) => () => void }; 125 + 126 + /** 127 + * Options for configuring async effects 128 + */ 129 + export interface AsyncEffectOptions { 130 + /** 131 + * Enable automatic AbortController integration. 132 + * When true, provides an AbortSignal to the effect function for canceling async operations. 133 + */ 134 + abortable?: boolean; 135 + 136 + /** 137 + * Debounce delay in milliseconds. 138 + * Effect execution is delayed until this duration has passed without dependencies changing. 139 + */ 140 + debounce?: number; 141 + 142 + /** 143 + * Throttle delay in milliseconds. 144 + * Effect execution is rate-limited to at most once per this duration. 145 + */ 146 + throttle?: number; 147 + 148 + /** 149 + * Error handler for async effect failures. 150 + * Receives the error and a retry function. 151 + */ 152 + onError?: (error: Error, retry: () => void) => void; 153 + 154 + /** 155 + * Number of automatic retry attempts on error. 156 + * Defaults to 0 (no retries). 157 + */ 158 + retries?: number; 159 + 160 + /** 161 + * Delay in milliseconds between retry attempts. 162 + * Defaults to 0 (immediate retry). 163 + */ 164 + retryDelay?: number; 165 + } 166 + 167 + /** 168 + * Async effect function signature. 169 + * Receives an optional AbortSignal when abortable option is enabled. 170 + * Can return a cleanup function or a Promise that resolves to a cleanup function. 171 + */ 172 + export type AsyncEffectFunction = (signal?: AbortSignal) => Promise<void | (() => void)>; 173 + 174 + /** 175 + * Lifecycle hook callback types 176 + */ 177 + export type LifecycleHookCallback = () => void; 178 + export type MountHookCallback = (root: Element, scope: Scope) => void; 179 + export type UnmountHookCallback = (root: Element) => void; 180 + export type ElementMountHookCallback = (element: Element, scope: Scope) => void; 181 + export type ElementUnmountHookCallback = (element: Element) => void; 182 + export type BindingHookCallback = (element: Element, bindingName: string) => void; 183 + 184 + /** 185 + * Lifecycle hook names 186 + */ 187 + export type GlobalHookName = "beforeMount" | "afterMount" | "beforeUnmount" | "afterUnmount"; 188 + 189 + /** 190 + * Extended plugin context with lifecycle hooks 191 + */ 192 + export interface PluginLifecycle { 193 + /** 194 + * Register a callback to run when the plugin is initialized for an element 195 + */ 196 + onMount: (callback: LifecycleHookCallback) => void; 197 + 198 + /** 199 + * Register a callback to run when the element is being unmounted 200 + */ 201 + onUnmount: (callback: LifecycleHookCallback) => void; 202 + 203 + /** 204 + * Register a callback to run before the binding is created 205 + */ 206 + beforeBinding: (callback: LifecycleHookCallback) => void; 207 + 208 + /** 209 + * Register a callback to run after the binding is created 210 + */ 211 + afterBinding: (callback: LifecycleHookCallback) => void; 212 + }
+661
lib/test/core/asyncEffect.test.ts
··· 1 + import { asyncEffect } from "@volt/core/asyncEffect"; 2 + import { signal } from "@volt/core/signal"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + 5 + describe("asyncEffect", () => { 6 + beforeEach(() => { 7 + vi.useFakeTimers(); 8 + }); 9 + 10 + afterEach(() => { 11 + vi.restoreAllMocks(); 12 + vi.useRealTimers(); 13 + }); 14 + 15 + describe("basic async execution", () => { 16 + it("executes async effect immediately", async () => { 17 + const spy = vi.fn(); 18 + const dependency = signal(0); 19 + 20 + asyncEffect(async () => { 21 + spy(); 22 + }, [dependency]); 23 + 24 + await vi.runAllTimersAsync(); 25 + 26 + expect(spy).toHaveBeenCalledTimes(1); 27 + }); 28 + 29 + it("executes when dependency changes", async () => { 30 + const spy = vi.fn(); 31 + const dependency = signal(0); 32 + 33 + asyncEffect(async () => { 34 + spy(dependency.get()); 35 + }, [dependency]); 36 + 37 + await vi.runAllTimersAsync(); 38 + expect(spy).toHaveBeenCalledWith(0); 39 + 40 + dependency.set(1); 41 + await vi.runAllTimersAsync(); 42 + expect(spy).toHaveBeenCalledWith(1); 43 + expect(spy).toHaveBeenCalledTimes(2); 44 + }); 45 + 46 + it("supports multiple dependencies", async () => { 47 + const spy = vi.fn(); 48 + const dep1 = signal(1); 49 + const dep2 = signal(2); 50 + 51 + asyncEffect(async () => { 52 + spy(dep1.get(), dep2.get()); 53 + }, [dep1, dep2]); 54 + 55 + await vi.runAllTimersAsync(); 56 + expect(spy).toHaveBeenCalledWith(1, 2); 57 + 58 + dep1.set(10); 59 + await vi.runAllTimersAsync(); 60 + expect(spy).toHaveBeenCalledWith(10, 2); 61 + 62 + dep2.set(20); 63 + await vi.runAllTimersAsync(); 64 + expect(spy).toHaveBeenCalledWith(10, 20); 65 + 66 + expect(spy).toHaveBeenCalledTimes(3); 67 + }); 68 + 69 + it("handles cleanup functions", async () => { 70 + const cleanupSpy = vi.fn(); 71 + const dependency = signal(0); 72 + 73 + asyncEffect(async () => { 74 + return () => { 75 + cleanupSpy(); 76 + }; 77 + }, [dependency]); 78 + 79 + await vi.runAllTimersAsync(); 80 + 81 + dependency.set(1); 82 + await vi.runAllTimersAsync(); 83 + 84 + expect(cleanupSpy).toHaveBeenCalledTimes(1); 85 + }); 86 + 87 + it("calls cleanup on unmount", async () => { 88 + const cleanupSpy = vi.fn(); 89 + const dependency = signal(0); 90 + 91 + const unsubscribe = asyncEffect(async () => { 92 + return () => { 93 + cleanupSpy(); 94 + }; 95 + }, [dependency]); 96 + 97 + await vi.runAllTimersAsync(); 98 + 99 + unsubscribe(); 100 + await vi.runAllTimersAsync(); 101 + 102 + expect(cleanupSpy).toHaveBeenCalledTimes(1); 103 + }); 104 + }); 105 + 106 + describe("abort controller integration", () => { 107 + it("provides AbortSignal when abortable option is true", async () => { 108 + let receivedSignal: AbortSignal | undefined; 109 + const dependency = signal(0); 110 + 111 + asyncEffect( 112 + async (signal) => { 113 + receivedSignal = signal; 114 + }, 115 + [dependency], 116 + { abortable: true }, 117 + ); 118 + 119 + await vi.runAllTimersAsync(); 120 + 121 + expect(receivedSignal).toBeInstanceOf(AbortSignal); 122 + expect(receivedSignal?.aborted).toBe(false); 123 + }); 124 + 125 + it("aborts previous effect when dependency changes", async () => { 126 + const signals: AbortSignal[] = []; 127 + const dependency = signal(0); 128 + 129 + asyncEffect( 130 + async (signal) => { 131 + if (signal) { 132 + signals.push(signal); 133 + } 134 + await new Promise((resolve) => setTimeout(resolve, 100)); 135 + }, 136 + [dependency], 137 + { abortable: true }, 138 + ); 139 + 140 + await vi.advanceTimersByTimeAsync(50); 141 + 142 + dependency.set(1); 143 + await vi.advanceTimersByTimeAsync(50); 144 + 145 + expect(signals).toHaveLength(2); 146 + expect(signals[0].aborted).toBe(true); 147 + expect(signals[1].aborted).toBe(false); 148 + }); 149 + 150 + it("aborts on cleanup", async () => { 151 + let abortSignal: AbortSignal | undefined; 152 + const dependency = signal(0); 153 + 154 + const cleanup = asyncEffect( 155 + async (signal) => { 156 + abortSignal = signal; 157 + }, 158 + [dependency], 159 + { abortable: true }, 160 + ); 161 + 162 + await vi.runAllTimersAsync(); 163 + 164 + cleanup(); 165 + 166 + expect(abortSignal?.aborted).toBe(true); 167 + }); 168 + 169 + it("does not provide signal when abortable is false", async () => { 170 + const signals: (AbortSignal | undefined)[] = []; 171 + const dependency = signal(0); 172 + 173 + asyncEffect( 174 + async (signal) => { 175 + signals.push(signal); 176 + }, 177 + [dependency], 178 + { abortable: false }, 179 + ); 180 + 181 + await vi.runAllTimersAsync(); 182 + 183 + expect(signals).toHaveLength(1); 184 + expect(signals[0]).toBeUndefined(); 185 + }); 186 + }); 187 + 188 + describe("race protection", () => { 189 + it("discards results from stale executions via execution ID check", async () => { 190 + const results: number[] = []; 191 + const dependency = signal(0); 192 + let currentExecutionId = 0; 193 + 194 + asyncEffect(async () => { 195 + const executionId = ++currentExecutionId; 196 + const value = dependency.get(); 197 + const delay = value === 0 ? 100 : 10; 198 + await new Promise((resolve) => setTimeout(resolve, delay)); 199 + 200 + if (executionId === currentExecutionId) { 201 + results.push(value); 202 + } 203 + }, [dependency]); 204 + 205 + await vi.advanceTimersByTimeAsync(50); 206 + 207 + dependency.set(1); 208 + 209 + await vi.runAllTimersAsync(); 210 + 211 + expect(results[results.length - 1]).toBe(1); 212 + }); 213 + 214 + it("tracks execution order with race conditions", async () => { 215 + const startTimes: number[] = []; 216 + const completionTimes: number[] = []; 217 + const dependency = signal(0); 218 + 219 + asyncEffect(async () => { 220 + const value = dependency.get(); 221 + startTimes.push(value); 222 + await new Promise((resolve) => setTimeout(resolve, 50)); 223 + completionTimes.push(value); 224 + }, [dependency]); 225 + 226 + await vi.advanceTimersByTimeAsync(10); 227 + dependency.set(1); 228 + 229 + await vi.advanceTimersByTimeAsync(10); 230 + dependency.set(2); 231 + 232 + await vi.runAllTimersAsync(); 233 + 234 + expect(startTimes.length).toBeGreaterThanOrEqual(1); 235 + }); 236 + }); 237 + 238 + describe("debounce", () => { 239 + it("delays execution until debounce period passes", async () => { 240 + const spy = vi.fn(); 241 + const dependency = signal(0); 242 + 243 + asyncEffect( 244 + async () => { 245 + spy(dependency.get()); 246 + }, 247 + [dependency], 248 + { debounce: 300 }, 249 + ); 250 + 251 + expect(spy).not.toHaveBeenCalled(); 252 + 253 + await vi.advanceTimersByTimeAsync(200); 254 + expect(spy).not.toHaveBeenCalled(); 255 + 256 + await vi.advanceTimersByTimeAsync(100); 257 + expect(spy).toHaveBeenCalledWith(0); 258 + expect(spy).toHaveBeenCalledTimes(1); 259 + }); 260 + 261 + it("resets debounce timer on each dependency change", async () => { 262 + const spy = vi.fn(); 263 + const dependency = signal(0); 264 + 265 + asyncEffect( 266 + async () => { 267 + spy(dependency.get()); 268 + }, 269 + [dependency], 270 + { debounce: 300 }, 271 + ); 272 + 273 + await vi.advanceTimersByTimeAsync(200); 274 + dependency.set(1); 275 + 276 + await vi.advanceTimersByTimeAsync(200); 277 + dependency.set(2); 278 + 279 + await vi.advanceTimersByTimeAsync(200); 280 + expect(spy).not.toHaveBeenCalled(); 281 + 282 + await vi.advanceTimersByTimeAsync(100); 283 + expect(spy).toHaveBeenCalledWith(2); 284 + expect(spy).toHaveBeenCalledTimes(1); 285 + }); 286 + 287 + it("executes only once after multiple rapid changes", async () => { 288 + const spy = vi.fn(); 289 + const dependency = signal(0); 290 + 291 + asyncEffect( 292 + async () => { 293 + spy(dependency.get()); 294 + }, 295 + [dependency], 296 + { debounce: 100 }, 297 + ); 298 + 299 + for (let i = 1; i <= 5; i++) { 300 + dependency.set(i); 301 + await vi.advanceTimersByTimeAsync(50); 302 + } 303 + 304 + await vi.runAllTimersAsync(); 305 + 306 + expect(spy).toHaveBeenCalledWith(5); 307 + expect(spy).toHaveBeenCalledTimes(1); 308 + }); 309 + }); 310 + 311 + describe("throttle", () => { 312 + it("limits execution frequency", async () => { 313 + const spy = vi.fn(); 314 + const dependency = signal(0); 315 + 316 + asyncEffect( 317 + async () => { 318 + spy(dependency.get()); 319 + }, 320 + [dependency], 321 + { throttle: 200 }, 322 + ); 323 + 324 + await vi.runAllTimersAsync(); 325 + expect(spy).toHaveBeenCalledTimes(1); 326 + 327 + dependency.set(1); 328 + await vi.advanceTimersByTimeAsync(100); 329 + expect(spy).toHaveBeenCalledTimes(1); 330 + 331 + await vi.advanceTimersByTimeAsync(100); 332 + expect(spy).toHaveBeenCalledTimes(2); 333 + }); 334 + 335 + it("executes immediately on first trigger", async () => { 336 + const spy = vi.fn(); 337 + const dependency = signal(0); 338 + 339 + asyncEffect( 340 + async () => { 341 + spy(dependency.get()); 342 + }, 343 + [dependency], 344 + { throttle: 1000 }, 345 + ); 346 + 347 + await vi.runAllTimersAsync(); 348 + expect(spy).toHaveBeenCalledWith(0); 349 + }); 350 + 351 + it("queues one execution during throttle period", async () => { 352 + const spy = vi.fn(); 353 + const dependency = signal(0); 354 + 355 + asyncEffect( 356 + async () => { 357 + spy(dependency.get()); 358 + }, 359 + [dependency], 360 + { throttle: 300 }, 361 + ); 362 + 363 + await vi.runAllTimersAsync(); 364 + expect(spy).toHaveBeenCalledTimes(1); 365 + 366 + dependency.set(1); 367 + dependency.set(2); 368 + dependency.set(3); 369 + 370 + await vi.advanceTimersByTimeAsync(300); 371 + 372 + expect(spy).toHaveBeenCalledWith(3); 373 + expect(spy).toHaveBeenCalledTimes(2); 374 + }); 375 + }); 376 + 377 + describe("error handling", () => { 378 + it("catches and logs errors", async () => { 379 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 380 + const dependency = signal(0); 381 + 382 + asyncEffect(async () => { 383 + throw new Error("Test error"); 384 + }, [dependency]); 385 + 386 + await vi.runAllTimersAsync(); 387 + 388 + expect(consoleErrorSpy).toHaveBeenCalledWith("Error in async effect:", expect.any(Error)); 389 + 390 + consoleErrorSpy.mockRestore(); 391 + }); 392 + 393 + it("calls onError handler", async () => { 394 + const errorHandler = vi.fn(); 395 + const dependency = signal(0); 396 + 397 + asyncEffect( 398 + async () => { 399 + throw new Error("Test error"); 400 + }, 401 + [dependency], 402 + { onError: errorHandler }, 403 + ); 404 + 405 + await vi.runAllTimersAsync(); 406 + 407 + expect(errorHandler).toHaveBeenCalledWith(expect.any(Error), expect.any(Function)); 408 + }); 409 + 410 + it("retries on error when retries option is set", async () => { 411 + let attempts = 0; 412 + const dependency = signal(0); 413 + 414 + asyncEffect( 415 + async () => { 416 + attempts++; 417 + if (attempts < 3) { 418 + throw new Error("Retry test"); 419 + } 420 + }, 421 + [dependency], 422 + { retries: 3 }, 423 + ); 424 + 425 + await vi.runAllTimersAsync(); 426 + 427 + expect(attempts).toBe(3); 428 + }); 429 + 430 + it("respects retry delay", async () => { 431 + let attempts = 0; 432 + const dependency = signal(0); 433 + 434 + asyncEffect( 435 + async () => { 436 + attempts++; 437 + if (attempts < 2) { 438 + throw new Error("Retry test"); 439 + } 440 + }, 441 + [dependency], 442 + { retries: 2, retryDelay: 500 }, 443 + ); 444 + 445 + await vi.advanceTimersByTimeAsync(100); 446 + expect(attempts).toBe(1); 447 + 448 + await vi.advanceTimersByTimeAsync(500); 449 + expect(attempts).toBe(2); 450 + }); 451 + 452 + it("allows manual retry via onError callback", async () => { 453 + let attempts = 0; 454 + const dependency = signal(0); 455 + 456 + asyncEffect( 457 + async () => { 458 + attempts++; 459 + if (attempts <= 2) { 460 + throw new Error("Retry test"); 461 + } 462 + }, 463 + [dependency], 464 + { 465 + retries: 1, 466 + onError: (_error, retry) => { 467 + if (attempts === 2) { 468 + retry(); 469 + } 470 + }, 471 + }, 472 + ); 473 + 474 + await vi.runAllTimersAsync(); 475 + 476 + expect(attempts).toBe(3); 477 + }); 478 + 479 + it("does not retry aborted operations", async () => { 480 + let attempts = 0; 481 + const dependency = signal(0); 482 + 483 + asyncEffect( 484 + async (signal) => { 485 + attempts++; 486 + signal?.addEventListener("abort", () => { 487 + throw new Error("Aborted"); 488 + }); 489 + await new Promise((resolve) => setTimeout(resolve, 100)); 490 + signal?.dispatchEvent(new Event("abort")); 491 + }, 492 + [dependency], 493 + { abortable: true, retries: 3 }, 494 + ); 495 + 496 + await vi.advanceTimersByTimeAsync(50); 497 + dependency.set(1); 498 + await vi.runAllTimersAsync(); 499 + 500 + expect(attempts).toBe(2); 501 + }); 502 + }); 503 + 504 + describe("cleanup behavior", () => { 505 + it("cleans up debounce timers on unmount", async () => { 506 + const spy = vi.fn(); 507 + const dependency = signal(0); 508 + 509 + const cleanup = asyncEffect( 510 + async () => { 511 + spy(); 512 + }, 513 + [dependency], 514 + { debounce: 1000 }, 515 + ); 516 + 517 + await vi.advanceTimersByTimeAsync(500); 518 + cleanup(); 519 + await vi.runAllTimersAsync(); 520 + 521 + expect(spy).not.toHaveBeenCalled(); 522 + }); 523 + 524 + it("cleans up throttle timers on unmount", async () => { 525 + const spy = vi.fn(); 526 + const dependency = signal(0); 527 + 528 + const cleanup = asyncEffect( 529 + async () => { 530 + spy(); 531 + }, 532 + [dependency], 533 + { throttle: 1000 }, 534 + ); 535 + 536 + await vi.runAllTimersAsync(); 537 + expect(spy).toHaveBeenCalledTimes(1); 538 + 539 + dependency.set(1); 540 + await vi.advanceTimersByTimeAsync(500); 541 + cleanup(); 542 + await vi.runAllTimersAsync(); 543 + 544 + expect(spy).toHaveBeenCalledTimes(1); 545 + }); 546 + 547 + it("unsubscribes from all dependencies on cleanup", async () => { 548 + const spy = vi.fn(); 549 + const dep1 = signal(0); 550 + const dep2 = signal(0); 551 + 552 + const cleanup = asyncEffect(async () => { 553 + spy(); 554 + }, [dep1, dep2]); 555 + 556 + await vi.runAllTimersAsync(); 557 + expect(spy).toHaveBeenCalledTimes(1); 558 + 559 + cleanup(); 560 + 561 + dep1.set(1); 562 + dep2.set(1); 563 + await vi.runAllTimersAsync(); 564 + 565 + expect(spy).toHaveBeenCalledTimes(1); 566 + }); 567 + 568 + it("cleanup prevents new executions", async () => { 569 + const executionCount = vi.fn(); 570 + const dependency = signal(0); 571 + 572 + const cleanup = asyncEffect(async () => { 573 + executionCount(); 574 + }, [dependency]); 575 + 576 + await vi.runAllTimersAsync(); 577 + const countAfterFirstRun = executionCount.mock.calls.length; 578 + 579 + cleanup(); 580 + 581 + dependency.set(1); 582 + await vi.runAllTimersAsync(); 583 + 584 + expect(executionCount).toHaveBeenCalledTimes(countAfterFirstRun); 585 + }); 586 + }); 587 + 588 + describe("complex scenarios", () => { 589 + it("combines debounce with abort", async () => { 590 + const spy = vi.fn(); 591 + const signals: AbortSignal[] = []; 592 + const dependency = signal(0); 593 + 594 + asyncEffect( 595 + async (signal) => { 596 + if (signal) { 597 + signals.push(signal); 598 + } 599 + spy(dependency.get()); 600 + }, 601 + [dependency], 602 + { debounce: 200, abortable: true }, 603 + ); 604 + 605 + dependency.set(1); 606 + await vi.advanceTimersByTimeAsync(100); 607 + 608 + dependency.set(2); 609 + await vi.advanceTimersByTimeAsync(200); 610 + 611 + expect(spy).toHaveBeenCalledWith(2); 612 + expect(spy).toHaveBeenCalledTimes(1); 613 + }); 614 + 615 + it("combines throttle with error handling", async () => { 616 + let attempts = 0; 617 + const dependency = signal(0); 618 + 619 + asyncEffect( 620 + async () => { 621 + attempts++; 622 + if (attempts === 1) { 623 + throw new Error("First attempt fails"); 624 + } 625 + }, 626 + [dependency], 627 + { throttle: 100, retries: 1 }, 628 + ); 629 + 630 + await vi.runAllTimersAsync(); 631 + 632 + expect(attempts).toBe(2); 633 + }); 634 + 635 + it("handles rapid changes with all features enabled", async () => { 636 + const results: number[] = []; 637 + const dependency = signal(0); 638 + 639 + asyncEffect( 640 + async (signal) => { 641 + const value = dependency.get(); 642 + await new Promise((resolve) => setTimeout(resolve, 50)); 643 + if (!signal?.aborted) { 644 + results.push(value); 645 + } 646 + }, 647 + [dependency], 648 + { debounce: 100, abortable: true, retries: 1 }, 649 + ); 650 + 651 + for (let i = 1; i <= 5; i++) { 652 + dependency.set(i); 653 + await vi.advanceTimersByTimeAsync(50); 654 + } 655 + 656 + await vi.runAllTimersAsync(); 657 + 658 + expect(results).toEqual([5]); 659 + }); 660 + }); 661 + });
+103 -2
lib/test/core/events.test.ts
··· 1 + import { mount } from "@volt/core/binder"; 2 + import { signal } from "@volt/core/signal"; 1 3 import { describe, expect, it, vi } from "vitest"; 2 - import { mount } from "../../src/core/binder"; 3 - import { signal } from "../../src/core/signal"; 4 4 5 5 describe("event bindings", () => { 6 6 it("binds click events", () => { ··· 158 158 input.dispatchEvent(new Event("input")); 159 159 160 160 expect(value.get()).toBe("changed"); 161 + }); 162 + 163 + describe("$el edge cases", () => { 164 + it("$el is available in inline expressions", () => { 165 + const button = document.createElement("button"); 166 + button.id = "test-button"; 167 + button.dataset.voltOnClick = "elementId.set($el.id)"; 168 + 169 + const elementId = signal(""); 170 + mount(button, { elementId }); 171 + 172 + button.click(); 173 + 174 + expect(elementId.get()).toBe("test-button"); 175 + }); 176 + 177 + it("can access element properties via $el in expressions", () => { 178 + const input = document.createElement("input"); 179 + input.value = "initial"; 180 + input.dataset.voltOnInput = "value.set($el.value)"; 181 + 182 + const value = signal(""); 183 + mount(input, { value }); 184 + 185 + input.value = "changed"; 186 + input.dispatchEvent(new Event("input")); 187 + 188 + expect(value.get()).toBe("changed"); 189 + }); 190 + 191 + it("$el persists across multiple event triggers", () => { 192 + const button = document.createElement("button"); 193 + button.id = "btn"; 194 + button.dataset.voltOnClick = "ids.set([...ids.get(), $el.id])"; 195 + 196 + const ids = signal([] as string[]); 197 + mount(button, { ids }); 198 + 199 + button.click(); 200 + button.click(); 201 + 202 + expect(ids.get()).toEqual(["btn", "btn"]); 203 + }); 204 + }); 205 + 206 + describe("$event edge cases", () => { 207 + it("can access event type via $event", () => { 208 + const input = document.createElement("input"); 209 + input.dataset.voltOnInput = "eventType.set($event.type)"; 210 + 211 + const eventType = signal(""); 212 + mount(input, { eventType }); 213 + 214 + input.dispatchEvent(new Event("input")); 215 + 216 + expect(eventType.get()).toBe("input"); 217 + }); 218 + 219 + it("$event.preventDefault works in expressions", () => { 220 + const form = document.createElement("form"); 221 + form.dataset.voltOnSubmit = "handleSubmit"; 222 + 223 + const handleSubmit = (event: Event) => { 224 + event.preventDefault(); 225 + }; 226 + 227 + mount(form, { handleSubmit }); 228 + 229 + const submitEvent = new Event("submit", { cancelable: true }); 230 + form.dispatchEvent(submitEvent); 231 + 232 + expect(submitEvent.defaultPrevented).toBe(true); 233 + }); 234 + 235 + it("can access event properties in expressions", () => { 236 + const button = document.createElement("button"); 237 + button.dataset.voltOnClick = "wasShiftKey.set($event.shiftKey)"; 238 + 239 + const wasShiftKey = signal(false); 240 + mount(button, { wasShiftKey }); 241 + 242 + const mouseEvent = new MouseEvent("click", { shiftKey: true }); 243 + button.dispatchEvent(mouseEvent); 244 + 245 + expect(wasShiftKey.get()).toBe(true); 246 + }); 247 + }); 248 + 249 + describe("$el and $event interaction", () => { 250 + it("both $el and $event are available together", () => { 251 + const input = document.createElement("input"); 252 + input.id = "test-input"; 253 + input.dataset.voltOnInput = "data.set({ id: $el.id, type: $event.type })"; 254 + 255 + const data = signal({} as { id: string; type: string }); 256 + mount(input, { data }); 257 + 258 + input.dispatchEvent(new Event("input")); 259 + 260 + expect(data.get()).toEqual({ id: "test-input", type: "input" }); 261 + }); 161 262 }); 162 263 });
+487
lib/test/core/lifecycle.test.ts
··· 1 + import type { PluginContext } from "$types/volt"; 2 + import { mount } from "@volt/core/binder"; 3 + import { 4 + clearAllGlobalHooks, 5 + clearGlobalHooks, 6 + getElementBindings, 7 + isElementMounted, 8 + notifyElementMounted, 9 + notifyElementUnmounted, 10 + registerElementHook, 11 + registerGlobalHook, 12 + unregisterGlobalHook, 13 + } from "@volt/core/lifecycle"; 14 + import { registerPlugin } from "@volt/core/plugin"; 15 + import { signal } from "@volt/core/signal"; 16 + import { afterEach, describe, expect, it, vi } from "vitest"; 17 + 18 + describe("lifecycle hooks", () => { 19 + afterEach(() => { 20 + clearAllGlobalHooks(); 21 + }); 22 + 23 + describe("global lifecycle hooks", () => { 24 + describe("beforeMount", () => { 25 + it("executes before mount", () => { 26 + const executionOrder: string[] = []; 27 + const root = document.createElement("div"); 28 + root.innerHTML = "<div data-volt-text=\"message\"></div>"; 29 + 30 + registerGlobalHook("beforeMount", () => { 31 + executionOrder.push("beforeMount"); 32 + }); 33 + 34 + const message = signal("test"); 35 + executionOrder.push("before mount call"); 36 + mount(root, { message }); 37 + executionOrder.push("after mount call"); 38 + 39 + expect(executionOrder).toEqual(["before mount call", "beforeMount", "after mount call"]); 40 + }); 41 + 42 + it("receives root and scope", () => { 43 + let receivedRoot: Element | undefined; 44 + let receivedScope: Record<string, unknown> | undefined; 45 + 46 + const root = document.createElement("div"); 47 + const message = signal("test"); 48 + 49 + registerGlobalHook("beforeMount", (element: Element, scope: Record<string, unknown>) => { 50 + receivedRoot = element; 51 + receivedScope = scope; 52 + }); 53 + 54 + mount(root, { message }); 55 + 56 + expect(receivedRoot).toBe(root); 57 + expect(receivedScope).toEqual({ message }); 58 + }); 59 + 60 + it("can register multiple hooks", () => { 61 + const hooks: number[] = []; 62 + const root = document.createElement("div"); 63 + 64 + registerGlobalHook("beforeMount", () => { 65 + hooks.push(1); 66 + }); 67 + registerGlobalHook("beforeMount", () => { 68 + hooks.push(2); 69 + }); 70 + 71 + mount(root, {}); 72 + 73 + expect(hooks).toEqual([1, 2]); 74 + }); 75 + }); 76 + 77 + describe("afterMount", () => { 78 + it("executes after mount completes", () => { 79 + const executionOrder: string[] = []; 80 + const root = document.createElement("div"); 81 + root.innerHTML = "<div data-volt-text=\"message\"></div>"; 82 + 83 + registerGlobalHook("afterMount", () => { 84 + executionOrder.push("afterMount"); 85 + }); 86 + 87 + const message = signal("test"); 88 + executionOrder.push("before mount"); 89 + mount(root, { message }); 90 + executionOrder.push("after mount"); 91 + 92 + expect(executionOrder).toEqual(["before mount", "afterMount", "after mount"]); 93 + }); 94 + 95 + it("executes after mount completes", () => { 96 + const root = document.createElement("div"); 97 + root.innerHTML = "<div data-volt-text=\"message\"></div>"; 98 + 99 + let mountCompleted = false; 100 + 101 + registerGlobalHook("afterMount", () => { 102 + mountCompleted = true; 103 + }); 104 + 105 + const message = signal("hello"); 106 + mount(root, { message }); 107 + 108 + expect(mountCompleted).toBe(true); 109 + }); 110 + }); 111 + 112 + describe("beforeUnmount", () => { 113 + it("executes before unmount", () => { 114 + const executionOrder: string[] = []; 115 + const root = document.createElement("div"); 116 + 117 + registerGlobalHook("beforeUnmount", () => { 118 + executionOrder.push("beforeUnmount"); 119 + }); 120 + 121 + const cleanup = mount(root, {}); 122 + 123 + executionOrder.push("before cleanup"); 124 + cleanup(); 125 + executionOrder.push("after cleanup"); 126 + 127 + expect(executionOrder).toEqual(["before cleanup", "beforeUnmount", "after cleanup"]); 128 + }); 129 + 130 + it("executes before bindings are destroyed", () => { 131 + const root = document.createElement("div"); 132 + root.innerHTML = "<div data-volt-text=\"message\"></div>"; 133 + 134 + let wasMounted = false; 135 + 136 + registerGlobalHook("beforeUnmount", () => { 137 + wasMounted = true; 138 + }); 139 + 140 + const message = signal("hello"); 141 + const cleanup = mount(root, { message }); 142 + 143 + cleanup(); 144 + 145 + expect(wasMounted).toBe(true); 146 + }); 147 + }); 148 + 149 + describe("afterUnmount", () => { 150 + it("executes after unmount completes", () => { 151 + const executionOrder: string[] = []; 152 + const root = document.createElement("div"); 153 + 154 + registerGlobalHook("afterUnmount", () => { 155 + executionOrder.push("afterUnmount"); 156 + }); 157 + 158 + const cleanup = mount(root, {}); 159 + 160 + executionOrder.push("before cleanup"); 161 + cleanup(); 162 + executionOrder.push("after cleanup"); 163 + 164 + expect(executionOrder).toEqual(["before cleanup", "afterUnmount", "after cleanup"]); 165 + }); 166 + }); 167 + 168 + describe("hook registration management", () => { 169 + it("can unregister hooks", () => { 170 + const hook = vi.fn(); 171 + const root = document.createElement("div"); 172 + 173 + const unregister = registerGlobalHook("beforeMount", hook); 174 + 175 + mount(root, {}); 176 + expect(hook).toHaveBeenCalledTimes(1); 177 + 178 + unregister(); 179 + 180 + mount(root, {}); 181 + expect(hook).toHaveBeenCalledTimes(1); 182 + }); 183 + 184 + it("unregisterGlobalHook removes hooks", () => { 185 + const hook = vi.fn(); 186 + const root = document.createElement("div"); 187 + 188 + registerGlobalHook("beforeMount", hook); 189 + 190 + mount(root, {}); 191 + expect(hook).toHaveBeenCalledTimes(1); 192 + 193 + unregisterGlobalHook("beforeMount", hook); 194 + 195 + mount(root, {}); 196 + expect(hook).toHaveBeenCalledTimes(1); 197 + }); 198 + 199 + it("clearGlobalHooks removes all hooks for a lifecycle event", () => { 200 + const hook1 = vi.fn(); 201 + const hook2 = vi.fn(); 202 + const root = document.createElement("div"); 203 + 204 + registerGlobalHook("beforeMount", hook1); 205 + registerGlobalHook("beforeMount", hook2); 206 + 207 + clearGlobalHooks("beforeMount"); 208 + 209 + mount(root, {}); 210 + 211 + expect(hook1).not.toHaveBeenCalled(); 212 + expect(hook2).not.toHaveBeenCalled(); 213 + }); 214 + 215 + it("clearAllGlobalHooks removes all hooks", () => { 216 + const beforeMountHook = vi.fn(); 217 + const afterMountHook = vi.fn(); 218 + const root = document.createElement("div"); 219 + 220 + registerGlobalHook("beforeMount", beforeMountHook); 221 + registerGlobalHook("afterMount", afterMountHook); 222 + 223 + clearAllGlobalHooks(); 224 + 225 + mount(root, {}); 226 + 227 + expect(beforeMountHook).not.toHaveBeenCalled(); 228 + expect(afterMountHook).not.toHaveBeenCalled(); 229 + }); 230 + }); 231 + 232 + describe("error handling", () => { 233 + it("catches and logs errors in beforeMount hooks", () => { 234 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 235 + const root = document.createElement("div"); 236 + 237 + registerGlobalHook("beforeMount", () => { 238 + throw new Error("beforeMount error"); 239 + }); 240 + 241 + expect(() => { 242 + mount(root, {}); 243 + }).not.toThrow(); 244 + 245 + expect(consoleErrorSpy).toHaveBeenCalledWith("Error in global beforeMount hook:", expect.any(Error)); 246 + 247 + consoleErrorSpy.mockRestore(); 248 + }); 249 + 250 + it("continues executing other hooks after error", () => { 251 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 252 + const hook2 = vi.fn(); 253 + const root = document.createElement("div"); 254 + 255 + registerGlobalHook("beforeMount", () => { 256 + throw new Error("Error"); 257 + }); 258 + registerGlobalHook("beforeMount", hook2); 259 + 260 + mount(root, {}); 261 + 262 + expect(hook2).toHaveBeenCalled(); 263 + 264 + consoleErrorSpy.mockRestore(); 265 + }); 266 + }); 267 + }); 268 + 269 + describe("element lifecycle", () => { 270 + it("tracks element mounted state", () => { 271 + const element = document.createElement("div"); 272 + 273 + expect(isElementMounted(element)).toBe(false); 274 + notifyElementMounted(element); 275 + expect(isElementMounted(element)).toBe(true); 276 + notifyElementUnmounted(element); 277 + expect(isElementMounted(element)).toBe(false); 278 + }); 279 + 280 + it("executes onMount callbacks", () => { 281 + const callback = vi.fn(); 282 + const element = document.createElement("div"); 283 + 284 + registerElementHook(element, "mount", callback); 285 + notifyElementMounted(element); 286 + 287 + expect(callback).toHaveBeenCalledTimes(1); 288 + }); 289 + 290 + it("executes onUnmount callbacks", () => { 291 + const callback = vi.fn(); 292 + const element = document.createElement("div"); 293 + 294 + registerElementHook(element, "unmount", callback); 295 + notifyElementMounted(element); 296 + notifyElementUnmounted(element); 297 + 298 + expect(callback).toHaveBeenCalledTimes(1); 299 + }); 300 + 301 + it("only executes onMount once", () => { 302 + const callback = vi.fn(); 303 + const element = document.createElement("div"); 304 + 305 + registerElementHook(element, "mount", callback); 306 + notifyElementMounted(element); 307 + notifyElementMounted(element); 308 + 309 + expect(callback).toHaveBeenCalledTimes(1); 310 + }); 311 + 312 + it("only executes onUnmount if element was mounted", () => { 313 + const callback = vi.fn(); 314 + const element = document.createElement("div"); 315 + 316 + registerElementHook(element, "unmount", callback); 317 + notifyElementUnmounted(element); 318 + 319 + expect(callback).not.toHaveBeenCalled(); 320 + }); 321 + 322 + it("catches and logs errors in element hooks", () => { 323 + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 324 + const element = document.createElement("div"); 325 + 326 + registerElementHook(element, "mount", () => { 327 + throw new Error("Mount error"); 328 + }); 329 + 330 + notifyElementMounted(element); 331 + expect(consoleErrorSpy).toHaveBeenCalledWith("Error in element onMount hook:", expect.any(Error)); 332 + 333 + consoleErrorSpy.mockRestore(); 334 + }); 335 + }); 336 + 337 + describe("binding lifecycle", () => { 338 + it("tracks mounted state for elements with bindings", () => { 339 + const root = document.createElement("div"); 340 + root.dataset.voltText = "message"; 341 + const message = signal("test"); 342 + mount(root, { message }); 343 + expect(isElementMounted(root)).toBe(true); 344 + }); 345 + 346 + it("returns empty array for elements with no tracked bindings", () => { 347 + const element = document.createElement("div"); 348 + expect(getElementBindings(element)).toEqual([]); 349 + }); 350 + }); 351 + 352 + describe("plugin lifecycle hooks", () => { 353 + it("plugin can register mount hooks", () => { 354 + const onMountSpy = vi.fn(); 355 + const root = document.createElement("div"); 356 + root.dataset.voltCustom = "value"; 357 + 358 + registerPlugin("custom", (context: PluginContext) => { 359 + context.lifecycle.onMount(() => { 360 + onMountSpy(); 361 + }); 362 + }); 363 + 364 + mount(root, {}); 365 + 366 + expect(onMountSpy).toHaveBeenCalled(); 367 + }); 368 + 369 + it("plugin can register unmount hooks", () => { 370 + const onUnmountSpy = vi.fn(); 371 + const root = document.createElement("div"); 372 + root.dataset.voltCustom = "value"; 373 + 374 + registerPlugin("custom", (context: PluginContext) => { 375 + context.lifecycle.onUnmount(() => { 376 + onUnmountSpy(); 377 + }); 378 + }); 379 + 380 + const cleanup = mount(root, {}); 381 + cleanup(); 382 + 383 + expect(onUnmountSpy).toHaveBeenCalled(); 384 + }); 385 + 386 + it("plugin beforeBinding hooks execute before plugin handler", () => { 387 + const executionOrder: string[] = []; 388 + const root = document.createElement("div"); 389 + root.dataset.voltCustom = "value"; 390 + 391 + registerPlugin("custom", (context: PluginContext) => { 392 + context.lifecycle.beforeBinding(() => { 393 + executionOrder.push("beforeBinding"); 394 + }); 395 + executionOrder.push("handler"); 396 + }); 397 + 398 + mount(root, {}); 399 + 400 + expect(executionOrder).toEqual(["beforeBinding", "handler"]); 401 + }); 402 + 403 + it("plugin afterBinding hooks execute after plugin handler", async () => { 404 + const executionOrder: string[] = []; 405 + const root = document.createElement("div"); 406 + root.dataset.voltCustom = "value"; 407 + 408 + registerPlugin("custom", (context: PluginContext) => { 409 + executionOrder.push("handler"); 410 + context.lifecycle.afterBinding(() => { 411 + executionOrder.push("afterBinding"); 412 + }); 413 + }); 414 + 415 + mount(root, {}); 416 + 417 + await new Promise((resolve) => setTimeout(resolve, 0)); 418 + 419 + expect(executionOrder).toEqual(["handler", "afterBinding"]); 420 + }); 421 + }); 422 + 423 + describe("hook execution order", () => { 424 + it("executes hooks in correct order during mount", () => { 425 + const executionOrder: string[] = []; 426 + const root = document.createElement("div"); 427 + root.innerHTML = "<div data-volt-text=\"message\"></div>"; 428 + 429 + registerGlobalHook("beforeMount", () => { 430 + executionOrder.push("global:beforeMount"); 431 + }); 432 + 433 + registerGlobalHook("afterMount", () => { 434 + executionOrder.push("global:afterMount"); 435 + }); 436 + 437 + const message = signal("test"); 438 + mount(root, { message }); 439 + 440 + expect(executionOrder).toEqual(["global:beforeMount", "global:afterMount"]); 441 + }); 442 + 443 + it("executes hooks in correct order during unmount", () => { 444 + const executionOrder: string[] = []; 445 + const root = document.createElement("div"); 446 + 447 + registerGlobalHook("beforeUnmount", () => { 448 + executionOrder.push("global:beforeUnmount"); 449 + }); 450 + 451 + registerGlobalHook("afterUnmount", () => { 452 + executionOrder.push("global:afterUnmount"); 453 + }); 454 + 455 + const cleanup = mount(root, {}); 456 + cleanup(); 457 + 458 + expect(executionOrder).toEqual(["global:beforeUnmount", "global:afterUnmount"]); 459 + }); 460 + 461 + it("executes mount and unmount in order", () => { 462 + const executionOrder: string[] = []; 463 + const root = document.createElement("div"); 464 + 465 + registerGlobalHook("beforeMount", () => { 466 + executionOrder.push("beforeMount"); 467 + }); 468 + 469 + registerGlobalHook("afterMount", () => { 470 + executionOrder.push("afterMount"); 471 + }); 472 + 473 + registerGlobalHook("beforeUnmount", () => { 474 + executionOrder.push("beforeUnmount"); 475 + }); 476 + 477 + registerGlobalHook("afterUnmount", () => { 478 + executionOrder.push("afterUnmount"); 479 + }); 480 + 481 + const cleanup = mount(root, {}); 482 + cleanup(); 483 + 484 + expect(executionOrder).toEqual(["beforeMount", "afterMount", "beforeUnmount", "afterUnmount"]); 485 + }); 486 + }); 487 + });
+1 -7
lib/test/core/plugin.test.ts
··· 1 + import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "@volt/core/plugin"; 1 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "../../src/core/plugin"; 3 3 4 4 describe("plugin system", () => { 5 5 beforeEach(() => { ··· 10 10 it("registers a plugin with a given name", () => { 11 11 const handler = vi.fn(); 12 12 registerPlugin("test", handler); 13 - 14 13 expect(hasPlugin("test")).toBe(true); 15 14 }); 16 15 ··· 44 43 it("returns true for registered plugins", () => { 45 44 const handler = vi.fn(); 46 45 registerPlugin("test", handler); 47 - 48 46 expect(hasPlugin("test")).toBe(true); 49 47 }); 50 48 ··· 74 72 75 73 it("returns false when unregistering nonexistent plugin", () => { 76 74 const result = unregisterPlugin("nonexistent"); 77 - 78 75 expect(result).toBe(false); 79 76 }); 80 77 }); ··· 86 83 87 84 it("returns array of registered plugin names", () => { 88 85 const handler = vi.fn(); 89 - 90 86 registerPlugin("plugin1", handler); 91 87 registerPlugin("plugin2", handler); 92 88 registerPlugin("plugin3", handler); 93 89 94 90 const plugins = getRegisteredPlugins(); 95 - 96 91 expect(plugins).toHaveLength(3); 97 92 expect(plugins).toContain("plugin1"); 98 93 expect(plugins).toContain("plugin2"); ··· 120 115 registerPlugin("plugin1", handler); 121 116 registerPlugin("plugin2", handler); 122 117 registerPlugin("plugin3", handler); 123 - 124 118 clearPlugins(); 125 119 126 120 expect(getRegisteredPlugins()).toEqual([]);
+1 -2
lib/test/core/signal.test.ts
··· 1 + import { computed, effect, signal } from "@volt/core/signal"; 1 2 import { describe, expect, it, vi } from "vitest"; 2 - import { computed, effect, signal } from "../../src/core/signal"; 3 3 4 4 describe("signal", () => { 5 5 it("creates a signal with an initial value", () => { ··· 216 216 const effectFunction = vi.fn(); 217 217 218 218 effect(effectFunction, [count]); 219 - 220 219 expect(effectFunction).toHaveBeenCalledTimes(1); 221 220 }); 222 221
+1 -4
lib/tsconfig.json
··· 6 6 "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 7 "types": ["vite/client"], 8 8 "skipLibCheck": true, 9 - /* Bundler mode */ 10 9 "moduleResolution": "bundler", 11 10 "allowImportingTsExtensions": true, 12 11 "verbatimModuleSyntax": true, 13 12 "moduleDetection": "force", 14 13 "noEmit": true, 15 - /* Linting */ 16 14 "strict": true, 17 15 "noUnusedLocals": true, 18 16 "noUnusedParameters": true, 19 17 "erasableSyntaxOnly": true, 20 18 "noFallthroughCasesInSwitch": true, 21 19 "noUncheckedSideEffectImports": true, 22 - /* Path Aliases */ 23 20 "baseUrl": ".", 24 21 "paths": { "$types/*": ["./src/types/*"], "@volt/core/*": ["./src/core/*"], "@volt/plugins/*": ["./src/plugins/*"] } 25 22 }, 26 - "include": ["src", "lib/test"] 23 + "include": ["src", "test"] 27 24 }