How it works
Melact only looks like React.
01
The DOM writes are the same. The tree copy disappears.
02
JSX becomes a template and a hole array.
- Template. The largest static subtree of every JSX site (lowercase tags, literal attributes, literal text) becomes one HTML string, parsed once into a
<template>and cloned withcloneNode(true)per mount. - Holes. Each dynamic attribute, handler, text or child is a hole: an element, or a parent and the node to insert before. The compiler emits the walk to them,
firstChildandnextSiblingonly, as Svelte does; it runs on the fresh clone before anything is inserted, so nothing shifts. - Refresh. For every template the compiler also emits a refresh function. It checks each local the view reads with one
===, and evaluates and writes only the holes that read a changed one.
The compiler knows the shape of every template, so it writes out how to reach each hole on a fresh clone, sharing every step. The runtime keeps no paths and no tag tables.
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 }, ...))03
A render is one === per local.
Nothing is built to be compared afterwards. For each hole the ppx computes its free variables, scope-aware, from the AST, and emits per template site a refresh function:
View.compiled template 2 (fun scope (click, label, rows_hole) ->
let count_changed = View.check scope 0 count in (* one === against the last count *)
let rows_changed = View.check scope 1 rows in
View.set_handler click dec; (* handlers: swapped, never guarded *)
(match count_changed with
| Unchanged -> ()
| Changed -> View.set_text label (string_of_int count)); (* evaluated only now *)
match rows_changed with
| Unchanged -> ()
| Changed -> View.set_content rows_hole (React.array (Array.map row rows)))View.check scope k value compares value with the k-th remembered dependency and stores it. A hole whose free variables are all physically unchanged is neither evaluated nor compared, and a nested JSX tree inside it is not built at all: lazy everywhere, without writing it.
| hole | written with |
|---|---|
| text | node.data = s |
| attribute | setAttribute / removeAttribute (None removes) |
| property | el[name] = v (value, checked, innerHTML) |
| style | the old properties cleared, the new ones set |
| handler | guarded by the locals it reads, like any hole; Melact replaces the closure on the element |
| ref | the new callback is called with the element |
| child | patched recursively: a template, a list, a component |
Never guarded: an expression that reads mutable state (!r, r.contents, r.current,Js.Date, Js.Math.random, an assignment, or a variable bound by for/while) runs on every render; an expression with no free variables is constant and evaluated on mount only.
04
Keyed lists move as little as possible.
Reconcile.plan takes the old and new keys and returns a step per new entry: Keep, Move or Create, plus the old entries to drop. It tries the cheap cases first: common ends (a swapped first and last row is two moves), an empty side, then a small hash table from old keys to positions and the longest increasing run. When the keys keep their order the loop skips the plan and patches rows in place.
React.array (Array.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
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 one row of 1,000 renders two.
A keyed row that moves is 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.
05
Components re-render where state changed.
Each [@react.component] gets an identity at module level. Its props are compared one by one with ===. A setter marks its instance dirty, and one microtask renders the dirty instances parents first. Layout effects run right after their instance's patch. Passive effects run after the whole flush, and cleanups go children before parents.
By the Rules of React every hook is named use…, so melact-ppx sees whether a component's body can call one. A component that calls none compiles to View.pure, a child that keeps the component's identity and props and nothing else. Only components that call hooks get an instance with hook slots, effects and a place in the scheduler.
06
Hooks are typed by their call site.
Hooks keep state in slots read in call order, as in React. Instead of casting what a slot holds, melact-ppx gives every hook call site its own key, a Type.Id created once at module level, and passes it as a hidden first argument:
(* you write *)
let count, setCount = React.useState (fun () -> 0)
(* melact-ppx emits *)
let __melhole_hook_2 = Melact_runtime.Hook.key ()
let count, setCount = (React.useState __melhole_hook_2) (fun () -> 0)A slot stores its value with the key that made it, so reading it back is a typed Type.Id.provably_equal, with no Obj.magic. A hook inside an if, a match, a loop or a callback raises the hook alert at build time, reported by the OCaml type checker itself; at run time a hook called out of order raises "hooks were called in a different order" instead of handing back the wrong value.
07
Events are delegated.
The 22 delegated types are the bubbling set Solid uses: click, input, keydown, focusin,pointer*, mouse*, touch* and the rest. Any other event gets one listener on its element that reads the same property, so replacing its handler is free too.
A handler prop compiles to fun event -> handler (React.Event.ofNative event): ofNative is the identity, and the handler's own type picks the family. Every accessor reads the native event, and delegation sets currentTarget to the element whose handler runs, as React does.
08
Inputs show the state.
A guard compares a value with the one written last, but the user changes value and checked too. So these two props are controlled holes: after an input or change event, and the redraw it causes, the hole writes its last value back if the DOM differs. One law types into such a field and checkbox under React 18 and under Melact and compares the page.
09
Server rendering and hydration, in React's format.
ReactDOMServer.renderToString writes HTML in the format of React's own renderToString, with no markers, so a Melact app can also render on a native OCaml server with server-reason-react.ReactDOM.Client.hydrateRoot takes that HTML 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.
Melhole_server.capture, a script for the page's head, records what the user does before the code arrives, with each field's value at that moment, and hydration delivers it in order.
10
Mutate in place, and nothing updates.
Change detection is physical equality, exactly as in Elm. Code that mutates an array or a record in place and passes the same reference back will not re-render that part. Idiomatic OCaml builds new values, so this rarely matters, but it is a rule rather than an optimisation.