@brust/runtime-dom
React-free browser runtime that binds a compiled client chunk (defineBehavior) to server-rendered HTML
through x-* attributes. This README is the output format the M1c client backend prints against; every
behaviour below is pinned by a test in test/. Zero dependencies, eval-free, bundle < 18 KB minified
(bun scripts/build.ts).
Public surface
import { signal, computed, effect, batch, untracked, defineBehavior, setChunkLoader, mount, unmount } from '@brust/runtime-dom'
- Signals (
test/signal.test.ts): writes areObject.is-deduped;computedis lazy and cached;effect(fn)runs now and on change, a returned function is a cleanup that runs before each re-run and on dispose;batchdefers effects to its end;untrackedreads without subscribing. defineBehavior(name, factory):factory({ el, props, effect, onCleanup, ref })returns the instance members (signals, computeds, functions, plain values).propsis a signal of the props object. Aninitmember, if a function, runs after the factory.setChunkLoader(load): called once per unknown behavior name; the loader is expected to calldefineBehavior(name, …). Default: none (hosts wait).mount(root = document.body): idempotent; mounts every[x-data]in document order (parents first) and starts aMutationObserverthat mounts added hosts and disposes removed ones (a removed-then-reinserted host is disposed, then mounted fresh).unmount(root)disposes instances underrootand stops the observer. A factory that throws is logged and its instance discarded; other hosts still mount.
Directive value grammar
value := path (":" bindings)?
path := ident ("." ident)* // looked up on the instance members; a signal/computed hop is unwrapped
bindings := ident ("," ident)* // loop-scope names (x-for item/index); the member must be a function and is called with them
Anything else (x + 1, fn()) is not parsed: a warning is logged once and the DOM is left as rendered. An unknown member behaves the same.
Attributes
Directives come only from compiler output: the compiler never prints an x-* attribute written in component JSX (warning reserved-attr), and the runtime does not bind inside dangerouslySetInnerHTML markup (x-html / x-raw) or a React island (<brust-island>).
x-data="name": one instance per host. Nested hosts are bound by their own instance, not the parent's walker.x-props='{"json":1}': seedsctx.props. Invalid JSON warns and seeds{}.x-props-bind="member[:binding]": on a child host, rebinds the child'spropssignal reactively to the nearest ancestor instance's member (an object, typically acomputed; function values pass through). Insidex-forthe row's scope supplies the bindings. If the parent is not mounted yet (chunk still loading) the child links up when the parent mounts. No ancestor: warns, keeps thex-propsseed.x-text="value": setstextContent;null/undefined→''.x-html="value": setsinnerHTML(raw markup, unescaped);null/undefined→''. Emitted only by the compiler, for adangerouslySetInnerHTMLwhose value changes after first paint; the author sanitises the markup (same contract as React). The first-paint check compares parsed markup, so equivalent spellings do not warn.x-show="value":style.display = ''when truthy,'none'otherwise.x-if="value": the element is replaced by a<!--x-if-->comment anchor; a clone is inserted after it while truthy and removed otherwise. Nestedx-datahosts in the clone mount/dispose with it. Server HTML omits the element when false.x-bind-<attr>="value":class→className;value→.value;disabled checked selected readonly required hidden open multiple→ property + attribute (present/absent); anything else:null/undefined/falseremoves the attribute, otherwisesetAttribute. Refused (warn once):on*andsrcdocattributes, and, inhref src action formaction poster data, any URL whose parsed scheme is nothttp(s):/mailto:/tel:(relative URLs are fine;java\tscript:is caught because the URL is parsed, not pattern-matched).x-on-<event>="member[:binding]": calls the member with the bound scope values first, then the event:pick(item, index, event).x-model="member": the member must be a writable signal (else warns). text-like:inputevent ↔.value; checkbox:change↔ boolean.checked; radio:changewrites.valuewhen checked, signal sets.checked; singleselect:change↔.value, re-applied whenever its options change.select[multiple]is unsupported (warns).x-for="item[, index] in source by keyFn":source := path (":" bindings)?is a member path to an array (or signal/computed of one); with bindings the member is a function called with the outer loop names, so a nested list reads the outer row, e.g.<tr x-for="row in rows by k1"><td x-for="c in cells:row by k2">;keyFnis a member function of the item. Server-rendered sibling rows carrying the samex-forare adopted in order on first run; with zero rows the server emits a<!--x-for-->comment followed by onehiddentemplate element (carryingx-for) which is cloned. Rows are reconciled by key (moves keep DOM identity); a reused row whose item/index changed has its binders re-run. Duplicate keys warn once and fall back to index identity. Elements withx-forare owned by the directive: their otherx-*attributes are bound on the rows, with scope{ item, index }.x-ref="name":ctx.ref('name').currentis the element after mount,nullafter dispose.
First-paint rule
Nothing re-renders on mount: server HTML is the initial state. A binder writes the DOM only when its effect re-runs after a change, or when the initial x-text / x-html value differs from the server text,
in which case it corrects it and warns once (first-paint mismatch …): a mismatch is a compiler bug the runtime must not hide. Elements created after mount (new rows, x-if clones) never trip this check.
Gates
bun test · bunx tsc --noEmit -p . (bun check needs Bun ≥ 1.4.3) · bun scripts/build.ts (fails if the bundle imports React).
React islands
The server paints a react-tier component as
<brust-island data-id="<componentId>" x-props='<json>'>…server HTML…</brust-island>.
The island's chunk registers its hydrate function without importing the runtime:
;(globalThis.__brustIslands ||= []).push(['<componentId>', (host, props) => hydrateRoot(host, <Comp {...props}/>)])
globalThis.__brustIslandReady?.()
The runtime hydrates every host once, when the browser is idle (requestIdleCallback, else
setTimeout(…, 1)), and sets data-hydrated="1" on success. Load order between the runtime
script and island chunks does not matter. Bad x-props JSON hydrates with {} and warns once;
a hydrate that throws is reported on console.error and the server HTML stays. This is the only
strategy in M2 (spec S12).
Store
The 0.1.x brustjs/store API on the same reactive core the directives use (one graph, even across module copies:
tracking state lives on globalThis[Symbol.for('brust.reactive.ctx')]).
@brust/runtime-dom/store(also the runtime entry, and@brust/core/store):signal computed effect batch untracked isSignal isComputed defineStoreand the typesSignal Computed Dispose StoreHandle Snapshot.defineStore(name, factory)returns a handle:store.field,store.snapshot(),store.serialize(),store.hydrate(state),store.subscribe(cb). First factory wins per name.- The browser keeps one instance per name in
globalThis[Symbol.for('brust.stores')]. The first access to a name seeds it from<script type="application/json" data-brust-store="NAME">(lazy, once). @brust/runtime-dom/store/react(also@brust/core/store/react):useStore(store)viauseSyncExternalStore. React is an optional peer, imported only by this subpath; the runtime bundle stays React-free.@brust/runtime-dom/store/internal: the server-resolver hook and serialization helpers the Bun worker uses. Not part of the public surface.
Feature modules (L33, F139)
The runtime is a core plus feature modules, so a page loads only what its components use and the client JS does not grow as the compiler learns more of React.
@brust/runtime-dom/core(src/core.ts): signals,defineBehavior/setChunkLoader,mount/unmount, the binder registry (BINDERS) and the internals features need (src/core-api.ts). It knows no directive by name.@brust/runtime-dom/features/<name>(src/features/*.ts):basics(x-text,x-show,x-bind-*,x-on-*,x-ref),if,for,model,html,island(React island hydration glue). A feature registers itself with the core when imported and imports the core only throughcore-api; in the browser buildcore-apiis the shared core file, so there is one signal graph and one binder registry per page.@brust/runtime-dom(src/index.ts) is core + every feature, for tests and non-split consumers. The browser build never uses it.
The rule for a new feature. The micro-directives nearly every interactive page uses ship together in basics. Anything else gets its OWN module when it is at least about 1 KB gzip OR used by a minority of pages: store, island, if/for/model/html as measured, and every future feature (context, portal, focus trap, ...). Do not add a feature to basics or the core to save a file; add it as a module the compiler requests only for routes that use it. The CI byte budget (core + basics) holds the line.