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.
11.1 Why opt-in
Section titled “11.1 Why opt-in”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
addEventListeneron a node the consumer forgot to wrap indata-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.
Relationship to the static-check layer
Section titled “Relationship to the static-check layer”The dev-warns are the runtime layer. Two earlier layers catch related misuse before the program runs:
- Strict TS —
tsc --noEmitagainst 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, ineslint-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 dynamicraw()argument so theeslint-disablesuppression becomes the permanent acknowledgment) andai-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 The six warnings
Section titled “11.2 The six warnings”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.
11.2.5 KERF_DEV_WARN_EACH_IN_MORPH_SKIP=1
Section titled “11.2.5 KERF_DEV_WARN_EACH_IN_MORPH_SKIP=1”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.
11.2.6 KERF_DEV_WARN_DELEGATE_IN_EFFECT=1
Section titled “11.2.6 KERF_DEV_WARN_DELEGATE_IN_EFFECT=1”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.
11.3 Design rules for the family
Section titled “11.3 Design rules for the family”Every dev-warning in this family follows the same shape. New warnings added to the family must too.
11.3.1 Gating
Section titled “11.3.1 Gating”- 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. - Per-warning env var. Each warning has its own
KERF_DEV_WARN_<NOUN>=1env var. There is intentionally no umbrellaKERF_DEV_WARN=1flag — opt-in is per-warning, so a consumer can enable the Rule 4 warner while leaving the Rule 9 warner off. - Default off. Every warning is off by default. The env-var
=0and the unset state both mean off.
11.3.2 One-shot dedup
Section titled “11.3.2 One-shot dedup”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.
11.3.3 Warning message shape
Section titled “11.3.3 Warning message shape”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.
11.3.4 No public-API surface
Section titled “11.3.4 No public-API surface”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.
11.3.5 Zero production cost
Section titled “11.3.5 Zero production cost”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.
11.4 Where each warning is referenced
Section titled “11.4 Where each warning is referenced”| Surface | KF-174 (rebuilt listeners) | KF-176 (untracked signals) | KF-212 (narrow set) | duplicate cacheKey | each-in-morph-skip | KF-238 (delegate-in-effect) |
|---|---|---|---|---|---|---|
| Source module | src/dev-listener-warn.ts | src/dev-signal.ts | src/dev-store-warn.ts | src/dev-each-warn.ts | src/dev-each-warn.ts | src/dev-delegate-warn.ts |
| Wired in | src/mount.ts | src/reactive.ts | src/store.ts | src/each.ts | src/mount.ts | src/reactive.ts (effect wrap) + src/delegate.ts (check) |
| Numbered doc | docs/5-event-delegation.md (Rule 4) | docs/2-reactivity.md (Rule 8) | docs/3-stores.md (Rule 9) | docs/4-render.md §4.2 | docs/4-render.md §4.3 | docs/5-event-delegation.md §5.3 |
| AI usage guide | docs/ai/usage-guide.md “Hard rules” | same | same | n/a | docs/ai/usage-guide.md “Common errors” | docs/ai/usage-guide.md Hard Rule 5 + “Common errors” |
| Test fixture | tests/unit/dev-listener-warn.internal.test.ts | covered in tests/unit/reactive.test.ts | tests/unit/dev-store-warn.internal.test.ts | tests/unit/dev-each-warn.internal.test.ts | same | tests/unit/dev-delegate-warn.internal.test.ts |
11.5 Adding a new warning
Section titled “11.5 Adding a new warning”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:
- Create
src/dev-<area>-warn.tsexportingisOptedIn(), a warning-emitter function, and_resetWarnedForTests()/_resetWarnContext(ctx). - Wire it into the host primitive’s module (e.g.,
src/mount.tsfor the rebuilt-listeners warner,src/store.tsfor the narrow-set warner). - Write
tests/unit/dev-<area>-warn.internal.test.tsexercising the opt-out / opt-in / dedup paths. - Update this doc with a new §11.2.N subsection and a row in §11.4.
- Add the env var to
docs/ai/usage-guide.mdand the relevant numbered doc. - 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.