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. The refinements are the ones the slot's type carries along the chain it denotes — an alias, a nominal wrapper, a where, a generic that hands a parameter back (NonEmpty(Text) on type NonEmpty(T) = T where nonempty) — the standard library's own declarations included: slot e : Email is checked by email exactly as slot e : Text where email is, because Email is a refined nominal the standard library declares (Domain Types). A refinement written inside the type is checked too, at the path it is written at (§1.3.3): on slot form : {email: Text where email}, form.email := "nope" and form := {email: "nope"} are both rejected, and so is a union payload or a container element that fails its own predicate. A type carrying several predicates is checked against all of them, and the rejection names the first one the value fails, in the order §1.3.1 gives them (§1.3.1). 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.— or, for a predicate written inside the slot's type, with the path to where it failed, slot "form" cannot hold {"email":"nope"} (email at .email) — 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 + 1A value from outside the program is checked before it reaches a reducer. A response or a stored value that Decoder.Json(T) decodes to a value T's predicates refuse is the effect's .err (§6.1.4, §6.7.2), so the program's .err reducer handles it. Otherwise it would be an .ok whose writes this rule discards, together with everything else the reducer did, such as ending a loading state.
Two 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. The slot keeps its previous value, the field keeps what was typed, anderror(field=…)renders the message for what the field shows.
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 and the injected base stylesheet - Repaints the page when the resolved theme differs from the one the mounted tree was painted under. Token props (
bg,color,pad,gap,radius,shadow, typography, …) are resolved to literal values when a tile renders, so a tile whose own props did not change would otherwise keep the old theme's values; that pass builds the tree afresh instead of diffing it. Element identity is not kept across a switch: a focused control gets its focus and selection back the way it does after any rebuild, and DOM state no slot holds starts over: a scroll offset, an uncontrolled<details>, the text of an unbound input, a file input's selection, playing media, and text abindrefused (the rebuilt control shows the value its slot kept, and the field'serror(field=…)message goes with the refused text). A switch does not replay enter animations: an element it rebuilds that carriestransitionormotionshows a one-shot animation's final frame at once, and a repeating one keeps running. Every view of the app, hydrated or not, does the same
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, the value of aSomefor an Option, and each entry as one[key, value]pair for any other object — a Map, or a Set, whose value istrue(_s.filter).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], ...]; how a subsequent list op's lambda binds$1/$2is decided by the checker from the receiver's type, not here (Stdlib §2.2.3) — aTuple(K, V)element is taken apart into$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 an old runtime ignores the field. That is a statement about the field only: the stdlib helpers the compiler calls to compute a key (the implicit key's_s.loopKeys, below) are not forward-compatible, so a new compiler's output needs a runtime at least as new as it (see Migration).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. The author promises it is unique among its siblings: within one loop, and across loops and other keyed children under one parent. When every child at that level carries a key (the all-or-nothing rule above), two siblings with one key are alocation: "reconcile"panic on the next render, because the runtime cannot tell which of them the key meant. A loop whose{key: …}values collide (for s in [7, 3, 7] text(s.show) {key: s.show}) is then a program error: the runtime does not fall back to position, it reports[kumiki] error in reconcile: reconcile: duplicate TileNode.key "7" among sibling tiles — keys must be unique within a parent's children listand rebuilds the whole tree, replacing every element on the page (Lifecycle §7.2.2). When a sibling at that level has no key, the keyed pass does not run, so the colliding keys go unused and the parent takes the structural walk.Inside
foriteration, tile calls that do not declare their own{key: ...}receive an implicit key with three parts, in this order:- the loop: an identifier that is stable across renders and distinct per loop in the source;
- the occurrence: how many elements up to and including this one have the same shown value (1 for the first
7, 2 for the second); - the shown value,
_s.show(<loopVar>).
Informative: the implementation names a loop by the tile definition it is written in and its ordinal among that definition's loops in source order (
App_0,App_1, …), so a blank line or an edit outside that definition leaves every key as it was. It joins the parts as<loop>|<occurrence>|<shown>with_s.loopKeys(<list>, "<loop>"), called once per evaluation of the loop, and hands iteration i the i-th entry. The loop name and the occurrence hold no|and the occurrence is a run of digits, so the three parts read back unambiguously and no shown value can spell another element's key. A loop whose every tile call carries its own{key: …}reads no implicit key, and the implementation does not compute them.So a list does not have to hold distinct values:
[7, 3, 7], and two loops under one parent that share a value, key every child apart. A list ofNones and an element whose shown value is empty key apart the same way; before the occurrence was part of the key, the first was a duplicate-key panic and the second an empty key that_wkrefused. The one exception is a single loop in the source that contributes to one parent's children more than once:tile Items = for x in xs text(x.show)used ascolumn(Items, Items)expands one loop twice, both expansions carry the same keys, and the parent is the duplicate-key panic of item 1. Give each use its own container (column(row(Items), row(Items))).A reorder of elements with distinct shown values keeps every key, since each is the first occurrence of its shown value, so the reuse guarantees above hold as before. Two qualifications follow from the occurrence:
- An insert or remove before a repeated value renumbers its later occurrences, so the elements of equal values may trade places, and the moves that makes can take a neighbouring row with them.
- Where
showis not injective for the element type, the implicit key degenerates to position. Every record shows alike ([object Object]), and a variant shows only its tag, soDone(1)andDone(2)share one shown value. The occurrence is then the element's position, and a reorder, insert or remove patches rows in place instead of moving them: the guarantees above (minimum moves,<input>focus and caret, an open<select>, IME composition) do not hold for such a list. A reorderable list of records wants an explicit key,{key: t.id}.
Explicit keys always win. Nested
forloops overwrite the enclosing implicit key with the inner loop's, so a tile call underfor i in inneris keyed byiregardless of any outerfor o in outer. The innerfor— the outer one's body, or an arm of a branch there — renders a list per outer iteration, so each of its nodes is then keyed as a list under the outer key, the way item 3 keys a list-bodied call: siblings from different outer iterations that share an inner key stay distinct.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. When the body renders a list (its body is afor), the call site's key — explicit or implicit — names the list, not one node, and one key on every node would collapse them onto a single identity. Each node takes the pair of the call site's key and its own key — the one its ownforgave it, however deeply thatfornests — or, when it has none, its position in the flattened list, encoded as the JSON array[callKey, nodeKey], so the nodes stay distinct and two pairs never spell the same string. A reorder of the outer list moves each node's element; a reorder inside the list moves them by their own keys. The list's nodes are children of the container the call sits in, however deeply thefors that produced them nest.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: a change to it bumps both packages in one release, at whatever level its changeset names. @kumikijs/compiler depends on @kumikijs/runtime, and a runtime release raises the compiler's range on it, so a compiler installed from the registry brings a runtime at least as new. An old compiler's output runs on a new runtime. A new compiler's output needs a runtime at least as new: it calls _s.loopKeys, which an older runtime does not have, and fails at the first render with _s.loopKeys is not a function. 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.
An effect with an empty cap is a standard presentation effect and passes ungated.
The notification is the PanicInfo of lifecycle.md §7.2.3, under category: "capability", with location naming the refused effect. It also goes to console.error, the channel the verification tiers read, and — where an episode is open around the emit — to that episode as a panic step. Nothing was thrown, so it carries no stack and no cause.
Both passes report, in the same words. What differs is what they can report to: renderToString has no app.error to fire, the same way a reducer panic on that pass is a panic step and nothing more (§10.5.1.1), so on the server the console and the episode are the whole of it.
The episode a refusal attaches to is the one that owns the emit, which is not always the one in focus. Under the default policy the dispatcher refuses while the triggering episode is still open, and the step lands there. Under a deferred policy (§10.4.3) the launch fires from a timer or a queue tail, long after that episode closed; the step MUST still land on the episode that claimed the effect-start, and episode-id MUST name it — a refusal that fell back to "no episode" would leave that episode holding an effect-start and an effect-cancel and nothing else, which is what a replaced debounce timer looks like. Where a refusal produces both a panic step and an effect-cancel, the panic step comes first: the cancel settles the episode, and a reader meets the reason before the consequence.
The one emit with no episode to name is one from app.init, dispatched before the first episode opens: it reports to the console and to app.error, carrying episode-id: None.
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"],
"env-reads": [{"kind": "now", "value": 1717900000000}], "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 reducer step additionally carries:
env-reads: what the reducer body read from the environment while it ran, in the order it asked. An entry is{kind, value};kindis one ofnow/random/fresh-id/prefers-dark— the builtins whose answer comes from outside the program, so that nothing in the slots determines it. The field is omitted when the body read nothing, which is most reducers. A reducer whose batch a refinement rejected (§10.3.3) still records its reads: the body ran, and a replay that re-runs it has to see the same answers or it may not reject at all.The recorded scope is the reducer body, and only that. The same builtins are callable anywhere an expression is (§10.10) —
nowin a tile expression,prefers-dark()during a render — and a read outside a reducer body is not recorded and not reproduced. Nothing replays a render, so there is nothing for such a read to be reproduced against.What a read answers is journalled; what a value is later formatted with is not.
now.format(...)andTime.parse("yyyy-MM-dd")resolve in the host's local time zone (stdlib.md §2.4.2), which is not anenv-readskind, so an episode recorded in one zone and replayed in another reproduces the recorded instant and formats it differently. The guarantee below is over the four kinds, not over every value derived from them.env-readsis why a replay is the same run. An episode that recorded only what a reducer wrote could not be replayed:replayre-executes the body, and a body that reads the environment reads it again — a new die roll, a later instant — so the replayedslot-diffsbelong to a different run than the recorded ones.env-readsis the recorded run's environment, kept so it can be handed back (§10.5.3).Like
stack/cause/categorybelow, it is optional for forward compatibility: an episode log written by an older runtime carries noenv-readsand MUST continue to parse and replay unchanged.
A panic step additionally carries:
name: the reducer whose body threw, when the throw came from one (locationis prose, and a replay needs a key). Absent for arender/hydratepanic.env-reads: what that body read before it threw, in the same shape as areducerstep's. A reducer that threw wrote noreducerstep, so without these two fields the episode a user attaches to a bug report — the one that crashed — is the one a replay cannot reproduce: it re-reads the environment, takes a different branch, and completes.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 failure was caught, or forcapabilityrefused. Emitted by the reducer / tile-render / hydrate / capability sites today;effectandunknownare reserved values so consumers can exhaustive-switch as future callsites are wired in — a capability provider throw surfaces as aneffect-endwithresult: "err", NOT as apanicstep, which is a different thing from the refusalcapabilitynames.
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. The stack and the cause chain are dev-tooling and stay here: the runtime never splats the PanicRecord into a user reducer $event. What a program is handed is the PanicInfo lifecycle.md §7.2.3 declares — message, location, category, the id of this episode as episode-id, and cause as the nearest link's message alone, with no stack on it.
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.- The one emit that produces no pair is one the capability check refuses (§10.4.2): the pass records
effect-start, then apanicstep carrying the refusal, theneffect-cancel(targetId = <effect-name>), and runs nothing. A reader MUST NOT take that unpaired start as truncation or as an effect still in flight — the episode is complete, and the emit did not run; thepanicstep says why, and makes the episodestatus: "panic". The live dispatcher writes the same three steps in the same order for a refusal under a deferred policy, which is the case where it too claimed a token; under the default policy it returns before claiming one, so only thepanicstep lands. Either way both sides report the refusal, andstatusis"panic"wherever the step is.
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). - The entry reducer runs on the payload it ran on when recorded. An episode's
trigger.payloadis the payload the live runtime handed its first reducer —{$el, $event}for a UI event,{$1, $2}for an effect result — and replay hands it over unchanged, so$el.idxand$event.valueread what they read live. A trigger with no payload whose first reducer is an.ok/.errreducer — anssr.hydratebootstrap (§10.5.1.1), which the SSR pass opens rather than a reducer — ran on the lasteffect-endof that effect and outcome recorded before that reducer, so its value is the reducer's$1; that recorded result is then consumed, and afrom-logmock of the same effect continues after it.- If the log carries no such step — a trimmed or hand-edited log — the reducer runs with no
$1, and that is reported rather than inferred: the episode's trace line carries(no recorded result for <reducer>)and the run ends with anentry results missing:summary, so the panic that follows does not read as a bug in the reducer. - Replay has one entry reducer per episode, so a bootstrap whose SSR pass ran several
app.initeffects replays only the first entry reducer's chain. Anepisode-testwithslots-equal: from-logover such a log fails on a slot mismatch rather than a panic, and when twoinitchains emit the same effect, thefrom-logcursor the entry advanced can hand a re-emit the other chain's result. That failure is silent — crossed-over values, not a fallback.
- If the log carries no such step — a trimmed or hand-edited log — the reducer runs with no
- An environment read is answered from the log, not from the environment. Before a reducer body runs, replay installs that reducer's recorded
env-reads(§10.5.1);now,random(),<T>.fresh()andprefers-dark()then return what they returned during the recording instead of reading the clock / the random source / the id generator / the OS preference again. Replaying an episode whose reducer read the environment therefore reproduces its recordedslot-diffsexactly, every time.- Reads are matched to recorded answers by kind, in recorded order within a kind, so an extra read of one builtin cannot shift another's answers.
- A read with no recorded answer left — an older log, or a body that read more this time than it did when recorded — falls through to the live source rather than failing the replay. That read is the one thing a replay cannot reproduce, so it is reported rather than inferred: the step's trace line carries
(env: N read live)and the run ends with anenvironment reads:summary. The same line reports recorded answers the replayed body never asked for (N recorded unused) and entries the log carried that were not well-formed (N malformed); a step with none of the three says nothing. - An entry whose
valueis missing or is not the type itskindcalls for (now/randomare numbers,fresh-ida string,prefers-darka boolean) is rejected when the scope opens rather than handed to the body. Consuming a malformed entry would returnundefined—now.showrenders"undefined", arithmetic goesNaN— and nothing would throw to say why. A rejected entry is countedmalformedand its read falls through to the live source. - A recorded
panicstep replays like areducerstep: itsnamenames the entry reducer and itsenv-readsare installed the same way, so a recorded crash crashes again. - Recorded
env-readsare matched to the replayed reducer by name: replay derives the chain by re-executing reducers rather than walking the log's steps, so the nth run of reducerfootakes the nth recordedfoostep's reads. That is an ordering assumption, not an alignment guarantee — replay walks a reducer's emits in declaration order while the recording appended.ok/.errsteps in effect-completion order, so one reducer reached twice by two effects that completed out of declaration order takes the two recorded read sets swapped. That failure is silent: the values are crossed over rather than absent, so it raises nolivecount.
- 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 initial route is where the requested path lands: a static redirect (Routing §3.10), top-level or inside the matched parent's
sub-routes, is resolved first, through the same lookupmountuses, and the target is rendered.routereads the target while the tiles render, and the snapshot'srouteand the bootstrap episode'strigger.target(§10.5.1.1) name it. - The requested path may carry a query and a hash (a request's URL passed through as is). It is split the way the client's router reads a location, and its pathname is matched as written:
//fooand/a/../bare not normalized, because a browser'slocation.pathnamekeeps them, and the server has to land where the client would.
- The initial route is where the requested path lands: a static redirect (Routing §3.10), top-level or inside the matched parent's
- 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 (
- The served element tree is the one the client builds, not only the same outer element: a tile whose renderer nests elements of its own is served nested the same way. The first paint is what the element tree decides — a flat one lays out differently from a nested one — and it is also what a crawler reads.
markdownis served as the paragraphs the renderer parses (one per blank line,white-space: pre-wrap), not as its raw source in one text node.overlaywraps every child after the first in the same absolutely-positioned layer the renderer builds, placed byalign.- A closed
modal/drawer/popoveris served as the present-but-hidden host the renderer mounts —display: nonearound the content box — rather than as an empty string. Opening a surface is a style flip on both paths, and the content of a closed one stays readable to a crawler. (Hydration is not the reason: it replaces the served DOM wholesale either way — §10.6.2.) check/switch/radioare served as the<label>that wraps their control, anderroras the<span>its message will arrive in.
- Form state is the one thing a served attribute carries that the mounted element does not — but only where the property does not reflect:
value,checkedandselectedare set as DOM properties on the client and leave the markup untouched, while an attribute is the only way a served page can carry them.disabled,readonlyand<details open>DO reflect, so they are attributes on both paths and must agree. - 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, pluspanicand effect-cancel for an emit the capability check refused, in that order — see §10.5.1.1).beforevalues insideslot-diffsare the slot's declared default at the start of the SSR pass;afteris the post-init value mirrored inslots.bootstrap.statusis"completed"for a chain that ran, and"panic"for one carrying apanicstep — a refused emit is the case where an otherwise successful init pass ships a"panic"bootstrap. A client MUST hydrate either one; the status describes the recorded run, not the snapshot's usability.
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 analysis
kumiki build --minify # minify the generated app module
kumiki build --bundle # link app + runtime into one file--bundle links the generated module and the runtime modules it imports into a single minified app.js, and emits no runtime/. It is worth its own flag rather than being implied by --minify because the two optimise opposite things. The modular layout gives runtime/core.js a URL that does not change when the app does, so a returning visitor re-downloads only app.js. Bundling gives a first visitor one request and one compression stream over the whole payload — gzip and brotli build their dictionary per response, so twenty small modules compress markedly worse than the same bytes linked together — and it tree-shakes across the seam a module boundary hides. On the example apps it is 19–28% off the compressed payload.
It is not the default for the same reason --minify is not: it implies minification, and a readable app.js is what the debug loop (§10.5) reads a stack trace out of. The runtime/ layout it removes is a caching concern only — nothing else reads it.
--minify is opt-in, and the readable default is the deliberate one: the generated module is what a stack trace points into, so a build that minified it unasked would cost the debug loop (§10.5) its most direct evidence. The runtime modules the build copies alongside it are minified either way — they ship that way from the runtime's own build.
Output 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()A host that runs reducer bodies itself — a custom test harness, a replayer of its own — can bracket their environment reads with the same journal the runtime uses (§10.5.1):
import { withEnvRecord, withEnvReplay } from "@kumikijs/runtime";
const run = withEnvRecord(() => reducer.apply(live, payload));
run.env.reads; // the `env-reads` to log
const again = withEnvReplay(run.env.reads, () => reducer.apply(live, payload));
again.env.live; // reads the list could not answerBoth take the body as a callback because the scope is process-wide while it is open: a frame left behind captures every later read in the process, so the only balanced way to open one is to hand it the body. The raw beginEnvRecord / beginEnvReplay / endEnvScope are exported for a host that has to bracket across a boundary a callback cannot span, and balancing them is then the caller's job.
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. A read inside a reducer body has the value it returned recorded on that episode's step as env-reads (§10.5.1), so a replay reproduces it rather than drawing a new value; a read anywhere else (a tile expression, a render) is not recorded, and nothing replays those |
*.fresh() | generates UUIDv7; recorded as an env-reads entry like now / random() under the same reducer-body scope, so a replayed episode stamps the ids the run actually stamped |
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/