brumate

Reactive graph

The compiler decides what ships to the browser from a graph of what reads what, which it builds once, when it compiles. This page first follows one component from its source to a click that updates the page. The sections after it describe each part in detail.

Step by step

This section follows one component from its source to a click that updates the page:

<script>
  import { $memo, $signal } from 'brumate/signals';

  const label = 'Clicks';
  let count = $signal(0);
  const double = $memo(count * 2);
</script>

<h1>{label}</h1>
<button onClick=(() => count++)>+</button>
<p>{count} / {double}</p>

1. The compiler builds the graph

Each declaration and each read in the markup is a node. An edge goes from a value to each node that reads it:

onClick (client root) --writes--> count
count  --> double
count  --> {count}
double --> {double}
label  --> {label}

The graph is built once, when the file compiles. The browser never builds it.

2. The compiler finds what can change

It starts at each client root and follows the edges:

Only the nodes that a root reaches get a slot in the page state and code in the browser.

3. The server renders HTML with markers

The server module renders the component as strings. Live values get slots, and the markup around them gets markers:

const $$s1 = $$ctx.scope(1, undefined, "Counter_24o7ge");
const label = 'Clicks';
let count = (0);
const double = (count * 2);
$$html += "<h1>" + … + "</h1><button b-b=\"" + $$s1 + ":0\" b-on=\"click=Counter_24o7ge#h4\">+</button><p><!--t" + $$s1 + ":1-->" + $$b.insert((count)) + "<!--/t--> / <!--t" + $$s1 + ":2-->" + $$b.insert((double)) + "<!--/t--></p>";
$$ctx.slots($$s1, [count, double], ["count", "double"]);

The HTML of the page:

<h1>Clicks</h1>
<button b-b="0:0" b-on="click=Counter_24o7ge#h4">+</button>
<p><!--t0:1-->0<!--/t--> / <!--t0:2-->0<!--/t--></p>

4. The server writes the state

The slots go into the page as a script:

<script b-state b-scopes b-next="1">…$bR(($R,$b)=>(({0:{k:1,m:"Counter_24o7ge",s:[0,0]}})))</script>

5. The browser waits

The page shows before any component code runs. The loader listens on document for the events that b-on names, and imports the core and the client entry of Counter_24o7ge at once. See Client modules.

6. A click runs the handler

The client entry exports the exports of each part:

export * from "./Counter.brm?brumate-client&part=h4";
export * from "./Counter.brm?brumate-client&part=update";
export const g = { 1: { 0: [[1, "m2"], [1, "u5"]], 1: [[1, "u6"]] } };

The core reads the state script and makes scope 0, with its slots [0, 0]. On the click, the loader walks from the target to the element with b-on, and reads b-b="0:0". The core calls the handler with that scope:

export const h4 = ($$s) => $$b.update($$s, 0, 1, false);

count++ became update(scope, slot 0, +1). Reads of slots become get(scope, slot) and writes become set(scope, slot, value).

7. The write runs the code that reads it

set stores the new value in slot 0, and queues each entry of g[1][0]. A queued update waits once, however many slots it reads change. Then the write runs what waits:

export function m2($$s) {
  $$b.memo($$s, 1, () => $$b.get($$s, 0) * 2);
}
export function u5($$s) {
  $$b.text($$s, 1, $$b.get($$s, 0));
}
export function u6($$s) {
  $$b.text($$s, 2, $$b.get($$s, 1));
}
  1. The memos first, in the order they are declared, each once. m2 computes double and writes it to slot 1. That write queues g[1][1], which is u6.
  2. Then the bindings, each once, in the order they were queued: u5 sets text 1 to 1, and u6 sets text 2 to 2.
  3. Then the effects, if any read the slots.

The memos and bindings of a component run before those of the components inside it, so a child gets every new prop and context value before its memos compute. The effects of a child run before those of its parent. All of it runs before set returns, so code that writes count and then reads double reads the new value.

The page shows 1 / 2. Nothing else ran: the heading, the button and the rest of the page were not touched.

Names of the exports

Pipeline

  1. Parse. Read the file into its scripts, styles and a markup tree with blocks.
  2. Scopes. Find the declarations of each scope: props, signals, memos, functions and block variables.
  3. Graph. Build the edges from the reads of each node, following local functions.
  4. Reachability. Start at each client root, and mark every node, function and template that it reaches.
  5. Checks. Report writes outside client roots, server functions that capture component values, and misused compile-time functions.
  6. Server code. Write a function that renders HTML strings with markers, and records the state of the reachable scopes.
  7. Client code. Write one part per client root, and the update code that the reachable nodes need.
  8. Render module. Write a render function for the browser, for parents whose blocks render the component again.

Nodes and edges

The graph is static. The runtime records no subscriptions.

When a client root writes a signal, the runtime looks up the nodes that read that slot and runs their updates in order. It needs the edges of that signal only, not the whole component.

What ships for each node

Node Ships when
$signal A client root writes it. Its value is in the page state.
$memo Something upstream can change in the browser. Its value is in the page state, so it does not compute again on resume.
$memo of a promise Something upstream can change. It runs again in the browser, and keeps its old value until the promise settles.
A memo that calls a server function that refresh names Always, so it can load again after a call.
Markup binding, such as {count} Its source can change in the browser.
#if, #switch, #key Its condition can change. Then the bodies of its branches ship.
#for, #repeat Its list or count can change. Then the body of an item ships.
Static text and elements Never. They are HTML only.

A signal that no client root writes is a constant after the server renders it, and is not sent.

Client roots

The compiler starts at each client root and follows what it reads, writes and calls:

Everything a root reaches ships. Everything else stays on the server.

Props across files

Compiled alone, a component treats every prop as live. analyzeProject finds the props that can actually change, across every .brm file of a project:

  1. Every prop starts out unchanged. Code outside .brm files renders components only on the server, so the props it passes are constants.
  2. Each file's graph is built with the props found so far. Its summary lists the components it renders, and the props it passes values that can change. A spread or computed prop that can change counts as every prop.
  3. Those props become live in the child. The steps repeat until nothing changes, because a parent's values can depend on its own props.

A prop that never changes needs no slot and no update code. A function passed as a prop never changes: it calls the parent's handler, which reads the current values. The Vite plugin runs the analysis when the build starts and when a .brm file changes.

Scopes

A scope holds the state of one instance:

Each scope that client code needs gets a number at render time, unique in the page. Elements with handlers name their scope in an attribute, so a handler finds its state. Page format describes the numbers and markers.

A scope also has a kind: its number in the compiled component, which selects the update code that applies to it. The page state records it as k.

Render modules

A component that a parent renders inside a block that changes needs its render function in the browser. The compiler writes it into a third module, App.brm?brumate-render.