Write components. Ship direct DOM.
FluxDOM is an experimental compiler for single-file .flow components, paired with a small fine-grained DOM runtime. The compiler turns your template into plain DOM calls. At runtime, signals update only the nodes that read them. There is no virtual DOM and no diffing.
Live site and demo · Compiler source · Known limitations
Status: pre-1.0 learning project. It is built to be read and understood, not to run your checkout page. Syntax, output and APIs can change.
You write this:
<script lang="ts">
let count = 0;
</script>
<template>
<button :class="count > 5 ? 'max' : ''" @click="() => count++">
Count: {count}
</button>
</template>The compiler produces this (real output, whitespace trimmed):
import { createSignal } from '@fluxdom/runtime';
import { addEventListener, createEffect, createElement, createText, insert, setAttribute, setText } from '@fluxdom/runtime';
const [__count, __setCount] = createSignal(0);
export function render() {
const __el0 = createElement("button");
addEventListener(__el0, "click", () => __setCount(__count() + 1));
createEffect(() => { setAttribute(__el0, "class", __count() > 5 ? 'max' : ''); });
const __text1 = createText(" Count: ");
insert(__el0, __text1);
const __expr2 = createText('');
createEffect(() => { setText(__expr2, String(__count())); });
insert(__el0, __expr2);
return __el0;
}Three things happened:
let count = 0became a signal. Every top-levelletin<script>turns into acreateSignalpair. Reads become__count(), writes become__setCount(...).- Each dynamic part got its own effect. The text node and the
classattribute each subscribe only to the signals they read. - Clicking writes one text node. There is no re-render and no tree comparison. The effect that owns
__expr2runs and callssetText. You can watch this happen, line by line, in the live demo.
App.flow
│
├─ 1. Parse split <script>, <template>, <style>; build a template AST
├─ 2. Analyze find signals (top-level let), pick a render strategy
├─ 3. Transform script → signals, template → DOM calls, style → scoped CSS
├─ 4. Emit render() function + virtual CSS module
└─ 5. Bundle the Vite plugin hands both to Vite
| Stage | Where to read it |
|---|---|
| Parse | compiler/src/parser |
| Analyze | compiler/src/analyzer |
| Script transform | transformer/ast-rewriter.ts |
| Template transform | transformer/template-transform.ts |
| Scoped styles | transformer/style-transform.ts |
| Signals and effects | runtime/src/reactivity |
The script transform uses a real JavaScript/TypeScript AST (@babel/parser) with scope tracking. A count parameter inside a function is a different variable from the top-level count signal, and the compiler knows that. When it can't handle something, it fails with a clear error instead of emitting broken JavaScript.
| Package | What it does |
|---|---|
@fluxdom/compiler |
.flow parser, analysis and code generation |
@fluxdom/runtime |
Signals, effects, lazy computed values, batching, DOM helpers |
@fluxdom/vite-plugin |
Compiles .flow imports and bundles their CSS |
@fluxdom/cli |
create scaffold plus dev / build wrappers |
@fluxdom/store |
Signal-backed stores with optional browser persistence |
@fluxdom/router |
Route matching and navigation signals |
@fluxdom/server |
Escaped static HTML rendering, never evaluates component code |
The packages are published on npm as 0.1.0. That release predates the AST-based compiler and the runtime fixes, so use the repository for the current behavior until the next release.
From the repository:
git clone https://github.com/onuracar-dev/FluxDOM.git
cd FluxDOM
npm ci
npm run check # build every package and run the tests
npm run dev --workspace hello-world # http://localhost:5180Using a component in your own entry file:
import { render } from './App.flow';
import { insert } from '@fluxdom/runtime';
insert(document.getElementById('app')!, render());Scaffold a new project (uses the published 0.1.0 packages):
npx @fluxdom/cli create my-flow-app
cd my-flow-app && npm install && npm run dev| Syntax | Meaning |
|---|---|
{expr} |
Text that updates when the signals in expr change |
:attr="expr" |
Reactive attribute |
@event="handler" |
Event listener |
{#if cond} … {/if} |
Conditional block |
{#each list as item, index} … {/each} |
List block |
<style scoped> |
CSS scoped to this component |
Works today
- AST-based script transform with scope tracking, covered by edge-case tests.
- Nested
{#if}/{#each}, reactive attributes, events and expressions. - Scoped styles bundled through Vite.
- Signals, effects, batching, and lazy computed values without diamond-dependency glitches.
- Static SSR that escapes output and refuses to evaluate component code.
Not yet
- Keyed lists.
{#each}rerenders its whole block when the list changes. - Structural
{#if}. It hides content with a display wrapper instead of unmounting it. - Real hydration and full SSR. Hydration currently rerenders on the client.
- Destructuring in a top-level
let, exporting signals, and destructuring assignment to signals. These fail with a compiler error that says what to write instead. - In
x++used inside a larger expression, the prefix/postfix return value difference is not preserved. - Router mounting, nested routes, lifecycle hooks, source maps, and fine-grained HMR.
Don't compile .flow files you don't trust. See SECURITY.md.
packages/ compiler, runtime, vite-plugin, cli, store, router, server, devtools (private)
examples/ hello-world app
apps/docs/ minimal docs app built with FluxDOM
website/ fluxdom.onuracar.dev (React shell, demos run on the FluxDOM runtime)
docs/adr/ design decisions
- FluxDOM and its sibling project NimbleJS are versioned independently on purpose. See ADR 0001.
- The website's demos run on FluxDOM's own runtime. The page shell is React, because FluxDOM's router and SSR aren't ready yet.
Issues and small pull requests are welcome. Read CONTRIBUTING.md first, and run npm run check before you open a PR.
MIT © Onur Acar