Back to flin
flin

The First Browser Render: When FLIN Met the DOM

The first time FLIN rendered in the browser: compiling bytecode to HTML, the milestone of Session 26.

Thales & Claude | March 25, 2026 10 min flin
flinbrowserrenderhtmlmilestonefrontend

There is a moment in the life of every programming language when it stops being a toy and starts being real. For FLIN, that moment came on January 3, 2026, in Session 026, when a .flin file compiled to bytecode, executed in the VM, produced HTML, was served over HTTP, and rendered interactively in Google Chrome.

A counter button that actually counted. A reactive binding that actually updated. A view block that actually produced a web page.

It took 26 sessions and thousands of lines of Rust to reach this point. This article is about that session -- what worked, what broke, how we fixed it, and what it felt like to see FLIN render for the first time.

---

The Setup

By the start of Session 026, FLIN had:

  • A lexer, parser, and type checker that could process .flin files.
  • A code generator that emitted bytecode.
  • A virtual machine that could execute that bytecode.
  • A garbage collector that managed heap memory.
  • An HTTP server that could serve static files.
  • A view renderer that could produce HTML from view opcodes.
  • A reactivity runtime that could track state changes.

What it did not have was proof that all of these pieces worked together. Each component had been tested in isolation -- unit tests for the lexer, integration tests for the VM, manual tests for the HTTP server. But the end-to-end pipeline, from .flin source to browser pixel, had never been exercised.

Session 026 was the integration test.

---

The Welcome Page

We created examples/welcome.flin, a showcase page designed to exercise every feature:

count = 0

view {

Welcome to FLIN

The language that writes apps like it's 1995.

Interactive Counter

Count: {count}

} ```

This page exercises:

  • Global variable declaration (count = 0).
  • View rendering with nested HTML elements.
  • Static attributes (class="welcome").
  • Static text content.
  • Reactive text binding ({count}).
  • Event handlers with state mutation (click={count++}).
  • Event handlers with state reset (click={count = 0}).

If this page renders correctly, the entire pipeline works. If it breaks, the error tells us which layer failed.

---

Bug 1: Stack Underflow in Match Expressions

The first attempt to compile and run the welcome page crashed with a stack underflow error. The stack trace pointed to the match expression handler in the code generator.

The root cause was subtle: the emit_match function in src/codegen/emitter.rs assumed that the JumpIfFalse instruction does not pop the condition value from the stack. In reality, JumpIfFalse always pops the condition.

The emitter was emitting an extra Pop instruction after JumpIfFalse, attempting to clean up a value that was already gone. This left the stack one element short, and the next instruction that tried to pop a value hit an empty stack.

The fix was removing two erroneous Pop instructions:

// Before (broken):
emit(JumpIfFalse, else_addr);
emit(Pop);  // ERROR: condition already popped by JumpIfFalse

// After (correct): emit(JumpIfFalse, else_addr); // No Pop needed -- JumpIfFalse consumed the condition ```

Two lines removed. Two hours of debugging. This is the nature of VM bugs: the symptom (stack underflow) appears far from the cause (an extra Pop ten instructions earlier), and the only way to find it is to trace the stack state instruction by instruction.

---

Bug 2: String Comparison Failure

With the stack underflow fixed, the welcome page compiled and the VM produced HTML. But the reactive counter did not work. Clicking the "Increment" button did nothing.

Investigation revealed that the match expression for view rendering was comparing string values by ObjectId rather than by content. Two strings with the same content but different heap allocations were not equal:

// Before (broken):
OpCode::Eq => {
    let b = self.pop();
    let a = self.pop();
    self.push(Value::Bool(a == b));  // Compares ObjectId, not content
}

Value::Object(ObjectId(5)) and Value::Object(ObjectId(12)) are not equal even if both point to the string "click". The == operator on Value compared the enum variants structurally, which for Object variants meant comparing the ObjectId (a heap index).

The fix was a dedicated values_equal helper that dereferences objects and compares their content:

fn values_equal(&self, a: &Value, b: &Value) -> bool {
    match (a, b) {
        (Value::None, Value::None) => true,
        (Value::Bool(a), Value::Bool(b)) => a == b,
        (Value::Int(a), Value::Int(b)) => a == b,
        (Value::Float(a), Value::Float(b)) => a == b,
        (Value::Int(a), Value::Float(b)) => (*a as f64) == *b,
        (Value::Float(a), Value::Int(b)) => *a == (*b as f64),
        (Value::Object(a_id), Value::Object(b_id)) => {
            if a_id == b_id {
                return true;  // Same object
            }
            // Dereference and compare content
            match (self.get_object(*a_id), self.get_object(*b_id)) {
                (Some(a_obj), Some(b_obj)) => match (&a_obj.data, &b_obj.data) {
                    (ObjectData::String(a), ObjectData::String(b)) => a == b,
                    (ObjectData::List(a), ObjectData::List(b)) => {
                        a.len() == b.len() &&
                        a.iter().zip(b.iter()).all(|(x, y)| self.values_equal(x, y))
                    }
                    _ => false,
                },
                _ => false,
            }
        }
        _ => false,
    }
}

This function handles the easy cases (primitives compared by value) and the hard case (objects compared by dereferenced content). For strings, it compares the actual string data. For lists, it recursively compares each element.

The if a_id == b_id { return true; } shortcut is important: if two values point to the same heap object, they are trivially equal without dereferencing. This handles the common case where a variable is compared to itself.

---

The Moment It Worked

With both bugs fixed, we rebuilt the FLIN binary and ran:

./target/release/flin dev examples/welcome.flin

The terminal printed:

[FLIN] Dev server running at http://localhost:3000
[FLIN] Watching examples/welcome.flin for changes

Opening http://localhost:3000 in Chrome showed the welcome page. The heading said "Welcome to FLIN." The counter showed "Count: 0." Two buttons: "Increment" and "Reset."

Clicking "Increment" changed the count to 1. Clicking again: 2. Again: 3. Clicking "Reset" set it back to 0. Every click, the {count} binding updated instantly.

It worked.

A .flin file, written in a language that did not exist eight days earlier, compiled to bytecode by a compiler written from scratch in Rust, executed by a virtual machine with a garbage collector, rendered to HTML by a view engine with reactive annotations, served over HTTP by an embedded web server, and made interactive in the browser by a 50-line JavaScript runtime.

Twenty-six sessions. No frameworks. No dependencies beyond Rust's standard library and a handful of crates. No human engineers besides Thales. One AI CTO (Claude) generating the code.

---

The Welcome Page in Production

The welcome page that launched on that day was more than a counter demo. It included:

  • The FLIN logo, served as a static asset from the public/ directory.
  • A "Five Pillars" section describing FLIN's core concepts: Entities, Views, Actions, Intents, and Time.
  • A quick-start guide with commands.
  • A footer with the creator signature.
  • A dark theme with FLIN's colour palette.

All of it rendered from a single .flin file. All of it reactive. All of it served by a Rust binary that was under 20 MB.

---

What the Debugging Taught Us

The two bugs in Session 026 were both interaction bugs -- they appeared only when multiple subsystems worked together. The stack underflow was a code generator bug that only manifested during VM execution. The string comparison was a VM bug that only manifested when the view renderer compared attribute values.

Unit tests did not catch them because unit tests test components in isolation. Integration tests should have caught them, but the integration test suite had not yet covered the view rendering pipeline.

The lesson was clear: end-to-end testing, from source code to browser render, must be a first-class concern. After Session 026, we committed to testing the full pipeline for every new feature, not just the individual components.

---

The Version Bump

Session 026 warranted a version bump from v0.26.0 to v0.50.0. This was not because of the number of features added (two bug fixes and a welcome page). It was because the project had crossed a qualitative threshold.

Before v0.50.0, FLIN was a compiler and a VM. After v0.50.0, FLIN was a web framework. It could take a .flin file and produce a working, interactive web page. The fact that the web page was simple -- a counter -- did not matter. The pipeline was complete. Everything that came after was elaboration, not invention.

---

Known Issues

Session 026 was honest about what did not work yet:

  • .to_float method returned null. String-to-float conversion was not implemented, which meant the calculator example still failed.
  • Lambda parameter handling. Functions showed as null in the runtime state, meaning callbacks with parameters did not work correctly in views.
  • Action blocks. The name = { ... } syntax for named action blocks needed more testing.

These were real bugs, documented in the session log with priority labels (P0, P1, P2). They would be fixed in subsequent sessions. But they did not diminish the achievement of Session 026: FLIN rendered in the browser.

---

The Server That Made It Possible

The browser render would not have happened without the HTTP server that served the page. The FLIN dev server, implemented in src/server/http.rs, was deliberately minimal:

  • Route "/" and "/index.html" to the FLIN renderer.
  • Route all other paths to the public/ directory for static assets.
  • Return 404 for everything else.

The serve_flin() function was the critical path: read the FLIN source file, compile it, render the views, wrap the output in an HTML document (with , , , and the reactivity runtime