Skip to content

Dev-mode warnings (opt-in)

A family of opt-in runtime warnings that surface common kerf misuse at the moment the developer makes the wrong call.

Two gates stand in front of every one of them. First, the diagnostics must be installed — kerf does not infer development mode; you import kerfjs/dev behind your own build’s dev flag. Second, each warning has its own feature-specific environment variable, so installing does not flood the console. Production is therefore unchanged at zero runtime cost and zero bundle cost — with the dev entry absent the whole family is unreachable and a bundler drops it.

This doc is the canonical statement of what the family is for, when each member fires, and the rules that keep them coherent.

The warnings here surface real misuse patterns, but each one has a non-trivial false-positive surface in real codebases:

  • A third-party widget that legitimately calls addEventListener on a node the consumer forgot to wrap in data-morph-skip.
  • A purely-imperative signal() used as a mutable cell with no UI consumer.
  • A store action that intentionally replaces state with a smaller shape (a reset() that drops keys, a feature-flag-driven schema change).

A warning that fires on every render in a real project is a warning that gets disabled and ignored. Opt-in lets CI and dev environments that want the diagnostic enable it explicitly while leaving the rest of the world untouched.

The opt-in shape also means production bundles short-circuit before any per-call work runs: core calls through a nullable hook slot, so with the dev entry absent the cost is one property read and the warner code is not in the bundle at all.

The dev-warns are the runtime layer. Two earlier layers catch related misuse before the program runs:

  • Strict TStsc --noEmit against properly-typed store state catches Hard Rule 9 partial-set bugs as type errors. All complete example apps in this repo are under that gate.
  • eslint-plugin-kerfjs — a separate publishable package, in eslint-plugin/, with eight rules that fire at edit time for hard-rule violations the dev-warns can’t see syntactically. Four are errors: no-inline-jsx-event-handlers (Rule 10), require-data-key-in-each (Rule 2), no-nested-mount (Rule 6), prefer-module-jsx-augmentation (Rule 12). Four warn: require-delegate-disposer (Rule 5), prefer-attr-selector, no-raw-with-dynamic-arg, ai-assistant-configs. Three additional rules cover non-hard-rule patterns: no-raw-with-dynamic-arg (XSS audit trail — warns on every dynamic raw() argument so the eslint-disable suppression becomes the permanent acknowledgment), prefer-attr-selector (rename-safety nudge for delegate() literal selectors), and ai-assistant-configs (project hygiene — checks that the bundled AI configs are installed and current).

The three layers are complementary, not redundant. Lint catches AST-shaped antipatterns at edit time; tsc catches type-shaped bugs at build time; the dev-warns catch the runtime patterns that need flow / call-graph information no static checker has.

KERF_DEV_WARN_REBUILT_LISTENERS=1 (Rule 4)

Section titled “KERF_DEV_WARN_REBUILT_LISTENERS=1 (Rule 4)”

Trigger: a node carrying an imperative addEventListener listener is removed from a mount()-managed tree (by the morph, by an explicit each() removal, or by a parent re-render). What it catches: Rule 4 violations — el.addEventListener('click', fn) on a node inside a mount tree, whose listener is lost the next time the morph rebuilds that subtree.

Mechanism. When mount() runs with the env var set, it installs (once per realm) a monkey-patch on EventTarget.prototype.addEventListener that marks each Element receiver with a Symbol.for("kerfjs.devListener") flag. A MutationObserver on the mount root watches for childList / subtree removals; any removed Element (or descendant of a removed subtree) carrying the marker fires the one-shot warning. The fix message points at delegate() and data-morph-skip as the canonical fixes.

Why opt-in. The monkey-patch is realm-wide — every imperative listener gets marked, including third-party widget code paths the consumer is using correctly. False-positive surface includes custom elements that attach listeners in their constructor and library-owned subtrees the consumer forgot to wrap in data-morph-skip.

KERF_DEV_WARN_UNTRACKED_SIGNALS=1 (Rule 8)

Section titled “KERF_DEV_WARN_UNTRACKED_SIGNALS=1 (Rule 8)”

Trigger: a signal’s .value is written when no subscriber has ever attached to that signal. What it catches: Rule 8 violations — reading signal.value outside a render fn or effect() callback (so the read doesn’t subscribe), then writing to it later and being surprised the UI doesn’t update.

Mechanism. With the env var set, signal() returns a DevSignal<T> subclass instead of the bare Signal<T>. The subclass uses signals-core’s SignalOptions.watched callback to set a per-instance __hasSubscriber flag — fired the first time any subscriber attaches. Writes to .value check the flag; if it’s still false on the first write, the one-shot warning fires. The flag is sticky — once set, it never clears, so a signal that was subscribed at some point won’t warn even after its subscribers detach.

Why opt-in. Purely imperative signals (used as mutable cells with no UI consumer) are legitimate and would always warn under this heuristic. The opt-in keeps the diagnostic available for UI-shaped projects without penalising data-pipeline-shaped projects.

Coverage boundary — this is the one warning whose reach depends on install order. Because the constructor is chosen when the signal is created, only signals created after kerfjs/dev is installed can ever warn. Static imports are hoisted above a top-level await import(), so in the common layout

import { counter } from './store.js'; // created HERE
if (import.meta.env.DEV) await import('kerfjs/dev'); // ...installs after

the module-scope signals this warning most wants to catch are created first, and it finds nothing. The fix: make import 'kerfjs/dev' the FIRST STATIC import of a dev-only entry file (static imports evaluate in order), then load the rest of your app.

Opting in prints this boundary once at install, so the gap is loud rather than silent — a diagnostic that quietly covers nothing is worse than no diagnostic, because the author concludes their code is clean.

Trigger: defineStore.set(next) is called with at least one key from the current state missing in next. What it catches: Rule 9 violations — set() REPLACES state, so a partial-set call wipes any keys not in next. The canonical bug shape is set({ filter }) against a 3-key state of {items, filter, editingId} — the next read of items returns undefined and the next action that calls items.map(...) throws.

Mechanism. Each defineStore carries a per-instance one-shot context object ({ warned: boolean }). On every set() call, maybeWarnNarrowSet(prev, next, ctx) runs the gate: short-circuit on NODE_ENV / env var, short-circuit on non-plain-object state (arrays, null, primitives), then check Object.keys(prev).some(k => !(k in next)). If any key is missing, the warning fires once for this store and the context flips to warned: true. The warning message names the missing keys (e.g., `items`, `editingId`) and points at set({ ...get(), ...next }) as the canonical merge fix.

Why opt-in. Narrow-set IS legal — a reset() action that drops keys, a feature-flag-driven schema change, a state shape that genuinely needs to shrink. The warn would fire on every legitimate shape-shrinking call otherwise. Opt-in lets dev/CI environments that want the diagnostic enable it without penalising consumers who use the shape-shrinking pattern intentionally.

Trigger semantics — “any missing key,” not “fewer total keys.” A set({ a, c }) against { a, b } (same count, different keys) also wipes b, so the warner fires. The bug-shape is “at least one key from current is missing in next”; key-count is just an implementation detail that would have missed same-count-different-keys cases.

Trigger: eachSnapshotById (the core render path of each()) discovers that two or more items in the same list produce the same value from the cacheKey function. Only fires when a cacheKey function was actually provided (if no third arg is passed to each(), the check is skipped entirely).

What it catches: a cacheKey function that isn’t unique per item, which makes the memoization coarser than intended — distinct items share the same invalidation key, so when external state changes and the cacheKey would logically differ for only one of the duplicates, the cached HTML for the other is also invalidated (or not invalidated, depending on state direction). In practice this is not a correctness bug (the per-item HTML cache is a WeakMap keyed by object identity, so there’s no cross-item cache pollution), but it IS a reliable indicator of a mistake in the cacheKey function — e.g. (item) => item.category when the intent was (item) => \${item.id}-${selectedId === item.id ? ‘on’ : ‘off’}“.

Mechanism. maybeWarnDuplicateCacheKeys(id, segItems) is called at the end of eachSnapshotById when cacheKey !== undefined. The function: (1) short-circuits on NODE_ENV / env var; (2) checks a module-level warnedDupIds Set for dedup; (3) iterates segItems collecting cacheKey values into a Set, and fires a warning the first time a duplicate is found.

Dedup scope. Per list id (same as KERF_DEV_WARN_EACH_IN_MORPH_SKIP). One warning per each() callsite.

Why opt-in. Duplicate cacheKey values are not a correctness bug — they’re a code-smell. Projects that intentionally use a coarse cacheKey (e.g. grouping rows by type so a type change invalidates the whole group) would see spurious warnings.

Trigger: bindListsFromMarkers (called by mount() on every first-render or newly-appearing list) discovers that a new list binding’s liveParent has a data-morph-skip ancestor between it and the mount rootEl. What it catches: the asymmetric-freeze pattern — each() rows inside a data-morph-skip subtree still update (the keyed reconciler operates directly on the live parent independently of the morph), but static signal-reactive JSX inside the same skipped ancestor is frozen because the morph short-circuits before visiting that element’s children.

Mechanism. maybeWarnEachInMorphSkip(id, liveParent, rootEl) is called after the binding is created. The function: (1) short-circuits on NODE_ENV / env var; (2) checks a module-level warnedIds Set for dedup; (3) walks from liveParent up to rootEl looking for any ancestor with data-morph-skip; (4) if found, fires a console.warn naming the list id, explaining the asymmetry, and pointing at removing data-morph-skip as the fix.

Dedup scope. Per list id (the internal sequential id assigned by the render context counter — stable across renders within a mount). One warning per each() callsite, not one per render pass.

Why opt-in. Placing an each() list inside a library-owned data-morph-skip element is uncommon but occasionally intentional (e.g., the library provides the host while kerf manages the rows). The warning would fire on every such legitimately-structured mount otherwise.

Trigger: delegate() or delegateCapture() is called while the call stack is inside an effect() body. What it catches: the listener-stacking pattern documented in the event-delegation guide — every effect re-run executes its body fresh, so a delegate() call inside the body installs a NEW root listener on each re-run. The effect’s disposer cleans up the reactive subscription but not the side-effects the body produced, so previous listeners stay attached, the per-listener closures pin rootEl / handler / everything the handler closes over, and listener count grows linearly with signal churn.

Mechanism. With the env var set, kerf’s effect() factory wraps the user body in enterEffect() / exitEffect() calls that increment and decrement a module-level depth counter. Both delegate() and delegateCapture() call warnIfInsideEffect() at the top of their bodies; the function checks the env-var gate, then the depth counter; if depth > 0 it fires a one-shot console.warn naming the caller (delegate vs delegateCapture) and pointing at “register once at module / setup scope and gate behavior on the signal inside the handler” as the fix. The wrap also uses try / finally so a body that throws still decrements the counter — a thrown effect doesn’t leave the depth permanently incremented.

Dedup scope. One warning per process. Structurally identical to the rebuilt-listeners warning — the signal is “your code has this antipattern”; firing once is enough to direct attention. A consumer who fixes the first instance and has another won’t be told twice in the same process, but they’ll see it on the next run.

Why opt-in. No realistic kerf code legitimately calls delegate() inside an effect() body — but the wrap of effect() itself adds a microscopic call-frame overhead, so the bare coreEffect re-export stays the default path when the env var is unset. Production NODE_ENV short-circuits before the wrap decision; production bundles see the bare re-export with zero overhead.

Trigger: mount() takes its fast path (a re-render whose static-surrounds HTML is byte-for-byte identical to the previous render, so the morph AND the fine-grained binding re-wiring are both skipped) and a GLOBAL (static-surround) hole registers a different signal instance than the one currently wired. What it catches: the silently-stale-binding pattern documented in the reactivity guide — class={cond ? sigA : sigB} (switching which signal instance a hole binds while the surrounds string is unchanged). On the fast path kerf keeps the original binding effect bound to sigA and never re-binds to sigB, so the hole freezes: no error, the UI just stops updating.

Mechanism. mount() retains the global-hole binding list that is actually wired (prevWiredBindings, refreshed whenever wireBindings runs — first render and every surrounds-changed morph). On a fast-path render it calls maybeWarnStaleBinding(prevWiredBindings, bindingCtx.list), which: (1) short-circuits on NODE_ENV / env var; (2) walks the two lists in registration order (they describe the same holes in the same order on the fast path); (3) when a hole’s signal instance differs and hasn’t already warned, fires a one-shot console.warn naming the hole (kind / attr / id) and pointing at “bind one computed that switches internally” as the fix. The retention itself is gated on the same opt-in, so the fast path stays allocation-free when the warning is off.

Dedup scope. Per hole (the stable per-hole binding id). One warning per switched hole, not one per render pass.

Why opt-in. The comparison is raw signal identity, so a global hole bound with a fresh inline computed(() => …) — a new instance every render, but reading the same signals, hence safe — would also differ on the fast path and warn. Binding a stable signal / computed reference for global holes (the idiomatic shape) avoids that; the opt-in gate keeps the diagnostic available without penalising projects that pass a fresh inline computed into a global hole. (Row holes inside each() are wired per-row-node and disposed on row removal, so they never reach this warner.)

Trigger: a mount() re-render whose static-surrounds HTML changed (the byte-compare failed, so the full morph pass runs) but where every difference is confined to text content and attribute values — no element added, removed, moved, or retagged. What it catches: value holes written as .value reads that could have been fine-grained bindings. Under the “values bind, structure re-renders” idiom (see the reactivity guide), a value-only re-render means the whole render + parse + morph pass was avoidable: passing the signal/computed itself ({count}, class={sig}) updates just the changed nodes — and a mount whose render reads no .value never re-renders at all.

Mechanism. On a surrounds-changed render with the gate open, mount() calls maybeWarnValueOnlyRerender(prevHtml, nextHtml, ctx), which parses both strings into detached <template>s and walks the two trees in lockstep. Text data and element attributes (names and values — a boolean attribute appearing/disappearing is a value change, since falsy attributes are omitted) may differ; any change of child count, node type, tag name, or comment data classifies the render as structural and nothing fires. Conservative by construction: false negatives are acceptable, false positives would erode trust in the guidance.

Dedup scope. Once per mount (a per-mount context object), not per render.

Why opt-in. Re-rendering on .value reads is correct — this is a migration aid for adopting the bound-first idiom, not a lint on broken code. The parse-and-compare also has real (dev-only) cost, so it runs only when asked, and only on the already-slow surrounds-changed path; the env read short-circuits everything else.

Trigger: bindListsFromMarkers takes its self-heal branch — a list marker found in the live tree already has a binding, but that binding’s own marker is no longer inside the mount root, meaning the morph rebuilt the list’s container this render and cloned a fresh marker. Two shapes reach it: an ancestor’s tag changed, so replaceChild swapped the whole subtree; or a same-tag sibling positionally took the container’s place (a <ul> banner rendered before a <ul> list). A conditional element inside the list parent that merely shifts the marker does NOT reach it — the morph’s marker-aware lookahead moves the marker and its rows up as a unit, so the binding survives and no rows are re-created. What it catches: the lossy-recovery pattern. The self-heal makes the rebuild correct — the stale binding is dropped, its still-live stranded rows are removed, the list re-binds against the fresh marker, and the next reconcile repopulates the rows — but the rows are re-created from scratch, so focus, scroll positions, in-progress IME composition, and any imperative listeners on the old row nodes are silently discarded. An author who didn’t intend the rebuild gets no other signal that their rows are being churned. (A conditional sibling merely appearing or disappearing before the list’s container does NOT fire this — the morph’s positional lookahead preserves the container in place for that shape.)

Mechanism. maybeWarnListRebind(id, liveParent) is called from the self-heal branch after the stale binding is dropped, with the fresh container the cloned marker landed in. The function: (1) short-circuits on NODE_ENV / env var; (2) checks a module-level warnedIds Set for dedup; (3) fires a console.warn naming the list id and container tag, explaining the row-state loss, and pointing at the fix: give the list’s own container a stable id/data-key (which makes it both un-hijackable positionally and findable by key), plus stable ancestor tags. The message explicitly steers away from keying the conditional sibling, which only helps in the removal direction — when the sibling reappears its key has no live counterpart, the diff falls back to position, and the unkeyed container is taken over anyway.

Dedup scope. Per list id (same as KERF_DEV_WARN_EACH_IN_MORPH_SKIP). One warning per each() callsite, not one per rebuild.

Why opt-in. Swapping an ancestor’s tag across renders (<section><article> around the same list) is occasionally intentional — semantic element changes driven by state — and the rebuild-with-repopulate behavior is then exactly what the author wants. The opt-in keeps the diagnostic available for projects that want it without penalising that pattern.

Trigger: an each() reconcile reuses a memoized row at an index different from the one it was rendered at, while the row’s render function declares an index parameter (render.length >= 2). What it catches: a stale index argument. each(items, (item, index) => …) passes the row’s position, but each() memoizes a row’s HTML by object identity (plus cacheKey plus content version) — the index is not part of that key. So a reorder, or an insert / remove / move ahead of a surviving row, serves that row’s cached HTML, which was computed at its OLD index. A numbered list ({index + 1}. …), zebra striping, or an “N of M” label silently shows the wrong number, while every other row looks right.

Mechanism. The row memo (CacheEntry) records the index each entry was rendered at. Two sites detect a shift:

  • Snapshot path (eachSnapshotById): on a cache HIT, if the entry’s stored index differs from the row’s current index, warn (a plain-array reorder).
  • Granular path (eachGranular): the applied arraySignal patches are replayed against the pre-batch row count to see whether any patch shifts an existing row — a non-tail insert / remove, or any move. A pure tail append or tail remove shifts nothing and does not warn.

Both are gated on render.length >= 2 first (a list that never reads the index can never go stale) and on the env-var opt-in, so the check is skipped entirely otherwise. The message names the list id and the fix: fold the index into the memo key with each(items, render, { cacheKey: (_, i) => i }) (combined with an explicit key if the list has one), so a shifted row re-renders.

Dedup scope. Per list id (module-level warnedIds Set), same as KERF_DEV_WARN_EACH_IN_MORPH_SKIP. One warning per each() callsite.

Why opt-in. render.length >= 2 is a heuristic: a render fn may declare the index parameter and never use it in its output, in which case a reorder is harmless and the warning is a false positive. And the fix carries a cost — folding the index into the memo key means a shift re-renders every displaced row (O(n) on a structural change), which a list whose index only labels never-reordered rows should not pay. Opt-in keeps the diagnostic available without penalising either shape.

Parser repairs (KERF_DEV_WARN_PARSER_REPAIR=1)

Section titled “Parser repairs (KERF_DEV_WARN_PARSER_REPAIR=1)”

Trigger: the rendered markup puts a block-level element inside a <p>. What it catches: the structure you wrote silently not being the structure you get.

kerf renders JSX to an HTML string and lets the parser build the DOM, so the parser’s content-model repairs apply. <p> may contain only phrasing content, so the parser closes it before a block-level child:

<p><section>head</section><ul>{each(rows, …)}</ul></p>

parses as <p></p><section>head</section><ul>…</ul> — an empty <p>, with every child hoisted to be its sibling.

What it costs, precisely. Less than it looks. kerf reconciles the tree the parser actually produced, and does so consistently: updates, list inserts and conditional toggles all behave correctly afterwards. Nothing is corrupted in the ordinary case. What you lose is the shape you wrote — your <p> is empty, your children are elsewhere, and any CSS or querySelector that assumed the nesting quietly stops matching.

Why it warrants a warning rather than a doc note: distance. The symptom is “my list isn’t inside the element I put it in”, three levels away from the <p> that caused it, and nothing in the JSX looks wrong. The fix is a one-liner (use a <div>, or move the block content out) once you know which tag pair to look at.

Why opt-in: detection scans the emitted HTML rather than re-parsing it. A block-level open tag before a </p> is an unambiguous repair signal in kerf’s own well-formed output, but it is a heuristic rather than a proof, so the family’s opt-in default keeps that judgement with the consumer. One-shot per offending tag pair.

Related: the each() row contract already throws for the <table>/implicit-<tbody> case, which is the same family of repair reaching kerf’s row binding rather than its static surrounds.

List identity shift (always-on, not opt-in)

Section titled “List identity shift (always-on, not opt-in)”

Trigger: eachGranular finds that a list id’s recorded data source has changed — i.e. this call-order id is now a different list than it was last render. What it catches: the silent cost of unkeyed list identity. A list without an explicit key is identified by its call order, so any render that changes how many each() calls run before it reassigns its identity; the list is then rebuilt from scratch and its rows lose DOM identity, focus, scroll position and in-progress IME composition, at O(rows) instead of O(changes). See the list-identity guide.

Mechanism. eachGranular records an unkeyed list id as a candidate when its recorded data source changed; mount() reports candidates at the end of the render, and only when the render’s each() call count ALSO changed — which is what an id shift actually requires. A changed source on its own is not a shift: the same list swapping which signal it renders (a filter or tab switch) changes source too, and warning there told authors to fix correct code. Keyed lists are excluded entirely, since a key is the identity. It runs only when the dev entry is installed (core reaches it through the listIdShift hook slot), dedups on a per-render-context warnedIds Set, and names the fix: each(items, render, { key: 'my-list' }) — plus the non-obvious part, that keying the conditional list is usually enough, because a keyed list does not occupy a call-order slot.

Dedup scope. Per list id, per mount — the set lives on the render context, because ids are per-mount and a module-level set meant the first mount to warn for id '0' silenced every other mount’s genuine shift forever.

Why always-on rather than env-gated. Same reasoning as the missing-row-key warning: it fires only when kerf is about to silently discard row state, it has no legitimate-use false-positive surface (an author never wants a list rebuilt by accident), and it names a one-line fix. Opting into a warning you would always want is friction with no benefit.

Throughout this doc, “always-on” means “always on once kerfjs/dev is installed” — it is the per-warning env var these skip, not the install step. The double-mount guard is the sole exception: it is unconditional in every build, dev entry or not, because it throws on a structural error rather than warning about a pattern.

Known blind spots. Two shapes stay invisible: a shift between two each() calls over the same arraySignal (indistinguishable by source), and two unkeyed lists swapping order at a constant call count. Both are the price of a conservative trigger, and keys close both by construction — which is what the message asks for.

Double-mount guard (always-on, not opt-in)

Section titled “Double-mount guard (always-on, not opt-in)”

Trigger: mount(el, render) is called on an element that is already the root of a live mount, or on a descendant or ancestor of such an element. What it catches: the “two competing effects” pattern — two mount() calls on the same DOM subtree both install effect() watchers that fight over the same live nodes, producing conflicting DOM mutations and unpredictable rendering output with no runtime error.

Mechanism. mount() assigns a non-enumerable Symbol.for('kerfjs.mounted') marker to the rootEl at mount time. assertNotInsideMountedTree() checks the element itself (same-element double-mount), all ancestors (descendant-of-mounted mount), and all descendants (ancestor-of-mounted mount) before allowing the mount to proceed. If any check fires, it throws with a message naming the element (<tagName> or <tagName#id>) and pointing at the disposer as the fix. The disposer returned by mount() deletes the marker, so a legitimate unmount + remount cycle never false-positives.

This guard is unconditional — it does not even require the dev entry. Double-mounting is almost never intentional — it’s a programming error in every realistic scenario (hot-reload teardown missing, copy-paste island setup, conditional mount() hitting the same element on re-evaluation). A console.warn would let the broken two-effect state continue running, which is harder to debug than an immediate throw. The nested-mount cases (mount(ancestor) after mount(descendant)) are structural errors that must also fail hard. Unlike the opt-in family, there is no env var to silence this guard.

Sibling mounts are allowed. Two mount() calls on independent elements (neither is an ancestor or descendant of the other) work correctly — each manages its own subtree. This is the multi-island pattern for apps with independently reactive regions of the page.

Dangerous-URL screen (throws in dev, warns in prod)

Section titled “Dangerous-URL screen (throws in dev, warns in prod)”

Trigger: a plain-string URL value that resolves to a javascript: / vbscript: scheme or a script-executing data: document type is written to a URL-bearing attribute (href, src, xlink:href, formaction, action, data). What it catches: a stored-XSS payload reaching a href={...} interpolation that would otherwise turn into a clickable script vector. See the JSX runtime doc for the screening details.

Mechanism. The attribute is always dropped (omitted from the string / removed from the live node). How the drop is reported depends on the mode: in dev the screen throws an Error with the diagnostic; in prod it console.warns and drops. Mode comes from whether the diagnostics are installed: kerfjs/dev fills the urlScreenThrow hook slot, so importing it selects the throwing behavior and omitting it selects warn+drop. kerf no longer probes the environment to decide — which also fixes the case where a production browser bundle inferred DEVELOPMENT and threw on attacker-influenced data. raw() / SafeHtml values are the documented bypass in both modes.

Like the double-mount guard, this is always-on (unconditional), not opt-in — there is no env var to silence it, only the mode split. A dropped-but-silent dangerous URL in dev is the exact failure mode the throw fixes (nobody reads the console; the attribute just quietly vanishes). Production keeps the non-crashing warn+drop so attacker-influenced data can never take down a shipped app — production output is byte-identical to before this split. This is the one place kerf changes behavior between dev and prod for the same input; it’s justified because the dev throw only ever fires on input a correct app would never produce (a dangerous URL that isn’t wrapped in raw()).

Structural invariant checks (KERF_DEV_INVARIANTS)

Section titled “Structural invariant checks (KERF_DEV_INVARIANTS)”

Trigger: a list binding disagrees with the live DOM. What it catches: the state that precedes a wrong render, at the render that created it.

Unlike everything else in this family, this one does not describe a pattern the author should change — it reports a kerf bug. It exists because every reconciler defect found so far shared one property: kerf kept running happily in a corrupt state, and the damage surfaced several operations later as a wrong render, far from its cause. Each check below is the negation of a defect that actually shipped:

CheckThe defect it would have caught
marker-livea binding whose marker left the tree — every later reconcile mutates a detached parent
marker-idan id carried by a different marker node, which pointed an arriving list at the previous occupant’s container
row-parent / row-livea binding holding rows that are detached or attached elsewhere — the “stranded rows” shape
row-orderrows that no longer follow their own marker in document order
row-aliasone row node claimed by two bindings
region-overlaptwo lists in one parent interleaving their rows
row-counta binding holding a different number of rows than the data it rendered from. Every other check is internal — it confirms the binding agrees with the live DOM — so a list that reconciled to the wrong count still passes them if it’s self-consistent. Comparing against the source length is the one check that catches a list rendering too few or too many rows; it is what would have caught the self-healed-empty-binding defect — a binding that was internally perfect and externally blank — at the render that caused it. Supplied per-list by mount() only when the checks are enabled, so production pays nothing.

Modes. KERF_DEV_INVARIANTS=1 warns; KERF_DEV_INVARIANTS=throw throws. Unset (the default) is a complete no-op — the DOM is never walked. The throw mode exists because a warning inside a passing test is invisible. Consumers debugging a suspected reconciler bug want 1 first.

Cost. O(rows) per render when enabled, zero when not. Like the rest of the family the checks live in the dev chunk, so a production build that never imports kerfjs/dev cannot reach them at all.

Every dev-warning in this family follows the same shape.

  1. Reachable only through the dev entry. Core never imports a warner. It reads a nullable slot off the devHooks registry and calls through it (devHooks.listRebind?.(id, parent)), and only kerfjs/dev fills those slots. A consumer who never imports the dev entry pays one property read per call site, and the warner module is not in their bundle at all.
  2. Per-warning switch. Each warning has its own switch, named the same way in both places it can be set: enableWarnings({ narrowSet: true }) in code, or KERF_DEV_WARN_NARROW_SET=1 in the environment. There is intentionally no umbrella “all warnings” flag — opt-in is per-warning, so a consumer can enable the Rule 4 warner while leaving the Rule 9 warner off.
  3. Default off. Every warning is off by default. The env-var =0 and the unset state both mean off.

enableWarnings() (exported from kerfjs/dev) writes to an in-memory map; globalThis.process?.env?.KERF_DEV_* is read when nothing has overridden it. Both funnel through one internal lookup, and an explicit call wins in BOTH directions — { narrowSet: false } silences a warning an ambient variable switched on.

The env var alone could not be the switch, because it is unreachable in the majority case. A browser realm has no process object, so every one of these warnings was permanently off in exactly the environment — a Vite/webpack dev server — where a developer most wants them. A bundler define does not rescue it either: the read goes through globalThis.process into a local binding, so nothing substitutes the process.env.X token. That indirection is deliberate. The environment variables remain the natural switch for Node, SSR, and CI.

The consumer already holds the module at the moment they opt in (const dev = await import('kerfjs/dev')), which makes a typed function there both the most reachable switch and the most discoverable one — the option keys autocomplete, where an env-var name has to be remembered exactly.

Each warning fires at most once per “owner”: once per mount() for rebuilt listeners, once per signal for untracked signals, once per store for narrow sets. The dedup scope is the smallest unit that meaningfully represents “the developer has now seen this warning for this owner” — not module-global (which would make a second buggy store invisible after the first warns) and not per-call (which would spam every render).

Every warning message ends with:

Set KERF_DEV_WARN_<NOUN>=0 (or unset it) to silence this warning.

This is the consumer’s escape hatch — they can disable the warning without rolling back the env var entirely. The message also names the canonical fix (e.g., “Use delegate(),” “Use set({ ...get(), ...next })”) so the developer doesn’t need to fetch additional docs to act on it.

None of the warners are re-exported from the main kerfjs barrel. Consumers don’t import them individually; the warning is a runtime behavior of the host primitive (signal(), mount(), defineStore) once the dev entry is installed and the env var is set. The internal warner modules are not part of the public barrel.

The one deliberate exception is the kerfjs/dev subpath itself, which is a side-effect import rather than an API — plus clearDevHooks / installDevHooks / devHooks re-exported from it so a consumer’s own test suite can assert production-shaped behavior without reloading modules.

This keeps the public surface small and means a consumer’s IDE autocomplete doesn’t suggest dev-warning APIs they shouldn’t touch.

A production bundle pays nothing for this family, on both axes — and the bundle axis is the one that used to be untrue.

Runtime cost: one property read. Core call sites are devHooks.someHook?.(...). With nothing installed the slot is undefined and the call short-circuits. The few sites that would otherwise do expensive preparatory work — capturing the previous render’s binding list, allocating a per-render Map for the invariant checks — consult a …Enabled predicate slot first, so they skip the work rather than doing it and discarding it.

Bundle cost: zero bytes. Because nothing in the main entry references a warner module, the whole family is unreachable and a bundler drops it. The consumer’s own dev flag is what makes this work:

if (import.meta.env.DEV) await import('kerfjs/dev');

In a production build that condition folds to false, so the statement is eliminated and the chunk is never emitted, let alone fetched. Measured with a Rollup consumer: a realistic import (signal, computed, effect, batch, mount, each, delegate) is 12.24 KB min+gzip with the dev entry absent vs 16.91 KB when the family shipped in the main bundle — 4.67 KB, 27% of the bundle.

The fast-path benchmark numbers are taken without the dev entry installed, so production behavior is what’s measured.

Installing the diagnostics — kerf does not infer dev mode

Section titled “Installing the diagnostics — kerf does not infer dev mode”

kerf has no idea whether it is running in development, and deliberately does not try to find out. Importing kerfjs/dev is the development signal.

// Your entry file. YOUR flag, YOUR bundler, YOUR build.
if (import.meta.env.DEV) await import('kerfjs/dev'); // Vite
if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev'); // webpack / Node

A no-build/CDN app imports it unconditionally from its development page and simply omits it from the production page — there is no bundler to fold a condition, and nothing for kerf to detect.

Where to see it. Every complete example app in this repo opens with that line directly under its imports, so the idiom is visible in the first screen of any example a reader opens. The no-build app is the deliberate counter-example: live-poll maps kerfjs/dev in its importmap but never imports it, because what that page serves is the production app — see the no-build example doc. A component package must not install the diagnostics at all; that decision belongs to the consuming app (building reusable component packages).

Two costs the install line carries. It makes the entry module top-level-await, which requires an ESM output format (esbuild and Rollup support it for format: 'esm'; an iife/cjs build fails to emit). And it needs import.meta.env to be typed — real apps get that from /// <reference types="vite/client" />.

Install ordering. Every hook except one is read at call time — render, reconcile, set(), delegate() — so installing any time before your first mount() is enough. The exception is signal(), which picks its constructor when the signal is created. Static imports are hoisted above a top-level await import(), so module-scope signals in imported modules are created before the dev entry runs and KERF_DEV_WARN_UNTRACKED_SIGNALS will not see them. To cover those, make import 'kerfjs/dev' the first static import of a dev-only entry file, or load your app through a dynamic import after it.

Two layers, not one. Installation decides whether the diagnostics are present; each individual warner still reads its own KERF_DEV_WARN_* env var to decide whether it is switched on. Installing the dev entry does not flood the console — it makes the opt-in warnings available.

Uninstalling. kerfjs/dev re-exports clearDevHooks() (and installDevHooks() / devHooks) so a consumer’s test suite can assert production-shaped behavior without module-reload gymnastics.

globalThis.KERF_DEV and NODE_ENV mean nothing to kerf. Not importing the dev entry is the escape hatch for turning the diagnostics off, and it is a compile-time one. Each warner reads only its own KERF_DEV_WARN_* variable. Whether the diagnostics run is decided in exactly one place: whether you imported them.