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

Configure Feed

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

feat: global state

feat: completed state machine

+2707 -120
+1
dev/docs/README.md
··· 1 + # Internal Development Docs
+177
dev/docs/design/pins.md
··· 1 + # Pin Scoping 2 + 3 + Element references via `data-volt-pin` require a scoping strategy to avoid name collisions and provide predictable access patterns. 4 + This document explores three approaches that were considered 5 + 6 + ## Current Implementation: [data-volt] Root Scoping 7 + 8 + **How it works:** 9 + 10 + - Each `[data-volt]` root has its own pin registry 11 + - Pins are isolated to their scope 12 + - `$pins.name` accesses pins within the current root 13 + 14 + **Example:** 15 + 16 + ```html 17 + <div data-volt> 18 + <input data-volt-pin="username" /> 19 + <button data-volt-on-click="$pins.username.focus()">Focus</button> 20 + </div> 21 + 22 + <div data-volt> 23 + <input data-volt-pin="username" /> <!-- Different registry --> 24 + <button data-volt-on-click="$pins.username.focus()">Focus</button> 25 + </div> 26 + ``` 27 + 28 + **Pros:** 29 + 30 + - Predictable: Each root is isolated 31 + - No name collision risk across roots 32 + - Aligns with current scope model (one scope per root) 33 + - Simple to implement and reason about 34 + 35 + **Cons:** 36 + 37 + - Can't share pins across roots (must use global state instead) 38 + - No sub-scoping within a root for component-like patterns 39 + 40 + **Use Cases:** 41 + 42 + - Simple applications with clear root boundaries 43 + - When each `[data-volt]` represents a distinct feature/component 44 + 45 + --- 46 + 47 + ## Alternative 1: Explicit Scope Boundaries (data-volt-scope) 48 + 49 + **How it works:** 50 + 51 + - Introduce `data-volt-scope` attribute to create nested scopes 52 + - Pins registered within a scope are isolated to that scope and its descendants 53 + - `$pins` searches up the scope chain 54 + 55 + **Example:** 56 + 57 + ```html 58 + <div data-volt> 59 + <div data-volt-scope="form1"> 60 + <input data-volt-pin="username" /> 61 + <button data-volt-on-click="$pins.username.focus()">Focus</button> 62 + </div> 63 + 64 + <div data-volt-scope="form2"> 65 + <input data-volt-pin="username" /> <!-- Different scope --> 66 + <button data-volt-on-click="$pins.username.focus()">Focus</button> 67 + </div> 68 + </div> 69 + ``` 70 + 71 + **Pros:** 72 + 73 + - Fine-grained control over scope boundaries 74 + - Supports nested component patterns 75 + - Can isolate widgets within a larger root 76 + 77 + **Cons:** 78 + 79 + - More complex: requires scope hierarchy tracking 80 + - Additional attribute to learn 81 + - Lookup complexity (walking scope chain) 82 + - Breaks current 1:1 scope-to-root model 83 + 84 + **Use Cases:** 85 + 86 + - Large applications with reusable sub-components 87 + - When you need multiple isolated widgets within one root 88 + - Form libraries with nested fieldsets 89 + 90 + **Implementation Complexity:** 91 + 92 + - Requires scope hierarchy (parent references) 93 + - WeakMap must track scope chains 94 + - Pin lookup becomes recursive 95 + 96 + ## Alternative 2: Global Document-Wide Registry 97 + 98 + **How it works:** 99 + 100 + - Single global pin registry for entire document 101 + - All pins accessible from any scope 102 + - Names must be unique across the entire page 103 + 104 + **Example:** 105 + 106 + ```html 107 + <div data-volt> 108 + <input data-volt-pin="username" /> 109 + </div> 110 + 111 + <div data-volt> 112 + <!-- Can access pins from other roots --> 113 + <button data-volt-on-click="$pins.username.focus()">Focus</button> 114 + </div> 115 + ``` 116 + 117 + **Pros:** 118 + 119 + - Simplest to understand: flat namespace 120 + - Easy to share element references across roots 121 + - No scoping complexity 122 + 123 + **Cons:** 124 + 125 + - High risk of name collisions 126 + - No isolation between roots (breaks encapsulation) 127 + - Debugging becomes harder (where is this pin defined?) 128 + - Not composable (can't have two instances of same component) 129 + 130 + **Use Cases:** 131 + 132 + - Prototypes and simple pages 133 + - Single-page applications with unique IDs everywhere 134 + - When cross-root communication is primary goal 135 + 136 + **Implementation Complexity:** 137 + 138 + - Simple: single Map instead of per-scope maps 139 + - No WeakMap needed 140 + 141 + ## Comparison Table 142 + 143 + | Aspect | Root Scoping (Current) | Explicit Scopes | Global | 144 + |--------|------------------------|-----------------|--------| 145 + | Isolation | Per root | Per scope boundary | None | 146 + | Name Collisions | Safe within root | Safe within scope | High risk | 147 + | Complexity | Low | Medium | Very Low | 148 + | Cross-root Access | Not supported | Not supported | Supported | 149 + | Nested Components | Not supported | Supported | Not needed | 150 + | Implementation | Simple | Complex | Trivial | 151 + | Composability | Good | Excellent | Poor | 152 + 153 + ## Decision Rationale 154 + 155 + **Current implementation uses Root Scoping** for the following reasons: 156 + 157 + 1. **Aligns with existing architecture**: VoltX already uses one scope per `[data-volt]` root 158 + 2. **Simplicity**: No additional concepts or attributes to learn 159 + 3. **Good enough**: Most use cases don't require nested scopes 160 + 4. **Future extensibility**: Can add `data-volt-scope` later if needed (additive change) 161 + 162 + **When to reconsider:** 163 + 164 + - If users frequently request nested component isolation 165 + - If framework adds first-class component system 166 + - If cross-root pin access becomes a common need (could add `data-volt-pin-global`) 167 + 168 + ## Migration Path 169 + 170 + If we later adopt Alternative 1 (Explicit Scopes): 171 + 172 + 1. Keep current behavior as default 173 + 2. Add `data-volt-scope` for opt-in nested scopes 174 + 3. Update metadata to track parent scopes 175 + 4. Modify `getPin()` to walk scope chain 176 + 177 + This would be backward compatible since existing code without `data-volt-scope` would continue to work.
+527
docs/global-state.md
··· 1 + # Global State 2 + 3 + VoltX provides built-in global state management through special variables and the global store. These features enable sharing state across components, accessing metadata, and coordinating behavior without external dependencies. 4 + 5 + ## Overview 6 + 7 + Every Volt scope automatically receives special variables (prefixed with `$`) that provide access to: 8 + 9 + - **Global Store** - Shared reactive state across all scopes 10 + - **Scope Metadata** - Information about the current reactive context 11 + - **Element References** - Access to pinned DOM elements 12 + - **Utility Functions** - Helper functions for common tasks 13 + 14 + ## Special Variables 15 + 16 + ### `$store` 17 + 18 + Access globally shared reactive state across all Volt roots. 19 + 20 + **Declarative API:** 21 + 22 + ```html 23 + <!-- Define global store --> 24 + <script type="application/json" data-volt-store> 25 + { 26 + "theme": "dark", 27 + "user": { "name": "Alice" } 28 + } 29 + </script> 30 + 31 + <!-- Use in any Volt root --> 32 + <div data-volt> 33 + <p data-volt-text="$store.get('theme')"></p> 34 + <button data-volt-on-click="$store.set('theme', 'light')">Toggle</button> 35 + </div> 36 + ``` 37 + 38 + **Programmatic API:** 39 + 40 + ```typescript 41 + import { registerStore, getStore } from 'voltx.js'; 42 + 43 + // Register store with signals or raw values 44 + registerStore({ 45 + theme: signal('dark'), 46 + count: 0 // Auto-wrapped in signal 47 + }); 48 + 49 + // Access store 50 + const store = getStore(); 51 + store.set('count', 5); 52 + console.log(store.get('count')); // 5 53 + ``` 54 + 55 + **Methods:** 56 + 57 + - `$store.get(key)` - Get signal value 58 + - `$store.set(key, value)` - Update signal value 59 + - `$store.has(key)` - Check if key exists 60 + - `$store[key]` - Direct signal access 61 + 62 + ### `$origin` 63 + 64 + Reference to the root element of the current reactive scope. 65 + 66 + ```html 67 + <div data-volt id="app-root"> 68 + <p data-volt-text="'Root ID: ' + $origin.id"></p> 69 + <!-- Displays: "Root ID: app-root" --> 70 + </div> 71 + ``` 72 + 73 + ### `$scope` 74 + 75 + Direct access to the raw scope object containing all signals and context. 76 + 77 + ```html 78 + <div data-volt data-volt-state='{"count": 0}'> 79 + <p data-volt-text="Object.keys($scope).length"></p> 80 + <!-- Shows number of scope properties --> 81 + </div> 82 + ``` 83 + 84 + ### `$pins` 85 + 86 + Access DOM elements registered with `data-volt-pin`. 87 + 88 + ```html 89 + <div data-volt> 90 + <input data-volt-pin="username" /> 91 + <input data-volt-pin="password" type="password" /> 92 + 93 + <button data-volt-on-click="$pins.username.focus()"> 94 + Focus Username 95 + </button> 96 + 97 + <button data-volt-on-click="$pins.password.value = ''"> 98 + Clear Password 99 + </button> 100 + </div> 101 + ``` 102 + 103 + **Notes:** 104 + 105 + - Pins are scoped to their root element 106 + - Each root maintains its own pin registry 107 + - Pins are accessible immediately after registration 108 + 109 + ### `$pulse(callback)` 110 + 111 + Defers callback execution to the next microtask, ensuring DOM updates have completed. 112 + 113 + ```html 114 + <div data-volt data-volt-state='{"count": 0}'> 115 + <button data-volt-on-click="count.set(count.get() + 1); $pulse(() => console.log('Updated!'))"> 116 + Increment 117 + </button> 118 + </div> 119 + ``` 120 + 121 + **Use Cases:** 122 + 123 + - Run code after DOM updates 124 + - Coordinate async operations 125 + - Batch multiple updates 126 + 127 + ### `$uid(prefix?)` 128 + 129 + Generates unique, deterministic IDs within the scope. 130 + 131 + ```html 132 + <div data-volt> 133 + <input data-volt-bind:id="$uid('field')" /> 134 + <!-- id="volt-field-1" --> 135 + 136 + <input data-volt-bind:id="$uid('field')" /> 137 + <!-- id="volt-field-2" --> 138 + 139 + <input data-volt-bind:id="$uid()" /> 140 + <!-- id="volt-3" --> 141 + </div> 142 + ``` 143 + 144 + **Notes:** 145 + 146 + - IDs are unique within the scope 147 + - Counter increments on each call 148 + - Different scopes have independent counters 149 + 150 + ### `$arc(eventName, detail?)` 151 + 152 + Dispatches a CustomEvent from the current element. 153 + 154 + ```html 155 + <div data-volt data-volt-on-user:save="console.log('Saved:', $event.detail)"> 156 + <button data-volt-on-click="$arc('user:save', { id: 123, name: 'Alice' })"> 157 + Save User 158 + </button> 159 + </div> 160 + ``` 161 + 162 + **Event Properties:** 163 + 164 + - `bubbles: true` - Event bubbles up the DOM 165 + - `composed: true` - Crosses shadow DOM boundaries 166 + - `cancelable: true` - Can be prevented 167 + - `detail` - Custom data payload 168 + 169 + ### `$probe(expression, callback)` 170 + 171 + Observes a reactive expression and calls a callback when dependencies change. 172 + 173 + ```html 174 + <div data-volt data-volt-state='{"count": 0}' data-volt-init="$probe('count', v => console.log('Count:', v))"> 175 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 176 + <!-- Logs: "Count: 0" immediately, then "Count: 1", "Count: 2", etc. --> 177 + </div> 178 + ``` 179 + 180 + **Parameters:** 181 + 182 + - `expression` (string) - Reactive expression to observe 183 + - `callback` (function) - Called with expression value on changes 184 + 185 + **Returns:** 186 + 187 + - Cleanup function to stop observing 188 + 189 + **Example:** 190 + 191 + ```html 192 + <div data-volt 193 + data-volt-state='{"x": 0, "y": 0}' 194 + data-volt-init="const cleanup = $probe('x + y', sum => console.log('Sum:', sum))"> 195 + 196 + <button data-volt-on-click="x.set(x.get() + 1)">+X</button> 197 + <button data-volt-on-click="y.set(y.get() + 1)">+Y</button> 198 + 199 + <!-- Logs: "Sum: 0" initially, then on every change --> 200 + </div> 201 + ``` 202 + 203 + ## `data-volt-init` 204 + 205 + Run initialization code once when an element is mounted. 206 + 207 + **Basic Usage:** 208 + 209 + ```html 210 + <div data-volt 211 + data-volt-state='{"initialized": false}' 212 + data-volt-init="initialized.set(true)"> 213 + 214 + <p data-volt-text="initialized"></p> 215 + <!-- Displays: true --> 216 + </div> 217 + ``` 218 + 219 + **Setting Up Observers:** 220 + 221 + ```html 222 + <div data-volt 223 + data-volt-state='{"count": 0, "log": []}' 224 + data-volt-init="$probe('count', v => log.push(v))"> 225 + 226 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 227 + <p data-volt-text="log.join(', ')"></p> 228 + <!-- Displays: "0, 1, 2, ..." --> 229 + </div> 230 + ``` 231 + 232 + **Accessing Special Variables:** 233 + 234 + ```html 235 + <div data-volt 236 + id="main" 237 + data-volt-state='{"rootId": ""}' 238 + data-volt-init="rootId.set($origin.id)"> 239 + 240 + <p data-volt-text="rootId"></p> 241 + <!-- Displays: "main" --> 242 + </div> 243 + ``` 244 + 245 + ## Global Store Patterns 246 + 247 + ### Shared Application State 248 + 249 + ```html 250 + <!-- Define global state once --> 251 + <script type="application/json" data-volt-store> 252 + { 253 + "theme": "light", 254 + "user": null, 255 + "authenticated": false 256 + } 257 + </script> 258 + 259 + <!-- Header component --> 260 + <div data-volt> 261 + <div data-volt-class="$store.get('theme')"> 262 + <button data-volt-on-click="$store.set('theme', $store.get('theme') === 'light' ? 'dark' : 'light')"> 263 + Toggle Theme 264 + </button> 265 + </div> 266 + </div> 267 + 268 + <!-- User profile --> 269 + <div data-volt> 270 + <div data-volt-if="$store.get('authenticated')"> 271 + <p data-volt-text="'Welcome, ' + $store.get('user').name"></p> 272 + </div> 273 + </div> 274 + ``` 275 + 276 + ### Cross-Component Communication 277 + 278 + ```html 279 + <script type="application/json" data-volt-store> 280 + { 281 + "selectedId": null, 282 + "items": [] 283 + } 284 + </script> 285 + 286 + <!-- Item list --> 287 + <div data-volt> 288 + <div data-volt-for="item in $store.get('items')"> 289 + <button data-volt-on-click="$store.set('selectedId', item.id)" data-volt-text="item.name"></button> 290 + </div> 291 + </div> 292 + 293 + <!-- Item details --> 294 + <div data-volt> 295 + <div data-volt-if="$store.get('selectedId')"> 296 + <p data-volt-text="'Selected: ' + $store.get('selectedId')"></p> 297 + </div> 298 + </div> 299 + ``` 300 + 301 + ### Persistent Global State 302 + 303 + ```typescript 304 + import { registerStore, getStore } from 'voltx.js'; 305 + import { registerPlugin, persistPlugin } from 'voltx.js'; 306 + 307 + // Register persist plugin 308 + registerPlugin('persist', persistPlugin); 309 + 310 + // Initialize store with persisted values 311 + const saved = localStorage.getItem('app-store'); 312 + const initialState = saved ? JSON.parse(saved) : { theme: 'light', user: null }; 313 + 314 + registerStore(initialState); 315 + 316 + // Save on changes 317 + const store = getStore(); 318 + const originalSet = store.set.bind(store); 319 + store.set = (key, value) => { 320 + originalSet(key, value); 321 + localStorage.setItem('app-store', JSON.stringify({ 322 + theme: store.get('theme'), 323 + user: store.get('user') 324 + })); 325 + }; 326 + ``` 327 + 328 + ## Best Practices 329 + 330 + ### Use `$store` for Shared State 331 + 332 + Global state should live in `$store`: 333 + 334 + ```html 335 + <!-- Good: Shared theme in store --> 336 + <script type="application/json" data-volt-store> 337 + { "theme": "dark" } 338 + </script> 339 + 340 + <div data-volt> 341 + <p data-volt-class="$store.get('theme')">Content</p> 342 + </div> 343 + ``` 344 + 345 + ### Use `$pins` for Element Access 346 + 347 + Access DOM elements through pins instead of `querySelector`: 348 + 349 + ```html 350 + <!-- Good: Using pins --> 351 + <div data-volt> 352 + <input data-volt-pin="username" /> 353 + <button data-volt-on-click="$pins.username.focus()">Focus</button> 354 + </div> 355 + 356 + <!-- Avoid: Manual querySelector --> 357 + <div data-volt> 358 + <input id="username" /> 359 + <button data-volt-on-click="document.querySelector('#username').focus()">Focus</button> 360 + </div> 361 + ``` 362 + 363 + ### Use `data-volt-init` for Setup 364 + 365 + Initialize observers and one-time setup in `data-volt-init`: 366 + 367 + ```html 368 + <div data-volt 369 + data-volt-state='{"count": 0}' 370 + data-volt-init="$probe('count', v => console.log('Count:', v))"> 371 + 372 + <!-- Component content --> 373 + </div> 374 + ``` 375 + 376 + ### Scope Pin Names Appropriately 377 + 378 + Use descriptive pin names and avoid collisions: 379 + 380 + ```html 381 + <!-- Good: Descriptive names --> 382 + <div data-volt> 383 + <input data-volt-pin="searchInput" /> 384 + <input data-volt-pin="filterInput" /> 385 + </div> 386 + 387 + <!-- Avoid: Generic names that might collide --> 388 + <div data-volt> 389 + <input data-volt-pin="input" /> 390 + <input data-volt-pin="input2" /> 391 + </div> 392 + ``` 393 + 394 + ### Clean Up Observers 395 + 396 + Always clean up `$probe` observers when no longer needed: 397 + 398 + ```html 399 + <div data-volt 400 + data-volt-state='{"active": true}' 401 + data-volt-init="const cleanup = $probe('active', v => console.log(v))"> 402 + 403 + <button data-volt-on-click="active.set(false); cleanup()">Deactivate</button> 404 + </div> 405 + ``` 406 + 407 + ## Examples 408 + 409 + ### Todo App with Global State 410 + 411 + ```html 412 + <script type="application/json" data-volt-store> 413 + { 414 + "todos": [], 415 + "filter": "all" 416 + } 417 + </script> 418 + 419 + <!-- Add todo form --> 420 + <div data-volt data-volt-state='{"newTodo": ""}'> 421 + <input data-volt-model="newTodo" data-volt-pin="todoInput" /> 422 + <button data-volt-on-click="$store.get('todos').push({ text: newTodo.get(), done: false }); newTodo.set(''); $pins.todoInput.focus()"> 423 + Add 424 + </button> 425 + </div> 426 + 427 + <!-- Filter buttons --> 428 + <div data-volt> 429 + <button data-volt-on-click="$store.set('filter', 'all')">All</button> 430 + <button data-volt-on-click="$store.set('filter', 'active')">Active</button> 431 + <button data-volt-on-click="$store.set('filter', 'done')">Done</button> 432 + </div> 433 + 434 + <!-- Todo list --> 435 + <div data-volt> 436 + <div data-volt-for="todo in $store.get('todos')"> 437 + <div data-volt-if="$store.get('filter') === 'all' || ($store.get('filter') === 'done' && todo.done) || ($store.get('filter') === 'active' && !todo.done)"> 438 + <input type="checkbox" data-volt-bind:checked="todo.done" /> 439 + <span data-volt-text="todo.text"></span> 440 + </div> 441 + </div> 442 + </div> 443 + ``` 444 + 445 + ### Multi-Step Form 446 + 447 + ```html 448 + <script type="application/json" data-volt-store> 449 + { 450 + "step": 1, 451 + "formData": { "name": "", "email": "", "phone": "" } 452 + } 453 + </script> 454 + 455 + <div data-volt> 456 + <!-- Step indicator --> 457 + <p data-volt-text="'Step ' + $store.get('step') + ' of 3'"></p> 458 + 459 + <!-- Step 1: Name --> 460 + <div data-volt-if="$store.get('step') === 1"> 461 + <input data-volt-model="$store.get('formData').name" placeholder="Name" /> 462 + <button data-volt-on-click="$store.set('step', 2)">Next</button> 463 + </div> 464 + 465 + <!-- Step 2: Email --> 466 + <div data-volt-if="$store.get('step') === 2"> 467 + <input data-volt-model="$store.get('formData').email" placeholder="Email" /> 468 + <button data-volt-on-click="$store.set('step', 1)">Back</button> 469 + <button data-volt-on-click="$store.set('step', 3)">Next</button> 470 + </div> 471 + 472 + <!-- Step 3: Phone --> 473 + <div data-volt-if="$store.get('step') === 3"> 474 + <input data-volt-model="$store.get('formData').phone" placeholder="Phone" /> 475 + <button data-volt-on-click="$store.set('step', 2)">Back</button> 476 + <button data-volt-on-click="console.log('Submit:', $store.get('formData'))">Submit</button> 477 + </div> 478 + </div> 479 + ``` 480 + 481 + ## API Reference 482 + 483 + ### `registerStore(state)` 484 + 485 + Register global store state programmatically. 486 + 487 + ```typescript 488 + import { registerStore } from 'voltx.js'; 489 + import { signal } from 'voltx.js'; 490 + 491 + registerStore({ 492 + theme: signal('dark'), // Existing signal 493 + count: 0 // Auto-wrapped 494 + }); 495 + ``` 496 + 497 + ### `getStore()` 498 + 499 + Get the global store instance. 500 + 501 + ```typescript 502 + import { getStore } from 'voltx.js'; 503 + 504 + const store = getStore(); 505 + store.set('theme', 'light'); 506 + console.log(store.get('theme')); // 'light' 507 + console.log(store.has('theme')); // true 508 + ``` 509 + 510 + ### `getScopeMetadata(scope)` 511 + 512 + Get metadata for a scope (advanced use). 513 + 514 + ```typescript 515 + import { getScopeMetadata } from 'voltx.js'; 516 + 517 + const metadata = getScopeMetadata(scope); 518 + console.log(metadata.origin); // Root element 519 + console.log(metadata.pins); // Pin registry 520 + console.log(metadata.uidCounter); // Current UID counter 521 + ``` 522 + 523 + ## See Also 524 + 525 + - [Reactivity](/reactivity) - Understanding signals and computed values 526 + - [Plugins](/plugins) - Extending Volt with plugins 527 + - [HTTP Actions](/http) - Server communication patterns
+132 -35
lib/src/core/binder.ts
··· 2 2 * Binder system for mounting and managing Volt.js bindings 3 3 */ 4 4 5 - import type { Optional } from "$types/helpers"; 5 + import type { Nullable, Optional } from "$types/helpers"; 6 6 import type { 7 7 BindingContext, 8 8 CleanupFunction, ··· 15 15 } from "$types/volt"; 16 16 import { BOOLEAN_ATTRS } from "./constants"; 17 17 import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 18 - import { evaluate, extractDeps } from "./evaluator"; 18 + import { evaluate } from "./evaluator"; 19 19 import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; 20 20 import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 21 21 import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; 22 22 import { getPlugin } from "./plugin"; 23 - import { findScopedSignal, isNil } from "./shared"; 23 + import { createScopeMetadata, getPin, registerPin } from "./scope-metadata"; 24 + import { createArc, createProbe, createPulse, createUid } from "./scope-vars"; 25 + import { findScopedSignal, isNil, updateAndRegister } from "./shared"; 26 + import { getStore } from "./store"; 24 27 25 28 /** 26 29 * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. ··· 30 33 * @returns Cleanup function to unmount and dispose all bindings. 31 34 */ 32 35 export function mount(root: Element, scope: Scope): CleanupFunction { 36 + injectSpecialVars(scope, root); 33 37 execGlobalHooks("beforeMount", root, scope); 34 38 35 39 const allElements = walkDOM(root); 36 40 37 41 const elements = allElements.filter((element) => { 38 - let current: Element | null = element; 42 + let current: Nullable<Element> = element; 39 43 while (current) { 40 44 if (Object.hasOwn((current as HTMLElement).dataset, "voltSkip")) { 41 45 return false; ··· 168 172 bindModel(ctx, value, modifiers); 169 173 break; 170 174 } 175 + case "pin": { 176 + bindPin(ctx, value); 177 + break; 178 + } 179 + case "init": { 180 + bindInit(ctx, value); 181 + break; 182 + } 171 183 case "for": { 172 184 bindFor(ctx, value); 173 185 break; ··· 222 234 setHTML(ctx.element, String(value ?? "")); 223 235 } 224 236 }; 225 - update(); 226 - 227 - const deps = extractDeps(expr, ctx.scope); 228 - for (const dep of deps) { 229 - const unsubscribe = dep.subscribe(update); 230 - ctx.cleanups.push(unsubscribe); 231 - } 237 + updateAndRegister(ctx, update, expr); 232 238 }; 233 - } 234 - 235 - /** 236 - * Helper function to execute an update function and subscribe to all signal dependencies. 237 - * Used by bindings that need reactive updates (class, show, style, for, if). 238 - */ 239 - function updateAndUnsub(ctx: BindingContext, update: () => void, expr: string) { 240 - update(); 241 - const deps = extractDeps(expr, ctx.scope); 242 - for (const dep of deps) { 243 - const unsubscribe = dep.subscribe(update); 244 - ctx.cleanups.push(unsubscribe); 245 - } 246 239 } 247 240 248 241 /** ··· 269 262 prevClasses = classes; 270 263 }; 271 264 272 - updateAndUnsub(ctx, update, expr); 265 + updateAndRegister(ctx, update, expr); 273 266 } 274 267 275 268 /** ··· 291 284 } 292 285 }; 293 286 294 - updateAndUnsub(ctx, update, expr); 287 + updateAndRegister(ctx, update, expr); 295 288 } 296 289 297 290 /** ··· 325 318 } 326 319 }; 327 320 328 - updateAndUnsub(ctx, update, expr); 321 + updateAndRegister(ctx, update, expr); 322 + } 323 + 324 + function extractStatements(expr: string) { 325 + const statements: string[] = []; 326 + let current = ""; 327 + let depth = 0; 328 + let inString: string | null = null; 329 + 330 + for (const [i, char] of [...expr].entries()) { 331 + const prev = i > 0 ? expr[i - 1] : ""; 332 + 333 + if ((char === "\"" || char === "'") && prev !== "\\") { 334 + if (inString === char) { 335 + inString = null; 336 + } else if (inString === null) { 337 + inString = char; 338 + } 339 + } 340 + 341 + if (inString === null) { 342 + if (char === "(" || char === "{" || char === "[") { 343 + depth++; 344 + } else if (char === ")" || char === "}" || char === "]") { 345 + depth--; 346 + } 347 + } 348 + 349 + if (char === ";" && depth === 0 && inString === null) { 350 + if (current.trim()) { 351 + statements.push(current.trim()); 352 + } 353 + current = ""; 354 + } else { 355 + current += char; 356 + } 357 + } 358 + 359 + if (current.trim()) { 360 + statements.push(current.trim()); 361 + } 362 + 363 + return statements; 329 364 } 330 365 331 366 /** ··· 348 383 const eventScope: Scope = { ...ctx.scope, $el: ctx.element, $event: event }; 349 384 350 385 try { 351 - const result = evaluate(expr, eventScope); 386 + const statements = extractStatements(expr); 387 + let result: unknown; 388 + for (const stmt of statements) { 389 + result = evaluate(stmt, eventScope); 390 + } 391 + 352 392 if (typeof result === "function") { 353 393 result(event); 354 394 } ··· 560 600 } 561 601 }; 562 602 563 - update(); 603 + updateAndRegister(ctx, update, expr); 604 + } 564 605 565 - const deps = extractDeps(expr, ctx.scope); 566 - for (const dep of deps) { 567 - const unsubscribe = dep.subscribe(update); 568 - ctx.cleanups.push(unsubscribe); 606 + /** 607 + * Bind data-volt-init to run initialization code once when the element is mounted. 608 + */ 609 + function bindInit(ctx: BindingContext, expr: string): void { 610 + try { 611 + const statements = extractStatements(expr); 612 + for (const stmt of statements) { 613 + evaluate(stmt, ctx.scope); 614 + } 615 + } catch (error) { 616 + console.error("Error in data-volt-init:", error); 569 617 } 570 618 } 571 619 572 620 /** 621 + * Bind data-volt-pin to register an element reference in the scope's pin registry. 622 + * Makes the element accessible via $pins.name ($pins[name]) in expressions and event handlers. 623 + * 624 + * @example 625 + * ```html 626 + * <input data-volt-pin="username" /> 627 + * <button data-volt-on-click="$pins.username.focus()">Focus Input</button> 628 + * ``` 629 + */ 630 + function bindPin(ctx: BindingContext, name: string): void { 631 + registerPin(ctx.scope, name, ctx.element); 632 + } 633 + 634 + /** 573 635 * Bind data-volt-for to render a list of items. 574 636 * Subscribes to array signal and re-renders when array changes. 575 637 */ ··· 629 691 } 630 692 }; 631 693 632 - updateAndUnsub(ctx, render, expr); 694 + updateAndRegister(ctx, render, expr); 633 695 634 696 ctx.cleanups.push(() => { 635 697 for (const cleanup of renderedCleanups) { ··· 707 769 } 708 770 }; 709 771 710 - updateAndUnsub(ctx, render, expr); 772 + updateAndRegister(ctx, render, expr); 711 773 712 774 ctx.cleanups.push(() => { 713 775 if (currentCleanup) { ··· 799 861 lifecycle, 800 862 }; 801 863 } 864 + 865 + /** 866 + * Inject special variables ($store, $origin, $scope, $pins, $pulse, $uid, $arc, $probe) 867 + * into the scope for this root element. 868 + * 869 + * Creates scope metadata and makes runtime utilities available in expressions. 870 + * We create a Proxy for $pins that dynamically reads from metadata to ensure pins registered later are immediately accessible 871 + */ 872 + function injectSpecialVars(scope: Scope, root: Element): void { 873 + createScopeMetadata(scope, root); 874 + 875 + scope.$store = getStore(); 876 + scope.$pulse = createPulse(); 877 + scope.$origin = root; 878 + scope.$scope = scope; 879 + 880 + scope.$pins = new Proxy({}, { 881 + get(_target, prop: string) { 882 + if (typeof prop === "string") { 883 + return getPin(scope, prop); 884 + } 885 + return void 0; 886 + }, 887 + has(_target, prop: string) { 888 + if (typeof prop === "string") { 889 + return getPin(scope, prop) !== undefined; 890 + } 891 + return false; 892 + }, 893 + }); 894 + 895 + scope.$uid = createUid(scope); 896 + scope.$arc = createArc(root); 897 + scope.$probe = createProbe(scope); 898 + }
+42
lib/src/core/charge.ts
··· 9 9 import { evaluate } from "./evaluator"; 10 10 import { getComputedAttributes, isNil } from "./shared"; 11 11 import { computed, signal } from "./signal"; 12 + import { registerStore } from "./store"; 12 13 13 14 /** 14 15 * Discover and mount all Volt roots in the document. 15 16 * Parses data-volt-state for initial state and data-volt-computed for derived values. 17 + * Also parses declarative global store from script[data-volt-store] elements. 16 18 * 17 19 * @param rootSelector - Selector for root elements (default: "[data-volt]") 18 20 * @returns ChargeResult containing mounted roots and cleanup function 19 21 * 20 22 * @example 21 23 * ```html 24 + * <!-- Global store (declarative) --> 25 + * <script type="application/json" data-volt-store> 26 + * { 27 + * "theme": "dark", 28 + * "user": { "name": "Alice" } 29 + * } 30 + * </script> 31 + * 22 32 * <div data-volt data-volt-state='{"count": 0}' data-volt-computed:double="count * 2"> 23 33 * <p data-volt-text="count"></p> 24 34 * <p data-volt-text="double"></p> 35 + * <p data-volt-text="$store.theme"></p> 25 36 * </div> 26 37 * ``` 27 38 * ··· 31 42 * ``` 32 43 */ 33 44 export function charge(rootSelector = "[data-volt]"): ChargeResult { 45 + parseDeclarativeStore(); 46 + 34 47 const elements = document.querySelectorAll(rootSelector); 35 48 const chargedRoots: ChargedRoot[] = []; 36 49 ··· 94 107 95 108 return scope; 96 109 } 110 + 111 + /** 112 + * Parse and register global store from declarative script tags. 113 + * 114 + * Looks for: <script type="application/json" data-volt-store> 115 + * Expects JSON object with key-value pairs to register in global store. 116 + */ 117 + function parseDeclarativeStore(): void { 118 + const scripts = document.querySelectorAll("script[data-volt-store][type=\"application/json\"]"); 119 + 120 + for (const script of scripts) { 121 + try { 122 + const content = script.textContent?.trim(); 123 + if (!content) continue; 124 + 125 + const data = JSON.parse(content); 126 + 127 + if (typeof data !== "object" || isNil(data) || Array.isArray(data)) { 128 + console.error("data-volt-store script must contain a JSON object, got:", typeof data); 129 + continue; 130 + } 131 + 132 + registerStore(data); 133 + } catch (error) { 134 + console.error("Failed to parse data-volt-store script:", error); 135 + console.error("Script element:", script); 136 + } 137 + } 138 + }
+2 -48
lib/src/core/evaluator.ts
··· 5 5 * Includes sandboxing to prevent prototype pollution and sandbox escape attacks. 6 6 */ 7 7 8 - import type { Dep, Scope } from "$types/volt"; 8 + import type { Scope } from "$types/volt"; 9 9 import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants"; 10 - import { findScopedSignal, isNil, isSignal } from "./shared"; 10 + import { isNil, isSignal } from "./shared"; 11 11 12 12 const dangerousProps = new Set(DANGEROUS_PROPERTIES); 13 13 const safeGlobals = new Set(SAFE_GLOBALS); ··· 871 871 return undefined; 872 872 } 873 873 } 874 - 875 - /** 876 - * Extract all signal dependencies from an expression by finding identifiers that correspond to signals in the scope. 877 - * 878 - * This function handles both simple property paths (e.g., "todo.title") and complex expressions (e.g., "email.length > 0 && emailValid"). 879 - * 880 - * @param expr - The expression to analyze 881 - * @param scope - The scope containing potential signal dependencies 882 - * @returns Array of signals found in the expression 883 - */ 884 - export function extractDeps(expr: string, scope: Scope): Array<Dep> { 885 - const deps: Array<Dep> = []; 886 - const seen = new Set<string>(); 887 - 888 - const identifierRegex = /\b([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\b/g; 889 - const matches = expr.matchAll(identifierRegex); 890 - 891 - for (const match of matches) { 892 - const path = match[1]; 893 - 894 - if (["true", "false", "null", "undefined"].includes(path)) { 895 - continue; 896 - } 897 - 898 - if (seen.has(path)) { 899 - continue; 900 - } 901 - 902 - seen.add(path); 903 - 904 - const signal = findScopedSignal(scope, path); 905 - if (signal) { 906 - deps.push(signal); 907 - continue; 908 - } 909 - 910 - const parts = path.split("."); 911 - const topLevel = parts[0]; 912 - const value = scope[topLevel]; 913 - if (isSignal(value) && !deps.includes(value)) { 914 - deps.push(value); 915 - } 916 - } 917 - 918 - return deps; 919 - }
+131
lib/src/core/scope-metadata.ts
··· 1 + /** 2 + * Scope metadata management system 3 + * 4 + * Stores metadata for each reactive scope using WeakMap to avoid polluting scope objects. 5 + * Metadata includes origin element, pin registry, UID counter, and optional parent reference. 6 + */ 7 + 8 + import type { Scope, ScopeMetadata } from "$types/volt"; 9 + 10 + /** 11 + * WeakMap storing metadata for each scope. 12 + * WeakMap ensures metadata is garbage collected when scope is no longer referenced. 13 + */ 14 + const scopeMetadataMap = new WeakMap<Scope, ScopeMetadata>(); 15 + 16 + /** 17 + * Create and store metadata for a scope. 18 + * 19 + * @param scope - The reactive scope object 20 + * @param origin - The root element that owns this scope 21 + * @param parent - Optional parent scope for debugging/inspection 22 + * @returns The created metadata object 23 + * 24 + * @example 25 + * ```ts 26 + * const scope = { count: signal(0) }; 27 + * const metadata = createScopeMetadata(scope, rootElement); 28 + * ``` 29 + */ 30 + export function createScopeMetadata(scope: Scope, origin: Element, parent?: Scope): ScopeMetadata { 31 + const metadata: ScopeMetadata = { origin, pins: new Map<string, Element>(), uidCounter: 0, parent }; 32 + 33 + scopeMetadataMap.set(scope, metadata); 34 + return metadata; 35 + } 36 + 37 + /** 38 + * Get metadata for a scope. 39 + * 40 + * @param scope - The scope to get metadata for 41 + * @returns The metadata object, or undefined if not found 42 + * 43 + * @example 44 + * ```ts 45 + * const metadata = getScopeMetadata(scope); 46 + * if (metadata) { 47 + * console.log('Origin element:', metadata.origin); 48 + * } 49 + * ``` 50 + */ 51 + export function getScopeMetadata(scope: Scope): ScopeMetadata | undefined { 52 + return scopeMetadataMap.get(scope); 53 + } 54 + 55 + /** 56 + * Register a pinned element in the scope's pin registry. 57 + * 58 + * @param scope - The scope to register the pin in 59 + * @param name - The pin name 60 + * @param element - The element to pin 61 + * 62 + * @example 63 + * ```ts 64 + * registerPin(scope, 'submitButton', buttonElement); 65 + * // Later accessible via $pins.submitButton 66 + * ``` 67 + */ 68 + export function registerPin(scope: Scope, name: string, element: Element): void { 69 + const metadata = scopeMetadataMap.get(scope); 70 + if (metadata) { 71 + metadata.pins.set(name, element); 72 + } 73 + } 74 + 75 + /** 76 + * Get a pinned element by name from the scope. 77 + * 78 + * @param scope - The scope to search in 79 + * @param name - The pin name to retrieve 80 + * @returns The pinned element, or undefined if not found 81 + * 82 + * @example 83 + * ```ts 84 + * const button = getPin(scope, 'submitButton'); 85 + * if (button) { 86 + * button.focus(); 87 + * } 88 + * ``` 89 + */ 90 + export function getPin(scope: Scope, name: string): Element | undefined { 91 + const metadata = scopeMetadataMap.get(scope); 92 + return metadata?.pins.get(name); 93 + } 94 + 95 + /** 96 + * Get all pins for a scope as a record object. 97 + * This is what gets injected as $pins in the scope. 98 + * 99 + * @param scope - The scope to get pins for 100 + * @returns Record mapping pin names to elements 101 + * 102 + * @example 103 + * ```ts 104 + * const pins = getPins(scope); 105 + * // Access as: pins.submitButton, pins.inputField, etc. 106 + * ``` 107 + */ 108 + export function getPins(scope: Scope): Record<string, Element> { 109 + const metadata = scopeMetadataMap.get(scope); 110 + if (!metadata) return {}; 111 + 112 + const pins: Record<string, Element> = {}; 113 + for (const [name, element] of metadata.pins) { 114 + pins[name] = element; 115 + } 116 + return pins; 117 + } 118 + 119 + /** 120 + * Increment and return the UID counter for a scope. 121 + * Used internally by $uid() to generate deterministic IDs. 122 + * 123 + * @param scope - The scope to increment counter for 124 + * @returns The next UID number 125 + */ 126 + export function incrementUidCounter(scope: Scope): number { 127 + const metadata = scopeMetadataMap.get(scope); 128 + if (!metadata) return 0; 129 + 130 + return ++metadata.uidCounter; 131 + }
+142
lib/src/core/scope-vars.ts
··· 1 + /** 2 + * Special scope variables ($pulse, $uid, $arc, $probe) 3 + * 4 + * Factory functions for creating the special `$` variables that are injected into scopes. 5 + * These provide runtime utilities accessible in expressions and event handlers. 6 + */ 7 + 8 + import type { ArcFunction, CleanupFunction, ProbeFunction, PulseFunction, Scope, UidFunction } from "$types/volt"; 9 + import { evaluate } from "./evaluator"; 10 + import { incrementUidCounter } from "./scope-metadata"; 11 + import { extractDeps } from "./shared"; 12 + 13 + /** 14 + * Create the $pulse function for deferring execution to next microtask. 15 + * 16 + * $pulse() schedules a callback to run after the current execution context completes, 17 + * ensuring that DOM updates have been applied before the callback runs. 18 + * 19 + * @returns The $pulse function 20 + * 21 + * @example 22 + * ```html 23 + * <button data-volt-on-click="count++; $pulse(() => console.log('DOM updated'))"> 24 + * Increment 25 + * </button> 26 + * ``` 27 + */ 28 + export function createPulse(): PulseFunction { 29 + return (cb: () => void) => { 30 + queueMicrotask(cb); 31 + }; 32 + } 33 + 34 + /** 35 + * Create the $uid function for generating unique, deterministic IDs within a scope. 36 + * 37 + * Each call increments a counter specific to the scope, ensuring IDs are unique 38 + * within that scope and deterministic across renders. 39 + * 40 + * @param scope - The reactive scope 41 + * @returns The $uid function 42 + * 43 + * @example 44 + * ```html 45 + * <input data-volt-bind:id="$uid('field')" /> 46 + * <!-- Generates: "volt-field-1", "volt-field-2", etc. --> 47 + * ``` 48 + */ 49 + export function createUid(scope: Scope): UidFunction { 50 + return (prefix?: string) => { 51 + const counter = incrementUidCounter(scope); 52 + return prefix ? `volt-${prefix}-${counter}` : `volt-${counter}`; 53 + }; 54 + } 55 + 56 + /** 57 + * Create the $arc function for dispatching CustomEvents from an element. 58 + * 59 + * $arc() provides a simple way to emit custom events with optional detail data. 60 + * The event bubbles by default and can be caught by parent elements. 61 + * 62 + * @param element - The element to dispatch events from 63 + * @returns The $arc function 64 + * 65 + * @example 66 + * ```html 67 + * <button data-volt-on-click="$arc('user:save', { id: userId })"> 68 + * Save 69 + * </button> 70 + * 71 + * <div data-volt-on-user:save="handleSave($event.detail)"> 72 + * <!-- Catches the custom event --> 73 + * </div> 74 + * ``` 75 + */ 76 + export function createArc(element: Element): ArcFunction { 77 + return (eventName: string, detail?: unknown) => { 78 + const event = new CustomEvent(eventName, { detail, bubbles: true, composed: true, cancelable: true }); 79 + element.dispatchEvent(event); 80 + }; 81 + } 82 + 83 + /** 84 + * Create the $probe function for imperatively observing reactive expressions. 85 + * 86 + * $probe() creates an effect that watches an expression and calls a callback 87 + * whenever the expression's dependencies change. The callback is invoked immediately 88 + * with the initial value, then whenever any dependency changes. 89 + * 90 + * @param scope - The reactive scope 91 + * @returns The $probe function 92 + * 93 + * @example 94 + * ```html 95 + * <div data-volt-init="$probe('count', v => console.log('Count changed:', v))"> 96 + * <!-- Logs whenever count changes --> 97 + * </div> 98 + * ``` 99 + */ 100 + export function createProbe(scope: Scope): ProbeFunction { 101 + return (expr: string, cb: (value: unknown) => void): CleanupFunction => { 102 + const unsubs: Array<() => void> = []; 103 + let isDisposed = false; 104 + 105 + const runProbe = () => { 106 + if (isDisposed) { 107 + return; 108 + } 109 + 110 + try { 111 + const value = evaluate(expr, scope); 112 + cb(value); 113 + } catch (error) { 114 + console.error("Error in $probe expression:", error); 115 + } 116 + }; 117 + 118 + try { 119 + const deps = extractDeps(expr, scope); 120 + 121 + for (const dep of deps) { 122 + const unsubscribe = dep.subscribe(runProbe); 123 + unsubs.push(unsubscribe); 124 + } 125 + } catch (error) { 126 + console.error("Error extracting dependencies for $probe:", error); 127 + } 128 + 129 + runProbe(); 130 + return () => { 131 + isDisposed = true; 132 + 133 + for (const unsub of unsubs) { 134 + try { 135 + unsub(); 136 + } catch (error) { 137 + console.error("Error unsubscribing $probe:", error); 138 + } 139 + } 140 + }; 141 + }; 142 + }
+86 -1
lib/src/core/shared.ts
··· 1 + /** 2 + * @packageDocumentation Shared module 3 + * 4 + * functions exported from this module should only depend on types and other helpers 5 + */ 1 6 import type { None, Optional } from "$types/helpers"; 2 - import type { Dep, Scope, Signal } from "$types/volt"; 7 + import type { BindingContext, Dep, Scope, Signal } from "$types/volt"; 3 8 4 9 export function kebabToCamel(str: string): string { 5 10 return str.replaceAll(/-([a-z])/g, (_, letter) => letter.toUpperCase()); ··· 69 74 export function sleep(ms: number): Promise<void> { 70 75 return new Promise((resolve) => setTimeout(resolve, ms)); 71 76 } 77 + 78 + /** 79 + * Extract all signal dependencies from an expression by finding identifiers that correspond to signals in the scope. 80 + * 81 + * This function handles both simple property paths (e.g., "todo.title") and complex expressions (e.g., "email.length > 0 && emailValid"). 82 + * It also handles special $store.get() and $store.set() calls by extracting the key and finding the underlying signal. 83 + * 84 + * @param expr - The expression to analyze 85 + * @param scope - The scope containing potential signal dependencies 86 + * @returns Array of signals found in the expression 87 + */ 88 + export function extractDeps(expr: string, scope: Scope): Array<Dep> { 89 + const deps: Array<Dep> = []; 90 + const seen = new Set<string>(); 91 + const storeCalls = expr.matchAll(/\$store\.(get|set|has)\s*\(\s*['"]([^'"]+)['"]\s*(?:,|\))/g); 92 + 93 + for (const match of storeCalls) { 94 + const key = match[2]; 95 + const storeKey = `$store.${key}`; 96 + 97 + if (seen.has(storeKey)) { 98 + continue; 99 + } 100 + 101 + seen.add(storeKey); 102 + 103 + const store = scope.$store; 104 + if (store && typeof store === "object" && "_signals" in store) { 105 + const storeSignals = store._signals as Map<string, Signal<unknown>>; 106 + const signal = storeSignals.get(key); 107 + if (signal && !deps.includes(signal)) { 108 + deps.push(signal); 109 + } 110 + } 111 + } 112 + 113 + const matches = expr.matchAll(/\b([a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)*)\b/g); 114 + 115 + for (const match of matches) { 116 + const path = match[1]; 117 + 118 + if (["true", "false", "null", "undefined"].includes(path)) { 119 + continue; 120 + } 121 + 122 + if (seen.has(path)) { 123 + continue; 124 + } 125 + 126 + seen.add(path); 127 + 128 + const signal = findScopedSignal(scope, path); 129 + if (signal) { 130 + deps.push(signal); 131 + continue; 132 + } 133 + 134 + const parts = path.split("."); 135 + const topLevel = parts[0]; 136 + const value = scope[topLevel]; 137 + if (isSignal(value) && !deps.includes(value)) { 138 + deps.push(value); 139 + } 140 + } 141 + 142 + return deps; 143 + } 144 + 145 + /** 146 + * Helper function to execute an update function and subscribe to all signal dependencies. 147 + * Used by bindings that need reactive updates (class, show, style, for, if) to register cleanup functions. 148 + */ 149 + export function updateAndRegister(ctx: BindingContext, update: () => void, expr: string) { 150 + update(); 151 + const deps = extractDeps(expr, ctx.scope); 152 + for (const dep of deps) { 153 + const unsubscribe = dep.subscribe(update); 154 + ctx.cleanups.push(unsubscribe); 155 + } 156 + }
+105
lib/src/core/store.ts
··· 1 + /** 2 + * Global reactive store for cross-scope state management 3 + * 4 + * The store holds signals that are accessible from any scope via $store. 5 + * Supports both programmatic registration and declarative initialization. 6 + */ 7 + 8 + import type { Optional } from "$types/helpers"; 9 + import type { GlobalStore, Signal } from "$types/volt"; 10 + import { signal } from "./signal"; 11 + 12 + /** 13 + * Internal signal registry for the global store 14 + */ 15 + const storeSignals = new Map<string, Signal<unknown>>(); 16 + 17 + /** 18 + * Global store singleton with helper methods and direct signal access 19 + */ 20 + const store: GlobalStore = { 21 + _signals: storeSignals, 22 + 23 + get<T = unknown>(key: string): Optional<T> { 24 + const sig = storeSignals.get(key); 25 + return sig ? (sig.get() as T) : undefined; 26 + }, 27 + 28 + set<T = unknown>(key: string, value: T): void { 29 + const sig = storeSignals.get(key); 30 + if (sig) { 31 + sig.set(value); 32 + } else { 33 + storeSignals.set(key, signal(value)); 34 + } 35 + }, 36 + 37 + has(key: string): boolean { 38 + return storeSignals.has(key); 39 + }, 40 + }; 41 + 42 + /** 43 + * Register state in the global store. 44 + * 45 + * Accepts either: 46 + * - An object of signals: { theme: signal('dark') } 47 + * - An object of values: { count: 0 } (auto-wrapped in signals) 48 + * 49 + * @param state - Object containing signals or raw values to register globally 50 + * 51 + * @example 52 + * ```ts 53 + * // With signals 54 + * registerStore({ 55 + * theme: signal('dark'), 56 + * user: signal({ name: 'Alice' }) 57 + * }); 58 + * 59 + * // With raw values (auto-wrapped) 60 + * registerStore({ 61 + * count: 0, 62 + * isLoggedIn: false 63 + * }); 64 + * ``` 65 + */ 66 + export function registerStore(state: Record<string, unknown>): void { 67 + for (const [key, value] of Object.entries(state)) { 68 + if (value && typeof value === "object" && "get" in value && "set" in value && "subscribe" in value) { 69 + storeSignals.set(key, value as Signal<unknown>); 70 + Object.defineProperty(store, key, { get: () => value, enumerable: true, configurable: true }); 71 + } else { 72 + const sig = signal(value); 73 + storeSignals.set(key, sig); 74 + Object.defineProperty(store, key, { get: () => sig, enumerable: true, configurable: true }); 75 + } 76 + } 77 + } 78 + 79 + /** 80 + * Get the global store instance. 81 + * 82 + * The store provides: 83 + * - Direct signal access: `$store.theme` returns the signal 84 + * - Helper methods: `$store.get('theme')` returns the unwrapped value 85 + * - Signal creation: `$store.set('newKey', value)` creates a new signal 86 + * 87 + * @returns The global store singleton 88 + * 89 + * @example 90 + * ```ts 91 + * const store = getStore(); 92 + * 93 + * // Access signal directly 94 + * const themeSignal = store.theme; 95 + * 96 + * // Get unwrapped value 97 + * const currentTheme = store.get('theme'); 98 + * 99 + * // Set value (creates signal if doesn't exist) 100 + * store.set('theme', 'light'); 101 + * ``` 102 + */ 103 + export function getStore(): GlobalStore { 104 + return store; 105 + }
+9
lib/src/index.ts
··· 19 19 } from "$core/lifecycle"; 20 20 export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin"; 21 21 export { isReactive, reactive, toRaw } from "$core/reactive"; 22 + export { getScopeMetadata } from "$core/scope-metadata"; 22 23 export { computed, effect, signal } from "$core/signal"; 23 24 export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; 25 + export { getStore, registerStore } from "$core/store"; 24 26 export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; 25 27 export { scrollPlugin } from "$plugins/scroll"; 26 28 export { urlPlugin } from "$plugins/url"; 27 29 export type { 30 + ArcFunction, 28 31 AsyncEffectFunction, 29 32 AsyncEffectOptions, 30 33 ChargedRoot, 31 34 ChargeResult, 32 35 ComputedSignal, 33 36 GlobalHookName, 37 + GlobalStore, 34 38 HydrateOptions, 35 39 HydrateResult, 36 40 IsReactive, 37 41 ParsedHttpConfig, 42 + PinRegistry, 38 43 PluginContext, 39 44 PluginHandler, 45 + ProbeFunction, 46 + PulseFunction, 40 47 ReactiveArray, 41 48 RetryConfig, 49 + ScopeMetadata, 42 50 SerializedScope, 43 51 Signal, 52 + UidFunction, 44 53 UnwrapReactive, 45 54 } from "$types/volt";
+17 -23
lib/src/styles/base.css
··· 11 11 padding: 0; 12 12 } 13 13 14 - /** 15 - * Document root configuration 16 - * Sets base font size for rem calculations 17 - */ 18 14 html { 19 15 font-size: var(--font-size-base); 20 16 -webkit-text-size-adjust: 100%; ··· 23 19 text-rendering: optimizeLegibility; 24 20 } 25 21 26 - /** 27 - * Body element - Primary container 28 - * Sets default typography and colors for the entire document 29 - */ 30 22 body { 31 23 font-family: var(--font-sans); 32 24 font-size: 1rem; ··· 75 67 } 76 68 } 77 69 78 - /** 79 - * Mobile sidenotes - Inline with subtle styling 80 - */ 81 - @media (max-width: 767px) { 82 - p small { 83 - display: block; 84 - margin-top: var(--space-sm); 85 - margin-bottom: var(--space-sm); 86 - padding: var(--space-sm); 87 - background-color: var(--color-bg-alt); 88 - border-left: 2px solid var(--color-accent); 89 - border-radius: var(--radius-sm); 90 - font-size: 0.9rem; 91 - } 92 - } 70 + 93 71 94 72 article, section { 95 73 margin-bottom: var(--space-3xl); ··· 190 168 } 191 169 } 192 170 171 + 172 + 193 173 @media (max-width: 768px) { 194 174 :root { 195 175 --font-size-base: 16px; ··· 213 193 nav ul { 214 194 flex-direction: column; 215 195 gap: var(--space-sm); 196 + } 197 + 198 + /** 199 + * Mobile sidenotes - Inline with subtle styling 200 + */ 201 + p small { 202 + display: block; 203 + margin-top: var(--space-sm); 204 + margin-bottom: var(--space-sm); 205 + padding: var(--space-sm); 206 + background-color: var(--color-bg-alt); 207 + border-left: 2px solid var(--color-accent); 208 + border-radius: var(--radius-sm); 209 + font-size: 0.9rem; 216 210 } 217 211 } 218 212
+9 -9
lib/src/styles/index.css
··· 1 1 /* 2 - 8b d8 88 3 - `8b d8' 88 ,d 4 - `8b d8' 88 88 5 - `8b d8' ,adPPYba, 88 MM88MMM ,adPPYba, ,adPPYba, ,adPPYba, 6 - `8b d8' a8" "8a 88 88 a8" "" I8[ "" I8[ "" 7 - `8b d8' 8b d8 88 88 8b `"Y8ba, `"Y8ba, 8 - `888' "8a, ,a8" 88 88, 888 "8a, ,aa aa ]8I aa ]8I 9 - `8' `"YbbdP"' 88 "Y888 888 `"Ybbd8"' `"YbbdP"' `"YbbdP"' 2 + 888 888 888 888 Y88b d88P 3 + 888 888 888 888 Y88b d88P 4 + 888 888 888 888 Y88o88P 5 + Y88b d88P .d88b. 888 888888 Y888P .d8888b .d8888b .d8888b 6 + Y88b d88P d88""88b 888 888 d888b d88P" 88K 88K 7 + Y88o88P 888 888 888 888 d88888b 888 "Y8888b. "Y8888b. 8 + Y888P Y88..88P 888 Y88b. d88P Y88b d8b Y88b. X88 X88 9 + Y8P "Y88P" 888 "Y888 d88P Y88b Y8P "Y8888P 88888P' 88888P' 10 10 */ 11 11 12 12 /** ··· 22 22 * Inspired by: magick.css, latex-css, sakura, matcha, mvp.css 23 23 */ 24 24 25 - @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap'); 25 + @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap'); 26 26 27 27 /* Design system tokens */ 28 28 @import './variables.css';
+3
lib/src/styles/typography.css
··· 1 1 /* ========================================================================== */ 2 2 /* Typography & Inline Elements */ 3 + /* */ 4 + /* See variables.css for fonts. */ 3 5 /* ========================================================================== */ 4 6 5 7 /** ··· 9 11 */ 10 12 h1, h2, h3, h4, h5, h6 { 11 13 font-weight: 700; 14 + font-family: var(--font-display); 12 15 line-height: var(--line-height-tight); 13 16 color: var(--color-text); 14 17 margin-top: var(--space-2xl);
+2 -1
lib/src/styles/variables.css
··· 16 16 --font-size-4xl: 2.027rem; 17 17 --font-size-5xl: 2.566rem; 18 18 19 - --font-sans: "DM Sans", sans-serif; 19 + --font-sans: "Fira Sans", sans-serif; 20 + --font-display: "DM Sans", sans-serif; 20 21 --font-serif: "Libre Baskerville", serif; 21 22 --font-mono: "Google Sans Code", monospace; 22 23
+83
lib/src/types/volt.d.ts
··· 342 342 * Result of parsing an attribute name with modifiers 343 343 */ 344 344 export type ParsedAttribute = { baseName: string; modifiers: Modifier[] }; 345 + 346 + /** 347 + * Registry mapping pin names to DOM elements within a scope 348 + */ 349 + export type PinRegistry = Map<string, Element>; 350 + 351 + /** 352 + * Metadata associated with a reactive scope. 353 + * Stored externally via WeakMap to avoid polluting scope object. 354 + */ 355 + export type ScopeMetadata = { 356 + /** 357 + * The root element that owns this scope 358 + */ 359 + origin: Element; 360 + 361 + /** 362 + * Registry of pinned elements (data-volt-pin) 363 + */ 364 + pins: PinRegistry; 365 + 366 + /** 367 + * Counter for generating unique IDs within this scope 368 + */ 369 + uidCounter: number; 370 + 371 + /** 372 + * Optional parent scope reference (for debugging/inspection) 373 + */ 374 + parent?: Scope; 375 + }; 376 + 377 + /** 378 + * Global reactive store interface. 379 + * Holds signals accessible across all scopes via $store. 380 + */ 381 + export interface GlobalStore { 382 + /** 383 + * Internal signal registry 384 + */ 385 + readonly _signals: Map<string, Signal<unknown>>; 386 + 387 + /** 388 + * Get a signal value from the store 389 + */ 390 + get<T = unknown>(key: string): T | undefined; 391 + 392 + /** 393 + * Set a signal value in the store. 394 + * Creates a new signal if the key doesn't exist. 395 + */ 396 + set<T = unknown>(key: string, value: T): void; 397 + 398 + /** 399 + * Check if a key exists in the store 400 + */ 401 + has(key: string): boolean; 402 + 403 + /** 404 + * Access signals directly (for advanced use) 405 + */ 406 + [key: string]: unknown; 407 + } 408 + 409 + /** 410 + * Function signature for $pulse() - microtask scheduler 411 + */ 412 + export type PulseFunction = (callback: () => void) => void; 413 + 414 + /** 415 + * Function signature for $uid() - unique ID generator 416 + */ 417 + export type UidFunction = (prefix?: string) => string; 418 + 419 + /** 420 + * Function signature for $arc() - CustomEvent dispatcher 421 + */ 422 + export type ArcFunction = (eventName: string, detail?: unknown) => void; 423 + 424 + /** 425 + * Function signature for $probe() - reactive observer 426 + */ 427 + export type ProbeFunction = (expression: string, callback: (value: unknown) => void) => CleanupFunction;
+10 -1
lib/test/core/charge.test.ts
··· 147 147 const result = charge(); 148 148 149 149 expect(result.roots).toHaveLength(1); 150 - expect(result.roots[0].scope).toEqual({}); 150 + 151 + const scope = result.roots[0].scope; 152 + expect(scope.$store).toBeDefined(); 153 + expect(scope.$arc).toBeDefined(); 154 + expect(scope.$origin).toBeDefined(); 155 + expect(scope.$pins).toBeDefined(); 156 + expect(scope.$probe).toBeDefined(); 157 + expect(scope.$pulse).toBeDefined(); 158 + expect(scope.$scope).toBe(scope); 159 + expect(scope.$uid).toBeDefined(); 151 160 152 161 result.cleanup(); 153 162 });
+1 -1
lib/test/core/if-binding.test.ts
··· 1 1 import { mount } from "$core/binder"; 2 - import { extractDeps } from "$core/evaluator"; 2 + import { extractDeps } from "$core/shared"; 3 3 import { signal } from "$core/signal"; 4 4 import { describe, expect, it } from "vitest"; 5 5
+4 -1
lib/test/core/lifecycle.test.ts
··· 53 53 54 54 mount(root, { message }); 55 55 56 + expect(receivedRoot).toBeDefined(); 56 57 expect(receivedRoot).toBe(root); 57 - expect(receivedScope).toEqual({ message }); 58 + expect(receivedScope!.message).toBe(message); 59 + expect(receivedScope!.$store).toBeDefined(); 60 + expect(receivedScope!.$arc).toBeDefined(); 58 61 }); 59 62 60 63 it("can register multiple hooks", () => {
+223
lib/test/core/scope-metadata.test.ts
··· 1 + import { 2 + createScopeMetadata, 3 + getPin, 4 + getPins, 5 + getScopeMetadata, 6 + incrementUidCounter, 7 + registerPin, 8 + } from "$core/scope-metadata"; 9 + import type { Scope } from "$types/volt"; 10 + import { beforeEach, describe, expect, it } from "vitest"; 11 + 12 + describe("Scope Metadata", () => { 13 + let testElement: HTMLDivElement; 14 + let testScope: Scope; 15 + 16 + beforeEach(() => { 17 + testElement = document.createElement("div"); 18 + testScope = {}; 19 + }); 20 + 21 + describe("createScopeMetadata", () => { 22 + it("creates metadata for a scope", () => { 23 + const metadata = createScopeMetadata(testScope, testElement); 24 + 25 + expect(metadata).toBeDefined(); 26 + expect(metadata.origin).toBe(testElement); 27 + expect(metadata.pins).toBeInstanceOf(Map); 28 + expect(metadata.uidCounter).toBe(0); 29 + }); 30 + 31 + it("stores metadata in WeakMap", () => { 32 + createScopeMetadata(testScope, testElement); 33 + 34 + const retrieved = getScopeMetadata(testScope); 35 + expect(retrieved).toBeDefined(); 36 + expect(retrieved?.origin).toBe(testElement); 37 + }); 38 + 39 + it("supports optional parent scope", () => { 40 + const parentScope: Scope = {}; 41 + const metadata = createScopeMetadata(testScope, testElement, parentScope); 42 + 43 + expect(metadata.parent).toBe(parentScope); 44 + }); 45 + 46 + it("initializes empty pin registry", () => { 47 + createScopeMetadata(testScope, testElement); 48 + 49 + const metadata = getScopeMetadata(testScope); 50 + expect(metadata?.pins.size).toBe(0); 51 + }); 52 + }); 53 + 54 + describe("getScopeMetadata", () => { 55 + it("returns undefined for scope without metadata", () => { 56 + const emptyScope: Scope = {}; 57 + expect(getScopeMetadata(emptyScope)).toBeUndefined(); 58 + }); 59 + 60 + it("returns metadata for scope with metadata", () => { 61 + createScopeMetadata(testScope, testElement); 62 + 63 + const metadata = getScopeMetadata(testScope); 64 + expect(metadata).toBeDefined(); 65 + }); 66 + }); 67 + 68 + describe("registerPin", () => { 69 + beforeEach(() => { 70 + createScopeMetadata(testScope, testElement); 71 + }); 72 + 73 + it("registers an element with a name", () => { 74 + const input = document.createElement("input"); 75 + registerPin(testScope, "username", input); 76 + 77 + const metadata = getScopeMetadata(testScope); 78 + expect(metadata?.pins.get("username")).toBe(input); 79 + }); 80 + 81 + it("allows multiple pins with different names", () => { 82 + const input1 = document.createElement("input"); 83 + const input2 = document.createElement("input"); 84 + 85 + registerPin(testScope, "username", input1); 86 + registerPin(testScope, "email", input2); 87 + 88 + const metadata = getScopeMetadata(testScope); 89 + expect(metadata?.pins.size).toBe(2); 90 + expect(metadata?.pins.get("username")).toBe(input1); 91 + expect(metadata?.pins.get("email")).toBe(input2); 92 + }); 93 + 94 + it("overwrites existing pin with same name", () => { 95 + const input1 = document.createElement("input"); 96 + const input2 = document.createElement("input"); 97 + 98 + registerPin(testScope, "username", input1); 99 + registerPin(testScope, "username", input2); 100 + 101 + const metadata = getScopeMetadata(testScope); 102 + expect(metadata?.pins.get("username")).toBe(input2); 103 + }); 104 + 105 + it("does nothing if scope has no metadata", () => { 106 + const emptyScope: Scope = {}; 107 + const input = document.createElement("input"); 108 + 109 + // Should not throw 110 + registerPin(emptyScope, "username", input); 111 + 112 + expect(getScopeMetadata(emptyScope)).toBeUndefined(); 113 + }); 114 + }); 115 + 116 + describe("getPin", () => { 117 + beforeEach(() => { 118 + createScopeMetadata(testScope, testElement); 119 + }); 120 + 121 + it("returns registered pin by name", () => { 122 + const input = document.createElement("input"); 123 + registerPin(testScope, "username", input); 124 + 125 + expect(getPin(testScope, "username")).toBe(input); 126 + }); 127 + 128 + it("returns undefined for unregistered pin", () => { 129 + expect(getPin(testScope, "unknown")).toBeUndefined(); 130 + }); 131 + 132 + it("returns undefined if scope has no metadata", () => { 133 + const emptyScope: Scope = {}; 134 + expect(getPin(emptyScope, "username")).toBeUndefined(); 135 + }); 136 + }); 137 + 138 + describe("getPins", () => { 139 + beforeEach(() => { 140 + createScopeMetadata(testScope, testElement); 141 + }); 142 + 143 + it("returns empty object for scope with no pins", () => { 144 + const pins = getPins(testScope); 145 + 146 + expect(pins).toEqual({}); 147 + }); 148 + 149 + it("returns all pins as record object", () => { 150 + const input1 = document.createElement("input"); 151 + const input2 = document.createElement("input"); 152 + 153 + registerPin(testScope, "username", input1); 154 + registerPin(testScope, "email", input2); 155 + 156 + const pins = getPins(testScope); 157 + 158 + expect(pins.username).toBe(input1); 159 + expect(pins.email).toBe(input2); 160 + expect(Object.keys(pins)).toHaveLength(2); 161 + }); 162 + 163 + it("returns empty object if scope has no metadata", () => { 164 + const emptyScope: Scope = {}; 165 + const pins = getPins(emptyScope); 166 + 167 + expect(pins).toEqual({}); 168 + }); 169 + }); 170 + 171 + describe("incrementUidCounter", () => { 172 + beforeEach(() => { 173 + createScopeMetadata(testScope, testElement); 174 + }); 175 + 176 + it("increments counter and returns new value", () => { 177 + const id1 = incrementUidCounter(testScope); 178 + const id2 = incrementUidCounter(testScope); 179 + const id3 = incrementUidCounter(testScope); 180 + 181 + expect(id1).toBe(1); 182 + expect(id2).toBe(2); 183 + expect(id3).toBe(3); 184 + }); 185 + 186 + it("returns 0 for scope without metadata", () => { 187 + const emptyScope: Scope = {}; 188 + expect(incrementUidCounter(emptyScope)).toBe(0); 189 + }); 190 + 191 + it("maintains separate counters per scope", () => { 192 + const scope1: Scope = {}; 193 + const scope2: Scope = {}; 194 + const elem1 = document.createElement("div"); 195 + const elem2 = document.createElement("div"); 196 + 197 + createScopeMetadata(scope1, elem1); 198 + createScopeMetadata(scope2, elem2); 199 + 200 + expect(incrementUidCounter(scope1)).toBe(1); 201 + expect(incrementUidCounter(scope2)).toBe(1); 202 + expect(incrementUidCounter(scope1)).toBe(2); 203 + expect(incrementUidCounter(scope2)).toBe(2); 204 + }); 205 + }); 206 + 207 + describe("Memory Management", () => { 208 + it("allows garbage collection of scope", () => { 209 + // This test verifies WeakMap behavior 210 + let scope: Scope | null = {}; 211 + const element = document.createElement("div"); 212 + 213 + createScopeMetadata(scope, element); 214 + expect(getScopeMetadata(scope)).toBeDefined(); 215 + 216 + // Clear reference - metadata should be GC-able 217 + scope = null; 218 + 219 + // Cannot directly test GC, but at least verify no errors 220 + expect(true).toBe(true); 221 + }); 222 + }); 223 + });
+325
lib/test/core/scope-vars.test.ts
··· 1 + import { createScopeMetadata } from "$core/scope-metadata"; 2 + import { createArc, createProbe, createPulse, createUid } from "$core/scope-vars"; 3 + import { signal } from "$core/signal"; 4 + import type { Scope } from "$types/volt"; 5 + import { beforeEach, describe, expect, it, vi } from "vitest"; 6 + 7 + describe("Special Scope Variables", () => { 8 + let testElement: HTMLDivElement; 9 + let testScope: Scope; 10 + 11 + beforeEach(() => { 12 + testElement = document.createElement("div"); 13 + testScope = {}; 14 + createScopeMetadata(testScope, testElement); 15 + }); 16 + 17 + describe("$pulse", () => { 18 + it("defers callback to next microtask", async () => { 19 + const pulse = createPulse(); 20 + const cb = vi.fn(); 21 + 22 + pulse(cb); 23 + expect(cb).not.toHaveBeenCalled(); 24 + 25 + await Promise.resolve(); 26 + expect(cb).toHaveBeenCalledTimes(1); 27 + }); 28 + 29 + it("executes callback after current execution context", async () => { 30 + const pulse = createPulse(); 31 + const order: string[] = []; 32 + 33 + pulse(() => order.push("microtask")); 34 + order.push("sync"); 35 + 36 + await Promise.resolve(); 37 + 38 + expect(order).toEqual(["sync", "microtask"]); 39 + }); 40 + 41 + it("supports multiple callbacks", async () => { 42 + const pulse = createPulse(); 43 + const cb1 = vi.fn(); 44 + const cb2 = vi.fn(); 45 + 46 + pulse(cb1); 47 + pulse(cb2); 48 + 49 + await Promise.resolve(); 50 + 51 + expect(cb1).toHaveBeenCalledTimes(1); 52 + expect(cb2).toHaveBeenCalledTimes(1); 53 + }); 54 + }); 55 + 56 + describe("$uid", () => { 57 + it("generates unique IDs with incrementing counter", () => { 58 + const uid = createUid(testScope); 59 + 60 + const id1 = uid(); 61 + const id2 = uid(); 62 + const id3 = uid(); 63 + 64 + expect(id1).toBe("volt-1"); 65 + expect(id2).toBe("volt-2"); 66 + expect(id3).toBe("volt-3"); 67 + }); 68 + 69 + it("supports optional prefix", () => { 70 + const uid = createUid(testScope); 71 + 72 + const id1 = uid("field"); 73 + const id2 = uid("button"); 74 + 75 + expect(id1).toBe("volt-field-1"); 76 + expect(id2).toBe("volt-button-2"); 77 + }); 78 + 79 + it("maintains separate counters per scope", () => { 80 + const scope1: Scope = {}; 81 + const scope2: Scope = {}; 82 + const elem1 = document.createElement("div"); 83 + const elem2 = document.createElement("div"); 84 + 85 + createScopeMetadata(scope1, elem1); 86 + createScopeMetadata(scope2, elem2); 87 + 88 + const uid1 = createUid(scope1); 89 + const uid2 = createUid(scope2); 90 + 91 + expect(uid1()).toBe("volt-1"); 92 + expect(uid2()).toBe("volt-1"); 93 + expect(uid1()).toBe("volt-2"); 94 + expect(uid2()).toBe("volt-2"); 95 + }); 96 + 97 + it("generates deterministic IDs for same scope", () => { 98 + const uid = createUid(testScope); 99 + 100 + const ids = [uid(), uid(), uid()]; 101 + 102 + expect(ids).toEqual(["volt-1", "volt-2", "volt-3"]); 103 + }); 104 + }); 105 + 106 + describe("$arc", () => { 107 + it("dispatches CustomEvent from element", () => { 108 + const arc = createArc(testElement); 109 + const listener = vi.fn(); 110 + 111 + testElement.addEventListener("user:save", listener); 112 + 113 + arc("user:save"); 114 + 115 + expect(listener).toHaveBeenCalledTimes(1); 116 + }); 117 + 118 + it("includes detail in CustomEvent", () => { 119 + const arc = createArc(testElement); 120 + const listener = vi.fn(); 121 + 122 + testElement.addEventListener("user:save", listener); 123 + 124 + const detail = { id: 123, name: "Alice" }; 125 + arc("user:save", detail); 126 + 127 + expect(listener).toHaveBeenCalledTimes(1); 128 + const event = listener.mock.calls[0][0] as CustomEvent; 129 + expect(event.detail).toEqual(detail); 130 + }); 131 + 132 + it("creates bubbling event", () => { 133 + const parent = document.createElement("div"); 134 + const child = document.createElement("div"); 135 + parent.append(child); 136 + 137 + const arc = createArc(child); 138 + const listener = vi.fn(); 139 + 140 + parent.addEventListener("custom-event", listener); 141 + 142 + arc("custom-event", { value: "test" }); 143 + 144 + expect(listener).toHaveBeenCalledTimes(1); 145 + }); 146 + 147 + it("creates composed event (crosses shadow DOM)", () => { 148 + const arc = createArc(testElement); 149 + const listener = vi.fn(); 150 + 151 + testElement.addEventListener("custom-event", listener); 152 + 153 + arc("custom-event"); 154 + 155 + const event = listener.mock.calls[0][0] as CustomEvent; 156 + expect(event.composed).toBe(true); 157 + }); 158 + 159 + it("creates cancelable event", () => { 160 + const arc = createArc(testElement); 161 + const listener = vi.fn(); 162 + 163 + testElement.addEventListener("custom-event", listener); 164 + 165 + arc("custom-event"); 166 + 167 + const event = listener.mock.calls[0][0] as CustomEvent; 168 + expect(event.cancelable).toBe(true); 169 + }); 170 + }); 171 + 172 + describe("$probe", () => { 173 + it("calls callback immediately with initial value", () => { 174 + const count = signal(5); 175 + testScope.count = count; 176 + 177 + const probe = createProbe(testScope); 178 + const cb = vi.fn(); 179 + 180 + probe("count", cb); 181 + 182 + expect(cb).toHaveBeenCalledTimes(1); 183 + expect(cb).toHaveBeenCalledWith(5); 184 + }); 185 + 186 + it("calls callback when dependency changes", () => { 187 + const count = signal(0); 188 + testScope.count = count; 189 + 190 + const probe = createProbe(testScope); 191 + const cb = vi.fn(); 192 + 193 + probe("count", cb); 194 + 195 + expect(cb).toHaveBeenCalledTimes(1); 196 + expect(cb).toHaveBeenCalledWith(0); 197 + 198 + count.set(1); 199 + 200 + expect(cb).toHaveBeenCalledTimes(2); 201 + expect(cb).toHaveBeenCalledWith(1); 202 + 203 + count.set(5); 204 + 205 + expect(cb).toHaveBeenCalledTimes(3); 206 + expect(cb).toHaveBeenCalledWith(5); 207 + }); 208 + 209 + it("supports computed expressions", () => { 210 + const count = signal(10); 211 + testScope.count = count; 212 + 213 + const probe = createProbe(testScope); 214 + const cb = vi.fn(); 215 + 216 + probe("count * 2", cb); 217 + 218 + expect(cb).toHaveBeenCalledWith(20); 219 + 220 + count.set(15); 221 + 222 + expect(cb).toHaveBeenCalledWith(30); 223 + }); 224 + 225 + it("returns cleanup function that stops observing", () => { 226 + const count = signal(0); 227 + testScope.count = count; 228 + 229 + const probe = createProbe(testScope); 230 + const cb = vi.fn(); 231 + 232 + const cleanup = probe("count", cb); 233 + 234 + expect(typeof cleanup).toBe("function"); 235 + expect(cb).toHaveBeenCalledTimes(1); 236 + 237 + cleanup(); 238 + 239 + count.set(1); 240 + 241 + expect(cb).toHaveBeenCalledTimes(1); 242 + }); 243 + 244 + it("handles multiple dependencies", () => { 245 + const a = signal(1); 246 + const b = signal(2); 247 + testScope.a = a; 248 + testScope.b = b; 249 + 250 + const probe = createProbe(testScope); 251 + const cb = vi.fn(); 252 + 253 + probe("a + b", cb); 254 + expect(cb).toHaveBeenCalledWith(3); 255 + 256 + a.set(5); 257 + expect(cb).toHaveBeenCalledWith(7); 258 + 259 + b.set(10); 260 + expect(cb).toHaveBeenCalledWith(15); 261 + }); 262 + 263 + it("handles errors in expressions gracefully", () => { 264 + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); 265 + const probe = createProbe(testScope); 266 + 267 + probe("null.toString()", () => {}); 268 + 269 + expect(consoleError).toHaveBeenCalledWith( 270 + expect.stringContaining("Error evaluating expression"), 271 + expect.any(Error), 272 + ); 273 + 274 + consoleError.mockRestore(); 275 + }); 276 + 277 + it("handles errors in callbacks gracefully", () => { 278 + const count = signal(0); 279 + testScope.count = count; 280 + 281 + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); 282 + const probe = createProbe(testScope); 283 + const callback = vi.fn(() => { 284 + throw new Error("Callback error"); 285 + }); 286 + 287 + probe("count", callback); 288 + expect(consoleError).toHaveBeenCalled(); 289 + consoleError.mockRestore(); 290 + }); 291 + }); 292 + 293 + describe("Integration", () => { 294 + it("all functions are independent", () => { 295 + const pulse = createPulse(); 296 + const uid = createUid(testScope); 297 + const arc = createArc(testElement); 298 + const probe = createProbe(testScope); 299 + 300 + expect(pulse).toBeDefined(); 301 + expect(uid).toBeDefined(); 302 + expect(arc).toBeDefined(); 303 + expect(probe).toBeDefined(); 304 + }); 305 + 306 + it("functions can be called multiple times", async () => { 307 + const pulse = createPulse(); 308 + const uid = createUid(testScope); 309 + const arc = createArc(testElement); 310 + const cb = vi.fn(); 311 + pulse(cb); 312 + 313 + const id = uid("test"); 314 + const listener = vi.fn(); 315 + testElement.addEventListener("event", listener); 316 + arc("event"); 317 + 318 + await Promise.resolve(); 319 + 320 + expect(cb).toHaveBeenCalled(); 321 + expect(id).toBe("volt-test-1"); 322 + expect(listener).toHaveBeenCalled(); 323 + }); 324 + }); 325 + });
+128
lib/test/core/store.test.ts
··· 1 + import { getStore, registerStore } from "$core/store"; 2 + import { signal } from "$core/signal"; 3 + import { beforeEach, describe, expect, it } from "vitest"; 4 + 5 + describe("Global Store", () => { 6 + beforeEach(() => { 7 + // Clear store between tests by getting a fresh instance 8 + const store = getStore(); 9 + for (const key of Object.keys(store)) { 10 + if (key !== "_signals" && key !== "get" && key !== "set" && key !== "has") { 11 + delete (store as Record<string, unknown>)[key]; 12 + } 13 + } 14 + store._signals.clear(); 15 + }); 16 + 17 + describe("registerStore", () => { 18 + it("registers raw values as signals", () => { 19 + registerStore({ count: 0, theme: "dark" }); 20 + 21 + const store = getStore(); 22 + expect(store.get("count")).toBe(0); 23 + expect(store.get("theme")).toBe("dark"); 24 + }); 25 + 26 + it("registers existing signals directly", () => { 27 + const count = signal(42); 28 + registerStore({ count }); 29 + 30 + const store = getStore(); 31 + expect(store.get("count")).toBe(42); 32 + 33 + // Verify it's the same signal 34 + count.set(100); 35 + expect(store.get("count")).toBe(100); 36 + }); 37 + 38 + it("makes signals accessible as direct properties", () => { 39 + registerStore({ theme: "dark" }); 40 + 41 + const store = getStore(); 42 + const themeSignal = (store as Record<string, unknown>).theme; 43 + 44 + expect(themeSignal).toBeDefined(); 45 + expect(typeof themeSignal).toBe("object"); 46 + }); 47 + 48 + it("allows updating values via set()", () => { 49 + registerStore({ count: 0 }); 50 + 51 + const store = getStore(); 52 + store.set("count", 5); 53 + 54 + expect(store.get("count")).toBe(5); 55 + }); 56 + 57 + it("creates new signal if key doesn't exist when using set()", () => { 58 + const store = getStore(); 59 + expect(store.has("newKey")).toBe(false); 60 + 61 + store.set("newKey", "value"); 62 + 63 + expect(store.has("newKey")).toBe(true); 64 + expect(store.get("newKey")).toBe("value"); 65 + }); 66 + }); 67 + 68 + describe("getStore", () => { 69 + it("returns the same store instance", () => { 70 + const store1 = getStore(); 71 + const store2 = getStore(); 72 + 73 + expect(store1).toBe(store2); 74 + }); 75 + 76 + it("has helper methods", () => { 77 + const store = getStore(); 78 + 79 + expect(typeof store.get).toBe("function"); 80 + expect(typeof store.set).toBe("function"); 81 + expect(typeof store.has).toBe("function"); 82 + }); 83 + }); 84 + 85 + describe("store.has()", () => { 86 + it("returns true for registered keys", () => { 87 + registerStore({ count: 0 }); 88 + 89 + const store = getStore(); 90 + expect(store.has("count")).toBe(true); 91 + }); 92 + 93 + it("returns false for unregistered keys", () => { 94 + const store = getStore(); 95 + expect(store.has("unknown")).toBe(false); 96 + }); 97 + }); 98 + 99 + describe("store.get()", () => { 100 + it("returns undefined for unregistered keys", () => { 101 + const store = getStore(); 102 + expect(store.get("unknown")).toBeUndefined(); 103 + }); 104 + 105 + it("returns unwrapped signal values", () => { 106 + const count = signal(42); 107 + registerStore({ count }); 108 + 109 + const store = getStore(); 110 + expect(store.get("count")).toBe(42); 111 + }); 112 + }); 113 + 114 + describe("Integration", () => { 115 + it("allows sharing state across multiple scopes", () => { 116 + registerStore({ theme: "dark" }); 117 + 118 + const store = getStore(); 119 + expect(store.get("theme")).toBe("dark"); 120 + 121 + store.set("theme", "light"); 122 + expect(store.get("theme")).toBe("light"); 123 + }); 124 + 125 + // TODO: Add test for store reactivity in expressions once binder integration is complete 126 + // TODO: Add test for declarative store registration via <script data-volt-store> 127 + }); 128 + });
+548
lib/test/integration/global-state.test.ts
··· 1 + import { mount } from "$core/binder"; 2 + import { charge } from "$core/charge"; 3 + import { signal } from "$core/signal"; 4 + import { getStore, registerStore } from "$core/store"; 5 + import type { Scope } from "$types/volt"; 6 + import { screen, waitFor } from "@testing-library/dom"; 7 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 8 + 9 + describe("Global State Integration", () => { 10 + beforeEach(() => { 11 + document.body.innerHTML = ""; 12 + const store = getStore(); 13 + for (const key of Object.keys(store)) { 14 + if (key !== "_signals" && key !== "get" && key !== "set" && key !== "has") { 15 + delete (store as Record<string, unknown>)[key]; 16 + } 17 + } 18 + store._signals.clear(); 19 + }); 20 + 21 + afterEach(() => { 22 + document.body.innerHTML = ""; 23 + }); 24 + 25 + describe("$store in expressions", () => { 26 + it("accesses global store values in data-volt-text", () => { 27 + registerStore({ theme: "dark" }); 28 + 29 + document.body.innerHTML = ` 30 + <div data-volt> 31 + <p data-volt-text="$store.get('theme')"></p> 32 + </div> 33 + `; 34 + 35 + charge(); 36 + 37 + expect(screen.getByText("dark")).toBeInTheDocument(); 38 + }); 39 + 40 + it("reacts to store changes", async () => { 41 + registerStore({ count: 0 }); 42 + 43 + document.body.innerHTML = ` 44 + <div data-volt> 45 + <p data-volt-text="$store.get('count')"></p> 46 + </div> 47 + `; 48 + 49 + charge(); 50 + 51 + expect(screen.getByText("0")).toBeInTheDocument(); 52 + 53 + const store = getStore(); 54 + store.set("count", 5); 55 + 56 + await waitFor(() => { 57 + expect(screen.getByText("5")).toBeInTheDocument(); 58 + }); 59 + }); 60 + 61 + it("shares state across multiple roots", async () => { 62 + registerStore({ message: "Hello" }); 63 + 64 + document.body.innerHTML = ` 65 + <div data-volt> 66 + <p data-volt-text="$store.get('message')"></p> 67 + </div> 68 + <div data-volt> 69 + <p data-volt-text="$store.get('message')"></p> 70 + </div> 71 + `; 72 + 73 + charge(); 74 + 75 + const paragraphs = screen.getAllByText("Hello"); 76 + expect(paragraphs).toHaveLength(2); 77 + 78 + const store = getStore(); 79 + store.set("message", "World"); 80 + 81 + await waitFor(() => { 82 + const updated = screen.getAllByText("World"); 83 + expect(updated).toHaveLength(2); 84 + }); 85 + }); 86 + 87 + // TODO: Add test for accessing store signals directly ($store.theme vs $store.get('theme')) 88 + }); 89 + 90 + describe("$origin", () => { 91 + it("references the root element", () => { 92 + document.body.innerHTML = ` 93 + <div data-volt id="test-root"> 94 + <p data-volt-text="$origin.id"></p> 95 + </div> 96 + `; 97 + 98 + charge(); 99 + 100 + expect(screen.getByText("test-root")).toBeInTheDocument(); 101 + }); 102 + 103 + it("is different for different roots", () => { 104 + document.body.innerHTML = ` 105 + <div data-volt id="root1"> 106 + <p data-volt-text="$origin.id"></p> 107 + </div> 108 + <div data-volt id="root2"> 109 + <p data-volt-text="$origin.id"></p> 110 + </div> 111 + `; 112 + 113 + charge(); 114 + 115 + expect(screen.getByText("root1")).toBeInTheDocument(); 116 + expect(screen.getByText("root2")).toBeInTheDocument(); 117 + }); 118 + }); 119 + 120 + describe("$scope", () => { 121 + it("provides access to scope object", () => { 122 + const root = document.createElement("div"); 123 + const paragraph = document.createElement("p"); 124 + paragraph.dataset.voltText = "Object.keys($scope).length"; 125 + root.append(paragraph); 126 + document.body.append(root); 127 + 128 + const scope: Scope = { count: signal(0) }; 129 + mount(root, scope); 130 + 131 + expect(paragraph.textContent).toBeTruthy(); 132 + }); 133 + 134 + // TODO: Add more $scope access tests 135 + }); 136 + 137 + describe("$pins", () => { 138 + it("accesses pinned elements", () => { 139 + document.body.innerHTML = ` 140 + <div data-volt> 141 + <input data-volt-pin="username" value="Alice" /> 142 + <p data-volt-text="$pins.username.value"></p> 143 + </div> 144 + `; 145 + 146 + charge(); 147 + 148 + expect(screen.getByText("Alice")).toBeInTheDocument(); 149 + }); 150 + 151 + it("allows calling methods on pinned elements", () => { 152 + document.body.innerHTML = ` 153 + <div data-volt> 154 + <input data-volt-pin="field" /> 155 + <button data-volt-on-click="$pins.field.focus()">Focus</button> 156 + </div> 157 + `; 158 + 159 + charge(); 160 + 161 + const input = screen.getByRole("textbox"); 162 + const button = screen.getByRole("button"); 163 + 164 + const focusSpy = vi.spyOn(input, "focus"); 165 + 166 + button.click(); 167 + 168 + expect(focusSpy).toHaveBeenCalled(); 169 + }); 170 + 171 + it("isolates pins per root", () => { 172 + document.body.innerHTML = ` 173 + <div data-volt> 174 + <input data-volt-pin="field" value="First" /> 175 + <p data-volt-text="$pins.field.value"></p> 176 + </div> 177 + <div data-volt> 178 + <input data-volt-pin="field" value="Second" /> 179 + <p data-volt-text="$pins.field.value"></p> 180 + </div> 181 + `; 182 + 183 + charge(); 184 + 185 + expect(screen.getByText("First")).toBeInTheDocument(); 186 + expect(screen.getByText("Second")).toBeInTheDocument(); 187 + }); 188 + 189 + // TODO: Test dynamic pin registration (pins added after mount) 190 + }); 191 + 192 + describe("$pulse", () => { 193 + it("defers execution to next microtask", async () => { 194 + document.body.innerHTML = ` 195 + <div data-volt data-volt-state='{"log": []}'> 196 + <button data-volt-on-click="log.set([...log.get(), 'sync']); $pulse(() => log.set([...log.get(), 'async']))">Click</button> 197 + <p data-volt-text="log.get().join(', ')"></p> 198 + </div> 199 + `; 200 + 201 + charge(); 202 + 203 + const button = screen.getByRole("button"); 204 + button.click(); 205 + 206 + expect(screen.getByText("sync")).toBeInTheDocument(); 207 + await waitFor(() => { 208 + expect(screen.getByText("sync, async")).toBeInTheDocument(); 209 + }); 210 + }); 211 + 212 + // TODO: Add test for $pulse ensuring DOM updates are applied 213 + }); 214 + 215 + describe("$uid", () => { 216 + it("generates unique IDs", () => { 217 + document.body.innerHTML = ` 218 + <div data-volt> 219 + <input data-volt-bind:id="$uid('field')" /> 220 + <input data-volt-bind:id="$uid('field')" /> 221 + </div> 222 + `; 223 + 224 + charge(); 225 + 226 + const inputs = screen.getAllByRole("textbox"); 227 + expect(inputs[0].id).toBe("volt-field-1"); 228 + expect(inputs[1].id).toBe("volt-field-2"); 229 + }); 230 + 231 + it("maintains separate counters per root", () => { 232 + document.body.innerHTML = ` 233 + <div data-volt> 234 + <input data-volt-bind:id="$uid('field')" /> 235 + </div> 236 + <div data-volt> 237 + <input data-volt-bind:id="$uid('field')" /> 238 + </div> 239 + `; 240 + 241 + charge(); 242 + 243 + const inputs = screen.getAllByRole("textbox"); 244 + expect(inputs[0].id).toBe("volt-field-1"); 245 + expect(inputs[1].id).toBe("volt-field-1"); 246 + }); 247 + 248 + // TODO: Add test for deterministic ID generation across re-renders 249 + }); 250 + 251 + describe("$arc", () => { 252 + it("dispatches custom events", () => { 253 + document.body.innerHTML = ` 254 + <div data-volt data-volt-state='{"saved": false}' data-volt-on-user:save="saved.set(true)"> 255 + <button data-volt-on-click="$arc('user:save', { id: 123 })">Save</button> 256 + <p data-volt-text="saved"></p> 257 + </div> 258 + `; 259 + 260 + charge(); 261 + 262 + const button = screen.getByRole("button"); 263 + expect(screen.getByText("false")).toBeInTheDocument(); 264 + 265 + button.click(); 266 + 267 + expect(screen.getByText("true")).toBeInTheDocument(); 268 + }); 269 + 270 + it("includes event detail", () => { 271 + document.body.innerHTML = ` 272 + <div data-volt data-volt-state='{"userId": 0}' data-volt-on-user:save="userId.set($event.detail.id)"> 273 + <button data-volt-on-click="$arc('user:save', { id: 456 })">Save</button> 274 + <p data-volt-text="userId"></p> 275 + </div> 276 + `; 277 + 278 + charge(); 279 + 280 + const button = screen.getByRole("button"); 281 + button.click(); 282 + 283 + expect(screen.getByText("456")).toBeInTheDocument(); 284 + }); 285 + 286 + // TODO: Test event bubbling across DOM hierarchy 287 + }); 288 + 289 + describe("$probe", () => { 290 + it("observes reactive expressions and calls callback on changes", async () => { 291 + const cb = vi.fn(); 292 + 293 + document.body.innerHTML = ` 294 + <div data-volt data-volt-state='{"count": 0}'> 295 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 296 + </div> 297 + `; 298 + 299 + const result = charge(); 300 + const scope = result.roots[0].scope; 301 + // @ts-expect-error $probe requires casting 302 + const cleanup = scope.$probe("count", cb); 303 + 304 + expect(cb).toHaveBeenCalledTimes(1); 305 + expect(cb).toHaveBeenCalledWith(0); 306 + 307 + const button = screen.getByRole("button"); 308 + button.click(); 309 + 310 + await waitFor(() => { 311 + expect(cb).toHaveBeenCalledTimes(2); 312 + expect(cb).toHaveBeenLastCalledWith(1); 313 + }); 314 + 315 + cleanup(); 316 + }); 317 + 318 + it("can be used with data-volt-init", async () => { 319 + document.body.innerHTML = ` 320 + <div data-volt data-volt-state='{"count": 0, "log": []}' data-volt-init="$probe('count', v => log.set([...log.get(), v]))"> 321 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 322 + <p data-volt-text="log.get().join(', ')"></p> 323 + </div> 324 + `; 325 + 326 + charge(); 327 + 328 + expect(screen.getByText("0")).toBeInTheDocument(); 329 + 330 + const button = screen.getByRole("button"); 331 + button.click(); 332 + 333 + await waitFor(() => { 334 + expect(screen.getByText("0, 1")).toBeInTheDocument(); 335 + }); 336 + 337 + button.click(); 338 + 339 + await waitFor(() => { 340 + expect(screen.getByText("0, 1, 2")).toBeInTheDocument(); 341 + }); 342 + }); 343 + 344 + it("observes computed expressions", async () => { 345 + const callback = vi.fn(); 346 + 347 + document.body.innerHTML = ` 348 + <div data-volt data-volt-state='{"count": 5}'> 349 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 350 + </div> 351 + `; 352 + 353 + const result = charge(); 354 + const scope = result.roots[0].scope; 355 + // @ts-expect-error $probe requires casting 356 + const cleanup = scope.$probe("count * 2", callback); 357 + 358 + expect(callback).toHaveBeenCalledWith(10); 359 + 360 + const button = screen.getByRole("button"); 361 + button.click(); 362 + 363 + await waitFor(() => { 364 + expect(callback).toHaveBeenCalledWith(12); 365 + }); 366 + 367 + cleanup(); 368 + }); 369 + 370 + it("cleanup stops observing", async () => { 371 + const callback = vi.fn(); 372 + 373 + document.body.innerHTML = ` 374 + <div data-volt data-volt-state='{"count": 0}'> 375 + <button data-volt-on-click="count.set(count.get() + 1)">Increment</button> 376 + </div> 377 + `; 378 + 379 + const result = charge(); 380 + const scope = result.roots[0].scope; 381 + 382 + // @ts-expect-error $probe requires casting 383 + const cleanup = scope.$probe("count", callback); 384 + 385 + expect(callback).toHaveBeenCalledTimes(1); 386 + 387 + cleanup(); 388 + 389 + const button = screen.getByRole("button"); 390 + button.click(); 391 + 392 + await new Promise((resolve) => setTimeout(resolve, 50)); 393 + 394 + expect(callback).toHaveBeenCalledTimes(1); 395 + }); 396 + }); 397 + 398 + describe("data-volt-init", () => { 399 + it("executes code once on mount", () => { 400 + document.body.innerHTML = ` 401 + <div data-volt data-volt-state='{"mounted": false}' data-volt-init="mounted.set(true)"> 402 + <p data-volt-text="mounted"></p> 403 + </div> 404 + `; 405 + 406 + charge(); 407 + 408 + expect(screen.getByText("true")).toBeInTheDocument(); 409 + }); 410 + 411 + it("has access to scope variables", () => { 412 + document.body.innerHTML = ` 413 + <div data-volt data-volt-state='{"initialized": false}' data-volt-init="initialized.set(true)"> 414 + <p data-volt-text="initialized"></p> 415 + </div> 416 + `; 417 + 418 + charge(); 419 + 420 + expect(screen.getByText("true")).toBeInTheDocument(); 421 + }); 422 + 423 + it("can call methods and use special variables", () => { 424 + document.body.innerHTML = ` 425 + <div data-volt id="test-root" data-volt-state='{"originId": "", "generatedId": ""}' data-volt-init="originId.set($origin.id); generatedId.set($uid('test'))"> 426 + <p data-volt-text="originId"></p> 427 + <p data-volt-text="generatedId"></p> 428 + </div> 429 + `; 430 + 431 + charge(); 432 + 433 + expect(screen.getByText("test-root")).toBeInTheDocument(); 434 + expect(screen.getByText("volt-test-1")).toBeInTheDocument(); 435 + }); 436 + 437 + it("works with multiple elements", () => { 438 + document.body.innerHTML = ` 439 + <div data-volt data-volt-state='{"logs": []}'> 440 + <div data-volt-init="logs.push('first')">First</div> 441 + <div data-volt-init="logs.push('second')">Second</div> 442 + <p data-volt-text="logs.join(', ')"></p> 443 + </div> 444 + `; 445 + 446 + charge(); 447 + 448 + expect(screen.getByText("first, second")).toBeInTheDocument(); 449 + }); 450 + 451 + it("handles errors gracefully", () => { 452 + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); 453 + 454 + document.body.innerHTML = ` 455 + <div data-volt data-volt-init="nonExistentVariable.doSomething()"> 456 + <p>Content</p> 457 + </div> 458 + `; 459 + 460 + charge(); 461 + 462 + expect(consoleError).toHaveBeenCalledWith( 463 + expect.stringContaining("Error evaluating expression"), 464 + expect.any(Error), 465 + ); 466 + 467 + consoleError.mockRestore(); 468 + }); 469 + }); 470 + 471 + describe("Declarative Store", () => { 472 + it("registers store from script tag", () => { 473 + document.body.innerHTML = ` 474 + <script type="application/json" data-volt-store> 475 + { 476 + "theme": "dark", 477 + "count": 0 478 + } 479 + </script> 480 + 481 + <div data-volt> 482 + <p data-volt-text="$store.get('theme')"></p> 483 + <p data-volt-text="$store.get('count')"></p> 484 + </div> 485 + `; 486 + 487 + charge(); 488 + 489 + expect(screen.getByText("dark")).toBeInTheDocument(); 490 + expect(screen.getByText("0")).toBeInTheDocument(); 491 + }); 492 + 493 + it("handles multiple store script tags", () => { 494 + document.body.innerHTML = ` 495 + <script type="application/json" data-volt-store> 496 + { "theme": "dark" } 497 + </script> 498 + 499 + <script type="application/json" data-volt-store> 500 + { "count": 5 } 501 + </script> 502 + 503 + <div data-volt> 504 + <p data-volt-text="$store.get('theme')"></p> 505 + <p data-volt-text="$store.get('count')"></p> 506 + </div> 507 + `; 508 + 509 + charge(); 510 + 511 + expect(screen.getByText("dark")).toBeInTheDocument(); 512 + expect(screen.getByText("5")).toBeInTheDocument(); 513 + }); 514 + 515 + // TODO: Test error handling for invalid JSON in store script 516 + }); 517 + 518 + describe("Combined Features", () => { 519 + it("uses multiple special variables together", async () => { 520 + registerStore({ prefix: "user" }); 521 + 522 + document.body.innerHTML = ` 523 + <div data-volt data-volt-state='{"name": "Alice"}'> 524 + <input data-volt-pin="nameInput" data-volt-bind:id="$uid($store.get('prefix'))" data-volt-model="name" /> 525 + <button data-volt-on-click="$arc('name:changed', { value: name }); $pulse(() => $pins.nameInput.focus())"> 526 + Update 527 + </button> 528 + <p data-volt-text="'Root: ' + $origin.tagName"></p> 529 + </div> 530 + `; 531 + 532 + charge(); 533 + 534 + const input = screen.getByRole("textbox"); 535 + const button = screen.getByRole("button"); 536 + expect(input.id).toBe("volt-user-1"); 537 + expect(screen.getByText("Root: DIV")).toBeInTheDocument(); 538 + 539 + const focusSpy = vi.spyOn(input, "focus"); 540 + 541 + button.click(); 542 + 543 + await waitFor(() => { 544 + expect(focusSpy).toHaveBeenCalled(); 545 + }); 546 + }); 547 + }); 548 + });