Runtime Implementation Guide
For runtime implementers, this defines the compilation pipeline and the execution model.
10.1 Compilation Pipeline
Eight stages, each rejecting its own class of error before the next one runs:
| Stage | Produces | Rejects |
|---|---|---|
| project (selector) | Kumiki source — a text view of the CRDT graph store | — |
| parse | AST | — |
| name resolution | resolved AST | undef-ref, dangling references |
| type check | typed AST | type-mismatch, refinement violations |
| effect analysis | effect-annotated AST | cap-missing, direct calls |
| purity check | verified AST | reducer-side-effect, tile-mutation |
| lower | IR (Kumiki Intermediate Representation) | — |
| codegen | runtime artifacts | — |
Codegen writes four artifacts: the signal graph (JS or WASM), the effect dispatcher table, the episode logger, and the dev-tool trace UI. Errors come back as the structured errors of AI Editing.
10.2 IR
The intermediate representation is a Typed Dataflow Graph. A node is one of the following:
| Node kind | Role |
|---|---|
slot-read | read from a slot |
slot-write | write to a slot (reducer only) |
field-access, index | record/collection element access |
op, call | operation / function call (including fn-defined functions) |
fn-body | the body of the fn layer (pure computation, depends only on arguments) |
match | union branching |
if, when, for | control |
emit | effect emission |
event-source | the entry point of an event |
dom-node | DOM output node |
dom-bind | slot binding to a DOM node |
Edges are dependencies (dataflow).
10.2.1 IR Serialization Format
Debuggable as JSON; in production, CBOR (binary):
{
"version": "0.1",
"slots": [
{"name": "todos", "type": "...", "init": "...", "hash": "..."},
{"name": "draft", "type": "Text", "init": {"text": ""}, "hash": "..."}
],
"effects": [
{"name": "persist", "cap": "storage.write", "in": "...", "out": "Unit", "policy": "debounce:300"}
],
"reducers": [
{
"name": "addTodo",
"on": {"kind": "ui.submit", "selector": {"tile": "NewTodoForm"}},
"do": [
{"op": "let", "name": "id", "value": {"op": "call", "fn": "TodoId.fresh"}},
{"op": "slot-write", "lhs": {"slot": "todos", "key": {"var": "id"}}, "rhs": "..."},
{"op": "slot-write", "lhs": {"slot": "draft"}, "rhs": {"text": ""}},
{"op": "emit", "name": "persist", "args": [{"slot-read": "todos"}]}
]
}
],
"tiles": [
{
"name": "App",
"body": {"kind": "page", "children": [...]},
"deps": ["slot:todos", "slot:draft", "tile:TodoList", "fn:matchFilter"]
}
],
"fns": [
{
"name": "matchFilter",
"params": [{"name": "t", "type": "Todo"}, {"name": "f", "type": "Filter"}],
"ret": "Bool",
"body": {"op": "match", ...},
"hash": "..."
}
],
"app": {
"name": "TodoApp",
"caps": ["storage.read", "storage.write"],
"routes": {"/": "App", "/404": "NotFound"},
"init": [{"emit": "loadTodos", "args": []}],
"theme": "DefaultTheme"
}
}10.3 Signal Graph
The runtime generates a static signal graph from the IR. It is Solid-style fine-grained reactivity, but in Kumiki the graph structure is fully determined at compile time (no signal tracking at runtime).
10.3.1 Node Kinds
| Node | Input | Output |
|---|---|---|
SlotNode | – | slot value |
ComputeNode | values of upstream nodes | derived value |
BindNode | values of upstream nodes | DOM operation |
EventNode | DOM event | reducer call |
10.3.2 Update Algorithm
on reducer execution:
collect modified slots into Set<SlotId>
for each modified slot:
for each downstream ComputeNode/BindNode (precomputed):
mark dirty
process dirty queue in topological order:
recompute ComputeNode
apply BindNode → DOM mutationBecause dependencies are statically analyzed at compile time, the runtime tracking cost is 0.
10.3.3 batching
All slot changes within a single reducer execution are treated as one batch. Successive changes inside a for loop are also the same batch. After the batch is finalized, the signal graph is updated only once.
A batch commits all-or-nothing
Every write is checked against the target slot's refinement (Registered Refinement Predicates) — not just the value the slot ends the batch on. If any write is rejected, the whole reducer application is discarded: no slot is written, no emit is dispatched, no stop-timer runs, and no re-render is triggered.
Per-write rather than per-batch, because a batch is a map and only remembers the last value assigned to each slot. A for loop that leaves the slot's range and comes back would end on a legal value, and the illegal one it passed through — readable by every later statement, as below — would never be seen:
reducer drift on=ui.click(Btn)
do= for d in [1, 1, 1, 1, -1, -1, -1, -1] { count := count + d # reaches 4
mirror := mirror + count }This is not a panic — the app stays interactive, slots are untouched, and app.error does not fire — but it is never silent. The runtime reports
[kumiki] reducer "bump" was rejected: slot "count" cannot hold 4 (between(0, 3)). No slot was written and no effect was emitted.via console.error, the same channel and contract as an unhandled effect error (Standard Capabilities), so the verification tiers (smoke / runScenario / e2e) all flag it.
The rule exists because the alternative — skipping only the rejected slot and writing the rest — half-applies the reducer, and lets a value the slot never took escape into a sibling slot, since statements later in the body read the batch under construction:
type Small = nominal Int where between(0, 3)
slot count : Small = 0
slot mirror : Int = 0
reducer bump on=ui.click(Btn)
do= count := count + 1 # at the ceiling, this value is rejected
mirror := count # ...and must not be readable hereA reachable bound is the program's business, not the runtime's. Write the guard:
reducer bump on=ui.click(Btn)
do= if count < 3 then count := count + 1Two things a refinement does not gate:
- The declared default.
slot email : Text where email = ""starts out holding a value its own refinement rejects — that is what makeserror(field=email)show a message on a pristine form (Error Display). - Two-way
bind. Input rejection is per field and never reports (Handling of refinement) — a half-typed value is expected, not a defect. By default the slot keeps its previous value; understrict=falseit takes the new one and the form'svalidflag goes false instead.
The two combine into a trap. A slot whose declared default violates its own refinement cannot be reset to that default from a reducer: name := "" on a Text where nonempty slot is a write like any other, so it discards the batch. Either widen the slot's type and refine at the boundary, or model the empty case with Option:
slot name : Text where nonempty = "" # starts invalid — allowed
reducer clear on=ui.click(Btn) do= name := "" # rejected — not allowed10.3.4 Invariants of DOM Rendering
- null/undefined child nodes are skipped. A false branch like
when(false, X)passesnullas a child, butrenderTileignores it and renders only the siblings column/row/card/box/panel/stack/region/scroll/fieldsetare all<div>-based containers.stackis equivalent tocolumn(vertical stack)gridisdisplay: grid+ acolsprop yieldinggrid-template-columns: repeat(N, 1fr)(numeric) or a direct CSS value (string)divideris a standalone<hr>element (no children)- timer reducer fires via
setInterval, and stops viaclearIntervalon the app'sdispose
10.3.5 The bind path of input/textarea/select
You can bind to a nested lvalue path like bind=draft.title. The runtime:
- Display: follows
_live[root][...path]to read the initial value - Change: on an input event, uses
_setPathto immutably update the root slot - Focus restoration: identifies it by putting the full path string (
"draft.title") in thedata-kumiki-bindattribute
10.3.6 Dynamic theme switching
You can specify the theme by slot name, like app theme = themeName. The runtime:
- If
app.themeNamedoes not exist inapp.themes, reads_live[app.themeName]to resolve the theme name - Re-runs
applyThemeDefaultsat the beginning of eachrender()→ changes to the slot value are reflected in the body style
slot themeName : Text = "Light"
theme Light = { colors: {bg: "#fff", fg: "#222"}, ... }
theme Dark = { colors: {bg: "#222", fg: "#eee"}, ... }
reducer toggle on=ui.click(ThemeBtn) do= themeName := if themeName == "Light" then "Dark" else "Light"
app App ... theme = themeName # ← pass the slot name10.3.7 polymorphic collection methods
.filter / .map / .get-or, etc., are type-dispatched at runtime:
.filter(pred):Array.prototype.filterfor an Array,mapFilterfor an Object.map(fn): element map for an Array; for Option/Result, map over the contents of Some/Ok (mapOver).flat-map(fn): passes the Some/Ok of Option/Result to f, while None/Err passes through (flatMapOption).get-or(default)(Option) /.get-or(key, default)(Map): distinguished by the argument countm.entriesreturns[[k, v], ...], and the lambda of a subsequent list op is automatically destructured to$1=k, $2=v
10.3.8 Value Matching of select
select(value=v, options=[...]) decides the selected state of an option by a structural key:
- A variant is keyed by recursively serializing
_tag+ payload.Some(Backlog)andSome(InProgress)become different keys (with a flat_tagcomparison both would collide as"Some", so including the payload is essential) - You can use a "variant wrapped in a variant" such as
Option(Status)as an option value
10.3.9 Focus Restoration
Since #190 (identity-preserving reconciliation, §10.3.11) the runtime patches every same-kind tile in place instead of tearing it down on a data-prop change, so the still-mounted <input> / <textarea> / <select> retains browser focus naturally. The snapshot/restore layer described below is retained as a fallback for the wholesale-swap paths — reconcile bailout, panic recovery, and a keyed reorder that moves the focused element itself — where element identity is either lost or the element is physically moved and blurred by the browser. A focused child that a reorder leaves in place does not reach the fallback at all: the keyed pass places only the children that have to move (§10.3.10), so the cursor never leaves.
It maintains the focus and cursor position of an input/textarea being edited even after re-rendering:
- Elements with
bind=: re-identified by thedata-kumiki-bindattribute (a nested path is a full path string) - Elements with
id=: re-identified by id - Neither (e.g. a search box with only
value=): re-identified positionally by a DOM child-index path <select>is included in the snapshot set so the reorder / bailout paths can restore focus on the picker (its native open-dropdown state is unrecoverable here — that guarantee comes from the patch path in §10.3.11)
10.3.10 Stable tile identity
The compiler↔runtime tile-tree contract carries an optional per-tile identity field for keyed reconcile (introduced in #187 as the diff kernel, wired end to end in #188):
type TileNode = (/* … kind variants … */) & { readonly key?: string };Contract.
The field is additive and optional. A tile without
keyis a legalTileNode; old compiled output (no keys anywhere) still mounts on a new runtime, and a new compiler's keyed output still mounts on the old runtime (which simply ignores the field).The reconciler uses all-or-nothing keyed matching per parent: when every child at a given level carries a
key, the runtime pairs children across renders by key (survives reorder, insert, and remove without rebuilding the parent's subtree). When any child is missing akey, the reconciler falls back to the pre-#188 structural walk (position +kind+ data-prop equality; length change → rebuild).The structural walk is all-or-nothing per parent too. Its two remaining give-up conditions — a hole in the child list, and an old child with no element mapping — are decided for the whole list before any of it is applied. So a parent either reconciles every child or rebuilds without having touched one, and never leaves half a pass behind for the rebuild to discard. What this buys is truthful reporting:
binds-updated(§10.3.11) and the diagnostics (§10.3.12) describe the render that happened, and nonewMapentry is written for a subtree that was abandoned.The keyed pass answers the mapping question with a panic, not a diagnostic. An old child with no element mapping stops that pass too, and the same way for every child in the list: each old child's element is resolved before anything is reconciled, mounted, or removed, and a missing one throws — recorded as a
location: "reconcile"panic and rebuilt wholesale, which is audible to a host that never opted intoonDiagnostic. The measured placement check below deliberately steps over an unmapped child rather than declining on it (an unmapped child is a broken invariant, not a placement style), so this panic is the only thing that reports it — which is why it is raised the moment that measurement comes back clean, ahead of the declared-placement check that could otherwise decline first and carry the invariant, unheard, into the structural walk. What a child was about to be — reused or removed — does not decide whether it is heard either.A child list that is empty on exactly one side of a render is decided before keys are consulted at all. With nothing on the old side there is no pairing to attempt — every new child is a fresh mount, every old child departs — so keys have nothing to match and the rule above has nothing to say. What is left is where the new children go, which only the parent's renderer knows, so the runtime re-enters that renderer for a fresh interior and moves it into the mounted element. The parent keeps its own element (and the browser-owned state on it) and neither
child-count-changenorwrapped-childrenis reported, for a keyed and an unkeyed list alike. Two consequences worth knowing: a wrapping renderer stays correct here, because the renderer itself did the placing; and the renderer's non-child interior (adetails'<summary>, a surface's content wrapper and title) is rebuilt along with the children. This is the "empty state → first item" transition — an empty todo list, a result set before the first query, an empty cart — and it only reaches this path when the container's children are only the loop. Aforsharing a parent with static siblings never empties that parent's child list.Keyed matching additionally requires that the parent's renderer place its children directly under its own element. Matching by key is only half the work: the reconciler then moves survivors, drops departures, and mounts newcomers by addressing the parent element, which it can only do for slots the parent element holds. Two independent checks answer that, because they answer different questions:
- Where the mounted children are is measured from the DOM. A child mounted below a renderer-owned wrapper puts the pass out of reach; the reconciler declines and reports
wrapped-children(§10.3.12). - Where a newcomer would go is read from the renderer's declared placement, because nothing can measure a slot that does not exist yet.
overlayplaces its first child directly and wraps the rest, so a one-layer overlay measures as fully placeable right up until it grows — and the keyed pass would then append the second layer bare. When a keyed list under such a parent gains a member the reconciler declines and reportsunplaceable-insert(§10.3.12). A same-membership render has nothing to place and still takes the keyed path.
Of the current built-ins,
overlaywraps every child after the first in a positioning layer, andmodal/drawer/popoverwrap all of theirs in a content div. A host renderer opts out the same way by appending children to anything other than the element it returns; the declared set covers built-ins only, so a host renderer is taken at its word and must place its children directly. Put a reorderable or growing keyed list under a plain container (column/row/list), and keep host renderers' children directly under their root element.- Where the mounted children are is measured from the DOM. A child mounted below a renderer-owned wrapper puts the pass out of reach; the reconciler declines and reports
The diagnostics can both fire for one parent in the same render: a wrapping parent whose keyed child list also changed length reports the placement decline (
wrapped-childrenorunplaceable-insert— the keys went unused) and thenchild-count-change(the structural walk rebuilt it anyway). They name different facts, and fixing the first is what makes the second stop mattering.A keyed reorder moves the minimum. Matching by key says which old element belongs to which new child; it does not by itself say how many of them have to be touched to produce the new sequence. The reconciler leaves the survivors whose old positions already ascend exactly where they are — the longest such run, so the fewest children are left over — and inserts everything else against its successor. Concretely: a render that does not change the order performs no DOM placement at all, a single item moving costs one, and the worst case (no two children keeping their relative order) costs N−1. This is a correctness guarantee, not only a throughput one. Re-attaching a node blurs it, so a child that is moved for no reason loses focus, the caret, an open
<select>dropdown and an in-flight IME composition — the state keyed matching exists to keep. A focused child that a reorder leaves alone therefore keeps it natively, without the snapshot/restore fallback of §10.3.9. Every placement is made before an existing node: the child that follows it in the new order, or — for the last child, which has no successor — the sibling that follows the whole mounted child list (the end of the parent when there is none). So a renderer that keeps content of its own after its children keeps it there.
What the compiler emits.
- Author-supplied
{key: <expr>}on a tile-call's props block is lifted to the emittedTileNode's top-levelkeyfield. The value is coerced to a string via_s.show(...). It does not also flow intoprops.el. - Inside
foriteration, tile calls that do not declare their own{key: ...}receive an implicit key derived from the loop variable —_s.show(<loopVar>). Explicit keys always win. Nestedforloops overwrite the enclosing implicit key with the inner loop's binding, so a tile call underfor i in innergets_s.show(i)regardless of any outerfor o in outer. - User-tile boundaries do not propagate the enclosing implicit key into the tile's body — the
_wkwrap sits on the outer boundary node, and the body composes its own identity if it iterates internally. TileWhen/TileIf/TileMatchare transparent: the implicit key flows through the branch that emits the tile.
Runtime consumption. The reconciler in packages/runtime/src/core.ts reads oldNode.key and newNode.key at the child-list level. key is included in TILE_SKIP_TOP so a key change alone does not trigger replaceWithFreshTile on the parent — key drives which old child pairs with which new child, not whether the tile itself is rebuilt.
Migration. Runtime and compiler ship the key contract as a matched pair (both in the same minor version bump). Each side degrades gracefully alone, but the reorder-stable-reuse guarantees (survives <select> value, <input> focus and caret, and event listeners across insert/remove/reorder) require both.
10.3.11 Identity-preserving reconciliation (#190)
Keyed diff (§10.3.10) preserves DOM identity for tiles whose data props did not change. #190 extends the guarantee to tiles whose data props did change but which the runtime can update in place, so browser-owned state (<select> open dropdown, <video> playback position, <details> open, contenteditable caret and IME composition) survives a re-render mid- interaction.
Contract. Every tile-renderer module exports two maps: TileRenderers (create) and TilePatchers (update):
export type TilePatcher<K> = (
el: HTMLElement,
oldNode: TileNode & { kind: K },
newNode: TileNode & { kind: K },
ctx: TileCtx,
) => void;When reconcile sees oldNode.kind === newNode.kind and any own data prop differs, it looks up a patcher for the kind. If one is registered, the mounted element is mutated in place and the reconcile then walks the children as usual (a container tile may have both attribute and child changes in the same render). Without a patcher, reconcile falls back to the pre-#190 subtree rebuild — patchers remain incrementally adoptable, not an all-or-nothing runtime rewrite.
Handler-slot pattern. Reused elements must not multiply-register listeners (the runtime uses addEventListener and holds no listener refs), and a listener registered at create time closes over the create-time node. For controls whose bind / onChange / onClose / to may change between renders, each renderer stores the current handlers in a per-element WeakMap<HTMLElement, Handlers> slot. Native listeners registered by create dispatch through the slot; patch overwrites the slot with the new node's handlers. This applies to input, textarea, select, check, radio, switch, slider, editable, form, button, link, modal, drawer, and popover. details is intentionally excluded: it carries no Kumiki-level dynamic handler and browser-native toggle semantics do not need re-routing. The universal handlers applyUiEventHandlers lifts on every tile (onKeyDown / onMouseEnter / onFocus / onBlur) share a single UI_HANDLER_STATE slot that the reconcile refreshes on every patch, so a closure change reaches the next event regardless of tile kind. Their native listeners are registered on the render that first puts one of the four in the slot — at create time for a tile that carries one, on the patch for a conditional branch that introduces one later. A tile that never carries one registers nothing.
Value-write guards. Text inputs (input, textarea, editable) skip the .value / .textContent assignment when it already matches newNode.value / newNode.text (so typing does not reset the caret) and skip it entirely while an IME composition is in flight (compositionstart → compositionend) so the browser's JP/CN/KR candidate window is not dismissed mid-glyph. Slider skips when activeElement === el (mid-drag guard). The reducer-driven "clear the field" case is picked up by the outer snapshot layer (§10.3.9), which captures selection ranges before the patch runs.
Consequences for binds-updated (episode log). The patch path pushes tileTouchedId(newNode) onto the reconcile-touched set exactly like a subtree rebuild does, so the causal chain "slot X changed → tiles/binds A, B patched" continues to land on signal-update.binds-updated (#189) regardless of whether reconcile ended up rebuilding or patching.
Only what survived the render is named. A render that gave up on a parent and rebuilt it lists that parent and nothing under it — the walk decides the parent's fate before applying any of its children (§10.3.10), so there is no partly-applied pass whose identifiers could outlive the work they describe.
10.3.12 Reconcile diagnostics
Keyed diff (§10.3.10) and in-place patching (§10.3.11) both degrade silently: when the reconciler cannot preserve a subtree's identity it rebuilds it, or drops to a weaker matching strategy — always correct, never throws. An app can therefore be re-mounting its whole tree on every render and look perfectly healthy from the outside. MountOptions.onDiagnostic opts into seeing those decisions.
mount(app, root, { onDiagnostic: (d) => console.warn(d) });Contract. Omitting the sink is the default and costs one optional call per fallback; nothing is computed and nothing is emitted. There is no build-time flag — a production mount is silent because it does not opt in, not because a bundler stripped anything.
Observing never changes what is observed. A sink that throws is swallowed, and so is the scan that feeds it: reading a host tile's fields runs Object.keys, property getters and Object.getPrototypeOf against values the host owns, and the equality kernel short-circuits at the first difference, so a Proxy trap or an accessor may throw where the kernel never reached. All of this happens inside the reconcile bailout, so an escaping throw would be recorded as a location: "reconcile" panic and rebuild the whole tree — the exact identity loss this channel reports, caused by the reporting. A tile whose scan throws is left undiagnosed and rendered as it would have been without a sink; the sink's own failures are the host's to notice.
Reported fallbacks.
Every diagnostic names the tile that lost its identity guarantee (tileKind, the authored tile name when there is one, and the same id the episode log uses). Each reason additionally carries the evidence only it has, so a report is actionable without re-deriving it from the source:
| reason | evidence | what was lost |
|---|---|---|
no-patcher | — | The tile's data props changed and no patcher is registered for its kind, so the subtree was rebuilt — discarding focus, caret, <select> open state, <video> playback on that element. |
child-count-change | oldCount, newCount | An unkeyed sibling list changed length, so the parent was rebuilt. Giving every child a key (§10.3.10) lifts this: the keyed matcher then survives insert / remove / reorder without touching the untouched siblings. Never fires when one side of the change is empty — that boundary keeps the parent regardless of keys (§10.3.10). |
child-hole | index | A children array had an empty slot. Kumiki codegen flattens nils away, so this only reaches the walker from a host-built tile tree. Reported before any sibling is applied (§10.3.10), so the parent's rebuild is the only thing this render did. |
child-unmapped | index, childKind | An old child had no entry in the node → element map, meaning its parent's renderer built it without going through ctx.render. The walker cannot reuse what it cannot find, so that parent rebuilds on every render. Reported before any sibling is applied, same as child-hole. |
wrapped-children | index, childKind | Every child carried a key, but the parent's renderer wraps its children instead of placing them directly (§10.3.10), so the keyed matcher stood down and the positional walk ran. Rebuilds nothing on its own: what is lost is reorder-stable element identity, not the subtree. |
unplaceable-insert | index, childKind | Every child carried a key and every mounted one sits directly under the parent — but the new child at index is a newcomer under a renderer that does not place every child directly (§10.3.10), so there is no slot the keyed matcher may mount it into. A short list measures as placeable right up until it grows, which is why this is read from the renderer rather than from the DOM. Rebuilds nothing on its own; the positional walk that follows reports child-count-change if the length changed. |
Two rebuild paths are deliberately not reported. A kind change means a different thing occupies that position, so there is no identity to preserve. And a patcher that declines in place via PatchRequiresRebuild (§10.3.11) is a normal, expected outcome that the sentinel exists to keep out of the log.
A rebuilt parent reports for itself alone. The four reasons that rebuild a parent — no-patcher, child-count-change, child-hole, child-unmapped — are all decided before any of its children are reconciled (§10.3.10), so nothing below it is examined and nothing below it is reported: no reconcile-fallback and no never-equal-prop from that subtree, on that render. Same rule binds-updated follows (§10.3.11) — what the render discarded is not described. (wrapped-children and unplaceable-insert rebuild nothing, so the walk that follows them reports normally.)
Know the cost. A no-patcher is a configuration fact — that kind has no patcher registered, and will not on the next render either — so a child sitting under a parent that rebuilds every render stays invisible for as long as that lasts. The parent's own reason is the one to read first: fix the rebuild, and the subtree's diagnostics start arriving.
Handlers compare by identity. The kernel once treated any two functions as equal, because codegen minted a fresh closure every render and comparing them by identity would have marked every tile as changed forever. That was only safe while both closures dispatched to the same reducer: a conditional swapping two inline tiles that differ only in their handler compared equal, was reused untouched, and kept dispatching to the reducer it was created with — silently.
Codegen therefore memoises one closure per reducer list, per app instance (§10.3.13), and the kernel compares functions like any other value. An unchanged handler is the same reference and still takes the reuse path; a changed one is a difference, so the patcher runs and refreshes the per-element handler slots of §10.3.11.
A host renderer that still mints a handler inline on every render never compares equal to itself. That is reported — see function-identity below — rather than silently reused.
Props that can never compare equal. A tile whose data props compare unequal on every render, with a patcher registered for its kind, is the identity-preserving happy path as far as the walker is concerned: the patcher runs, the element survives, nothing degraded — so no fallback is reported and the app looks perfectly healthy while re-applying the same attributes forever. Unequal decisions are read for a value that could not have compared equal however identical the two renders were, and reported as never-equal-prop:
cause | the rule it runs into |
|---|---|
non-plain-object | A Date, Map, Set, RegExp, DOM node, class instance, or a cross-realm object. Their state lives outside their own enumerable keys, so only === can make two of them equal (§10.3.13) — which a value rebuilt each render never is. |
nan | NaN, which is not equal to itself by definition (§10.3.13). |
function-identity | A function whose identity changed. The scan keeps no history, so this fires on any two distinct closures — including the one-off swap a conditional makes between two memoised handlers. What it always means is that the pair could not compare equal; whether it repeats depends on whether the host rebuilds the handler per render, which is the case worth fixing by memoising it. |
The scope comes from MountOptions.hostTileKinds, which the package-entry mount derives from the tiles override map — including overrides of built-in kinds, since a host renderer put in a built-in's place brings the host's own prop conventions with it. A host calling mountCore with its own renderers passes the set directly. The scan reads the node's own fields and one level into props, the convention every built-in renderer follows (props.onClick, props.onChange); a host tile that buries its handlers deeper (props.handlers.onClick) is not inspected. No cause can come out of codegen, so a report always names a host-built tree. It fires whether or not a patcher is registered: with one it is the only signal that the tile churns, without one the rebuild is already reported as no-patcher and this names the field that reason cannot. Both are emitted, cause before consequence.
A value only reports once both sides are of the same never-equal shape — a plain bag that becomes a Date, or a number that becomes NaN, is an ordinary change on the render it happens and is reported on the next one. The same instance handed over twice compares equal through === and is never reported.
The two diagnostic kinds carry different cost, not different correctness. A reconcile-fallback costs performance and browser-owned element state; a never-equal-prop costs a diff and a patch on every render. kumiki dev routes both to console.warn.
Relationship to the episode log. An episode is the author-facing causal record of what the app did, and already reports that a subtree was re-rendered through signal-update.binds-updated (§10.3.11). A diagnostic reports why the runtime chose to rebuild rather than reuse — framework internals, useful when tuning an app or a host integration, noise inside a behavioural trace. They are complementary channels, which is why this is not a new episode step kind.
Consumers. smoke() collects them into a non-fatal SmokeReport.diagnostics, each wrapped with the same phase / trigger a SmokeIssue carries so the provoking interaction is identifiable (rebuilding more than necessary is not a failure; SmokeOptions.diagnosticsAsIssues — kumiki smoke --diagnostics-as-issues — opts into treating it as one). runScenario attaches them to the step whose action triggered the re-render, and kumiki run prints them under that step. kumiki smoke prints a per-reason summary.
10.3.13 Data-prop equality
§10.3.10 and §10.3.11 both turn on "the tile's data props did not change". This is the rule that decides it. It is the runtime's single reuse predicate: a false positive keeps a stale element mounted with no symptom, a false negative rebuilds a subtree that did not change.
Scope. The comparison walks a TileNode's own fields except kind, children, and key. kind is the discriminant and is settled before the predicate runs (§10.3.12). children is the walker's own business — each child is reconciled on its own, so a changed grandchild must not rebuild every ancestor. key is identity metadata consumed by the keyed child matcher (§10.3.10); by the time a pair reaches the predicate they have already been established as the same instance.
Values. Two values are equal when:
- they are the same value (
===), or - both are arrays of the same length whose elements are pairwise equal, or
- both are plain data bags — prototype
Object.prototypeornull— whose union of own keys maps to pairwise-equal values.
Consequences worth stating outright:
An absent key and an explicit
undefinedare equal.{a: 1}and{a: 1, b: undefined}are the same tile; codegen omits optional fields, and a conditional spread produces either form for the same authored tile.nullis notundefined,0is notfalse,""is not0. Comparison is===-based, never==.NaNis not equal toNaN. A tile carrying one rebuilds on every render.NaNin a prop means a computation already failed; rebuilding is the safe side, and the churn is a visible symptom rather than a frozen tile — and on a host tile thenever-equal-propdiagnostic in §10.3.12 names the field, since with a patcher registered the churn is otherwise invisible.A non-plain object is never equal to anything but itself.
Date,Map,Set,RegExp, DOM nodes, and class instances hold their state outside their own enumerable keys, so key-wise comparison would report a changed value as unchanged. Kumiki codegen emits only plain data, so this is unreachable from a.kumikisource; a host renderer handed one gets a rebuild rather than silent reuse. The same instance passed twice still compares equal through===. "Plain" is decided by prototype identity, which is realm-local: an object built in another realm (an<iframe>, avmcontext) is treated as exotic and rebuilds — the safe direction, and the reason this is not relaxed into atoString-tag check. A host handing one to the same tile every render pays for a diff it can never win; that is whatnever-equal-prop(§10.3.12) reports.A function is equal only to itself. Handlers are values like any other here. Codegen emits every handler through a per-instance memo keyed by the reducer list it dispatches, so the same wiring yields the same reference on every render and an unchanged tile still takes the reuse path — while a handler that genuinely changed, as when a conditional swaps two inline tiles that differ only in
onClick, is a difference the walker acts on. A host renderer that mints a handler inline on every render never compares equal to itself; it pays a patch (or, with no patcher, a rebuild) every render, whichnever-equal-prop(§10.3.12) reports asfunction-identity.
Cycles are outside the contract. Two structurally cyclic but distinct bags recurse until the stack runs out; the throw lands in the reconcile bailout, which rebuilds the tree wholesale and records a location: "reconcile" panic. Unsupported, but contained and visible — codegen cannot produce a cycle, and a visited set would cost every render to defend against one.
10.4 Effect Dispatcher
Responsible for executing the effects emitted from a reducer.
10.4.1 Acceptance
When a reducer completes, the set of emitted effects is passed to the dispatcher:
[{name: "persist", args: {...}, key: <derived>, policy: "debounce:300"}, ...]10.4.2 capability check
Checks whether each effect's cap is included in app.caps. A violation is not executed and is notified to app.error.
10.4.3 policy Handling
| policy | Implementation |
|---|---|
| parallel (default) | immediate dispatch |
latest | cancel the running effect of the same name, start a new one |
latest-per-key(k) | the same, per (effect-name, key) |
queue | execute sequentially in FIFO |
debounce(d) | wait d ms on calls of the same name and execute only the last |
throttle(d) | discard additional calls of the same name within d ms |
once | discard calls with the same in |
10.4.4 retry
When retry=... is specified, retry on an Err result that is a 5xx/network error. Exponential backoff adds ±20% jitter.
10.4.5 Delivery of Results
On effect completion, the result is notified to the runtime as an <effect-name>.ok($value, $key) / <effect-name>.err($error, $key) event. The matching reducer is executed.
10.4.6 Implementation of Standard Capabilities
| capability | Implementation |
|---|---|
http.* | fetch() |
storage.* | window.localStorage |
session.* | window.sessionStorage |
indexed.* | IndexedDB API |
nav.* | History API |
clipboard.* | Clipboard API |
notification.show | built-in tile (toast/confirm/modal) |
analytics.* | hook (implementation injected via app.analytics at app startup) |
log.* | console.* + optional hook |
crypto.* | Web Crypto API |
media.* | MediaDevices API |
geo.* | Geolocation API |
socket.* | WebSocket |
10.5 Episode Loop
The causal sequence derived from a single trigger is recorded as one episode.
10.5.1 Structure of an episode
{
"id": "ep_01JC...",
"trigger": {"kind": "ui.click", "target": "AddBtn", "payload": {...}, "ts": ...},
"steps": [
{"kind": "reducer", "name": "addTodo", "slot-diffs": [...], "emits": ["persist"], "ts": ...},
{"kind": "effect-start", "name": "persist", "args": {...}, "ts": ...},
{"kind": "effect-end", "name": "persist", "result": "ok", "value": "()", "ts": ...},
{"kind": "signal-update", "dirty-slots": ["todos"], "binds-updated": ["TodoList.row.0", ...], "ts": ...},
{
"kind": "panic",
"message": "boom",
"location": "reducer \"addTodo\"",
"stack": "Error: boom\n at ...",
"cause": [{"message": "root", "stack": "..."}],
"category": "reducer",
"ts": ...
}
],
"status": "completed" | "panic" | "cancelled" | "ongoing"
}A panic step additionally carries:
stack: theError.stackof the caught throw, when available.cause: the flattenedError.causechain, nearest cause first, root-most last, each link{message, stack?}. Capped at 8 links; self-cycles are broken.category: one ofreducer/effect/capability/tile-render/hydrate/unknown— where in the runtime the throw was caught. Emitted by the reducer / tile-render / hydrate catch sites today;effect/capability/unknownare reserved values so consumers can exhaustive-switch as future callsites are wired in — a capability provider throw currently surfaces as aneffect-endwithresult: "err", NOT as apanicstep.
stack, cause, and category are optional for forward compatibility: episode logs written by older runtimes carry only message / location, and MUST continue to parse and replay unchanged. Readers that don't recognise a field MUST ignore it. stack / cause are dev-tooling — the runtime never splats them into user reducer $event payloads; only message, location, and category reach app.error / route.error(<pattern>).
Reserved trigger.kind values: ui.click, ui.submit, ui.change, ui.input, lifecycle, route.enter, timer, effect.ok, effect.err, init, and ssr.hydrate (the SSR bootstrap, see §10.6.2). ssr.hydrate is asymmetric: the server constructs it during renderToString, ships it to the client as JSON, and the client logger ingests it directly — the client MUST NOT open an ssr.hydrate episode itself via the usual beginTrigger path.
Deferred-policy effect attribution. Effects emitted under policy=debounce(d) complete their setTimeout AFTER the triggering reducer's episode has nominally ended. The dispatcher therefore claims the effect-start step (and its episode-token) at dispatch time, not at the eventual launch, so the deferred effect-end and its .ok / .err reducer chain stay on the originating episode — the causal chain stays whole. A debounce timer that is replaced before it fires records an effect-cancel step (with targetId = <effect-name>) on its originating episode, which then commits as status="completed" with no effect-end. policy=throttle(d) launches synchronously on the leading edge (so the standard sync path attaches effect-start); subsequent dispatches within the window are silently suppressed — the originating reducer's emits list shows the suppressed effect name, but no effect-start follows.
10.5.1.1 Bootstrap episode (SSR hydration)
The server-side renderToString pass collapses the entire app.init causal chain into a single bootstrap episode and ships it inside the SSR snapshot (§10.6.1). Its shape is just an Episode (above) with two additional contracts:
trigger.kind = "ssr.hydrate",trigger.target = <initial-route-path>.stepsmirror the real server-side execution: eachapp.initemit produces a pairedeffect-start/effect-end, the matching{effect, outcome}reducer adds areducerstep (withvolatile-filteredslot-diffs), and a finalsignal-updatelists the non-volatileslots that changed. There is no synthesisedssr.bootstrapstep — the chain stays in the canonical episode grammar so replay tooling works unchanged.
Example:
{
"id": "ep_01JC...",
"trigger": {"kind": "ssr.hydrate", "target": "/", "ts": 1717900000000},
"steps": [
{"kind": "effect-start", "name": "loadUser", "args": {"url": "/api/me"}, "ts": 1717900000001},
{"kind": "effect-end", "name": "loadUser", "result": "ok", "value": {"id": "u_1"}, "ts": 1717900000045},
{"kind": "reducer", "name": "loadUser.ok", "slot-diffs": [{"name": "user", "before": null, "after": {"id": "u_1"}}], "emits": [], "ts": 1717900000046},
{"kind": "signal-update", "dirty-slots": ["user"], "binds-updated": [], "ts": 1717900000047}
],
"status": "completed"
}10.5.2 episode store
- The most recent N in memory (default 100)
- The most recent M in localStorage (default 20, size limit 5MB)
- During development, write to a file with
--episode-log /path/to/log.jsonl
The bootstrap episode (trigger.kind = "ssr.hydrate") is stored on the same path as any other episode: appended to the in-memory ring and (when localStorage mirroring is enabled) persisted on the same eviction policy. No special pinning — once enough later episodes accrue, the bootstrap eventually falls off the tail like any FIFO entry. Hydration runs persistLocalStorage() as part of the ingest so the mirror reflects the bootstrap immediately (an AC for §10.6.2 verification).
10.5.3 replay
kumiki replay <input.kumiki> --from-log <log.jsonl> # replay every episode in the log
kumiki replay <input.kumiki> --from-log <log> <episode-id> # replay one episode by id
kumiki replay <input.kumiki> --from-log <log> \ # repeatable; each entry follows
--mock 'loadUser: from-log' --mock 'persist: ignore' # §8.6's mock grammar
kumiki replay <input.kumiki> --from-log <log> --until-step 5 # stop after the 5th observed step--from-logis currently required. The barekumiki replay <episode-id>form (against the runtime's in-memory store, §10.5.2) needs a long-lived dev-server context and is out of scope for the CLI verb.--mock '<effect>: <spec>'is repeatable.<spec>follows the same grammar asepisode-test.mocks(§8.6):from-log|ignore|ok(<json>)|err(<json>). The payload is parsed as JSON, sook({"id":"u1"})works on the command line as-is.- An effect with no
--mockentry is dropped (matchesepisode-test's default). --until-step Ncounts each observed step (reducer / effect-start / effect-end / signal-update / panic) as one, globally across all replayed episodes, 1-indexed. The slots at the moment of interruption are printed.- Replay synthesises a
signal-updateevent per episode from the slots a reducer actually changed; recordedsignal-updateentries in the input log are not re-played verbatim (they're advisory provenance, not driving input). - A
panicstep (§10.5.1) is rendered as a multi-line block: header[panic:<category>] <message> <location>followed by indented.stacklines and, for eachcauselink, aCaused by: <message>line with the link's own indented stack. The replay executor derivescategoryfor every observed panic (an older episode log missing the field still gets a category assigned when its reducer re-throws during replay), so the multi-line form is what the CLI normally shows. The formatter also accepts a minimal{kind, message}panic event and prints it as the single-line[panic] <message>fallback — this only surfaces if a caller feedsformatEventa hand-authored event outside the normal replay pipeline. - Exit code is
0on a clean run,1if any episode panicked or surfaced an unhandled effect error.
10.6 SSR / Edge / Client Split
10.6.1 SSR
- HTML generation renders the tile of the initial route once on the server-side via
renderToString(app, options)from@kumikijs/runtime. - The slot initial values may include the results of the effects emitted in
app.init(not re-executed at hydration). - The served HTML carries the same inline style the client would paint: a tile's element, its kind's own layout (a
column's flex axis, acard's box metrics, agrid's tracks), and the declarations its props map to (gap/align/justify/pad/max-w/bg/radius/style, and a text tile'scolor/size/weight/strike). Without them the first paint lays every container out as a block and the page reflows the moment hydration finishes, which is the shift SSR exists to remove.- A responsive value (
{base, sm, md, …}) collapses to itsbase: a breakpoint is a question about the viewport and the server has none. - What is not served is what an inline declaration cannot carry:
transition, thehover:/focus:/active:blocks and themotionlayer are classes backed by injected CSS, and the client adds them on hydration. Nor are event handlers, focus state, or the resolvediconSVG — the placeholder is the same element the renderer writes, under the same attribute, but it is empty and unsized until the client resolves the path, so the icon's arrival does move what follows it. Nor, for the same reason, is anything the theme stylesheet paints: acard's surface, border and shadow, and thebutton/input/linkrings, are rules the client injects at mount, and the server serves the box without them.
- A responsive value (
- Response bundle composition:
- HTML (the result of initial tile rendering)
- JSON (the snapshot envelope, structured as below)
- JS (signal graph + effect dispatcher)
The snapshot envelope is versioned and self-describing:
{
"kumiki": 1,
"route": "/posts/abc",
"slots": { "<slot-name>": <value>, ... },
"bootstrap": { /* Episode (§10.5.1), trigger.kind = "ssr.hydrate" */ },
"renderedAt": 1717900000000
}kumikiis the snapshot schema version (current =1). A client whose runtime expects a different version MUST discard the snapshot and fall back to a full CSR boot — this keeps server / client out-of-sync deploys safe.slotsexcludes every slot whose declaration carries thevolatilemodifier (§1.4.1 modifiers table): the runtime treats SSR snapshotting as the same serialisation boundary as persistence, sovolatileslots are never written to the wire.bootstrap.steps[].slot-diffsuse the samevolatilefilter, so a volatile slot never appears in either theslotsmap or the bootstrap diff.bootstrap.steps[0..]carry the realapp.initcausal chain (effect-start / effect-end / reducer / signal-update).beforevalues insideslot-diffsare the slot's declared default at the start of the SSR pass;afteris the post-init value mirrored inslots.
10.6.2 Hydration
Hydration runs in a strict, synchronous order. If any step throws, the client discards the snapshot and falls back to a full CSR boot:
- Snapshot load + version check. Parse the snapshot envelope (e.g. from a
<script type="application/json" id="kumiki-state">block). Ifkumiki !== 1, skip steps 2–4 and run a cold CSR boot. - Slot overlay. Write each entry of
snapshot.slotsintoapp.liveBEFORE wiring routing, effects, orapp.start. Volatile slots stay at their declared default — they were never in the snapshot. - Bootstrap ingest. Inject
snapshot.bootstrapinto the episode logger via the dedicatedingestBootstrappath. This is the only legal way for a client to surface anssr.hydrateepisode;beginTriggeris forbidden for that kind. After this step,app.episodes()[0]is the SSR causal chain. - Event handler attach. Attach the runtime's event delegation to the SSR HTML so user input starts dispatching client-side reducers.
app.startfires. The lifecycle reducer fires normally (it never ran on the server).app.initdoes NOT re-fire — the snapshot already carries its results.route.enterfor the current pattern fires afterapp.start, exactly as in a CSR boot.
The observed order on the client is therefore app.episodes() = [bootstrap, app.start episode, route.enter episode?, user-driven episodes...]. The hydration boundary preserves episode continuity — no ssr.hydrate-to-app.start gap and no duplicate init effects.
10.6.3 Edge
SSR on Cloudflare Workers / Vercel Edge, etc.:
- Part of the effect dispatcher (
http.*,storage.kv.*) runs on the edge side - The rest is deferred to the client
- Bundle size budget: runtime 30KB + app code (target)
10.7 Development Server
kumiki dev # start the development server
kumiki dev --port 5173
kumiki dev --episode-log ./eps.log
kumiki dev --strict-a11yFeatures:
- Hot reload (on code change, slots are retained)
- error overlay (detailed display on panic)
- episode timeline panel (visualizes recent episodes)
- inspector (slot values, tile tree, dependency graph)
10.8 Build
kumiki build # production build
kumiki build --target=spa # SPA only
kumiki build --target=ssr # Node.js SSR
kumiki build --target=edge # Edge runtime
kumiki build --target=static # static site
kumiki build --analyze # bundle analysisOutput composition:
dist/
├── index.html
├── assets/
│ ├── app-<hash>.js
│ ├── app-<hash>.css ← reset + theme token expansion only
│ └── icons-<hash>.svg
├── server/ ← only for SSR/Edge
│ └── entry.js
└── manifest.json10.8.1 Vite plugin (@kumikijs/vite)
The build-integration ecosystem seam: drop Kumiki into an existing Vite project (and therefore Next/Astro/SvelteKit/etc.) and import .kumiki files like any module. Each source compiles to an ESM module that default-exports the compiled AppShape (via codegen's exportApp — no auto-mount; the importer owns mounting through mount or defineKumikiElement).
// vite.config.ts
import { kumiki } from "@kumikijs/vite";
export default { plugins: [kumiki()] };import App from "./app.kumiki";
import { mount } from "@kumikijs/runtime";
mount(App, document.getElementById("root"));The module also exports a createApp() factory — import App, { createApp } from "./app.kumiki" — for spinning up multiple independent instances (each createApp() returns an AppShape with its own state).
The runtime is shared, not copied — the compiled module keeps its
import "@kumikijs/runtime"and the bundler ships one copy, which the example above depends on:mountcomes from the same package.bundle: trueinlines the runtime into the module instead, for a module that must stand alone; anything else importing the runtime then gets a second copy (129 kB against 82 kB for the counter, and one more copy per further.kumikiimport) and the copies do not share the runtime's module-level state. The plugin resolves the specifier from the project when the project can, and from its own dependency otherwise, so a project that installed only@kumikijs/vitestill builds — with one copy either way.Options —
bundle(defaultfalse, above).types(defaultfalse: emit a sibling<name>.kumiki.gen.tsof typedKumikiSlots/KumikiProvidershelpers for type-safe provider authoring; written only when its contents change — the names are prefixed so they cannot collide with a program's ownSlotsorProviderstype).Capabilities —
kumiki.caps.jsonis resolved automatically, by the same rule as the CLI: from the source file's directory up to the nearest directory holding apackage.json. Deliberately not bounded by Vite'sroot, becausekumiki devmakes the.kumikifile's own directory the root — a manifestkumiki checkreads has to be the manifest the dev server reads. See Registering custom capabilities.Failures are located — a type error, a parse error and a lex error all reach Vite's overlay as a diagnostic carrying file, line and column, so the overlay can jump to the offending line.
Typing the import — reference the shipped ambient types once so
import App from "./x.kumiki"is typed asAppShape:ts/// <reference types="@kumikijs/vite/client" />
Verified by packages/vite/test/plugin.test.ts (compilation), runtime-dedupe.test.ts (one runtime in a real vite build) and diagnostics.test.ts (located failures, manifest lookup); the typed-helper generator (generateDts) by packages/compiler/test/dts.test.ts and dts-compiles.test.ts.
10.9 Runtime API (for Embedding)
When embedding a Kumiki app from a host app:
import { mount } from "kumiki/runtime"
const app = mount({
target: document.getElementById("app"),
bundle: "/assets/app.js",
initialSlots: { /* ... */ },
effectHandlers: {
"analytics.send": (event, props) => myAnalytics.track(event, props)
}
})
app.dispatch({ kind: "ui.click", target: "AddBtn", payload: {} })
app.slots.todos // read-only
app.episodes // recent episodes
app.unmount()10.9.1 Web Component embedding (defineKumikiElement)
The outbound ecosystem seam: wrap a compiled app as a standard custom element so it drops into any host page or framework (React/Vue/Svelte/plain HTML) without a Kumiki-specific integration. It bridges the host both ways and owns the mount lifecycle (mount on connect, dispose on disconnect).
import { defineKumikiElement } from "@kumikijs/runtime";
import { App } from "./my-compiled-app.js"; // the bundle's exported AppShape
defineKumikiElement("my-widget", App, {
// inbound (host → app): host implementations for custom capabilities
providers: { "payments.charge": async (input) => /* … */ },
// outbound (app → host): custom-cap effects surface as DOM CustomEvents
events: ["telemetry.track"],
// declarative props: an observed attribute mapped to a slot
attributeSlots: { "data-count": { slot: "count", parse: Number } },
});<my-widget data-count="3"></my-widget>
<script>
document.querySelector("my-widget")
.addEventListener("telemetry.track", (e) => console.log(e.detail));
</script>- Inbound —
providersare forwarded tomount(same custom-capability seam as Standard Capabilities);attributeSlotsmap observed attributes to slots (applied on connect and on change); imperativeel.setSlot(name, v)/el.setSlots({…})write live slots (refinements enforced) andel.getSlot(name)/el.slotsread them. - Outbound — each capability in
eventsgets a passthrough that dispatchesCustomEvent(cap, { detail: input, bubbles, composed })and resolves ok; aproviders[cap]entry overrides the passthrough for that capability. - Style isolation — by default it renders into the element's light DOM (the runtime's document-level theme/motion styles apply, matching a standalone page). Pass
shadow: trueto render into an open shadow root: the app's motion / theme / state<style>nodes are injected into the shadow root (viamount'sstyleRoot), and theme background/foreground/font are applied to an in-shadow container — so host-page CSS does not bleed in and Kumiki's CSS does not leak out. - Registration is idempotent. For multiple independent instances of the same component, pass the compiled module's
createAppfactory instead of its default export — each element then builds its own state:defineKumikiElement("my-widget", createApp). Passing the defaultAppShapeshares one instance across all elements of that tag. - What a shared instance means. One
AppShapeis one app, however many hosts it is mounted into — throughdefineKumikiElementor by callingmounttwice. Every host is a view: they show the same slots, a click in one re-renders all of them, and an imperativesetSloton any element is a write to the one state. What the app owns once belongs to the first mount —app.init,app.start, the timers, the router, the effect dispatcher — so mounting a second view does not re-run initialization or double a timer's ticks. Each view keeps its own reconcile state, so identity-preserving diffing works per host. - Teardown is per view. Disposing one view empties and un-registers its host and leaves every other view interactive; the app itself is torn down when the last view goes, after which mounting the shape again starts it over —
app.init,app.start, the timers and the router run again. Its state is not reset:app.liveis the shape's, and a slot the app had written keeps that value into the next mount.createApp()is what returns a shape at its declared defaults. A view cannot be added withhydrate— a server snapshot overlays a fresh state, and this app's is already live.
Verified by packages/runtime/test/element.test.ts; the shared-instance semantics by packages/runtime/test/shared-mount.test.ts and T8 in packages/runtime/test/multi-mount.test.ts.
10.10 Implementation Responsibilities of the Standard Library
For the built-ins enumerated in Standard Library, the runtime implementation guarantees the following behavior:
| Feature | Guarantee |
|---|---|
Map, Set, List | pure (no in-place mutation) |
Option, Result | exhaustiveness check for pattern matching |
now, random() | callable wherever an expression is; the values they read are not recorded, so a replay of an episode that read one draws a new value |
*.fresh() | generates UUIDv7 |
panic(message) | puts the episode into the panic state and rolls back slots |
10.11 Performance Budget
| Item | Budget |
|---|---|
| runtime core | ~30KB gzip |
| 1 reducer execution time | < 1ms (typical) |
| signal graph update | < 16ms (60fps) |
| effect dispatch overhead | < 0.1ms |
| episode log write | < 0.5ms (memory) |
To meet these, the runtime is Rust → WASM (optional) or hand-written JS (default).
10.12 Record of Design Decisions
| Decision | Reason |
|---|---|
| signal graph is static | eliminates runtime dependency tracking; performance and predictability |
| batch updates | so that successive changes do not exceed 60fps |
| effects go via the dispatcher | guarantees capability guards and logging structurally |
| episode = per trigger | integrates debugging, testing, and audit into a single unit |
| SSR and CSR consume the same IR | the target difference is only the dispatcher implementation difference |
| runtime 30KB target | practicality on mobile / Edge |
10.13 Next
- Complete examples → examples/