← All posts

Inside Voodoo.js: how a custom interpreter brings plain HTML to life

Reverse engineering Voodoo.js: from the browser HTML parser’s DOM to a lexer, Pratt AST, tree-walking interpreter, Proxy effects, and direct node writes—without eval.

Inside Voodoo.js: how a custom interpreter brings plain HTML to life
Contents

On the surface Voodoo.js looks almost like a parlor trick: one HTML file, one script, a counter in curly braces — and the page already reacts to clicks. In the overview “breakthrough or a return to a simpler web” we unpacked the idea. Here is a different layer: how a string like {items.map(...)} travels from a DOM the browser already parsed, through an AST and an interpreter, into a reactive effect and a concrete write on a node.

Key takeaways

The main trick is not the syntax — it is the input. The browser “breaks” JSX-like markup into text nodes and elements. Voodoo.js reassembles the expression and only then runs it through its own pipeline.

Refusing eval / new Function is an architectural constraint, not marketing. Lexer → Pratt parser → AST → tree-walking interpreter plus a closed list of globals is what lets the library live under a strict CSP without unsafe-eval.

The unit of update is a reactive effect, not a full render. Reading a property through a Proxy registers a subscription; a write runs only the related effects and writes into “their” DOM nodes.

“No diff” is true for ordinary bindings and only half-true for lists. v-for still reconciles real DOM blocks: a mutation log, key scanning, even LIS when items are reordered.

The breakthrough is in the combination of layers. Proxies, effects, schedulers, and list reconcilers are each well known on their own. What is unusual is wiring DOM-based JSX recovery, a custom interpreter, fine-grained effects, no compiler, and no eval into one stack.

Why this teardown matters more than the README

In the news digest and the HTML-first pillar it is enough to understand the niche: prototypes, internal panels, apps without a mandatory build. To judge maturity and risk, you need to see the engine.

The project’s architecture document locks in four hard constraints: no mandatory build step; no eval / new Function; no Virtual DOM; no runtime dependencies. Everything else follows. Below we walk the layers: core (language) → state (reactivity) → DOM (walker and directives) → the framework shell. The walkthrough leans on the public ARCHITECTURE.md and repository layout (as of September 2026); where you still need an independent production check, that is called out.

The minimal example that usually sparks interest:

<script src="voodoo.full.min.js" defer></script>

<div v-data="{ count: 0 }">
  <button @click="count--">-</button>
  <strong>{count}</strong>
  <button @click="count++">+</button>
</div>

The question is not “how do I write a counter,” but how the framework understands {count} inside HTML that already exists.

The browser does the “wrong” thing — and that becomes an advantage

Consider:

<ul>
  {fruits.map((fruit) => (
    <li>{fruit}</li>
  ))}
</ul>

The browser does not know that {fruits.map(...)} is JavaScript. It parses HTML. Conceptually the DOM looks roughly like this:

<ul>
  ├── Text: "{fruits.map((fruit) => ("
  ├── Element: <li>{fruit}</li>
  └── Text: "))}"
</ul>

Ordinary JSX takes another path: source → compiler → JavaScript → browser → DOM. With Voodoo.js the chain is flipped:

HTML
  → browser HTML parser
  → DOM
  → Voodoo.js scanner
  → recovered expression string
  → lexer → parser → AST → interpreter
  → DOM again

The framework finds the text before and after the element, recognizes that the curly braces hold an expression, and recovers the construct, substituting a placeholder for the DOM element between the fragments. The recovered string then enters the normal expression pipeline.

So Voodoo.js does not teach the browser to understand JSX. It uses the side effects of the HTML parser as an intermediate representation. That is an unusual architectural trick and, at the same time, a source of fragility: any odd HTML that the browser normalizes differently than the scanner expects becomes an edge case.

Why you cannot just call eval

The simplest path is eval(expression) or new Function. Then you barely need a custom parser. Both options, however, sit poorly with a strict Content Security Policy: you need unsafe-eval.

So expressions go through:

source → Lexer → tokens → Pratt parser → AST → tree-walking interpreter → value

This is no longer a “templating engine with substitutions.” It is a small interpreted language environment inside the page — deliberately less powerful than full JavaScript.

Division of responsibility:

Layer Question
DOM scanner Where is the expression?
Lexer Which lexemes is it made of?
Parser What is its structure?
Interpreter What does that structure mean in this Scope?

Lexer, Pratt parser, and AST

Suppose the input is items.filter(item => item.active). The lexer emits a sequence such as: identifier(items), dot, identifier(filter), parentheses, arrow, access to active. From there the parser works on tokens, not on the raw string.

A Pratt-style approach was chosen: it fits operators with different precedence well (a + b * ca + (b * c)). Levels, conceptually: assignment → conditional → logical → comparison → additive → multiplicative → member/call → primary.

After parsing, the string is no longer executed. There is an AST. For example, count + price * 2 becomes a Binary(+) tree with a nested Binary(*). The interpreter walks the nodes recursively. The same AST can be evaluated in different Scopes — important for v-for, components, and nested v-data.

In the project architecture the AST is cached (the docs mention a cache limit) so repeated directive evaluations do not parse the same string over and over.

Scope, $ magics, and allowedGlobals

When the interpreter sees user, it does not jump straight to the global object. Lookup walks a chain:

current scope → parent → … → magics → allowedGlobals → undefined

Typical nesting: root scope → v-datav-for iteration → component scope. Writes follow the same logic: an existing key is updated on its owner; a new one is created in the current scope. That is why {count} inside v-data does not accidentally become a global variable.

After ordinary names, the “magics” are checked ($refs, $event, $root, $owner, and others). They can be lazy containers relative to the current context: the same $refs.button means different things in different places of execution.

If the name is still missing — a closed set of allowedGlobals. That is the security boundary: not every name automatically means a property of the JavaScript global environment. This is exactly why a custom interpreter can exist without eval: dangerous constructions such as recovering Function through constructor chains are cut by the access model (details live in the repository’s SECURITY.md; verify yourself before production).

How the interpreter builds reactivity

Take <strong>{count}</strong>. When evaluating Identifier("count"), a lookup happens. The value sits in a reactive Proxy. Reading state.count goes through get — and the system registers: effect N depends on count.

So evaluation is not only obtaining a value. It is how the dependency graph is built:

evaluate → read reactive property → track dependency

The structure, conceptually:

WeakMap(target → Map(property → Set<ReactiveEffect>))

When count changes, only effects subscribed to that property run — not a “redraw of the whole app.”

The path from count++ to a DOM write

A click on <button @click="count++">:

click
  → event directive
  → interpreter evaluates "count++"
  → Proxy.set
  → trigger
  → find effects by key
  → queueJob
  → microtask
  → effect.run (clear old deps, evaluate again)
  → textContent = new value

Several synchronous assignments (a=1; b=2; c=3) are batched: one Promise.resolve().then(flushJobs) per tick. The architecture documents a recursion limit (on the order of 100 re-runs of the same effect per flush) — protection against infinite loops.

After the main flush there is a post-flush queue: mounted / updated, watchers with flush: 'post', v-init. nextTick() can lean on the same flush promise as the “DOM is already updated” point.

Compare with the React model state → render → tree → diff → DOM. Here: state → dependency graph → effect → DOM. That is not an “optimization of the Virtual DOM”; it is a different unit of work.

DOM Walker: the heart of the runtime

runtime/walker.ts turns an existing DOM into a running program. Simplified order:

  1. fragment / text / element;
  2. already initialized? script / style / noscript? v-ignore / v-pre?
  3. collect directives;
  4. terminal v-for / v-if first;
  5. create a scope when needed;
  6. run directives;
  7. strip service attributes;
  8. walk children.

Order is critical. For <li v-for="item in items"><span>{item.name}</span></li> you cannot create an effect on item.name first: item appears only after the iteration scope. So v-for and v-if are high-priority terminal directives: first decide whether the subtree exists and with which scopes, then animate the insides.

The directive system is a separate layer. Approximate priorities from the architecture: IGNORE 100, FOR 90, IF 80, DATA 70, COMPONENT 65, REF 60, MODEL 40, BIND 30, DEFAULT 0, INIT −10, TRANSITION −20. Principle: every declarative behavior in HTML is a directive. The walker does not have to know every capability of the framework.

After processing, attributes such as v-data, @click, :disabled may disappear from the visible DOM (leaving “clean” markup), while original values live in a WeakMap. From the same place comes directiveIndex: after v-* are removed, a plain querySelectorAll('[v-tab]') no longer works.

With V.config.autoDiscover (the default), a MutationObserver picks up nodes inserted from outside via innerHTML, runs the walker again, and on removal calls destroy / stop on effects. Without EffectScope.stop() the reactive graph would keep subscriptions on dead nodes.

A component is not a render function

In React a component is often thought of as a function that returns a virtual tree. In Voodoo.js the model is closer to: an already existing DOM element + a scope (state, props, computed, methods, watchers, slots, lifecycle). There is no separate compile step and no render function.

Props such as :user="currentUser" are evaluated in the parent scope by a reactive effect and passed to the child. By default a component scope attaches to the root of the corresponding scope tree (isolation), rather than blindly inheriting the nearest v-data; inheritScope exists when inheritance is wanted.

That preserves a boundary between “page HTML context” and “component context” — useful for internal panels where you want both isolation and simple markup.

v-for: where a diff still exists

Saying “Voodoo.js never does a diff” is wrong. An ordinary text binding needs no diff. A list is different: you must learn which rows were added, removed, or moved, and which DOM blocks to reuse.

There is a dedicated list reconciler over real DOM blocks — not a Virtual DOM reconciler.

If the user does rows.splice(5000, 1), the reactive system may know the index and deletion length — a mutation log avoids scanning keys for all 10 000 rows. If a brand-new array arrives (rows = [...rows]), the operation history is lost: what remains is key comparison, shared prefix/suffix, and the changed region — already O(n) in identity reads.

On reorder, the Longest Increasing Subsequence (LIS) idea is used: which nodes to keep relatively stable, which to move. That is already a serious reconciliation algorithm.

Philosophy: do not diff the whole application; apply reconciliation only where you cannot do without it.

Comparison with Solid, Svelte, and React

On update mechanics Voodoo.js is closer to Solid than to React: read → track → write → precise effect → direct DOM write. The difference is at the entry:

Solid:  JSX → compiler → fine-grained runtime → DOM
Voodoo: HTML → browser parser → custom parser/interpreter → fine-grained runtime → DOM

Svelte moves work to compile time: know the DOM operations ahead of time. Voodoo.js deliberately pays a runtime cost for the absence of a mandatory build.

React: the unit of work is render/reconciliation. In Voodoo.js it is a reactive effect. The slogan “React without a Virtual DOM” is too coarse.

Close in spirit on the HTML-first axis, but with a different thrust — htmx and HTML over WebSockets: there the server more often moves the markup. Voodoo.js keeps a client expression language inside the document. On the cost of heavy build pipelines see also chunking in Turbopack.

What is genuinely unusual here

Not a breakthrough piece by piece: Proxy, effects, direct DOM, list reconciler, scheduler, lifecycle. All of that is known.

What is unusual is the combination:

browser HTML parser
+ recovering expressions from the DOM
+ runtime JSX/expression parser
+ custom interpreter without eval
+ fine-grained effects
+ direct DOM writes
+ no mandatory compiler

On the compile-time ↔ runtime spectrum: Svelte and Solid sit nearer the top; React sits in the middle with heavy runtime reconciliation; Voodoo.js sits deliberately near the bottom: less build infrastructure, more work and dynamism in the browser, fewer compile-time guarantees.

For AI generation that changes the unit of artifact: not “a project with a build,” but a self-contained HTML document. Hence the pillar question: can a model become the “compiler” the framework deliberately does not require?

The cost of the runtime model

Do not forget the bill:

  1. Parsing in the browser — expressions are parsed on the client.
  2. Interpreter — more expensive than ahead-of-time compiled JS.
  3. Reactivity bookkeeping — every bound piece of DOM has an effect.
  4. Runtime metadata — scopes, effects, original attributes.
  5. v-for — complex algorithms never went away.
  6. Bundle size — the thicker the “one script,” the weaker the simplicity slogan.
  7. Tooling — without a compiler, static analysis, types, and out-of-the-box IDE help suffer.

The fundamental risk of an expression language: a DSL that is too simple is awkward; one that is too powerful becomes a “second JavaScript” inside HTML. Every new language feature inflates the interpreter. Meanwhile nobody forbids real JavaScript: logic and APIs can live in an ordinary <script>, while HTML keeps expressions over reactive state. A hybrid is wiser than trying to replace the whole JS engine.

Frequently asked questions

Is this the same teardown as the “breakthrough or signal” pillar?

Short answer: no — the pillar is about meaning and niche; this article is about the internal pipeline.

Start with the overview, then come back here for lexer / AST / effects.

Can you trust the “no eval” claim in production?

Short answer: the architecture and allowlist look coherent, but you still need your own audit under your CSP.

Read SECURITY.md, verify the policy in a real browser, and do not copy the slogan from the project site.

Why Pratt if an off-the-shelf parser would do?

Short answer: a custom pipeline gives control over expression grammar and the security boundary.

The cost is maintenance. The upside is no dependency and no new Function.

Is it true that a Virtual DOM is never needed?

Short answer: for point bindings — yes in this model; for lists, reconciliation still exists.

The “VDOM is bad” argument is not the main point here. The main point is not to diff what you can update by address.

How close is this to Solid?

Short answer: on fine-grained updates — close; on how code reaches the runtime — the opposite.

Solid leans on a JSX compiler. Voodoo.js leans on the DOM after the HTML parser and its own interpreter.

Should you write large apps on this interpreter?

Short answer: for a product core it is usually early; for sandboxes and internal tools — consciously possible.

You will hit expression-language size, debugging, and ecosystem. Keep heavy logic in ordinary JavaScript.

Where should you look at the sources?

Short answer: the repository and ARCHITECTURE.md; demos on the project site.

Pointers: GitHub, ARCHITECTURE.md, documentation.

Conclusion

After walking the source architecture, Voodoo.js no longer looks like “yet another HTML framework with directives.” Technically it is:

a runtime-oriented reactive system over an existing DOM, with its own expression language and interpreter.

The core formula:

HTML
+ recovery from the DOM
+ AST interpreter
+ Scope graph
+ Proxy tracking
+ fine-grained effects
+ direct DOM writes
= Voodoo.js

The framework does not prove that a compiler is no longer needed. It proves something else: a compiler need not be a precondition for a sufficiently powerful modern reactive UI system. The cost of the choice is obvious. The mere fact that the linkage works is already a valuable engineering experiment.

Four questions “for later”: can the interpreter be sped up enough to compete with compiler-based frameworks on real apps; does it make sense to move the parser into WebAssembly; how to generate types and tooling from HTML; and can AI become the compiler that is deliberately absent here.

This week: open DevTools on the project demo, set a breakpoint on the counter text update, and walk the path from click to textContent once with your eyes — without the README. That is faster than any abstract diagram.

Stuzhuk Lab framing: fix the experiment boundary (what exactly you are checking — CSP, lists, or DX without a build), otherwise reverse engineering spreads into an endless digest without a “enough” criterion.