···11-# Expression Evaluation
22-33-VoltX.js evaluates JavaScript-like expressions in HTML templates using a sandboxed recursive descent parser.
44-The evaluator is CSP-compliant and does not use `eval()` or `new Function()`.
55-66-## Supported Syntax
77-88-The expression language supports a subset of JavaScript:
99-1010-- Standard literals (numbers, strings, booleans, null, undefined)
1111-- Arithmetic operators (`+`, `-`, `*`, `/`, `%`)
1212-- Comparison operators (`===`, `!==`, `<`, `>`, `<=`, `>=`)
1313-- Logical operators (`&&`, `||`, `!`)
1414-- Ternary operator (`? :`).
1515-1616-Property access works via dot notation (`user.name`) or bracket notation (`items[0]`).
1717-Method calls are supported on any object, including chaining (`text.trim().toUpperCase()`).
1818-Arrow functions work with single-expression bodies for use in array methods like `filter`, `map`, and `reduce`.
1919-2020-Array and object literals can be created inline, with spread operator support (`...`) for both arrays and objects.
2121-Signals are automatically unwrapped when referenced in expressions.
2222-2323-## Security Model
2424-2525-The evaluator implements a balanced sandbox that blocks dangerous operations while attempting to preserve flexibility for most use cases.
2626-2727-### Blocked Access
2828-2929-Three property names are unconditionally blocked to prevent prototype pollution: `__proto__`, `constructor`, and `prototype`.
3030-These restrictions apply to all access patterns including dot notation, bracket notation, and object literal keys.
3131-3232-The following global names are blocked even if present in scope:
3333-`Function`, `eval`, `globalThis`, `window`, `global`, `process`, `require`, `import`, `module`, `exports`.
3434-3535-### Allowed Operations
3636-3737-Standard constructors and utilities remain accessible: `Array`, `Object`, `String`, `Number`, `Boolean`, `Date`, `Math`, `JSON`, `RegExp`, `Map`, `Set`, `Promise`.
3838-3939-All built-in methods on native types (strings, arrays, objects, etc.) are permitted. Signal methods (`get`, `set`, `subscribe`) are explicitly allowed even though `constructor` is otherwise blocked.
4040-4141-### Error Handling
4242-4343-Expressions containing unsafe operations or syntax errors are caught, logged to the console, and return `undefined` rather than throwing. This prevents malicious or malformed expressions from breaking the application.
4444-4545-## Guidelines
4646-4747-### Performance
4848-4949-Expressions are parsed on every evaluation. For optimal performance, keep expressions simple and use computed signals for complex calculations.
5050-The evaluator automatically tracks signal dependencies so only affected bindings re-evaluate when signals change.
5151-5252-### Best Practices
5353-5454-- Use computed signals for logic that appears in multiple bindings or involves expensive operations.
5555-- Never use untrusted user input directly in expressions without validation.
5656-- Prefer simple, readable expressions in templates over complex nested operations.
5757-- Structure your scope data with consistent shapes (or consistent types) to avoid runtime errors.
docs/global-state.md
docs/usage/global-state.md
+53
docs/internals/async.md
···11+# Async Effect Internals
22+33+`asyncEffect` orchestrates asynchronous work that reacts to signals.
44+It combines the signal subscription model with scheduling helpers (debounce/throttle), abort signals, retries, and cleanup delivery.
55+The implementation lives in `lib/src/core/async-effect.ts`.
66+77+## Execution lifecycle
88+99+1. **Subscription** - Each dependency signal registers `scheduleExecution` with `subscribe()`.
1010+ The effect runs immediately on creation and whenever any dependency changes.
1111+2. **Scheduling** - `scheduleExecution` increments a monotonic `executionId`, then applies debounce or throttle rules before invoking `executeEffect`.
1212+3. **Abort + cleanup** - The previous cleanup function (if any) runs before each new execution.
1313+ When `abortable` is true, a shared `AbortController` is aborted prior to cleanup and replaced for the upcoming run.
1414+4. **Effect body** - The async callback receives the optional `AbortSignal`.
1515+ It may return a cleanup function (sync or async).
1616+ VoltX stores it so future runs can dispose the previous work.
1717+5. **Race protection** - The awaited result checks whether its `executionId` still matches the global counter.
1818+ If dependencies changed mid-flight, the run is considered stale and discarded.
1919+6. **Retry loop** - Errors increment a `retryCount`.
2020+ While the counter is below `retries`, the effect waits for `retryDelay` (if provided) and reruns the same `executionId`.
2121+ Once retries are exhausted VoltX logs the failure and, when `onError` is defined, passes the error alongside a `retry()` callback that resets the counter and schedules a new run.
2222+2323+## Scheduling helpers
2424+2525+- **Debounce** clears and reuses a `setTimeout`, delaying execution until changes stop for `opts.debounce` (in ms).
2626+- **Throttle** tracks the last execution timestamp.
2727+ If the window has not expired it schedules a timer to run later and flips `pendingExecution` so only one trailing invocation is queued.
2828+- Both helpers coexist with abort support: any timer-driven execution aborts the previous run before invoking the effect body.
2929+3030+## Cleanup guarantees
3131+3232+- Returning a function from the effect body registers it as the cleanup for the next iteration.
3333+- Abortable effects tip off downstream code through the `AbortSignal`, but cleanup functions still run even if the consumer ignores the signal.
3434+- Disposing the effect (via the returned function) aborts active requests, runs cleanup once, clears pending timers, and unsubscribes from every dependency.
3535+3636+## Error handling nuances
3737+3838+- All cleanup functions are wrapped in try/catch to avoid crashing the reactive loop.
3939+- Retry delays use `setTimeout` so they respect fake timers in Vitest.
4040+- Stale retries bail immediately if the global `executionId` has advanced, preventing duplicate work after rapid dependency changes.
4141+4242+## Testing Surface
4343+4444+`lib/test/core/async-effect.test.ts` covers:
4545+4646+- Immediate execution and dependency reactivity.
4747+- Cleanup semantics and disposal.
4848+- Abort controller wiring (abort on change, abort on dispose).
4949+- Race protection to ensure stale responses are ignored.
5050+- Debounce and throttle behavior.
5151+- Retry loops, `onError` callbacks, and manual retry invocation.
5252+5353+These tests rely on fake timers, so implementation details intentionally avoid microtasks for debounce/throttle, favoring `setTimeout` to keep deterministic control over scheduling.
+119
docs/internals/binder-eval.md
···11+# Bindings & Evaluation
22+33+VoltX’s binding layer is the glue between declarative `data-volt-*` attributes and the reactivity primitives that drive them.
44+Here we explain how the binder walks the DOM, how directives are dispatched, how expressions are compiled and executed, and the guardrails we erected while hardening the evaluator.
55+66+## Mount Pipeline
77+88+1. **Scope preparation** - `mount(root, scope)` first injects VoltX’s helper variables (`$store`, `$uid`, `$pins`, `$probe`, etc.) into the caller-provided scope.
99+ Helpers are frozen before exposure so user code cannot tamper with framework utilities.
1010+2. **Tree walk** - We perform a DOM walk rooted at `root`, skipping subtrees marked with `data-volt-skip`.
1111+ Elements cloaked with `data-volt-cloak` are un-cloaked during traversal.
1212+3. **Attribute collection** - `getVoltAttrs()` extracts `data-volt-*` attributes and normalises modifiers (e.g. `data-volt-on-click.prevent` -> `on-click` with `.prevent`).
1313+4. **Directive dispatch** - Structural directives (`data-volt-for`, `data-volt-if`) short-circuit the attribute loop because they clone/remove nodes.
1414+ Everything else is routed through `bindAttribute()` which:
1515+ - Routes `on-*` attributes to the event binding pipeline.
1616+ - Routes `bind:*` aliases (e.g. `bind:value`) to attribute binding helpers.
1717+ - For colon-prefixed segments (`data-volt-http:get`), hands control to plugin handlers.
1818+ - Falls back to the directive registry or plugin registry, then logs an unknown binding warning.
1919+5. **Lifecycle hooks** - Each bound element fires the global lifecycle callbacks (`beforeMount`, `afterMount`, etc.).
2020+ Per-plugin lifecycles are surfaced via `PluginContext.lifecycle`.
2121+2222+Each directive registers clean-up callbacks so `mount()` can return a disposer that un-subscribes signals, removes event listeners, and runs plugin uninstall hooks.
2323+2424+## Directive Registry
2525+2626+We expose `registerDirective(name, handler)` to allow plugins to self-register.
2727+Core only ships the structural directives and the minimal attribute/event set required for the base runtime.
2828+This keeps the lib bundle slim and allows tree shaking to drop unused features.
2929+3030+`registerDirective()` is side-effectful at module evaluation time. Optional packages import the binder, call `registerDirective()`, and expose their entry point via Vite’s plugin system.
3131+Consumers that never import the module never pay for its directives.
3232+3333+## Expression Compilation
3434+3535+All binding expressions funnel through `evaluate(expr, scope)` (or `evaluateStatements()` for multi-statement handlers).
3636+The evaluator implements a few layers of defense:
3737+3838+### Cached `new Function`
3939+4040+- Expressions are compiled into functions with `new Function("$scope", "$unwrap", ...)`.
4141+- We wrap execution in a `with ($scope) { ... }` block to preserve ergonomic access to identifiers.
4242+- Compiled functions are cached in a `Map` keyed by the expression string + mode (`expr` vs `stmt`)
4343+ Cache hits avoid re-parsing and reduce GC churn.
4444+4545+### Hardened Scope Proxy
4646+4747+`createScopeProxy(scope)` builds an `Object.create(null)` proxy that:
4848+4949+- Returns `undefined` for dangerous identifiers and properties (`constructor`, `__proto__`, `globalThis`, `Function`, etc.).
5050+- Reuses VoltX’s `wrapValue()` utility to auto-unwrap signals while guarding against prototype pollution.
5151+- Treats setters specially: if a scope entry is a signal, assignments route to `signal.set()`.
5252+- Spoofs `has` so the `with` block never falls through to `globalThis`.
5353+5454+Every call to `evaluate()` constructs this proxy and iss fast because signals and helpers are stored on the original scope, not the proxy.
5555+5656+### Safe Negation & `$unwrap`
5757+5858+Logical negation (`!signal`) is tricky when signals are proxied objects.
5959+Before compilation we run `transformExpression()` which rewrites top-level `!identifier` patterns into `!$unwrap(identifier)`.
6060+`$unwrap()` dereferences signals without exposing their methods, making boolean coercion reliable even when the underlying value is a reactive proxy or computed signal.
6161+6262+### Signal-Aware Wrapping
6363+6464+`wrapValue()` enforces blocking rules and auto-unwrapping:
6565+6666+- Signal reads return a small proxy exposing `get`, `set`, and `subscribe` while delegating property reads to the underlying value.
6767+- Nested values re-enter `wrapValue()` so the entire object graph respects the hazardous-key deny list.
6868+- When `unwrapSignals` is enabled (default for read contexts), signal reads return their current value so DOM bindings can treat them like plain data.
6969+- Statement contexts (event handlers, `data-volt-init`) pass `{ unwrapSignals: false }` so authors can still call `count.set()` or `store.set()` directly.
7070+7171+### Error Surfacing
7272+7373+Any runtime error thrown by the compiled function is wrapped in `EvaluationError` which carries the original expression for better debugging.
7474+Reference errors (missing identifiers) return `undefined` to mimic plain JavaScript.
7575+7676+## Event Handlers
7777+7878+`data-volt-on-*` bindings support modifiers (`prevent`, `stop`, `self`, `once`, `window`, `document`, `debounce`, `throttle`, `passive`). Before executing the handler we assemble an `eventScope` that inherits the original scope but adds `$el` and `$event`. Statements run sequentially; the last value is returned. If the handler returns a function we invoke it with the triggering event to mimic inline handler ergonomics (`data-volt-on-click="(ev) => fn(ev)"`).
7979+8080+Debounce/throttle modifiers wrap the execute function with cancellable helpers. Clean-up hooks clear timers when the element unmounts.
8181+8282+## Structural/Control Directives
8383+8484+### `data-volt-if`
8585+8686+- Clones/discards `if` and optional `else` templates.
8787+- Evaluates the condition reactively; dependencies are tracked via `extractDeps()` which scans expressions for signals.
8888+- Supports surge transitions by awaiting `executeSurgeEnter/Leave()` when available.
8989+- Maintains branch state so redundant renders are skipped. Clean-up disposes child mounts when a branch is swapped out.
9090+9191+### `data-volt-for`
9292+9393+- Parses `"item in items"` or `"(item, index) in items"` grammar.
9494+- Uses a placeholder comment to maintain insertion position.
9595+- Re-renders on dependency changes by clearing existing clones and re-mounting with a child scope containing the loop variables.
9696+- Registers per-item clean-up disposers so each clone tears down correctly.
9797+9898+## Data Flow & Dependency Tracking
9999+100100+Reactive updates rely on `updateAndRegister(ctx, update, expr)`:
101101+102102+1. Executes the update function immediately for initial DOM synchronisation.
103103+2. Calls `extractDeps()` to gather signals referenced within the expression (with special handling for `$store.get()` lookups).
104104+3. Subscribes to each signal and pushes the unsubscribe callback into the directive’s clean-up list.
105105+106106+This pattern is used by text/html bindings, class/style bindings, show/if/for, and plugin-provided directives.
107107+108108+## Challenges & Lessons
109109+110110+- **Security vs ergonomics** - Moving from a hand-rolled parser to `new Function` simplified expression support but introduced sandboxing risks.
111111+ The scope proxy and whitelists were essential to close off prototype pollution and global escape hatches.
112112+- **Signal negation** - `!signal` originally returned `false` because the proxy object was truthy.
113113+ The `$unwrap` transformation ensures boolean logic matches user expectations without forcing explicit `.get()` calls.
114114+- **Plugin isolation** - Allowing plugins to register directives meant we had to guarantee that the core binder stays stateless.
115115+ Directive handlers receive a `PluginContext` with controlled capabilities so they can integrate without mutating internal machinery.
116116+- **Error visibility** - Swallowing exceptions made debugging inline expressions painful.
117117+ `EvaluationError` and consistent logging in directives give developers actionable stack traces while keeping the runtime resilient.
118118+119119+With these guardrails the binder provides a secure, extensible bridge between declarative templates and VoltX’s reactive runtime.
+63-172
docs/internals/proxies.md
···11# Proxy Objects
2233-Volt's reactive proxy system implements deep reactivity for objects and arrays. Unlike `signal()` which wraps a single value, `reactive()` creates a transparent proxy where property access feels natural while maintaining full reactivity.
33+Volt’s `reactive()` helper wraps plain objects and arrays in Proxies that expose deep reactivity while defending against prototype-pollution and sandbox escapes.
44+This document details how the proxies are constructed, how they integrate with signals, and where the guardrails live.
4555-## Core Design
66+## Goals
6777-The reactive system uses JavaScript Proxies to intercept property access and mutations.
88-Each reactive proxy is backed by the original raw object and a map of signals: one signal per property, created lazily on first access.
99-1010-This design provides:
1111-1212-**Transparency**: Access `obj.count` instead of `obj.count.get()`. The proxy unwraps signals automatically.
1313-1414-**Deep Reactivity**: Nested objects and arrays are recursively wrapped, so `obj.nested.value` is reactive all the way down.
88+- **Transparency** - Accessing `state.user.name` should feel like working with a plain object, without explicit `.get()` calls.
99+- **Deep reactivity** - Nested objects, arrays, and symbols participate automatically.
1010+- **Safety** - Dangerous keys such as `__proto__` or `constructor` are blocked regardless of depth.
1111+- **Single source of truth** - Each raw object maps to exactly one proxy, keeping identity stable and ensuring watchers de-duplicate work.
15121616-**Lazy Signals**: Signals are only created when properties are accessed. An object with 100 properties only creates signals for the properties you actually use.
1313+## Core Data Structures
17141818-**Native Array Methods**: Array mutators like `push`, `pop`, `splice` work naturally and trigger updates correctly.
1515+Three WeakMaps maintain relationships:
19162020-## Three WeakMaps
1717+1. `rawToReactive` - From raw object to proxy. Guarantees we never create two proxies for the same target.
1818+2. `reactiveToRaw` - Reverse lookup used by `toRaw()` and `isReactive()`.
1919+3. `targetToSignals` - Maps a raw target to a `Map<key, Signal>`. Each property lazily receives a signal the first time it’s accessed.
21202222-The system uses three WeakMaps to track relationships:
2323-2424-1. `reactiveToRaw` maps from reactive proxy to original object. Used by `toRaw()` to unwrap proxies and by `isReactive()` to check if something is already a proxy.
2525-2. `rawToReactive` maps from original object to reactive proxy.
2626- This ensures only one proxy per object. Calling `reactive()` twice on the same object returns the same proxy instance.
2727-3. `targetToSignals` maps from target object to a Map of property signals.
2828- Each target has its own Map of `(key: string | symbol) -> Signal`. Signals are created lazily in `getPropertySignal()`.
2929-3030-WeakMaps allow us to avoid preventing garbage collection of proxies or targets.
2121+WeakMaps ensure metadata is collected with the target; no explicit teardown is required.
31223223## Property Access (get trap)
33243434-When you access `proxy.count`, the get trap:
2525+When a consumer reads `proxy.key`:
35263636-1. Checks for special keys: `__v_raw` returns the raw target, `__v_isReactive` returns true. These enable introspection.
3737-2. For arrays, checks if the key is a mutator method (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`, `fill`, `copyWithin`).
3838-If so, returns a wrapper function that applies the mutation to the target, then updates all affected index signals and the length signal.
3939-3. Gets or creates the signal for this property via `getPropertySignal()`.
4040-4. Calls `recordDep(sig)` to track this access for dependency tracking (if inside a computed or effect).
4141-5. Gets the actual value using `Reflect.get()`.
4242-6. If the value is an object, wraps it recursively with `reactive()` before returning. This provides deep reactivity.
4343-7. If the value is not an object, returns `sig.get()` which provides the signal's current value.
2727+1. **Special escape hatches** - `__v_raw` returns the raw target, `__v_isReactive` identifies proxies, and `$voltx_debug` (internal) surfaces debugging helpers.
2828+2. **Dangerous key check** - Keys like `"constructor"`, `"prototype"`, `"__proto__"`, or `"globalThis"` immediately return `undefined`.
2929+ This mirrors the evaluator’s hardened scope rules, keeping user expressions and runtime helpers aligned.
3030+3. **Array mutators** - If the target is an array and the key is a mutator (`push`, `splice`, etc.), we return a wrapped function that runs the native method then updates all affected signals (indices + `length`).
3131+4. **Signal retrieval** - `getPropertySignal()` returns/creates the signal for the property and registers it with the dependency tracker.
3232+5. **Value resolution** - The raw value is read via `Reflect.get()`.
3333+ If it’s an object/function, we recursively call `reactive()` so nested access stays reactive.
3434+ Otherwise we return `signal.get()` which unwraps the value.
44354545-The subtle difference in step 6-7 is important: for objects, we return the reactive proxy (not the signal value), so you get `obj.nested.prop` not `obj.nested.get().prop`. But dependency tracking still happens because we called `recordDep()` in step 4.
3636+This layered approach means `reactive()` objects are safe to embed in evaluator scopes—the same dangerous keys are filtered and every nested property remains reactive.
46374738## Property Mutation (set trap)
48394949-When you assign `proxy.count = 5`, the set trap:
4040+`proxy.key = value` executes:
50415151-1. Reads the old value via `Reflect.get()`.
5252-2. Performs the actual mutation with `Reflect.set()`.
5353-3. If the value changed (old !== new), gets the property signal and calls `sig.set(value)`.
5454-4. Returns the result from `Reflect.set()` to indicate success.
4242+1. Reads the previous value with `Reflect.get()` (needed for equality checks).
4343+2. Performs the write via `Reflect.set()`. Failures (e.g. frozen objects) surface as normal `false` return values.
4444+3. If the value changed, updates the property signal with `signal.set(newValue)` triggering downstream computeds/effects.
55455656-This ensures that mutations to the raw object and signal notifications happen atomically.
4646+Assignments to blocked keys (`__proto__`, `constructor`, etc.) are ignored to prevent prototype pollution.
4747+The setter returns `true` so user code doesn’t throw while the runtime remains protected.
57485858-## Property Deletion (deleteProperty trap)
4949+## Deletion & Existence
59506060-When you `delete proxy.count`:
5151+- `delete proxy.key` removes the property via `Reflect.deleteProperty()`, then sets the property signal to `undefined` to notify dependents.
5252+- The `in` operator (`'key' in proxy`) records a dependency and defers to `Reflect.has()`, making existence checks reactive.
61536262-1. Checks if the property existed with `Reflect.has()`.
6363-2. Performs the deletion with `Reflect.deleteProperty()`.
6464-3. If the property existed and deletion succeeded, gets the property signal and sets it to `undefined`.
6565-4. Returns the deletion result.
5454+## Array Handling
66556767-Setting the signal to `undefined` ensures any computeds or effects depending on that property get notified about the deletion.
5656+Array methods are wrapped to keep per-index signals in sync:
68576969-## Property Existence (has trap)
5858+- We snapshot the array’s pre-mutation length.
5959+- Call the native method on the raw array.
6060+- Compute the range of indices that may have changed and update/create their signals.
6161+- Update the `length` signal if needed.
70627171-The `in` operator (`'count' in proxy`) goes through the has trap:
6363+Methods that do not mutate (e.g. `slice`) pass through unwrapped.
72647373-1. Gets the property signal (creating it if needed).
7474-2. Records the dependency with `recordDep()`.
7575-3. Returns `Reflect.has()` result.
6565+## Integration with Signals
76667777-This makes property existence checks reactive. If you have a computed like `() => 'count' in state`, it will rerun if that property is added or deleted.
6767+Every reactive property is backed by a `signal`. This keeps the proxy layer thin—core logic lives in `signal.ts`, and the proxy simply orchestrates reads/writes against those signals.
6868+Because signals already integrate with dependency tracking, reactive object reads automatically wire into computeds, effects, and DOM bindings without extra bookkeeping.
78697979-## Array Mutators
7070+## Interop Utilities
80718181-Arrays get special handling because native methods like `push` mutate multiple indices simultaneously. The array mutator wrapper:
7272+- `toRaw(value)` unwraps a proxy by consulting `reactiveToRaw`.
7373+ Useful when passing data to libraries that cannot handle proxies.
7474+- `isReactive(value)` checks presence in `reactiveToRaw` without triggering getters.
7575+- `markRaw(value)` (internal helper) can flag objects that should bypass reactivity, useful for expensive third-party instances.
82768383-1. Gets the array method from the target (e.g., `Array.prototype.push`).
8484-2. Records the old length.
8585-3. Applies the method to the target with the provided arguments.
8686-4. Calculates the new length and determines the maximum index that might have changed.
8787-5. Loops through all potentially affected indices, gets/creates their signals, and updates them with the new values from the target.
8888-6. If the length changed, updates the length signal.
8989-7. Returns the result from the native method.
7777+## Evaluator Interaction
90789191-This approach ensures that mutations like `arr.splice(1, 2, 'a', 'b', 'c')` correctly update all affected index signals and the length signal, triggering computeds that depend on those indices.
7979+Bindings and expressions run via the hardened evaluator. When it encounters a reactive proxy it uses `wrapValue()` to create a safe view:
92809393-The mutator list is hardcoded as a Set: `['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin']`.
9494-Array methods that don't mutate (like `map`, `filter`, `slice`, `toSorted`) go through the normal get trap and return the native method bound to the target.
8181+- Dangerous keys remain blocked.
8282+- Signals returned from proxy properties expose `get`, `set`, and `subscribe`, but property reads on the signal proxy delegate back to the underlying value.
8383+- Primitive coercion works because the wrapper defines `valueOf`, `toString`, and `Symbol.toPrimitive` on demand.
8484+- Boolean negation (`!signal`) is rewritten to `!$unwrap(signal)` before compilation so reactive values behave like plain booleans.
95859696-## Signal Creation
8686+## Challenges & Lessons
97879898-The `getPropertySignal()` helper:
9999-100100-1. Gets the Map of signals for this target from `targetToSignals`, or creates one if needed.
101101-2. Looks up the signal for this specific key in the Map.
102102-3. If no signal exists, reads the initial value from the target with `Reflect.get()`, creates a new signal with that value, and stores it in the Map.
103103-4. Returns the signal.
104104-105105-This lazy creation means that accessing a property for the first time has a small overhead (creating the signal and Map entries), but subsequent accesses just look up the existing signal.
106106-107107-## Integration with Signal System
108108-109109-The reactive proxy system sits on top of the signal system. Each reactive property is backed by a signal created via `signal()` from the core signal module.
110110-When you access `proxy.count`, you're actually calling `get()` on the signal for the 'count' property. When you assign `proxy.count = 5`, you're calling `set(5)` on that signal.
111111-This means all the signal behavior works: dependency tracking via `recordDep()`, subscriber notifications, equality checks (`value === newValue` to prevent unnecessary updates).
112112-The proxy just provides a more convenient API so you'll use something like `obj.count++` instead of `obj.count.set(obj.count.get() + 1)`.
8888+- **Balancing safety and ergonomics** - Blocking hazardous keys everywhere (reactive proxies, evaluator proxies, and scope helpers) keeps the mental model consistent.
8989+ The challenge is ensuring legitimate use cases (e.g. accessing an object’s prototype intentionally) are still possible via `toRaw()` when absolutely required.
9090+- **Array performance** - Naively re-wrapping arrays each mutation was costly.
9191+ Hard-coding mutator wrappers keeps hot paths predictable without introducing per-access allocations.
9292+- **Equality semantics** - Since signals use strict equality, mutating nested objects in place does not trigger updates.
9393+ Documentation and lint rules encourage immutable patterns or explicit reassignments to keep changes observable.
9494+- **Garbage collection** - Using WeakMaps exclusively avoids memory leaks, but it meant giving up on certain debugging tricks (storing strong references to proxies).
9595+ The debug registry in `lib/src/debug` now mirrors relationships explicitly when debugging is enabled.
11396114114-## Deep Reactivity
115115-116116-Nested objects are made reactive recursively. When the get trap encounters an object value, it wraps it with `reactive()` before returning.
117117-118118-The `reactive()` function itself checks `rawToReactive` first, so if that nested object was already wrapped, it returns the existing proxy. This means:
119119-120120-```ts
121121-const state = reactive({ nested: { count: 0 } });
122122-const nested1 = state.nested;
123123-const nested2 = state.nested;
124124-nested1 === nested2; // true, same proxy
125125-```
126126-127127-Arrays within reactive objects are also proxied, and arrays containing objects have those objects proxied when accessed:
128128-129129-```ts
130130-const state = reactive({ items: [{ id: 1 }, { id: 2 }] });
131131-state.items[0].id = 3; // fully reactive
132132-state.items.push({ id: 4 }); // also reactive
133133-```
134134-135135-## Unwrapping with toRaw
136136-137137-The `toRaw()` function unwraps a proxy to get the original object. It checks if the value is an object, then looks it up in `reactiveToRaw`. If not found, returns the value as-is (it wasn't a proxy).
138138-139139-This is useful when you need to pass the raw object to third-party libraries that might not handle proxies well, or when you want to do mutations without triggering reactivity (though this is rarely needed).
140140-141141-## Checking Reactivity
142142-143143-The `isReactive()` function checks if a value is a reactive proxy by testing if it's an object and exists in `reactiveToRaw`.
144144-This is simpler than checking for the `__v_isReactive` property because it doesn't trigger the get trap.
145145-146146-## Type Safety
147147-148148-TypeScript types flow through transparently. If you pass `{ count: number }` to `reactive()`, you get back a reactive object typed as `{ count: number }`.
149149-The proxy is invisible to the type system. This is possible because Proxies preserve the object's interface keeping all properties and methods accessible with the same types.
150150-151151-## Performance Characteristics
152152-153153-- Creating a reactive proxy has minimal overhead, as it just creates the Proxy object and adding two WeakMap entries.
154154- - The first property access has the overhead of creating a signal and Map entries.
155155- - Subsequent property access is very fast
156156- - WeakMap lookup for the signal Map, then Map lookup for the signal, then signal.get().
157157- - Mutations are similarly fast
158158- - WeakMap lookups plus signal.set().
159159- - The WeakMaps have zero memory overhead for garbage collection so when a proxy is no longer referenced, all its metadata is collected automatically.
160160-- Arrays are slightly slower due to the mutator wrappers, but still $O(n)$ in the number of affected elements, which matches the native method's complexity.
161161-162162-## Edge Cases
163163-164164-**Non-object values**: `reactive()` logs a warning and returns the value unchanged.
165165-You can't make primitives reactive without wrapping them in a `signal()`.
166166-167167-**Already reactive**: Calling `reactive()` on a proxy returns the same proxy immediately.
168168-169169-**Prototype pollution**: The proxy traps use `Reflect` methods which respect the prototype chain naturally.
170170-No special protection is needed because signals are stored in a separate WeakMap, not on the object itself.
171171-172172-**Symbol properties**: Fully supported. Symbols can be used as property keys and get their own signals just like string keys.
173173-174174-**Non-enumerable properties**: Work correctly. The get/set traps handle them the same as enumerable properties.
175175-176176-**Frozen/sealed objects**: Setting properties on frozen objects will fail in `Reflect.set()` and the set trap will return false.
177177-The signal won't be updated, maintaining consistency.
178178-179179-## Signal vs. Reactive
180180-181181-Use `signal()` when:
182182-183183-- You have a single primitive value
184184-- You want explicit get/set calls
185185-- You're storing a function (functions can't be proxy targets)
186186-187187-Use `reactive()` when:
188188-189189-- You have an object with multiple properties
190190-- You want natural property access syntax
191191-- You have nested objects or arrays
192192-- You're integrating with code that expects plain objects
193193-194194-Both are backed by the same signal primitive. The choice is about API ergonomics, not capability.
195195-196196-## Implementation Notes
197197-198198-The proxy system is implemented in `lib/src/core/reactive.ts` and depends only on `signal()` and `recordDep()` from the tracker.
199199-It's completely independent of the binding system, expression evaluator, and other framework features.
200200-201201-This separation means you can use reactive objects in any context:
202202- - In the DOM binding system via `data-volt-*` attributes
203203- - Programmatic code with `mount()`
204204- - Standalone without any UI at all.
205205-206206-The declarative `data-volt-state` attribute in the charge system creates reactive objects from JSON, making deep reactivity available in fully declarative apps without writing any JavaScript.
9797+The proxy layer underpins VoltX’s "plain object" ergonomics while preserving the security posture demanded by the evaluator. Understanding these mechanics helps when extending the runtime or diagnosing subtle update issues.
+92
docs/internals/reactivity.md
···11+# Reactivity Architecture
22+33+VoltX’s reactivity system is built around a small set of primitives—signals, computed signals, and effects—that coordinate via an explicit dependency tracker.
44+This document explains how those pieces fit together, how updates flow through the system, and the trade-offs we made while hardening the implementation.
55+66+## Signals
77+88+`signal(initialValue)` returns an object with three methods:
99+1010+- `get()` records dependency access (via `recordDep`) and returns the current value.
1111+- `set(next)` performs a referential equality check; if the value changed it notifies subscribers.
1212+- `subscribe(listener)` registers callbacks that fire on every change and returns an unsubscribe function.
1313+1414+Signals store subscribers in a `Set`, so multiple identical subscriptions are deduplicated. Notifications are delivered over a shallow copy of the subscriber list to guard against mutation during iteration, and errors inside a subscriber are caught and logged so one faulty listener cannot collapse the cascade.
1515+1616+## Dependency Tracking
1717+1818+The tracker module exposes `startTracking(source?)`, `recordDep(dep)`, and `stopTracking()`.
1919+2020+The active tracking context lives on a stack:
2121+2222+1. A computed or effect calls `startTracking(source)` before evaluating its body.
2323+2. Each signal’s `get()` sees the active context and adds itself to the context’s dependency set.
2424+3. After the body executes, `stopTracking()` pops the context and returns the unique set of dependencies.
2525+2626+Cycle detection is enforced by comparing the `source` passed to `startTracking()` with the dependency being recorded.
2727+If they match we throw, preventing self-referential computeds from hanging the system.
2828+2929+## Computed Signals
3030+3131+`computed(fn)` wraps a pure function that may read other signals or computeds. Key behaviours:
3232+3333+- Lazily initialised on first `get()` or `subscribe()` call (`recompute()` runs only when needed).
3434+- During recomputation we unsubscribe from all previous dependencies, start a new tracking context, run `fn`, then subscribe to the newly discovered dependencies.
3535+- Re-entrancy protection guards against accidental recursive loops by throwing when we detect a recompute while one is already running (often a sign of cyclic dependencies).
3636+- If the derived value changes and there are downstream subscribers we notify them immediately.
3737+3838+Because a computed emits a Signal-like interface, it can be used anywhere a regular signal appears (e.g. bindings, store, nested computeds).
3939+4040+## Effects
4141+4242+`effect(fn)` is VoltX’s autorun/side effect primitive. Internally it mirrors `computed`:
4343+4444+1. Runs `fn` inside a tracking context.
4545+2. Subscribes to dependencies and re-runs when any change.
4646+3. Supports cleanups: if `fn` returns a function we call it before the next run (or on disposal).
4747+4848+The returned disposer clears all subscriptions and performs a final cleanup.
4949+Effects are deliberately eager. They run once on creation so initialisation logic (like attaching event listeners) happens immediately.
5050+5151+## Reactive Objects
5252+5353+While this document focuses on signals, most application code interacts with `reactive()` objects.
5454+These are proxies backed by signals; property reads call `signal.get()`, writes call `signal.set()`.
5555+See [Proxy Objects](./proxies.md) for a detailed discussion.
5656+5757+## Scope Helpers
5858+5959+When a scope is mounted, VoltX injects several helpers that lean on the reactive core:
6060+6161+- `$pulse(cb)` queues `cb` on the microtask queue.
6262+ It’s often used to observe the DOM after reactive updates settle.
6363+- `$probe(expr, cb)` bridges the evaluator and the tracker.
6464+ It uses `extractDeps()` to pre-compute dependencies for the expression, subscribes to them, and re-evaluates via `evaluate()` on change.
6565+- `$arc`, `$uid`, `$pins`, `$store`, and `$probe` all use the same subscription mechanics.
6666+ When they touch signals they automatically participate in the dependency graph.
6767+6868+These helpers ensure that advanced patterns (imperative probes, custom event dispatch) stay aligned with reactive guarantees.
6969+7070+## Update Propagation
7171+7272+1. A signal’s `set()` runs, updates the value, and invokes each subscriber.
7373+2. Computed subscribers (registered via `dep.subscribe(recompute)`) recompute, so if their value changes they notify their own subscribers.
7474+3. Effects rerun, repeating their tracking cycle.
7575+4. DOM bindings registered through `updateAndRegister()` receive the update and perform minimal DOM writes.
7676+7777+VoltX does not batch updates automatically. Calling `set()` twice in a row will push two notifications.
7878+When batching is needed, use `$pulse` or wrap updates in a custom queue.
7979+8080+## Challenges & Trade-offs
8181+8282+- **Minimal core vs features** - The system intentionally avoids hidden mutation queues or scheduler magic.
8383+This keeps mental models simple but means users must explicitly batch when necessary.
8484+- **Signal identity** - Equality checks are referential.
8585+ While fast, it means that mutating nested objects without cloning can bypass change detection unless you touch the signal again.
8686+ We emphasises immutable patterns or explicit `set()` calls with copies.
8787+- **Dependency discovery** - Parsing expressions to pre-collect dependencies (`extractDeps`) introduces heuristics (e.g. `$store.get()` handling).
8888+ We balance accuracy with performance by focusing on common patterns and falling back to runtime evaluation if static analysis fails.
8989+- **Error resilience** - Subscriber callbacks, cleanup functions, and recompute bodies are wrapped in try/catch to prevent one failure from derailing the reactive loop.
9090+ The trade-off is noisy console logs, but the alternative—silently swallowing issues—was harder to debug.
9191+9292+Despite the lightweight implementation, these primitives provide deterministic, traceable update flows that underpin VoltX’s declarative bindings and plugin ecosystem.
docs/lifecycle.md
docs/usage/lifecycle.md
+19-11
docs/state.md
docs/usage/state.md
···11-# State Management
11+# Reactivity
2233VoltX uses signal-based reactivity for state management. State changes automatically trigger DOM updates without virtual DOM diffing or reconciliation.
44···6677### Signals
8899-Signals are the foundation of reactive state. A signal holds a single value that can be read, written, and observed for changes.
99+Signals are the foundation of reactive state.
1010+A signal holds a single value that can be read, written, and observed for changes.
10111112Create signals using the `signal()` function, which returns an object with three methods:
1213···1415- `set(newValue)` updates the value and notifies subscribers
1516- `subscribe(callback)` registers a listener for changes
16171717-Signals use strict equality (`===`) to determine if a value has changed. Setting a signal to its current value will not trigger notifications.
1818+Signals use strict equality (`===`) to determine if a value has changed.
1919+Setting a signal to its current value will not trigger notifications.
18201921### Computed Values
20222123Computed signals derive their values from other signals. They automatically track dependencies and recalculate only when those dependencies change.
22242323-The `computed()` function takes a calculation function and a dependency array. The framework ensures computed values stay synchronized with their sources.
2525+The `computed()` function takes a calculation function and a dependency array.
2626+The framework ensures computed values stay synchronized with their sources.
24272528Computed values are read-only and should not produce side effects. They exist purely to transform or combine other state.
2629···3437- Logging or analytics
3538- Coordinating multiple signals
36393737-For asynchronous operations, use `asyncEffect()` which handles cleanup of pending operations when dependencies change or the effect is disposed.
4040+For asynchronous operations, use `asyncEffect()` (see [asyncEffect](./usage/async-effect)) which handles cleanup of pending operations when dependencies change or the effect is disposed.
38413942## Declarative State
4043···4649<div data-volt data-volt-state='{"count": 0, "items": []}'>
4750```
48514949-The framework automatically converts these values into reactive signals. Nested objects and arrays become reactive, and property access in expressions automatically unwraps signal values.
5252+The framework automatically converts these values into reactive signals.
5353+Nested objects and arrays become reactive, and property access in expressions automatically unwraps signal values.
50545155### Computed Values in Markup
52565353-Derive values declaratively using `data-volt-computed:name` attributes. The name becomes a signal in the scope, and the attribute value is the computation expression:
5757+Derive values declaratively using `data-volt-computed:name` attributes.
5858+The name becomes a signal in the scope, and the attribute value is the computation expression:
54595560```html
5661<div data-volt
···5863 data-volt-computed:doubled="count * 2">
5964```
60656161-Computed values defined this way follow the same rules as programmatic computed signalsthey track dependencies and update automatically.
6666+Computed values defined this way follow the same rules as programmatic computed signals: they track dependencies and update automatically.
62676368## Programmatic State
6469···73787479## Scope and Access
75807676-Each mounted element creates a scope containing its signals and computed values. Bindings access signals by property path relative to their scope.
8181+Each mounted element creates a scope containing its signals and computed values.
8282+Bindings access signals by property path relative to their scope.
77837884When using declarative state, the scope is built automatically from `data-volt-state` and `data-volt-computed:*` attributes.
79858086When using programmatic mounting, the scope is the object passed as the second argument to `mount()`.
81878282-Bindings can access nested properties, and the evaluator automatically unwraps signal values. Event handlers receive special scope additions: `$el` for the element and `$event` for the event object.
8888+Bindings can access nested properties, and the evaluator automatically unwraps signal values.
8989+Event handlers receive special scope additions: `$el` for the element and `$event` for the event object.
83908491## Signal Methods in Expressions
8592···9310094101## State Persistence
951029696-Signals can be synchronized with browser storage using the built-in persist plugin. See the plugin documentation for details on localStorage, sessionStorage, and IndexedDB integration.
103103+Signals can be synchronized with browser storage using the built-in persist plugin.
104104+See the plugin documentation (coming soon!) for details on localStorage, sessionStorage, and IndexedDB integration.
9710598106## State Serialization
99107
+113
docs/usage/async-effect.md
···11+# Async Effects
22+33+Volt’s `asyncEffect` helper runs asynchronous workflows whenever one or more signals change.
44+It handles abort signals, debounce/throttle scheduling, retries, and cleanup so you can focus on data fetching logic instead of wiring.
55+66+## When to use it
77+88+- Fetching or mutating remote data in response to signal changes.
99+- Performing background work that should cancel when inputs flip rapidly.
1010+- Retrying transient failures without duplicating boilerplate.
1111+- Triggering imperative side effects (e.g., analytics) that return cleanups.
1212+1313+## Basic Example
1414+1515+In this example, if you change `query` with `query.set("new value")` the effect re-runs.
1616+1717+```ts
1818+import { asyncEffect, signal } from "voltx.js";
1919+2020+const query = signal("");
2121+const results = signal([]);
2222+2323+asyncEffect(async () => {
2424+ if (!query.get()) {
2525+ results.set([]);
2626+ return;
2727+ }
2828+2929+ const response = await fetch(`/api/search?q=${encodeURIComponent(query.get())}`);
3030+ results.set(await response.json());
3131+}, [query]);
3232+```
3333+3434+If the effect returns a cleanup function it is invoked before the next execution and on disposal.
3535+3636+## Abortable Fetches
3737+3838+Pass `{ abortable: true }` to receive an `AbortSignal`.
3939+VoltX aborts the previous run each time dependencies change or when you dispose the effect.
4040+4141+```ts
4242+asyncEffect(
4343+ async (signal) => {
4444+ const response = await fetch(`/api/files/${fileId.get()}`, { signal });
4545+ data.set(await response.json());
4646+ },
4747+ [fileId],
4848+ { abortable: true },
4949+);
5050+```
5151+5252+## Debounce and Throttle
5353+5454+- `debounce: number` waits until inputs are quiet for the specified milliseconds.
5555+- `throttle: number` skips executions until the interval has elapsed; the latest change runs once the window closes.
5656+5757+```ts
5858+asyncEffect(
5959+ async () => {
6060+ await saveDraft(documentId.get(), draftBody.get());
6161+ },
6262+ [draftBody],
6363+ { debounce: 500 },
6464+);
6565+```
6666+6767+Combine `debounce` and `abortable` to cancel in-flight saves when the user keeps typing.
6868+6969+## Retry Strategies
7070+7171+`retries` controls how many times VoltX should re-run the effect after it throws.
7272+`retryDelay` adds a pause between attempts. Use `onError` for custom logging or to expose a manual `retry()` hook.
7373+7474+```ts
7575+asyncEffect(
7676+ async () => {
7777+ const res = await fetch("/api/profile");
7878+ if (!res.ok) throw new Error("Request failed");
7979+ profile.set(await res.json());
8080+ },
8181+ [refreshToken],
8282+ {
8383+ retries: 3,
8484+ retryDelay: 1000,
8585+ onError(error, retry) {
8686+ toast.error(error.message);
8787+ retry(); // optionally kick off another attempt immediately
8888+ },
8989+ },
9090+);
9191+```
9292+9393+## Cleanup and disposal
9494+9595+Hold on to the disposer returned by `asyncEffect` when you need to stop reacting:
9696+9797+```ts
9898+const stop = asyncEffect(async () => {
9999+ const subscription = await openStream();
100100+ return () => subscription.close();
101101+}, [channel]);
102102+103103+window.addEventListener("beforeunload", stop);
104104+```
105105+106106+VoltX automatically runs the cleanup when dependencies change, when the effect retries successfully, and when you call the disposer.
107107+108108+## Tips
109109+110110+- Keep the dependency list stable & wrap derived values in computeds if necessary.
111111+- Throw errors from the effect body to trigger retries or the `onError` callback.
112112+- Prefer `debounce` for text inputs and `throttle` for scroll/resize signals.
113113+- Always check abort signals before committing expensive results when `abortable` is enabled.
+1-1
docs/usage/counter.md
···11-# Building a Counter
11+# Counter (Example)
2233This tutorial walks through building a simple counter application to demonstrate VoltX.js fundamentals: reactive state, event handling, computed values, and declarative markup.
44
+63
docs/usage/expressions.md
···11+# Expression Evaluation
22+33+VoltX.js evaluates JavaScript-like expressions in HTML templates using a cached `new Function()` compiler wrapped in a hardened scope proxy.
44+The evaluator compiles each unique expression once, caches the resulting function, and executes it against a sandboxed scope that only exposes explicitly whitelisted globals.
55+66+## Supported Syntax
77+88+The expression language supports a subset of JavaScript:
99+1010+- Standard literals (numbers, strings, booleans, null, undefined)
1111+- Arithmetic operators (`+`, `-`, `*`, `/`, `%`)
1212+- Comparison operators (`===`, `!==`, `<`, `>`, `<=`, `>=`)
1313+- Logical operators (`&&`, `||`, `!`)
1414+- Ternary operator (`? :`).
1515+1616+Property access works via dot notation (`user.name`) or bracket notation (`items[0]`).
1717+Method calls are supported on any object, including chaining (`text.trim().toUpperCase()`).
1818+Arrow functions work with single-expression bodies for use in array methods like `filter`, `map`, and `reduce`.
1919+2020+Array and object literals can be created inline, with spread operator support (`...`) for both arrays and objects.
2121+Signals are automatically unwrapped when referenced in expressions.
2222+2323+## Security Model
2424+2525+The evaluator wraps each scope in an `Object.create(null)` proxy that filters dangerous identifiers, unwraps signals safely, and prevents prototype-chain access.
2626+Even though the implementation relies on `new Function()`, the compiled function only ever sees the proxy—never the real `globalThis`.
2727+2828+### Blocked Access
2929+3030+Three property names are unconditionally blocked to prevent prototype pollution: `__proto__`, `constructor`, and `prototype`.
3131+These restrictions apply to all access patterns including dot notation, bracket notation, and object literal keys.
3232+3333+The following global names are blocked even if present in scope:
3434+`Function`, `eval`, `globalThis`, `window`, `global`, `process`, `require`, `import`, `module`, `exports`.
3535+3636+### Allowed Operations
3737+3838+Standard constructors and utilities remain accessible: `Array`, `Object`, `String`, `Number`, `Boolean`, `Date`, `Math`, `JSON`, `RegExp`, `Map`, `Set`, `Promise`.
3939+4040+All built-in methods on native types (strings, arrays, objects, etc.) are permitted.
4141+Signal methods (`get`, `set`, `subscribe`) are explicitly allowed even though `constructor` is otherwise blocked.
4242+4343+### Error Handling
4444+4545+Expressions containing unsafe operations or syntax errors are wrapped in an `EvaluationError`.
4646+VoltX logs the error with the original expression for easier debugging and returns `undefined` to keep the UI responsive.
4747+4848+Boolean negation is rewritten internally (`!foo` becomes `!$unwrap(foo)`) so signals behave like plain values during coercion without leaking signal internals into the template.
4949+5050+## Guidelines
5151+5252+### Performance
5353+5454+Expressions are compiled on first use and subsequent evaluations hit the cache.
5555+Keep expressions simple and prefer computed signals for heavy logic—the evaluator already tracks dependencies so only affected bindings re-run.
5656+5757+### Best Practices
5858+5959+- Use computed signals for logic that appears in multiple bindings or involves expensive operations.
6060+- Never use untrusted user input directly in expressions without validation.
6161+- Prefer simple, readable expressions in templates over complex nested operations.
6262+- Structure your scope data with consistent shapes (or consistent types) to avoid runtime errors.
6363+- Remember that event handlers (`data-volt-on-*`) evaluate statements without unwrapping signals; call `signal.set(...)` or `store.set(...)` directly when you need to mutate state.
+26-6
lib/README.md
···11# VoltX.js
2233+[](https://codecov.io/gh/stormlightlabs/volt)
44+[](https://jsr.io/@voltx/core)
55+
66+37> [!WARNING]
48> VoltX.js is in active development.
59>
···9131014## Features
11151212-- Declarative HTML-driven reactivity via `data-volt-*` attributes
1313-- Signal-based state management with automatic DOM updates
1414-- Zero dependencies, under 15 KB gzipped
1515-- No virtual DOM, no build step required
1616-- Server-side rendering and hydration support
1717-- Built-in plugins for persistence, routing, and scroll management
1616+- Declarative, HTML-first reactivity via `data-volt-*` attributes
1717+- Fine-grained signals and effects that update the DOM without a virtual DOM
1818+- Zero runtime dependencies and a sub-15 KB gzipped core
1919+- Built-in transport for SSE/WebSocket streams and JSON Patch updates
2020+- Hydration-friendly rendering with SSR helpers
2121+- Optional CSS design system and debug overlay for inspecting signal graphs
18221923## Installation
2024···5054</script>
5155```
52565757+## Plugins
5858+5959+- `data-volt-persist` – automatically sync state across reloads and tabs
6060+- `data-volt-url` – keep signals in sync with query params and hashes
6161+- `data-volt-scroll` – manage scroll restoration and anchored navigation
6262+- `data-volt-shift` – trigger reusable keyframe animations
6363+- `data-volt-surge` – apply enter/leave transitions with view-transition support
6464+6565+Plugins are opt-in and can be combined declaratively or registered programmatically via `charge({ plugins: [...] })`.
6666+5367## Using CSS
54685569Import the optional CSS framework:
···6781## Documentation
68826983Full documentation available at [https://stormlightlabs.github.io/volt/](https://stormlightlabs.github.io/volt/)
8484+8585+## Development
8686+8787+- `pnpm install` (at the repo root) to bootstrap the workspace
8888+- `pnpm --filter lib dev` runs package scripts such as `build`, `test`, and `typecheck`
8989+- `pnpm dev` spins up the playground in `lib/dev` for interactive testing
70907191## License
7292
···1717import { BOOLEAN_ATTRS } from "./constants";
1818import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom";
1919import { evaluate } from "./evaluator";
2020-import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http";
2120import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle";
2221import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers";
2322import { getPlugin } from "./plugin";
···2524import { createArc, createProbe, createPulse, createUid } from "./scope-vars";
2625import { findScopedSignal, isNil, updateAndRegister } from "./shared";
2726import { getStore } from "./store";
2727+2828+/**
2929+ * Directive registry for custom bindings
3030+ *
3131+ * Allows modules (like HTTP) to register directive handlers that can be tree-shaken when not imported.
3232+ */
3333+type DirectiveHandler = (ctx: BindingContext, value: string, modifiers?: Modifier[]) => void;
3434+3535+const directiveRegistry = new Map<string, DirectiveHandler>();
3636+3737+/**
3838+ * Register a custom directive handler
3939+ *
4040+ * Used by optional modules (HTTP, plugins) to register directive handlers that can be tree-shaken when the module is not imported.
4141+ *
4242+ * @param name - Directive name (without data-volt- prefix)
4343+ * @param handler - Handler function that processes the directive
4444+ */
4545+export function registerDirective(name: string, handler: DirectiveHandler): void {
4646+ directiveRegistry.set(name, handler);
4747+}
28482949/**
3050 * Mount Volt.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
···189209 case "else": {
190210 break;
191211 }
192192- case "get": {
193193- bindGet(ctx, value);
194194- break;
195195- }
196196- case "post": {
197197- bindPost(ctx, value);
198198- break;
199199- }
200200- case "put": {
201201- bindPut(ctx, value);
202202- break;
203203- }
204204- case "patch": {
205205- bindPatch(ctx, value);
206206- break;
207207- }
208208- case "delete": {
209209- bindDelete(ctx, value);
210210- break;
211211- }
212212 default: {
213213+ // Check directive registry first (for HTTP and other optional directives)
214214+ const directiveHandler = directiveRegistry.get(baseName);
215215+ if (directiveHandler) {
216216+ directiveHandler(ctx, value, modifiers);
217217+ return;
218218+ }
219219+220220+ // Then check plugin registry
213221 const plugin = getPlugin(baseName);
214222 if (plugin) {
215223 execPlugin(plugin, ctx, value, baseName);
···426434 const statements = extractStatements(expr);
427435 let result: unknown;
428436 for (const stmt of statements) {
429429- result = evaluate(stmt, eventScope);
437437+ result = evaluate(stmt, eventScope, { unwrapSignals: false });
430438 }
431439432440 if (typeof result === "function") {
···650658 try {
651659 const statements = extractStatements(expr);
652660 for (const stmt of statements) {
653653- evaluate(stmt, ctx.scope);
661661+ evaluate(stmt, ctx.scope, { unwrapSignals: false });
654662 }
655663 } catch (error) {
656664 console.error("Error in data-volt-init:", error);
···958966 ctx.cleanups.push(fn);
959967 },
960968 findSignal: (path) => findScopedSignal(ctx.scope, path),
961961- evaluate: (expr) => evaluate(expr, ctx.scope),
969969+ evaluate: (expr, options) => evaluate(expr, ctx.scope, options),
962970 lifecycle,
963971 };
964972}
+7-3
lib/src/core/constants.ts
···14141515export const DANGEROUS_PROPERTIES = ["__proto__", "prototype", "constructor"];
16161717+/**
1818+ * Dangerous globals that should be blocked from expressions
1919+ *
2020+ * NOTE: The scope proxy's has trap returns true for ALL properties to prevent the 'with' statement from falling back to outer scope, giving us complete control
2121+ */
1722export const DANGEROUS_GLOBALS = [
1818- "Function",
1919- "eval",
2020- "globalThis",
2123 "window",
2424+ "self",
2225 "global",
2626+ "globalThis",
2327 "process",
2428 "require",
2529 "import",
+30-12
lib/src/core/dom.ts
···4455/**
66 * Walk the DOM tree and collect all elements with data-volt-* attributes in document order (parent before children).
77- *
87 * Skips children of elements with data-volt-for or data-volt-if since those will be processed when the parent element is cloned and mounted.
98 *
109 * @param root - The root element to start walking from
···5352 * @returns Map of attribute names to values (without the data-volt- prefix)
5453 */
5554export function getVoltAttrs(el: Element): Map<string, string> {
5656- const attributes = new Map<string, string>();
5555+ const attrs = new Map<string, string>();
57565858- for (const attribute of el.attributes) {
5959- if (attribute.name.startsWith("data-volt-")) {
6060- const name = attribute.name.slice(10);
5757+ for (const attr of el.attributes) {
5858+ if (attr.name.startsWith("data-volt-")) {
5959+ const name = attr.name.slice(10);
61606262- // Skip charge metadata attributes
6361 if (name === "state" || name.startsWith("computed:")) {
6462 continue;
6563 }
66646767- attributes.set(name, attribute.value);
6565+ attrs.set(name, attr.value);
6866 }
6967 }
7070-7171- return attributes;
6868+ return attrs;
7269}
73707471/**
···104101}
105102106103/**
104104+ * Check if value is a wrapped signal (from wrapSignal in evaluator)
105105+ */
106106+function isWrappedSignal(value: unknown): boolean {
107107+ return (value !== null
108108+ && typeof value === "object"
109109+ && typeof (value as { get?: unknown }).get === "function"
110110+ && typeof (value as { subscribe?: unknown }).subscribe === "function");
111111+}
112112+113113+/**
114114+ * Unwrap a value if it's a signal or wrapped signal
115115+ */
116116+function unwrapIfSignal(value: unknown): unknown {
117117+ if (isWrappedSignal(value)) {
118118+ return (value as { get: () => unknown }).get();
119119+ }
120120+ return value;
121121+}
122122+123123+/**
107124 * Parse a class binding expression.
108125 * Supports string values ("active"), object notation ({active: true}),
109126 * and other primitives (true, false, numbers) which are converted to strings.
···115132 const classes = new Map<string, boolean>();
116133 switch (typeof value) {
117134 case "string": {
118118- for (const className of value.split(/\s+/).filter(Boolean)) {
119119- classes.set(className, true);
135135+ for (const cls of value.split(/\s+/).filter(Boolean)) {
136136+ classes.set(cls, true);
120137 }
121138 break;
122139 }
123140 case "object": {
124141 if (value !== null) {
125142 for (const [key, value_] of Object.entries(value)) {
126126- classes.set(key, Boolean(value_));
143143+ const unwrapped = unwrapIfSignal(value_);
144144+ classes.set(key, Boolean(unwrapped));
127145 }
128146 }
129147 break;
+383-739
lib/src/core/evaluator.ts
···11/**
22- * Safe expression evaluation with operators support
22+ * Safe expression evaluation using cached Function compiler
33 *
44- * Implements a recursive descent parser for expressions without using eval().
55- * Includes sandboxing to prevent prototype pollution and sandbox escape attacks.
44+ * Replaces hand-rolled parser with Function constructor for significant bundle size reduction.
55+ * Includes hardened scope proxy to prevent prototype pollution and auto-unwrap signals.
66 */
7788import type { Scope } from "$types/volt";
99import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants";
1010-import { isNil, isSignal } from "./shared";
1010+import { isSignal } from "./shared";
1111+1212+/**
1313+ * Custom error class for expression evaluation failures
1414+ *
1515+ * Provides context about which expression failed and the underlying cause.
1616+ */
1717+export class EvaluationError extends Error {
1818+ public expr: string;
1919+ public cause: unknown;
2020+ constructor(expression: string, cause: unknown) {
2121+ const message = cause instanceof Error ? cause.message : String(cause);
2222+ super(`Error evaluating "${expression}": ${message}`);
2323+ this.name = "EvaluationError";
2424+ this.expr = expression;
2525+ this.cause = cause;
2626+ }
2727+}
11281229const dangerousProps = new Set(DANGEROUS_PROPERTIES);
3030+const dangerousGlobals = new Set(DANGEROUS_GLOBALS);
1331const safeGlobals = new Set(SAFE_GLOBALS);
14321515-function isSafeProp(key: unknown): boolean {
1616- if (typeof key !== "string" && typeof key !== "number") {
1717- return true;
1818- }
3333+interface WrapOptions {
3434+ unwrapSignals: boolean;
3535+}
3636+3737+const defaultWrapOptions: WrapOptions = { unwrapSignals: false };
3838+const readWrapOptions: WrapOptions = { unwrapSignals: true };
19392020- const keyStr = String(key);
2121- return !dangerousProps.has(keyStr);
2222-}
4040+export type EvaluateOpts = { unwrapSignals?: boolean };
23412424-function isSafeAccess(object: unknown, key: unknown): boolean {
2525- if (!isSafeProp(key)) {
4242+/**
4343+ * Check if a property name is dangerous and should be blocked
4444+ */
4545+function isDangerousProperty(key: unknown): boolean {
4646+ if (typeof key !== "string" && typeof key !== "symbol") {
2647 return false;
2748 }
4949+ return dangerousProps.has(String(key));
5050+}
28512929- if (typeof object === "function") {
3030- const keyStr = String(key);
3131- if (keyStr === "constructor" && object.name && !safeGlobals.has(object.name)) {
3232- return false;
3333- }
3434- }
3535-3636- return true;
5252+/**
5353+ * Type guard to check if a Dep has a set method (is a Signal vs ComputedSignal)
5454+ */
5555+function hasSetMethod(
5656+ dep: unknown,
5757+): dep is { get: () => unknown; set: (v: unknown) => void; subscribe: (fn: () => void) => () => void } {
5858+ return (typeof dep === "object"
5959+ && dep !== null
6060+ && "set" in dep
6161+ && typeof (dep as { set?: unknown }).set === "function");
3762}
38633939-type TokenType =
4040- | "NUMBER"
4141- | "STRING"
4242- | "TRUE"
4343- | "FALSE"
4444- | "NULL"
4545- | "UNDEFINED"
4646- | "IDENTIFIER"
4747- | "DOT"
4848- | "LBRACKET"
4949- | "RBRACKET"
5050- | "LPAREN"
5151- | "RPAREN"
5252- | "LBRACE"
5353- | "RBRACE"
5454- | "COMMA"
5555- | "QUESTION"
5656- | "COLON"
5757- | "ARROW"
5858- | "DOT_DOT_DOT"
5959- | "PLUS"
6060- | "MINUS"
6161- | "STAR"
6262- | "SLASH"
6363- | "PERCENT"
6464- | "BANG"
6565- | "EQ_EQ_EQ"
6666- | "BANG_EQ_EQ"
6767- | "LT"
6868- | "GT"
6969- | "LT_EQ"
7070- | "GT_EQ"
7171- | "AND_AND"
7272- | "OR_OR"
7373- | "EOF";
6464+/**
6565+ * Wrap a signal to behave like its value while preserving methods
6666+ *
6767+ * Creates a proxy that:
6868+ * - Returns signal methods (.get, .subscribe, and .set if available) when accessed
6969+ * - Acts like the unwrapped value for all other operations
7070+ * - Unwraps nested signals in the value
7171+ *
7272+ * Handles both Signal (has set) and ComputedSignal (no set)
7373+ */
7474+function wrapSignal(
7575+ signal: { get: () => unknown; subscribe: (fn: () => void) => () => void },
7676+ options: WrapOptions,
7777+): unknown {
7878+ const hasSet = hasSetMethod(signal);
74797575-type Token = { type: TokenType; value: unknown; start: number; end: number };
8080+ const wrapper: Record<string | symbol, unknown> = {
8181+ get: signal.get,
8282+ subscribe: signal.subscribe,
8383+ valueOf: () => signal.get(),
8484+ toString: () => String(signal.get()),
8585+ [Symbol.toPrimitive]: (_hint: string) => signal.get(),
8686+ };
76877777-function tokenize(expr: string): Token[] {
7878- const tokens: Token[] = [];
7979- let pos = 0;
8888+ if (hasSet) {
8989+ wrapper.set = signal.set;
9090+ }
80918181- while (pos < expr.length) {
8282- const char = expr[pos];
9292+ return new Proxy(wrapper, {
9393+ get(target, prop) {
9494+ if (isDangerousProperty(prop)) {
9595+ return;
9696+ }
83978484- if (/\s/.test(char)) {
8585- pos++;
8686- continue;
8787- }
9898+ if (prop === "get" || prop === "subscribe") {
9999+ return target[prop];
100100+ }
881018989- if (/\d/.test(char) || (char === "-" && pos + 1 < expr.length && /\d/.test(expr[pos + 1]))) {
9090- const start = pos;
9191- if (char === "-") pos++;
9292- while (pos < expr.length && /[\d.]/.test(expr[pos])) {
9393- pos++;
102102+ if (prop === "set" && hasSet) {
103103+ return target[prop];
94104 }
9595- tokens.push({ type: "NUMBER", value: Number(expr.slice(start, pos)), start, end: pos });
9696- continue;
9797- }
981059999- if (char === "\"" || char === "'") {
100100- const start = pos;
101101- const quote = char;
102102- pos++;
103103- let value = "";
104104- while (pos < expr.length && expr[pos] !== quote) {
105105- if (expr[pos] === "\\") {
106106- pos++;
107107- if (pos < expr.length) {
108108- value += expr[pos];
109109- }
110110- } else {
111111- value += expr[pos];
112112- }
113113- pos++;
106106+ if (prop === "valueOf" || prop === "toString" || prop === Symbol.toPrimitive) {
107107+ return target[prop];
114108 }
115115- if (pos < expr.length) pos++;
116116- tokens.push({ type: "STRING", value, start, end: pos });
117117- continue;
118118- }
119109120120- if (/[a-zA-Z_$]/.test(char)) {
121121- const start = pos;
122122- while (pos < expr.length && /[a-zA-Z0-9_$]/.test(expr[pos])) {
123123- pos++;
110110+ const unwrapped = signal.get();
111111+ if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) {
112112+ const wrapped = wrapValue(unwrapped, options);
113113+ return (wrapped as Record<string | symbol, unknown>)[prop];
124114 }
125125- const value = expr.slice(start, pos);
126115127127- switch (value) {
128128- case "true": {
129129- tokens.push({ type: "TRUE", value: true, start, end: pos });
130130- break;
116116+ if (unwrapped !== null && unwrapped !== undefined) {
117117+ const boxed = new Object(unwrapped) as Record<string | symbol, unknown>;
118118+ const value = Reflect.get(boxed, prop, boxed);
119119+120120+ if (typeof value === "function") {
121121+ return value.bind(unwrapped);
131122 }
132132- case "false": {
133133- tokens.push({ type: "FALSE", value: false, start, end: pos });
134134- break;
135135- }
136136- case "null": {
137137- tokens.push({ type: "NULL", value: null, start, end: pos });
138138- break;
139139- }
140140- case "undefined": {
141141- tokens.push({ type: "UNDEFINED", value: undefined, start, end: pos });
142142- break;
143143- }
144144- default: {
145145- tokens.push({ type: "IDENTIFIER", value, start, end: pos });
146146- }
123123+124124+ return wrapValue(value, options);
147125 }
148148- continue;
149149- }
150126151151- const start = pos;
127127+ return;
128128+ },
152129153153- if (pos + 2 < expr.length) {
154154- const threeChar = expr.slice(pos, pos + 3);
155155- if (threeChar === "===") {
156156- tokens.push({ type: "EQ_EQ_EQ", value: "===", start, end: pos + 3 });
157157- pos += 3;
158158- continue;
130130+ has(_target, prop) {
131131+ if (isDangerousProperty(prop)) {
132132+ return false;
159133 }
160160- if (threeChar === "!==") {
161161- tokens.push({ type: "BANG_EQ_EQ", value: "!==", start, end: pos + 3 });
162162- pos += 3;
163163- continue;
164164- }
165165- if (threeChar === "...") {
166166- tokens.push({ type: "DOT_DOT_DOT", value: "...", start, end: pos + 3 });
167167- pos += 3;
168168- continue;
134134+135135+ if (prop === "get" || prop === "subscribe") {
136136+ return true;
169137 }
170170- }
171138172172- if (pos + 1 < expr.length) {
173173- const twoChar = expr.slice(pos, pos + 2);
174174- switch (twoChar) {
175175- case "<=": {
176176- tokens.push({ type: "LT_EQ", value: "<=", start, end: pos + 2 });
177177- pos += 2;
178178- continue;
179179- }
180180- case ">=": {
181181- tokens.push({ type: "GT_EQ", value: ">=", start, end: pos + 2 });
182182- pos += 2;
183183- continue;
184184- }
185185- case "&&": {
186186- tokens.push({ type: "AND_AND", value: "&&", start, end: pos + 2 });
187187- pos += 2;
188188- continue;
189189- }
190190- case "||": {
191191- tokens.push({ type: "OR_OR", value: "||", start, end: pos + 2 });
192192- pos += 2;
193193- continue;
194194- }
195195- case "=>": {
196196- tokens.push({ type: "ARROW", value: "=>", start, end: pos + 2 });
197197- pos += 2;
198198- continue;
199199- }
139139+ if (prop === "set" && hasSet) {
140140+ return true;
200141 }
201201- }
202142203203- switch (char) {
204204- case ".": {
205205- tokens.push({ type: "DOT", value: ".", start, end: pos + 1 });
206206- pos++;
207207- break;
143143+ const unwrapped = signal.get();
144144+ if (unwrapped && (typeof unwrapped === "object" || typeof unwrapped === "function")) {
145145+ return prop in unwrapped;
208146 }
209209- case "[": {
210210- tokens.push({ type: "LBRACKET", value: "[", start, end: pos + 1 });
211211- pos++;
212212- break;
147147+ if (unwrapped !== null && unwrapped !== undefined) {
148148+ const boxed = new Object(unwrapped) as Record<string | symbol, unknown>;
149149+ return Reflect.has(boxed, prop);
213150 }
214214- case "]": {
215215- tokens.push({ type: "RBRACKET", value: "]", start, end: pos + 1 });
216216- pos++;
217217- break;
218218- }
219219- case "(": {
220220- tokens.push({ type: "LPAREN", value: "(", start, end: pos + 1 });
221221- pos++;
222222- break;
223223- }
224224- case ")": {
225225- tokens.push({ type: "RPAREN", value: ")", start, end: pos + 1 });
226226- pos++;
227227- break;
228228- }
229229- case "+": {
230230- tokens.push({ type: "PLUS", value: "+", start, end: pos + 1 });
231231- pos++;
232232- break;
233233- }
234234- case "-": {
235235- tokens.push({ type: "MINUS", value: "-", start, end: pos + 1 });
236236- pos++;
237237- break;
238238- }
239239- case "*": {
240240- tokens.push({ type: "STAR", value: "*", start, end: pos + 1 });
241241- pos++;
242242- break;
243243- }
244244- case "/": {
245245- tokens.push({ type: "SLASH", value: "/", start, end: pos + 1 });
246246- pos++;
247247- break;
248248- }
249249- case "%": {
250250- tokens.push({ type: "PERCENT", value: "%", start, end: pos + 1 });
251251- pos++;
252252- break;
253253- }
254254- case "!": {
255255- tokens.push({ type: "BANG", value: "!", start, end: pos + 1 });
256256- pos++;
257257- break;
258258- }
259259- case "<": {
260260- tokens.push({ type: "LT", value: "<", start, end: pos + 1 });
261261- pos++;
262262- break;
263263- }
264264- case ">": {
265265- tokens.push({ type: "GT", value: ">", start, end: pos + 1 });
266266- pos++;
267267- break;
268268- }
269269- case "{": {
270270- tokens.push({ type: "LBRACE", value: "{", start, end: pos + 1 });
271271- pos++;
272272- break;
273273- }
274274- case "}": {
275275- tokens.push({ type: "RBRACE", value: "}", start, end: pos + 1 });
276276- pos++;
277277- break;
278278- }
279279- case ",": {
280280- tokens.push({ type: "COMMA", value: ",", start, end: pos + 1 });
281281- pos++;
282282- break;
283283- }
284284- case "?": {
285285- tokens.push({ type: "QUESTION", value: "?", start, end: pos + 1 });
286286- pos++;
287287- break;
288288- }
289289- case ":": {
290290- tokens.push({ type: "COLON", value: ":", start, end: pos + 1 });
291291- pos++;
292292- break;
293293- }
294294- default: {
295295- throw new Error(`Unexpected character '${char}' at position ${pos}`);
296296- }
297297- }
298298- }
299299-300300- tokens.push({ type: "EOF", value: null, start: pos, end: pos });
301301- return tokens;
151151+ return false;
152152+ },
153153+ }) as unknown;
302154}
303155304156/**
305305- * Recursive descent parser for expression evaluation with operator precedence
157157+ * Wrap a value to block dangerous property access
158158+ *
159159+ * Wraps ALL objects to prevent prototype pollution attacks.
160160+ * Built-in methods still work because we only block dangerous properties.
306161 */
307307-class Parser {
308308- private tokens: Token[];
309309- private current = 0;
310310- private scope: Scope;
311311- private dangerousGlobals = new Set(DANGEROUS_GLOBALS);
312312-313313- constructor(tokens: Token[], scope: Scope) {
314314- this.tokens = tokens;
315315- this.scope = scope;
316316- }
317317-318318- parse(): unknown {
319319- return this.parseExpr();
320320- }
321321-322322- private parseExpr(): unknown {
323323- return this.parseTernary();
162162+function wrapValue(value: unknown, options: WrapOptions = defaultWrapOptions): unknown {
163163+ if (value === null || value === undefined) {
164164+ return value;
324165 }
325166326326- private parseTernary(): unknown {
327327- const expr = this.parseLogicalOr();
328328-329329- if (this.match("QUESTION")) {
330330- const trueBranch = this.parseExpr();
331331- this.consume("COLON", "Expected ':' in ternary expression");
332332- const falseBranch = this.parseExpr();
333333- return expr ? trueBranch : falseBranch;
167167+ if (isSignal(value)) {
168168+ if (options.unwrapSignals) {
169169+ return wrapValue((value as { get: () => unknown }).get(), options);
334170 }
335335-336336- return expr;
171171+ return wrapSignal(value, options);
337172 }
338173339339- private parseLogicalOr(): unknown {
340340- let left = this.parseLogicalAnd();
341341-342342- while (this.match("OR_OR")) {
343343- const right = this.parseLogicalAnd();
344344- left = Boolean(left) || Boolean(right);
345345- }
346346-347347- return left;
174174+ if (typeof value !== "object" && typeof value !== "function") {
175175+ return value;
348176 }
349177350350- private parseLogicalAnd(): unknown {
351351- let left = this.parseEquality();
178178+ return new Proxy(value as object, {
179179+ get(target, prop) {
180180+ if (isDangerousProperty(prop)) {
181181+ return;
182182+ }
352183353353- while (this.match("AND_AND")) {
354354- const right = this.parseEquality();
355355- left = Boolean(left) && Boolean(right);
356356- }
184184+ const result = (target as Record<string | symbol, unknown>)[prop];
357185358358- return left;
359359- }
360360-361361- private parseEquality(): unknown {
362362- let left = this.parseRelational();
363363-364364- while (true) {
365365- if (this.match("EQ_EQ_EQ")) {
366366- const right = this.parseRelational();
367367- left = left === right;
368368- } else if (this.match("BANG_EQ_EQ")) {
369369- const right = this.parseRelational();
370370- left = left !== right;
371371- } else {
372372- break;
186186+ if (typeof result === "function") {
187187+ return result.bind(target);
373188 }
374374- }
375189376376- return left;
377377- }
190190+ return wrapValue(result, options);
191191+ },
378192379379- private parseRelational(): unknown {
380380- let left = this.parseAdditive();
381381-382382- while (true) {
383383- if (this.match("LT")) {
384384- const right = this.parseAdditive();
385385- left = (left as number) < (right as number);
386386- } else if (this.match("GT")) {
387387- const right = this.parseAdditive();
388388- left = (left as number) > (right as number);
389389- } else if (this.match("LT_EQ")) {
390390- const right = this.parseAdditive();
391391- left = (left as number) <= (right as number);
392392- } else if (this.match("GT_EQ")) {
393393- const right = this.parseAdditive();
394394- left = (left as number) >= (right as number);
395395- } else {
396396- break;
193193+ set(target, prop, newValue) {
194194+ if (isDangerousProperty(prop)) {
195195+ return true;
397196 }
398398- }
399197400400- return left;
401401- }
402402-403403- private parseAdditive(): unknown {
404404- let left = this.parseMultiplicative();
198198+ (target as Record<string | symbol, unknown>)[prop] = newValue;
199199+ return true;
200200+ },
405201406406- while (true) {
407407- if (this.match("PLUS")) {
408408- const right = this.parseMultiplicative();
409409- left = (left as number) + (right as number);
410410- } else if (this.match("MINUS")) {
411411- const right = this.parseMultiplicative();
412412- left = (left as number) - (right as number);
413413- } else {
414414- break;
202202+ has(target, prop) {
203203+ if (isDangerousProperty(prop)) {
204204+ return false;
415205 }
416416- }
206206+ return prop in target;
207207+ },
208208+ });
209209+}
417210418418- return left;
419419- }
211211+/**
212212+ * Create a hardened proxy around a scope object
213213+ *
214214+ * This proxy:
215215+ * - Blocks access to dangerous properties (constructor, __proto__, prototype, globalThis)
216216+ * - Auto-unwraps signals on get (transparent reactivity)
217217+ * - Only allows access to scope properties and whitelisted globals
218218+ * - Uses Object.create(null) to prevent prototype chain attacks
219219+ * - Wraps all returned values to prevent nested dangerous access
220220+ *
221221+ * @param scope - The scope object to wrap
222222+ * @returns Proxied scope with security hardening
223223+ */
224224+function createScopeProxy(scope: Scope, options: WrapOptions = defaultWrapOptions): Scope {
225225+ const base = Object.create(null) as Scope;
420226421421- private parseMultiplicative(): unknown {
422422- let left = this.parseUnary();
227227+ return new Proxy(base, {
228228+ get(_target, prop) {
229229+ const propStr = String(prop);
423230424424- while (true) {
425425- if (this.match("STAR")) {
426426- const right = this.parseUnary();
427427- left = (left as number) * (right as number);
428428- } else if (this.match("SLASH")) {
429429- const right = this.parseUnary();
430430- left = (left as number) / (right as number);
431431- } else if (this.match("PERCENT")) {
432432- const right = this.parseUnary();
433433- left = (left as number) % (right as number);
434434- } else {
435435- break;
231231+ if (dangerousGlobals.has(propStr)) {
232232+ return;
436233 }
437437- }
438234439439- return left;
440440- }
235235+ if (isDangerousProperty(prop)) {
236236+ return;
237237+ }
441238442442- private parseUnary(): unknown {
443443- if (this.match("BANG")) {
444444- const operand = this.parseUnary();
445445- return !operand;
446446- }
447447-448448- if (this.match("MINUS")) {
449449- const operand = this.parseUnary();
450450- return -(operand as number);
451451- }
452452-453453- if (this.match("PLUS")) {
454454- const operand = this.parseUnary();
455455- return +(operand as number);
456456- }
239239+ if (propStr in scope) {
240240+ const value = scope[propStr];
241241+ return wrapValue(value, options);
242242+ }
457243458458- return this.parseMemberAccess();
459459- }
244244+ if (safeGlobals.has(propStr)) {
245245+ return wrapValue((globalThis as Record<string, unknown>)[propStr], options);
246246+ }
460247461461- private parseMemberAccess(): unknown {
462462- let object = this.parsePrimary();
248248+ return;
249249+ },
463250464464- while (true) {
465465- if (this.match("DOT")) {
466466- const prop = this.consume("IDENTIFIER", "Expected property name after '.'");
467467- const propValue = this.getMember(object, prop.value as string);
251251+ set(_target, prop, value) {
252252+ if (isDangerousProperty(prop)) {
253253+ return true;
254254+ }
468255469469- if (this.check("LPAREN")) {
470470- this.advance();
471471- const args = this.parseArgumentList();
472472- this.consume("RPAREN", "Expected ')' after arguments");
473473- const propName = prop.value as string;
474474- const isSignalMethod = isSignal(object)
475475- && (propName === "get" || propName === "set" || propName === "subscribe");
476476- const unwrappedObject = !isSignalMethod && isSignal(object) ? object.get() : object;
477477- object = this.callMethod(unwrappedObject, propName, args);
478478- } else {
479479- object = propValue;
480480- }
481481- } else if (this.match("LBRACKET")) {
482482- const index = this.parseExpr();
483483- this.consume("RBRACKET", "Expected ']' after member access");
484484- object = this.getMember(object, index);
485485- } else if (this.match("LPAREN")) {
486486- const args = this.parseArgumentList();
487487- this.consume("RPAREN", "Expected ')' after arguments");
256256+ const propStr = String(prop);
488257489489- if (typeof object === "function") {
490490- const func = object as { name?: string };
491491- if (func.name === "Function" || func.name === "eval") {
492492- throw new Error("Cannot call dangerous function");
493493- }
494494- object = (object as (...args: unknown[]) => unknown)(...args);
495495- } else {
496496- throw new TypeError("Attempting to call a non-function value");
258258+ if (propStr in scope) {
259259+ const existing = scope[propStr];
260260+ if (isSignal(existing) && hasSetMethod(existing)) {
261261+ existing.set(value);
262262+ return true;
497263 }
498498- } else {
499499- break;
500264 }
501501- }
502265503503- if (isSignal(object)) {
504504- return (object as { get: () => unknown }).get();
505505- }
266266+ scope[propStr] = value;
267267+ return true;
268268+ },
506269507507- return object;
508508- }
509509-510510- private parseArgumentList(): unknown[] {
511511- const args: unknown[] = [];
512512-513513- if (this.check("RPAREN")) {
514514- return args;
515515- }
516516-517517- do {
518518- args.push(this.parseExpr());
519519- } while (this.match("COMMA"));
520520-521521- return args;
522522- }
523523-524524- private callMethod(object: unknown, methodName: string, args: unknown[]): unknown {
525525- if (isNil(object)) {
526526- throw new Error(`Cannot call method '${methodName}' on ${object}`);
527527- }
528528-529529- if (!isSafeAccess(object, methodName)) {
530530- throw new Error(`Unsafe method call: ${methodName}`);
531531- }
532532-533533- const method = (object as Record<string, unknown>)[methodName];
534534-535535- if (typeof method !== "function") {
536536- throw new TypeError(`'${methodName}' is not a function`);
537537- }
538538-539539- return (method as (...args: unknown[]) => unknown).call(object, ...args);
540540- }
541541-542542- private parsePrimary(): unknown {
543543- if (this.match("NUMBER", "STRING", "TRUE", "FALSE", "NULL", "UNDEFINED")) {
544544- return this.previous().value;
545545- }
270270+ /**
271271+ * Always return true to prevent 'with' statement from falling back to outer scope
272272+ */
273273+ has(_target, prop) {
274274+ if (prop === "$unwrap") {
275275+ return false;
276276+ }
277277+ return true;
278278+ },
546279547547- if (this.match("IDENTIFIER")) {
548548- const identifier = this.previous().value as string;
280280+ ownKeys(_target) {
281281+ return Object.keys(scope).filter((key) => !isDangerousProperty(key));
282282+ },
549283550550- if (this.check("ARROW")) {
551551- this.current--;
552552- return this.parseArrowFunction();
284284+ getOwnPropertyDescriptor(_target, prop) {
285285+ if (isDangerousProperty(prop)) {
286286+ return;
553287 }
554288555555- return this.resolvePropPath(identifier);
556556- }
289289+ const propStr = String(prop);
557290558558- if (this.match("LPAREN")) {
559559- const start = this.current;
560560-561561- if (this.isArrowFunctionParams()) {
562562- this.current = start - 1;
563563- return this.parseArrowFunction();
291291+ if (propStr in scope) {
292292+ return { configurable: true, enumerable: true, writable: true, value: scope[propStr] };
564293 }
565294566566- const expr = this.parseExpr();
567567- this.consume("RPAREN", "Expected ')' after expression");
568568- return expr;
569569- }
295295+ return;
296296+ },
297297+ });
298298+}
570299571571- if (this.match("LBRACKET")) {
572572- return this.parseArrayLiteral();
573573- }
300300+/**
301301+ * Cache for compiled expression functions
302302+ *
303303+ * Key: expression string
304304+ * Value: compiled function
305305+ */
306306+type CompiledExpr = (scope: Scope, unwrap: (value: unknown) => unknown) => unknown;
574307575575- if (this.match("LBRACE")) {
576576- return this.parseObjectLiteral();
577577- }
308308+const exprCache = new Map<string, CompiledExpr>();
578309579579- throw new Error(`Unexpected token: ${this.peek().type}`);
310310+function isIdentifierStart(char: string): boolean {
311311+ if (char.length === 0) {
312312+ return false;
580313 }
581581-582582- private parseArrayLiteral(): unknown[] {
583583- const elements: unknown[] = [];
584584-585585- if (this.match("RBRACKET")) {
586586- return elements;
587587- }
588588-589589- do {
590590- if (this.match("DOT_DOT_DOT")) {
591591- const spreadValue = this.parseExpr();
592592- if (Array.isArray(spreadValue)) {
593593- elements.push(...spreadValue);
594594- } else {
595595- throw new TypeError("Spread operator can only be used with arrays");
596596- }
597597- } else {
598598- elements.push(this.parseExpr());
599599- }
600600- } while (this.match("COMMA"));
314314+ const code = char.charCodeAt(0);
315315+ return ((code >= 65 && code <= 90) || (code >= 97 && code <= 122) || char === "_" || char === "$");
316316+}
601317602602- this.consume("RBRACKET", "Expected ']' after array elements");
603603- return elements;
318318+function isIdentifierPart(char: string): boolean {
319319+ if (char.length === 0) {
320320+ return false;
604321 }
322322+ const code = char.charCodeAt(0);
323323+ return ((code >= 65 && code <= 90)
324324+ || (code >= 97 && code <= 122)
325325+ || (code >= 48 && code <= 57)
326326+ || char === "_" || char === "$");
327327+}
605328606606- private parseObjectLiteral(): Record<string, unknown> {
607607- const object: Record<string, unknown> = {};
329329+function isWhitespace(char: string): boolean {
330330+ return char === " " || char === "\n" || char === "\r" || char === "\t";
331331+}
608332609609- if (this.match("RBRACE")) {
610610- return object;
611611- }
612612-613613- do {
614614- if (this.match("DOT_DOT_DOT")) {
615615- const spreadValue = this.parseExpr();
616616- if (typeof spreadValue === "object" && spreadValue !== null && !Array.isArray(spreadValue)) {
617617- for (const key of Object.keys(spreadValue)) {
618618- if (!isSafeProp(key)) {
619619- throw new Error(`Unsafe property in spread: ${key}`);
620620- }
621621- }
622622- Object.assign(object, spreadValue);
623623- } else {
624624- throw new Error("Spread operator can only be used with objects in object literals");
625625- }
626626- } else {
627627- let key: string;
333333+function transformExpr(expr: string): string {
334334+ let result = "";
335335+ let index = 0;
628336629629- if (this.match("IDENTIFIER")) {
630630- key = this.previous().value as string;
631631- } else if (this.match("STRING")) {
632632- key = this.previous().value as string;
633633- } else {
634634- throw new Error("Expected property key in object literal");
635635- }
337337+ while (index < expr.length) {
338338+ const char = expr[index];
636339637637- if (!isSafeProp(key)) {
638638- throw new Error(`Unsafe property key in object literal: ${key}`);
639639- }
340340+ if (char === "!") {
341341+ const next = expr[index + 1] ?? "";
640342641641- this.consume("COLON", "Expected ':' after property key");
642642- const value = this.parseExpr();
643643- object[key] = value;
343343+ if (next === "=") {
344344+ result += "!";
345345+ index += 1;
346346+ continue;
644347 }
645645- } while (this.match("COMMA"));
646348647647- this.consume("RBRACE", "Expected '}' after object properties");
648648- return object;
649649- }
650650-651651- private parseArrowFunction(): (...args: unknown[]) => unknown {
652652- const params: string[] = [];
653653-654654- if (this.match("IDENTIFIER")) {
655655- params.push(this.previous().value as string);
656656- } else if (this.match("LPAREN")) {
657657- if (!this.check("RPAREN")) {
658658- do {
659659- const param = this.consume("IDENTIFIER", "Expected parameter name");
660660- params.push(param.value as string);
661661- } while (this.match("COMMA"));
349349+ let cursor = index + 1;
350350+ while (cursor < expr.length && isWhitespace(expr[cursor])) {
351351+ cursor += 1;
662352 }
663663- this.consume("RPAREN", "Expected ')' after parameters");
664664- } else {
665665- throw new Error("Expected arrow function parameters");
666666- }
667353668668- this.consume("ARROW", "Expected '=>' in arrow function");
669669-670670- if (this.match("LBRACE")) {
671671- let braceDepth = 1;
672672- while (braceDepth > 0 && !this.isAtEnd()) {
673673- if (this.check("LBRACE")) braceDepth++;
674674- if (this.check("RBRACE")) braceDepth--;
675675- this.advance();
354354+ const identStart = expr[cursor] ?? "";
355355+ if (!isIdentifierStart(identStart)) {
356356+ result += "!";
357357+ index += 1;
358358+ continue;
676359 }
677677- throw new Error("Arrow function block bodies are not yet supported. Use single expressions only.");
678678- } else {
679679- const exprTokens: Token[] = [];
680680- let parenDepth = 0;
681681- let bracketDepth = 0;
682682- let braceDepth = 0;
683360684684- outer: while (!this.isAtEnd()) {
685685- const token = this.peek();
686686-687687- switch (token.type) {
688688- case "LPAREN": {
689689- parenDepth++;
690690- break;
691691- }
692692- case "RPAREN": {
693693- if (parenDepth === 0) break outer;
694694- parenDepth--;
695695- break;
696696- }
697697- case "LBRACKET": {
698698- bracketDepth++;
699699- break;
700700- }
701701- case "RBRACKET": {
702702- if (bracketDepth === 0) break outer;
703703- bracketDepth--;
704704- break;
705705- }
706706- case "LBRACE": {
707707- braceDepth++;
708708- break;
709709- }
710710- case "RBRACE": {
711711- if (braceDepth === 0) break outer;
712712- braceDepth--;
713713- break;
714714- }
715715- case "COMMA": {
716716- if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
717717- break outer;
718718- }
719719- break;
720720- }
721721- default: {
722722- break;
723723- }
724724- }
725725-726726- exprTokens.push(this.advance());
361361+ let end = cursor + 1;
362362+ while (end < expr.length && isIdentifierPart(expr.charAt(end))) {
363363+ end += 1;
727364 }
728365729729- const capturedScope = this.scope;
730730-731731- return (...args: unknown[]) => {
732732- const arrowScope: Scope = { ...capturedScope };
733733- for (const [index, param] of params.entries()) {
734734- arrowScope[param] = args[index];
735735- }
736736-737737- const parser = new Parser([...exprTokens, { type: "EOF", value: null, start: 0, end: 0 }], arrowScope);
738738- return parser.parse();
739739- };
740740- }
741741- }
742742-743743- private isArrowFunctionParams(): boolean {
744744- const saved = this.current;
745745- let result = false;
746746-747747- try {
748748- if (this.check("RPAREN")) {
749749- this.advance();
750750- if (this.check("ARROW")) {
751751- result = true;
366366+ while (end < expr.length && expr[end] === ".") {
367367+ const afterDot = expr[end + 1] ?? "";
368368+ if (!isIdentifierStart(afterDot)) {
369369+ break;
752370 }
753753- } else {
754754- while (!this.isAtEnd() && !this.check("RPAREN")) {
755755- if (!this.match("IDENTIFIER", "COMMA")) {
756756- result = false;
757757- break;
758758- }
759759- }
760760- if (this.match("RPAREN") && this.check("ARROW")) {
761761- result = true;
371371+ end += 2;
372372+ while (end < expr.length && isIdentifierPart(expr.charAt(end))) {
373373+ end += 1;
762374 }
763375 }
764764- } finally {
765765- this.current = saved;
766766- }
767767-768768- return result;
769769- }
770770-771771- private getMember(object: unknown, key: unknown): unknown {
772772- if (isNil(object)) {
773773- return undefined;
774774- }
775775-776776- if (!isSafeAccess(object, key)) {
777777- throw new Error(`Unsafe property access: ${String(key)}`);
778778- }
779376780780- if (isSignal(object) && (key === "get" || key === "set" || key === "subscribe")) {
781781- return (object as Record<string, unknown>)[key as string];
782782- }
377377+ const nextChar = expr[end] ?? "";
378378+ if (nextChar === "(") {
379379+ result += "!";
380380+ index += 1;
381381+ continue;
382382+ }
783383784784- if (isSignal(object)) {
785785- object = (object as { get: () => unknown }).get();
384384+ const identifier = expr.slice(cursor, end);
385385+ result += "!$unwrap(" + identifier + ")";
386386+ index = end;
387387+ continue;
786388 }
787389788788- const value = (object as Record<string | number, unknown>)[key as string | number];
789789-790790- if (isSignal(value)) {
791791- return value.get();
792792- }
793793-794794- return value;
390390+ result += char;
391391+ index += 1;
795392 }
796393797797- private resolvePropPath(path: string): unknown {
798798- if (!isSafeProp(path)) {
799799- throw new Error(`Unsafe property access: ${path}`);
800800- }
801801-802802- if (this.dangerousGlobals.has(path)) {
803803- throw new Error(`Access to dangerous global: ${path}`);
804804- }
805805-806806- if (path in this.scope) {
807807- return this.scope[path];
808808- }
394394+ return result;
395395+}
809396810810- if (safeGlobals.has(path)) {
811811- return (globalThis as Record<string, unknown>)[path];
812812- }
813813-814814- return undefined;
397397+function unwrapMaybeSignal(value: unknown): unknown {
398398+ if (isSignal(value)) {
399399+ return (value as { get: () => unknown }).get();
815400 }
401401+ return value;
402402+}
816403817817- private match(...types: TokenType[]): boolean {
818818- for (const type of types) {
819819- if (this.check(type)) {
820820- this.advance();
821821- return true;
822822- }
823823- }
824824- return false;
825825- }
826826-827827- private check(type: TokenType): boolean {
828828- if (this.isAtEnd()) return false;
829829- return this.peek().type === type;
830830- }
404404+/**
405405+ * Compile an expression into a function using the Function constructor
406406+ *
407407+ * Uses 'with' statement to allow direct variable access from scope.
408408+ * The with statement works because we're not in strict mode for the function body,
409409+ * but the scope proxy ensures safety.
410410+ *
411411+ * @param expr - Expression string to compile
412412+ * @param isStmt - Whether this is a statement (no return) or expression (return value)
413413+ * @returns Compiled function
414414+ */
415415+function compileExpr(expr: string, isStmt = false): CompiledExpr {
416416+ const cacheKey = `${isStmt ? "stmt" : "expr"}:${expr}`;
831417832832- private advance(): Token {
833833- if (!this.isAtEnd()) this.current++;
834834- return this.previous();
418418+ let fn = exprCache.get(cacheKey);
419419+ if (fn) {
420420+ return fn;
835421 }
836422837837- private isAtEnd(): boolean {
838838- return this.peek().type === "EOF";
423423+ try {
424424+ const transformed = transformExpr(expr);
425425+ if (isStmt) {
426426+ fn = new Function("$scope", "$unwrap", `with($scope){${transformed}}`) as CompiledExpr;
427427+ } else {
428428+ fn = new Function("$scope", "$unwrap", `with($scope){return(${transformed})}`) as CompiledExpr;
429429+ }
430430+ exprCache.set(cacheKey, fn);
431431+ return fn;
432432+ } catch (error) {
433433+ throw new EvaluationError(expr, error);
839434 }
435435+}
840436841841- private peek(): Token {
842842- return this.tokens[this.current];
437437+/**
438438+ * Unwrap signals at the top level only
439439+ *
440440+ * Unwraps direct signals and wrapped signals but preserves object/array structure.
441441+ * This allows bindings to still track nested signals while unwrapping top-level signal results.
442442+ */
443443+function unwrapSignal(value: unknown): unknown {
444444+ if (isSignal(value)) {
445445+ return (value as { get: () => unknown }).get();
843446 }
844447845845- private previous(): Token {
846846- return this.tokens[this.current - 1];
448448+ if (
449449+ value
450450+ && typeof value === "object"
451451+ && typeof (value as { get?: unknown }).get === "function"
452452+ && typeof (value as { subscribe?: unknown }).subscribe === "function"
453453+ ) {
454454+ return (value as { get: () => unknown }).get();
847455 }
848456849849- private consume(type: TokenType, message: string): Token {
850850- if (this.check(type)) return this.advance();
851851- throw new Error(`${message} at position ${this.peek().start}`);
852852- }
457457+ return value;
853458}
854459855460/**
856856- * Evaluate an expression against a scope object.
461461+ * Evaluate an expression against a scope object
857462 *
858858- * Supports literals, property access, operators, and member access.
463463+ * Supports:
464464+ * - Literals: numbers, strings, booleans, null, undefined
465465+ * - Operators: +, -, *, /, %, ==, !=, ===, !==, <, >, <=, >=, &&, ||, !
466466+ * - Property access: obj.prop, obj['prop'], nested paths
467467+ * - Ternary: condition ? trueVal : falseVal
468468+ * - Array/object literals: [1, 2, 3], {key: value}
469469+ * - Function calls: fn(arg1, arg2)
470470+ * - Arrow functions: (x) => x * 2
471471+ * - Signals auto-unwrapped
859472 *
860473 * @param expr - The expression string to evaluate
861474 * @param scope - The scope object containing values
862475 * @returns The evaluated result
476476+ * @throws EvaluationError if expression is invalid or evaluation fails
863477 */
864864-export function evaluate(expr: string, scope: Scope): unknown {
478478+export function evaluate(expr: string, scope: Scope, opts?: EvaluateOpts): unknown {
865479 try {
866866- const tokens = tokenize(expr);
867867- const parser = new Parser(tokens, scope);
868868- return parser.parse();
480480+ const fn = compileExpr(expr, false);
481481+ const wrapOptions = opts && opts.unwrapSignals ? readWrapOptions : defaultWrapOptions;
482482+ const proxiedScope = createScopeProxy(scope, wrapOptions);
483483+ const result = fn(proxiedScope, unwrapMaybeSignal);
484484+ return unwrapSignal(result);
869485 } catch (error) {
870870- console.error(`Error evaluating expression "${expr}":`, error);
871871- return undefined;
486486+ if (error instanceof EvaluationError) {
487487+ throw error;
488488+ }
489489+ if (error instanceof ReferenceError) {
490490+ return undefined;
491491+ }
492492+ throw new EvaluationError(expr, error);
493493+ }
494494+}
495495+496496+/**
497497+ * Evaluate multiple statements against a scope object
498498+ *
499499+ * Used for event handlers that may contain multiple semicolon-separated statements.
500500+ * Statements are executed in order but no return value is captured.
501501+ *
502502+ * @param expr - The statement(s) to evaluate
503503+ * @param scope - The scope object containing values
504504+ * @throws EvaluationError if evaluation fails
505505+ */
506506+export function evaluateStatements(expr: string, scope: Scope): void {
507507+ try {
508508+ const fn = compileExpr(expr, true);
509509+ const proxiedScope = createScopeProxy(scope);
510510+ fn(proxiedScope, unwrapMaybeSignal);
511511+ } catch (error) {
512512+ if (error instanceof EvaluationError) {
513513+ throw error;
514514+ }
515515+ throw new EvaluationError(expr, error);
872516 }
873517}
+11-1
lib/src/core/http.ts
···1616 Scope,
1717 SwapStrategy,
1818} from "$types/volt";
1919+import { registerDirective } from "./binder";
1920import { evaluate } from "./evaluator";
2021import { sleep } from "./shared";
2122···654655655656/**
656657 * Generic HTTP method binding handler
657657- *
658658 * Attaches an event listener that triggers an HTTP request when fired & automatically serializes forms for POST/PUT/PATCH methods.
659659 */
660660function bindHttpMethod(ctx: BindingContext | PluginContext, method: HttpMethod, url: string): void {
···688688 ctx.cleanups.push(cleanup);
689689 }
690690}
691691+692692+/**
693693+ * Auto-register HTTP directives when this module is imported
694694+ * This enables tree-shaking: if the HTTP module isn't imported, these directives won't be included in the bundle.
695695+ */
696696+registerDirective("get", bindGet);
697697+registerDirective("post", bindPost);
698698+registerDirective("put", bindPut);
699699+registerDirective("patch", bindPatch);
700700+registerDirective("delete", bindDelete);
+1-2
lib/src/debug.ts
···1818 * @packageDocumentation
1919 */
20202121-import { reactive as coreReactive } from "$core/reactive";
2222-import { computed as coreComputed, signal as coreSignal } from "$core/signal";
2321import type { ComputedSignal, Signal, SignalType } from "$types/volt";
2422import {
2523 buildDependencyGraph,
···5250 registerReactive,
5351 registerSignal,
5452} from "./debug/registry";
5353+import { computed as coreComputed, reactive as coreReactive, signal as coreSignal } from "./index";
55545655/**
5756 * Create a signal with automatic debug registration.
+3-1
lib/src/types/volt.d.ts
···2233export type Scope = Record<string, unknown>;
4455+export type EvaluateOpts = { unwrapSignals?: boolean };
66+57/**
68 * Context object available to all bindings
79 */
···3840 * Evaluate an expression against the scope.
3941 * Handles simple property paths, literals, and signal unwrapping.
4042 */
4141- evaluate(expression: string): unknown;
4343+ evaluate(expression: string, options?: EvaluateOpts): unknown;
42444345 /**
4446 * Lifecycle hooks for plugin-specific mount/unmount behavior