+7
-8
ROADMAP.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
-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
+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
}