# 4. Render `mount(rootEl, render)` is the single rendering primitive. ```ts import { mount, signal } from 'kerfjs'; const count = signal(0); const dispose = mount(document.getElementById('app')!, () => (
{count.value}
)); ``` ## 4.1 What `mount` does 1. Wraps `effect()` so the render fn re-runs whenever any signal it reads changes. 2. Evaluates `render()` to a `SafeHtml`. The wrapped `Segment` is either a single static-html node (most renders), or a tree containing `list` segments (anywhere `each(...)` was used) and `mixed` segments wrapping their parents. As a small ergonomic affordance, a render that returns `null`, `undefined`, `false`, or `true` is coerced to "render nothing" (empty string) — so `mount(el, () => cond ? : null)` and `mount(el, () => cond && )` work without each consumer adding a sentinel. Numbers stringify; real strings pass through. 3. **First render:** sets `rootEl.innerHTML` to the flattened HTML (with sentinel comments around each list), then walks those comments to bind every list to its live parent. Bulk parse, single pass. 4. **Subsequent renders:** builds a marker-only template (lists become `` placeholders, *no row HTML*), runs kerf's native morph (`src/morph.ts`) over the static surrounds, then dispatches each list segment to a keyed reconciler that operates directly on the live parent's children. Cache-hit rows are reused verbatim. When the list's items are unchanged in count and order but some rows' content changed (the common "external state flipped a class/label" case), those rows are morphed *in place* on their existing nodes — preserving DOM identity, focus, scroll, IME composition, and in-progress CSS transitions. (This in-place behavior is new in 0.15.0; versions ≤ 0.14.x recreated the row node on a content change instead. One consequence of reusing the node: a CSS enter-animation keyed on the row element's *creation* no longer replays on a content-only update — key such animations on a state-class toggle if you need them to fire.) Genuinely new rows are batched into one parse and `insertBefore`'d into place; a longest-increasing-subsequence pass keeps reorder mutations to the minimum. 5. Returns a disposer that tears down the effect. The structural payoff: a thousand-row list where 100 rows changed runs ~100 cache misses, one bulk parse for those 100 rows, ~100 `insertBefore` calls, and zero work for the 900 unchanged rows. The static surrounds (which are usually small) go through the general-purpose diff. ## 4.2 Morph keys `morph()` matches elements across the reconciliation by: - **`id`** — wins over any other key. Useful for singletons. - **`data-key`** — generic per-row key for list items. Elements without a key are matched positionally by tag name. Pure-HTML diffs work fine without keys; you only need keys when list rows reorder, are inserted in the middle, or removed. ```tsx // Reorderable list — give each row a stable data-key ``` For large lists, swap `.map(...)` for the `each(items, render, cacheKey?)` helper. It returns a structured list segment that `mount()` recognizes and routes to the keyed reconciler — bypassing the parse-the-whole-table step entirely. Each row is memoized by item identity (with an optional `cacheKey` that captures external state like a "selected id"), so unchanged rows skip JSX evaluation, string-building, *and* the morph walk. Items must be objects (the cache is a `WeakMap`); the immutable-update style elsewhere in this codebase makes the cache work automatically — replace a row with a fresh object and it re-renders, leave its reference alone and it doesn't. ```tsx import { each } from 'kerfjs'; ``` > **Memo cache invariant.** The memo cache invalidates *purely* on the third argument (the `cacheKey` function's return value) plus item identity. If a row's rendered output depends on external state that the memo doesn't include, the row will go stale — kerf will return cached HTML even though the render function would produce something different now. The fix is either: (a) bake that state into the memo (`(r) => \`${r.id}-${selectedId === r.id ? 'on' : 'off'}\``), or (b) own the changing DOM imperatively under `data-morph-skip` and let kerf cache the surrounding shell. The kanban example chooses (b) for the live drag transform; the TodoMVC example chooses (a) for the per-row view/edit flip. > **Static structural arrays — use `.map()`, not `each()`.** `each()` is for dynamic lists. When the outer array is a module-level constant (`COLUMNS`, settings sections, nav tabs) whose items never change identity, the per-item HTML cache hits every render *forever* — the row render fn is invoked exactly once at first paint and never again, even when signals it reads change. Signal subscriptions established during that first render get dropped after the next effect run (signal-core only retains subscriptions for signals re-read in the current run), so writes to those signals quietly stop triggering re-renders. The whole rendered tree looks frozen; only elements *outside* the `each()` reflect updates. > > The wrong shape: > > ```tsx > const COLUMNS = [{ id: 'todo', title: 'To do' }, { id: 'doing', title: 'Doing' }, { id: 'done', title: 'Done' }]; > const board = signal>({ todo: [...], doing: [...], done: [...] }); > > mount(root, () => ( >
> {each(COLUMNS, (col) => ( // ← static array; cache-hits forever >
> {each(board.value[col.id], (card) => ...)} // ← signal read never re-tracked >
> ))} >
> )); > ``` > > The right shape: > > ```tsx > mount(root, () => ( >
> {COLUMNS.map((col) => ( // ← .map: outer loop re-runs every render >
> {each(board.value[col.id], (card) => ...)} // ← inner each() still gets keyed reconcile >
> ))} >
> )); > ``` > > Rule of thumb: if the array reference is the same across renders AND the row render reads signals, you want `.map()`. If the array is a fresh reference per render (because it came from a signal or a filter/sort pipeline), you want `each()`. Inner `each()` over the *dynamic* sub-list is fine in both shapes. ### Granular reconcile via `arraySignal` Pass an `arraySignal` to `each()` and `mount()` runs an even faster path: instead of iterating the whole snapshot to classify changed/unchanged rows, the reconciler consumes the patch queue the `arraySignal` emitted (one `update`/`insert`/`remove`/`move` per mutation) and applies only those to the live DOM. Cost is O(patches), not O(N). ```tsx import { each, mount } from 'kerfjs'; import { arraySignal } from 'kerfjs/array-signal'; const rows = arraySignal<{ id: number; label: string }>([]); mount(rootEl, () => (
    {each(rows, (r) =>
  • {r.label}
  • )}
)); rows.push({ id: 1, label: 'a' }); // 1 insert patch rows.update(0, (r) => ({ ...r, label: 'A' })); // 1 update patch ``` When patches are emitted contiguously (e.g. an append-1k loop, or a partial-update batch), the reconciler bulk-parses them in a single `template.innerHTML` call and applies one `insertBefore` per fragment. A few invariants the granular path holds: - **First render takes the snapshot path** even when patches were queued before mount — there's no binding yet to apply patches against, so the whole list is rendered fresh. - **`replace()` always falls back to snapshot** — wholesale resets are easier to reconcile that way and preserve focus better. - **A throwing render falls back to snapshot** — pre-rendering happens at JSX-eval time inside a try/catch, so a single bad row doesn't desync the binding from the signal. - **Drift triggers a rebuild** — if a previous render threw mid-batch, the next render notices that `binding.length + patch_delta !== signal.length` and rebuilds via the snapshot path. See §2.6 for the full `arraySignal` API. ## 4.3 Diff escape hatches Three `data-*` attributes opt portions of the live tree out of the diff. They overlap deliberately — pick the one that matches your reason for excluding the element. | Attribute | Element itself | Subtree | Trailing-removal | Use when | | --- | --- | --- | --- | --- | | `data-morph-skip` | left verbatim (no attr morph) | left verbatim | n/a (the element is in the template) | Library-owned hosts: xterm / Monaco / D3 — the library mutates classes too, so you don't want kerf undoing them. | | `data-morph-skip-children` | attrs morph | left verbatim | n/a | Client-hydrated slots: server emits an empty container, the client fills it asynchronously, but the server's classes / data attrs on the slot itself still need to flow through (e.g. `class="slot is-loading"` → `"slot is-ready"`). | | `data-morph-preserve` | attrs morph if matched; otherwise untouched | morphed if matched; otherwise untouched | skipped (element survives even when the new template doesn't emit it) | Imperatively-injected nodes the consumer added AFTER first render — autoplay `