Skip to content

Render

mount(rootEl, render) is the single rendering primitive.

import { mount, signal } from 'kerfjs';
const count = signal(0);
const dispose = mount(document.getElementById('app')!, () => (
<div>
<span>{count.value}</span>
<button data-action="inc">+</button>
</div>
));
  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.
  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 <!--kf-list:N--> placeholders, no row HTML), runs kerf’s native diff (src/diff.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; replaced/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.

diff() 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.

// Reorderable list — give each row a stable data-key
<ul>
{rows.value.map((r) => (
<li data-key={r.id}>{r.label}</li>
))}
</ul>

For large lists, swap .map(...) for the each(items, render, key?) helper. It returns a structured list segment that mount() recognises and routes to the keyed reconciler — bypassing the parse-the-whole-table step entirely. Each row is memoised by item identity (with an optional key 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.

import { each } from 'kerfjs';
<ul>
{each(rows.value, (r) => <li data-key={r.id}>{r.label}</li>)}
</ul>

Apply this attribute to any element whose subtree you DON’T want kerf to touch:

<div id="chart-mount" data-morph-skip />

After the first render, mount your library widget into #chart-mount directly:

const chart = new ThirdPartyChart(document.getElementById('chart-mount')!);

On subsequent re-renders, the diff sees data-morph-skip on the host and short-circuits — so the entire subtree (the chart’s internal DOM) is preserved. Use this for:

  • xterm.js / Monaco-style editors.
  • D3 / Plotly / Chart.js mounted regions.
  • Any element with imperative DOM mutations you manage yourself.

When the diff is about to update an element that is currently the active element, kerf preserves the user’s in-progress edit. The mechanism differs by element kind:

For input[type=text|search|url|email|tel|password|""] and <textarea>, kerf:

  • Copies the live .value and selectionStart / selectionEnd onto the morph target.
  • Lets the diff proceed with the update.

The result: attribute updates from the surrounding render still apply (className, disabled, etc.), but the user’s typed value and cursor position survive. They never see their cursor jump mid-keystroke.

Other input types (range, color, file, date, checkbox, radio…) don’t have meaningful text-selection state, so they aren’t touched specially — the diff proceeds normally.

For a focused contenteditable, kerf takes the heavier-handed approach: the entire subtree is skipped on this morph, the same way data-morph-skip works. The user’s typed content, caret position, and any multi-range selection survive verbatim — including any custom DOM they produced (<b>, <a>, line breaks, etc.). The trade-off is that any update to the contenteditable’s attributes or children is also deferred until the next render after the user blurs.

This is the behaviour you almost always want for in-progress rich-text editing: don’t disturb the editor mid-edit. If you want kerf to drive a contenteditable’s content despite the user being focused, that’s outside the framework — manage it imperatively or move that state outside the contenteditable.

For anything else with focus (a <button>, <a>, <div tabindex>), the diff proceeds normally. There’s no special handling — none of those elements have user-visible state that a re-render would clobber.

When the keyed list reconciler moves a row whose descendant is the focused element, the row’s DOM node is reused — the focused element stays connected to the document. Some engines (older Safari, happy-dom) drop focus state on insertBefore even when the element survives, so the reconciler snapshots the active element + its selection range before the move pass and re-applies them afterwards. Engines that already preserve focus see a no-op; engines that don’t get a transparent fix.

Replaced rows (cache miss — the row’s HTML changed) are a different story: the old node is removed before the new one is inserted, so focus that lived inside it is genuinely gone. That matches the behaviour of any framework that re-renders a row.

You can call mount() on different elements for different parts of the page. Each one gets its own effect() and tracks its own signals:

mount(badgeEl, () => <span>{cart.state.value.items.length}</span>);
mount(listEl, () => <ul>{cart.state.value.items.map(renderRow)}</ul>);
mount(footerEl, () => <div>{cartTotal.value.toFixed(2)}</div>);

Each region re-renders only when its own dependencies change. Adding an item to the cart triggers all three; changing an unrelated piece of state triggers none.

SafeHtml.toString() is server-safe. You can build the same JSX server-side, write the resulting string into your HTML response, and then call mount() on the same element on the client. The first-render path bulk-renders into the existing DOM via innerHTML; if the server output and client output match (which they should, given the same store state), the resulting tree is identical — and signal subscriptions are now wired up for future updates.

This isn’t a full SSR story (no streaming, no hydration mismatch detection), but it’s enough for “render once on the server, hydrate interactivity on the client” workflows.

The disposer returned by mount() tears down the effect:

const dispose = mount(rootEl, render);
// later, when rootEl leaves the DOM:
dispose();

After dispose, signal mutations no longer trigger re-renders for this mount. The DOM tree itself is left as-is — kerf doesn’t clear it; you do.

  • It doesn’t manage component lifecycle. There’s no onMount / onUnmount / onUpdate hook. Use effect() directly if you need a side effect tied to a signal.
  • It doesn’t batch updates across animation frames. If a signal mutates 100 times in 16ms, the render fn runs 100 times. Use batch() if you have a multi-write action that should fire once.
  • It doesn’t dedupe identical renders. If your render fn returns the same HTML on consecutive runs, the diff still walks the tree (and short-circuits per element via isEqualNode). The cost is the walk; it’s cheap for small trees, and lists go through the keyed reconciler which is even cheaper.