kerfjs 5.0.0-beta.4 → 5.0.0-beta.41

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +345 -116
  2. package/README.md +43 -23
  3. package/ai/cursorrules +87 -48
  4. package/ai/manifest.json +10 -7
  5. package/ai/skill.md +101 -67
  6. package/dist/actions.js +2 -2
  7. package/dist/actions.js.map +1 -1
  8. package/dist/array-signal.js +1 -1
  9. package/dist/async.js +22 -19
  10. package/dist/async.js.map +1 -1
  11. package/dist/attach.js +3 -1
  12. package/dist/attach.js.map +1 -1
  13. package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +3 -2
  14. package/dist/chunk-5WRGJZV6.js.map +1 -0
  15. package/dist/{chunk-GY4XV2UV.js → chunk-BDX3R4OM.js} +3 -2
  16. package/dist/chunk-BDX3R4OM.js.map +1 -0
  17. package/dist/{chunk-HW7KSM2Y.js → chunk-CEQMZYLR.js} +1 -1
  18. package/dist/{chunk-HW7KSM2Y.js.map → chunk-CEQMZYLR.js.map} +1 -1
  19. package/dist/{chunk-SVATPF5R.js → chunk-DLY444MN.js} +121 -49
  20. package/dist/chunk-DLY444MN.js.map +1 -0
  21. package/dist/{chunk-KPXIOG2C.js → chunk-E5R5GNKE.js} +14 -4
  22. package/dist/chunk-E5R5GNKE.js.map +1 -0
  23. package/dist/{chunk-LVH3GC6B.js → chunk-ELXVRKY2.js} +3 -3
  24. package/dist/chunk-ELXVRKY2.js.map +1 -0
  25. package/dist/{chunk-ZDCJZCNO.js → chunk-JRKE6XEK.js} +37 -16
  26. package/dist/chunk-JRKE6XEK.js.map +1 -0
  27. package/dist/{chunk-UZJ6I4T6.js → chunk-MK42GLPV.js} +1 -1
  28. package/dist/chunk-MK42GLPV.js.map +1 -0
  29. package/dist/{chunk-V46JKE44.js → chunk-WKIPLNVO.js} +1 -1
  30. package/dist/chunk-WKIPLNVO.js.map +1 -0
  31. package/dist/{chunk-U6FK33SG.js → chunk-Y2FOYPBV.js} +2 -2
  32. package/dist/{chunk-U6FK33SG.js.map → chunk-Y2FOYPBV.js.map} +1 -1
  33. package/dist/{chunk-KZJXHFIB.js → chunk-ZOIERTUW.js} +3 -3
  34. package/dist/chunk-ZOIERTUW.js.map +1 -0
  35. package/dist/dev.js +64 -17
  36. package/dist/dev.js.map +1 -1
  37. package/dist/html.js +5 -4
  38. package/dist/html.js.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +23 -14
  41. package/dist/index.js.map +1 -1
  42. package/dist/jsx-runtime.d.ts +12 -5
  43. package/dist/jsx-runtime.js +1 -1
  44. package/dist/list.js +43 -14
  45. package/dist/list.js.map +1 -1
  46. package/dist/overlay.js +99 -32
  47. package/dist/overlay.js.map +1 -1
  48. package/dist/remount.js +2 -2
  49. package/dist/router.js +33 -10
  50. package/dist/router.js.map +1 -1
  51. package/dist/scope.js +5 -4
  52. package/dist/scope.js.map +1 -1
  53. package/dist/testing.js +1 -1
  54. package/dist/timing.js +1 -1
  55. package/dist/timing.js.map +1 -1
  56. package/llms.txt +6 -2
  57. package/package.json +32 -7
  58. package/setup/cli.mjs +90 -0
  59. package/setup/index.d.mts +37 -0
  60. package/setup/index.mjs +1347 -0
  61. package/setup/jsonc.mjs +201 -0
  62. package/setup/state.schema.json +56 -0
  63. package/dist/chunk-GY4XV2UV.js.map +0 -1
  64. package/dist/chunk-KPXIOG2C.js.map +0 -1
  65. package/dist/chunk-KZJXHFIB.js.map +0 -1
  66. package/dist/chunk-LVH3GC6B.js.map +0 -1
  67. package/dist/chunk-QIP723L4.js.map +0 -1
  68. package/dist/chunk-SVATPF5R.js.map +0 -1
  69. package/dist/chunk-UZJ6I4T6.js.map +0 -1
  70. package/dist/chunk-V46JKE44.js.map +0 -1
  71. package/dist/chunk-ZDCJZCNO.js.map +0 -1
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 and its @kerfjs/ui component package (https://github.com/brianwestphal/kerf). Use this skill whenever the user is writing or modifying code that imports `kerfjs` or `@kerfjs/ui`, 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.21.0
4
+ kerf-skill-version: 1.23.0
5
5
  ---
6
6
 
7
7
  # Building apps with kerf
@@ -33,13 +33,13 @@ when the complete layer is intentional. Prefer a component before inventing a
33
33
  parallel local primitive, but keep domain state, routing, commands, and tab/menu
34
34
  policy in the app.
35
35
 
36
- Use MenuItem/MenuActionRow/MenuHeader/AppTab `rootAttributes` only for application
37
- `data-*` metadata. MenuActionRow `trailingActionAttributes` and MenuHeader
36
+ Use ListItem/ListActionRow/ListHeader/AppTab `rootAttributes` only for application
37
+ `data-*` metadata. ListActionRow `trailingActionAttributes` and ListHeader
38
38
  `triggerAttributes` additionally support native popover target/action and
39
39
  `aria-controls`/`aria-haspopup`. Do not use these slots to override action,
40
40
  item identity, selection, disclosure, naming, disabled, icon, or role
41
41
  semantics; one `role="menuitem"` does not make a complete menu widget.
42
- `MenuItem.trailing` is dormant. Use `MenuActionRow` for sibling primary and
42
+ `ListItem.trailing` is dormant. Use `ListActionRow` for sibling primary and
43
43
  trailing native buttons, and keep its `label`, `icon`, and
44
44
  `trailingActionIcon` SafeHtml slots free of controls.
45
45
  AppTab protects its component/action/identity/selection and transient drag/drop
@@ -77,41 +77,53 @@ full AI contract is at `node_modules/@kerfjs/ui/ai/skill.md`.
77
77
 
78
78
  ```ts
79
79
  import {
80
- signal, computed, effect, batch, // reactivity
81
- defineStore, resetAllStores, // stores
82
- mount, morph, each, // render (reactive + one-shot) + keyed list
83
- delegate, delegateCapture, // events
84
- toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
85
- SafeHtml, isSafeHtml, raw, Fragment,
86
- } from 'kerfjs';
80
+ signal,
81
+ computed,
82
+ effect,
83
+ batch, // reactivity
84
+ defineStore,
85
+ resetAllStores, // stores
86
+ mount,
87
+ morph,
88
+ each, // render (reactive + one-shot) + keyed list
89
+ delegate,
90
+ delegateCapture, // events
91
+ toElement, // direct JSX → DOM Element (or DocumentFragment for multi-root)
92
+ SafeHtml,
93
+ isSafeHtml,
94
+ raw,
95
+ Fragment,
96
+ type JSXChildren,
97
+ } from "kerfjs";
87
98
 
88
99
  // Optional, only when you need granular collection updates:
89
- import { arraySignal } from 'kerfjs/array-signal';
100
+ import { arraySignal } from "kerfjs/array-signal";
90
101
 
91
102
  // Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
92
- if (import.meta.env.DEV) await import('kerfjs/dev');
103
+ if (import.meta.env.DEV) await import("kerfjs/dev");
93
104
  ```
94
105
 
95
- | Export | Use |
96
- | --- | --- |
97
- | `signal(initial)` | atomic reactive state; `.value` get/set |
98
- | `computed(fn)` | derived value, read-only |
99
- | `effect(fn)` | side effect on signal change; returns disposer |
100
- | `batch(fn)` | coalesce multiple writes into one re-run |
101
- | `defineStore({initial, actions})` | named multi-consumer state |
102
- | `resetAllStores()` | reset every store (test teardown) |
103
- | `mount(el, render)` | bind reactive render to a DOM element; returns disposer |
104
- | `morph(liveRoot, template)` | one-shot reconcile against a populated element (SSR hydration, page-refresh diffs). Template = `Element`, `SafeHtml`, or HTML string |
105
- | `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 |
106
- | `each(items, render, { cacheKey, key })` | same, options form. **`key` gives the list a stable identity** — required whenever a *conditional* list can render before this one, else kerf rebuilds this list and its rows lose focus/scroll/IME. A keyed list takes no positional slot, so keying the conditional list usually fixes its siblings too |
107
- | `delegate(root, type, sel, h)` | one listener at the root; `closest(selector)` walk from target |
108
- | `delegateCapture(root, type, sel, h, opts?)` | capture-phase escape hatch; `closest()` walk-up by default (same as `delegate`); pass `{ match: 'direct' }` for strict `target.matches()` |
109
- | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
110
- | `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 |
111
- | `toElement(jsx)` | parse JSX into a DOM node (SVG-aware). Single-root → `Element`; multi-root (`<><svg/> label</>`, two icons side by side) → `DocumentFragment` that `appendChild`/`replaceChildren`/`append` inlines into the parent. |
112
- | `raw(html)` | inject pre-escaped HTML |
113
- | `arraySignal(initial?)` | granular keyed-list signal (subpath `kerfjs/array-signal`); `each()` reconciles in O(patches) |
114
- | `` html`…` `` | tagged template (subpath `kerfjs/html`) — JSX-identical runtime semantics with NO build step, for CDN/importmap projects. Real HTML attribute names (`class`, not `className`); holes only in text positions or as a COMPLETE attribute value (`attr=${v}` / `attr="${v}"`) |
106
+ | Export | Use |
107
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `signal(initial)` | atomic reactive state; `.value` get/set |
109
+ | `computed(fn)` | derived value, read-only |
110
+ | `effect(fn)` | side effect on signal change; returns disposer |
111
+ | `batch(fn)` | coalesce multiple writes into one re-run |
112
+ | `defineStore({initial, actions})` | named multi-consumer state |
113
+ | `resetAllStores()` | reset every store (test teardown) |
114
+ | `mount(el, render)` | bind reactive render to a DOM element; returns disposer |
115
+ | `morph(liveRoot, template)` | one-shot reconcile against a populated element (SSR hydration, page-refresh diffs). Template = `Element`, `SafeHtml`, or HTML string |
116
+ | `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 |
117
+ | `each(items, render, { cacheKey, key })` | same, options form. **`key` gives the list a stable identity** — required whenever a _conditional_ list can render before this one, else kerf rebuilds this list and its rows lose focus/scroll/IME. A keyed list takes no positional slot, so keying the conditional list usually fixes its siblings too |
118
+ | `delegate(root, type, sel, h)` | one listener at the root; `closest(selector)` walk from target |
119
+ | `delegateCapture(root, type, sel, h, opts?)` | capture-phase escape hatch; `closest()` walk-up by default (same as `delegate`); pass `{ match: 'direct' }` for strict `target.matches()` |
120
+ | `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
121
+ | `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 |
122
+ | `toElement(jsx)` | parse JSX into a DOM node (SVG-aware). Single-root → `Element`; multi-root (`<><svg/> label</>`, two icons side by side) → `DocumentFragment` that `appendChild`/`replaceChildren`/`append` inlines into the parent. |
123
+ | `raw(html)` | inject pre-escaped HTML |
124
+ | `JSXChildren` | recursive type for general-purpose component `children`: SafeHtml, primitives, read-only signals, nullish/boolean empty values, and readonly arrays at any depth. Use a narrower union for semantically restricted slots |
125
+ | `arraySignal(initial?)` | granular keyed-list signal (subpath `kerfjs/array-signal`); `each()` reconciles in O(patches) |
126
+ | `` html`…` `` | tagged template (subpath `kerfjs/html`) — JSX-identical runtime semantics with NO build step, for CDN/importmap projects. Real HTML attribute names (`class`, not `className`); holes only in text positions or as a COMPLETE attribute value (`attr=${v}` / `attr="${v}"`) |
115
127
 
116
128
  ## Hard rules — every AI assistant gets these wrong at least once
117
129
 
@@ -125,7 +137,7 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
125
137
  5. **Capture the `delegate()` / `delegateCapture()` disposer** whenever the registration's scope is shorter than the page. Both helpers return `() => void`; the listener closure pins `rootEl`, `handler`, and everything the handler closes over (stores, signals, app state). Discarding the disposer on a transient root (modal, route view, mount swap, dynamic widget) leaks the listener AND the app graph it captures; re-mount cycles stack listeners linearly. `mount()`'s own disposer does NOT remove delegates for you. Safe to discard only when the registration is truly page-lifetime (root is `document.body` or equivalent, attached once at startup, never torn down).
126
138
  6. **One `mount()` per root.** Don't nest `mount()` calls. Compose with plain functions returning JSX.
127
139
  7. **Components are plain functions.** `<MyComponent props />` works — the JSX runtime calls `MyComponent(props)` and uses the returned JSX — but there's no hook system, no lifecycle, and no per-instance state. State lives in module-scope signals or stores, never in component closures.
128
- 8. **Values bind, structure re-renders.** For a value hole, pass the signal/computed ITSELF (`<span>{count}</span>`, `class={sig}`) — kerf updates that one node directly, no render re-run. Read `.value` only when the JSX *structure* depends on the signal — and then the read must happen INSIDE the render function to be tracked: `const x = count.value; mount(el, () => <span>{x}</span>)` does NOT re-render. One caveat on bound holes: bind a STABLE signal/computed instance per hole (`class={computed(() => …)}` that switches internally), never `class={cond ? sigA : sigB}` — switching instances can go silently stale (detectable via `KERF_DEV_WARN_STALE_BINDING=1`). The idiom's endpoint: a render that reads NO `.value` runs exactly once — a fully bound mount never re-renders. To find `.value` holes worth migrating, `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` flags re-renders whose only differences were text/attribute values.
140
+ 8. **Values bind, structure re-renders.** For a value hole, pass the signal/computed ITSELF (`<span>{count}</span>`, `class={sig}`) — kerf updates that one node directly, no render re-run. Read `.value` only when the JSX _structure_ depends on the signal — and then the read must happen INSIDE the render function to be tracked: `const x = count.value; mount(el, () => <span>{x}</span>)` does NOT re-render. One caveat on bound holes: bind a STABLE signal/computed instance per hole (`class={computed(() => …)}` that switches internally), never `class={cond ? sigA : sigB}` — switching instances can go silently stale (detectable via `KERF_DEV_WARN_STALE_BINDING=1`). The idiom's endpoint: a render that reads NO `.value` runs exactly once — a fully bound mount never re-renders. To find `.value` holes worth migrating, `KERF_DEV_WARN_VALUE_ONLY_RERENDER=1` flags re-renders whose only differences were text/attribute values.
129
141
  9. **Store actions take `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
130
142
  10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root.
131
143
  11. **`arraySignal` is opt-in for long keyed lists** where most updates are pointwise. For short lists / filter+sort pipelines, plain `signal` + `each(items.value, ...)` is simpler and equally fast.
@@ -138,22 +150,26 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
138
150
  When deciding which primitive to reach for, work down the axes:
139
151
 
140
152
  **Events.**
153
+
141
154
  - Originates inside the mount tree → `delegate(rootEl, type, sel, handler)`. Originates outside (window-level keyboard, online/offline, beforeunload) → native `window.addEventListener` at module top-level.
142
155
  - Gesture that needs to follow an element after press (drag, draw, resize) → at the start event, `el.setPointerCapture(e.pointerId)`. Subsequent `pointermove` / `pointerup` redirect to the captured element and `delegate(rootEl, 'pointermove', '[data-card]', …)` still picks them up. Don't reach for `window.addEventListener` for in-mount-tree gestures.
143
156
  - Well-known non-bubbler (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) → still `delegate()`; it auto-promotes to capture. Custom non-bubblers or capture-phase interception → `delegateCapture()` (also `closest()`-matched by default). Need strict element-match? Add `{ match: 'direct' }` on either helper.
144
157
 
145
158
  **Lists.**
159
+
146
160
  - Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
147
161
  - Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
148
162
  - Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
149
163
 
150
164
  **Side effects / imperative DOM.**
165
+
151
166
  - Library-owned subtree survives across renders → `data-morph-skip` on host.
152
167
  - Host attributes morph but subtree preserved → `data-morph-skip-children`.
153
168
  - Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
154
169
  - Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
155
170
 
156
171
  **Raw HTML.**
172
+
157
173
  - User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
158
174
  - Author-controlled trusted HTML → `raw(html)` directly.
159
175
 
@@ -168,20 +184,27 @@ When deciding which primitive to reach for, work down the axes:
168
184
  // directly with no render re-run; read `.value` only when the JSX STRUCTURE
169
185
  // depends on the signal (conditionals, list shape).
170
186
  const count = signal(0);
171
- const ACTIONS = { inc: attr('data-action', 'inc') } as const satisfies Record<string, AttrSpec<'data-action'>>;
187
+ const ACTIONS = { inc: attr("data-action", "inc") } as const satisfies Record<
188
+ string,
189
+ AttrSpec<"data-action">
190
+ >;
172
191
 
173
- mount(document.getElementById('app')!, () => (
192
+ mount(document.getElementById("app")!, () => (
174
193
  <div>
175
194
  <button {...ACTIONS.inc.attrs}>+</button>
176
195
  <span>{count}</span>
177
196
  </div>
178
197
  ));
179
- delegate(rootEl, 'click', ACTIONS.inc.selector, () => { count.value += 1; });
198
+ delegate(rootEl, "click", ACTIONS.inc.selector, () => {
199
+ count.value += 1;
200
+ });
180
201
 
181
202
  // Pattern 2: keyed list with per-row memoization
182
203
  mount(listEl, () => (
183
204
  <ul>
184
- {each(rows.value, (row) => <li data-key={row.id}>{row.label}</li>)}
205
+ {each(rows.value, (row) => (
206
+ <li data-key={row.id}>{row.label}</li>
207
+ ))}
185
208
  </ul>
186
209
  ));
187
210
 
@@ -189,8 +212,8 @@ mount(listEl, () => (
189
212
  const cart = defineStore({
190
213
  initial: () => ({ items: [] as string[] }),
191
214
  actions: (set, get) => ({
192
- add: (id: string) => set({ items: [...get().items, id] }),
193
- clear: () => set({ items: [] }),
215
+ add: (id: string) => set({ items: [...get().items, id] }),
216
+ clear: () => set({ items: [] }),
194
217
  }),
195
218
  });
196
219
  // access: cart.state.value.items, cart.actions.add('x'), cart.reset()
@@ -205,9 +228,15 @@ morph(liveCard, '<article class="card">…</article>');
205
228
  const selectedId = signal<number | null>(null);
206
229
  mount(listEl, () => (
207
230
  <ul>
208
- {each(rows.value, (row) => (
209
- <li class={computed(() => (row.id === selectedId.value ? 'sel' : ''))}>{row.label}</li>
210
- ), (row) => row.id)}
231
+ {each(
232
+ rows.value,
233
+ (row) => (
234
+ <li class={computed(() => (row.id === selectedId.value ? "sel" : ""))}>
235
+ {row.label}
236
+ </li>
237
+ ),
238
+ (row) => row.id,
239
+ )}
211
240
  </ul>
212
241
  ));
213
242
  // selectedId.value = 3 → only the ~2 affected <li> class attrs update.
@@ -215,34 +244,39 @@ mount(listEl, () => (
215
244
  // Pattern 6: no build step (CDN / importmap) — the html tagged template
216
245
  // instead of JSX. Same runtime semantics; real HTML attribute names; a hole
217
246
  // must be a text position or a COMPLETE attribute value (partials throw).
218
- import { html } from 'kerfjs/html';
219
- mount(rootEl, () => html`
220
- <div class="${cls}">Count: ${count}</div>
221
- <ul>${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}</ul>
222
- `);
247
+ import { html } from "kerfjs/html";
248
+ mount(
249
+ rootEl,
250
+ () => html`
251
+ <div class="${cls}">Count: ${count}</div>
252
+ <ul>
253
+ ${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}
254
+ </ul>
255
+ `,
256
+ );
223
257
  ```
224
258
 
225
259
  ## Diagnosing common errors
226
260
 
227
- | Error / symptom | Root cause | Fix |
228
- | --- | --- | --- |
229
- | `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 |
230
- | `draggable={true}` / `spellCheck={false}` / `contentEditable={false}` / `writingsuggestions={false}` / `translate={false}` / `autocorrect={false}` won't typecheck | these are HTML **enumerated** attributes, not boolean ones — they take keyword strings (`"true"`/`"false"`; `"yes"`/`"no"` for `translate`; `"on"`/`"off"` for `autocorrect`), and omitting one selects a third state, so the boolean form rendered the opposite of what was meant | Write the keyword: `draggable="true"`, `spellCheck="false"`, `writingsuggestions="false"`, `translate="no"`, `autocorrect="off"`. Omit for the default state. Real boolean attrs (`hidden`, `checked`, `disabled`, `autofocus`, `required`, `inert`) are unaffected, as is `popover` (bare form = the spec's `auto` state); for a signal use `signal('true')` |
231
- | `<select value={x}>` / `<textarea value={x}>` won't typecheck | neither element has a `value` content attribute — the markup was inert | `<option value="b" selected>`; `<textarea>{draft}</textarea>` |
232
- | Focus / cursor lost on every keystroke | list items lack `data-key` | Add `data-key` (or `id`) to each list item |
233
- | 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) |
234
- | Render fn never re-runs | signal was read outside the render fn | Move `signal.value` read inside the render fn |
235
- | SVG renders as broken / namespaceless markup | `innerHTML` used directly | Use `mount` or `toElement` (SVG-aware) |
236
- | Library widget destroyed on every render | host reachable by the morph | Wrap host in `data-morph-skip`; mount the library imperatively after first render |
237
- | `<my-tag>` fails to typecheck | declaration merging targeted global JSX | Use `declare module 'kerfjs/jsx-runtime' { namespace JSX { … } }` instead |
238
- | `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 |
239
- | 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 |
240
- | 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 |
241
- | 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 |
242
- | `` html`` ``: partial attribute values are not supported | In `kerfjs/html` templates a hole must be the COMPLETE attribute value | Build the full string first (`` class="${`a ${b}`}" ``), or bind `class="${computed(() => `a ${b.value}`)}"` for a reactive one |
243
- | An `each()` list's rows lose focus / scroll / typing state when an unrelated conditional list above them appears or disappears (kerf warns about this in dev) | Lists without a key are identified by their position among the render's `each()` calls, so adding/removing one above shifts this list's identity and kerf rebuilds it | Give the lists stable keys: `each(items, render, { key: 'results' })`. Keying just the conditional list is usually enough |
244
- | Keyed `each()` list suddenly renders zero rows — only its `<!--kf-list:N-->` marker — with no errors, and it never recovers (kerfjs ≤ 2.0.1) | A conditionally-rendered sibling BEFORE the list (possibly higher in the tree, e.g. an error banner) was removed that render; older kerfjs rebuilt the shifted list container from the template, permanently detaching the list's internal binding | Upgrade kerfjs (fixed after 2.0.1 — the morph now moves the shifted container up in place, keeping node identity). On older versions, keep the structure before the list stable: wrap the conditional in an always-present container (`<div class="banners">{cond ? <div/> : ''}</div>`) |
245
- | A numbered / zebra-striped / "N of M" `each()` list shows the wrong number on rows that MOVED (reorder, or non-tail insert/remove), while unmoved rows look right | The render fn's `index` argument is NOT part of the memo key (only item identity + `cacheKey` + content version are), so a row that keeps identity but changes position keeps the HTML it rendered at its old index | Fold the index into the memo key so displaced rows re-render: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `KERF_DEV_WARN_STALE_INDEX=1` |
261
+ | Error / symptom | Root cause | Fix |
262
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
263
+ | `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 |
264
+ | `draggable={true}` / `spellCheck={false}` / `contentEditable={false}` / `writingsuggestions={false}` / `translate={false}` / `autocorrect={false}` won't typecheck | these are HTML **enumerated** attributes, not boolean ones — they take keyword strings (`"true"`/`"false"`; `"yes"`/`"no"` for `translate`; `"on"`/`"off"` for `autocorrect`), and omitting one selects a third state, so the boolean form rendered the opposite of what was meant | Write the keyword: `draggable="true"`, `spellCheck="false"`, `writingsuggestions="false"`, `translate="no"`, `autocorrect="off"`. Omit for the default state. Real boolean attrs (`hidden`, `checked`, `disabled`, `autofocus`, `required`, `inert`) are unaffected, as is `popover` (bare form = the spec's `auto` state); for a signal use `signal('true')` |
265
+ | `<select value={x}>` / `<textarea value={x}>` won't typecheck | neither element has a `value` content attribute — the markup was inert | `<option value="b" selected>`; `<textarea>{draft}</textarea>` |
266
+ | Focus / cursor lost on every keystroke | list items lack `data-key` | Add `data-key` (or `id`) to each list item |
267
+ | 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) |
268
+ | Render fn never re-runs | signal was read outside the render fn | Move `signal.value` read inside the render fn |
269
+ | SVG renders as broken / namespaceless markup | `innerHTML` used directly | Use `mount` or `toElement` (SVG-aware) |
270
+ | Library widget destroyed on every render | host reachable by the morph | Wrap host in `data-morph-skip`; mount the library imperatively after first render |
271
+ | `<my-tag>` fails to typecheck | declaration merging targeted global JSX | Use `declare module 'kerfjs/jsx-runtime' { namespace JSX { … } }` instead |
272
+ | `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 |
273
+ | 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 |
274
+ | 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 |
275
+ | 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 |
276
+ | ` html` ``: partial attribute values are not supported | In `kerfjs/html` templates a hole must be the COMPLETE attribute value | Build the full string first (``class="${`a ${b}`}"``), or bind `class="${computed(() => `a ${b.value}`)}"` for a reactive one |
277
+ | An `each()` list's rows lose focus / scroll / typing state when an unrelated conditional list above them appears or disappears (kerf warns about this in dev) | Lists without a key are identified by their position among the render's `each()` calls, so adding/removing one above shifts this list's identity and kerf rebuilds it | Give the lists stable keys: `each(items, render, { key: 'results' })`. Keying just the conditional list is usually enough |
278
+ | Keyed `each()` list suddenly renders zero rows — only its `<!--kf-list:N-->` marker — with no errors, and it never recovers (kerfjs ≤ 2.0.1) | A conditionally-rendered sibling BEFORE the list (possibly higher in the tree, e.g. an error banner) was removed that render; older kerfjs rebuilt the shifted list container from the template, permanently detaching the list's internal binding | Upgrade kerfjs (fixed after 2.0.1 — the morph now moves the shifted container up in place, keeping node identity). On older versions, keep the structure before the list stable: wrap the conditional in an always-present container (`<div class="banners">{cond ? <div/> : ''}</div>`) |
279
+ | A numbered / zebra-striped / "N of M" `each()` list shows the wrong number on rows that MOVED (reorder, or non-tail insert/remove), while unmoved rows look right | The render fn's `index` argument is NOT part of the memo key (only item identity + `cacheKey` + content version are), so a row that keeps identity but changes position keeps the HTML it rendered at its old index | Fold the index into the memo key so displaced rows re-render: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `KERF_DEV_WARN_STALE_INDEX=1` |
246
280
 
247
281
  ## Workflow guidance
248
282
 
package/dist/actions.js CHANGED
@@ -1,5 +1,5 @@
1
- import { attr } from './chunk-UZJ6I4T6.js';
2
- import { delegate } from './chunk-KPXIOG2C.js';
1
+ import { attr } from './chunk-MK42GLPV.js';
2
+ import { delegate } from './chunk-E5R5GNKE.js';
3
3
 
4
4
 
5
5
  // src/actions.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/actions.ts"],"names":[],"mappings":";;;;;AAmCA,IAAM,mBAAA,GAAsB,aAAA;AAerB,SAAS,OAAyB,KAAA,EAAsC;AAC7E,EAAA,OAAO,IAAA,CAAK,qBAAqB,KAAK,CAAA;AACxC;AAwBO,SAAS,eAAA,CACd,IAAA,EACA,SAAA,EACA,KAAA,EACA,OAAA,EACY;AACZ,EAAA,MAAM,QAAA,GAAW,SAAS,IAAA,IAAQ,mBAAA;AAClC,EAAA,OAAO,QAAA;AAAA,IACL,IAAA;AAAA,IACA,SAAA;AAAA,IACA,IAAI,QAAQ,CAAA,CAAA,CAAA;AAAA,IACZ,CAAC,OAAO,EAAA,KAAO;AAEb,MAAA,MAAM,OAAA,GAAU,KAAA,CAAM,EAAA,CAAG,YAAA,CAAa,QAAQ,CAAW,CAAA;AACzD,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AAAA,IAC9C,CAAA;AAAA,IACA;AAAA,GACF;AACF","file":"actions.js","sourcesContent":["/**\n * `kerfjs/actions` — the delegated action-table helper.\n *\n * The most-reinvented idiom across real kerf apps: one table of `data-action`\n * attribute specs used as the single source of truth for BOTH the JSX attribute\n * and the delegate selector, plus a hand-rolled `switch (dataset.action)`\n * dispatcher. This subpath blesses it as two thin helpers over the existing\n * `attr()` + `delegate()` — it does NOT replace them.\n *\n * import { action, delegateActions } from 'kerfjs/actions';\n *\n * const A = {\n * select: action('select-file'),\n * remove: action('remove-file'),\n * };\n *\n * // JSX — spread the attr (rename-safe; no hardcoded attribute name):\n * // <button {...A.select.attrs} data-id={id}>…</button>\n *\n * // Wire the whole table with ONE delegated listener; returns a disposer:\n * const dispose = delegateActions(root, 'click', {\n * [A.select.value]: (_e, el) => selectFile(el.getAttribute('data-id')),\n * [A.remove.value]: (_e, el) => removeFile(el.getAttribute('data-id')),\n * });\n *\n * Contract: `delegateActions` returns a `() => void` disposer and holds no\n * per-instance state — the same shape as `delegate()`, which it builds on (so\n * it inherits the single-listener dispatch and the capture auto-promotion for\n * well-known non-bubbling event types). One event type per call, mirroring\n * `delegate()`; collect the disposers for a root that needs several.\n */\nimport { attr, type AttrSpec } from './attr.js';\nimport { delegate, type DelegateOptions } from './delegate.js';\n\n/** The attribute an action table keys on by default. */\nconst DEFAULT_ACTION_ATTR = 'data-action';\n\n/**\n * A handler in a {@link delegateActions} table. Receives the DOM event and the\n * matched element (walk-up `closest()` match by default) — the same shape as a\n * `delegate()` handler.\n */\nexport type ActionHandler<E extends Element = Element> = (event: Event, el: E) => void;\n\n/**\n * `action(value)` — an {@link AttrSpec} on `data-action`. A thin specialization\n * of `attr('data-action', value)`: spread its `.attrs` in JSX and use its\n * `.value` as the handler-table key, so the action name lives in exactly one\n * place and can't drift between the markup and the dispatcher.\n */\nexport function action<V extends string>(value: V): AttrSpec<'data-action', V> {\n return attr(DEFAULT_ACTION_ATTR, value);\n}\n\n/** Options for {@link delegateActions}. Extends {@link DelegateOptions}. */\nexport interface DelegateActionsOptions extends DelegateOptions {\n /**\n * The attribute the table keys on. Default `'data-action'`. Override it only\n * if you also author the specs with `attr(yourName, …)` instead of `action()`.\n */\n attr?: string;\n}\n\n/**\n * Wire a whole table of action handlers with ONE delegated listener.\n *\n * On `eventType`, the nearest element carrying the action attribute (walk-up\n * `closest()` by default; pass `{ match: 'direct' }` for an exact-element match)\n * is looked up in `table` by its attribute value, and the matching handler\n * runs. An element whose action is absent from the table is ignored — the same\n * behavior as a `switch (dataset.action)` with no matching `case`.\n *\n * Returns a `() => void` disposer. One event type per call (the smallest\n * surface, mirroring `delegate()`); collect the disposers when a root needs\n * several event types.\n */\nexport function delegateActions<E extends Element = Element>(\n root: HTMLElement,\n eventType: string,\n table: Readonly<Record<string, ActionHandler<E>>>,\n options?: DelegateActionsOptions,\n): () => void {\n const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;\n return delegate<E>(\n root,\n eventType,\n `[${attrName}]`,\n (event, el) => {\n // `el` matched `[${attrName}]`, so the attribute is always present.\n const handler = table[el.getAttribute(attrName) as string];\n if (handler !== undefined) handler(event, el);\n },\n options,\n );\n}\n"]}
1
+ {"version":3,"sources":["../src/actions.ts"],"names":[],"mappings":";;;;;AAmCA,IAAM,mBAAA,GAAsB,aAAA;AAkBrB,SAAS,OAAyB,KAAA,EAAsC;AAC7E,EAAA,OAAO,IAAA,CAAK,qBAAqB,KAAK,CAAA;AACxC;AAwBO,SAAS,eAAA,CACd,IAAA,EACA,SAAA,EACA,KAAA,EACA,OAAA,EACY;AACZ,EAAA,MAAM,QAAA,GAAW,SAAS,IAAA,IAAQ,mBAAA;AAClC,EAAA,OAAO,QAAA;AAAA,IACL,IAAA;AAAA,IACA,SAAA;AAAA,IACA,IAAI,QAAQ,CAAA,CAAA,CAAA;AAAA,IACZ,CAAC,OAAO,EAAA,KAAO;AAEb,MAAA,MAAM,OAAA,GAAU,KAAA,CAAM,EAAA,CAAG,YAAA,CAAa,QAAQ,CAAW,CAAA;AACzD,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AAAA,IAC9C,CAAA;AAAA,IACA;AAAA,GACF;AACF","file":"actions.js","sourcesContent":["/**\n * `kerfjs/actions` — the delegated action-table helper.\n *\n * The most-reinvented idiom across real kerf apps: one table of `data-action`\n * attribute specs used as the single source of truth for BOTH the JSX attribute\n * and the delegate selector, plus a hand-rolled `switch (dataset.action)`\n * dispatcher. This subpath blesses it as two thin helpers over the existing\n * `attr()` + `delegate()` — it does NOT replace them.\n *\n * import { action, delegateActions } from 'kerfjs/actions';\n *\n * const A = {\n * select: action('select-file'),\n * remove: action('remove-file'),\n * };\n *\n * // JSX — spread the attr (rename-safe; no hardcoded attribute name):\n * // <button {...A.select.attrs} data-id={id}>…</button>\n *\n * // Wire the whole table with ONE delegated listener; returns a disposer:\n * const dispose = delegateActions(root, 'click', {\n * [A.select.value]: (_e, el) => selectFile(el.getAttribute('data-id')),\n * [A.remove.value]: (_e, el) => removeFile(el.getAttribute('data-id')),\n * });\n *\n * Contract: `delegateActions` returns a `() => void` disposer and holds no\n * per-instance state — the same shape as `delegate()`, which it builds on (so\n * it inherits the single-listener dispatch and the capture auto-promotion for\n * well-known non-bubbling event types). One event type per call, mirroring\n * `delegate()`; collect the disposers for a root that needs several.\n */\nimport { attr, type AttrSpec } from './attr.js';\nimport { delegate, type DelegateOptions } from './delegate.js';\n\n/** The attribute an action table keys on by default. */\nconst DEFAULT_ACTION_ATTR = 'data-action';\n\n/**\n * A handler in a {@link delegateActions} table. Receives the DOM event and the\n * matched element (walk-up `closest()` match by default) — the same shape as a\n * `delegate()` handler.\n */\nexport type ActionHandler<E extends Element = Element> = (\n event: Event,\n el: E,\n) => void;\n\n/**\n * `action(value)` — an {@link AttrSpec} on `data-action`. A thin specialization\n * of `attr('data-action', value)`: spread its `.attrs` in JSX and use its\n * `.value` as the handler-table key, so the action name lives in exactly one\n * place and can't drift between the markup and the dispatcher.\n */\nexport function action<V extends string>(value: V): AttrSpec<'data-action', V> {\n return attr(DEFAULT_ACTION_ATTR, value);\n}\n\n/** Options for {@link delegateActions}. Extends {@link DelegateOptions}. */\nexport interface DelegateActionsOptions extends DelegateOptions {\n /**\n * The attribute the table keys on. Default `'data-action'`. Override it only\n * if you also author the specs with `attr(yourName, …)` instead of `action()`.\n */\n attr?: string;\n}\n\n/**\n * Wire a whole table of action handlers with ONE delegated listener.\n *\n * On `eventType`, the nearest element carrying the action attribute (walk-up\n * `closest()` by default; pass `{ match: 'direct' }` for an exact-element match)\n * is looked up in `table` by its attribute value, and the matching handler\n * runs. An element whose action is absent from the table is ignored — the same\n * behavior as a `switch (dataset.action)` with no matching `case`.\n *\n * Returns a `() => void` disposer. One event type per call (the smallest\n * surface, mirroring `delegate()`); collect the disposers when a root needs\n * several event types.\n */\nexport function delegateActions<E extends Element = Element>(\n root: HTMLElement,\n eventType: string,\n table: Readonly<Record<string, ActionHandler<E>>>,\n options?: DelegateActionsOptions,\n): () => void {\n const attrName = options?.attr ?? DEFAULT_ACTION_ATTR;\n return delegate<E>(\n root,\n eventType,\n `[${attrName}]`,\n (event, el) => {\n // `el` matched `[${attrName}]`, so the attribute is always present.\n const handler = table[el.getAttribute(attrName) as string];\n if (handler !== undefined) handler(event, el);\n },\n options,\n );\n}\n"]}
@@ -1,4 +1,4 @@
1
- export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-LVH3GC6B.js';
1
+ export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-ELXVRKY2.js';
2
2
 
3
3
 
4
4
 
package/dist/async.js CHANGED
@@ -1,4 +1,4 @@
1
- import { signal } from './chunk-U6FK33SG.js';
1
+ import { signal } from './chunk-Y2FOYPBV.js';
2
2
 
3
3
 
4
4
  // src/async.ts
@@ -49,7 +49,13 @@ function resource(options = {}) {
49
49
  };
50
50
  const fail = (error) => {
51
51
  if (gen === generation) {
52
- state.value = { ...state.value, status: "failed", error, progress: void 0, input };
52
+ state.value = {
53
+ ...state.value,
54
+ status: "failed",
55
+ error,
56
+ progress: void 0,
57
+ input
58
+ };
53
59
  }
54
60
  return void 0;
55
61
  };
@@ -59,23 +65,20 @@ function resource(options = {}) {
59
65
  } catch (error) {
60
66
  return Promise.resolve(fail(error));
61
67
  }
62
- return pending.then(
63
- (data) => {
64
- if (gen === generation) {
65
- if (key !== void 0) cache.set(key, data);
66
- state.value = {
67
- status: "completed",
68
- data,
69
- error: void 0,
70
- progress: void 0,
71
- input,
72
- revision: commit(data)
73
- };
74
- }
75
- return data;
76
- },
77
- fail
78
- );
68
+ return pending.then((data) => {
69
+ if (gen === generation) {
70
+ if (key !== void 0) cache.set(key, data);
71
+ state.value = {
72
+ status: "completed",
73
+ data,
74
+ error: void 0,
75
+ progress: void 0,
76
+ input,
77
+ revision: commit(data)
78
+ };
79
+ }
80
+ return data;
81
+ }, fail);
79
82
  }
80
83
  function reset() {
81
84
  generation++;
package/dist/async.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/async.ts"],"names":[],"mappings":"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","file":"async.js","sourcesContent":["/**\n * `kerfjs/async` — model async state, with the stale-response guard built in.\n *\n * Every real kerf app reproduces the same shape — `{ status, data, error }` —\n * for loading/error UI, each paired with a hand-rolled generation counter so a\n * slow response can't overwrite a newer one. This subpath blesses exactly that,\n * and no more: you still write the fetch (Node `fetch` for SSR, browser `fetch`\n * client-side), and `.run()` owns the status transitions plus the stale guard.\n *\n * import { resource } from 'kerfjs/async';\n *\n * const users = resource<User[]>();\n * users.run(() => fetch('/api/users').then((r) => r.json()));\n * // render off users.value.status: 'idle' | 'running' | 'completed' | 'failed'\n *\n * Only the LATEST run may resolve the state, so out-of-order responses are\n * dropped automatically. Optional progress: declare the `report` parameter on\n * your fetcher and call it (e.g. from an upload's progress events).\n *\n * Pass an input — `run(input, fetcher)` — to carry which request a run is for\n * through to `value.input` (set for `running`/`completed`/`failed`), so a\n * failure handler can recover the id/params of the run that failed:\n *\n * const diff = resource<Diff, { fileId: string }>();\n * diff.run({ fileId }, (report) => fetchDiff(fileId, report));\n * // on failure: diff.value.status === 'failed' && diff.value.input.fileId\n *\n * For a real SWR-with-cache section, pass `cacheKey` to keep the last value PER\n * input key (switching back to a loaded key paints its cached slice instantly\n * while it revalidates), and read `value.revision` — a counter that bumps only\n * when `data` actually CHANGES (by `equals`, default `Object.is`) — to skip a\n * redundant paint when a poll tick returns identical data:\n *\n * const win = resource<Slice, string>({ cacheKey: (w) => w, equals: sameSlice });\n * win.run(w, () => fetchSlice(w)); // instant cached paint for a revisited w\n */\nimport { signal } from './reactive.js';\n\n/** The lifecycle status of a {@link Resource}. */\nexport type ResourceStatus = 'idle' | 'running' | 'completed' | 'failed';\n\n/** Optional progress for a long-running fetch (uploads, chunked work). */\nexport interface ResourceProgress {\n completed: number;\n total: number;\n}\n\n/** The reactive state a {@link Resource} exposes. */\nexport interface ResourceState<T, I = void> {\n status: ResourceStatus;\n /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */\n data: T | undefined;\n /** The rejection from the most recent failed run. */\n error: unknown;\n /** Latest reported progress while running, or `undefined`. */\n progress: ResourceProgress | undefined;\n /**\n * The input of the LATEST run — the value passed to {@link Resource.run} as\n * `run(input, fetcher)`. Set for `running`, `completed`, AND `failed` (same\n * stale-guard rule as the rest of the state), so an effect can branch on\n * `status === 'failed'` and still know which request failed. `undefined` in\n * `idle`, and for the no-input `run(fetcher)` form.\n */\n input: I | undefined;\n /**\n * A monotonic counter that increments only when `data` actually CHANGES (by\n * the resource's `equals`, default `Object.is`). Compare it against the value\n * you last painted to skip a redundant re-render — e.g. a 30s poll returning\n * identical data leaves `revision` untouched, so you can bail before wiping\n * scroll / sort / hover state. Starts at `0`.\n */\n revision: number;\n}\n\n/** Construction options for {@link resource}. */\nexport interface ResourceOptions<T, I = void> {\n /**\n * Derive a cache key from a run's `input`. When set, the resource keeps the\n * last successful value PER key: starting a run for a key that was loaded\n * before paints its cached slice immediately (still `running`) while the fetch\n * revalidates in the background; a never-loaded key starts with no `data`.\n * Without `cacheKey`, a run keeps the previous run's `data` (single-slot\n * stale-while-revalidate), as before.\n */\n cacheKey?: (input: I) => string;\n /**\n * Equality used to decide whether `data` changed (drives `value.revision`).\n * Default `Object.is`. Pass a structural comparison to dedup a poll that\n * returns a fresh-but-equal object.\n */\n equals?: (a: T, b: T) => boolean;\n}\n\n/**\n * The fetcher passed to {@link Resource.run}. You own the transport. It receives\n * a `report(completed, total)` callback for optional progress — ignore it if you\n * don't need progress (a plain `() => Promise<T>` is assignable here).\n */\nexport type ResourceFetcher<T> = (report: (completed: number, total: number) => void) => Promise<T>;\n\n/**\n * An async-state container. Its `value` is a tracking read; drive UI off\n * `value.status`. `I` is the run-input type — parametrize it (`resource<T, I>()`)\n * to carry a typed `run(input, fetcher)` input through to `value.input`.\n */\nexport interface Resource<T, I = void> {\n /** Tracking read of the current {@link ResourceState}. */\n readonly value: ResourceState<T, I>;\n /**\n * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding\n * against stale responses (only the latest run resolves the state). Never\n * rejects — a failure lands in `value.error`; resolves with the data (or\n * `undefined` on failure) for callers who want to await it.\n */\n run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /**\n * Run `fetcher` for a given `input`, exposing it as `value.input` for the\n * `running`/`completed`/`failed` states of THIS run — so a failure handler can\n * recover which request failed. Same stale guard: only the latest run resolves.\n */\n run(input: I, fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /** Reset to `idle` (clearing data/error/progress/input, and the per-key cache) and invalidate any in-flight run. */\n reset(): void;\n /**\n * Read-only: the cached value for a `cacheKey` key, or `undefined` if that key\n * isn't cached (or no `cacheKey` was given). Lets a consumer ask \"is this slice\n * cached?\" without running it (which would mutate state).\n */\n cached(key: string): T | undefined;\n /** Read-only: the keys currently in the per-input cache (`.length` is the cache size). */\n cachedKeys(): string[];\n /** Evict the per-input cache — one `key`, or the whole cache when called with no argument. Does NOT change `value`. */\n clearCache(key?: string): void;\n}\n\n/** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */\nexport function resource<T, I = void>(options: ResourceOptions<T, I> = {}): Resource<T, I> {\n const { cacheKey, equals } = options;\n const eq: (a: T, b: T) => boolean = equals ?? Object.is;\n const cache = new Map<string, T>(); // per-key SWR cache (GC-tied to the resource)\n\n // Revision tracking: `revision` bumps only when `data` changes (by `eq`).\n let revision = 0;\n let lastData: T | undefined;\n const changed = (next: T | undefined): boolean =>\n // undefined transitions are handled by reference; two defined values by `eq`.\n lastData === undefined || next === undefined ? lastData !== next : !eq(lastData, next);\n const commit = (next: T | undefined): number => {\n if (changed(next)) {\n revision++;\n lastData = next;\n }\n return revision;\n };\n\n const state = signal<ResourceState<T, I>>({\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n input: undefined,\n revision: 0,\n });\n // Per-resource run counter (closure-local, not module state) — the stale guard.\n let generation = 0;\n\n function run(\n inputOrFetcher: I | ResourceFetcher<T>,\n maybeFetcher?: ResourceFetcher<T>,\n ): Promise<T | undefined> {\n // Two-arg form is (input, fetcher); one-arg form is (fetcher) with no input.\n // A fetcher is always a function, so `maybeFetcher === undefined` uniquely\n // identifies the one-arg call — even when the input value is itself undefined.\n const fetcher = (maybeFetcher ?? inputOrFetcher) as ResourceFetcher<T>;\n const input = (maybeFetcher === undefined ? undefined : inputOrFetcher) as I | undefined;\n const key = cacheKey !== undefined && maybeFetcher !== undefined ? cacheKey(input as I) : undefined;\n\n // What `data` shows while running: the cached slice for this key (per-key\n // SWR), or the previous run's data (single-slot SWR) when no cacheKey.\n const runningData = cacheKey !== undefined\n ? (key !== undefined ? cache.get(key) : undefined)\n : state.value.data;\n\n const gen = ++generation;\n state.value = {\n status: 'running',\n data: runningData,\n error: undefined,\n progress: undefined,\n input,\n revision: commit(runningData),\n };\n\n const report = (completed: number, total: number): void => {\n if (gen === generation) {\n state.value = { ...state.value, progress: { completed, total } };\n }\n };\n\n const fail = (error: unknown): undefined => {\n if (gen === generation) {\n // Keep `data` (and thus `revision`) on failure — stale-while-error.\n state.value = { ...state.value, status: 'failed', error, progress: undefined, input };\n }\n return undefined;\n };\n\n let pending: Promise<T>;\n try {\n pending = fetcher(report);\n } catch (error: unknown) {\n return Promise.resolve(fail(error));\n }\n\n return pending.then(\n (data) => {\n if (gen === generation) {\n if (key !== undefined) cache.set(key, data);\n state.value = {\n status: 'completed',\n data,\n error: undefined,\n progress: undefined,\n input,\n revision: commit(data),\n };\n }\n return data;\n },\n fail,\n );\n }\n\n function reset(): void {\n generation++; // invalidate any in-flight run\n cache.clear();\n state.value = {\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n input: undefined,\n revision: commit(undefined),\n };\n }\n\n return {\n get value() {\n return state.value;\n },\n run,\n reset,\n cached: (k) => cache.get(k),\n cachedKeys: () => Array.from(cache.keys()),\n clearCache: (k) => {\n if (k === undefined) cache.clear();\n else cache.delete(k);\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/async.ts"],"names":[],"mappings":"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","file":"async.js","sourcesContent":["/**\n * `kerfjs/async` — model async state, with the stale-response guard built in.\n *\n * Every real kerf app reproduces the same shape — `{ status, data, error }` —\n * for loading/error UI, each paired with a hand-rolled generation counter so a\n * slow response can't overwrite a newer one. This subpath blesses exactly that,\n * and no more: you still write the fetch (Node `fetch` for SSR, browser `fetch`\n * client-side), and `.run()` owns the status transitions plus the stale guard.\n *\n * import { resource } from 'kerfjs/async';\n *\n * const users = resource<User[]>();\n * users.run(() => fetch('/api/users').then((r) => r.json()));\n * // render off users.value.status: 'idle' | 'running' | 'completed' | 'failed'\n *\n * Only the LATEST run may resolve the state, so out-of-order responses are\n * dropped automatically. Optional progress: declare the `report` parameter on\n * your fetcher and call it (e.g. from an upload's progress events).\n *\n * Pass an input — `run(input, fetcher)` — to carry which request a run is for\n * through to `value.input` (set for `running`/`completed`/`failed`), so a\n * failure handler can recover the id/params of the run that failed:\n *\n * const diff = resource<Diff, { fileId: string }>();\n * diff.run({ fileId }, (report) => fetchDiff(fileId, report));\n * // on failure: diff.value.status === 'failed' && diff.value.input.fileId\n *\n * For a real SWR-with-cache section, pass `cacheKey` to keep the last value PER\n * input key (switching back to a loaded key paints its cached slice instantly\n * while it revalidates), and read `value.revision` — a counter that bumps only\n * when `data` actually CHANGES (by `equals`, default `Object.is`) — to skip a\n * redundant paint when a poll tick returns identical data:\n *\n * const win = resource<Slice, string>({ cacheKey: (w) => w, equals: sameSlice });\n * win.run(w, () => fetchSlice(w)); // instant cached paint for a revisited w\n */\nimport { signal } from './reactive.js';\n\n/** The lifecycle status of a {@link Resource}. */\nexport type ResourceStatus = 'idle' | 'running' | 'completed' | 'failed';\n\n/** Optional progress for a long-running fetch (uploads, chunked work). */\nexport interface ResourceProgress {\n completed: number;\n total: number;\n}\n\n/** The reactive state a {@link Resource} exposes. */\nexport interface ResourceState<T, I = void> {\n status: ResourceStatus;\n /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */\n data: T | undefined;\n /** The rejection from the most recent failed run. */\n error: unknown;\n /** Latest reported progress while running, or `undefined`. */\n progress: ResourceProgress | undefined;\n /**\n * The input of the LATEST run — the value passed to {@link Resource.run} as\n * `run(input, fetcher)`. Set for `running`, `completed`, AND `failed` (same\n * stale-guard rule as the rest of the state), so an effect can branch on\n * `status === 'failed'` and still know which request failed. `undefined` in\n * `idle`, and for the no-input `run(fetcher)` form.\n */\n input: I | undefined;\n /**\n * A monotonic counter that increments only when `data` actually CHANGES (by\n * the resource's `equals`, default `Object.is`). Compare it against the value\n * you last painted to skip a redundant re-render — e.g. a 30s poll returning\n * identical data leaves `revision` untouched, so you can bail before wiping\n * scroll / sort / hover state. Starts at `0`.\n */\n revision: number;\n}\n\n/** Construction options for {@link resource}. */\nexport interface ResourceOptions<T, I = void> {\n /**\n * Derive a cache key from a run's `input`. When set, the resource keeps the\n * last successful value PER key: starting a run for a key that was loaded\n * before paints its cached slice immediately (still `running`) while the fetch\n * revalidates in the background; a never-loaded key starts with no `data`.\n * Without `cacheKey`, a run keeps the previous run's `data` (single-slot\n * stale-while-revalidate), as before.\n */\n cacheKey?: (input: I) => string;\n /**\n * Equality used to decide whether `data` changed (drives `value.revision`).\n * Default `Object.is`. Pass a structural comparison to dedup a poll that\n * returns a fresh-but-equal object.\n */\n equals?: (a: T, b: T) => boolean;\n}\n\n/**\n * The fetcher passed to {@link Resource.run}. You own the transport. It receives\n * a `report(completed, total)` callback for optional progress — ignore it if you\n * don't need progress (a plain `() => Promise<T>` is assignable here).\n */\nexport type ResourceFetcher<T> = (\n report: (completed: number, total: number) => void,\n) => Promise<T>;\n\n/**\n * An async-state container. Its `value` is a tracking read; drive UI off\n * `value.status`. `I` is the run-input type — parametrize it (`resource<T, I>()`)\n * to carry a typed `run(input, fetcher)` input through to `value.input`.\n */\nexport interface Resource<T, I = void> {\n /** Tracking read of the current {@link ResourceState}. */\n readonly value: ResourceState<T, I>;\n /**\n * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding\n * against stale responses (only the latest run resolves the state). Never\n * rejects — a failure lands in `value.error`; resolves with the data (or\n * `undefined` on failure) for callers who want to await it.\n */\n run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /**\n * Run `fetcher` for a given `input`, exposing it as `value.input` for the\n * `running`/`completed`/`failed` states of THIS run — so a failure handler can\n * recover which request failed. Same stale guard: only the latest run resolves.\n */\n run(input: I, fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /** Reset to `idle` (clearing data/error/progress/input, and the per-key cache) and invalidate any in-flight run. */\n reset(): void;\n /**\n * Read-only: the cached value for a `cacheKey` key, or `undefined` if that key\n * isn't cached (or no `cacheKey` was given). Lets a consumer ask \"is this slice\n * cached?\" without running it (which would mutate state).\n */\n cached(key: string): T | undefined;\n /** Read-only: the keys currently in the per-input cache (`.length` is the cache size). */\n cachedKeys(): string[];\n /** Evict the per-input cache — one `key`, or the whole cache when called with no argument. Does NOT change `value`. */\n clearCache(key?: string): void;\n}\n\n/** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */\nexport function resource<T, I = void>(\n options: ResourceOptions<T, I> = {},\n): Resource<T, I> {\n const { cacheKey, equals } = options;\n const eq: (a: T, b: T) => boolean = equals ?? Object.is;\n const cache = new Map<string, T>(); // per-key SWR cache (GC-tied to the resource)\n\n // Revision tracking: `revision` bumps only when `data` changes (by `eq`).\n let revision = 0;\n let lastData: T | undefined;\n const changed = (next: T | undefined): boolean =>\n // undefined transitions are handled by reference; two defined values by `eq`.\n lastData === undefined || next === undefined\n ? lastData !== next\n : !eq(lastData, next);\n const commit = (next: T | undefined): number => {\n if (changed(next)) {\n revision++;\n lastData = next;\n }\n return revision;\n };\n\n const state = signal<ResourceState<T, I>>({\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n input: undefined,\n revision: 0,\n });\n // Per-resource run counter (closure-local, not module state) — the stale guard.\n let generation = 0;\n\n function run(\n inputOrFetcher: I | ResourceFetcher<T>,\n maybeFetcher?: ResourceFetcher<T>,\n ): Promise<T | undefined> {\n // Two-arg form is (input, fetcher); one-arg form is (fetcher) with no input.\n // A fetcher is always a function, so `maybeFetcher === undefined` uniquely\n // identifies the one-arg call — even when the input value is itself undefined.\n const fetcher = (maybeFetcher ?? inputOrFetcher) as ResourceFetcher<T>;\n const input = (maybeFetcher === undefined ? undefined : inputOrFetcher) as\n I | undefined;\n const key =\n cacheKey !== undefined && maybeFetcher !== undefined\n ? cacheKey(input as I)\n : undefined;\n\n // What `data` shows while running: the cached slice for this key (per-key\n // SWR), or the previous run's data (single-slot SWR) when no cacheKey.\n const runningData =\n cacheKey !== undefined\n ? key !== undefined\n ? cache.get(key)\n : undefined\n : state.value.data;\n\n const gen = ++generation;\n state.value = {\n status: 'running',\n data: runningData,\n error: undefined,\n progress: undefined,\n input,\n revision: commit(runningData),\n };\n\n const report = (completed: number, total: number): void => {\n if (gen === generation) {\n state.value = { ...state.value, progress: { completed, total } };\n }\n };\n\n const fail = (error: unknown): undefined => {\n if (gen === generation) {\n // Keep `data` (and thus `revision`) on failure — stale-while-error.\n state.value = {\n ...state.value,\n status: 'failed',\n error,\n progress: undefined,\n input,\n };\n }\n return undefined;\n };\n\n let pending: Promise<T>;\n try {\n pending = fetcher(report);\n } catch (error: unknown) {\n return Promise.resolve(fail(error));\n }\n\n return pending.then((data) => {\n if (gen === generation) {\n if (key !== undefined) cache.set(key, data);\n state.value = {\n status: 'completed',\n data,\n error: undefined,\n progress: undefined,\n input,\n revision: commit(data),\n };\n }\n return data;\n }, fail);\n }\n\n function reset(): void {\n generation++; // invalidate any in-flight run\n cache.clear();\n state.value = {\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n input: undefined,\n revision: commit(undefined),\n };\n }\n\n return {\n get value() {\n return state.value;\n },\n run,\n reset,\n cached: (k) => cache.get(k),\n cachedKeys: () => Array.from(cache.keys()),\n clearCache: (k) => {\n if (k === undefined) cache.clear();\n else cache.delete(k);\n },\n };\n}\n"]}
package/dist/attach.js CHANGED
@@ -25,7 +25,9 @@ function attach(node, setup) {
25
25
  if (done) return;
26
26
  if (!hasConnected) {
27
27
  if (!node.isConnected) {
28
- const connectedAndRemovedInBatch = records.some((record) => [...record.addedNodes].some(addedWithin));
28
+ const connectedAndRemovedInBatch = records.some(
29
+ (record) => [...record.addedNodes].some(addedWithin)
30
+ );
29
31
  if (!connectedAndRemovedInBatch) return;
30
32
  hasConnected = true;
31
33
  finish();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/attach.ts"],"names":[],"mappings":"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","file":"attach.js","sourcesContent":["/**\n * `kerfjs/attach` — bind a non-kerf widget's lifecycle to a single DOM node.\n *\n * `data-morph-skip` lets a library own a subtree so kerf won't touch it — but\n * nothing manages that widget's LIFECYCLE. You set it up imperatively after\n * render and must remember to tear it down when the node is replaced/removed\n * (dropping document-level listeners the widget added, etc.). `attach` closes\n * that seam: run a setup against one **existing** DOM node and auto-run its\n * teardown when that node leaves the document.\n *\n * import { attach } from 'kerfjs/attach';\n *\n * attach(canvasEl, (el) => {\n * const chart = D3.mount(el);\n * return () => chart.destroy(); // runs when el leaves the DOM (or on dispose)\n * });\n *\n * `setup(node)` runs immediately — the node already exists, so there is nothing\n * to wait for (this is NOT React's `useEffect`: no dependency array, no re-run,\n * no render-phase or hook-order scoping; it is closer to a Web Component's\n * `connectedCallback`/`disconnectedCallback` pair, Svelte's\n * `onMount(() => () => cleanup)`, or Solid's `onCleanup`). A node may start\n * disconnected: setup still runs now, and teardown waits until it has first\n * connected and subsequently leaves the document. The returned teardown runs\n * once — whichever comes first — on that removal (detected by a\n * `MutationObserver`, so a morph swap, a `remountOn` replacement, or any removal\n * triggers it) or when the returned disposer is called. Re-creation is NOT\n * handled here: a fresh node is a fresh `attach()` call — pair it with\n * `kerfjs/remount`, which replaces the node and re-runs your render (and thus\n * this call) on the new one.\n *\n * Related: `kerfjs/scope`'s `observeRemovals` also auto-disposes on removal via a\n * `MutationObserver`, but scoped to a whole subtree's registered disposers rather\n * than one node's setup/teardown pair — reach for that when you're collecting\n * many disposers under an element, and for `attach` when you're binding one\n * widget's lifecycle to one node.\n */\n\n/** The setup callback for {@link attach}: run against `node`, optionally return a teardown. */\nexport type AttachSetup = (node: Element) => (() => void) | void;\n\n/**\n * Run `setup(node)` now, and its returned teardown once — after `node` has been\n * connected and then leaves the document, or when the returned disposer is\n * called, whichever is first. Returns an idempotent disposer so a `mount()` /\n * `Scope` can drive teardown explicitly.\n */\nexport function attach(node: Element, setup: AttachSetup): () => void {\n const teardown = setup(node);\n let done = false;\n let hasConnected = node.isConnected;\n let observedRoot: Node | undefined;\n let connectionFrame = 0;\n\n const finish = (): void => {\n if (done) return;\n done = true;\n globalThis.cancelAnimationFrame(connectionFrame);\n observer.disconnect();\n if (typeof teardown === 'function') teardown();\n };\n\n const addedWithin = (candidate: Node): boolean => {\n let current: Node | null = node;\n while (current !== null) {\n if (current === candidate) return true;\n const root = current.getRootNode();\n current = current.parentNode ?? (root instanceof ShadowRoot ? root.host : null);\n }\n return false;\n };\n\n const observer = new MutationObserver((records) => {\n if (done) return;\n\n if (!hasConnected) {\n // A node may be prepared before insertion. Its own detached root cannot\n // observe becoming someone else's child, so wait on ownerDocument until\n // it becomes connected; only a later disconnection is a teardown event.\n if (!node.isConnected) {\n const connectedAndRemovedInBatch = records.some((record) =>\n [...record.addedNodes].some(addedWithin));\n if (!connectedAndRemovedInBatch) return;\n hasConnected = true;\n finish();\n return;\n }\n hasConnected = true;\n globalThis.cancelAnimationFrame(connectionFrame);\n } else if (!node.isConnected) {\n finish();\n return;\n }\n\n // A connected node can move between documents or shadow roots without\n // ending its lifecycle. Follow its live root so a later removal remains\n // observable after that move.\n const root = node.getRootNode();\n if (root !== observedRoot) observe(root);\n });\n\n const observe = (root: Node): void => {\n observer.disconnect();\n observedRoot = root;\n const options = { childList: true, subtree: true } as const;\n observer.observe(node.ownerDocument, options);\n // Document observation sees a shadow host leave the document, while shadow\n // observation sees the node (or one of its in-shadow ancestors) removed.\n // Both are required to cover the node's composed lifecycle.\n if (root !== node.ownerDocument) observer.observe(root, options);\n };\n\n const watchForConnection = (): void => {\n connectionFrame = globalThis.requestAnimationFrame(() => {\n if (done || hasConnected) return;\n if (node.isConnected) {\n hasConnected = true;\n observe(node.getRootNode());\n return;\n }\n watchForConnection();\n });\n };\n\n // Connected nodes are watched from their live document/shadow root. For a\n // disconnected node, watch ownerDocument plus animation frames: the document\n // catches ordinary insertion, while the frame check also catches direct\n // insertion across a shadow boundary that document observation cannot see.\n observe(hasConnected ? node.getRootNode() : node.ownerDocument);\n if (!hasConnected) watchForConnection();\n\n return finish;\n}\n"]}
1
+ {"version":3,"sources":["../src/attach.ts"],"names":[],"mappings":"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","file":"attach.js","sourcesContent":["/**\n * `kerfjs/attach` — bind a non-kerf widget's lifecycle to a single DOM node.\n *\n * `data-morph-skip` lets a library own a subtree so kerf won't touch it — but\n * nothing manages that widget's LIFECYCLE. You set it up imperatively after\n * render and must remember to tear it down when the node is replaced/removed\n * (dropping document-level listeners the widget added, etc.). `attach` closes\n * that seam: run a setup against one **existing** DOM node and auto-run its\n * teardown when that node leaves the document.\n *\n * import { attach } from 'kerfjs/attach';\n *\n * attach(canvasEl, (el) => {\n * const chart = D3.mount(el);\n * return () => chart.destroy(); // runs when el leaves the DOM (or on dispose)\n * });\n *\n * `setup(node)` runs immediately — the node already exists, so there is nothing\n * to wait for (this is NOT React's `useEffect`: no dependency array, no re-run,\n * no render-phase or hook-order scoping; it is closer to a Web Component's\n * `connectedCallback`/`disconnectedCallback` pair, Svelte's\n * `onMount(() => () => cleanup)`, or Solid's `onCleanup`). A node may start\n * disconnected: setup still runs now, and teardown waits until it has first\n * connected and subsequently leaves the document. The returned teardown runs\n * once — whichever comes first — on that removal (detected by a\n * `MutationObserver`, so a morph swap, a `remountOn` replacement, or any removal\n * triggers it) or when the returned disposer is called. Re-creation is NOT\n * handled here: a fresh node is a fresh `attach()` call — pair it with\n * `kerfjs/remount`, which replaces the node and re-runs your render (and thus\n * this call) on the new one.\n *\n * Related: `kerfjs/scope`'s `observeRemovals` also auto-disposes on removal via a\n * `MutationObserver`, but scoped to a whole subtree's registered disposers rather\n * than one node's setup/teardown pair — reach for that when you're collecting\n * many disposers under an element, and for `attach` when you're binding one\n * widget's lifecycle to one node.\n */\n\n/** The setup callback for {@link attach}: run against `node`, optionally return a teardown. */\nexport type AttachSetup = (node: Element) => (() => void) | void;\n\n/**\n * Run `setup(node)` now, and its returned teardown once — after `node` has been\n * connected and then leaves the document, or when the returned disposer is\n * called, whichever is first. Returns an idempotent disposer so a `mount()` /\n * `Scope` can drive teardown explicitly.\n */\nexport function attach(node: Element, setup: AttachSetup): () => void {\n const teardown = setup(node);\n let done = false;\n let hasConnected = node.isConnected;\n let observedRoot: Node | undefined;\n let connectionFrame = 0;\n\n const finish = (): void => {\n if (done) return;\n done = true;\n globalThis.cancelAnimationFrame(connectionFrame);\n observer.disconnect();\n if (typeof teardown === 'function') teardown();\n };\n\n const addedWithin = (candidate: Node): boolean => {\n let current: Node | null = node;\n while (current !== null) {\n if (current === candidate) return true;\n const root = current.getRootNode();\n current =\n current.parentNode ?? (root instanceof ShadowRoot ? root.host : null);\n }\n return false;\n };\n\n const observer = new MutationObserver((records) => {\n if (done) return;\n\n if (!hasConnected) {\n // A node may be prepared before insertion. Its own detached root cannot\n // observe becoming someone else's child, so wait on ownerDocument until\n // it becomes connected; only a later disconnection is a teardown event.\n if (!node.isConnected) {\n const connectedAndRemovedInBatch = records.some((record) =>\n [...record.addedNodes].some(addedWithin),\n );\n if (!connectedAndRemovedInBatch) return;\n hasConnected = true;\n finish();\n return;\n }\n hasConnected = true;\n globalThis.cancelAnimationFrame(connectionFrame);\n } else if (!node.isConnected) {\n finish();\n return;\n }\n\n // A connected node can move between documents or shadow roots without\n // ending its lifecycle. Follow its live root so a later removal remains\n // observable after that move.\n const root = node.getRootNode();\n if (root !== observedRoot) observe(root);\n });\n\n const observe = (root: Node): void => {\n observer.disconnect();\n observedRoot = root;\n const options = { childList: true, subtree: true } as const;\n observer.observe(node.ownerDocument, options);\n // Document observation sees a shadow host leave the document, while shadow\n // observation sees the node (or one of its in-shadow ancestors) removed.\n // Both are required to cover the node's composed lifecycle.\n if (root !== node.ownerDocument) observer.observe(root, options);\n };\n\n const watchForConnection = (): void => {\n connectionFrame = globalThis.requestAnimationFrame(() => {\n if (done || hasConnected) return;\n if (node.isConnected) {\n hasConnected = true;\n observe(node.getRootNode());\n return;\n }\n watchForConnection();\n });\n };\n\n // Connected nodes are watched from their live document/shadow root. For a\n // disconnected node, watch ownerDocument plus animation frames: the document\n // catches ordinary insertion, while the frame check also catches direct\n // insertion across a shadow boundary that document observation cannot see.\n observe(hasConnected ? node.getRootNode() : node.ownerDocument);\n if (!hasConnected) watchForConnection();\n\n return finish;\n}\n"]}
@@ -2,7 +2,8 @@
2
2
  var versions = /* @__PURE__ */ new WeakMap();
3
3
  var anyVersioned = false;
4
4
  function bumpItemVersion(item) {
5
- if (item === null || typeof item !== "object" && typeof item !== "function") return;
5
+ if (item === null || typeof item !== "object" && typeof item !== "function")
6
+ return;
6
7
  anyVersioned = true;
7
8
  versions.set(item, (versions.get(item) ?? 0) + 1);
8
9
  }
@@ -12,4 +13,4 @@ function itemVersion(item) {
12
13
 
13
14
  export { bumpItemVersion, itemVersion };
14
15
 
15
- //# sourceMappingURL=chunk-QIP723L4.js.map
16
+ //# sourceMappingURL=chunk-5WRGJZV6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/item-version.ts"],"names":[],"mappings":";AA8BA,IAAM,QAAA,uBAAe,OAAA,EAAwB;AAC7C,IAAI,YAAA,GAAe,KAAA;AAWZ,SAAS,gBAAgB,IAAA,EAAqB;AACnD,EAAA,IAAI,SAAS,IAAA,IAAS,OAAO,IAAA,KAAS,QAAA,IAAY,OAAO,IAAA,KAAS,UAAA;AAChE,IAAA;AACF,EAAA,YAAA,GAAe,IAAA;AACf,EAAA,QAAA,CAAS,IAAI,IAAA,EAAA,CAAO,QAAA,CAAS,IAAI,IAAI,CAAA,IAAK,KAAK,CAAC,CAAA;AAClD;AAGO,SAAS,YAAY,IAAA,EAAsB;AAChD,EAAA,OAAO,YAAA,GAAgB,QAAA,CAAS,GAAA,CAAI,IAAI,KAAK,CAAA,GAAK,CAAA;AACpD","file":"chunk-5WRGJZV6.js","sourcesContent":["/**\n * Per-item content version — makes a same-ref `arraySignal.update()` visible to\n * the row memo (KF-418).\n *\n * kerf memoizes `each()` rows by object IDENTITY (`WeakMap<item, CacheEntry>`),\n * so a same-ref mutation — `update(i, r => { r.x = 1; return r })` — changes the\n * row's content without changing its identity and is invisible to the memo. The\n * KF-414 fix repaired only the single list that drained the patch; every other\n * consumer (a second list over the same signal, a second `mount()`, a\n * plain-array `filter()` view of the same items) still trusted its own memo\n * entry, keyed on an identity that didn't change, and rendered stale forever.\n *\n * `arraySignal.update()` bumps the returned item's version here; each row's\n * `CacheEntry` records the version it rendered at, and a cache hit now requires\n * the version to still match. A same-ref update therefore re-renders the row in\n * EVERY consumer, because they all read the same version off the same item ref.\n *\n * Why a shared module rather than a field on `ArraySignal`:\n * - the main-bundle `each()` must read the version, and importing the\n * `kerfjs/array-signal` subpath into the main bundle would defeat KF-95\n * (arraySignal only ships when the consumer imports it). This module lives in\n * the main bundle; the subpath imports it, not the other way round.\n * - keying on the item object (not the signal) is what lets a plain-array\n * `filter()` view — which has no reference to the arraySignal — still see the\n * bump: it holds the same item refs.\n *\n * `anyVersioned` keeps the common case free: until some `update()` actually\n * bumps a version, every lookup returns 0 without touching the WeakMap, so a app\n * that never mutates in place pays nothing per row.\n */\nconst versions = new WeakMap<object, number>();\nlet anyVersioned = false;\n\n/**\n * Bump `item`'s content version. Called by `arraySignal.update()` on the item it\n * returns. A no-op for any value that isn't a valid WeakMap key — a primitive\n * item (an `arraySignal<number>` used purely as a standalone signal) can never be\n * an `each()` row, since rows are memoized by object identity, so it needs no\n * content version. Guarding here keeps `update()` from throwing `Invalid value\n * used as weak map key` and leaves `anyVersioned` false until a real object is\n * versioned (so the primitive path stays a zero-cost lookup).\n */\nexport function bumpItemVersion(item: unknown): void {\n if (item === null || (typeof item !== 'object' && typeof item !== 'function'))\n return;\n anyVersioned = true;\n versions.set(item, (versions.get(item) ?? 0) + 1);\n}\n\n/** `item`'s current content version — 0 if it has never been same-ref-updated. */\nexport function itemVersion(item: object): number {\n return anyVersioned ? (versions.get(item) ?? 0) : 0;\n}\n"]}
@@ -10,7 +10,8 @@ function flatten(segment, withMarkers) {
10
10
  }
11
11
  function flattenWithoutListItems(segment) {
12
12
  if (segment.kind === "static") return segment.html;
13
- if (segment.kind === "list") return `<!--${LIST_MARKER_PREFIX}${segment.id}-->`;
13
+ if (segment.kind === "list")
14
+ return `<!--${LIST_MARKER_PREFIX}${segment.id}-->`;
14
15
  return segment.parts.map(flattenWithoutListItems).join("");
15
16
  }
16
17
  function collectLists(segment, out = /* @__PURE__ */ new Map()) {
@@ -70,4 +71,4 @@ function wrapWithTags(child, openTag, closeTag) {
70
71
 
71
72
  export { LIST_MARKER_PREFIX, collectLists, flatten, flattenWithoutListItems, mergeChildSegments, wrapWithTags };
72
73
 
73
- //# sourceMappingURL=chunk-GY4XV2UV.js.map
74
+ //# sourceMappingURL=chunk-BDX3R4OM.js.map