Concepts — Template and JSX Compilers
Phase 5 · Spec areas §31, §39. Prerequisites: bi-03 (you have written a tokenizer),
fw-02, fw-05.
1. Why a Principal Engineer needs this
Compilation is how a framework buys information it cannot get at runtime. A template is
statically analysable; a render() function generally is not. Everything a compiler-first
framework does better — static hoisting, patch flags, dependency analysis, dead-code
elimination — comes from that one asymmetry.
This module is also where bi-03's tokenizer pays off a second time. You have already written a
state machine over markup; the skill transfers directly.
2. Mental Model
source -> lexer -> parser -> AST -> transform -> codegen -> render function
The interesting stage is transform, because that is where knowledge is added:
- Static hoisting — a subtree with no dynamic bindings is created once, not per render.
- Dynamic-node detection / patch flags — annotate which parts of a node can change, so the runtime diff can skip everything else. The compiler tells the runtime what to look at.
- Compile-time dependency analysis — determine what a piece of markup reads, without running it.
The general principle: move work from runtime to build time, and move knowledge from the author to the runtime. Compare
bi-07: Blink compiles stylesheets intoRuleFeatureSetindices for exactly the same reason.
3. Build order
- Lexer for a small template language: text, interpolation
{{ }}, elements, attributes, directives. - Parser → AST, with source locations on every node.
- Transforms: interpolation, attribute binding, event binding, conditionals, loops.
- Codegen → a render function returning your
fw-05VNodes. - Static hoisting: detect fully-static subtrees; emit them once.
- Patch flags: annotate dynamic bindings; make your renderer honour them.
- Error reporting with source locations — a caret-and-line message, not a stack trace.
- Then: a JSX-to-
createElementtransform over real JS, using an existing JS parser.
4. Failure Lab
- Drop source locations. Produce a compile error and try to act on it. This is why step 2 says every node.
- Hoist a subtree that is not actually static (it reads a variable). Show the stale render.
- Emit a wrong patch flag — mark a dynamic node static. Show the missed update. This is the central risk of compiler optimisation: a wrong annotation is a silent correctness bug, not a slow one.
- Codegen that breaks on a legal input (nested quotes, unicode, an expression containing
}}).
4.5 Deep dive: what a compiler can know, precisely
The asymmetry that justifies compiler-first frameworks, stated concretely.
A template compiler can determine, without running anything:
- which nodes are entirely static (no bindings anywhere in the subtree),
- which attributes on a node are dynamic, and which specific ones,
- the shape of the tree — how many children, of what types,
- which variables an expression references,
- whether a list has a stable key expression,
- whether an event handler is a stable reference or recreated each render.
A JSX/JS render function generally cannot, because the "template" is arbitrary code:
{items.map(renderRow)} // renderRow could be anything
{cond ? <A/> : <B/>} // fine, analysable
{makeElement()} // opaque
The compiler must be conservative wherever it cannot prove a fact — and conservatism costs exactly the optimisation you wanted.
This is why React's compiler arrived a decade after Vue's: it had to become a whole-function analysis with escape hatches, rather than a template transform, and that is a substantially harder problem. Understanding the difficulty is more useful than tracking who shipped what.
4.6 Deep dive: the optimisations, and what each risks
| Optimisation | Wins | Silent failure if wrong |
|---|---|---|
| Static hoisting | subtree created once, not per render | hoisted node mutates and is shared across instances |
| Patch flags | skip prop enumeration and children diff | dynamic node marked static → update never appears |
| Static prop dedup | shared prop objects | a mutated shared object leaks between instances |
| Inline component slots | fewer closures | scope captured wrongly |
| Tree flattening | skip static intermediate nodes | dynamic descendant missed |
Every row's failure is a correctness bug that manifests as "the UI didn't update," with no error and no stack trace pointing at the compiler.
This is the defining risk of compiler optimisation and the reason
fw-06matters more than it looks. A slow program is annoying; a program that silently shows stale data is a data-integrity incident. Any team shipping a compiler optimisation needs a testing strategy that runs the same suite with optimisations on and off and compares — which is exactly Chromium's virtual test suites idea (bi-13) applied to a compiler.
4.7 Deep dive: source maps, and why they are always slightly wrong
Source maps map generated positions back to original ones. They are lossy by construction:
- Inlining destroys the one-to-one correspondence between call sites and functions.
- Hoisting moves code to a position that has no meaningful original location.
- Minification renames variables; the
namesfield helps, but scopes get merged. - Multiple transforms chained (TS → JSX → bundler → minifier) require composing maps, and each composition loses fidelity.
The practical consequences you will meet: breakpoints landing one line off, variables not inspectable under their original names, and stack traces that point at the wrong function after inlining.
Design guidance: every transform in your pipeline must produce a map, and the maps must compose. A single transform that does not is enough to break debugging for the whole chain — which is why "we just do a quick regex replace on the output" is a decision with a debugging cost nobody prices at the time.
4.8 Deep dive: error messages are the compiler's real UX
Step 7 of the build order asks for caret-and-line errors, and it is not busywork.
error: unclosed element <div>
--> Card.vue:12:3
|
12 | <div class="card">
| ^^^^ opened here, never closed
|
versus
TypeError: Cannot read properties of undefined (reading 'children')
at transform (compiler.js:412)
Both indicate the same bug. One takes five seconds to fix; the other takes twenty minutes and teaches the user to distrust the tool.
Getting this right requires that every AST node carries its source range, from lexing onward — which is why the build order puts source locations at step 2, before transforms exist. Retrofitting positions into an AST is painful and always incomplete; you cannot recover information you did not record.
The general rule: a compiler's error messages are the majority of its user interface. If you ever build a DSL, a config validator, or a schema checker for your organisation, this is the part that determines whether people adopt it.
5. Trade-offs
Compile-time vs runtime knowledge. The compiler knows the template shape; the runtime knows the actual data. Neither is sufficient alone, which is why compiled frameworks still have runtimes.
Optimisation vs debuggability. Hoisting and flags make generated code less like the source. Source maps are the mitigation and they are never perfect.
A template DSL vs plain JS. A DSL is analysable and constrained; JSX is expressive and mostly opaque to static analysis. This is the actual root of the React/Vue architectural divergence — not syntax preference.
6. Principal Engineer Review
-
What can a template compiler know that a JSX compiler cannot, and what follows from that?
-
A wrong patch flag is a silent correctness bug. Design the testing strategy that makes compiler optimisations safe to ship.
-
Argue that frameworks should move as much as possible to compile time. Then argue that runtime flexibility is worth more. What decides for a given product?
-
Source maps are always imperfect. What would you require before enabling an aggressive optimisation in a large org?
-
React added a compiler after a decade of runtime-only. Reconstruct why that took so long, and what changed.