How Fict's compiler works
2026-04-18
A source-accurate walkthrough of the compiler pipeline currently implemented in this repository.
What This Article Is and Is Not
This article describes the implementation under packages/compiler/src as it exists today. It is not a cleaned-up idealized pipeline, and it intentionally distinguishes:
- top-level stages that always run
- analysis helpers that only run on some paths
- representative IR snippets versus current emitted output
Unless otherwise noted, examples assume the source module imports from fict, so compiler-generated runtime imports also target the fict package family (for example fict/internal), not @fictjs/runtime/internal.
Compiler options materially affect behavior. In particular, fineGrainedDom, optimize, lazyConditional, getterCache, inlineDerivedMemos, resumable, strictReactivity, and strictGuarantee can change what gets emitted or whether compilation is allowed at all.
The Real Top-Level Flow
At the top level, the Babel plugin in packages/compiler/src/index.ts does this:
1 | +--------------+ |
That means the actual top-level pipeline is:
| Top-level stage | Main module | What it does |
|---|---|---|
| Front-end validation | index.ts |
macro import checks, placement rules, warnings, strict-guarantee enforcement |
| Build HIR | ir/build-hir.ts |
Babel AST to HIR/CFG |
| Optional optimization | ir/optimize.ts |
pure-function SSA optimization or reactive optimization |
| Lowering | ir/codegen.ts |
scopes, regions, structurization, helper imports, Babel AST emission |
ssa.ts, scopes.ts, regions.ts, and structurize.ts are important, but they are mostly invoked inside optimization or lowering rather than as separate top-level passes in index.ts.
Stage 0: Front-End Validation Before HIR Exists
Before the compiler builds HIR, index.ts performs a large amount of source-level validation and bookkeeping.
Macro import and placement checks
The plugin first discovers macro aliases imported from fict, fict/slim, and fict/plus, then enforces rules such as:
$state()must be imported fromfict$state()must be assigned directly to an identifier$state()cannot appear inside loops, conditionals, or nested non-reactive functions$effect()must be imported fromfict$effect()cannot appear inside loops, conditionals, or nested non-reactive functions
This matters because many invalid programs are rejected before HIR construction.
Warning and fail-closed behavior
Current defaults are intentionally strict. createFictPlugin() normalizes options so that:
optimizedefaults totruefineGrainedDomdefaults totruelazyConditionaldefaults totruegetterCachedefaults totrueinlineDerivedMemosdefaults totruestrictGuaranteedefaults totrueunless explicitly disabled
With strictGuarantee: true, non-guaranteed reactivity cases are escalated to hard errors rather than silently compiling with weaker behavior.
Cross-module metadata lookup starts here
index.ts also resolves previously-emitted module metadata for imports. That lets the compiler treat imported bindings as reactive when another module exported them as signal, memo, or store.
Stage 1: Build HIR
The first IR pass lives in packages/compiler/src/ir/build-hir.ts.
Why HIR exists
Fict does not try to reason directly on raw Babel AST after validation. Instead it builds a high-level IR that gives the compiler:
- explicit basic blocks and terminators
- a smaller, normalized expression set
- preserved high-level constructs such as JSX, conditional expressions, optional chains, and template literals
- a representation that both optimization and lowering can share
Destructuring is preprocessed first
Before statement-to-HIR conversion, build-hir.ts runs @babel/plugin-transform-destructuring and then rewrites object-rest helpers to Fict-specific forms such as __fictPropsRest.
That preprocessing is not an incidental detail. It is how the compiler preserves reactive props semantics while still operating on normalized assignments.
Core HIR types
The exact HIR types live in hir.ts. At a high level:
1 | type Instruction = |
HIRProgram also preserves:
preamblepostambleoriginalBody
That preserved body is important later, because codegen tries to rebuild the final module while keeping original statement ordering stable.
Representative HIR for a simple component
Consider this source:
1 | import { $state } from 'fict' |
Its HIR is conceptually a single basic block, because the function body contains no statement-level branching:
1 | Block 0 |
Important nuance: the ternary is still just an expression. It does not create CFG branching on its own.
Stage 2: SSA and CFG Utilities
packages/compiler/src/ir/ssa.ts serves two related but distinct purposes:
enterSSA(program)performs full SSA conversionanalyzeCFG(blocks)computes reusable CFG facts
Those are not the same thing, and the current compiler uses them differently.
Full SSA conversion exists, but it is not a universal top-level stage
enterSSA() does the expected textbook work:
- compute predecessors and successors
- compute dominators and dominance frontiers
- insert phi nodes
- rename definitions and uses to SSA versions
- eliminate redundant phi nodes
The naming scheme uses $$ssa:
1 | makeSSAName('count', 2) // count$$ssa2 |
One implementation detail matters here: getSSABaseName() is intentionally conservative. It strips suffixes from compiler-generated SSA names, but it does not blindly rewrite every user identifier that happens to end in $$ssaN.
CFG analysis is used more broadly than SSA renaming
Even when the whole function is not renamed into SSA form, analyzeCFG() is reused by:
scopes.tsstructurize.ts- other control-flow-sensitive lowering decisions
So the correct mental model is:
- SSA conversion is a targeted optimization tool
- CFG analysis is a shared compiler utility
Representative phi-node example
If a function does enter SSA and contains statement-level branching, merge blocks can receive phi nodes:
1 | function Example() { |
Conceptually, the join block looks like:
1 | Block 3 |
That example is valid as an SSA illustration. It is not evidence that every reactive component takes a mandatory global “Enter SSA” pass before lowering.
Stage 3: Optimization
Optimization lives in packages/compiler/src/ir/optimize.ts, and the current compiler has two optimization paths.
Path A: pure-function optimization
If a function is considered a pure optimization candidate, the compiler explicitly enters SSA and runs a classic SSA-style pipeline:
1 | ssaFn = enterSSA(...) |
This is where the article-worthy “full SSA optimization pipeline” really exists today.
Path B: reactive optimization
Non-pure functions take the reactive path instead. This path currently:
- analyzes reactive scopes with CFG-aware metadata
- builds reactive and purity contexts
- optimizes blocks locally
- optionally propagates constants across blocks
- performs cross-block common-subexpression elimination
- inlines single-use derived memos when allowed
- builds a reactive dependency graph
- runs reactive dead-code elimination from observable roots
Crucially, this path is not “enter SSA, then do everything else.” It works on the existing HIR plus CFG/scope information.
Reactive graph, not just local algebra
For reactive code, the optimizer cares about observability, not only local expression simplification.
Observable roots include things such as:
- returned values
- bindings
- effects
- explicit memos
- exported reactive values
That is why reactive DCE is graph-based rather than just “remove unused assignments.”
Stage 4: Reactive Scope Analysis
Reactive scope analysis lives in packages/compiler/src/ir/scopes.ts.
The base pass, analyzeReactiveScopes(fn), does this:
- collect writes and reads for each definition-like scope
- compute dependencies between scopes
- find escaping variables from return values
- mark scopes with external effects
- compute a memoization heuristic
- merge overlapping scopes
- prune scopes that do not contribute to escaping or memoized results
The current scope model
The core shape is:
1 | interface ReactiveScope { |
Optional-chain dependency paths
scopes.ts tracks dependency paths such as user?.profile?.name, not just base names. That feeds later subscription decisions.
The important guarantee is limited but useful:
- the compiler can distinguish base-object dependency from property-path dependency
- later passes can choose between whole-object and property-level subscriptions more precisely
SSA-enhanced scope analysis
The higher-level API used by lowering is analyzeReactiveScopesWithSSA(fn). Despite the name, it does not first run enterSSA().
Instead it returns:
- the base scope analysis result
- CFG analysis (
loopHeaders,backEdges, dominator tree) - control-flow read analysis
- loop-dependent scope metadata
That enhancement is enough for codegen decisions such as:
- whether a dependency requires re-execution because it affects control flow
- whether a scope is loop-dependent
Memoization heuristic is intentionally simple
Current shouldMemoizeScope() is heuristic, not an exact cost model. It mainly looks at:
- whether the scope has dependencies
- whether those dependencies come from other active scopes
- whether the scope touches the entry block
- whether it spans multiple blocks
- whether it contributes to escaping values
It does not currently implement a rich “cheap expression” cost model.
Stage 5: Regions
Regions live in packages/compiler/src/ir/regions.ts.
Regions are the bridge between abstract scopes and concrete emitted code:
1 | interface Region { |
How regions are actually created
generateRegions(fn, scopeResult, shapeResult) creates regions only for scopes that are relevant to emitted reactivity:
- scopes with
hasExternalEffect - scopes with
shouldMemoize
For each such scope, the compiler:
- collects owned instructions
- records whether the covered blocks contain control flow
- records whether any owned expression contains JSX
- computes dependencies
- refines subscriptions using shape analysis from
shapes.ts
Shape analysis matters here
regions.ts uses analyzeObjectShapes() plus helpers such as:
getPropertySubscription()shouldUseWholeObjectSubscription()
So a region can subscribe to a narrower set like user.name or user.email when the shape analysis says that is safe, instead of always subscribing to all of user.
A key current implementation detail: internal region memos
Older conceptual descriptions often say “a derived variable becomes one memo.”
Current Fict output is a little more structured than that. Regions with outputs often compile into an internal memo that returns an object of outputs, for example:
1 | const __region_0 = __fictUseMemo( |
That is a more accurate description of current output than the older “just one const finalPrice = useMemo(...)“ story.
Stage 6: Structurization
Structurization lives in packages/compiler/src/ir/structurize.ts.
Its job is to turn CFG-style blocks back into structured nodes such as:
ifwhileforforOfforInswitchtryreturnthrow
It also has a fallback:
1 | { kind: 'stateMachine', blocks: ..., entryBlock: ... } |
Important nuance: structurization is a utility, not always a separate top-level phase
The current compiler does not always run a clean standalone:
1 | regions -> structurize -> codegen |
Instead, structurization is used where lowering needs structured control flow:
- region-based lowering
- some pure-function lowering paths
- fallback handling when CFG shape is awkward
Safety behavior
structurizeCFG() tracks:
- depth limits
- emitted blocks
- problematic blocks
- shared side-effect blocks
- unreachable/unemitted reachable blocks
If needed, it can throw StructurizationError, or it can fall back to a state-machine node.
That fallback is real in the current implementation, so any rigorous description of Fict’s compiler should mention it.
Stage 7: Lowering and Code Generation
The main lowering entry point is lowerHIRWithRegions() in packages/compiler/src/ir/codegen.ts.
This is where several previously-separate concepts come together:
- runtime import-family detection
- imported reactive metadata
- hook-return metadata
- top-level statement segmentation
- function lowering
- scope analysis
- region generation
- structurization where needed
- final Babel AST emission
The runtime import family follows the source module
If a module already imports from fict, generated helpers target fict/internal.
If a module instead lives in a lower-level runtime-only integration, generated helpers can target @fictjs/runtime/internal.
That choice is made by detectRuntimeImportFamily() in constants.ts.
Props are not lowered as raw props.foo reads
For component props, codegen often emits reactive accessors such as:
1 | const price = prop(() => __props.price) |
So a precise article should talk about prop accessors, not just “props become getters somehow.”
Signals, memos, and effects depend on context
In component-like context, the compiler emits hook-aware helpers such as:
__fictUseContext__fictUseSignal__fictUseMemo__fictUseEffect
At module level it can emit non-hook helpers such as:
createSignalcreateMemocreateEffect
Hook slot numbering is explicit in the current implementation and starts from 1000.
Current Output Shape for PriceTag
Here is a representative excerpt from the current compiler output for the PriceTag example above:
1 | import { |
Several details here are important because they correct older, less accurate explanations:
- current output frequently introduces internal
__region_*memos - child slots are often updated through
insertBetween(...), not alwaysbindText(...) - delegated events can compile to
addEventListener(node, name, handler, true) - the DOM tree is located with
resolvePath(...)and slot markers, not hand-writtenfirstChild/nextSiblingchains
JSX Lowering in the Current Compiler
The current compiler does not have one universal “JSX becomes bindText everywhere” rule. It chooses helpers based on the kind of child or binding.
Static template extraction
For fine-grained DOM output, the compiler builds HTML strings for static structure via template(...).
Template hoisting is opportunistic, not universal. In the current implementation it is especially used for list-render contexts to avoid repeated template parsing.
Dynamic child insertion
Dynamic child expressions often become:
resolvePath(...)to find the slot markergetSlotEnd(...)to locate the slot boundaryinsertBetween(...)to insert dynamic child content
That is why a child like {label} in an element body may not compile to a direct bindText call.
Direct text bindings still exist
When codegen has a direct text target, it may choose:
setText(...)for static or fused patch casesbindText(...)for reactive text bindings
But that is only one lowering pattern among several.
Conditional children are specialized
Reactive conditional child expressions can lower to createConditional(...) plus onDestroy(...), rather than to a generic “re-run this block in useEffect“ wrapper.
Lists are specialized too
Recognized .map(...) JSX children can lower through the list helpers path (createKeyedList and related support). In the current tree, that logic lives primarily in codegen-list-child.ts, codegen-list-keys.ts, and the runtime list helpers.
Event Handling and Delegation
The event story in the current compiler is more precise than “it always emits bindEvent.”
- Delegated events are recognized via the compiler’s
DelegatedEventsset. - For delegated cases without special options, the compiler commonly emits
addEventListener(node, name, handler, true). - In the runtime, that delegated form lazily ensures document-level delegation via
delegateEvents(...). - For non-delegated events or events with options such as
capture,once, orpassive, the compiler falls back tobindEvent(...).
So a rigorous description must distinguish:
- compile-time delegated emission
- runtime delegated installation
- non-delegated per-node listeners
Getter Caching
When enabled, getter caching allows repeated synchronous reads of the same accessor to be reused rather than re-invoked.
This is a lowering detail, not a semantic phase of the IR pipeline, but it meaningfully affects emitted code quality. It is controlled by the getterCache option and implemented through codegen-cache.ts.
Cross-Module Reactive Metadata
Cross-module reactive metadata is represented by:
1 | interface ModuleReactiveMetadata { |
That means the current JSON shape is:
1 | { |
not:
1 | { |
Metadata emission modes are also more nuanced than “write a sidecar next to the file”:
true: emit adjacent sidecarsfalse: emit nothing'auto': emit to the metadata cache directory when no external metadata store or resolver is supplied
The default cache directory is .fict-cache/metadata.
The Best Mental Model for the Current Compiler
If you want a model that matches the source tree well, use this one:
index.tsvalidates source-level rules and builds compiler context.build-hir.tsconverts Babel AST into HIR + CFG.optimize.tsruns either:- a true SSA pipeline for pure functions, or
- a reactive optimization pipeline for non-pure functions.
codegen.tsperforms the heavy lowering work.- Inside lowering, the compiler invokes:
- scope analysis
- region generation
- structurization when structured control flow is needed
- final Babel AST emission
That is more accurate than treating SSA, scopes, regions, structurization, and codegen as seven always-separate top-level compiler stages.
Summary
The current Fict compiler is best understood as:
- a strict source validator first
- a HIR/CFG compiler second
- a dual-path optimizer
- a lowering engine that uses scopes, regions, and structurization as internal tools
HIR is central. CFG analysis is central. SSA is real, but targeted. Regions are real, but they are a lowering construct, not the public pipeline boundary. Structurization is real, but it is a utility the lowering path calls when needed, with a state-machine fallback when necessary.
That combination is what lets the compiler accept plain component code, enforce Fict’s reactivity guarantees, and emit fine-grained runtime code without pretending that the implementation is simpler or more linear than it actually is.