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
- 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. - Tree walk - We perform a DOM walk rooted at
root, skipping subtrees marked withdata-volt-skip. Elements cloaked withdata-volt-cloakare un-cloaked during traversal. - Attribute collection -
getVoltAttrs()extractsdata-volt-*attributes and normalises modifiers (e.g.data-volt-on-click.prevent->on-clickwith.prevent). - Directive dispatch - Structural directives (
data-volt-for,data-volt-if) short-circuit the attribute loop because they clone/remove nodes. Everything else is routed throughbindAttribute()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.
- Routes
- Lifecycle hooks - Each bound element fires the global lifecycle callbacks (
beforeMount,afterMount, etc.). Per-plugin lifecycles are surfaced viaPluginContext.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
Mapkeyed by the expression string + mode (exprvsstmt) Cache hits avoid re-parsing and reduce GC churn.
Hardened Scope Proxy
createScopeProxy(scope) builds an Object.create(null) proxy that:
- Returns
undefinedfor 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
hasso thewithblock never falls through toglobalThis.
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, andsubscribewhile delegating property reads to the underlying value. - Nested values re-enter
wrapValue()so the entire object graph respects the hazardous-key deny list. - When
unwrapSignalsis 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 callcount.set()orstore.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
ifand optionalelsetemplates. - 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):
- Executes the update function immediately for initial DOM synchronisation.
- Calls
extractDeps()to gather signals referenced within the expression (with special handling for$store.get()lookups). - 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 Functionsimplified expression support but introduced sandboxing risks. The scope proxy and whitelists were essential to close off prototype pollution and global escape hatches. - Signal negation -
!signaloriginally returnedfalsebecause the proxy object was truthy. The$unwraptransformation 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
PluginContextwith controlled capabilities so they can integrate without mutating internal machinery. - Error visibility - Swallowing exceptions made debugging inline expressions painful.
EvaluationErrorand 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.