From b6a1630899095e53f00085dff2f6be24d0026347 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 21 Oct 2025 00:21:53 +0000 Subject: [PATCH] feat: global state feat: completed state machine --- docs/global-state.md | 527 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dev/docs/README.md | 1 + lib/src/index.ts | 9 +++++++++ dev/docs/design/pins.md | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/src/core/binder.ts | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------- lib/src/core/charge.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ lib/src/core/evaluator.ts | 50 ++------------------------------------------------ lib/src/core/scope-metadata.ts | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/src/core/scope-vars.ts | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/src/core/shared.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- lib/src/core/store.ts | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/src/styles/base.css | 40 +++++++++++++++++----------------------- lib/src/styles/index.css | 18 +++++++++--------- lib/src/styles/typography.css | 3 +++ lib/src/styles/variables.css | 3 ++- lib/src/types/volt.d.ts | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/test/core/charge.test.ts | 11 ++++++++++- lib/test/core/if-binding.test.ts | 2 +- lib/test/core/lifecycle.test.ts | 5 ++++- lib/test/core/scope-metadata.test.ts | 223 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/test/core/scope-vars.test.ts | 325 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/test/core/store.test.ts | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/test/integration/global-state.test.ts | 548 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 23 file(s) changed, 2707 insertion(s)(+), 120 deletion(s)(-) diff --git a/docs/global-state.md b/docs/global-state.md new file mode 100644 --- /dev/null +++ b/docs/global-state.md @@ -0,0 +1,527 @@ +# Global State + +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. + +## Overview + +Every Volt scope automatically receives special variables (prefixed with `$`) that provide access to: + +- **Global Store** - Shared reactive state across all scopes +- **Scope Metadata** - Information about the current reactive context +- **Element References** - Access to pinned DOM elements +- **Utility Functions** - Helper functions for common tasks + +## Special Variables + +### `$store` + +Access globally shared reactive state across all Volt roots. + +**Declarative API:** + +```html + + + + +
+

+ +
+``` + +**Programmatic API:** + +```typescript +import { registerStore, getStore } from 'voltx.js'; + +// Register store with signals or raw values +registerStore({ + theme: signal('dark'), + count: 0 // Auto-wrapped in signal +}); + +// Access store +const store = getStore(); +store.set('count', 5); +console.log(store.get('count')); // 5 +``` + +**Methods:** + +- `$store.get(key)` - Get signal value +- `$store.set(key, value)` - Update signal value +- `$store.has(key)` - Check if key exists +- `$store[key]` - Direct signal access + +### `$origin` + +Reference to the root element of the current reactive scope. + +```html +
+

+ +
+``` + +### `$scope` + +Direct access to the raw scope object containing all signals and context. + +```html +
+

+ +
+``` + +### `$pins` + +Access DOM elements registered with `data-volt-pin`. + +```html +
+ + + + + + +
+``` + +**Notes:** + +- Pins are scoped to their root element +- Each root maintains its own pin registry +- Pins are accessible immediately after registration + +### `$pulse(callback)` + +Defers callback execution to the next microtask, ensuring DOM updates have completed. + +```html +
+ +
+``` + +**Use Cases:** + +- Run code after DOM updates +- Coordinate async operations +- Batch multiple updates + +### `$uid(prefix?)` + +Generates unique, deterministic IDs within the scope. + +```html +
+ + + + + + + + +
+``` + +**Notes:** + +- IDs are unique within the scope +- Counter increments on each call +- Different scopes have independent counters + +### `$arc(eventName, detail?)` + +Dispatches a CustomEvent from the current element. + +```html +
+ +
+``` + +**Event Properties:** + +- `bubbles: true` - Event bubbles up the DOM +- `composed: true` - Crosses shadow DOM boundaries +- `cancelable: true` - Can be prevented +- `detail` - Custom data payload + +### `$probe(expression, callback)` + +Observes a reactive expression and calls a callback when dependencies change. + +```html +
+ + +
+``` + +**Parameters:** + +- `expression` (string) - Reactive expression to observe +- `callback` (function) - Called with expression value on changes + +**Returns:** + +- Cleanup function to stop observing + +**Example:** + +```html +
+ + + + + +
+``` + +## `data-volt-init` + +Run initialization code once when an element is mounted. + +**Basic Usage:** + +```html +
+ +

+ +
+``` + +**Setting Up Observers:** + +```html +
+ + +

+ +
+``` + +**Accessing Special Variables:** + +```html +
+ +

+ +
+``` + +## Global Store Patterns + +### Shared Application State + +```html + + + + +
+
+ +
+
+ + +
+
+

+
+
+``` + +### Cross-Component Communication + +```html + + + +
+
+ +
+
+ + +
+
+

+
+
+``` + +### Persistent Global State + +```typescript +import { registerStore, getStore } from 'voltx.js'; +import { registerPlugin, persistPlugin } from 'voltx.js'; + +// Register persist plugin +registerPlugin('persist', persistPlugin); + +// Initialize store with persisted values +const saved = localStorage.getItem('app-store'); +const initialState = saved ? JSON.parse(saved) : { theme: 'light', user: null }; + +registerStore(initialState); + +// Save on changes +const store = getStore(); +const originalSet = store.set.bind(store); +store.set = (key, value) => { + originalSet(key, value); + localStorage.setItem('app-store', JSON.stringify({ + theme: store.get('theme'), + user: store.get('user') + })); +}; +``` + +## Best Practices + +### Use `$store` for Shared State + +Global state should live in `$store`: + +```html + + + +
+

Content

+
+``` + +### Use `$pins` for Element Access + +Access DOM elements through pins instead of `querySelector`: + +```html + +
+ + +
+ + +
+ + +
+``` + +### Use `data-volt-init` for Setup + +Initialize observers and one-time setup in `data-volt-init`: + +```html +
+ + +
+``` + +### Scope Pin Names Appropriately + +Use descriptive pin names and avoid collisions: + +```html + +
+ + +
+ + +
+ + +
+``` + +### Clean Up Observers + +Always clean up `$probe` observers when no longer needed: + +```html +
+ + +
+``` + +## Examples + +### Todo App with Global State + +```html + + + +
+ + +
+ + +
+ + + +
+ + +
+
+
+ + +
+
+
+``` + +### Multi-Step Form + +```html + + +
+ +

+ + +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+
+``` + +## API Reference + +### `registerStore(state)` + +Register global store state programmatically. + +```typescript +import { registerStore } from 'voltx.js'; +import { signal } from 'voltx.js'; + +registerStore({ + theme: signal('dark'), // Existing signal + count: 0 // Auto-wrapped +}); +``` + +### `getStore()` + +Get the global store instance. + +```typescript +import { getStore } from 'voltx.js'; + +const store = getStore(); +store.set('theme', 'light'); +console.log(store.get('theme')); // 'light' +console.log(store.has('theme')); // true +``` + +### `getScopeMetadata(scope)` + +Get metadata for a scope (advanced use). + +```typescript +import { getScopeMetadata } from 'voltx.js'; + +const metadata = getScopeMetadata(scope); +console.log(metadata.origin); // Root element +console.log(metadata.pins); // Pin registry +console.log(metadata.uidCounter); // Current UID counter +``` + +## See Also + +- [Reactivity](/reactivity) - Understanding signals and computed values +- [Plugins](/plugins) - Extending Volt with plugins +- [HTTP Actions](/http) - Server communication patterns diff --git a/dev/docs/README.md b/dev/docs/README.md new file mode 100644 --- /dev/null +++ b/dev/docs/README.md @@ -0,0 +1,1 @@ +# Internal Development Docs diff --git a/lib/src/index.ts b/lib/src/index.ts --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -19,27 +19,36 @@ } from "$core/lifecycle"; export { clearPlugins, getRegisteredPlugins, hasPlugin, registerPlugin, unregisterPlugin } from "$core/plugin"; export { isReactive, reactive, toRaw } from "$core/reactive"; +export { getScopeMetadata } from "$core/scope-metadata"; export { computed, effect, signal } from "$core/signal"; export { deserializeScope, hydrate, isHydrated, isServerRendered, serializeScope } from "$core/ssr"; +export { getStore, registerStore } from "$core/store"; export { persistPlugin, registerStorageAdapter } from "$plugins/persist"; export { scrollPlugin } from "$plugins/scroll"; export { urlPlugin } from "$plugins/url"; export type { + ArcFunction, AsyncEffectFunction, AsyncEffectOptions, ChargedRoot, ChargeResult, ComputedSignal, GlobalHookName, + GlobalStore, HydrateOptions, HydrateResult, IsReactive, ParsedHttpConfig, + PinRegistry, PluginContext, PluginHandler, + ProbeFunction, + PulseFunction, ReactiveArray, RetryConfig, + ScopeMetadata, SerializedScope, Signal, + UidFunction, UnwrapReactive, } from "$types/volt"; diff --git a/dev/docs/design/pins.md b/dev/docs/design/pins.md new file mode 100644 --- /dev/null +++ b/dev/docs/design/pins.md @@ -0,0 +1,177 @@ +# Pin Scoping + +Element references via `data-volt-pin` require a scoping strategy to avoid name collisions and provide predictable access patterns. +This document explores three approaches that were considered + +## Current Implementation: [data-volt] Root Scoping + +**How it works:** + +- Each `[data-volt]` root has its own pin registry +- Pins are isolated to their scope +- `$pins.name` accesses pins within the current root + +**Example:** + +```html +
+ + +
+ +
+ + +
+``` + +**Pros:** + +- Predictable: Each root is isolated +- No name collision risk across roots +- Aligns with current scope model (one scope per root) +- Simple to implement and reason about + +**Cons:** + +- Can't share pins across roots (must use global state instead) +- No sub-scoping within a root for component-like patterns + +**Use Cases:** + +- Simple applications with clear root boundaries +- When each `[data-volt]` represents a distinct feature/component + +--- + +## Alternative 1: Explicit Scope Boundaries (data-volt-scope) + +**How it works:** + +- Introduce `data-volt-scope` attribute to create nested scopes +- Pins registered within a scope are isolated to that scope and its descendants +- `$pins` searches up the scope chain + +**Example:** + +```html +
+
+ + +
+ +
+ + +
+
+``` + +**Pros:** + +- Fine-grained control over scope boundaries +- Supports nested component patterns +- Can isolate widgets within a larger root + +**Cons:** + +- More complex: requires scope hierarchy tracking +- Additional attribute to learn +- Lookup complexity (walking scope chain) +- Breaks current 1:1 scope-to-root model + +**Use Cases:** + +- Large applications with reusable sub-components +- When you need multiple isolated widgets within one root +- Form libraries with nested fieldsets + +**Implementation Complexity:** + +- Requires scope hierarchy (parent references) +- WeakMap must track scope chains +- Pin lookup becomes recursive + +## Alternative 2: Global Document-Wide Registry + +**How it works:** + +- Single global pin registry for entire document +- All pins accessible from any scope +- Names must be unique across the entire page + +**Example:** + +```html +
+ +
+ +
+ + +
+``` + +**Pros:** + +- Simplest to understand: flat namespace +- Easy to share element references across roots +- No scoping complexity + +**Cons:** + +- High risk of name collisions +- No isolation between roots (breaks encapsulation) +- Debugging becomes harder (where is this pin defined?) +- Not composable (can't have two instances of same component) + +**Use Cases:** + +- Prototypes and simple pages +- Single-page applications with unique IDs everywhere +- When cross-root communication is primary goal + +**Implementation Complexity:** + +- Simple: single Map instead of per-scope maps +- No WeakMap needed + +## Comparison Table + +| Aspect | Root Scoping (Current) | Explicit Scopes | Global | +|--------|------------------------|-----------------|--------| +| Isolation | Per root | Per scope boundary | None | +| Name Collisions | Safe within root | Safe within scope | High risk | +| Complexity | Low | Medium | Very Low | +| Cross-root Access | Not supported | Not supported | Supported | +| Nested Components | Not supported | Supported | Not needed | +| Implementation | Simple | Complex | Trivial | +| Composability | Good | Excellent | Poor | + +## Decision Rationale + +**Current implementation uses Root Scoping** for the following reasons: + +1. **Aligns with existing architecture**: VoltX already uses one scope per `[data-volt]` root +2. **Simplicity**: No additional concepts or attributes to learn +3. **Good enough**: Most use cases don't require nested scopes +4. **Future extensibility**: Can add `data-volt-scope` later if needed (additive change) + +**When to reconsider:** + +- If users frequently request nested component isolation +- If framework adds first-class component system +- If cross-root pin access becomes a common need (could add `data-volt-pin-global`) + +## Migration Path + +If we later adopt Alternative 1 (Explicit Scopes): + +1. Keep current behavior as default +2. Add `data-volt-scope` for opt-in nested scopes +3. Update metadata to track parent scopes +4. Modify `getPin()` to walk scope chain + +This would be backward compatible since existing code without `data-volt-scope` would continue to work. diff --git a/lib/src/core/binder.ts b/lib/src/core/binder.ts --- a/lib/src/core/binder.ts +++ b/lib/src/core/binder.ts @@ -2,7 +2,7 @@ * Binder system for mounting and managing Volt.js bindings */ -import type { Optional } from "$types/helpers"; +import type { Nullable, Optional } from "$types/helpers"; import type { BindingContext, CleanupFunction, @@ -15,12 +15,15 @@ } from "$types/volt"; import { BOOLEAN_ATTRS } from "./constants"; import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; -import { evaluate, extractDeps } from "./evaluator"; +import { evaluate } from "./evaluator"; import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; import { getPlugin } from "./plugin"; -import { findScopedSignal, isNil } from "./shared"; +import { createScopeMetadata, getPin, registerPin } from "./scope-metadata"; +import { createArc, createProbe, createPulse, createUid } from "./scope-vars"; +import { findScopedSignal, isNil, updateAndRegister } from "./shared"; +import { getStore } from "./store"; /** * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope. @@ -30,12 +33,13 @@ * @returns Cleanup function to unmount and dispose all bindings. */ export function mount(root: Element, scope: Scope): CleanupFunction { + injectSpecialVars(scope, root); execGlobalHooks("beforeMount", root, scope); const allElements = walkDOM(root); const elements = allElements.filter((element) => { - let current: Element | null = element; + let current: Nullable = element; while (current) { if (Object.hasOwn((current as HTMLElement).dataset, "voltSkip")) { return false; @@ -168,6 +172,14 @@ bindModel(ctx, value, modifiers); break; } + case "pin": { + bindPin(ctx, value); + break; + } + case "init": { + bindInit(ctx, value); + break; + } case "for": { bindFor(ctx, value); break; @@ -222,27 +234,8 @@ setHTML(ctx.element, String(value ?? "")); } }; - update(); - - const deps = extractDeps(expr, ctx.scope); - for (const dep of deps) { - const unsubscribe = dep.subscribe(update); - ctx.cleanups.push(unsubscribe); - } + updateAndRegister(ctx, update, expr); }; -} - -/** - * Helper function to execute an update function and subscribe to all signal dependencies. - * Used by bindings that need reactive updates (class, show, style, for, if). - */ -function updateAndUnsub(ctx: BindingContext, update: () => void, expr: string) { - update(); - const deps = extractDeps(expr, ctx.scope); - for (const dep of deps) { - const unsubscribe = dep.subscribe(update); - ctx.cleanups.push(unsubscribe); - } } /** @@ -269,7 +262,7 @@ prevClasses = classes; }; - updateAndUnsub(ctx, update, expr); + updateAndRegister(ctx, update, expr); } /** @@ -291,7 +284,7 @@ } }; - updateAndUnsub(ctx, update, expr); + updateAndRegister(ctx, update, expr); } /** @@ -325,7 +318,49 @@ } }; - updateAndUnsub(ctx, update, expr); + updateAndRegister(ctx, update, expr); +} + +function extractStatements(expr: string) { + const statements: string[] = []; + let current = ""; + let depth = 0; + let inString: string | null = null; + + for (const [i, char] of [...expr].entries()) { + const prev = i > 0 ? expr[i - 1] : ""; + + if ((char === "\"" || char === "'") && prev !== "\\") { + if (inString === char) { + inString = null; + } else if (inString === null) { + inString = char; + } + } + + if (inString === null) { + if (char === "(" || char === "{" || char === "[") { + depth++; + } else if (char === ")" || char === "}" || char === "]") { + depth--; + } + } + + if (char === ";" && depth === 0 && inString === null) { + if (current.trim()) { + statements.push(current.trim()); + } + current = ""; + } else { + current += char; + } + } + + if (current.trim()) { + statements.push(current.trim()); + } + + return statements; } /** @@ -348,7 +383,12 @@ const eventScope: Scope = { ...ctx.scope, $el: ctx.element, $event: event }; try { - const result = evaluate(expr, eventScope); + const statements = extractStatements(expr); + let result: unknown; + for (const stmt of statements) { + result = evaluate(stmt, eventScope); + } + if (typeof result === "function") { result(event); } @@ -560,13 +600,35 @@ } }; - update(); + updateAndRegister(ctx, update, expr); +} - const deps = extractDeps(expr, ctx.scope); - for (const dep of deps) { - const unsubscribe = dep.subscribe(update); - ctx.cleanups.push(unsubscribe); +/** + * Bind data-volt-init to run initialization code once when the element is mounted. + */ +function bindInit(ctx: BindingContext, expr: string): void { + try { + const statements = extractStatements(expr); + for (const stmt of statements) { + evaluate(stmt, ctx.scope); + } + } catch (error) { + console.error("Error in data-volt-init:", error); } +} + +/** + * Bind data-volt-pin to register an element reference in the scope's pin registry. + * Makes the element accessible via $pins.name ($pins[name]) in expressions and event handlers. + * + * @example + * ```html + * + * + * ``` + */ +function bindPin(ctx: BindingContext, name: string): void { + registerPin(ctx.scope, name, ctx.element); } /** @@ -629,7 +691,7 @@ } }; - updateAndUnsub(ctx, render, expr); + updateAndRegister(ctx, render, expr); ctx.cleanups.push(() => { for (const cleanup of renderedCleanups) { @@ -707,7 +769,7 @@ } }; - updateAndUnsub(ctx, render, expr); + updateAndRegister(ctx, render, expr); ctx.cleanups.push(() => { if (currentCleanup) { @@ -798,4 +860,39 @@ evaluate: (expr) => evaluate(expr, ctx.scope), lifecycle, }; +} + +/** + * Inject special variables ($store, $origin, $scope, $pins, $pulse, $uid, $arc, $probe) + * into the scope for this root element. + * + * Creates scope metadata and makes runtime utilities available in expressions. + * We create a Proxy for $pins that dynamically reads from metadata to ensure pins registered later are immediately accessible + */ +function injectSpecialVars(scope: Scope, root: Element): void { + createScopeMetadata(scope, root); + + scope.$store = getStore(); + scope.$pulse = createPulse(); + scope.$origin = root; + scope.$scope = scope; + + scope.$pins = new Proxy({}, { + get(_target, prop: string) { + if (typeof prop === "string") { + return getPin(scope, prop); + } + return void 0; + }, + has(_target, prop: string) { + if (typeof prop === "string") { + return getPin(scope, prop) !== undefined; + } + return false; + }, + }); + + scope.$uid = createUid(scope); + scope.$arc = createArc(root); + scope.$probe = createProbe(scope); } diff --git a/lib/src/core/charge.ts b/lib/src/core/charge.ts --- a/lib/src/core/charge.ts +++ b/lib/src/core/charge.ts @@ -9,19 +9,30 @@ import { evaluate } from "./evaluator"; import { getComputedAttributes, isNil } from "./shared"; import { computed, signal } from "./signal"; +import { registerStore } from "./store"; /** * Discover and mount all Volt roots in the document. * Parses data-volt-state for initial state and data-volt-computed for derived values. + * Also parses declarative global store from script[data-volt-store] elements. * * @param rootSelector - Selector for root elements (default: "[data-volt]") * @returns ChargeResult containing mounted roots and cleanup function * * @example * ```html + * + * + * *
*

*

+ *

*
* ``` * @@ -31,6 +42,8 @@ * ``` */ export function charge(rootSelector = "[data-volt]"): ChargeResult { + parseDeclarativeStore(); + const elements = document.querySelectorAll(rootSelector); const chargedRoots: ChargedRoot[] = []; @@ -93,4 +106,33 @@ } return scope; +} + +/** + * Parse and register global store from declarative script tags. + * + * Looks for: + +
+

+

+
+ `; + + charge(); + + expect(screen.getByText("dark")).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("handles multiple store script tags", () => { + document.body.innerHTML = ` + + + + +
+

+

+
+ `; + + charge(); + + expect(screen.getByText("dark")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + // TODO: Test error handling for invalid JSON in store script + }); + + describe("Combined Features", () => { + it("uses multiple special variables together", async () => { + registerStore({ prefix: "user" }); + + document.body.innerHTML = ` +
+ + +

+
+ `; + + charge(); + + const input = screen.getByRole("textbox"); + const button = screen.getByRole("button"); + expect(input.id).toBe("volt-user-1"); + expect(screen.getByText("Root: DIV")).toBeInTheDocument(); + + const focusSpy = vi.spyOn(input, "focus"); + + button.click(); + + await waitFor(() => { + expect(focusSpy).toHaveBeenCalled(); + }); + }); + }); +}); -- tangled.sh