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. Each warning is gated by both NODE_ENV !== 'production' and a feature-specific environment variable, so production behavior is unchanged for zero runtime cost; existing dev environments aren’t surprised either (every warn is off by default).

This doc is the canonical statement of what the family is for, when each member fires, and the rules that keep them coherent. New dev-warnings added in the future must follow the same shape.

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 can short-circuit before any per-call work runs — the env-var read is the first thing each warner does, so the production-mode cost is one truthy-check at instantiation.

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 five AST-only rules that fire at edit time for hard-rule violations the dev-warns can’t see syntactically: no-inline-jsx-event-handlers (Rule 10), require-data-key-in-each (Rule 2), require-delegate-disposer (Rule 5), no-nested-mount (Rule 6), prefer-module-jsx-augmentation (Rule 12). Two 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) 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.

11.2.1 KERF_DEV_WARN_REBUILT_LISTENERS=1 (Rule 4)

Section titled “11.2.1 KERF_DEV_WARN_REBUILT_LISTENERS=1 (Rule 4)”

Module: src/dev-listener-warn.ts (KF-174). 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.

11.2.2 KERF_DEV_WARN_UNTRACKED_SIGNALS=1 (Rule 8)

Section titled “11.2.2 KERF_DEV_WARN_UNTRACKED_SIGNALS=1 (Rule 8)”

Module: src/dev-signal.ts (KF-176). 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.

11.2.3 KERF_DEV_WARN_NARROW_SET=1 (Rule 9)

Section titled “11.2.3 KERF_DEV_WARN_NARROW_SET=1 (Rule 9)”

Module: src/dev-store-warn.ts (KF-212). 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.

11.2.4 KERF_DEV_WARN_DUPLICATE_EACH_KEYS=1

Section titled “11.2.4 KERF_DEV_WARN_DUPLICATE_EACH_KEYS=1”

Module: src/dev-each-warn.ts. 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.

Module: src/dev-each-warn.ts. 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.

Module: src/dev-delegate-warn.ts (KF-238). Trigger: delegate() or delegateCapture() is called while the call stack is inside an effect() body. What it catches: the listener-stacking pattern documented in docs/5-event-delegation.md §5.3 “When capturing the disposer still isn’t enough” — 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, reactive.ts’s effect() factory wraps the user body in enterEffect() / exitEffect() calls that increment and decrement a module-level depth counter in dev-delegate-warn.ts. 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 KF-174 (rebuilt listeners) — 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.

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

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

Module: src/mount.ts (KF-175, KF-225). 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 always-on (unconditional), not opt-in. 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.

Every dev-warning in this family follows the same shape. New warnings added to the family must too.

  1. Production short-circuit. The first thing every warner does is read process.env.NODE_ENV. If it’s 'production', return without any further work. The cost in production is one property read.
  2. Per-warning env var. Each warning has its own KERF_DEV_WARN_<NOUN>=1 env var. There is intentionally no umbrella KERF_DEV_WARN=1 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.

Each warning fires at most once per “owner”: once per mount() for KF-174, once per signal for KF-176, once per store for KF-212. 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).

For testability, each warner exports a _resetWarnedForTests() / _resetWarnContext(ctx) helper that re-arms the first-warning path. These are not on the public dist barrel; tests import them via the relative ../../src/dev-...-warn.js path. Test files that exercise this state are named *.internal.test.ts so the dist-full suite excludes them.

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; the warning is a runtime behavior of the host primitive (signal(), mount(), defineStore) when the env var is set. The internal modules (src/dev-listener-warn.ts, src/dev-signal.ts, src/dev-store-warn.ts) are not in src/index.ts.

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

The combined effect of the rules above is that a production bundle pays nothing for the dev-warn family. The env-var read short-circuits before any per-call work; tree-shaking can also drop the warner modules entirely if nothing imports them in a particular consumer’s bundle. The fast-path benchmark numbers in bench/results.md are taken with NODE_ENV=production so production behavior is what’s measured.

SurfaceKF-174 (rebuilt listeners)KF-176 (untracked signals)KF-212 (narrow set)duplicate cacheKeyeach-in-morph-skipKF-238 (delegate-in-effect)
Source modulesrc/dev-listener-warn.tssrc/dev-signal.tssrc/dev-store-warn.tssrc/dev-each-warn.tssrc/dev-each-warn.tssrc/dev-delegate-warn.ts
Wired insrc/mount.tssrc/reactive.tssrc/store.tssrc/each.tssrc/mount.tssrc/reactive.ts (effect wrap) + src/delegate.ts (check)
Numbered docdocs/5-event-delegation.md (Rule 4)docs/2-reactivity.md (Rule 8)docs/3-stores.md (Rule 9)docs/4-render.md §4.2docs/4-render.md §4.3docs/5-event-delegation.md §5.3
AI usage guidedocs/ai/usage-guide.md “Hard rules”samesamen/adocs/ai/usage-guide.md “Common errors”docs/ai/usage-guide.md Hard Rule 5 + “Common errors”
Test fixturetests/unit/dev-listener-warn.internal.test.tscovered in tests/unit/reactive.test.tstests/unit/dev-store-warn.internal.test.tstests/unit/dev-each-warn.internal.test.tssametests/unit/dev-delegate-warn.internal.test.ts

If a future Hard Rule violation lands a similar “silent until later” failure mode, the right shape is another env-var-gated, opt-in, one-shot warner in this family. To add one:

  1. Create src/dev-<area>-warn.ts exporting isOptedIn(), a warning-emitter function, and _resetWarnedForTests() / _resetWarnContext(ctx).
  2. Wire it into the host primitive’s module (e.g., src/mount.ts for the rebuilt-listeners warner, src/store.ts for the narrow-set warner).
  3. Write tests/unit/dev-<area>-warn.internal.test.ts exercising the opt-out / opt-in / dedup paths.
  4. Update this doc with a new §11.2.N subsection and a row in §11.4.
  5. Add the env var to docs/ai/usage-guide.md and the relevant numbered doc.
  6. CHANGELOG entry naming the ticket and the env var.

The shape is rigid on purpose — every new warning should be a copy-paste-and-modify of an existing one, not a fresh design.