kerfjs 0.15.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.1] - 2026-07-22
10
+
11
+
12
+
13
+ - KF-329: pin release-workflow npm upgrade to npm@11 — npm@12 needs Node ≥ 22.22.2, above the 22.21.0 pin (`c54559f`)
14
+
15
+ ## [1.0.0] - 2026-07-22
16
+
17
+
18
+
19
+ - **Fine-grained signal bindings** — pass a signal or `computed` directly into a JSX attribute or text hole (e.g. `class={computed(...)}` or `{sig}`) inside `mount()`, and that one node updates on signal change without re-running `render()` or walking the list reconciler. Opt-in per hole and non-breaking; works in static content and inside `each()` rows on both the snapshot and `arraySignal` reconcile paths, with binding effects correctly wired, carried, and disposed across row insert/update/remove/move.
20
+ - Bound values get the same safety treatment as static ones: URL screening and `SafeHtml`/`raw()` unwrapping now apply to fine-grained bound attributes.
21
+ - New interactive benchmark playground (`npm run bench:serve`) to explore kerf by hand in the standard 1k-rows benchmark app.
22
+ - New **row-selector** example app and a "Fine-grained bindings" section in the reactivity demo, both showing select-row updates with zero render re-runs and zero list reconciles.
23
+
24
+
25
+ - Hardened the dangerous-URL screen: scheme detection now normalizes away C0 control characters, DEL, and leading whitespace, so obfuscations like `java script:` or a NUL before the colon can no longer slip `javascript:`/`vbscript:` URLs past the check.
26
+ - Attribute names are now validated — malformed names (e.g. from spreading attacker-controlled keys into JSX) throw instead of breaking out of the open tag, and inline `on*` event handlers are rejected on both the static and signal-bound attribute paths, closing an XSS vector where a bound `onclick` would have installed a live handler.
27
+ - Documented kerf's reserved marker namespace (`data-kfb`, `data-kfbrow`, `kfb:`/`kfbr:`/`kf-list:` comments) — consumer content using these can collide with binding wiring, so they're now explicitly reserved.
28
+ - Documented the trusted-input bridges (`toElement()`, `morph()` with string/Element templates, `<iframe srcdoc>`) that bypass escaping by design, and the `raw()`/`SafeHtml` trust boundary.
29
+ - Hardened the release pipeline: least-privilege OIDC scoping per job, SHA-pinned GitHub Actions with automated Dependabot bumps, and a token-holding publish job that runs with `--ignore-scripts` and no build tools.
30
+
31
+
32
+ - Reduced the per-row create cost of fine-grained row bindings from ~1.65× to ~1.15× via lazy row wiring — the common single-root-binding case now needs no subtree walk or extra allocations.
33
+ - Published cross-framework benchmark numbers now come from the official upstream krausest js-framework-benchmark run (kerf is a merged upstream entry), replacing stale local-machine measurements.
34
+
35
+
36
+ - README refreshed for the 1.0 release: fine-grained updates and safe-by-default escaping promoted to headline features, and the status line flipped from "Pre-1.0 — API may evolve" to stable 1.0.
37
+ - Fine-grained bindings documented across all consumer and AI-assistant surfaces (reactivity docs, API reference, usage guide, Cursor rules, Claude skill).
38
+
39
+ - **Signposted the raw HTML/SVG → DOM bridges as trusted-input only.** `toElement()`, `morph()` (with a string/Element template), and the `<iframe srcdoc>` attribute bypass kerf's escaping/URL-screening by design — they're the same trust model as `innerHTML` / `raw()`. The docs now call this out loudly, including that the SVG path is *more* dangerous than the HTML path (a top-level `<svg><script>`, SVG event attributes, and `xlink:href="javascript:"` execute once inserted, whereas an HTML-string `<script>` is inert), and that `srcdoc` is HTML a browser re-parses as a document (so `srcdoc={userString}` executes even though the value is escaped as an attribute). No behavior change — these are documentation + regression tests: a real-browser spec (`tests/browser/trusted-html-bridges.spec.ts`) pins the execution boundary across Chromium/Firefox/WebKit, a unit test pins that the granular list fast path keeps the URL screen's guarantee end-to-end, and another pins that SVG input isn't sanitized. See [`docs/7-svg.md`](docs/7-svg.md) § Security, [`docs/8-api-reference.md`](docs/8-api-reference.md), and [`docs/6-jsx-runtime.md`](docs/6-jsx-runtime.md) §6.4.3.
40
+ - **Documented kerf's reserved marker namespace.** Fine-grained bindings and `each()` lists coordinate through in-band markers that the wiring pass finds by scanning the mounted subtree and matching by id. A consumer element that carries one of those names can collide with a real binding's id and silently steal its update, so the names are now documented as reserved: the `data-kfb` / `data-kfbrow` attributes and HTML comments beginning `kfb:` / `kfbr:` / `kf-list:`. Don't emit them from your own markup or via `raw()`. (kerf's escaping already prevents a plain text/attribute *value* from forging one — the only ways in are hand-written markup or `raw()`.) See [`docs/2-reactivity.md`](docs/2-reactivity.md) § "Reserved marker names".
41
+ - **Attribute names are now validated, and inline event handlers are rejected outright.** The JSX runtime already escaped attribute *values*; it now also validates each attribute *name* against a safe shape (a letter/underscore/colon followed by letters, digits, or `_ . : -`) and **throws** on anything else. This closes a markup-injection vector when an object with attacker-controlled keys is spread into JSX (`<div {...untrustedObj}>`) — previously a key like `'x><img onerror=…>'` broke out of the open tag even though the value was escaped. Separately, any `on*` attribute (a function *or* a string value, in any case — e.g. `onClick={fn}` or `onclick="…"`) now throws and points at `delegate()`; previously only function-valued keys matching `/^on[A-Z]/` were caught, so a string `onclick="alert(1)"` slipped through and became a live inline handler when parsed. Both checks now cover the **fine-grained bound path** too: a signal bound straight into an attribute (`onclick={signal}`) is written with `setAttribute`, and `setAttribute('onclick', …)` installs a live inline handler just as a parsed string would — so an `on*` (or malformed) name bound as a signal is rejected at binding time, closing the same vector on the signal path. See [`docs/6-jsx-runtime.md`](docs/6-jsx-runtime.md) §6.4.2.
42
+ - **Hardened the dangerous-URL screen.** The URL-attribute filter (on `href`/`src`/`xlink:href`/`formaction`/`action`) now: (1) sees through control-character and whitespace obfuscation of the scheme — a leading ``, an in-scheme `TAB`/`LF`/`CR` (`java script:`), or a `NUL` before the colon are all normalized away before the scheme is read, matching how a browser resolves the URL, so they can no longer slip a `javascript:` past the screen; (2) treats `data:` by subtype instead of only blocking `data:text/html` — script-executing document types (`data:text/html`, `data:image/svg+xml`, XHTML/XML) are dropped while inert media (raster images, fonts, audio, video, plain text/CSS) still pass, and any unknown subtype fails closed; and (3) also screens the `data` attribute on `<object>` (which loads its target as a document). `raw()` remains the opt-out. Both the static serializer and the fine-grained bound-attribute writer share the screen, so both paths are covered.
43
+ - **Fine-grained signal bindings.** Hand a `Signal`/`computed` *itself* (not its `.value`) into a JSX attribute (`class={someSignal}`) or a text hole (`{someSignal}`) inside a `mount()`, and kerf binds that hole directly to the signal: when the signal changes, only that attribute/text node updates — the render function does **not** re-run and the list reconciler does **not** walk. This is kerf's fine-grained update tier, sitting below the coarse `mount()` effect, for the "external state drives one spot" pattern (a `selectedId` flipping a row's class, a live status attribute, etc.). Works in static content and inside `each()` rows on both the snapshot and `arraySignal` (granular) paths, and row bindings' lifetimes track their row node (a row reorder is free; a removed row's binding is torn down). Opt-in and non-breaking: passing a raw signal into JSX previously threw, so existing apps are unchanged, and any hole that isn't a signal stringifies exactly as before. Bound URL attributes (`href`/`src`/`formaction`/`action`/`xlink:href`) get the same `javascript:`/`vbscript:`/`data:text/html` screening as static attributes (`raw()` opts out). Outside a `mount()` (SSR / `SafeHtml.toString()`) a bound signal snapshots its current value and emits no markers. See [`docs/2-reactivity.md`](docs/2-reactivity.md) §2.9.
44
+
45
+ ## [0.16.0] - 2026-07-01
46
+
47
+
48
+
49
+ - Fixed keyed-list selection breaking after a row was removed: a signal read only inside `each()`'s `cacheKey` (such as a `selectedId` toggling a row's class) no longer drops out of the reactive dependency set, so later changes re-render correctly.
50
+ - Fixed appending items to a list after clearing it rendering nothing.
51
+
52
+ - `each()` on an `arraySignal`: appending rows to a list that was just emptied (e.g. **Clear** then **Append**) now renders the new rows immediately, instead of showing nothing until a second append. After the list was emptied its binding was empty but no longer in its first-render state, so the granular insert path emitted a segment the reconciler rendered as empty; repopulating an emptied list now takes the snapshot (build-from-scratch) path, the same as a first render.
53
+ - `each()` on an `arraySignal`: a signal read only inside the `cacheKey` comparator (the "external state drives the row" pattern — e.g. a `selectedId` flipping a row's class) now stays tracked across a granular structural update. Previously, after a granular insert/remove/update/move, that signal dropped out of the `mount()` effect's dependency set (the granular path never re-evaluates `cacheKey` for untouched rows), so a later change to it silently failed to re-render — e.g. row selection stopped working after a row was deleted. The granular path now re-reads every row's `cacheKey`, which both keeps those signals tracked and detects content drift the patches can't express (a selection flip batched together with a structural change), falling back to the snapshot path when it does.
54
+
9
55
  ## [0.15.5] - 2026-06-30
10
56
 
11
57
 
package/README.md CHANGED
@@ -31,15 +31,21 @@ That's it. Your JSX renders to HTML strings, kerf's native diff applies the mini
31
31
 
32
32
  ## Why Kerf
33
33
 
34
- 1. **Small bundle.** ~11 KB minified + gzipped including `@preact/signals-core` (~12 KB with `arraySignal`). One runtime dependency. No virtual DOM, no scheduler, no concurrent-mode machinery. On the [krausest js-framework-benchmark](./bench/results.md) kerf is in the same cluster as Vue, vanjs, and Lit on most operations; Solid's compiler leads the update-path benchmarks (notably `partial update`), which kerf doesn't try to match by design — no compiler.
34
+ 1. **Small bundle.** ~11 KB minified + gzipped including `@preact/signals-core` (~12 KB with `arraySignal`). One runtime dependency. No virtual DOM, no scheduler, no concurrent-mode machinery. On the official [krausest js-framework-benchmark](https://krausest.github.io/js-framework-benchmark/current.html) — where kerf is a listed entry, measured on the same reference machine as every competitor ([local mirror](./bench/results.md)) — kerf is in the same cluster as Vue, vanjs, and Lit on most operations; Solid's compiler leads the update-path benchmarks (notably `partial update`), which kerf doesn't try to match by design — no compiler.
35
35
 
36
36
  2. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
37
37
 
38
- 3. **Focus, selection, listeners survive re-renders.** The reconciler morphs instead of rebuilding — caret position, selection range, and delegated listeners survive every re-render.
38
+ 3. **Fine-grained updates, opt-in.** Hand a signal *itself* into a JSX hole — `class={selectedId}` or `{status}` — and kerf binds that one node directly: when the signal changes, only that attribute or text node updates, with no render re-run and no list reconcile. It's a surgical tier beneath the coarse render effect, for the *external-state-drives-one-spot* pattern — a selection flip on a 10,000-row table touches exactly one class.
39
39
 
40
- 4. **Small public API.** ~17 exports from the main barrel (plus `arraySignal` on its own subpath). No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.
40
+ 4. **Focus, selection, listeners survive re-renders — even mid-list.** The reconciler morphs instead of rebuilding, so caret position, selection range, IME composition, and delegated listeners survive every re-render. Keyed lists get the same treatment: same-identity rows are updated *in place* rather than recreated, so a row reorder or a single-cell edit no longer blows away focus, scroll, or an in-flight animation the way node replacement does.
41
41
 
42
- 5. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain.
42
+ 5. **Safe by default.** Text and attribute values are HTML-escaped automatically, URL attributes are scheme-screened (`javascript:` / script-carrying `data:` dropped), inline `on*` handlers are rejected outright, and the same screening covers the fine-grained bound path — so untrusted data stays inert even when kerf is dropped into someone else's page. `raw()` is the explicit, auditable opt-out.
43
+
44
+ 6. **Small public API.** ~17 exports from the main barrel (plus `arraySignal` on its own subpath). No hooks, no lifecycle, no per-instance state. Components are plain functions that return JSX.
45
+
46
+ 7. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain.
47
+
48
+ 8. **Grown-up tooling around a tiny core.** An [ESLint plugin](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) that enforces the hard rules at edit time, a `create-kerf-component` scaffold for publishable component packages, drop-in AI-assistant configs, and side-by-side migration guides for a dozen-plus frameworks — none of which grows the core runtime past ~11 KB.
43
49
 
44
50
  ## When to use Kerf
45
51
 
@@ -106,6 +112,24 @@ delegate(root, 'click', '[data-action="remove"]', (_e, btn) => {
106
112
  });
107
113
  ```
108
114
 
115
+ ### Fine-grained updates: bind a signal into a hole
116
+
117
+ Inside a `mount()`, hand a signal *itself* (not its `.value`) into an attribute or text position and kerf wires that hole straight to the signal — the render function never re-runs and the list reconciler never walks:
118
+
119
+ ```ts
120
+ const status = signal('idle');
121
+
122
+ mount(root, () => (
123
+ <div class={status}> {/* class attribute bound to the signal */}
124
+ Status: {status} {/* text node bound to the signal */}
125
+ </div>
126
+ ));
127
+
128
+ status.value = 'saving'; // updates the class + the text node directly — no re-render
129
+ ```
130
+
131
+ The headline use is external state driving one spot: a `selectedId` flipping a single row's class inside a 10,000-row `each()` list touches exactly that one node, no reconcile. Works in static content and inside `each()` rows (a row's binding is torn down with the row); outside a `mount()` (SSR / `SafeHtml.toString()`) a bound signal just snapshots its current value. See [`docs/2-reactivity.md`](./docs/2-reactivity.md) §2.9.
132
+
109
133
  ### Long keyed lists: `arraySignal`
110
134
 
111
135
  For lists where most updates are pointwise (single-row edits, append-to-end, selection flips on individual rows), reach for `arraySignal` from the `kerfjs/array-signal` subpath. Mutators emit typed patches that `each()` applies in O(patches), not O(N):
@@ -190,7 +214,7 @@ See [`docs/13-component-packages.md`](./docs/13-component-packages.md) for the f
190
214
  - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
191
215
  - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; eight rules (four hard-rule errors + four warns: `require-delegate-disposer`, `prefer-attr-selector`, `no-raw-with-dynamic-arg`, `ai-assistant-configs`) at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
192
216
  - **Component scaffold:** `npm create kerf-component@latest <dir>` — `create-kerf-component`; generates a publishable component package with the hard packaging rules pre-wired (source: [`create-kerf-component/`](./create-kerf-component/))
193
- - **Demo:** [live demo](https://brianwestphal.github.io/kerf/demo/) — eight sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture, `arraySignal` patches)
217
+ - **Demo:** [live demo](https://brianwestphal.github.io/kerf/demo/) — nine sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture, `arraySignal` patches, fine-grained signal bindings)
194
218
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
195
219
 
196
220
  ## Why "kerf"?
@@ -201,7 +225,7 @@ A *kerf* is the narrow strip of material a saw blade removes when cutting — th
201
225
 
202
226
  ## Status
203
227
 
204
- Pre-1.0 — API may evolve. See [CHANGELOG.md](./CHANGELOG.md) for the current version and what's shipped.
228
+ 1.0 — the public API is stable and follows semver from here. See [CHANGELOG.md](./CHANGELOG.md) for the current version and what's shipped.
205
229
 
206
230
  ## Sponsor
207
231
 
package/ai/cursorrules CHANGED
@@ -1,4 +1,4 @@
1
- <!-- kerf-skill-version: 1.2.1 -->
1
+ <!-- kerf-skill-version: 1.3.0 -->
2
2
  # kerf.cursorrules — rules for building apps with kerf
3
3
  #
4
4
  # Drop this file into your project as `.cursorrules` (Cursor will pick it
@@ -123,6 +123,19 @@ const cart = defineStore({
123
123
 
124
124
  // One-shot reconcile against existing DOM (no signals)
125
125
  morph(liveCard, '<article class="card">…</article>');
126
+
127
+ // Fine-grained binding (opt-in): pass the signal/computed ITSELF into a hole
128
+ // so a change updates ONLY that node (no render re-run, no reconcile). For a
129
+ // hot spot driven by an external signal (selection class) — not everywhere.
130
+ // Use computed(), never a bare () => ….
131
+ const selectedId = signal<number | null>(null);
132
+ mount(listEl, () => (
133
+ <ul>
134
+ {each(rows.value, (row) => (
135
+ <li class={computed(() => (row.id === selectedId.value ? 'sel' : ''))}>{row.label}</li>
136
+ ), (row) => row.id)}
137
+ </ul>
138
+ ));
126
139
  ```
127
140
 
128
141
  ## Common errors → fixes
@@ -136,6 +149,7 @@ morph(liveCard, '<article class="card">…</article>');
136
149
  - `each(): row render at index N produced K top-level elements` → wrap multiple roots in one parent.
137
150
  - Drag/drop / state change has no visible effect, only stuff *outside* `each()` updates → you used `each(STATIC_ARRAY, …)` whose row render reads signals. Replace the outer with `STATIC_ARRAY.map(...)`; keep inner `each()` for the dynamic sub-list. See Hard Rule 14.
138
151
  - Row-enter CSS animation no longer replays when only a row's *content* changed (kerf ≥ 0.15.0) → 0.15.0+ morphs a same-identity, same-position row *in place* instead of recreating its node, so a mount-keyed `@keyframes` never re-triggers on a content-only update (≤ 0.14.x recreated the node, so it fired; the intentional flip side is that focus, scroll, IME, and in-progress transitions now survive). Key the animation on a state-class toggle, not element creation; to force a remount, churn the row's identity (new object ref / `data-key`).
152
+ - Want a hot spot to update without re-running the whole render → fine-grained binding: pass the signal/`computed` ITSELF into the attr/text hole (`class={computed(() => …)}`), not `.value`. Use `computed()` not a bare `() => …` (memoization keeps a shared-signal flip to ~O(changed nodes)). Opt-in per hole. Limit: a bound hole depending on the row's OWN mutated data goes stale on a granular in-place update — use plain interpolation there.
139
153
 
140
154
  ## Server / SSR
141
155
 
package/ai/manifest.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
- "kerfjsVersion": "0.15.5",
2
+ "kerfjsVersion": "1.0.1",
3
3
  "files": [
4
4
  {
5
5
  "name": "skill",
6
6
  "source": "kerf.claude-skill.md",
7
7
  "bundle": "ai/skill.md",
8
8
  "dest": ".claude/skills/kerf-app/SKILL.md",
9
- "version": "1.2.1",
10
- "sha256": "290e57bfc9b4af0630c586770e61063742d5f5fac3d75406f5acbc2e5322440f"
9
+ "version": "1.3.0",
10
+ "sha256": "789cfd9bbe0fa8a01b779595912b7fef569b3c021be412d35f453476e576bac6"
11
11
  },
12
12
  {
13
13
  "name": "cursorrules",
14
14
  "source": "kerf.cursorrules",
15
15
  "bundle": "ai/cursorrules",
16
16
  "dest": ".cursorrules",
17
- "version": "1.2.1",
18
- "sha256": "2fcd5e9e0fd889febdab19bcc7e415c342b10ae81e937852b8280014b8d374e0"
17
+ "version": "1.3.0",
18
+ "sha256": "2524a42cc3b488f8cc3cea736cf5b8d3e57d868c85260342ec50905243aa65f1"
19
19
  }
20
20
  ]
21
21
  }
package/ai/skill.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: kerf-app
3
3
  description: Build UIs in the kerf reactive framework (https://github.com/brianwestphal/kerf). Use this skill whenever the user is writing or modifying code that imports `kerfjs`, asks to add a feature to a kerf app, or asks "how do I do X in kerf?". Use it proactively the moment you spot a kerf import in the file you're editing.
4
- kerf-skill-version: 1.2.1
4
+ kerf-skill-version: 1.3.0
5
5
  ---
6
6
 
7
7
  # Building apps with kerf
@@ -132,6 +132,20 @@ const cart = defineStore({
132
132
 
133
133
  // Pattern 4: one-shot reconcile (no signals, no effect)
134
134
  morph(liveCard, '<article class="card">…</article>');
135
+
136
+ // Pattern 5: fine-grained binding (opt-in) — pass the signal/computed ITSELF
137
+ // into a hole so a change updates ONLY that node (no render re-run, no
138
+ // reconcile). For a hot spot driven by an external signal (selection class,
139
+ // live status attr) — not everywhere. Use computed(), never a bare () => ….
140
+ const selectedId = signal<number | null>(null);
141
+ mount(listEl, () => (
142
+ <ul>
143
+ {each(rows.value, (row) => (
144
+ <li class={computed(() => (row.id === selectedId.value ? 'sel' : ''))}>{row.label}</li>
145
+ ), (row) => row.id)}
146
+ </ul>
147
+ ));
148
+ // selectedId.value = 3 → only the ~2 affected <li> class attrs update.
135
149
  ```
136
150
 
137
151
  ## Diagnosing common errors
@@ -148,6 +162,7 @@ morph(liveCard, '<article class="card">…</article>');
148
162
  | `each(): row render at index N produced K top-level elements` | row returned multiple sibling elements or zero | Wrap them in one parent so the row renders exactly one element |
149
163
  | Drag/drop / state change has no visible effect; only elements *outside* `each()` update | Used `each(STATIC_ARRAY, …)` whose row render reads signals. Items never change identity → cache hits forever → row render never re-invoked → signal reads stop tracking | Replace outer with `STATIC_ARRAY.map(...)`; keep inner `each()` for the dynamic sub-list. See Hard Rule 14 |
150
164
  | Row-enter CSS animation no longer replays when only a row's *content* changed (kerf ≥ 0.15.0) | 0.15.0+ morphs a same-identity, same-position row *in place* instead of recreating its node, so a mount-keyed `@keyframes` never re-triggers on a content-only update (≤ 0.14.x recreated the node, so it fired). Intentional flip side: focus, scroll, IME, and in-progress transitions now survive | Key the animation on a state-class toggle, not element creation. To force a remount, churn the row's identity (new object ref / `data-key`) so the reconciler replaces the node |
165
+ | Want a hot spot to update without re-running the whole render | Fine-grained binding: pass the signal/`computed` ITSELF into the attr/text hole (`class={computed(() => …)}`), not `.value`. Use `computed()` not a bare `() => …` (memoization keeps a shared-signal flip to ~O(changed nodes)). Opt-in per hole. Limit: a bound hole depending on the row's OWN mutated data goes stale on a granular in-place update — use plain interpolation there |
151
166
 
152
167
  ## Workflow guidance
153
168
 
@@ -1,4 +1,4 @@
1
- import { signal } from './chunk-N4KF3GD2.js';
1
+ import { signal } from './chunk-4E26PO2C.js';
2
2
 
3
3
  // src/array-signal.ts
4
4
  var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
@@ -1,4 +1,4 @@
1
- import { signal as signal$1, effect as effect$1, Signal } from '@preact/signals-core';
1
+ import { Signal, signal as signal$1, effect as effect$1 } from '@preact/signals-core';
2
2
  export { batch, computed } from '@preact/signals-core';
3
3
 
4
4
  // src/reactive.ts
@@ -58,6 +58,9 @@ function isDevWarnUntrackedEnabled() {
58
58
  if (proc?.env?.NODE_ENV === "production") return false;
59
59
  return proc?.env?.KERF_DEV_WARN_UNTRACKED_SIGNALS === "1";
60
60
  }
61
+ function isSignal(value) {
62
+ return value instanceof Signal;
63
+ }
61
64
  function signal(value) {
62
65
  if (isDevWarnUntrackedEnabled()) return new DevSignal(value);
63
66
  return signal$1(value);
@@ -74,6 +77,6 @@ function effect(fn) {
74
77
  });
75
78
  }
76
79
 
77
- export { effect, signal, warnIfInsideEffect };
78
- //# sourceMappingURL=chunk-N4KF3GD2.js.map
79
- //# sourceMappingURL=chunk-N4KF3GD2.js.map
80
+ export { effect, isSignal, signal, warnIfInsideEffect };
81
+ //# sourceMappingURL=chunk-4E26PO2C.js.map
82
+ //# sourceMappingURL=chunk-4E26PO2C.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/dev-delegate-warn.ts","../src/dev-signal.ts","../src/reactive.ts"],"names":["Signal","coreSignal","coreEffect"],"mappings":";;;;;;AAyBA,IAAI,KAAA,GAAQ,CAAA;AACZ,IAAI,MAAA,GAAS,KAAA;AAEb,SAAS,SAAA,GAAqB;AAC5B,EAAA,MAAM,OAAQ,UAAA,CAA0E,OAAA;AACxF,EAAA,IAAI,IAAA,EAAM,GAAA,EAAK,QAAA,KAAa,YAAA,EAAc,OAAO,KAAA;AACjD,EAAA,OAAO,IAAA,EAAM,KAAK,gCAAA,KAAqC,GAAA;AACzD;AAGO,SAAS,WAAA,GAAoB;AAClC,EAAA,KAAA,EAAA;AACF;AAGO,SAAS,UAAA,GAAmB;AACjC,EAAA,KAAA,EAAA;AACF;AAGO,SAAS,gCAAA,GAA4C;AAC1D,EAAA,OAAO,SAAA,EAAU;AACnB;AAQO,SAAS,mBAAmB,EAAA,EAA0C;AAC3E,EAAA,IAAI,CAAC,WAAU,EAAG;AAClB,EAAA,IAAI,UAAU,CAAA,EAAG;AACjB,EAAA,IAAI,MAAA,EAAQ;AACZ,EAAA,MAAA,GAAS,IAAA;AACT,EAAA,OAAA,CAAQ,IAAA;AAAA,IACN,SAAS,EAAE,CAAA,gjBAAA;AAAA,GAOb;AACF;AC3CA,IAAM,eAAA,GACF,gUAAA;AAMG,IAAM,SAAA,GAAN,cAA2B,MAAA,CAAU;AAAA,EAClC,eAAA,GAAkB,KAAA;AAAA,EAClB,QAAA,GAAW,KAAA;AAAA,EACX,aAAA,GAAgB,KAAA;AAAA,EAExB,YAAY,OAAA,EAAa;AACvB,IAAA,KAAA,CAAM,OAAA,EAAc;AAAA,MAClB,OAAA,GAAyB;AACvB,QAAC,KAAiD,eAAA,GAAkB,IAAA;AAAA,MACtE;AAAA,KACD,CAAA;AACD,IAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AAAA,EACvB;AAAA,EAEA,IAAa,KAAA,GAAW;AAAE,IAAA,OAAO,KAAA,CAAM,KAAA;AAAA,EAAO;AAAA,EAC9C,IAAa,MAAM,CAAA,EAAM;AACvB,IAAA,KAAA,CAAM,KAAA,GAAQ,CAAA;AACd,IAAA,IAAI,KAAK,aAAA,IAAiB,CAAC,KAAK,eAAA,IAAmB,CAAC,KAAK,QAAA,EAAU;AACjE,MAAA,IAAA,CAAK,QAAA,GAAW,IAAA;AAChB,MAAA,OAAA,CAAQ,KAAK,eAAe,CAAA;AAAA,IAC9B;AAAA,EACF;AACF,CAAA;AAEO,SAAS,yBAAA,GAAqC;AACnD,EAAA,MAAM,OAAQ,UAAA,CAA0E,OAAA;AACxF,EAAA,IAAI,IAAA,EAAM,GAAA,EAAK,QAAA,KAAa,YAAA,EAAc,OAAO,KAAA;AACjD,EAAA,OAAO,IAAA,EAAM,KAAK,+BAAA,KAAoC,GAAA;AACxD;ACvBO,SAAS,SAAS,KAAA,EAA0C;AACjE,EAAA,OAAO,KAAA,YAAiBA,MAAAA;AAC1B;AAEO,SAAS,OAAU,KAAA,EAAsB;AAC9C,EAAA,IAAI,yBAAA,EAA0B,EAAG,OAAO,IAAI,UAAa,KAAU,CAAA;AACnE,EAAA,OAAOC,SAAW,KAAU,CAAA;AAC9B;AAEO,SAAS,OAAO,EAAA,EAA2C;AAChE,EAAA,IAAI,CAAC,gCAAA,EAAiC,EAAG,OAAOC,SAAW,EAAE,CAAA;AAC7D,EAAA,OAAOA,SAAW,MAAM;AACtB,IAAA,WAAA,EAAY;AACZ,IAAA,IAAI;AACF,MAAA,OAAO,EAAA,EAAG;AAAA,IACZ,CAAA,SAAE;AACA,MAAA,UAAA,EAAW;AAAA,IACb;AAAA,EACF,CAAC,CAAA;AACH","file":"chunk-4E26PO2C.js","sourcesContent":["/**\n * Dev-mode warning for `delegate()` / `delegateCapture()` calls that run\n * inside an `effect()` body (KERF_DEV_WARN_DELEGATE_IN_EFFECT=1).\n *\n * Why the pattern matters: every effect re-run executes its body fresh, which\n * means a `delegate()` call inside the body installs a NEW root listener on\n * each re-run. The effect's disposer cleans up the reactive subscription but\n * not the side-effects the body produced — so previous listeners stay\n * attached, the per-listener closure pins `rootEl` / `handler` / everything\n * the handler closes over, and listener count grows linearly with signal\n * churn. Structurally identical to the addEventListener-inside-mount foot-gun\n * (Hard Rule 4) but doesn't *look* like it.\n *\n * Static analysis can't reliably detect \"inside an effect\" without flow\n * information (effect() is just a function call), so the canonical defense\n * is this runtime opt-in warning. When enabled, `reactive.ts`'s `effect()`\n * wrapper increments a module-level counter before invoking the user body\n * and decrements after; `delegate.ts` checks the counter and fires the\n * warning once total.\n *\n * Production behavior is unchanged for zero runtime cost — the env-var check\n * short-circuits before any state is touched, and the wrapper in\n * `reactive.ts` only wraps when the gate is on.\n */\n\nlet depth = 0;\nlet warned = false;\n\nfunction isOptedIn(): boolean {\n const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;\n if (proc?.env?.NODE_ENV === 'production') return false;\n return proc?.env?.KERF_DEV_WARN_DELEGATE_IN_EFFECT === '1';\n}\n\n/** Called by the `effect()` wrapper in `reactive.ts` before running the user body. */\nexport function enterEffect(): void {\n depth++;\n}\n\n/** Called by the `effect()` wrapper in `reactive.ts` after the user body returns or throws. */\nexport function exitEffect(): void {\n depth--;\n}\n\n/** Public re-export of the env-var check so `reactive.ts` can decide whether to wrap. */\nexport function isDevWarnDelegateInEffectEnabled(): boolean {\n return isOptedIn();\n}\n\n/**\n * Called at the top of `delegate()` and `delegateCapture()`. If the call is\n * happening inside an `effect()` body (depth > 0) AND the env var is on, fire\n * a one-shot warning. The `fn` argument is the name of the caller for the\n * message (\"delegate\" vs \"delegateCapture\").\n */\nexport function warnIfInsideEffect(fn: 'delegate' | 'delegateCapture'): void {\n if (!isOptedIn()) return;\n if (depth === 0) return;\n if (warned) return;\n warned = true;\n console.warn(\n `kerf: ${fn}() was called inside an effect() body. `\n + 'Every effect re-run installs a fresh root listener; the effect disposer cleans up the '\n + 'reactive subscription but not the listeners, so listener count grows linearly with signal '\n + 'churn and each listener pins its handler closure. Register the delegate once at module '\n + 'or setup scope and gate behavior on the signal *inside the handler* where the read is free. '\n + 'See docs/5-event-delegation.md §5.3 \"When capturing the disposer still isn\\'t enough\". '\n + 'Set KERF_DEV_WARN_DELEGATE_IN_EFFECT=0 (or unset it) to silence this warning.',\n );\n}\n\n/** Test helper — resets the one-shot dedup flag and depth counter for unit tests. */\nexport function _resetWarnedForTests(): void {\n warned = false;\n depth = 0;\n}\n","/**\n * Dev-mode signal subclass with subscriber tracking (KF-176). When the\n * dev-warn opt-in is enabled, `signal()` returns a `DevSignal` that emits a\n * one-shot `console.warn` the first time `.value` is written to an instance\n * that has never had a subscriber attached. This surfaces the canonical\n * Rule 7 violation (read `.value` outside a render fn / effect — the read\n * doesn't subscribe, so subsequent writes silently fail to re-render) at\n * the moment the user makes the wrong write, instead of leaving them to\n * notice that their UI never updates.\n *\n * The gate is `process.env.NODE_ENV !== 'production'` AND\n * `KERF_DEV_WARN_UNTRACKED_SIGNALS === '1'`. Off by default because the\n * heuristic produces false positives for purely imperative signals (used as\n * mutable cells with no UI consumer); opt-in is the right shape until a\n * sharper heuristic is found. Production behavior is unchanged for zero\n * runtime cost.\n *\n * The subclass uses signals-core's `SignalOptions.watched` callback to set a\n * per-instance `__hasSubscriber` flag — fired by signals-core when the first\n * subscriber attaches. We never clear the flag on `unwatched`, so a signal\n * that *was* subscribed at some point won't warn even if its subscribers\n * later detach.\n */\n\nimport { Signal } from '@preact/signals-core';\n\nconst WARNING_MESSAGE\n = 'kerf: signal was written but has no subscribers. '\n + 'Did you read `.value` outside of a render fn / effect()? '\n + 'Hoisted reads do not subscribe, so subsequent writes will not re-render. '\n + 'Move the read inside mount()\\'s render fn or effect() callback. '\n + 'Set KERF_DEV_WARN_UNTRACKED_SIGNALS=0 (or unset it) to silence this warning.';\n\nexport class DevSignal<T> extends Signal<T> {\n private __hasSubscriber = false;\n private __warned = false;\n private __constructed = false;\n\n constructor(initial?: T) {\n super(initial as T, {\n watched(this: Signal<T>) {\n (this as unknown as { __hasSubscriber: boolean }).__hasSubscriber = true;\n },\n });\n this.__constructed = true;\n }\n\n override get value(): T { return super.value; }\n override set value(v: T) {\n super.value = v;\n if (this.__constructed && !this.__hasSubscriber && !this.__warned) {\n this.__warned = true;\n console.warn(WARNING_MESSAGE);\n }\n }\n}\n\nexport function isDevWarnUntrackedEnabled(): boolean {\n const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;\n if (proc?.env?.NODE_ENV === 'production') return false;\n return proc?.env?.KERF_DEV_WARN_UNTRACKED_SIGNALS === '1';\n}\n","/**\n * Re-exports of `@preact/signals-core`. Lets the rest of the codebase depend\n * on `'./reactive.js'` without naming the underlying lib, so swapping it out\n * later (or fronting it with a hand-rolled implementation) is a one-file\n * change.\n *\n * Two dev-gated wrappers sit in front of the bare re-exports:\n *\n * - `signal()` returns a `DevSignal` when `KERF_DEV_WARN_UNTRACKED_SIGNALS=1`\n * (KF-176) — warns on writes to signals with no subscribers.\n *\n * - `effect()` wraps the user body in `enterEffect()` / `exitEffect()` calls\n * when `KERF_DEV_WARN_DELEGATE_IN_EFFECT=1` so `delegate()` can detect when\n * it's running inside an effect body and fire the appropriate warning.\n *\n * Both gates short-circuit on `NODE_ENV === 'production'` — production\n * always sees the bare `@preact/signals-core` exports with zero overhead.\n */\n\nimport { effect as coreEffect,Signal,signal as coreSignal } from '@preact/signals-core';\n\nimport { enterEffect, exitEffect, isDevWarnDelegateInEffectEnabled } from './dev-delegate-warn.js';\nimport { DevSignal, isDevWarnUntrackedEnabled } from './dev-signal.js';\n\nexport {\n batch,\n computed,\n type ReadonlySignal,\n Signal,\n} from '@preact/signals-core';\n\n/**\n * Runtime type guard for a `@preact/signals-core` signal (both `signal()`\n * values and `computed()` values are `Signal` instances). Used by the JSX\n * runtime (KF-294) to detect a signal handed straight into an attribute or\n * text hole — the trigger for a fine-grained binding rather than a snapshot\n * stringify.\n */\nexport function isSignal(value: unknown): value is Signal<unknown> {\n return value instanceof Signal;\n}\n\nexport function signal<T>(value?: T): Signal<T> {\n if (isDevWarnUntrackedEnabled()) return new DevSignal<T>(value as T) as Signal<T>;\n return coreSignal(value as T);\n}\n\nexport function effect(fn: () => void | (() => void)): () => void {\n if (!isDevWarnDelegateInEffectEnabled()) return coreEffect(fn);\n return coreEffect(() => {\n enterEffect();\n try {\n return fn();\n } finally {\n exitEffect();\n }\n });\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { signal } from './chunk-N4KF3GD2.js';
1
+ import { signal } from './chunk-4E26PO2C.js';
2
2
 
3
3
  // src/dev-store-warn.ts
4
4
  var WARNING_PREFIX = "kerf: defineStore.set() called with keys missing from the current state \u2014 ";
@@ -66,5 +66,5 @@ function clearStoreRegistry() {
66
66
  }
67
67
 
68
68
  export { clearStoreRegistry, defineStore, resetAllStores };
69
- //# sourceMappingURL=chunk-4TJEO4AO.js.map
70
- //# sourceMappingURL=chunk-4TJEO4AO.js.map
69
+ //# sourceMappingURL=chunk-7SKIIA5P.js.map
70
+ //# sourceMappingURL=chunk-7SKIIA5P.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/dev-store-warn.ts","../src/store.ts"],"names":[],"mappings":"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","file":"chunk-4TJEO4AO.js","sourcesContent":["/**\n * Dev-mode warning for partial-set violations of Hard Rule 8 (KF-212). When\n * the opt-in env var `KERF_DEV_WARN_NARROW_SET=1` is set in a non-production\n * build, `defineStore`'s `set()` calls `maybeWarnNarrowSet(prev, next, ctx)`\n * before assigning. If `next` is a plain object whose own-keys are a strict\n * subset of `prev`'s own-keys, a one-shot `console.warn` fires naming the\n * missing keys and pointing at the canonical `set({ ...get(), ...next })`\n * merge fix.\n *\n * Why opt-in: narrow-set IS legal — sometimes you want to replace state with\n * a smaller shape (a reset() that drops keys, a feature-flag-driven schema\n * change). The warn is the right shape for the canonical bug (\"I wrote\n * `set({filter})` against a replace-semantics store and wiped items+editingId\")\n * but produces false positives for intentional shape changes. Opt-in keeps\n * the warning available to dev/CI environments that want the diagnostic\n * without surprising existing projects.\n *\n * Trigger condition: ANY key in `prev` missing from `next` — strictly broader\n * than \"fewer keys total.\" A `set({a, c})` against `cur = {a, b}` (same count,\n * different keys) also wipes `b`, so it warns. The original partial-set bug\n * shape was always \"at least one key from current is missing in next\"; the\n * key-count check in the original ticket sketch was an early-exit\n * optimization, not the semantic gate.\n *\n * Skips: non-object cur/next (booleans, numbers, strings — no \"keys\" to\n * miss), null/undefined either side, and arrays either side (shrinking-array\n * replacement is normal, not a partial set).\n *\n * Per-store one-shot dedup: each store warns at most once across its\n * lifetime — matches the KF-174 / KF-176 pattern of \"tell the developer\n * about the rule violation once, then trust them to fix it.\" The dedup\n * scope is the store, not the module, so a second store can still warn\n * if it independently hits the same bug.\n *\n * Production behavior is unchanged for zero runtime cost (the env-var read\n * short-circuits before any per-set work runs).\n */\n\nexport interface NarrowSetWarnContext {\n /** Set once per store; the warner reads/writes this to enforce the per-store one-shot dedup. */\n warned: boolean;\n}\n\nconst WARNING_PREFIX\n = 'kerf: defineStore.set() called with keys missing from the current state — ';\nconst WARNING_SUFFIX\n = '. set() REPLACES state; the missing keys will be undefined after this call. '\n + 'Use `set({ ...get(), ...next })` to merge instead, or update each call site to pass the full state. '\n + 'Set KERF_DEV_WARN_NARROW_SET=0 (or unset it) to silence this warning.';\n\nexport function isOptedIn(): boolean {\n const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;\n if (proc?.env?.NODE_ENV === 'production') return false;\n return proc?.env?.KERF_DEV_WARN_NARROW_SET === '1';\n}\n\nfunction isPlainObjectState(v: unknown): v is Record<string, unknown> {\n if (v === null || typeof v !== 'object') return false;\n if (Array.isArray(v)) return false;\n return true;\n}\n\nexport function maybeWarnNarrowSet(\n prev: unknown,\n next: unknown,\n ctx: NarrowSetWarnContext,\n): void {\n if (ctx.warned) return;\n if (!isOptedIn()) return;\n if (!isPlainObjectState(prev) || !isPlainObjectState(next)) return;\n\n const missing: string[] = [];\n for (const k of Object.keys(prev)) {\n if (!(k in next)) missing.push(k);\n }\n if (missing.length === 0) return;\n\n ctx.warned = true;\n const keysList = missing.map((k) => `\\`${k}\\``).join(', ');\n console.warn(`${WARNING_PREFIX}${keysList}${WARNING_SUFFIX}`);\n}\n\n/**\n * Test helper — resets the per-store `warned` flag on a context so a\n * subsequent test in the same module can re-exercise the first-warning\n * path. Not exported from the public barrel; the unit-test file imports it\n * directly via the relative path.\n */\nexport function _resetWarnContext(ctx: NarrowSetWarnContext): void {\n ctx.warned = false;\n}\n","/**\n * `defineStore({ initial, actions })` — composable testable stores layered on\n * top of `reactive.ts`'s signals.\n *\n * Three rules:\n * 1. `state` is read-only. Consumers read via `state.value` or subscribe via\n * `effect()`. They cannot write directly.\n * 2. `actions` is the only mutation surface. All writes go through named\n * action functions. This is what makes stores testable — assert against\n * actions, not against arbitrary writes.\n * 3. `reset()` resets to `initial()`. Always defined; tests use it for\n * setup, lifecycle hooks (route change, sign-out, etc.) use it for\n * tear-down.\n *\n * A module-level registry tracks every store created via `defineStore()`;\n * `resetAllStores()` walks the registry and calls each `reset()`. Useful for\n * tests + project-switch / logout / route-reset scenarios where every piece\n * of client state should return to its initial shape.\n */\n\nimport { maybeWarnNarrowSet, type NarrowSetWarnContext } from './dev-store-warn.js';\nimport type { ReadonlySignal, Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\nexport interface Store<TState, TActions> {\n /** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */\n readonly state: ReadonlySignal<TState>;\n /** Named mutators — the only way to change state. */\n readonly actions: TActions;\n /** Reset state to `initial()`. Used by tests and lifecycle hooks. */\n reset(): void;\n}\n\ninterface DefineStoreSpec<TState, TActions> {\n initial: () => TState;\n actions: (set: (next: TState) => void, get: () => Readonly<TState>) => TActions;\n}\n\nconst REGISTRY: Array<{ reset: () => void }> = [];\n\nconst IS_DEV: boolean = (() => {\n const proc = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process;\n return proc?.env?.NODE_ENV !== 'production';\n})();\n\nexport function defineStore<TState, TActions>(\n spec: DefineStoreSpec<TState, TActions>,\n): Store<TState, TActions> {\n const internal: Signal<TState> = signal(spec.initial());\n // KF-212: per-store one-shot dedup for the opt-in narrow-set warning.\n // Default off; consumers opt in via `KERF_DEV_WARN_NARROW_SET=1` in dev.\n // Production behavior is unchanged — `maybeWarnNarrowSet` short-circuits\n // on the env-var read before any per-set work runs.\n const warnCtx: NarrowSetWarnContext = { warned: false };\n\n const set = (next: TState): void => {\n maybeWarnNarrowSet(internal.value, next, warnCtx);\n internal.value = next;\n };\n // In dev, freeze the snapshot returned to actions so that\n // `get().count = 42`-style mutations (a documented Rule 8 violation) throw\n // a native `TypeError: Cannot assign to read only property` instead of\n // silently landing on the underlying state without notifying subscribers.\n // Production keeps the bare reference for zero overhead. Read NODE_ENV via\n // globalThis so the source works untouched in browsers (no bare `process`).\n const get = (): Readonly<TState> => {\n const v = internal.value;\n if (IS_DEV && v !== null && typeof v === 'object') {\n Object.freeze(v);\n }\n return v;\n };\n\n const actions = spec.actions(set, get);\n\n const store: Store<TState, TActions> = {\n state: internal,\n actions,\n reset() {\n internal.value = spec.initial();\n },\n };\n\n REGISTRY.push(store);\n return store;\n}\n\n/**\n * Reset every store registered via `defineStore()` to its `initial()` value.\n * Used by tests and by application lifecycle hooks (project switch, logout,\n * route reset).\n */\nexport function resetAllStores(): void {\n for (const s of REGISTRY) s.reset();\n}\n\n/**\n * Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,\n * not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.\n */\nexport function clearStoreRegistry(): void {\n REGISTRY.length = 0;\n}\n"]}
1
+ {"version":3,"sources":["../src/dev-store-warn.ts","../src/store.ts"],"names":[],"mappings":"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","file":"chunk-7SKIIA5P.js","sourcesContent":["/**\n * Dev-mode warning for partial-set violations of Hard Rule 8 (KF-212). When\n * the opt-in env var `KERF_DEV_WARN_NARROW_SET=1` is set in a non-production\n * build, `defineStore`'s `set()` calls `maybeWarnNarrowSet(prev, next, ctx)`\n * before assigning. If `next` is a plain object whose own-keys are a strict\n * subset of `prev`'s own-keys, a one-shot `console.warn` fires naming the\n * missing keys and pointing at the canonical `set({ ...get(), ...next })`\n * merge fix.\n *\n * Why opt-in: narrow-set IS legal — sometimes you want to replace state with\n * a smaller shape (a reset() that drops keys, a feature-flag-driven schema\n * change). The warn is the right shape for the canonical bug (\"I wrote\n * `set({filter})` against a replace-semantics store and wiped items+editingId\")\n * but produces false positives for intentional shape changes. Opt-in keeps\n * the warning available to dev/CI environments that want the diagnostic\n * without surprising existing projects.\n *\n * Trigger condition: ANY key in `prev` missing from `next` — strictly broader\n * than \"fewer keys total.\" A `set({a, c})` against `cur = {a, b}` (same count,\n * different keys) also wipes `b`, so it warns. The original partial-set bug\n * shape was always \"at least one key from current is missing in next\"; the\n * key-count check in the original ticket sketch was an early-exit\n * optimization, not the semantic gate.\n *\n * Skips: non-object cur/next (booleans, numbers, strings — no \"keys\" to\n * miss), null/undefined either side, and arrays either side (shrinking-array\n * replacement is normal, not a partial set).\n *\n * Per-store one-shot dedup: each store warns at most once across its\n * lifetime — matches the KF-174 / KF-176 pattern of \"tell the developer\n * about the rule violation once, then trust them to fix it.\" The dedup\n * scope is the store, not the module, so a second store can still warn\n * if it independently hits the same bug.\n *\n * Production behavior is unchanged for zero runtime cost (the env-var read\n * short-circuits before any per-set work runs).\n */\n\nexport interface NarrowSetWarnContext {\n /** Set once per store; the warner reads/writes this to enforce the per-store one-shot dedup. */\n warned: boolean;\n}\n\nconst WARNING_PREFIX\n = 'kerf: defineStore.set() called with keys missing from the current state — ';\nconst WARNING_SUFFIX\n = '. set() REPLACES state; the missing keys will be undefined after this call. '\n + 'Use `set({ ...get(), ...next })` to merge instead, or update each call site to pass the full state. '\n + 'Set KERF_DEV_WARN_NARROW_SET=0 (or unset it) to silence this warning.';\n\nexport function isOptedIn(): boolean {\n const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;\n if (proc?.env?.NODE_ENV === 'production') return false;\n return proc?.env?.KERF_DEV_WARN_NARROW_SET === '1';\n}\n\nfunction isPlainObjectState(v: unknown): v is Record<string, unknown> {\n if (v === null || typeof v !== 'object') return false;\n if (Array.isArray(v)) return false;\n return true;\n}\n\nexport function maybeWarnNarrowSet(\n prev: unknown,\n next: unknown,\n ctx: NarrowSetWarnContext,\n): void {\n if (ctx.warned) return;\n if (!isOptedIn()) return;\n if (!isPlainObjectState(prev) || !isPlainObjectState(next)) return;\n\n const missing: string[] = [];\n for (const k of Object.keys(prev)) {\n if (!(k in next)) missing.push(k);\n }\n if (missing.length === 0) return;\n\n ctx.warned = true;\n const keysList = missing.map((k) => `\\`${k}\\``).join(', ');\n console.warn(`${WARNING_PREFIX}${keysList}${WARNING_SUFFIX}`);\n}\n\n/**\n * Test helper — resets the per-store `warned` flag on a context so a\n * subsequent test in the same module can re-exercise the first-warning\n * path. Not exported from the public barrel; the unit-test file imports it\n * directly via the relative path.\n */\nexport function _resetWarnContext(ctx: NarrowSetWarnContext): void {\n ctx.warned = false;\n}\n","/**\n * `defineStore({ initial, actions })` — composable testable stores layered on\n * top of `reactive.ts`'s signals.\n *\n * Three rules:\n * 1. `state` is read-only. Consumers read via `state.value` or subscribe via\n * `effect()`. They cannot write directly.\n * 2. `actions` is the only mutation surface. All writes go through named\n * action functions. This is what makes stores testable — assert against\n * actions, not against arbitrary writes.\n * 3. `reset()` resets to `initial()`. Always defined; tests use it for\n * setup, lifecycle hooks (route change, sign-out, etc.) use it for\n * tear-down.\n *\n * A module-level registry tracks every store created via `defineStore()`;\n * `resetAllStores()` walks the registry and calls each `reset()`. Useful for\n * tests + project-switch / logout / route-reset scenarios where every piece\n * of client state should return to its initial shape.\n */\n\nimport { maybeWarnNarrowSet, type NarrowSetWarnContext } from './dev-store-warn.js';\nimport type { ReadonlySignal, Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\nexport interface Store<TState, TActions> {\n /** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */\n readonly state: ReadonlySignal<TState>;\n /** Named mutators — the only way to change state. */\n readonly actions: TActions;\n /** Reset state to `initial()`. Used by tests and lifecycle hooks. */\n reset(): void;\n}\n\ninterface DefineStoreSpec<TState, TActions> {\n initial: () => TState;\n actions: (set: (next: TState) => void, get: () => Readonly<TState>) => TActions;\n}\n\nconst REGISTRY: Array<{ reset: () => void }> = [];\n\nconst IS_DEV: boolean = (() => {\n const proc = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process;\n return proc?.env?.NODE_ENV !== 'production';\n})();\n\nexport function defineStore<TState, TActions>(\n spec: DefineStoreSpec<TState, TActions>,\n): Store<TState, TActions> {\n const internal: Signal<TState> = signal(spec.initial());\n // KF-212: per-store one-shot dedup for the opt-in narrow-set warning.\n // Default off; consumers opt in via `KERF_DEV_WARN_NARROW_SET=1` in dev.\n // Production behavior is unchanged — `maybeWarnNarrowSet` short-circuits\n // on the env-var read before any per-set work runs.\n const warnCtx: NarrowSetWarnContext = { warned: false };\n\n const set = (next: TState): void => {\n maybeWarnNarrowSet(internal.value, next, warnCtx);\n internal.value = next;\n };\n // In dev, freeze the snapshot returned to actions so that\n // `get().count = 42`-style mutations (a documented Rule 8 violation) throw\n // a native `TypeError: Cannot assign to read only property` instead of\n // silently landing on the underlying state without notifying subscribers.\n // Production keeps the bare reference for zero overhead. Read NODE_ENV via\n // globalThis so the source works untouched in browsers (no bare `process`).\n const get = (): Readonly<TState> => {\n const v = internal.value;\n if (IS_DEV && v !== null && typeof v === 'object') {\n Object.freeze(v);\n }\n return v;\n };\n\n const actions = spec.actions(set, get);\n\n const store: Store<TState, TActions> = {\n state: internal,\n actions,\n reset() {\n internal.value = spec.initial();\n },\n };\n\n REGISTRY.push(store);\n return store;\n}\n\n/**\n * Reset every store registered via `defineStore()` to its `initial()` value.\n * Used by tests and by application lifecycle hooks (project switch, logout,\n * route reset).\n */\nexport function resetAllStores(): void {\n for (const s of REGISTRY) s.reset();\n}\n\n/**\n * Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,\n * not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.\n */\nexport function clearStoreRegistry(): void {\n REGISTRY.length = 0;\n}\n"]}