API reference
Everything kerf exports, organized by module. Imported via import { … } from 'kerfjs' unless noted.
8.1 Reactivity
Section titled “8.1 Reactivity”signal<T>(value?: T): Signal<T>
Section titled “signal<T>(value?: T): Signal<T>”A reactive value. .value reads / writes; reads inside effect() / computed() are tracked.
computed<T>(fn: () => T): ReadonlySignal<T>
Section titled “computed<T>(fn: () => T): ReadonlySignal<T>”A derived signal. Re-runs fn whenever any signal it reads changes. Read-only.
effect(fn: () => void | (() => void)): () => void
Section titled “effect(fn: () => void | (() => void)): () => void”Run fn immediately, then re-run it whenever any signal it reads changes. Returns a disposer. fn may return a cleanup function, which runs before each re-run and on dispose (see docs/2-reactivity.md §2.3).
batch(fn: () => void): void
Section titled “batch(fn: () => void): void”Run fn, deferring effect re-runs until fn returns. Multiple writes inside fn produce a single re-run.
Signal<T> (type)
Section titled “Signal<T> (type)”interface Signal<T> { value: T }ReadonlySignal<T> (type)
Section titled “ReadonlySignal<T> (type)”interface ReadonlySignal<T> { readonly value: T }arraySignal<T>(initial?: readonly T[]): ArraySignal<T> — kerfjs/array-signal subpath
Section titled “arraySignal<T>(initial?: readonly T[]): ArraySignal<T> — kerfjs/array-signal subpath”import { arraySignal } from 'kerfjs/array-signal';
const rows = arraySignal<{ id: number; label: string }>([]);Granular collection signal. Lives in its own subpath — import { arraySignal } from 'kerfjs/array-signal' — so apps that don’t use it shed ~1 KB from the main barrel. Pair with each(...) inside a mount() for O(patches)-not-O(N) reconciles. See docs/2-reactivity.md §2.6 for the rationale and gotchas, and docs/4-render.md §4.2 (granular reconcile) for how the binding works.
class ArraySignal<T> { readonly value: readonly T[]; // tracking read update(index: number, fn: (item: T) => T): void; // → 1 update patch insert(index: number, item: T): void; // → 1 insert patch push(item: T): void; // sugar for insert(length, item) remove(index: number): T; // → 1 remove patch (returns removed item) move(from: number, to: number): void; // → 1 move patch (no-op if from === to) replace(items: readonly T[]): void; // → 1 replace patch (forces snapshot reconcile)}All mutators throw a descriptive Error on out-of-bounds indices (with one carve-out: move()’s from === to no-op check runs before its bounds check, so an out-of-bounds move(9, 9) silently no-ops). Reads on arraySig.value register a tracking dependency just like signal.value — computed(() => arraySig.value.filter(...)) and effect(() => render(arraySig.value)) work the same way.
The ArraySignal<T> class is detected via Symbol.for('kerfjs.ArraySignal'), not instanceof, so multiple bundle copies still interoperate. The brand symbol itself is also exported as ARRAY_SIGNAL_BRAND from kerfjs/array-signal for consumers who build their own collection types and want each(...) to recognize them via brand check.
The mutator events are surfaced as the ArrayPatch<T> type — a tagged-union covering update / insert / remove / move / replace:
type ArrayPatch<T> = | { type: 'update'; index: number; item: T } | { type: 'insert'; index: number; item: T } | { type: 'remove'; index: number } | { type: 'move'; from: number; to: number } | { type: 'replace'; items: readonly T[] };Most consumers never touch ArrayPatch directly — each(...) consumes the queue internally. Export the type when you want to observe patches from outside each() (e.g. logging, persistence layers, custom reconcilers).
8.2 Stores
Section titled “8.2 Stores”defineStore<TState, TActions>(spec): Store<TState, TActions>
Section titled “defineStore<TState, TActions>(spec): Store<TState, TActions>”defineStore({ initial: () => TState, actions: (set: (next: TState) => void, get: () => Readonly<TState>) => TActions,});Creates a store with state: ReadonlySignal<TState>, actions: TActions, reset(): void. Registers in the global registry consumed by resetAllStores().
set(next) REPLACES state; it does NOT merge. Pass the full state object on every call, or use set({ ...get(), ...patch }) to merge. When the diagnostics are installed (import 'kerfjs/dev'), get() returns a deep read-only Proxy so that any mutation of it — including a nested get().nested.x = 1 — throws a TypeError; reads (spread, JSON.stringify, Object.keys, iteration) are transparent, and the live state object is never frozen. Production returns the bare reference for zero overhead. Opt in to the runtime narrow-set warning with KERF_DEV_WARN_NARROW_SET=1 to catch partial-set bugs at the moment they happen (see docs/11-dev-warnings.md for the full dev-warn family).
resetAllStores(): void
Section titled “resetAllStores(): void”Calls reset() on every store registered via defineStore().
Store<TState, TActions> (type)
Section titled “Store<TState, TActions> (type)”interface Store<TState, TActions> { readonly state: ReadonlySignal<TState>; readonly actions: TActions; reset(): void;}clearStoreRegistry(): void — kerfjs/testing subpath
Section titled “clearStoreRegistry(): void — kerfjs/testing subpath”Empties the global store registry. Used by unit tests to isolate cases. Imported via the kerfjs/testing subpath, not the main kerfjs entry, so production builds don’t pull it in:
import { clearStoreRegistry } from 'kerfjs/testing';kerfjs/dev subpath — install the development diagnostics
Section titled “kerfjs/dev subpath — install the development diagnostics”kerf does not infer whether it is running in development. Importing this subpath is the development signal; omitting it is production. Gate the import with your own build’s dev flag, in your own code:
if (import.meta.env.DEV) await import('kerfjs/dev'); // Viteif (process.env.NODE_ENV !== 'production') await import('kerfjs/dev'); // webpack / NodeThat condition folds to false in your production build, so the statement is
eliminated and the chunk is never emitted or fetched — a realistic import is
12.24 KB min+gzip without it vs 16.91 KB when the diagnostics shipped in the
main bundle. A no-build/CDN app imports it from its development page and
omits it from the production page.
Installing enables: the whole KERF_DEV_WARN_* warning family (each still
requires its own switch — see enableWarnings() below), the structural list-invariant checks, the read-only
defineStore get() snapshot, and the throwing form of the dangerous-URL
screen (production warns and drops instead).
enableWarnings(options: DevWarningOptions): void
Section titled “enableWarnings(options: DevWarningOptions): void”Switches individual diagnostics on. Installing makes them PRESENT; this decides which are ON, so importing the entry never floods the console:
if (import.meta.env.DEV) { const dev = await import('kerfjs/dev'); dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });}Keys (all optional, all off unless named): rebuiltListeners,
untrackedSignals, narrowSet, delegateInEffect, eachInMorphSkip,
duplicateEachKeys, staleBinding, valueOnlyRerender, listRebind,
staleIndex, parserRepair, and invariants (true warns, 'throw'
throws). Each corresponds one-to-one with a KERF_DEV_WARN_* environment
variable — those still work for Node, SSR, and CI — and an explicit call wins
over the environment in both directions, so { narrowSet: false } silences an
ambient variable.
In a browser, this is the only switch there is. A browser realm has no
process object, and a bundler define cannot reach the read, so the
environment variables are unreachable in the environment where these warnings
are most wanted.
Call it as early as you can: every diagnostic reads its switch at call time
except untrackedSignals, which is chosen when a signal is created — enabling
that one prints its coverage boundary once.
The subpath also re-exports clearDevHooks(), installDevHooks() and
devHooks so a consumer’s own test suite can assert production-shaped
behavior without reloading modules.
Order matters in one place: signal() chooses its constructor at creation
time, so signals created before the dev entry runs are invisible to
KERF_DEV_WARN_UNTRACKED_SIGNALS — and since static imports hoist above a
top-level await import(), that is the common case. To cover module-scope
signals, make import 'kerfjs/dev' the first static import of a dev-only entry
file. Opting into that warning prints the boundary once so the gap is never
silent. Every other hook is read at call time. See
docs/11-dev-warnings.md §11.2.2 and §11.3.6.
8.3 Render
Section titled “8.3 Render”mount(rootEl: HTMLElement, render: () => MountResult): () => void
Section titled “mount(rootEl: HTMLElement, render: () => MountResult): () => void”type MountResult = SafeHtml | string | number | boolean | null | undefined;Bind render() to rootEl’s children. Wraps effect() with kerf’s segment-aware diff. Returns a disposer.
If rootEl belongs to an inert document (no browsing context — e.g. a DOMParser result, a <template>.content child, or document.implementation.createHTMLDocument() output), mount() adopts it into the live document first, so its first-render innerHTML write is safe on every engine (some engines mis-parse innerHTML on inert-document elements under rapid bursts). Roots that are already in the live document — the normal case — are untouched, and a live element in another realm (e.g. an iframe) is left in place. toElement() output is already adopted, so this only matters for hand-rolled roots.
MountResult is wide enough that consumers can write () => cond ? <jsx/> : null and () => cond && <jsx/> without a sentinel — matching the React / Solid convention. null / undefined / false / true coerce to “render nothing” (empty string); numbers stringify; everything else falls through String(...). See docs/4-render.md §4.1 (step 2 of the render pipeline) for the rationale and the equivalent fallback patterns. The MountResult type alias is exported from the main barrel for consumers that want to annotate their render functions explicitly.
The diff:
- Only ever touches
rootEl’s subtree;rootElitself is preserved. - Matches elements by
id, thendata-key. Position otherwise. - Short-circuits on the live element when:
- It has
data-morph-skip(element AND subtree preserved as-is; no attribute morphing). - It has
data-morph-skip-children(attributes morph; subtree preserved as-is). - It’s a list parent owned by
each(...)(children-only short-circuit;each’s reconciler owns those rows). Attribute morphing on the parent itself still happens. fromEl.isEqualNode(toEl)(no work needed).- It’s the focused
[contenteditable](entire subtree preserved on this morph; see §8.7 below anddocs/4-render.md§4.4).
- It has
- The trailing-removal pass (unmatched live children that the new template doesn’t emit) skips elements marked
data-morph-preserve— imperatively-injected nodes whose lifetime the consumer manages outside kerf. - Otherwise preserves the focused text-entry’s value + selection range, then proceeds.
Lists rendered with each(...) go through a separate keyed reconciler that operates directly on the live parent’s children — O(changes), not O(rows). See each below.
morph(liveRoot: Element, template: Element | SafeHtml | string): void
Section titled “morph(liveRoot: Element, template: Element | SafeHtml | string): void”One-shot in-place reconciliation primitive — the same algorithm mount() uses internally, exported for consumers that have an already-populated element they need to reconcile against a freshly-built template. Unlike mount(), morph() doesn’t wrap an effect() and doesn’t bulk-write innerHTML first: it runs once per call against the live tree as-is. When it mutates a checked / value / selected attribute it syncs the matching DOM property too, so controlled form state holds up after user interaction (the dirty-state flags would otherwise detach the visible state from the attribute); attributes the template never mentions are left alone.
import { morph, raw } from 'kerfjs';
morph(liveCard, freshlyBuiltCardEl); // Element templatemorph(liveCard, '<article class="card">…</article>'); // raw HTML stringmorph(liveCard, raw(htmlFromServer)); // SafeHtmlWhen template is a string or SafeHtml, kerf creates a transient element by cloning liveRoot’s shell (so the parsed children land inside an element with the same tag, which keeps innerHTML parsing rules consistent) and assigns the stringified template to its innerHTML. The transient is discarded after the reconciliation.
Every short-circuit mount()’s morph honors carries over: data-morph-skip (element + subtree preserved), data-morph-skip-children (attrs morph, subtree preserved), data-morph-preserve (element survives the trailing-removal pass), isEqualNode byte-identity skip, focused text-input value + selection preservation, focused-[contenteditable] subtree preservation, and <details> / <dialog>’s user-agent-owned open attribute. Match keys (id, then data-key) behave the same way.
morph() does NOT subscribe to signals. If you want re-renders, use mount(). If you want a one-shot reconciliation against a tree you own, this is the primitive. See docs/4-render.md §4.4.3.
A null / undefined liveRoot throws immediately with a descriptive error (the usual cause is a getElementById typo returning null at runtime despite the TypeScript types) — the same guard mount() applies to its rootEl.
Security — trusted templates only. A
string/SafeHtmltemplate is parsed as HTML with no escaping and no URL screening — the same trust model asinnerHTML/raw(). AnElementtemplate’s attributes are copied to the live tree verbatim, includingon*inline handlers andjavascript:URLs. So amorph()template must be markup you trust: built via JSX, or sanitized upstream (DOMPurify). Never pass unsanitized user input as amorph()template. (This is distinct from themount()render path, where JSX escapes values and screens dangerous URLs —morph()bypasses that because its template is already-built markup.)
each<T>(items, render, cacheKey?): SafeHtml
Section titled “each<T>(items, render, cacheKey?): SafeHtml”The third argument may instead be an options object —
each<T>(items, render, options?): SafeHtml — carrying cacheKey and/or
key. Both forms are supported; the options form is what you need when a list
requires a stable identity (see below).
each(rows.value, (row) => <tr data-key={row.id}>{row.label}</tr>);each(rows.value, (row) => <tr…>…</tr>, (row) => row.id === selectedId ? 1 : 0);each(rows.value, (row) => <tr…>…</tr>, { key: 'rows' });each(rows.value, (row) => <tr…>…</tr>, { key: 'rows', cacheKey: (row) => row.id === selectedId });Keyed list iteration with per-item memoization, routed through mount()’s native list reconciler. Skips re-running render for items whose object identity (and optional cacheKey) are unchanged since the previous call — those items keep their existing live DOM nodes verbatim. Items whose identity or cacheKey did change get a fresh node (all fresh-node HTML for a render is bulk-parsed in one innerHTML call); items that disappeared are removed. Reorders use a longest-increasing-subsequence pass so the number of insertBefore calls is the minimum possible. Items must be objects (cache is a WeakMap); wrap primitives if you need to iterate them. Each item’s render output must produce exactly one top-level element — and that element must survive HTML parsing as itself, so put an each() of <tr> inside an explicit <tbody> (a bare <table> makes the parser insert one, which kerf rejects with a precise error).
cacheKey is a passive comparator (not a reactive subscription): kerf calls it once per item per mount-effect run and compares the returned value against the previous run’s. Use it when external state, not the item itself, drives what the row should render (e.g. a “currently selected” id flips a CSS class). Distinct from data-key on the rendered element, which is the DOM-reconciliation identity that morph uses — cacheKey controls when the cached HTML is invalidated; data-key controls how a row maps to its existing live DOM node. (Renamed from key for clarity; positional callers — the canonical form — are unaffected.)
render receives (item, index). The index is the row’s position at render time; it is not part of the memo key (only item identity, cacheKey, and content version are). So a row that keeps its identity while its position changes — a reorder, or an insert/remove/move ahead of it — keeps the HTML it rendered at its old index, and any use of index in the output (a {index + 1}. prefix, zebra striping, an “N of M” label) goes stale on the moved rows. When the output depends on the index, fold it into the memo key so displaced rows re-render: each(items, render, { cacheKey: (_, i) => i }) (add a key if the list needs one). The opt-in KERF_DEV_WARN_STALE_INDEX=1 surfaces the hazard at runtime. (One edge this workaround doesn’t cover — an arraySignal batch whose fresh inserts displace each other — is noted in docs/4-render.md §4.2; for index-labeled rows with multi-insert batches, prefer immutable signal<T[]> updates.)
key gives the list a stable identity. Without one, a list is identified by its call order — “the n-th each() in this render” — so any render that changes how many each() calls run before it reassigns its identity, and kerf rebuilds the list from scratch: rows lose their DOM nodes, and with them focus, scroll position and in-progress IME composition. The common trigger is a conditional list rendered above another list. Give a key to any list that can be preceded by one:
{showFilters.value ? <ul>{each(filters.value, renderFilter, { key: 'filters' })}</ul> : ''}<ul>{each(results.value, renderResult, { key: 'results' })}</ul>A keyed list does not occupy a call-order slot, so keying just the conditional list is usually enough — its unkeyed siblings stop shifting too. Keys must be unique within a mount; two lists claiming the same key throw. In development, kerf warns once per list when it detects an identity shift and names the fix.
A key must be a non-empty string of letters, digits, or _ . : / - and may not contain -- — kerf writes it into the list’s marker comment in the DOM, so anything that could terminate a comment is rejected with an error rather than corrupting the mount.
each() does not nest. A row’s HTML is flattened to a string, so an each() called inside a row render never binds — it would render as inert static markup. Render an inner collection with plain .map() (it re-renders with its row), or restructure to a flat list. A keyed nested each() throws and says so.
If a descendant of a moved row holds focus, the reconciler snapshots the active element + its selection range before the move pass and re-applies them afterwards — so focus and caret position survive a reorder even on engines that drop focus on insertBefore (older Safari, happy-dom). See docs/4-render.md §4.4.
8.4 Event delegation
Section titled “8.4 Event delegation”delegate(rootEl, type, selector, handler, options?): () => void
Section titled “delegate(rootEl, type, selector, handler, options?): () => void”delegate(rootEl, 'click', '[data-action="add"]', (event, matched) => { ... });delegate(rootEl, 'focus', '.field-row', (event, row) => { ... });One root listener with closest(selector)-style walk-up matching; fires handler(event, matched) if the match is inside rootEl. Returns a () => void disposer — capture it and call it when the delegate’s scope ends (closing a modal, leaving a route, tearing down a widget). Discarding the disposer is only safe for genuinely page-lifetime registrations: top-level mount on a root that never tears down. Everywhere else the closure pins rootEl, handler, and everything the handler closes over, so an undisposed listener leaks the app graph and re-mounts stack listeners. mount()’s disposer does NOT remove delegates for you. See docs/5-event-delegation.md §5.3 — and §5.3’s “When capturing the disposer still isn’t enough” for the cluster of cases where capturing alone isn’t sufficient (delegate inside effect(), delegate on toElement() output that’s replaced, disposer variable overwrites, nested transient roots).
Auto-promotes the well-known non-bubbling event types (focus, blur, scroll, load, error, mouseenter, mouseleave) to capture phase under the hood, so the call site looks identical regardless of whether the event bubbles. Selector matching stays closest()-style for every event type — wrapper selectors still match when the event lands on a descendant.
The optional fifth argument is { match?: 'closest' | 'direct' } (see DelegateOptions below). It defaults to 'closest'; pass 'direct' to fire only when event.target itself matches the selector (no walk-up).
delegateCapture(rootEl, type, selector, handler, options?): () => void
Section titled “delegateCapture(rootEl, type, selector, handler, options?): () => void”Same shape, but installs on the capture phase. Selector matching is closest()-style by default — the same walk-up as delegate(), passing the matched ancestor (not the raw target) to your handler. The escape hatch — use it for custom non-bubbling events that aren’t in delegate()’s auto-promotion list, or when you want capture-phase semantics (run before any descendant’s bubble-phase handler). Same disposer-capture rule as delegate(). Pass { match: 'direct' } to opt into strict target.matches(selector) matching (fire only on the exact element the selector identifies, no walk-up).
DelegateOptions (type)
Section titled “DelegateOptions (type)”interface DelegateOptions { match?: 'closest' | 'direct';}Options accepted by both delegate() and delegateCapture(). match selects how the selector is applied to event.target: 'closest' (the default) walks up via closest(selector) and fires for the nearest matching ancestor inside rootEl; 'direct' fires only when event.target itself matches the selector.
attr(name, value) — static form
Section titled “attr(name, value) — static form”attr<N extends string, V extends string>(name: N, value: V): AttrSpec<N, V>Create a pre-computed attribute descriptor. Escapes name and value once at definition time; the resulting AttrSpec is frozen and ready to use in both JSX and delegate().
import { attr, type AttrSpec } from 'kerfjs';
const ACTIONS = { toggle: attr('data-action', 'toggle'), remove: attr('data-action', 'remove'),} as const satisfies Record<string, AttrSpec<'data-action'>>;
// In JSX — spread .attrs (rename-safe; no hardcoded attribute name at call sites):<button {...ACTIONS.toggle.attrs}>Toggle</button>
// In delegate — use the pre-computed selector:delegate(root, 'click', ACTIONS.toggle.selector, handler);// → '[data-action="toggle"]'attr(name) — dynamic form
Section titled “attr(name) — dynamic form”attr<N extends string, V extends string = string>(name: N): (value: V) => { readonly [K in N]: V }Pre-validates and pre-escapes the attribute name, then returns a factory for per-render values. Use for per-row data attributes like data-id where the value changes per item. Leaving both generics off infers N from the argument and defaults V to string; specify both explicitly to constrain which values the factory accepts.
const ITEM = { id: attr('data-id') } as const;
// In JSX — call the factory inline:<li {...ITEM.id(String(item.id))}>…</li>The attribute name is validated and CSS-escaped at creation; the factory result carries the raw value (escaped later by the JSX attribute renderer when spread). In the static form, both the name (CSS identifier) and value (double-quoted CSS string) are CSS-escaped into .selector at creation — SSR-safe, no CSS.escape dependency. Both forms throw on an empty attribute name.
For ad-hoc compound selectors, concatenate .selector strings:
delegate(root, 'click', ACTIONS.toggle.selector + attr('data-id', id).selector, handler);AttrSpec<N, V> (type)
Section titled “AttrSpec<N, V> (type)”interface AttrSpec<N extends string = string, V extends string = string> { readonly name: N; // raw attribute name readonly value: V; // raw attribute value readonly selector: string; // pre-computed '[name="value"]' selector string readonly attrs: { readonly [K in N]: V }; // spreadable JSX object}Generic type parameter: delegate<T extends Element = Element>()
Section titled “Generic type parameter: delegate<T extends Element = Element>()”Both delegate() and delegateCapture() accept an optional element-type generic that narrows the target argument in the handler, avoiding casts:
delegate<HTMLButtonElement>(root, 'click', 'button[data-action]', (e, btn) => { // btn is HTMLButtonElement — no cast needed btn.disabled = true;});The default is Element (untyped call sites are unaffected).
8.5 JSX runtime
Section titled “8.5 JSX runtime”import 'kerfjs/jsx-runtime' — TypeScript / esbuild config
Section titled “import 'kerfjs/jsx-runtime' — TypeScript / esbuild config”{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs" }}SafeHtml (class)
Section titled “SafeHtml (class)”class SafeHtml { readonly __html: string; constructor(html: string); toString(): string;}The return type of every JSX expression. .toString() returns the underlying HTML. (The listing above is the public contract, deliberately simplified: the emitted declaration’s constructor also accepts an internal Segment and the instance carries a __segment field — both internal plumbing for mount()’s list handling that consumers should not construct or read.)
SafeHtml instances carry a brand symbol — Symbol.for('kerfjs.SafeHtml') — so cross-bundle identification works even if a consumer’s bundler ends up loading two copies of kerf (e.g. the barrel and the JSX-runtime entry resolved as independent modules). Prefer isSafeHtml() over instanceof SafeHtml when writing custom integrations.
isSafeHtml(value: unknown): value is SafeHtml
Section titled “isSafeHtml(value: unknown): value is SafeHtml”Cross-bundle-safe type guard. Returns true for any object carrying the Symbol.for('kerfjs.SafeHtml') brand. Use this rather than instanceof SafeHtml if you’re inspecting JSX values yourself — instanceof fails when two copies of kerf produce structurally-identical-but-class-distinct SafeHtml instances.
raw(html: string): SafeHtml
Section titled “raw(html: string): SafeHtml”Wrap a pre-escaped HTML string. Useful for icons, rendered Markdown, server-included fragments.
Fragment (component)
Section titled “Fragment (component)”JSX <>...</> — concatenates children without a wrapper tag. Available from both kerfjs/jsx-runtime (used by the JSX transform) and the main kerfjs barrel (when you need to compose Fragment manually, e.g. jsx(Fragment, { children })).
html`…` — kerfjs/html subpath
Section titled “html`…` — kerfjs/html subpath”import { html, type HtmlValue } from 'kerfjs/html';
function html(strings: TemplateStringsArray, ...values: HtmlValue[]): SafeHtml;
type HtmlValue = | SafeHtml | string | number | boolean | null | undefined | ReadonlySignal<unknown> | readonly HtmlValue[];Tagged-template authoring path — the same SafeHtml output and the same runtime semantics as JSX, with no JSX transform required. Made for CDN / importmap consumers (“no build step” taken literally); it lives at its own subpath so JSX-only apps don’t ship it.
html`<div class="${cls}">Count: ${count}</div>`html`<ul>${each(items.value, (i) => html`<li id="${i.id}">${i.label}</li>`)}</ul>`Text/child holes follow the JSX child rules (escaping, number stringify, boolean/nullish → nothing, arrays, SafeHtml/each() passthrough, signal → fine-grained text binding, DOM nodes throw). Attribute holes follow the JSX attribute rules (boolean attributes, SafeHtml bypass, dangerous-URL screening, on*/malformed-name rejection, signal → fine-grained attribute binding). Two differences: attribute names are emitted verbatim (write class, not className — no camelCase aliasing), and holes are only legal in text positions or as a complete attribute value (attr=${v} / attr="${v}"); tag-name holes, attribute-name holes, partial values (class="a ${b}"), and holes inside comments throw. Static template parts pass through verbatim (same trust model as JSX tags/attrs). The static parts are parsed once per call site and cached by template-strings identity. See docs/6-jsx-runtime.md §6.11.
Custom-element typing via declaration merging
Section titled “Custom-element typing via declaration merging”Per-tag intrinsic-element interfaces live in src/jsx-types.ts and are aliased into the JSX namespace by src/jsx-runtime.ts. To add tags for custom elements / web components, declaration-merge into the kerfjs/jsx-runtime JSX namespace:
import type { KerfCustomElement } from 'kerfjs/jsx-runtime';
declare module 'kerfjs/jsx-runtime' { namespace JSX { interface IntrinsicElements { 'my-element': KerfCustomElement & { foo?: string }; } }}IntrinsicElements is exported as an interface (not a type alias) precisely to make this pattern work — type aliases can’t be merged. KerfCustomElement, KerfBaseAttrs, AttrLike, AttrValue, and DataAriaAttrs are all re-exported from kerfjs/jsx-runtime so apps can compose attribute types without reaching into the internal kerfjs/jsx-types path.
Attribute names, value sets, and per-element membership come from the WHATWG HTML Living Standard and SVG 2 — not from another framework’s table, which models a property surface rather than the content attributes kerf actually emits. Coverage is focused rather than exhaustive; the deliberate departures (lowercase aliases, contentEditable="inherit", the @deprecated presentational attributes) are enumerated in src/jsx-types.ts’s header. The rule that follows from it — boolean means boolean attribute, and HTML’s enumerated attributes take strings — is documented in docs/6-jsx-runtime.md §6.4.
Fine-grained signal bindings
Section titled “Fine-grained signal bindings”AttrValue / AttrLike (attribute values) and the JSX child type accept a ReadonlySignal<unknown> in addition to the usual string | number | boolean | null | undefined | SafeHtml. Passing a signal/computed itself into a JSX attribute (class={someSignal}) or text hole ({someSignal}) inside a mount() binds that hole fine-grained — the node updates on signal change without re-running render() or walking the reconciler. ReadonlySignal is used (covariant) so both signal(...) and computed(...) of any T are accepted. Outside a mount() (SSR / .toString()) the signal snapshots its current value. Full semantics, the “use computed() not a bare closure” rule, and the row-mutation staleness limitation are in docs/2-reactivity.md §2.9.
Dangerous URL filter
Section titled “Dangerous URL filter”Plain-string values passed to href, src, xlink:href, formaction, action, or data (<object data>) are screened by scheme (javascript: / vbscript: and script-executing data: document types). Matching values cause the attribute to be dropped entirely. In development the screen throws an Error with the diagnostic (fail loudly at your desk); in production it console.warns and drops (never crash a shipped app on attacker-influenced data) — the attribute is dropped in both modes, only the reporting differs, and production output is byte-identical to before. Mode follows whether kerfjs/dev is imported — kerf does not probe the environment. The javascript: no-op placeholders — javascript:void(0), javascript:void(0);, javascript:void 0, javascript:;, and a bare javascript: — are allowed: they are the placeholder-link idiom rather than an attack, and the match is against the whole normalized value so nothing can be appended to one. The screen is bypassed for SafeHtml (i.e. raw(...)) values in both modes — that’s the documented opt-out for legitimate cases (bookmarklet builders, sanitized-upstream URLs). Non-URL attributes are not screened. The same screen applies to fine-grained bound URL attributes (a href={sig} whose signal resolves to a dangerous value is dropped — throwing in dev, warning in prod). See docs/6-jsx-runtime.md §6.4.1 for the full rationale and examples.
8.6 Direct JSX → DOM
Section titled “8.6 Direct JSX → DOM”toElement(jsx: SafeHtml | string): Element | DocumentFragment
Section titled “toElement(jsx: SafeHtml | string): Element | DocumentFragment”Parses a JSX/SafeHtml/string and returns a DOM node ready to insert into a parent.
- Single-root input (one element child, surrounding whitespace OK) → returns the
Element. For<svg>roots and orphan SVG fragments (<path>,<g>,<circle>, …) the input is XML-parsed throughDOMParser('image/svg+xml')so the returned element is namespaced correctly and malformed SVG markup is rejected with a parse error. - Multi-root input (multiple elements, or any non-whitespace text alongside an element —
<><svg/> label</>,<>icon<text>icon</>, two icons side by side) → returns aDocumentFragmentcontaining every top-level node, including text nodes. Pass the result straight toparent.appendChild(...)/parent.replaceChildren(...)/parent.append(...)— the DOM insertion APIs inline aDocumentFragment’s children on insert and empty the fragment, so the caller never sees the wrapper. Nothing is silently dropped.
Throws if the input produces zero element children OR if DOMParser rejects an SVG input.
The returned node is always adopted into the live document (node.ownerDocument === document), never left owned by the inert <template> / DOMParser document it was parsed in. This matters when you operate on the node before inserting it — e.g. mount(toElement(<div/>), …), which sets innerHTML on first render. An inert-document element is unsafe to mutate that way on some engines (WebKit can mis-parse innerHTML on it under rapid bursts), so kerf moves the node into the live document up front. Identity and SVG/MathML namespaces are preserved by the adoption.
Security — trusted input only.
toElement()parses its input into live DOM with no escaping and no URL screening — the same trust model asinnerHTML/raw(). The SVG path is more dangerous than the HTML path: a top-level<svg><script>…</script></svg>, an SVG event attribute (onload,<animate onbegin>),xlink:href="javascript:", or<foreignObject>HTML all execute once the returned node is inserted into the live document — whereas an HTML-string<script>is inert (it’s parsed via<template>.innerHTML, which never runs scripts). Pass only markup you trust: built via JSX, or sanitized upstream with an SVG-aware sanitizer (DOMPurify). Never pass unsanitized user input.
8.7 Conventions used by mount
Section titled “8.7 Conventions used by mount”| Attribute | Effect |
|---|---|
id="..." | Used as a diff key. Highest priority. |
data-key="..." | Used as a diff key. Lower priority than id. |
data-morph-skip (any value, even empty) | Element AND subtree preserved as-is on every re-render. No attribute morphing on the element itself. |
data-morph-skip-children (any value, even empty) | Attributes on the element morph normally; the subtree is left as-is. For client-hydrated slots whose host state classes still need to flow through. |
data-morph-preserve (any value, even empty) | The element is skipped by the diff’s trailing-removal pass — survives across renders even when the new template doesn’t emit it. For imperatively-injected nodes (autoplay video, tooltip overlays, analytics pixels). Does NOT block a keyed-match move. |
| Element kind | Behavior when focused during a morph |
|---|---|
<input type="text" | "search" | "url" | "email" | "tel" | "password" | ""> | Live .value + selectionStart/selectionEnd copied to the morph target; morph proceeds (attribute updates apply). |
<textarea> | Same as text-entry inputs. |
[contenteditable] | Entire subtree skipped on this morph (same mechanism as data-morph-skip). User’s edit + caret + multi-range selection preserved verbatim; attribute updates deferred until the next render after blur. See docs/4-render.md §4.4. |
Anything else (<button>, <a>, <div tabindex>, non-text inputs…) | Morph proceeds normally — no special handling. |
| User-agent-owned attribute | Effect |
|---|---|
<details> open | The morph never removes open from a live <details> — the user agent toggles it on summary click and the diff treats it as user-owned. Trade-off: controlled-style <details open={false}> won’t auto-collapse a previously-opened details element; drive .open imperatively if you need controlled behavior. See docs/4-render.md §4.4.1. |
<dialog> open | Same as <details>. The browser sets open="" when .show() / .showModal() is called; the morph leaves it alone. |