kerfjs 4.4.1 → 4.5.0-beta.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 +270 -107
- package/LICENSE +1 -1
- package/README.md +45 -25
- package/ai/cursorrules +130 -48
- package/ai/manifest.json +63 -5
- package/ai/skill.md +148 -65
- package/dist/actions.d.ts +1 -1
- package/dist/actions.js +4 -4
- package/dist/actions.js.map +1 -1
- package/dist/array-signal.js +5 -5
- package/dist/async.js +34 -24
- package/dist/async.js.map +1 -1
- package/dist/attach.d.ts +11 -8
- package/dist/attach.js +54 -4
- package/dist/attach.js.map +1 -1
- package/dist/{chunk-QIP723L4.js → chunk-5WRGJZV6.js} +4 -3
- package/dist/chunk-5WRGJZV6.js.map +1 -0
- package/dist/{chunk-GY4XV2UV.js → chunk-BDX3R4OM.js} +4 -3
- package/dist/chunk-BDX3R4OM.js.map +1 -0
- package/dist/{chunk-VVDJLWMP.js → chunk-CEQMZYLR.js} +2 -2
- package/dist/chunk-CEQMZYLR.js.map +1 -0
- package/dist/{chunk-KEZTD6H4.js → chunk-E5R5GNKE.js} +15 -5
- package/dist/chunk-E5R5GNKE.js.map +1 -0
- package/dist/{chunk-MRYM3O3V.js → chunk-ELXVRKY2.js} +12 -9
- package/dist/chunk-ELXVRKY2.js.map +1 -0
- package/dist/{chunk-U32TFTGZ.js → chunk-MK42GLPV.js} +3 -3
- package/dist/chunk-MK42GLPV.js.map +1 -0
- package/dist/{chunk-SUPUPSBE.js → chunk-QFUNWHKH.js} +42 -20
- package/dist/chunk-QFUNWHKH.js.map +1 -0
- package/dist/{chunk-SRWQKB33.js → chunk-V757BT6U.js} +206 -111
- package/dist/chunk-V757BT6U.js.map +1 -0
- package/dist/{chunk-YHH7OUFA.js → chunk-WKIPLNVO.js} +3 -3
- package/dist/chunk-WKIPLNVO.js.map +1 -0
- package/dist/{chunk-3APBEVHF.js → chunk-Y2FOYPBV.js} +3 -3
- package/dist/{chunk-3APBEVHF.js.map → chunk-Y2FOYPBV.js.map} +1 -1
- package/dist/{chunk-SAYPJ6XR.js → chunk-ZOIERTUW.js} +10 -6
- package/dist/chunk-ZOIERTUW.js.map +1 -0
- package/dist/dev.d.ts +9 -6
- package/dist/dev.js +66 -19
- package/dist/dev.js.map +1 -1
- package/dist/html.d.ts +1 -1
- package/dist/html.js +8 -7
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +28 -19
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.js +5 -5
- package/dist/list.d.ts +1 -1
- package/dist/list.js +304 -213
- package/dist/list.js.map +1 -1
- package/dist/overlay.d.ts +190 -280
- package/dist/overlay.js +471 -364
- package/dist/overlay.js.map +1 -1
- package/dist/remount.d.ts +5 -3
- package/dist/remount.js +29 -10
- package/dist/remount.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +73 -28
- package/dist/router.js.map +1 -1
- package/dist/scope.d.ts +4 -3
- package/dist/scope.js +11 -10
- package/dist/scope.js.map +1 -1
- package/dist/testing.js +4 -4
- package/dist/timing.d.ts +3 -2
- package/dist/timing.js +5 -5
- package/dist/timing.js.map +1 -1
- package/llms.txt +13 -6
- package/package.json +35 -11
- package/setup/cli.mjs +90 -0
- package/setup/index.d.mts +37 -0
- package/setup/index.mjs +1347 -0
- package/setup/jsonc.mjs +201 -0
- package/setup/state.schema.json +56 -0
- package/dist/chunk-GY4XV2UV.js.map +0 -1
- package/dist/chunk-KEZTD6H4.js.map +0 -1
- package/dist/chunk-MRYM3O3V.js.map +0 -1
- package/dist/chunk-QIP723L4.js.map +0 -1
- package/dist/chunk-SAYPJ6XR.js.map +0 -1
- package/dist/chunk-SRWQKB33.js.map +0 -1
- package/dist/chunk-SUPUPSBE.js.map +0 -1
- package/dist/chunk-U32TFTGZ.js.map +0 -1
- package/dist/chunk-VVDJLWMP.js.map +0 -1
- package/dist/chunk-YHH7OUFA.js.map +0 -1
- /package/dist/{attrSelector-Cmu2ZoGO.d.ts → attr-Cmu2ZoGO.d.ts} +0 -0
package/ai/skill.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kerf-app
|
|
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.
|
|
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.22.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Building apps with kerf
|
|
@@ -22,45 +22,106 @@ kerf is a ~12 KB reactive UI framework (~13 KB with `arraySignal`): signals + DO
|
|
|
22
22
|
- **A component package must NEVER import `kerfjs/dev`.** The hooks are process-global, so installing them is the consuming app's decision — a library that does it forces the diagnostics (and the chunk) on every consumer. Put the import in your demo page or test harness instead.
|
|
23
23
|
- Recommended companion: `npm install --save-dev eslint-plugin-kerfjs` and add `kerfjs.configs.recommended` to the project's eslint config. Enforces five of the hard rules below (no inline JSX event handlers, require `data-key` in `each()`, capture `delegate()` disposers, no nested `mount()`, prefer module JSX augmentation) at edit time — useful as a self-correction signal when authoring kerf code.
|
|
24
24
|
|
|
25
|
+
## Optional first-party UI
|
|
26
|
+
|
|
27
|
+
Install `@kerfjs/ui` when the app needs shared toolbars, menu rows, controlled tab bars,
|
|
28
|
+
headers/value tables, resizable regions, selects, banners, empty states, or
|
|
29
|
+
loading indicators. Import visual components from explicit subpaths so a
|
|
30
|
+
CSS-aware browser bundler includes only their reachable styles. The root barrel
|
|
31
|
+
and `@kerfjs/ui/unstyled` are CSS-free; pair the barrel with `styles.css` only
|
|
32
|
+
when the complete layer is intentional. Prefer a component before inventing a
|
|
33
|
+
parallel local primitive, but keep domain state, routing, commands, and tab/menu
|
|
34
|
+
policy in the app.
|
|
35
|
+
|
|
36
|
+
Use ListItem/ListActionRow/ListHeader/AppTab `rootAttributes` only for application
|
|
37
|
+
`data-*` metadata. ListActionRow `trailingActionAttributes` and ListHeader
|
|
38
|
+
`triggerAttributes` additionally support native popover target/action and
|
|
39
|
+
`aria-controls`/`aria-haspopup`. Do not use these slots to override action,
|
|
40
|
+
item identity, selection, disclosure, naming, disabled, icon, or role
|
|
41
|
+
semantics; one `role="menuitem"` does not make a complete menu widget.
|
|
42
|
+
`ListItem.trailing` is dormant. Use `ListActionRow` for sibling primary and
|
|
43
|
+
trailing native buttons, and keep its `label`, `icon`, and
|
|
44
|
+
`trailingActionIcon` SafeHtml slots free of controls.
|
|
45
|
+
AppTab protects its component/action/identity/selection and transient drag/drop
|
|
46
|
+
attributes at runtime. Keep `AppTab.closeIcon` and
|
|
47
|
+
`ResizableRegion.handleIcon` decorative and free of controls; the existing
|
|
48
|
+
named controls and wiring retain interaction and disposal ownership.
|
|
49
|
+
|
|
50
|
+
Components emit stable `data-action` hooks; wire them at the mount root and
|
|
51
|
+
retain every disposer. `wireResizableRegions()` and `wireTabBars()` are the
|
|
52
|
+
explicit behavioral helpers and return disposers. Compose `AppTab` inside a
|
|
53
|
+
controlled `TabBar`; `wireTabBars()` supplies horizontal edge autoscroll during
|
|
54
|
+
dragging. Apply reorder reports with `reorderTabs()`, and keep order,
|
|
55
|
+
selection, close policy, routing, panels, and persistence in the host.
|
|
56
|
+
Icons and spinners are decorative unless labeled; use assertive banners only
|
|
57
|
+
for urgent interruption.
|
|
58
|
+
|
|
59
|
+
The opinionated semantic ramps match Hot Sheet 2 and Web Awesome. Override
|
|
60
|
+
`--kui-color-*` globally or component properties such as
|
|
61
|
+
`--kui-state-banner-background` at a tone or instance boundary; do not replace
|
|
62
|
+
private descendant selectors.
|
|
63
|
+
|
|
64
|
+
For Web Awesome's broader free component set, import the CSS-only
|
|
65
|
+
`@kerfjs/ui/webawesome.css` theme once, then import only each Web Awesome
|
|
66
|
+
component module the app renders. The theme registers no component JavaScript
|
|
67
|
+
and remains overridable through later or scoped `--wa-*` values.
|
|
68
|
+
|
|
69
|
+
`Select` renders pure Web Awesome markup. Import
|
|
70
|
+
`@kerfjs/ui/select/register` once in an application entry that uses it; never
|
|
71
|
+
hide that registration inside another component. Web Awesome is an optional
|
|
72
|
+
peer and must remain absent from bundles that use neither Select nor the Web
|
|
73
|
+
Awesome theme. The package's
|
|
74
|
+
full AI contract is at `node_modules/@kerfjs/ui/ai/skill.md`.
|
|
75
|
+
|
|
25
76
|
## Public API — one import path
|
|
26
77
|
|
|
27
78
|
```ts
|
|
28
79
|
import {
|
|
29
|
-
signal,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
} from "kerfjs";
|
|
36
97
|
|
|
37
98
|
// Optional, only when you need granular collection updates:
|
|
38
|
-
import { arraySignal } from
|
|
99
|
+
import { arraySignal } from "kerfjs/array-signal";
|
|
39
100
|
|
|
40
101
|
// Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
|
|
41
|
-
if (import.meta.env.DEV) await import(
|
|
102
|
+
if (import.meta.env.DEV) await import("kerfjs/dev");
|
|
42
103
|
```
|
|
43
104
|
|
|
44
|
-
| Export
|
|
45
|
-
|
|
|
46
|
-
| `signal(initial)`
|
|
47
|
-
| `computed(fn)`
|
|
48
|
-
| `effect(fn)`
|
|
49
|
-
| `batch(fn)`
|
|
50
|
-
| `defineStore({initial, actions})`
|
|
51
|
-
| `resetAllStores()`
|
|
52
|
-
| `mount(el, render)`
|
|
53
|
-
| `morph(liveRoot, template)`
|
|
54
|
-
| `each(items, render, cacheKey?)`
|
|
55
|
-
| `each(items, render, { cacheKey, key })`
|
|
56
|
-
| `delegate(root, type, sel, h)`
|
|
57
|
-
| `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()`
|
|
58
|
-
| `attr(name, value)`
|
|
59
|
-
| `attr(name)`
|
|
60
|
-
| `toElement(jsx)`
|
|
61
|
-
| `raw(html)`
|
|
62
|
-
| `arraySignal(initial?)`
|
|
63
|
-
| `` html`…` ``
|
|
105
|
+
| Export | Use |
|
|
106
|
+
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
107
|
+
| `signal(initial)` | atomic reactive state; `.value` get/set |
|
|
108
|
+
| `computed(fn)` | derived value, read-only |
|
|
109
|
+
| `effect(fn)` | side effect on signal change; returns disposer |
|
|
110
|
+
| `batch(fn)` | coalesce multiple writes into one re-run |
|
|
111
|
+
| `defineStore({initial, actions})` | named multi-consumer state |
|
|
112
|
+
| `resetAllStores()` | reset every store (test teardown) |
|
|
113
|
+
| `mount(el, render)` | bind reactive render to a DOM element; returns disposer |
|
|
114
|
+
| `morph(liveRoot, template)` | one-shot reconcile against a populated element (SSR hydration, page-refresh diffs). Template = `Element`, `SafeHtml`, or HTML string |
|
|
115
|
+
| `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 |
|
|
116
|
+
| `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 |
|
|
117
|
+
| `delegate(root, type, sel, h)` | one listener at the root; `closest(selector)` walk from target |
|
|
118
|
+
| `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()` |
|
|
119
|
+
| `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
|
|
120
|
+
| `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 |
|
|
121
|
+
| `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. |
|
|
122
|
+
| `raw(html)` | inject pre-escaped HTML |
|
|
123
|
+
| `arraySignal(initial?)` | granular keyed-list signal (subpath `kerfjs/array-signal`); `each()` reconciles in O(patches) |
|
|
124
|
+
| `` 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}"`) |
|
|
64
125
|
|
|
65
126
|
## Hard rules — every AI assistant gets these wrong at least once
|
|
66
127
|
|
|
@@ -74,7 +135,7 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
|
|
|
74
135
|
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).
|
|
75
136
|
6. **One `mount()` per root.** Don't nest `mount()` calls. Compose with plain functions returning JSX.
|
|
76
137
|
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.
|
|
77
|
-
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
|
|
138
|
+
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.
|
|
78
139
|
9. **Store actions take `(set, get)`, not `(state)`.** `set(next)` replaces state; mutating `get()` does nothing.
|
|
79
140
|
10. **Use `data-action` attributes, not inline `onClick`.** Inline handlers are NOT supported by the JSX → string runtime; delegate from the root.
|
|
80
141
|
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.
|
|
@@ -87,22 +148,26 @@ if (import.meta.env.DEV) await import('kerfjs/dev');
|
|
|
87
148
|
When deciding which primitive to reach for, work down the axes:
|
|
88
149
|
|
|
89
150
|
**Events.**
|
|
151
|
+
|
|
90
152
|
- 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.
|
|
91
153
|
- 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.
|
|
92
154
|
- 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.
|
|
93
155
|
|
|
94
156
|
**Lists.**
|
|
157
|
+
|
|
95
158
|
- Items change across renders (todos, chat messages, table rows) → `each(items, render)`.
|
|
96
159
|
- Static structural enumeration whose row render reads signals → `STATIC.map(item => <jsx/>)`. Inner `each(item.children, …)` still gets keyed reconcile.
|
|
97
160
|
- Long list with point-wise mutations → `arraySignal` + `each(arraySig, render)` for O(patches) updates.
|
|
98
161
|
|
|
99
162
|
**Side effects / imperative DOM.**
|
|
163
|
+
|
|
100
164
|
- Library-owned subtree survives across renders → `data-morph-skip` on host.
|
|
101
165
|
- Host attributes morph but subtree preserved → `data-morph-skip-children`.
|
|
102
166
|
- Imperatively-injected element survives the trailing-removal pass → `data-morph-preserve`.
|
|
103
167
|
- Focused input / contenteditable caret survives re-renders → automatic; no opt-in.
|
|
104
168
|
|
|
105
169
|
**Raw HTML.**
|
|
170
|
+
|
|
106
171
|
- User-controlled HTML → sanitize first (DOMPurify) then `raw(sanitized)`.
|
|
107
172
|
- Author-controlled trusted HTML → `raw(html)` directly.
|
|
108
173
|
|
|
@@ -117,20 +182,27 @@ When deciding which primitive to reach for, work down the axes:
|
|
|
117
182
|
// directly with no render re-run; read `.value` only when the JSX STRUCTURE
|
|
118
183
|
// depends on the signal (conditionals, list shape).
|
|
119
184
|
const count = signal(0);
|
|
120
|
-
const ACTIONS = { inc: attr(
|
|
185
|
+
const ACTIONS = { inc: attr("data-action", "inc") } as const satisfies Record<
|
|
186
|
+
string,
|
|
187
|
+
AttrSpec<"data-action">
|
|
188
|
+
>;
|
|
121
189
|
|
|
122
|
-
mount(document.getElementById(
|
|
190
|
+
mount(document.getElementById("app")!, () => (
|
|
123
191
|
<div>
|
|
124
192
|
<button {...ACTIONS.inc.attrs}>+</button>
|
|
125
193
|
<span>{count}</span>
|
|
126
194
|
</div>
|
|
127
195
|
));
|
|
128
|
-
delegate(rootEl,
|
|
196
|
+
delegate(rootEl, "click", ACTIONS.inc.selector, () => {
|
|
197
|
+
count.value += 1;
|
|
198
|
+
});
|
|
129
199
|
|
|
130
200
|
// Pattern 2: keyed list with per-row memoization
|
|
131
201
|
mount(listEl, () => (
|
|
132
202
|
<ul>
|
|
133
|
-
{each(rows.value, (row) =>
|
|
203
|
+
{each(rows.value, (row) => (
|
|
204
|
+
<li data-key={row.id}>{row.label}</li>
|
|
205
|
+
))}
|
|
134
206
|
</ul>
|
|
135
207
|
));
|
|
136
208
|
|
|
@@ -138,8 +210,8 @@ mount(listEl, () => (
|
|
|
138
210
|
const cart = defineStore({
|
|
139
211
|
initial: () => ({ items: [] as string[] }),
|
|
140
212
|
actions: (set, get) => ({
|
|
141
|
-
add:
|
|
142
|
-
clear: ()
|
|
213
|
+
add: (id: string) => set({ items: [...get().items, id] }),
|
|
214
|
+
clear: () => set({ items: [] }),
|
|
143
215
|
}),
|
|
144
216
|
});
|
|
145
217
|
// access: cart.state.value.items, cart.actions.add('x'), cart.reset()
|
|
@@ -154,9 +226,15 @@ morph(liveCard, '<article class="card">…</article>');
|
|
|
154
226
|
const selectedId = signal<number | null>(null);
|
|
155
227
|
mount(listEl, () => (
|
|
156
228
|
<ul>
|
|
157
|
-
{each(
|
|
158
|
-
|
|
159
|
-
|
|
229
|
+
{each(
|
|
230
|
+
rows.value,
|
|
231
|
+
(row) => (
|
|
232
|
+
<li class={computed(() => (row.id === selectedId.value ? "sel" : ""))}>
|
|
233
|
+
{row.label}
|
|
234
|
+
</li>
|
|
235
|
+
),
|
|
236
|
+
(row) => row.id,
|
|
237
|
+
)}
|
|
160
238
|
</ul>
|
|
161
239
|
));
|
|
162
240
|
// selectedId.value = 3 → only the ~2 affected <li> class attrs update.
|
|
@@ -164,34 +242,39 @@ mount(listEl, () => (
|
|
|
164
242
|
// Pattern 6: no build step (CDN / importmap) — the html tagged template
|
|
165
243
|
// instead of JSX. Same runtime semantics; real HTML attribute names; a hole
|
|
166
244
|
// must be a text position or a COMPLETE attribute value (partials throw).
|
|
167
|
-
import { html } from
|
|
168
|
-
mount(
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
245
|
+
import { html } from "kerfjs/html";
|
|
246
|
+
mount(
|
|
247
|
+
rootEl,
|
|
248
|
+
() => html`
|
|
249
|
+
<div class="${cls}">Count: ${count}</div>
|
|
250
|
+
<ul>
|
|
251
|
+
${each(rows.value, (row) => html`<li data-key="${row.id}">${row.label}</li>`)}
|
|
252
|
+
</ul>
|
|
253
|
+
`,
|
|
254
|
+
);
|
|
172
255
|
```
|
|
173
256
|
|
|
174
257
|
## Diagnosing common errors
|
|
175
258
|
|
|
176
|
-
| Error / symptom
|
|
177
|
-
|
|
|
178
|
-
| `JSX: DOM elements cannot be passed as children`
|
|
179
|
-
| `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
|
|
180
|
-
| `<select value={x}>` / `<textarea value={x}>` won't typecheck
|
|
181
|
-
| Focus / cursor lost on every keystroke
|
|
182
|
-
| Click handler stops firing after re-render
|
|
183
|
-
| Render fn never re-runs
|
|
184
|
-
| SVG renders as broken / namespaceless markup
|
|
185
|
-
| Library widget destroyed on every render
|
|
186
|
-
| `<my-tag>` fails to typecheck
|
|
187
|
-
| `each(): row render at index N produced K top-level elements`
|
|
188
|
-
| Drag/drop / state change has no visible effect; only elements
|
|
189
|
-
| Row-enter CSS animation no longer replays when only a row's
|
|
190
|
-
| Want a hot spot to update without re-running the whole render
|
|
191
|
-
|
|
|
192
|
-
| 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)
|
|
193
|
-
| Keyed `each()` list suddenly renders zero rows — only its `<!--kf-list:N-->` marker — with no errors, and it never recovers (kerfjs ≤ 2.0.1)
|
|
194
|
-
| 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
|
|
259
|
+
| Error / symptom | Root cause | Fix |
|
|
260
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
261
|
+
| `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 |
|
|
262
|
+
| `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')` |
|
|
263
|
+
| `<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>` |
|
|
264
|
+
| Focus / cursor lost on every keystroke | list items lack `data-key` | Add `data-key` (or `id`) to each list item |
|
|
265
|
+
| 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) |
|
|
266
|
+
| Render fn never re-runs | signal was read outside the render fn | Move `signal.value` read inside the render fn |
|
|
267
|
+
| SVG renders as broken / namespaceless markup | `innerHTML` used directly | Use `mount` or `toElement` (SVG-aware) |
|
|
268
|
+
| Library widget destroyed on every render | host reachable by the morph | Wrap host in `data-morph-skip`; mount the library imperatively after first render |
|
|
269
|
+
| `<my-tag>` fails to typecheck | declaration merging targeted global JSX | Use `declare module 'kerfjs/jsx-runtime' { namespace JSX { … } }` instead |
|
|
270
|
+
| `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 |
|
|
271
|
+
| 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 |
|
|
272
|
+
| 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 |
|
|
273
|
+
| 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 |
|
|
274
|
+
| ` 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 |
|
|
275
|
+
| 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 |
|
|
276
|
+
| 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>`) |
|
|
277
|
+
| 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` |
|
|
195
278
|
|
|
196
279
|
## Workflow guidance
|
|
197
280
|
|
package/dist/actions.d.ts
CHANGED
package/dist/actions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { attr } from './chunk-
|
|
2
|
-
import { delegate } from './chunk-
|
|
3
|
-
|
|
1
|
+
import { attr } from './chunk-MK42GLPV.js';
|
|
2
|
+
import { delegate } from './chunk-E5R5GNKE.js';
|
|
3
|
+
|
|
4
4
|
|
|
5
5
|
// src/actions.ts
|
|
6
6
|
var DEFAULT_ACTION_ATTR = "data-action";
|
|
@@ -22,5 +22,5 @@ function delegateActions(root, eventType, table, options) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export { action, delegateActions };
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
//# sourceMappingURL=actions.js.map
|
package/dist/actions.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/actions.ts"],"names":[],"mappings":";;;;;AAmCA,IAAM,mBAAA,GAAsB,aAAA;
|
|
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"]}
|
package/dist/array-signal.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal } from './chunk-ELXVRKY2.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
6
|
//# sourceMappingURL=array-signal.js.map
|
package/dist/async.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { signal } from './chunk-
|
|
2
|
-
|
|
1
|
+
import { signal } from './chunk-Y2FOYPBV.js';
|
|
2
|
+
|
|
3
3
|
|
|
4
4
|
// src/async.ts
|
|
5
5
|
function resource(options = {}) {
|
|
@@ -47,28 +47,38 @@ function resource(options = {}) {
|
|
|
47
47
|
state.value = { ...state.value, progress: { completed, total } };
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
|
-
|
|
51
|
-
(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
input,
|
|
60
|
-
revision: commit(data)
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return data;
|
|
64
|
-
},
|
|
65
|
-
(error) => {
|
|
66
|
-
if (gen === generation) {
|
|
67
|
-
state.value = { ...state.value, status: "failed", error, progress: void 0, input };
|
|
68
|
-
}
|
|
69
|
-
return void 0;
|
|
50
|
+
const fail = (error) => {
|
|
51
|
+
if (gen === generation) {
|
|
52
|
+
state.value = {
|
|
53
|
+
...state.value,
|
|
54
|
+
status: "failed",
|
|
55
|
+
error,
|
|
56
|
+
progress: void 0,
|
|
57
|
+
input
|
|
58
|
+
};
|
|
70
59
|
}
|
|
71
|
-
|
|
60
|
+
return void 0;
|
|
61
|
+
};
|
|
62
|
+
let pending;
|
|
63
|
+
try {
|
|
64
|
+
pending = fetcher(report);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return Promise.resolve(fail(error));
|
|
67
|
+
}
|
|
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);
|
|
72
82
|
}
|
|
73
83
|
function reset() {
|
|
74
84
|
generation++;
|
|
@@ -98,5 +108,5 @@ function resource(options = {}) {
|
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
export { resource };
|
|
101
|
-
|
|
111
|
+
|
|
102
112
|
//# sourceMappingURL=async.js.map
|
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 return fetcher(report).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 (error: unknown) => {\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 }\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.d.ts
CHANGED
|
@@ -19,11 +19,13 @@
|
|
|
19
19
|
* to wait for (this is NOT React's `useEffect`: no dependency array, no re-run,
|
|
20
20
|
* no render-phase or hook-order scoping; it is closer to a Web Component's
|
|
21
21
|
* `connectedCallback`/`disconnectedCallback` pair, Svelte's
|
|
22
|
-
* `onMount(() => () => cleanup)`, or Solid's `onCleanup`).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* `onMount(() => () => cleanup)`, or Solid's `onCleanup`). A node may start
|
|
23
|
+
* disconnected: setup still runs now, and teardown waits until it has first
|
|
24
|
+
* connected and subsequently leaves the document. The returned teardown runs
|
|
25
|
+
* once — whichever comes first — on that removal (detected by a
|
|
26
|
+
* `MutationObserver`, so a morph swap, a `remountOn` replacement, or any removal
|
|
27
|
+
* triggers it) or when the returned disposer is called. Re-creation is NOT
|
|
28
|
+
* handled here: a fresh node is a fresh `attach()` call — pair it with
|
|
27
29
|
* `kerfjs/remount`, which replaces the node and re-runs your render (and thus
|
|
28
30
|
* this call) on the new one.
|
|
29
31
|
*
|
|
@@ -36,9 +38,10 @@
|
|
|
36
38
|
/** The setup callback for {@link attach}: run against `node`, optionally return a teardown. */
|
|
37
39
|
type AttachSetup = (node: Element) => (() => void) | void;
|
|
38
40
|
/**
|
|
39
|
-
* Run `setup(node)` now, and its returned teardown once —
|
|
40
|
-
* document, or when the returned disposer is
|
|
41
|
-
*
|
|
41
|
+
* Run `setup(node)` now, and its returned teardown once — after `node` has been
|
|
42
|
+
* connected and then leaves the document, or when the returned disposer is
|
|
43
|
+
* called, whichever is first. Returns an idempotent disposer so a `mount()` /
|
|
44
|
+
* `Scope` can drive teardown explicitly.
|
|
42
45
|
*/
|
|
43
46
|
declare function attach(node: Element, setup: AttachSetup): () => void;
|
|
44
47
|
|