kerfjs 0.10.0 → 0.11.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
@@ -7,9 +7,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  ## Unreleased
8
8
 
9
9
 
10
- - `kerfjs` npm package now bundles the drop-in AI-assistant configs at `ai/skill.md`, `ai/cursorrules`, and `ai/manifest.json` — so `npm install kerfjs` lands them directly on disk instead of asking consumers to find them on GitHub. The repo-root `kerf.claude-skill.md` / `kerf.cursorrules` remain the source of truth; `ai/` is regenerated by `scripts/sync-ai-bundle.mjs` and kept honest by the new `npm run check:ai-bundle-in-sync` gate (wired into `npm run check`). Each bundled file carries a `kerf-skill-version` line + a `KERF-APP-CANONICAL-END` marker so the new ESLint rule can detect drift and auto-fix only the kerf-maintained section while preserving consumer customisations below the marker. See `docs/12-ai-assistant-configs.md`.
10
+ - KF-230 — `eslint-plugin-kerfjs` adds `kerfjs/prefer-attr-selector` (`warn` in recommended; plugin bumped to v0.11.0) — flags `delegate(_, _, '[name="value"]', _)` literal selectors and recommends `attr('name', 'value').selector`. Routing JSX `{...spec.attrs}` and the delegate target through one typed source means a rename can't desync the two. Conservative AST match: compound selectors (`[a="x"][b="y"]`), tag-qualified (`button[data-action="x"]`), bare-presence (`[data-new]`), and class / id selectors are left alone — those aren't 1:1 swaps for `attr()`. Severity `warn` because the literal form still works at runtime.
11
+ - KF-230 — `scripts/check-docs-examples.mjs` gains Check 3 (example doc/source import mirroring). For every paired `site/src/content/docs/examples/complete/<name>.md` ↔ `site/src/examples/complete/<name>/main.tsx`, every named kerfjs import the source pulls in must also appear in at least one kerf-import statement in the doc page. Catches "release adds a new public API (`attr()`, …) and updates the example source, but the rendered doc excerpt still uses the pre-release pattern" — the v0.11.0 release shipped exactly this drift across TodoMVC + chat doc pages, 12 migration pages, 7 reactivity-demo sections, and `docs/ai/usage-guide.md`, all corrected in the same ticket.
12
+ - `attr()` now ships two overloads and a richer return type. **Static** `attr(name, value)` returns `AttrSpec<N, V>` — a frozen descriptor with `.name`, `.value`, `.selector`, and `.attrs` (a `{ readonly [name]: value }` object ready to spread into JSX, keeping the attribute name out of every call site so renames propagate automatically). **Dynamic** `attr<N, V extends string = string>(name)` pre-validates the attribute name and returns a per-render factory `(value: V) => { readonly [name]: V }` for per-row data attributes like `data-id`. `V` defaults to `string`; specify both generics explicitly (e.g. `attr<'data-sort', 'asc'|'desc'>('data-sort')`) to constrain the value set the factory accepts. `AttrSpec` gains two type parameters (`N extends string`, `V extends string`) for tighter `satisfies` constraints. `satisfies Record<string, AttrSpec<'data-action'>>` now catches a mismatched attribute name at compile time.
13
+ - `delegate<T extends Element = Element>()` and `delegateCapture<T>()` now accept an optional element-type generic that narrows the matched element in the handler — `delegate<HTMLButtonElement>(...)` avoids the `as HTMLButtonElement` cast, zero runtime change.
14
+ - `get()` inside `defineStore` actions is now typed `() => Readonly<TState>` — the compile-time counterpart to the existing dev-mode `Object.freeze` on the snapshot. Actions that try to mutate `get().prop = ...` now fail `tsc --noEmit` without a cast, before even reaching the runtime `TypeError`.
15
+ - Two new opt-in dev warnings in `src/dev-each-warn.ts`: `KERF_DEV_WARN_DUPLICATE_EACH_KEYS=1` (fires when two items in the same `each()` list produce the same `cacheKey` value — indicates a non-unique cache-key function) and `KERF_DEV_WARN_EACH_IN_MORPH_SKIP=1` (fires when an `each()` list is nested inside a `data-morph-skip` subtree — flags the asymmetric-freeze pattern where static JSX in the same ancestor is frozen while the list still updates). Both are one-shot per `each()` callsite.
16
+ - `eslint-plugin-kerfjs` adds `kerfjs/no-raw-with-dynamic-arg` (`warn` in recommended) — flags any `raw()` call whose argument is not a static string literal or an expression-free template literal. Creates a searchable audit trail for every dynamic HTML injection point; suppress with `eslint-disable-next-line` to acknowledge the sanitizer.
17
+ - `mount()` same-element double-mount error now names the element in the message (`<div#app> is already mounted`) instead of a generic description, so the duplicate mount site is immediately locatable in DevTools or a test failure log.
18
+ - `kerfjs` npm package now bundles the drop-in AI-assistant configs at `ai/skill.md`, `ai/cursorrules`, and `ai/manifest.json` — so `npm install kerfjs` lands them directly on disk instead of asking consumers to find them on GitHub. The repo-root `kerf.claude-skill.md` / `kerf.cursorrules` remain the source of truth; `ai/` is regenerated by `scripts/sync-ai-bundle.mjs` and kept honest by the new `npm run check:ai-bundle-in-sync` gate (wired into `npm run check`). Each bundled file carries a `kerf-skill-version` line + a `KERF-APP-CANONICAL-END` marker so the new ESLint rule can detect drift and auto-fix only the kerf-maintained section while preserving consumer customizations below the marker. See `docs/12-ai-assistant-configs.md`.
11
19
  - `eslint-plugin-kerfjs` v0.9.0 adds `kerfjs/ai-assistant-configs` (`warn` in recommended config) — once per lint pass, checks the consumer's `.claude/skills/kerf-app/SKILL.md` and `.cursorrules` against the bundled `kerfjs/ai/manifest.json`. Reports `missing` / `stale` / `forked` states; `eslint --fix` writes the bundled canonical above the `KERF-APP-CANONICAL-END` marker and preserves the consumer's append zone below it (the "versioned-section preservation" strategy). Granular disable via `['warn', { claude: false, cursor: true }]`.
12
20
 
21
+ ## [0.11.1] - 2026-05-21
22
+
23
+
24
+ - `attr()` redesign: typed `AttrSpec<N, V>` exposes `.attrs` with dual overloads for cleaner attribute handling
25
+
26
+ ## [0.11.0] - 2026-05-21
27
+
28
+
29
+ - `attr()` redesigned with `AttrSpec<N,V>` shape, `.attrs` accessor, and dual overloads
30
+ - Hardened defensive programming across the runtime for safer edge-case handling
31
+ - Refreshed published performance numbers from a fresh cross-framework benchmark run
32
+
13
33
  ## [0.10.0] - 2026-05-20
14
34
 
15
35
 
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  > Introducing Kerf.
12
12
  > The smallest cut.
13
13
  >
14
- > 6.1 KB. No virtual DOM. No compiler. No magic.
14
+ > ~11 KB. No virtual DOM. No compiler. No magic.
15
15
  > Reactive UI that touches only the bytes that changed.
16
16
 
17
17
  ```ts
@@ -31,7 +31,7 @@ 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.** 6.1 KB gzipped including signals (6.5 KB with `arraySignal`). One runtime dependency (`@preact/signals-core`). 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 wins the compiler-driven `select row` and `partial update` benchmarks.
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 wins the compiler-driven `select row` and `partial update` benchmarks.
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
 
@@ -56,7 +56,7 @@ That's it. Your JSX renders to HTML strings, kerf's native diff applies the mini
56
56
  - Building a deeply componentised design-system app → **React / Solid / Svelte**.
57
57
  - Need React Native / cross-platform mobile → **React** (Kerf + Tauri/Electron also covers many of these cases).
58
58
  - Building a static site → **Astro** (we use it for *this* project's site).
59
- - Already invested in a framework where switching cost outweighs the ~6 KB win.
59
+ - Already invested in a framework where switching cost outweighs the bundle size gain.
60
60
 
61
61
  ## Quick tour
62
62
 
@@ -158,7 +158,7 @@ npm install kerfjs
158
158
 
159
159
  ### Optional: `eslint-plugin-kerfjs`
160
160
 
161
- A companion ESLint plugin enforces four of kerf's hard rules at edit time — inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, and global `JSX.IntrinsicElements` augmentation. The plugin is AST-only (no parser-services dependency), so it works with any TypeScript-ESLint setup.
161
+ A companion ESLint plugin enforces kerf's hard rules at edit time. Four AST rules catch hard-rule violations — inline JSX event handlers, missing `data-key` in `each()`, nested `mount()`, and global `JSX.IntrinsicElements` augmentation. Two additional rules cover `raw()` XSS audit trails and AI-assistant config hygiene. The plugin is AST-only (no parser-services dependency), so it works with any TypeScript-ESLint setup.
162
162
 
163
163
  ```bash
164
164
  npm install --save-dev eslint-plugin-kerfjs
@@ -170,7 +170,7 @@ import kerfjs from 'eslint-plugin-kerfjs';
170
170
  export default [kerfjs.configs.recommended];
171
171
  ```
172
172
 
173
- Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — legacy `.eslintrc` config, per-rule examples, and the "why only four rules" framing.
173
+ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — legacy `.eslintrc` config, per-rule examples, and the rationale for which violations get lint rules vs. dev-warns vs. strict TS.
174
174
 
175
175
  ## Links
176
176
 
@@ -178,7 +178,7 @@ Full docs at [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwes
178
178
  - **Docs:** [`docs/`](./docs/) — overview · reactivity · stores · render · events · jsx · svg · [API reference](./docs/8-api-reference.md)
179
179
  - **Migrating:** [coming from another framework?](https://brianwestphal.github.io/kerf/migrating/) — side-by-side TodoMVC translations + per-framework gotchas
180
180
  - **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — reference for AI tools fetching kerf docs (linked from `llms.txt`)
181
- - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; four AST-only rules enforcing kerf hard rules at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
181
+ - **ESLint plugin:** [brianwestphal.github.io/kerf/docs/eslint-plugin/](https://brianwestphal.github.io/kerf/docs/eslint-plugin/) — `eslint-plugin-kerfjs`; six rules (four hard-rule errors + `no-raw-with-dynamic-arg` warn + `ai-assistant-configs` warn) at edit time (source: [`eslint-plugin/`](./eslint-plugin/))
182
182
  - **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)
183
183
  - **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
184
184
 
package/ai/cursorrules CHANGED
@@ -1,4 +1,4 @@
1
- <!-- kerf-skill-version: 1.0.0 -->
1
+ <!-- kerf-skill-version: 1.1.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
@@ -43,6 +43,8 @@ import { arraySignal } from 'kerfjs/array-signal';
43
43
  | `each(items, render, cacheKey?)` | keyed list iteration; per-row memoization on object identity (+ optional cacheKey — a passive comparator for external state). Distinct from `data-key` on the rendered element |
44
44
  | `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
45
45
  | `delegateCapture(root, type, sel, h)` | capture-phase escape hatch, strict `target.matches()` |
46
+ | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
47
+ | `attr(name)` | dynamic factory — `attr<N,V=string>(name)` returns `(value: V) => { readonly [name]: V }`; both generics off → N inferred, V defaults to string; specify both to constrain values |
46
48
  | `toElement(jsx)` | parse JSX into one DOM node (SVG-aware) |
47
49
  | `raw(html)` | inject pre-escaped HTML |
48
50
  | `arraySignal(initial?)` | granular keyed-list signal at `kerfjs/array-signal` subpath; `each()` reconciles in O(patches) |
@@ -92,13 +94,15 @@ When deciding which primitive to reach for, work down the axes:
92
94
  ```tsx
93
95
  // Signal + mount
94
96
  const count = signal(0);
97
+ const ACTIONS = { inc: attr('data-action', 'inc') } as const satisfies Record<string, AttrSpec<'data-action'>>;
98
+
95
99
  mount(document.getElementById('app')!, () => (
96
100
  <div>
97
- <button data-action="inc">+</button>
101
+ <button {...ACTIONS.inc.attrs}>+</button>
98
102
  <span>{count.value}</span>
99
103
  </div>
100
104
  ));
101
- delegate(rootEl, 'click', '[data-action="inc"]', () => { count.value += 1; });
105
+ delegate(rootEl, 'click', ACTIONS.inc.selector, () => { count.value += 1; });
102
106
 
103
107
  // Keyed list with per-item memoization
104
108
  mount(listEl, () => (
@@ -124,7 +128,7 @@ morph(liveCard, '<article class="card">…</article>');
124
128
 
125
129
  - `JSX: DOM elements cannot be passed as children` → you passed a `toElement()` result inside JSX. Build the whole tree in JSX; get refs via `querySelector` after rendering.
126
130
  - Focus / cursor lost on every keystroke → list items lack `data-key`. Add it.
127
- - Click handler stops firing after re-render → `el.addEventListener` was used. Replace with `delegate(rootEl, 'click', '[data-action="..."]', ...)`.
131
+ - Click handler stops firing after re-render → `el.addEventListener` was used. Replace with `delegate(rootEl, 'click', ACTIONS.foo.selector, ...)` (or a string literal `'[data-action="foo"]'` for ad-hoc cases).
128
132
  - Render fn never re-runs → signal was read outside the render fn. Move the `signal.value` read inside.
129
133
  - SVG renders as broken / namespaceless markup → use `mount` (HTML path) or `toElement` (SVG-aware), not `innerHTML`.
130
134
  - Library widget destroyed on every render → wrap host in `data-morph-skip`; mount the library imperatively after first render.
package/ai/manifest.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
- "kerfjsVersion": "0.10.0",
2
+ "kerfjsVersion": "0.11.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.0.0",
10
- "sha256": "bef982cb743fd4c73e666ee5cf774112e57c93ebb13b24bbacbf08577db0930a"
9
+ "version": "1.1.0",
10
+ "sha256": "f2aadb21e33dd389028951ae1ecbd9f8ba68648b3c5beba43fb4dfa2ab29f195"
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.0.0",
18
- "sha256": "98f7faff2b7585ef9cc511add42ea5d9bf0cbae8307b066af090e9281136d2ab"
17
+ "version": "1.1.0",
18
+ "sha256": "a2ec8c221dc694c98b81009cd2d99f9159a45fda94585de2f54471b227099eda"
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.0.0
4
+ kerf-skill-version: 1.1.0
5
5
  ---
6
6
 
7
7
  # Building apps with kerf
@@ -10,7 +10,7 @@ kerf-skill-version: 1.0.0
10
10
  > project's `.claude/skills/kerf-app/SKILL.md`) so Claude Code activates
11
11
  > it whenever you work on a kerf app.
12
12
 
13
- kerf is a ~6.1 KB reactive UI framework (6.5 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler. The whole public surface fits in 15 exports.
13
+ kerf is a ~11 KB reactive UI framework (~12 KB with `arraySignal`): signals + DOM morphing + JSX → HTML strings. No virtual DOM, no compiler, no scheduler. The whole public surface fits in 15 exports.
14
14
 
15
15
  ## Setup
16
16
 
@@ -48,6 +48,8 @@ import { arraySignal } from 'kerfjs/array-signal';
48
48
  | `each(items, render, cacheKey?)` | keyed list iteration; per-row memoization on identity (+ optional cacheKey — a passive comparator for external state). Distinct from `data-key` on the rendered element |
49
49
  | `delegate(root, type, sel, h)` | one listener at the root; `closest(selector)` walk from target |
50
50
  | `delegateCapture(root, type, sel, h)` | capture-phase escape hatch; `target.matches()` strict match |
51
+ | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
52
+ | `attr(name)` | dynamic factory — `attr<N,V=string>(name)` returns `(value: V) => { readonly [name]: V }`; both generics off → N inferred, V defaults to string; specify both to constrain values |
51
53
  | `toElement(jsx)` | parse JSX into one DOM node (SVG-aware) |
52
54
  | `raw(html)` | inject pre-escaped HTML |
53
55
  | `arraySignal(initial?)` | granular keyed-list signal (subpath `kerfjs/array-signal`); `each()` reconciles in O(patches) |
@@ -100,13 +102,15 @@ When deciding which primitive to reach for, work down the axes:
100
102
  ```tsx
101
103
  // Pattern 1: signal + mount + delegate
102
104
  const count = signal(0);
105
+ const ACTIONS = { inc: attr('data-action', 'inc') } as const satisfies Record<string, AttrSpec<'data-action'>>;
106
+
103
107
  mount(document.getElementById('app')!, () => (
104
108
  <div>
105
- <button data-action="inc">+</button>
109
+ <button {...ACTIONS.inc.attrs}>+</button>
106
110
  <span>{count.value}</span>
107
111
  </div>
108
112
  ));
109
- delegate(rootEl, 'click', '[data-action="inc"]', () => { count.value += 1; });
113
+ delegate(rootEl, 'click', ACTIONS.inc.selector, () => { count.value += 1; });
110
114
 
111
115
  // Pattern 2: keyed list with per-row memoization
112
116
  mount(listEl, () => (
@@ -135,7 +139,7 @@ morph(liveCard, '<article class="card">…</article>');
135
139
  | --- | --- | --- |
136
140
  | `JSX: DOM elements cannot be passed as children` | passed a `toElement()` result inside JSX | Build the whole tree in JSX; refs via `querySelector` after rendering |
137
141
  | Focus / cursor lost on every keystroke | list items lack `data-key` | Add `data-key` (or `id`) to each list item |
138
- | Click handler stops firing after re-render | `el.addEventListener` was used | Replace with `delegate(rootEl, 'click', '[data-action="..."]', ...)` |
142
+ | Click handler stops firing after re-render | `el.addEventListener` was used | Replace with `delegate(rootEl, 'click', ACTIONS.foo.selector, ...)` (or a string literal for ad-hoc cases) |
139
143
  | Render fn never re-runs | signal was read outside the render fn | Move `signal.value` read inside the render fn |
140
144
  | SVG renders as broken / namespaceless markup | `innerHTML` used directly | Use `mount` or `toElement` (SVG-aware) |
141
145
  | Library widget destroyed on every render | host reachable by the morph | Wrap host in `data-morph-skip`; mount the library imperatively after first render |
@@ -66,5 +66,5 @@ function clearStoreRegistry() {
66
66
  }
67
67
 
68
68
  export { clearStoreRegistry, defineStore, resetAllStores };
69
- //# sourceMappingURL=chunk-WUFUTNA7.js.map
70
- //# sourceMappingURL=chunk-WUFUTNA7.js.map
69
+ //# sourceMappingURL=chunk-IBVKW6WU.js.map
70
+ //# sourceMappingURL=chunk-IBVKW6WU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/dev-store-warn.ts","../src/store.ts"],"names":[],"mappings":"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","file":"chunk-IBVKW6WU.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"]}
package/dist/index.d.ts CHANGED
@@ -3,7 +3,83 @@ import { SafeHtml } from './jsx-runtime.js';
3
3
  export { Fragment, isSafeHtml, raw } from './jsx-runtime.js';
4
4
  import { Signal } from '@preact/signals-core';
5
5
  export { ReadonlySignal, Signal, batch, computed, effect } from '@preact/signals-core';
6
- export { S as Store, d as defineStore, r as resetAllStores } from './testing-CdMgVVoI.js';
6
+ export { S as Store, d as defineStore, r as resetAllStores } from './testing-DNEY7wi3.js';
7
+
8
+ /**
9
+ * `attr(name, value)` — create a pre-computed attribute descriptor (static form).
10
+ * `attr(name)` — create a per-render factory for dynamic attribute values (dynamic form).
11
+ *
12
+ * **Static form** — best for fixed action names, filter keys, role values, etc.
13
+ * Escapes once at module-load time; produces a full {@link AttrSpec} with
14
+ * `.name`, `.value`, `.selector`, and `.attrs`.
15
+ *
16
+ * const ACTIONS = {
17
+ * toggle: attr('data-action', 'toggle'),
18
+ * remove: attr('data-action', 'remove'),
19
+ * } as const satisfies Record<string, AttrSpec<'data-action'>>;
20
+ *
21
+ * // In JSX — spread .attrs (rename-safe; no hardcoded attribute name):
22
+ * <button {...ACTIONS.toggle.attrs}>Toggle</button>
23
+ *
24
+ * // In delegate — use the pre-computed selector:
25
+ * delegate(root, 'click', ACTIONS.toggle.selector, handler);
26
+ *
27
+ * **Dynamic form** — best for per-row data like `data-id`, where the value
28
+ * changes per item but the attribute name is constant.
29
+ * The name is validated and pre-escaped at definition time; calling the
30
+ * returned factory is cheap (just escape the value and freeze the object).
31
+ *
32
+ * const ITEM = { id: attr('data-id') } as const;
33
+ *
34
+ * // In JSX — call the factory inline:
35
+ * <li {...ITEM.id(String(item.id))}>…</li>
36
+ *
37
+ * For ad-hoc compound selectors, concatenate `.selector` strings:
38
+ *
39
+ * delegate(root, 'click',
40
+ * ACTIONS.toggle.selector + attr('data-id', id).selector,
41
+ * handler);
42
+ *
43
+ * Escaping:
44
+ * - Attribute name: escaped as a CSS identifier via `cssEscapeIdent`, which is
45
+ * an SSR-safe (no `CSS.escape`) adaptation of the Mathias Bynens polyfill
46
+ * (https://github.com/nicktindall/cyclon.p2p-common, MIT licensed). Handles
47
+ * control chars, leading digits, non-ASCII, and CSS metacharacters.
48
+ * - Attribute value: embedded in double quotes as a CSS string. Backslashes and
49
+ * double-quote characters are backslash-escaped; control characters are
50
+ * hex-escaped per CSS Syntax Level 3 §3.4.
51
+ *
52
+ * Throws on an empty attribute name (not a valid CSS identifier).
53
+ */
54
+ /** Descriptor created by the static {@link attr} overload. */
55
+ interface AttrSpec<N extends string = string, V extends string = string> {
56
+ /** The raw attribute name passed to `attr()`. */
57
+ readonly name: N;
58
+ /** The raw attribute value passed to `attr()`. */
59
+ readonly value: V;
60
+ /** Pre-computed `[name="value"]` CSS selector string, safe to pass to `delegate()`. */
61
+ readonly selector: string;
62
+ /** Spreadable JSX object — `{ [name]: value }` — keeps the attribute name out of JSX literals. */
63
+ readonly attrs: {
64
+ readonly [K in N]: V;
65
+ };
66
+ }
67
+ /**
68
+ * Static overload — pre-computes the full descriptor at definition time.
69
+ * Returns an {@link AttrSpec} with `.name`, `.value`, `.selector`, and `.attrs`.
70
+ */
71
+ declare function attr<N extends string, V extends string>(name: N, value: V): AttrSpec<N, V>;
72
+ /**
73
+ * Dynamic overload — pre-validates and pre-escapes the attribute name, returns a
74
+ * factory that accepts a per-render value and produces a frozen spreadable object.
75
+ * Use for per-row attributes like `data-id` where the value changes per item.
76
+ * The optional `V` generic constrains which values the factory accepts:
77
+ * `attr<'data-id', 'a'|'b'>('data-id')` → `(value: 'a'|'b') => { 'data-id': 'a'|'b' }`.
78
+ * Leaving both generics off infers `N` from the argument and defaults `V` to `string`.
79
+ */
80
+ declare function attr<N extends string, V extends string = string>(name: N): (value: V) => {
81
+ readonly [K in N]: V;
82
+ };
7
83
 
8
84
  /**
9
85
  * Tiny event-delegation helpers. Replace per-element `addEventListener` calls
@@ -34,7 +110,6 @@ export { S as Store, d as defineStore, r as resetAllStores } from './testing-CdM
34
110
  * host element with `data-morph-skip` and manage the library's
35
111
  * lifecycle directly. No delegation helper applies.
36
112
  */
37
- type Handler = (event: Event, target: Element) => void;
38
113
  /**
39
114
  * Delegation that "just works" for both bubbling and the common non-bubbling
40
115
  * events. Installs ONE listener on `rootEl`; for known non-bubblers (see
@@ -43,22 +118,29 @@ type Handler = (event: Event, target: Element) => void;
43
118
  * matching walks up from `event.target` via `closest(selector)` and fires
44
119
  * `handler(event, matched)` if the match is inside `rootEl`.
45
120
  *
121
+ * The generic `T` narrows the second handler argument to the expected element
122
+ * type — `delegate<HTMLButtonElement>(root, 'click', 'button', (e, btn) => btn.value)`
123
+ * — so consumers can avoid casts. Defaults to `Element` for untyped calls.
124
+ *
46
125
  * Returns a disposer that removes the listener.
47
126
  *
48
127
  * Usage (pseudo-code — see examples for live ones):
49
128
  * delegate(rootEl, 'click', '[data-action="add"]', handlerFn);
50
129
  * delegate(rootEl, 'focus', 'input', handlerFn); // auto-capture
51
130
  */
52
- declare function delegate(rootEl: HTMLElement, type: string, selector: string, handler: Handler): () => void;
131
+ declare function delegate<T extends Element = Element>(rootEl: HTMLElement, type: string, selector: string, handler: (event: Event, target: T) => void): () => void;
53
132
  /**
54
133
  * Capture-phase delegation — for non-bubbling events (`focus`, `blur`,
55
134
  * `scroll`, `load`, `error`). Reaches descendants of `rootEl` that match
56
135
  * `selector` regardless of how many times the diff has rebuilt them.
57
136
  *
137
+ * The generic `T` narrows the second handler argument to the expected element
138
+ * type, mirroring `delegate<T>()`. Defaults to `Element` for untyped calls.
139
+ *
58
140
  * Usage (pseudo-code — see examples for live ones):
59
141
  * delegateCapture(rootEl, 'focus', 'input, textarea', handlerFn);
60
142
  */
61
- declare function delegateCapture(rootEl: HTMLElement, type: string, selector: string, handler: Handler): () => void;
143
+ declare function delegateCapture<T extends Element = Element>(rootEl: HTMLElement, type: string, selector: string, handler: (event: Event, target: T) => void): () => void;
62
144
 
63
145
  /**
64
146
  * `each(items, render, cacheKey?)` — keyed list iteration with per-item memoization.
@@ -243,4 +325,4 @@ declare function signal<T>(value?: T): Signal<T>;
243
325
 
244
326
  declare function toElement(jsx: SafeHtml | string): Element;
245
327
 
246
- export { type MountResult, SafeHtml, delegate, delegateCapture, each, morph, mount, signal, toElement };
328
+ export { type AttrSpec, type MountResult, SafeHtml, attr, delegate, delegateCapture, each, morph, mount, signal, toElement };
package/dist/index.js CHANGED
@@ -1,9 +1,80 @@
1
1
  import { isSafeHtml, listSafeHtml, flattenWithoutListItems, collectLists, flatten, granularListSafeHtml } from './chunk-4VT4YZOO.js';
2
2
  export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-4VT4YZOO.js';
3
- export { defineStore, resetAllStores } from './chunk-WUFUTNA7.js';
3
+ export { defineStore, resetAllStores } from './chunk-IBVKW6WU.js';
4
4
  import { effect } from './chunk-UU2YJEJY.js';
5
5
  export { batch, computed, effect, signal } from './chunk-UU2YJEJY.js';
6
6
 
7
+ // src/attrSelector.ts
8
+ function cssEscapeIdent(value) {
9
+ if (value === "") {
10
+ throw new Error("attr: attribute name must not be empty");
11
+ }
12
+ const str = String(value);
13
+ let result = "";
14
+ for (let i = 0; i < str.length; i++) {
15
+ const cp = str.charCodeAt(i);
16
+ const ch = str.charAt(i);
17
+ if (cp === 0) {
18
+ result += "\uFFFD";
19
+ continue;
20
+ }
21
+ if (cp >= 1 && cp <= 31 || cp === 127) {
22
+ result += "\\" + cp.toString(16) + " ";
23
+ continue;
24
+ }
25
+ if (i === 0 && cp >= 48 && cp <= 57) {
26
+ result += "\\" + cp.toString(16) + " ";
27
+ continue;
28
+ }
29
+ if (i === 1 && cp >= 48 && cp <= 57 && str.charCodeAt(0) === 45) {
30
+ result += "\\" + cp.toString(16) + " ";
31
+ continue;
32
+ }
33
+ if (cp >= 128 || cp === 45 || // `-`
34
+ cp === 95 || // `_`
35
+ cp >= 48 && cp <= 57 || // 0-9
36
+ cp >= 65 && cp <= 90 || // A-Z
37
+ cp >= 97 && cp <= 122) {
38
+ result += ch;
39
+ continue;
40
+ }
41
+ result += "\\" + ch;
42
+ }
43
+ return result;
44
+ }
45
+ function escapeCSSString(value) {
46
+ let result = "";
47
+ for (let i = 0; i < value.length; i++) {
48
+ const cp = value.charCodeAt(i);
49
+ const ch = value.charAt(i);
50
+ if (cp === 0) {
51
+ result += "\uFFFD";
52
+ } else if (cp >= 1 && cp <= 31 || cp === 127) {
53
+ result += "\\" + cp.toString(16) + " ";
54
+ } else if (cp === 92) {
55
+ result += "\\\\";
56
+ } else if (cp === 34) {
57
+ result += '\\"';
58
+ } else {
59
+ result += ch;
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ function attr(name, value) {
65
+ const escapedName = cssEscapeIdent(name);
66
+ if (value !== void 0) {
67
+ const selector = `[${escapedName}="${escapeCSSString(value)}"]`;
68
+ return Object.freeze({
69
+ name,
70
+ value,
71
+ selector,
72
+ attrs: Object.freeze({ [name]: value })
73
+ });
74
+ }
75
+ return (v) => Object.freeze({ [name]: v });
76
+ }
77
+
7
78
  // src/delegate.ts
8
79
  var NON_BUBBLING = /* @__PURE__ */ new Set([
9
80
  "focus",
@@ -54,6 +125,52 @@ function delegateCapture(rootEl, type, selector, handler) {
54
125
  };
55
126
  }
56
127
 
128
+ // src/dev-each-warn.ts
129
+ var warnedIds = /* @__PURE__ */ new Set();
130
+ function isOptedIn() {
131
+ const proc = globalThis.process;
132
+ if (proc?.env?.NODE_ENV === "production") return false;
133
+ return proc?.env?.KERF_DEV_WARN_EACH_IN_MORPH_SKIP === "1";
134
+ }
135
+ function hasMorphSkipAncestor(el, root) {
136
+ let ancestor = el.parentElement;
137
+ while (ancestor !== null && ancestor !== root) {
138
+ if (ancestor.dataset.morphSkip !== void 0) return true;
139
+ ancestor = ancestor.parentElement;
140
+ }
141
+ return false;
142
+ }
143
+ function maybeWarnEachInMorphSkip(id, liveParent, rootEl) {
144
+ if (!isOptedIn()) return;
145
+ if (warnedIds.has(id)) return;
146
+ if (!hasMorphSkipAncestor(liveParent, rootEl)) return;
147
+ warnedIds.add(id);
148
+ console.warn(
149
+ `kerf: each() list '${id}' is inside a data-morph-skip subtree. The keyed reconciler still updates the list rows, but any static signal-reactive JSX inside the same skipped ancestor (e.g. <p>{count.value}</p>) is frozen \u2014 the morph never visits it. Remove data-morph-skip from any element that contains reactive JSX content and reserve it for truly library-owned hosts. Set KERF_DEV_WARN_EACH_IN_MORPH_SKIP=0 (or unset it) to silence this warning.`
150
+ );
151
+ }
152
+ var warnedDupIds = /* @__PURE__ */ new Set();
153
+ function isOptedInDupKeys() {
154
+ const proc = globalThis.process;
155
+ if (proc?.env?.NODE_ENV === "production") return false;
156
+ return proc?.env?.KERF_DEV_WARN_DUPLICATE_EACH_KEYS === "1";
157
+ }
158
+ function maybeWarnDuplicateCacheKeys(id, segItems) {
159
+ if (!isOptedInDupKeys()) return;
160
+ if (warnedDupIds.has(id)) return;
161
+ const seen = /* @__PURE__ */ new Set();
162
+ for (const si of segItems) {
163
+ if (seen.has(si.cacheKey)) {
164
+ warnedDupIds.add(id);
165
+ console.warn(
166
+ `kerf: each() list '${id}' has duplicate cacheKey values (duplicate: ${String(si.cacheKey)}). The cacheKey function should return a unique value per row so kerf can tell apart items for memoization \u2014 duplicate values cause some rows to return stale cached HTML when external state that affects their render changes. Set KERF_DEV_WARN_DUPLICATE_EACH_KEYS=0 (or unset it) to silence this warning.`
167
+ );
168
+ return;
169
+ }
170
+ seen.add(si.cacheKey);
171
+ }
172
+ }
173
+
57
174
  // src/each.ts
58
175
  var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
59
176
  function isArraySignal(value) {
@@ -161,6 +278,9 @@ function eachSnapshotById(items, render, cacheKey, id) {
161
278
  }
162
279
  segItems[i] = { ref: item, cacheKey: k, html };
163
280
  }
281
+ if (cacheKey !== void 0) {
282
+ maybeWarnDuplicateCacheKeys(id, segItems);
283
+ }
164
284
  return listSafeHtml(id, segItems);
165
285
  }
166
286
 
@@ -282,13 +402,13 @@ function isUserAgentOwnedAttr(tagName, name) {
282
402
  function morphAttributes(fromEl, toEl) {
283
403
  const toAttrs = toEl.attributes;
284
404
  for (let i = 0; i < toAttrs.length; i++) {
285
- const attr = toAttrs[i];
286
- const ns = attr.namespaceURI;
287
- const name = attr.localName;
288
- const value = attr.value;
405
+ const attr2 = toAttrs[i];
406
+ const ns = attr2.namespaceURI;
407
+ const name = attr2.localName;
408
+ const value = attr2.value;
289
409
  if (ns !== null) {
290
410
  if (fromEl.getAttributeNS(ns, name) !== value) {
291
- fromEl.setAttributeNS(ns, attr.name, value);
411
+ fromEl.setAttributeNS(ns, attr2.name, value);
292
412
  }
293
413
  } else if (fromEl.getAttribute(name) !== value) {
294
414
  fromEl.setAttribute(name, value);
@@ -297,9 +417,9 @@ function morphAttributes(fromEl, toEl) {
297
417
  const fromAttrs = fromEl.attributes;
298
418
  const fromTag = fromEl.tagName;
299
419
  for (let i = fromAttrs.length - 1; i >= 0; i--) {
300
- const attr = fromAttrs[i];
301
- const ns = attr.namespaceURI;
302
- const name = attr.localName;
420
+ const attr2 = fromAttrs[i];
421
+ const ns = attr2.namespaceURI;
422
+ const name = attr2.localName;
303
423
  if (ns !== null) {
304
424
  if (!toEl.hasAttributeNS(ns, name)) fromEl.removeAttributeNS(ns, name);
305
425
  } else if (!toEl.hasAttribute(name) && !isUserAgentOwnedAttr(fromTag, name)) {
@@ -331,7 +451,7 @@ function preserveTextEntryState(fromEl, toEl) {
331
451
  var LISTENER_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.devListener");
332
452
  var patched = false;
333
453
  var warned = false;
334
- function isOptedIn() {
454
+ function isOptedIn2() {
335
455
  const proc = globalThis.process;
336
456
  if (proc?.env?.NODE_ENV === "production") return false;
337
457
  return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === "1";
@@ -375,7 +495,7 @@ function emitWarning() {
375
495
  );
376
496
  }
377
497
  function installListenerRebuildWarn(rootEl) {
378
- if (!isOptedIn()) return null;
498
+ if (!isOptedIn2()) return null;
379
499
  patchAddEventListenerOnce();
380
500
  const observer = new MutationObserver((mutations) => {
381
501
  if (warned) return;
@@ -970,10 +1090,15 @@ function reconcileList(binding, listSeg) {
970
1090
  // src/mount.ts
971
1091
  var LIST_MARKER_PREFIX = "kf-list:";
972
1092
  var MOUNTED_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.mounted");
1093
+ function describeEl(el) {
1094
+ const tag = el.tagName.toLowerCase();
1095
+ const id = el.id ? `#${el.id}` : "";
1096
+ return `<${tag}${id}>`;
1097
+ }
973
1098
  function assertNotInsideMountedTree(rootEl) {
974
1099
  if (rootEl[MOUNTED_MARKER] === true) {
975
1100
  throw new Error(
976
- "mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \u2014 compose with plain functions that return JSX instead of nesting mounts."
1101
+ `mount: ${describeEl(rootEl)} is already mounted. Call the disposer returned by the first mount() before mounting again. kerf supports one mount per element \u2014 compose with plain functions that return JSX instead of nesting mounts.`
977
1102
  );
978
1103
  }
979
1104
  let ancestor = rootEl.parentElement;
@@ -1092,6 +1217,7 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
1092
1217
  if (items.length > 0) {
1093
1218
  maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
1094
1219
  }
1220
+ maybeWarnEachInMorphSkip(id, liveParent, rootEl);
1095
1221
  bindings.set(id, binding);
1096
1222
  }
1097
1223
  }
@@ -1199,6 +1325,6 @@ function toElement(jsx) {
1199
1325
  return child;
1200
1326
  }
1201
1327
 
1202
- export { delegate, delegateCapture, each, morph, mount, toElement };
1328
+ export { attr, delegate, delegateCapture, each, morph, mount, toElement };
1203
1329
  //# sourceMappingURL=index.js.map
1204
1330
  //# sourceMappingURL=index.js.map