# Bindings & Evaluation VoltX’s binding layer is the glue between declarative `data-volt-*` attributes and the reactivity primitives that drive them. 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. ## Mount Pipeline 1. **Scope preparation** - `mount(root, scope)` first injects VoltX’s helper variables (`$store`, `$uid`, `$pins`, `$probe`, etc.) into the caller-provided scope. Helpers are frozen before exposure so user code cannot tamper with framework utilities. 2. **Tree walk** - We perform a DOM walk rooted at `root`, skipping subtrees marked with `data-volt-skip`. Elements cloaked with `data-volt-cloak` are un-cloaked during traversal. 3. **Attribute collection** - `getVoltAttrs()` extracts `data-volt-*` attributes and normalises modifiers (e.g. `data-volt-on-click.prevent` -> `on-click` with `.prevent`). 4. **Directive dispatch** - Structural directives (`data-volt-for`, `data-volt-if`) short-circuit the attribute loop because they clone/remove nodes. Everything else is routed through `bindAttribute()` which: - Routes `on-*` attributes to the event binding pipeline. - Routes `bind:*` aliases (e.g. `bind:value`) to attribute binding helpers. - For colon-prefixed segments (`data-volt-http:get`), hands control to plugin handlers. - Falls back to the directive registry or plugin registry, then logs an unknown binding warning. 5. **Lifecycle hooks** - Each bound element fires the global lifecycle callbacks (`beforeMount`, `afterMount`, etc.). Per-plugin lifecycles are surfaced via `PluginContext.lifecycle`. Each directive registers clean-up callbacks so `mount()` can return a disposer that un-subscribes signals, removes event listeners, and runs plugin uninstall hooks. ## Directive Registry We expose `registerDirective(name, handler)` to allow plugins to self-register. Core only ships the structural directives and the minimal attribute/event set required for the base runtime. This keeps the lib bundle slim and allows tree shaking to drop unused features. `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. Consumers that never import the module never pay for its directives. ## Expression Compilation All binding expressions funnel through `evaluate(expr, scope)` (or `evaluateStatements()` for multi-statement handlers). The evaluator implements a few layers of defense: ### Cached `new Function` - Expressions are compiled into functions with `new Function("$scope", "$unwrap", ...)`. - We wrap execution in a `with ($scope) { ... }` block to preserve ergonomic access to identifiers. - Compiled functions are cached in a `Map` keyed by the expression string + mode (`expr` vs `stmt`) Cache hits avoid re-parsing and reduce GC churn. ### Hardened Scope Proxy `createScopeProxy(scope)` builds an `Object.create(null)` proxy that: - Returns `undefined` for dangerous identifiers and properties (`constructor`, `__proto__`, `globalThis`, `Function`, etc.). - Reuses VoltX’s `wrapValue()` utility to auto-unwrap signals while guarding against prototype pollution. - Treats setters specially: if a scope entry is a signal, assignments route to `signal.set()`. - Spoofs `has` so the `with` block never falls through to `globalThis`. Every call to `evaluate()` constructs this proxy and iss fast because signals and helpers are stored on the original scope, not the proxy. ### Safe Negation & `$unwrap` Logical negation (`!signal`) is tricky when signals are proxied objects. Before compilation we run `transformExpression()` which rewrites top-level `!identifier` patterns into `!$unwrap(identifier)`. `$unwrap()` dereferences signals without exposing their methods, making boolean coercion reliable even when the underlying value is a reactive proxy or computed signal. ### Signal-Aware Wrapping `wrapValue()` enforces blocking rules and auto-unwrapping: - Signal reads return a small proxy exposing `get`, `set`, and `subscribe` while delegating property reads to the underlying value. - Nested values re-enter `wrapValue()` so the entire object graph respects the hazardous-key deny list. - When `unwrapSignals` is enabled (default for read contexts), signal reads return their current value so DOM bindings can treat them like plain data. - Statement contexts (event handlers, `data-volt-init`) pass `{ unwrapSignals: false }` so authors can still call `count.set()` or `store.set()` directly. ### Error Surfacing Any runtime error thrown by the compiled function is wrapped in `EvaluationError` which carries the original expression for better debugging. Reference errors (missing identifiers) return `undefined` to mimic plain JavaScript. ## Event Handlers `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)"`). Debounce/throttle modifiers wrap the execute function with cancellable helpers. Clean-up hooks clear timers when the element unmounts. ## Structural/Control Directives ### `data-volt-if` - Clones/discards `if` and optional `else` templates. - Evaluates the condition reactively; dependencies are tracked via `extractDeps()` which scans expressions for signals. - Supports surge transitions by awaiting `executeSurgeEnter/Leave()` when available. - Maintains branch state so redundant renders are skipped. Clean-up disposes child mounts when a branch is swapped out. ### `data-volt-for` - Parses `"item in items"` or `"(item, index) in items"` grammar. - Uses a placeholder comment to maintain insertion position. - Re-renders on dependency changes by clearing existing clones and re-mounting with a child scope containing the loop variables. - Registers per-item clean-up disposers so each clone tears down correctly. ## Data Flow & Dependency Tracking Reactive updates rely on `updateAndRegister(ctx, update, expr)`: 1. Executes the update function immediately for initial DOM synchronisation. 2. Calls `extractDeps()` to gather signals referenced within the expression (with special handling for `$store.get()` lookups). 3. Subscribes to each signal and pushes the unsubscribe callback into the directive’s clean-up list. This pattern is used by text/html bindings, class/style bindings, show/if/for, and plugin-provided directives. ## Challenges & Lessons - **Security vs ergonomics** - Moving from a hand-rolled parser to `new Function` simplified expression support but introduced sandboxing risks. The scope proxy and whitelists were essential to close off prototype pollution and global escape hatches. - **Signal negation** - `!signal` originally returned `false` because the proxy object was truthy. The `$unwrap` transformation ensures boolean logic matches user expectations without forcing explicit `.get()` calls. - **Plugin isolation** - Allowing plugins to register directives meant we had to guarantee that the core binder stays stateless. Directive handlers receive a `PluginContext` with controlled capabilities so they can integrate without mutating internal machinery. - **Error visibility** - Swallowing exceptions made debugging inline expressions painful. `EvaluationError` and consistent logging in directives give developers actionable stack traces while keeping the runtime resilient. With these guardrails the binder provides a secure, extensible bridge between declarative templates and VoltX’s reactive runtime.