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