Skip to content
Meltea

How it works

Steeped at build time, poured at run time.

meltea-ppx sees every view at build time, turns its static part into a DOM template and emits the update code for it. What remains at run time is what the compiled code calls, and only that: cloning a template, moving keyed rows, delegating events. No virtual DOM, no tree diff, no reactive graph.

The glass

The static part becomes a template, parsed once.

The largest static subtree of every view becomes one HTML string, parsed once into a <template> and cloned withcloneNode(true) per mount. Every dynamic part is a hole: a text, an attribute, a handler, a child.

The compiler knows the shape of every template, so it writes out how to reach each hole on a fresh clone, firstChild andnextSibling only, sharing every step. The runtime keeps no paths and no tag tables.

the compiled walk
View.template (Template.html "<p>a<!---->b<!----><b></b></p>") (fun scope p ->
  let n0 = Template.first p in
  let n1 = Template.next n0 in
  ...
  (View.text_hole { parent = p; before = Template.next_or_end n0 }, ...))

The pour

A refresh that writes only the holes whose values changed.

For every template the compiler emits the update code, as Svelte and dartea do. Each local the view reads is checked with one ===; only the holes that read a changed one are evaluated and written. Pure views and immutable values make this exact: an unchanged reference is an unchanged value.

The counter in Meltea: you write the view in mlx; Meltea compiles it into a template tree of div, two buttons and an output whose text is the only hole; when + is clicked the update makes count 42, the compiled code compares count with its last value and writes 42 into that one text node; the grey static nodes get no DOM writes; there is no virtual DOM

The counter's view compiles to this JavaScript (names shortened):

counter.js
const template = View.template(Template.html("<div class=\"counter\"><button>-</button>…"),
  (scope, div) => {
    const minus = div.firstChild, output = minus.nextSibling, plus = output.nextSibling, …;
    return [handler_hole(scope, minus, "click"), text_hole({ parent: output, before: null }), …];
  });

const view = (count, dispatch) =>
  View.compiled(template, 2, (scope, holes) => {
    const sent = check(scope, 0, dispatch), counted = check(scope, 1, count); // one === each
    if (sent === Changed) set_handler(holes[0], () => dispatch(Decrement));  // first draw only
    if (counted === Changed) set_text(holes[1], String(count));
    …
  });

On mount nothing is remembered yet, so every hole is evaluated once. A hole whose free variables are all physically unchanged is neither evaluated nor compared, and a nested tree inside it is not built at all. An expression that reads mutable state is evaluated on every render.

Who writes what

A message that changes nothing the view reads does not call the view.

meltea-ppx reads update the way Svelte 4 reads assignments. For every message it finds which model fields the branch can change: { model with draft } changes draft, model changes nothing, anything it does not recognise changes everything. It reads view the same way. Both become bit masks over field names, generated next to update andview; nothing goes into your code.

Fields an update changes, in Meltea's TodoMVC: meltea-ppx reads update and finds what each message can change: Type changes field, Route changes filter, Add changes todos, field and next, Ignore changes nothing, and Edit, which it does not recognise, changes all fields. It reads view the same way: todos, field, editing and filter, never next. After Ignore nothing the view reads changed, so the view is not called; after Type the view runs and one === per local picks the holes to write

subscriptions is read the same way: a program asks for them again only when a field they read changed. TodoMVC'sfun _ -> Sub.hash … reads none, so it is asked once, at the start.

Kept warm

Derived values are remembered by the fields they read.

A let at the top of a view, let shown = List.filter … model.items, builds a new list on every call, and a new list fails the=== of every hole that reads it. meltea-ppx compiles such a let to View.remember, keyed by the fields and locals it reads: when every input is the one the value was computed from, the old value comes back, the same object, and the holes that read it are not evaluated.

Remembered values in a Meltea view: Type m changes only the draft field. Without remembering, let shown = List.filter over model.items builds a new list on every call, so the list hole sees a different value and checks all 1,000 rows. meltea-ppx compiles the let to View.remember keyed by model.filter and model.items; both are the same objects, so the old list comes back, the hole is skipped, and only the draft hole is written

A word from the compiler

Fanout: when typing would make a list check every row.

The same reading gives the compiler the whole path of an event, with no annotations: a handler sends a message, the message's branch ofupdate writes fields, and a list in view reads some of them. When a frequent event (onInput, key and pointer moves, scroll, drag) sends a message whose fields a list reads, meltea-ppx says so at the handler, as an alert namedfanout:

dune build
File "todomvc.mlx", line 142, characters 19-44:
142 |            onInput=(fun text -> Retype text)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^
Alert fanout: each input event here sends Retype, which changes editing;
the list at line 187 reads editing, so every input event checks all of its rows
Fanout in Meltea's TodoMVC: meltea-ppx follows a frequent event to the lists it reaches. The edit field's onInput sends Retype, whose branch of update writes editing, and the list in view reads todos, filter and editing, so every keystroke makes the list check all its rows; the compiler raises the alert fanout at the handler. The new-todo field's onInput sends Type, which writes field, which no list reads, so typing writes one hole and there is no alert. A key handler's literal branch such as Enter sends Add once and does not count

Branches of a key handler matched on a literal key ("Enter" -> Add) are single actions and do not count. The alert is a compiler warning, not an error; (handler [@alert "-fanout"]) keeps it quiet where the cost is accepted. It compiles to nothing.

A tray of glasses

Rows render again only when what they read changed.

Html.list (List.map row items) compiles to a keyed loop. As in Vue Vapor's selectors, the compiler splits a row into its item and its leaves, the smallest pieces without JSX that read outer variables, for example

a leaf
match selected with Some id when id = item.id -> "danger" | _ -> ""

and emits a projection that evaluates only those. A row whose item is the same object and whose projection did not change is kept without calling the row function: selecting a row of 1,000 renders two.

Compiled selectors, after Vue Vapor: a row of Html.list (List.map …) reads its item and the outer value selected through one leaf without JSX; the compiler emits a projection that evaluates only that leaf for each row. When selected goes from Some 2 to Some 5 and the six items are the same objects, only rows 2 and 5 have a changed projection, so only they render, in place; the other four are kept without calling the row function

Keyed rows that move are moved with parentNode.moveBefore where the browser has it, so a focused input, a playing video, an iframe or a running CSS animation inside the row survive the move; elsewhere it is insertBefore. Swapping two rows of 1,000 is two moves.

Light glasses

A compiled row is one record.

When a row is a single lowercase element with a key, the compiler emits View.rows: one record per row with its root, scope and holes, holes that are the bare nodes, and the last written values kept next to the guarded locals. In Meltea an event prop is a message, so the compiler hoists one handler per site to module level; the element carries only the locals the message reads, and the shared handler builds the message when the user clicks.

What one row of a compiled list holds. Before: a child description with build and patch closures, a with_key wrapper, a refresh closure, a mounted child with three closures, a compiled state with a lend closure, a scope with its deps, a holes array with five hole records, two handler closures and a projection array. Now: one Mounted_row record with the root, the scope and the holes, the scope's deps also holding the last written values, the holes as bare nodes, the handler data as a property of the element, and the projection array; the template, one refresh per list, the key function and one handler per site are shared. Creating 1,000 rows allocates about 430 kB instead of 939, and 1,000 rows take 1.22 MB instead of 1.68

Sip by sip

Pacing, as the Elm runtime does.

A program draws at the next microtask after a message, so every click and keystroke is on screen before the browser paints. Pointer moves, scroll, wheel and drag fire many times a frame; a message they send waits for the next animation frame, so a burst of moves is one draw. A click in between draws at once.

What you see is the model

Controlled inputs always show the model.

value and checked are properties the user changes too. They are controlled holes: after an input orchange event, and the redraw it causes, the hole puts its last written value back if the DOM differs. An update that rejects or filters the text keeps the field in step with the model.

Served on the server, finished in the browser

Hydration in React's HTML format, with early events.

Program.render writes HTML in the format of React's renderToString: no markers, a <!-- --> comment only between two adjacent texts. ~start:Program.hydrate takes it over in document order with code the compiler generates for each template under its -hydratable flag: it keeps the server's elements and sets handlers. HTML that does not match the view is replaced by a normal mount.

Server rendering and hydration in React's format: on the server Program.render, renderToString or server-reason-react write plain HTML with no markers, and a comment only between two adjacent texts; in the browser, code the compiler generated for each template walks the server's nodes in document order, after an off-page clone has let the template's own refresh compute the values: the root with the same tag is taken over, texts and lists take exactly their nodes, and the buttons get their handlers; no element is added to the page, and HTML that does not match the view is cleared and mounted from scratch. Before the code arrives, the capture script in the page's head records clicks, typing and key presses with each target's value, and hydration delivers them in order

Between the server's HTML appearing and the code hydrating it, a page looks alive and is not. Melhole_server.capture, a script of a few hundred bytes for the page's <head>, records clicks, typing, key presses and submits with each target's value andchecked at that moment; the first hydration delivers them in order.

No DOM required

A server renderer in plain OCaml, natively or in Node.

Under meltea-ppx -server a view compiles to code that writes its HTML: the static markup of each tree is joined at compile time into as few strings as possible, and every dynamic part becomes one call into melhole.server, a library of plain OCaml that builds natively and under Melange. For the counter:

what -server emits
Melhole_server.markup w "<div class=\"counter\"><button>-</button><output";
Melhole_server.attribute w "class" (Some (if count < 0 then "negative" else ""));
Melhole_server.markup w ">";
Melhole_server.dynamic_text w (string_of_int count);
Melhole_server.markup w "</output><button>+</button><button";
Melhole_server.attribute w "disabled" (if count = 0 then Some "" else None);
Melhole_server.markup w ">reset</button></div>"
One Meltea module, page.mlx, built twice. meltea-ppx for the browser compiles the view into a template parsed once and cloned, a walk to the output and a refresh that writes the text only when count changed. meltea-ppx -server compiles the same view into code that writes strings with no DOM, natively or in Node: the static markup joined into a few strings at compile time and one call per dynamic part, the class and the count. The server writes HTML in React's format, the same bytes natively and under Melange, and the browser build hydrates it

meltea.server has the same Html, Cmd, Sub and Program modules as meltea, so one module with the model, update and view builds twice. Floats are written as JavaScript's String writes them, so a native server and the browser agree. The model goes into the page as data, so the browser starts from it without runninginit again.

Served hot

Hot reload keeps the model.

A model lives outside the code, so new code can take it over, as elm-watch does. meltea-ppx writes a shape next to update: a digest of the type declarations of its module. ~start:Program.hot finds the program running on the root and starts from its model when the shapes are the same, or from init when the types changed.

Hot reload in Meltea: a program runs on the root with its model and the shape of its module's types. After an edit, Vite runs main.mlx again, and Program.element with ~start:Program.hot finds the running program; when the new code's shape is the same, the model carries over and the new view and update show the old state; when the types changed, the program starts from init

Graceful

Transitions and View Transitions.

Html.transition ~name and Html.transition_group ~name animate what enters, leaves and moves with CSS classes, as Vue's<Transition> and <TransitionGroup>; a list inside a group is still the compiled keyed loop with selectors. The code lives in melhole's Transition module, so an app that does not animate does not carry it.

view.mlx
let view model =
  <ul className="todo-list">
    (Html.transition_group ~name:"fade" (Html.list (List.map row (visible model))))
  </ul>
style.css
.fade-enter-from, .fade-leave-to { opacity: 0; }
.fade-enter-active, .fade-leave-active, .fade-move { transition: all 200ms; }
Transitions, as Vue's Transition and TransitionGroup, with name fade. Enter: in the patch the new row b gets fade-enter-from and fade-enter-active, two frames later fade-enter-to replaces fade-enter-from while CSS animates, and when the duration is up no classes are left. Leave: the removed row b is put back where it was with fade-leave-from and fade-leave-active, two frames later fade-leave-to, and when the duration is up it leaves the DOM. Move, in a group: row c went first, so after the patch the DOM is c a b but a transform shows every moved row where it was; two frames later the rows get fade-move and the transforms are cleared, so they slide to their new places; at the end no classes are left

Cmd.view_transition, returned by an update, makes the next draw run inside document.startViewTransition, so the browser animates the whole page from the old state to the new one; where the API is missing, the draw is as usual.

Tested by laws

Property-based tests, not scenarios.

Accepted only with these holding:

  • Equivalence: a guarded refresh renders the same DOM and runs the same effects as an unguarded one.
  • Guard soundness: a hole is evaluated whenever one of its free variables changed.
  • Guard economy: a hole whose free variables are all unchanged is not evaluated.
  • Volatility: a hole that reads mutable state is evaluated on every render.
  • After any sequence of messages, the DOM is the view of fold update init messages.
  • Keyed loops reuse a row exactly when its item and projection are unchanged, and patching always shows what mounting from scratch shows.