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>));4.1 What mount does
Section titled “4.1 What mount does”- Wraps
effect()so the render fn re-runs whenever any signal it reads changes. - Evaluates
render()to aSafeHtml. The wrappedSegmentis either a single static-html node (most renders), or a tree containinglistsegments (anywhereeach(...)was used) andmixedsegments wrapping their parents. - First render: sets
rootEl.innerHTMLto 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. - 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 andinsertBefore’d into place. A longest-increasing-subsequence pass keeps reorder mutations to the minimum. - 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 Diff keys
Section titled “4.2 Diff keys”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>4.3 data-morph-skip
Section titled “4.3 data-morph-skip”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.
4.4 Focus + selection preservation
Section titled “4.4 Focus + selection preservation”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:
<input> (text-entry types) and <textarea>
Section titled “<input> (text-entry types) and <textarea>”For input[type=text|search|url|email|tel|password|""] and <textarea>, kerf:
- Copies the live
.valueandselectionStart/selectionEndonto 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.
[contenteditable]
Section titled “[contenteditable]”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.
Non-text focused elements
Section titled “Non-text focused elements”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.
Across each() reorders
Section titled “Across each() reorders”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.
4.5 Multiple mount() calls
Section titled “4.5 Multiple mount() calls”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.
4.6 Server-rendering
Section titled “4.6 Server-rendering”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.
4.7 Disposing
Section titled “4.7 Disposing”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.
4.8 What mount does NOT do
Section titled “4.8 What mount does NOT do”- It doesn’t manage component lifecycle. There’s no
onMount/onUnmount/onUpdatehook. Useeffect()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.