kerfjs 2.0.1 → 3.0.0
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 +100 -0
- package/ai/cursorrules +11 -1
- package/ai/manifest.json +5 -5
- package/ai/skill.md +11 -1
- package/dist/array-signal.d.ts +7 -1
- package/dist/array-signal.js +11 -2
- package/dist/array-signal.js.map +1 -1
- package/dist/bindings-CYwoJpQb.d.ts +60 -0
- package/dist/chunk-3APBEVHF.js +20 -0
- package/dist/chunk-3APBEVHF.js.map +1 -0
- package/dist/chunk-GY4XV2UV.js +73 -0
- package/dist/chunk-GY4XV2UV.js.map +1 -0
- package/dist/{chunk-GYRZQCSY.js → chunk-JVVU2RQO.js} +11 -79
- package/dist/chunk-JVVU2RQO.js.map +1 -0
- package/dist/chunk-QIP723L4.js +15 -0
- package/dist/chunk-QIP723L4.js.map +1 -0
- package/dist/chunk-SAYPJ6XR.js +43 -0
- package/dist/chunk-SAYPJ6XR.js.map +1 -0
- package/dist/chunk-VVDJLWMP.js +14 -0
- package/dist/chunk-VVDJLWMP.js.map +1 -0
- package/dist/chunk-YHH7OUFA.js +58 -0
- package/dist/chunk-YHH7OUFA.js.map +1 -0
- package/dist/dev.d.ts +339 -0
- package/dist/dev.js +607 -0
- package/dist/dev.js.map +1 -0
- package/dist/html.d.ts +1 -0
- package/dist/html.js +4 -2
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +49 -11
- package/dist/index.js +351 -340
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.d.ts +16 -60
- package/dist/jsx-runtime.js +4 -2
- package/dist/testing.js +3 -2
- package/llms.txt +2 -2
- package/package.json +22 -11
- package/dist/chunk-GYRZQCSY.js.map +0 -1
- package/dist/chunk-KFUDM3VP.js +0 -131
- package/dist/chunk-KFUDM3VP.js.map +0 -1
- package/dist/chunk-NU7YHYEV.js +0 -90
- package/dist/chunk-NU7YHYEV.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,106 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.0.0] - 2026-07-27
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
- **kerf no longer infers development mode — add one line to your entry to keep the dev diagnostics.**
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
if (import.meta.env.DEV) await import('kerfjs/dev'); // Vite
|
|
17
|
+
if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev'); // webpack / Node
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Without it you get production shape: no dev warnings, no read-only `defineStore` `get()`
|
|
21
|
+
snapshot, and a screened dangerous URL warns-and-drops instead of throwing. Nothing else
|
|
22
|
+
changes, and if you never used the dev warnings you get a smaller bundle for free.
|
|
23
|
+
|
|
24
|
+
**Why it changed.** kerf inferred its own dev/prod mode by reading `globalThis.process?.env?.NODE_ENV`. Bundlers substitute the *bare* `process.env.NODE_ENV` token and never create a `globalThis.process` object for browser targets, so that read was `undefined`, `undefined !== 'production'` was `true`, and every production browser build silently took the development path. Consequences that were shipping: every `defineStore` `get()` returned a deep read-only `Proxy` (an allocation on every store read), a screened `javascript:`/`data:` URL **threw** instead of the documented warn-and-drop, and the always-on list-key warnings printed to production consoles. Server/Node builds, where `process` exists, were unaffected.
|
|
25
|
+
|
|
26
|
+
Removing the inference also shrinks production bundles by **~4.7 KB min+gzip (27%)** — a realistic import (`signal`/`computed`/`effect`/`batch`/`mount`/`each`/`delegate`) goes from 16.91 KB to **12.24 KB**. Previously the dev-warning modules were imported unconditionally by `mount()`/`each()` and gated at runtime, so they shipped to production regardless of build mode and no amount of tree-shaking could reclaim them. With the dev entry absent they are unreachable, and the `import()` statement itself is eliminated — the chunk is never emitted, let alone fetched.
|
|
27
|
+
|
|
28
|
+
`globalThis.KERF_DEV` is no longer consulted — not importing the dev entry is now the (compile-time) way to opt out. One ordering note: `signal()` picks its constructor at creation time, so put the import first if you rely on the untracked-signal warning.
|
|
29
|
+
|
|
30
|
+
- The `KERF_DEV_WARN_*` diagnostics no longer consult `NODE_ENV` or `globalThis.KERF_DEV` at all. Whether they run is decided in exactly one place: whether you imported `kerfjs/dev`. The previous release stopped kerf's core from inferring dev mode but left a second, inherited gate inside each warner, so a Node/SSR consumer who *deliberately* installed the diagnostics under `NODE_ENV=production` got silence — and `globalThis.KERF_DEV = false` still silenced warnings the consumer had explicitly opted into. Both are gone; each warning is now gated only by its own env var. If you were using `globalThis.KERF_DEV` to turn diagnostics off, remove the dev import instead (which is also what sheds the ~4.7 KB from your bundle).
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
- **New:** `each()` accepts an options object — `each(items, render, { cacheKey, key })` — and `key` gives a list a **stable identity**. Without one a list is identified by its position among the `each()` calls in a render, so adding or removing a conditional list above it made kerf rebuild it from scratch: rows lost their DOM nodes, and with them focus, scroll position and in-progress IME composition. Keying a list removes that dependency; because a keyed list doesn't occupy a positional slot, keying just the *conditional* list usually stabilizes its siblings too. The existing three-argument `each(items, render, cacheKey)` form is unchanged. In development kerf now warns once per list when it detects such a shift and names the fix.
|
|
34
|
+
|
|
35
|
+
- **`kerfjs/dev` now exports `enableWarnings()`** — and it fixes a defect: until now, none of the
|
|
36
|
+
`KERF_DEV_WARN_*` diagnostics could be switched on in a browser at all. Every one of them read `globalThis.process.env`, which does not exist in a browser realm — and a bundler `define` cannot reach it either, because the read goes through `globalThis.process` into a local binding rather than the substitutable `process.env.X` token. So in a Vite/webpack dev server, the environment where these warnings are most wanted, the entire opt-in family was permanently and silently off. Only Node/SSR (and kerf's own vitest suite, which is why nothing caught it) could turn any of them on.
|
|
37
|
+
|
|
38
|
+
The new export switches diagnostics on from code — where you already are at the moment you opt in:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
if (import.meta.env.DEV) {
|
|
42
|
+
const dev = await import('kerfjs/dev');
|
|
43
|
+
dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Keys are the same warnings under camelCase names (`rebuiltListeners`, `untrackedSignals`, `narrowSet`, `delegateInEffect`, `eachInMorphSkip`, `duplicateEachKeys`, `staleBinding`, `valueOnlyRerender`, `listRebind`, `staleIndex`, `parserRepair`, plus `invariants: true | 'throw'`), and they autocomplete, which an env-var name never did. The `KERF_DEV_WARN_*` variables keep working for Node, SSR, and CI; an explicit call wins over the environment in both directions, so `{ narrowSet: false }` silences an ambient variable.
|
|
48
|
+
|
|
49
|
+
- New opt-in development check `KERF_DEV_INVARIANTS=1` (or `=throw`): after every render kerf audits its list bookkeeping against the live DOM — markers still in the tree and still carrying their own list's id, rows attached under the list's own parent in order after its marker, no row claimed by two lists, no two lists' rows interleaved in one parent, and each list holding as many rows as the data it rendered from (so a list that reconciled to the wrong number of rows is caught at the render that did it, not several interactions later). Unlike the rest of the `KERF_DEV_*` family it doesn't describe a pattern to change in your app; it reports a bug in kerf, at the render that caused it rather than as a wrong picture several interactions later. Off by default with no cost when unset; kerf's own test suites run it in `throw` mode.
|
|
50
|
+
|
|
51
|
+
- New opt-in dev warning `KERF_DEV_WARN_STALE_INDEX=1`: fires when an `each()` list reuses a memoized row at a different index than it rendered at, while the row's render function takes an `index` argument. `each()` memoizes rows by object identity, not position — the `index` argument is not part of the memo key — so a reorder or a non-tail insert/remove/move serves a moved row the HTML it rendered at its old index, and a numbered list, zebra striping, or an "N of M" label silently shows the wrong value on just those rows. The warning names the fix (`each(items, render, { cacheKey: (_, i) => i })`, which folds the index into the memo key so displaced rows re-render). Off by default with zero production cost, like the rest of the `KERF_DEV_WARN_*` family; also newly documented in `docs/4-render.md`.
|
|
52
|
+
|
|
53
|
+
- New opt-in dev warning `KERF_DEV_WARN_PARSER_REPAIR=1`: fires (once per tag pair) when your markup puts a block-level element inside a `<p>`. The HTML parser closes a `<p>` before block content, so the `<p>` ends up empty and its children become its siblings — kerf reconciles that repaired tree correctly and updates keep working, but the structure you wrote is gone, along with any CSS or `querySelector` that relied on it. The symptom shows up far from the cause, which is why it is worth naming.
|
|
54
|
+
|
|
55
|
+
- New opt-in dev warning `KERF_DEV_WARN_LIST_REBIND=1`: fires (once per list) when an `each()` list's container is rebuilt by the morph — an ancestor's tag changed across renders, so the subtree was replaced and the list self-healed by re-binding and repopulating. The recovery is correct but discards row DOM state (focus, scroll, IME, imperative listeners); the warning names the list and points at keeping ancestor tags stable. Follows the standard `KERF_DEV_WARN_*` family rules: off by default, dev-mode only, zero production cost.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
- `KERF_DEV_WARN_UNTRACKED_SIGNALS=1` now tells you what it can and cannot see. The warning picks its machinery when a signal is *created*, so it only covers signals created after `kerfjs/dev` is installed — and because static imports are hoisted above a top-level `await import('kerfjs/dev')`, the module-scope signals it most wants to catch are usually created first. Previously that failed silently: you set the env var, saw nothing, and concluded your code was clean. Opting in now prints the coverage boundary once, along with the fix (make `import 'kerfjs/dev'` the first static import of a dev-only entry file). The boundary itself can't be removed — `Signal.prototype`'s `value` accessor is non-configurable, so already-created signals can't be retro-fitted without kerf keeping a registry of every signal, which production would pay for.
|
|
59
|
+
|
|
60
|
+
- An `each()` of `<tr>` written directly inside `<table>` now fails with a clear error instead of silently duplicating rows: the HTML parser inserts a `<tbody>` around the rows, which kerf cannot bind through. The message names both tags and shows the supported shape (`<table><tbody>{each(...)}</tbody></table>`). Previously this also mis-reported the rows as missing `data-key`.
|
|
61
|
+
|
|
62
|
+
- A keyed `each()` written inside another list's row now explains that nested lists aren't reconciled, instead of reporting a duplicate key.
|
|
63
|
+
|
|
64
|
+
- Toolchain: the repo now type-checks with the native **TypeScript 7** compiler across every gate (`typecheck`, the dist `.d.ts` typing gates, the docs code-block compile — a full-repo `tsc --noEmit` now takes ~0.3 s), with `typescript@6` (the JS-API bridge release) retained for tsup's `.d.ts` emit and typescript-eslint, which still require the JS compiler API. `@typescript-eslint/*` bumped to 8.65. No shipped-code changes — `dist/` output is unaffected.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
- Fixed: passing a **function** as an `each()` item through an `arraySignal` insert/update (an unusual mistake, but functions are valid `WeakMap` keys so the per-item cache silently accepted them) rendered the row and then threw `each(): items must be objects…` on a *later, unrelated* re-render — far from the cause. The granular path now enforces the same objects-only item contract the snapshot path does, so the error is thrown on the render the offending mutation triggered, naming the type and index. Primitive items already behaved this way; functions now match.
|
|
68
|
+
|
|
69
|
+
- `arraySignal.update(i, fn)` now works when `fn` mutates the row object and returns it (not only when it returns a fresh object). Previously such a same-ref update rendered correctly in one list but left every other view of the same signal — a second list, a second `mount()`, a `filter()`ed plain-array view — permanently stale, and was lost outright when the update was batched with a selection change or a `replace()`. kerf now tracks a per-item content version so the change reaches every consumer. Returning a fresh object remains the idiomatic style; both are supported. (Fixed in the same line of work: the per-item version tracking briefly made `arraySignal.update()` throw on a signal of primitives — `arraySignal<number>` used as a plain signal — because a primitive can't key the internal version map; primitive items are now simply skipped, since only object rows are ever memoized.)
|
|
70
|
+
|
|
71
|
+
- Fixed: `each()` rows under a `<math>` element rendered as MathML on first paint but fell into the HTML namespace on every later update (a granular insert, or a rebuild) — the rows became inert `HTMLUnknownElement`s that don't display as math. Row re-parsing now re-enters MathML foreign content the same way it already did for SVG (the KF-389 fix, generalized), so MathML lists keep their namespace across updates. The reverse case is handled too: a list of ordinary HTML rows placed where the parser re-enters HTML content — SVG `<foreignObject>`/`<desc>`/`<title>`, MathML `<mi>`/`<mo>`/`<mn>`/`<ms>`/`<mtext>` — is no longer wrapped on update, so those rows keep the HTML namespace instead of crashing (a block-level row) or turning foreign (an inline row). And a genuine "row produced the wrong number of elements" error now reports the count kerf actually parsed, rather than a misleading one from a re-parse in the wrong namespace.
|
|
72
|
+
|
|
73
|
+
- Fixed: a list rendered ZERO rows when a granular `arraySignal` change (an `insert`/`remove`/`update`) was batched with a re-render that rebuilt the list's container — a swapped ancestor tag, or a sibling appearing and positionally taking the container's place (e.g. a banner toggled on in the same update that removed a row). The list's data was intact in the signal; kerf now detects the rebuild and re-renders the affected list from a full snapshot instead of blanking it.
|
|
74
|
+
|
|
75
|
+
- Fixed: the diff could repurpose a `data-morph-skip-children` slot or an imperatively-injected `data-morph-preserve` node when a conditional sibling reappeared at its position — destroying a client-hydrated subtree, or a tooltip/overlay you injected. The morph now refuses to positionally adopt any node marked `data-morph-skip` / `data-morph-skip-children` / `data-morph-preserve` as a stand-in for an unrelated template element; it inserts the template element fresh beside it. A keyed match still morphs such a node in place.
|
|
76
|
+
|
|
77
|
+
- Fixed: an `arraySignal.update()` that mutated a row object in place (returning the same reference) could be silently reverted to its old content by the next unrelated re-render. The granular update path now keeps kerf's per-row HTML cache in sync with what it rendered, so the two never disagree. (Immutable updates — returning a fresh object — were unaffected, and remain the recommendation.)
|
|
78
|
+
|
|
79
|
+
- Fixed: showing an empty conditionally-rendered `each()` list in the same batch as an update to a *sibling* list could empty the sibling entirely — it rendered zero rows. When the conditional list reappeared, its list-marker comment landed next to the sibling's, and the diff overwrote the sibling marker's internal id with the reappearing one's, so the sibling's binding could no longer find its own marker. Marker comments (kerf's internal list and binding anchors) now pair only with the identical marker, never with a different one that happens to be the same kind. Ordinary comments in your markup are unaffected.
|
|
80
|
+
|
|
81
|
+
- Fixed: when an element rendered by a condition came back, the diff could pair it with an unrelated sibling that happened to have the same tag, and then whatever protects that sibling's contents kept the wrong contents alive inside it. A `data-morph-skip` widget **swallowed** the reappearing element — its content never rendered — and the widget was duplicated, which for a library-owned subtree (an editor, a chart, a terminal) means a second live instance attached to a node the library has no reference to. A `data-morph-preserve` child ended up under the foreign host and appeared twice. A bound hole's text leaked into the reappearing element and rendered twice. Three rules now gate that pairing: an element with an `id`/`data-key` is only ever matched to a live element with the same key; a `data-morph-skip` element only ever matches another one (so a library-owned subtree is never adopted as a stand-in, whether or not you use keys); and a comment anchoring kerf's own state only matches an anchor of the same kind. Ordinary elements without a key still match positionally exactly as before.
|
|
82
|
+
|
|
83
|
+
- Fixed: when the number of `each()` calls in a render changed — a conditional list appearing or disappearing — a *surviving* list could render the departed list's rows, or render its own rows inside the wrong container. Lists without a `key` are identified by their position among the `each()` calls, and both the per-item HTML cache and the live list binding were being read as belonging to whichever list now held that position. Two lists over the same collection hit each other's cache exactly, so nothing could detect it from the data. A shift now discards that state and re-renders, which is what the documentation already described it as costing: a rebuild, never wrong output. Lists given a `key` are unaffected, and a render that doesn't change the call count is unaffected. One related improvement falls out: an unrelated list no longer loses its row nodes when a nested `each()` shifts the count.
|
|
84
|
+
|
|
85
|
+
- Fixed: a row added to an `each()` list could land in the wrong place whenever the list wasn't the last thing inside its parent. A list ends at its last row, but kerf was looking for the next *element* after it and skipping everything else on the way — so static content following the list (a footer row, a totals line, an "add item" control) got jumped, and a new row appeared after it instead of before. The same skip crossed a neighboring list's internal anchor: with two `each()` lists in one parent, rows from the first could be placed inside the second's region, and when both lists started empty their rows came out **in the wrong order** — the second list's rows rendered first. Both were correct on the initial paint and only went wrong on a later update, which made them read as intermittent. Lists now anchor on the next node of any kind, so a list's rows always stay within its own region.
|
|
86
|
+
|
|
87
|
+
- Fixed: an `each()` list inside an `<svg>` whose row markup contained an apostrophe (or anything else the serializer writes back differently) failed to mount at all, with a self-contradictory error. Also fixed: `each({ key })` now validates the key, so a key containing an HTML comment terminator can no longer break out of the list's internal marker and put markup in the page.
|
|
88
|
+
|
|
89
|
+
- The development warning about list identity no longer fires when a list simply swaps which data it renders (a filter or tab change) — it only reports an actual identity shift, and each mount now reports its own.
|
|
90
|
+
|
|
91
|
+
- Fixed: a controlled `<textarea>` row could keep a stale value after the user had typed in it, when the update changed only its text.
|
|
92
|
+
|
|
93
|
+
- Focus now survives every move the diff makes, not just morph-in-place updates — moving a subtree (a keyed match, a shifted sibling) no longer drops the caret on engines that blur on `insertBefore`.
|
|
94
|
+
|
|
95
|
+
- Fixed: a controlled `checked` / `value` on an `each()` row's own top-level element could stay visibly stale after the user had interacted with it — the row reconciler's attribute-only fast path wrote the attribute without syncing the live property, so whether the control obeyed your data depended on which internal route the update happened to take.
|
|
96
|
+
|
|
97
|
+
- Fixed: when a conditionally-rendered `each()` list is added or removed, a sibling list could render the *other* list's rows — a batched "hide one list and push to another" applied the queued update to the wrong list's DOM. Lists now verify which data a pending update belongs to before applying it, and rebuild from their own items when it doesn't match.
|
|
98
|
+
|
|
99
|
+
- Fixed: `each()` rows inside an `<svg>` root were re-parsed in the HTML namespace on every update, so rows added or structurally changed after the first render were invisible in the browser — the initial picture looked right, which made it read as a rendering flake. Row parsing now follows the list parent's namespace (rows under `<foreignObject>` correctly stay HTML).
|
|
100
|
+
|
|
101
|
+
- Fixed: removing a conditionally-rendered sibling ahead of a keyed `each()` list (e.g. a banner that disappears) permanently emptied the list — the morph rebuilt the list's container from the template and the list binding stayed pointed at the detached subtree, silently rendering zero rows forever. The morph now performs a positional lookahead (a later same-tag live element is moved up and morphed in place instead of being cloned from scratch), so list containers — and any stateful element — survive a preceding sibling's removal with node identity intact. As defense in depth, `mount()` now self-heals a list binding whose marker left the live tree (e.g. an ancestor's tag changed, so the whole subtree was replaced): the stale binding is dropped and re-bound so rows repopulate instead of vanishing.
|
|
102
|
+
|
|
103
|
+
- Fixed: anything sitting between a keyed `each()` list's anchor and its rows — a node injected imperatively into the list region — could make a conditional sibling's removal reorder the list, landing a trailing sibling ahead of the rows. A list's row region (anchor through last row) is now treated as one unit by the diff: it moves whole, the diff's cursor steps over it whole, and injected nodes inside it travel along keeping their position relative to the rows.
|
|
104
|
+
|
|
105
|
+
- A conditional sibling *inside* a keyed `each()` list's parent — a header row that comes and goes above the list — no longer costs the list's rows their DOM identity. The morph now recognizes the list's marker when a sibling shifts it, moving the marker and its rows up as a single unit instead of rebuilding the list, so row nodes, focus, and the caret survive the toggle. (An ancestor tag change, or a same-tag sibling that positionally takes the container's place, still rebuilds the list — give the list's own container a stable `id`/`data-key` if its rows need to survive that.)
|
|
106
|
+
|
|
107
|
+
- Fixed: a conditional sibling that shared or positionally shadowed a keyed `each()` list's container could strand the list's rows and then render a duplicate copy of them (e.g. a header `<li>` toggled inside the list's `<ul>`, or a same-tag banner `<ul>` before the list container). The morph can separate the list's marker comment from its still-attached rows; the self-heal now removes any such still-live stranded rows before repopulating, so recovery replaces the rows rather than duplicating them.
|
|
108
|
+
|
|
9
109
|
## [2.0.1] - 2026-07-23
|
|
10
110
|
|
|
11
111
|
|
package/ai/cursorrules
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- kerf-skill-version: 1.
|
|
1
|
+
<!-- kerf-skill-version: 1.12.0 -->
|
|
2
2
|
# kerf.cursorrules — rules for building apps with kerf
|
|
3
3
|
#
|
|
4
4
|
# Drop this file into your project as `.cursorrules` (Cursor will pick it
|
|
@@ -12,6 +12,9 @@ You are writing a UI in kerf — a ~6.1 KB reactive framework (6.5 KB with `arra
|
|
|
12
12
|
- Install with `npm install kerfjs`.
|
|
13
13
|
- `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`.
|
|
14
14
|
- Vite / esbuild need no extra config.
|
|
15
|
+
- **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it FIRST if relying on the untracked-signal warning — `signal()` picks its constructor at creation time.
|
|
16
|
+
- **Switch individual warnings on with `enableWarnings()`**, which is the only switch that works in a browser (no `process` object there, and a bundler `define` cannot reach the read): `const dev = await import('kerfjs/dev'); dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });`. The `KERF_DEV_WARN_*` env vars do the same for Node/SSR/CI; an explicit call wins either way.
|
|
17
|
+
- **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.
|
|
15
18
|
- Recommended: also install `eslint-plugin-kerfjs` (`npm install --save-dev eslint-plugin-kerfjs`) and add `kerfjs.configs.recommended` to the project's eslint config. It 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 so violations surface as IDE squiggles before any code runs.
|
|
16
19
|
|
|
17
20
|
## Public API — one import path
|
|
@@ -28,6 +31,9 @@ import {
|
|
|
28
31
|
|
|
29
32
|
// Optional, only when you need granular collection updates:
|
|
30
33
|
import { arraySignal } from 'kerfjs/array-signal';
|
|
34
|
+
|
|
35
|
+
// Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
|
|
36
|
+
if (import.meta.env.DEV) await import('kerfjs/dev');
|
|
31
37
|
```
|
|
32
38
|
|
|
33
39
|
| Export | Use |
|
|
@@ -41,6 +47,7 @@ import { arraySignal } from 'kerfjs/array-signal';
|
|
|
41
47
|
| `mount(el, render)` | bind reactive render to a DOM element; returns a disposer |
|
|
42
48
|
| `morph(liveRoot, template)` | one-shot reconcile against an already-populated element (SSR hydration, page-refresh diffs). Template can be `Element`, `SafeHtml`, or HTML string |
|
|
43
49
|
| `each(items, render, cacheKey?)` | keyed list iteration; per-row memoization on object identity (+ optional cacheKey — a passive comparator for external state). Distinct from `data-key` on the rendered element |
|
|
50
|
+
| `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 |
|
|
44
51
|
| `delegate(root, type, sel, h)` | one listener at the root, walks `closest(selector)` from target |
|
|
45
52
|
| `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()` |
|
|
46
53
|
| `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
|
|
@@ -165,6 +172,9 @@ mount(listEl, () => (
|
|
|
165
172
|
- Row-enter CSS animation no longer replays when only a row's *content* changed (kerf ≥ 0.15.0) → 0.15.0+ morphs a same-identity, same-position row *in place* instead of recreating its node, so a mount-keyed `@keyframes` never re-triggers on a content-only update (≤ 0.14.x recreated the node, so it fired; the intentional flip side is that focus, scroll, IME, and in-progress transitions now survive). Key the animation on a state-class toggle, not element creation; to force a remount, churn the row's identity (new object ref / `data-key`).
|
|
166
173
|
- 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.
|
|
167
174
|
- `` html`` ``: partial attribute values are not supported → in `kerfjs/html` templates a hole must be the COMPLETE attribute value. Replace `class="a ${b}"` with a pre-built string (`` class="${`a ${b}`}" ``) or, for a bound attribute, `class="${computed(() => `a ${b.value}`)}"`.
|
|
175
|
+
- An `each()` list's rows lose focus / scroll / typing state when an unrelated conditional list above them appears or disappears (kerf warns in dev) → lists without a key are identified by 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.
|
|
176
|
+
- 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>`).
|
|
177
|
+
- 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 HTML rendered at its old index. Fold the index into the memo key: `each(items, (it, i) => …, { cacheKey: (_, i) => i })` (add `key` if used). Opt-in dev warn: `KERF_DEV_WARN_STALE_INDEX=1`.
|
|
168
178
|
|
|
169
179
|
## Server / SSR
|
|
170
180
|
|
package/ai/manifest.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kerfjsVersion": "
|
|
2
|
+
"kerfjsVersion": "3.0.0",
|
|
3
3
|
"files": [
|
|
4
4
|
{
|
|
5
5
|
"name": "skill",
|
|
6
6
|
"source": "kerf.claude-skill.md",
|
|
7
7
|
"bundle": "ai/skill.md",
|
|
8
8
|
"dest": ".claude/skills/kerf-app/SKILL.md",
|
|
9
|
-
"version": "1.
|
|
10
|
-
"sha256": "
|
|
9
|
+
"version": "1.12.0",
|
|
10
|
+
"sha256": "7acf72fd1b2781054520cc5af66cd73ee2f44b43c643e981f1f7a92ead60f747"
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"name": "cursorrules",
|
|
14
14
|
"source": "kerf.cursorrules",
|
|
15
15
|
"bundle": "ai/cursorrules",
|
|
16
16
|
"dest": ".cursorrules",
|
|
17
|
-
"version": "1.
|
|
18
|
-
"sha256": "
|
|
17
|
+
"version": "1.12.0",
|
|
18
|
+
"sha256": "48ff3d0f483a9fcd7f57ca20c17c0aadfce0e4a0b39d9e96e964ef9260f782d3"
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
21
|
}
|
package/ai/skill.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kerf-app
|
|
3
3
|
description: Build UIs in the kerf reactive framework (https://github.com/brianwestphal/kerf). Use this skill whenever the user is writing or modifying code that imports `kerfjs`, asks to add a feature to a kerf app, or asks "how do I do X in kerf?". Use it proactively the moment you spot a kerf import in the file you're editing.
|
|
4
|
-
kerf-skill-version: 1.
|
|
4
|
+
kerf-skill-version: 1.12.0
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Building apps with kerf
|
|
@@ -17,6 +17,9 @@ kerf is a ~11 KB reactive UI framework (~12 KB with `arraySignal`): signals + DO
|
|
|
17
17
|
- Install: `npm install kerfjs`
|
|
18
18
|
- `tsconfig.json`: `"jsx": "react-jsx"`, `"jsxImportSource": "kerfjs"`
|
|
19
19
|
- Vite / esbuild need no extra config.
|
|
20
|
+
- **Dev diagnostics are opt-in by import, and only an APP installs them.** kerf does not infer dev mode. In the app entry add `if (import.meta.env.DEV) await import('kerfjs/dev');` (Vite) or `if (process.env.NODE_ENV !== 'production') await import('kerfjs/dev');` (webpack/Node). That enables the read-only store `get()` snapshot, the throwing dangerous-URL screen, and makes the `KERF_DEV_WARN_*` family available; omitting it is production shape and sheds ~4.7 KB min+gzip because the condition folds away and the chunk is never emitted. Put it FIRST if relying on the untracked-signal warning — `signal()` picks its constructor at creation time.
|
|
21
|
+
- **Switch individual warnings on with `enableWarnings()`**, which is the only switch that works in a browser (no `process` object there, and a bundler `define` cannot reach the read): `const dev = await import('kerfjs/dev'); dev.enableWarnings({ staleBinding: true, narrowSet: true, invariants: 'throw' });`. The `KERF_DEV_WARN_*` env vars do the same for Node/SSR/CI; an explicit call wins either way.
|
|
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.
|
|
20
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.
|
|
21
24
|
|
|
22
25
|
## Public API — one import path
|
|
@@ -33,6 +36,9 @@ import {
|
|
|
33
36
|
|
|
34
37
|
// Optional, only when you need granular collection updates:
|
|
35
38
|
import { arraySignal } from 'kerfjs/array-signal';
|
|
39
|
+
|
|
40
|
+
// Development diagnostics — gate with YOUR build's dev flag, in YOUR code.
|
|
41
|
+
if (import.meta.env.DEV) await import('kerfjs/dev');
|
|
36
42
|
```
|
|
37
43
|
|
|
38
44
|
| Export | Use |
|
|
@@ -46,6 +52,7 @@ import { arraySignal } from 'kerfjs/array-signal';
|
|
|
46
52
|
| `mount(el, render)` | bind reactive render to a DOM element; returns disposer |
|
|
47
53
|
| `morph(liveRoot, template)` | one-shot reconcile against a populated element (SSR hydration, page-refresh diffs). Template = `Element`, `SafeHtml`, or HTML string |
|
|
48
54
|
| `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 |
|
|
55
|
+
| `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 |
|
|
49
56
|
| `delegate(root, type, sel, h)` | one listener at the root; `closest(selector)` walk from target |
|
|
50
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()` |
|
|
51
58
|
| `attr(name, value)` | pre-computed `AttrSpec<N,V>` — `.selector` for `delegate()`, `.attrs` to spread into JSX (rename-safe) |
|
|
@@ -180,6 +187,9 @@ mount(rootEl, () => html`
|
|
|
180
187
|
| 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 |
|
|
181
188
|
| 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 |
|
|
182
189
|
| `` 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 |
|
|
190
|
+
| 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 |
|
|
191
|
+
| 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>`) |
|
|
192
|
+
| 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` |
|
|
183
193
|
|
|
184
194
|
## Workflow guidance
|
|
185
195
|
|
package/dist/array-signal.d.ts
CHANGED
|
@@ -59,7 +59,13 @@ declare class ArraySignal<T> {
|
|
|
59
59
|
constructor(initial?: readonly T[]);
|
|
60
60
|
/** Read-only snapshot. Reads inside an effect/computed register a dependency. */
|
|
61
61
|
get value(): readonly T[];
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Replace the item at `index` with `fn(currentItem)`. Emits one `update`
|
|
64
|
+
* patch. Both styles work: returning a fresh object (idiomatic) invalidates
|
|
65
|
+
* the row by identity, and mutating `item` in place and returning it works
|
|
66
|
+
* too — a per-item content version (KF-418) makes the same-ref change visible
|
|
67
|
+
* to every consumer's row memo.
|
|
68
|
+
*/
|
|
63
69
|
update(index: number, fn: (item: T) => T): void;
|
|
64
70
|
/** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */
|
|
65
71
|
insert(index: number, item: T): void;
|
package/dist/array-signal.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bumpItemVersion } from './chunk-QIP723L4.js';
|
|
2
|
+
import { signal } from './chunk-3APBEVHF.js';
|
|
3
|
+
import './chunk-VVDJLWMP.js';
|
|
2
4
|
|
|
3
5
|
// src/array-signal.ts
|
|
4
6
|
var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
|
|
@@ -18,7 +20,13 @@ var ArraySignal = class {
|
|
|
18
20
|
void this._version.value;
|
|
19
21
|
return this._items;
|
|
20
22
|
}
|
|
21
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Replace the item at `index` with `fn(currentItem)`. Emits one `update`
|
|
25
|
+
* patch. Both styles work: returning a fresh object (idiomatic) invalidates
|
|
26
|
+
* the row by identity, and mutating `item` in place and returning it works
|
|
27
|
+
* too — a per-item content version (KF-418) makes the same-ref change visible
|
|
28
|
+
* to every consumer's row memo.
|
|
29
|
+
*/
|
|
22
30
|
update(index, fn) {
|
|
23
31
|
if (index < 0 || index >= this._items.length) {
|
|
24
32
|
throw new Error(
|
|
@@ -28,6 +36,7 @@ var ArraySignal = class {
|
|
|
28
36
|
const next = fn(this._items[index]);
|
|
29
37
|
this._items[index] = next;
|
|
30
38
|
this._patches.push({ type: "update", index, item: next });
|
|
39
|
+
bumpItemVersion(next);
|
|
31
40
|
this._version.value++;
|
|
32
41
|
}
|
|
33
42
|
/** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */
|
package/dist/array-signal.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/array-signal.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/array-signal.ts"],"names":[],"mappings":"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","file":"array-signal.js","sourcesContent":["/**\n * `arraySignal(initial)` — granular collection signal.\n *\n * A keyed-list-friendly variant of `signal()` that emits typed patch events\n * for every mutation (update / insert / remove / move / replace). When such\n * a signal is bound to `each(...)` inside a `mount()`, the keyed list\n * reconciler applies just the patches against the live DOM — no per-item\n * iteration, no `classifyItems` Map build, no LIS pass over unchanged rows.\n *\n * const rows = arraySignal<Row>([]);\n *\n * rows.update(42, (r) => ({ ...r, label: 'changed' })); // 1 update event\n * rows.insert(0, { id: 'x', ... }); // 1 insert event\n * rows.remove(7); // 1 remove event\n * rows.move(3, 0); // 1 move event\n * rows.replace([...]); // falls back to snapshot reconcile\n *\n * Read-side semantics match a regular signal: `arraySig.value` is a\n * snapshot, and reads inside `effect()` / `computed()` register as\n * dependencies, so derived values keep working.\n */\n\nimport { bumpItemVersion } from './item-version.js';\nimport type { Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\n/** A single granular mutation event. */\nexport type ArrayPatch<T> =\n | { type: 'update'; index: number; item: T }\n | { type: 'insert'; index: number; item: T }\n | { type: 'remove'; index: number }\n | { type: 'move'; from: number; to: number }\n | { type: 'replace'; items: readonly T[] };\n\n/**\n * Cross-bundle brand for `ArraySignal` instances. `each()` and the\n * granular reconciler check for this brand instead of `instanceof\n * ArraySignal`, so the main `kerfjs` barrel can detect arraySignal\n * inputs without importing the class at runtime — the class lives\n * only in the `kerfjs/array-signal` subpath, so apps that don't need\n * granular collections shed ~1 KB.\n *\n * Same `Symbol.for(...)`-based pattern as `SafeHtml` (KF-14): cross-\n * bundle-safe, zero-cost runtime check.\n */\nexport const ARRAY_SIGNAL_BRAND = Symbol.for('kerfjs.ArraySignal');\n\nexport class ArraySignal<T> {\n private _items: T[];\n private _version: Signal<number>;\n private _patches: ArrayPatch<T>[];\n // Branded so `isArraySignal()` recognizes instances from any copy of this module.\n readonly [ARRAY_SIGNAL_BRAND] = true as const;\n\n constructor(initial: readonly T[] = []) {\n this._items = [...initial];\n this._version = signal(0);\n this._patches = [];\n }\n\n /** Read-only snapshot. Reads inside an effect/computed register a dependency. */\n get value(): readonly T[] {\n // Touch the version signal so signals-core treats reads as tracked.\n void this._version.value;\n return this._items;\n }\n\n /**\n * Replace the item at `index` with `fn(currentItem)`. Emits one `update`\n * patch. Both styles work: returning a fresh object (idiomatic) invalidates\n * the row by identity, and mutating `item` in place and returning it works\n * too — a per-item content version (KF-418) makes the same-ref change visible\n * to every consumer's row memo.\n */\n update(index: number, fn: (item: T) => T): void {\n if (index < 0 || index >= this._items.length) {\n throw new Error(\n `arraySignal.update: index ${index} out of bounds [0, ${this._items.length}).`,\n );\n }\n const next = fn(this._items[index]);\n this._items[index] = next;\n this._patches.push({ type: 'update', index, item: next });\n // KF-418: a same-ref update (fn mutates and returns the same object) is\n // invisible to the row memo, which is keyed on object identity. Bump the\n // item's content version so every consumer — this list, another list over\n // this signal, a second mount, a plain-array filter() view — re-renders it.\n // Non-object items (an arraySignal<number> used as a plain signal) are\n // skipped by bumpItemVersion — they can't be each() rows (KF-419).\n bumpItemVersion(next);\n this._version.value++;\n }\n\n /** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */\n insert(index: number, item: T): void {\n if (index < 0 || index > this._items.length) {\n throw new Error(\n `arraySignal.insert: index ${index} out of bounds [0, ${this._items.length}].`,\n );\n }\n this._items.splice(index, 0, item);\n this._patches.push({ type: 'insert', index, item });\n this._version.value++;\n }\n\n /** Append `item` at the end. Sugar for `insert(items.length, item)`. */\n push(item: T): void {\n this.insert(this._items.length, item);\n }\n\n /** Remove and return the item at `index`. Emits one `remove` patch. */\n remove(index: number): T {\n if (index < 0 || index >= this._items.length) {\n throw new Error(\n `arraySignal.remove: index ${index} out of bounds [0, ${this._items.length}).`,\n );\n }\n const [removed] = this._items.splice(index, 1);\n this._patches.push({ type: 'remove', index });\n this._version.value++;\n return removed;\n }\n\n /** Move the item at `from` to position `to`. Emits one `move` patch (no-op when from === to). */\n move(from: number, to: number): void {\n if (from === to) return;\n if (from < 0 || from >= this._items.length || to < 0 || to >= this._items.length) {\n throw new Error(\n `arraySignal.move: indices out of bounds (from=${from}, to=${to}, length=${this._items.length}).`,\n );\n }\n const [item] = this._items.splice(from, 1);\n this._items.splice(to, 0, item);\n this._patches.push({ type: 'move', from, to });\n this._version.value++;\n }\n\n /** Replace every item. Emits one `replace` patch — the granular reconciler falls back to a full keyed diff for this case. */\n replace(items: readonly T[]): void {\n this._items = [...items];\n this._patches.push({ type: 'replace', items: this._items });\n this._version.value++;\n }\n\n /**\n * @internal Used by `each()` when binding this signal to a list. Returns\n * the queue of granular patches issued since the previous call, then\n * clears the queue. Best paired with a single binding — a second consumer\n * in the same render gets an empty array (which forces the snapshot\n * fall-back path, which is correct but slower).\n */\n _consumePatches(): ArrayPatch<T>[] {\n const out = this._patches;\n this._patches = [];\n return out;\n }\n}\n\n/** Construct an array signal seeded with `initial`. */\nexport function arraySignal<T>(initial: readonly T[] = []): ArraySignal<T> {\n return new ArraySignal(initial);\n}\n"]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Signal } from '@preact/signals-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fine-grained signal bindings (KF-294 spike).
|
|
5
|
+
*
|
|
6
|
+
* When a `Signal` is interpolated straight into a JSX attribute
|
|
7
|
+
* (`class={sig}`) or a text child (`{sig}`) INSIDE a `mount()` render, the
|
|
8
|
+
* JSX runtime stops stringifying it. Instead it emits a marker into the HTML
|
|
9
|
+
* string and records a binding here; after the string is parsed to DOM, a
|
|
10
|
+
* wiring pass attaches one `effect` per binding that writes straight to the
|
|
11
|
+
* live node. A later change to that signal then updates the node WITHOUT
|
|
12
|
+
* re-running the render function or walking the list reconciler.
|
|
13
|
+
*
|
|
14
|
+
* This reuses the "marker in string, wire up after parse" mechanism the
|
|
15
|
+
* keyed-list reconciler already uses for `<!--kf-list:{id}-->` markers.
|
|
16
|
+
*
|
|
17
|
+
* TWO SCOPES of binding, with disjoint marker namespaces so their wiring
|
|
18
|
+
* passes never collide:
|
|
19
|
+
*
|
|
20
|
+
* - GLOBAL holes — signals in the static surrounds (outside any `each()`
|
|
21
|
+
* row). Markers: `data-kfb` attribute / `<!--kfb:{id}-->` comment. Ids come
|
|
22
|
+
* from the mount render context's counter; wired by `wireBindings()` over
|
|
23
|
+
* the whole mount root; disposed/re-wired by `mount()` each render.
|
|
24
|
+
*
|
|
25
|
+
* - ROW holes — signals inside an `each()` row. Markers: `data-kfbrow`
|
|
26
|
+
* attribute / `<!--kfbr:{id}-->` comment. Ids are row-LOCAL (reset per row)
|
|
27
|
+
* so they stay stable and collision-free as rows are inserted/removed/moved.
|
|
28
|
+
* Captured per row by `captureRowBindings()`, carried on the list segment
|
|
29
|
+
* item, and wired/disposed by the list reconciler at each row node's
|
|
30
|
+
* create/remove — so a binding's lifetime tracks its row node's lifetime,
|
|
31
|
+
* and row reorders (which reuse the same node) are free.
|
|
32
|
+
*
|
|
33
|
+
* Outside a `mount()` render (SSR / `SafeHtml.toString()`) neither scope is
|
|
34
|
+
* active: the runtime snapshots `signal.value` and emits no markers, so server
|
|
35
|
+
* output is correct and legacy `.toString()` callers are unaffected.
|
|
36
|
+
*
|
|
37
|
+
* Module-level mutable state note: `context` / `rowSink` (plus `rowCounter`,
|
|
38
|
+
* the row-hole id counter that resets with each row capture) are a third
|
|
39
|
+
* sanctioned module-level mutable location (alongside `store.ts:REGISTRY` and
|
|
40
|
+
* `each.ts:context`). They hold the current render's binding sinks and are set
|
|
41
|
+
* / cleared by `mount()` and `each()` around the render calls. The only other
|
|
42
|
+
* module-level container here is `insertedTextNodes`, a WeakMap keyed on text
|
|
43
|
+
* marker comments — a pure cache whose entries die with their nodes (GC-tied
|
|
44
|
+
* lifetime), so it carries no cross-render semantics.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
interface AttrBinding {
|
|
48
|
+
kind: 'attr';
|
|
49
|
+
id: string;
|
|
50
|
+
attr: string;
|
|
51
|
+
signal: Signal<unknown>;
|
|
52
|
+
}
|
|
53
|
+
interface TextBinding {
|
|
54
|
+
kind: 'text';
|
|
55
|
+
id: string;
|
|
56
|
+
signal: Signal<unknown>;
|
|
57
|
+
}
|
|
58
|
+
type Binding = AttrBinding | TextBinding;
|
|
59
|
+
|
|
60
|
+
export type { Binding as B };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { devHooks } from './chunk-VVDJLWMP.js';
|
|
2
|
+
import { Signal, signal as signal$1, effect as effect$1 } from '@preact/signals-core';
|
|
3
|
+
export { batch, computed } from '@preact/signals-core';
|
|
4
|
+
|
|
5
|
+
function isSignal(value) {
|
|
6
|
+
return value instanceof Signal;
|
|
7
|
+
}
|
|
8
|
+
function signal(value) {
|
|
9
|
+
const factory = devHooks.signalFactory;
|
|
10
|
+
if (factory) return factory(value);
|
|
11
|
+
return signal$1(value);
|
|
12
|
+
}
|
|
13
|
+
function effect(fn) {
|
|
14
|
+
const wrap = devHooks.wrapEffect;
|
|
15
|
+
return effect$1(wrap ? wrap(fn) : fn);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { effect, isSignal, signal };
|
|
19
|
+
//# sourceMappingURL=chunk-3APBEVHF.js.map
|
|
20
|
+
//# sourceMappingURL=chunk-3APBEVHF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reactive.ts"],"names":["coreSignal","coreEffect"],"mappings":";;;;AA0CO,SAAS,SAAS,KAAA,EAA0C;AACjE,EAAA,OAAO,KAAA,YAAiB,MAAA;AAC1B;AAEO,SAAS,OAAU,KAAA,EAAsB;AAC9C,EAAA,MAAM,UAAU,QAAA,CAAS,aAAA;AACzB,EAAA,IAAI,OAAA,EAAS,OAAO,OAAA,CAAW,KAAU,CAAA;AACzC,EAAA,OAAOA,SAAW,KAAU,CAAA;AAC9B;AAEO,SAAS,OAAO,EAAA,EAA2C;AAChE,EAAA,MAAM,OAAO,QAAA,CAAS,UAAA;AACtB,EAAA,OAAOC,QAAA,CAAW,IAAA,GAAO,IAAA,CAAK,EAAE,IAAI,EAAE,CAAA;AACxC","file":"chunk-3APBEVHF.js","sourcesContent":["/**\n * Re-exports of `@preact/signals-core`. Lets the rest of the codebase depend\n * on `'./reactive.js'` without naming the underlying lib, so swapping it out\n * later (or fronting it with a hand-rolled implementation) is a one-file\n * change.\n *\n * Two dev hook slots sit in front of the bare re-exports:\n *\n * - `signalFactory` replaces the constructor so writes to never-subscribed\n * signals can warn (`KERF_DEV_WARN_UNTRACKED_SIGNALS=1`).\n *\n * - `wrapEffect` wraps the user body so `delegate()` can detect that it's\n * running inside an effect (`KERF_DEV_WARN_DELEGATE_IN_EFFECT=1`).\n *\n * Both are `undefined` unless the consumer imported `kerfjs/dev`, so production\n * sees the bare `@preact/signals-core` exports behind one property read.\n *\n * ORDERING: `signalFactory` is resolved at signal-CREATION time, so signals\n * created before `kerfjs/dev` is installed stay plain and the untracked-signal\n * warning never sees them. Static imports hoist above a `await import()`, so a\n * module-scope signal in an imported module is created first. See\n * docs/11-dev-warnings.md for the install-ordering rules.\n */\n\nimport { effect as coreEffect,Signal,signal as coreSignal } from '@preact/signals-core';\n\nimport { devHooks } from './dev-hooks.js';\n\nexport {\n batch,\n computed,\n type ReadonlySignal,\n Signal,\n} from '@preact/signals-core';\n\n/**\n * Runtime type guard for a `@preact/signals-core` signal (both `signal()`\n * values and `computed()` values are `Signal` instances). Used by the JSX\n * runtime (KF-294) to detect a signal handed straight into an attribute or\n * text hole — the trigger for a fine-grained binding rather than a snapshot\n * stringify.\n */\nexport function isSignal(value: unknown): value is Signal<unknown> {\n return value instanceof Signal;\n}\n\nexport function signal<T>(value?: T): Signal<T> {\n const factory = devHooks.signalFactory;\n if (factory) return factory<T>(value as T);\n return coreSignal(value as T);\n}\n\nexport function effect(fn: () => void | (() => void)): () => void {\n const wrap = devHooks.wrapEffect;\n return coreEffect(wrap ? wrap(fn) : fn);\n}\n"]}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/segment.ts
|
|
2
|
+
var LIST_MARKER_PREFIX = "kf-list:";
|
|
3
|
+
function flatten(segment, withMarkers) {
|
|
4
|
+
if (segment.kind === "static") return segment.html;
|
|
5
|
+
if (segment.kind === "list") {
|
|
6
|
+
const items = segment.items.map((i) => i.html).join("");
|
|
7
|
+
return withMarkers ? `<!--${LIST_MARKER_PREFIX}${segment.id}-->${items}` : items;
|
|
8
|
+
}
|
|
9
|
+
return segment.parts.map((p) => flatten(p, withMarkers)).join("");
|
|
10
|
+
}
|
|
11
|
+
function flattenWithoutListItems(segment) {
|
|
12
|
+
if (segment.kind === "static") return segment.html;
|
|
13
|
+
if (segment.kind === "list") return `<!--${LIST_MARKER_PREFIX}${segment.id}-->`;
|
|
14
|
+
return segment.parts.map(flattenWithoutListItems).join("");
|
|
15
|
+
}
|
|
16
|
+
function collectLists(segment, out = /* @__PURE__ */ new Map()) {
|
|
17
|
+
if (segment.kind === "list") out.set(segment.id, segment);
|
|
18
|
+
else if (segment.kind === "mixed") {
|
|
19
|
+
for (const part of segment.parts) collectLists(part, out);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function mergeChildSegments(parts) {
|
|
24
|
+
if (parts.length === 0) return { kind: "static", html: "" };
|
|
25
|
+
if (parts.every((p) => p.kind === "static")) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "static",
|
|
28
|
+
html: parts.map((p) => p.html).join("")
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const merged = [];
|
|
32
|
+
let coalesced = "";
|
|
33
|
+
for (const p of parts) {
|
|
34
|
+
if (p.kind === "static") {
|
|
35
|
+
coalesced += p.html;
|
|
36
|
+
} else {
|
|
37
|
+
if (coalesced !== "") {
|
|
38
|
+
merged.push({ kind: "static", html: coalesced });
|
|
39
|
+
coalesced = "";
|
|
40
|
+
}
|
|
41
|
+
merged.push(p);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (coalesced !== "") merged.push({ kind: "static", html: coalesced });
|
|
45
|
+
return { kind: "mixed", parts: merged };
|
|
46
|
+
}
|
|
47
|
+
function wrapWithTags(child, openTag, closeTag) {
|
|
48
|
+
if (child.kind === "static") {
|
|
49
|
+
return { kind: "static", html: openTag + child.html + closeTag };
|
|
50
|
+
}
|
|
51
|
+
if (child.kind === "mixed") {
|
|
52
|
+
return {
|
|
53
|
+
kind: "mixed",
|
|
54
|
+
parts: [
|
|
55
|
+
{ kind: "static", html: openTag },
|
|
56
|
+
...child.parts,
|
|
57
|
+
{ kind: "static", html: closeTag }
|
|
58
|
+
]
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
kind: "mixed",
|
|
63
|
+
parts: [
|
|
64
|
+
{ kind: "static", html: openTag },
|
|
65
|
+
child,
|
|
66
|
+
{ kind: "static", html: closeTag }
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { LIST_MARKER_PREFIX, collectLists, flatten, flattenWithoutListItems, mergeChildSegments, wrapWithTags };
|
|
72
|
+
//# sourceMappingURL=chunk-GY4XV2UV.js.map
|
|
73
|
+
//# sourceMappingURL=chunk-GY4XV2UV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/segment.ts"],"names":[],"mappings":"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","file":"chunk-GY4XV2UV.js","sourcesContent":["/**\n * `Segment` — kerf's structured render output.\n *\n * The JSX runtime emits a `SafeHtml` wrapping a `Segment`. Most renders\n * produce a single static segment (just an HTML string), which behaves\n * exactly like a string for backward compatibility. When the tree\n * contains a list (`each()`) or a parent whose children include a list,\n * the runtime emits a structured segment that `mount()` can dispatch\n * on — running its native keyed reconciler for the list parts and\n * leaving the static surrounds to the general-purpose diff.\n *\n * Why have a structured form at all: the perf bottleneck for huge\n * keyed lists isn't the per-row JSX work (which `each()` already\n * memoizes). It's that flattening every render's whole tree to one\n * big HTML string forces a full `innerHTML` parse and a tree walk\n * over rows we know are unchanged. The segment shape lets mount()\n * skip both for the list parts.\n */\n\nimport type { Binding } from './bindings.js';\n\nexport type Segment = StaticSegment | ListSegment | MixedSegment;\n\nexport interface StaticSegment {\n kind: 'static';\n html: string;\n}\n\nexport interface ListItem {\n /**\n * The row's object identity. Used by the reconciler to match new items\n * against live DOM nodes across renders. Unchanged ref → reuse the\n * existing live node; replaced ref → build a fresh node.\n */\n ref: object;\n /**\n * KF-294: the row's fine-grained binding specs (signals in row attrs/text).\n * Undefined for granular-path rows (which snapshot in this spike). The\n * snapshot reconciler wires these to the fresh row node and disposes them\n * when the row is removed.\n */\n bindings?: Binding[];\n /**\n * Optional cache-invalidation key that captures external state affecting\n * this row's render (e.g. selection class). Different cacheKey on the\n * same `ref` triggers a cache miss for that row. `undefined` when the\n * user didn't pass a `key` callback to `each()`.\n */\n cacheKey: unknown;\n html: string;\n}\n\nexport interface ListSegment {\n kind: 'list';\n id: string;\n items: ListItem[];\n /**\n * Optional granular patches (KF-92). When present, the list reconciler\n * applies these directly to the existing binding instead of doing a\n * full classify+reconcile pass. Emitted by `each()` when bound to an\n * `arraySignal`. Mutually exclusive with the `items` snapshot in the\n * sense that the snapshot is treated as informational/fall-back when\n * patches are present.\n */\n patches?: ArrayPatchInternal[];\n /**\n * KF-388: the identity of the data this list renders — the `arraySignal`\n * instance, or `undefined` for a plain array.\n *\n * A list's `id` is its call-order index, so a render that changes how many\n * `each()` calls precede this one hands this segment a DIFFERENT list's\n * binding. Patches are only meaningful against the binding they were queued\n * for, so the reconciler compares this against the binding's recorded source\n * before trusting the patch queue. It is an identity check, not a value\n * check — the instance is never read.\n */\n source?: object;\n}\n\n/**\n * Internal patch shape used inside list segments. Mirrors `ArrayPatch<T>`\n * from `array-signal.ts` but typed against `object` so the segment layer\n * doesn't need to be generic. `update` / `insert` patches carry the row's\n * pre-rendered HTML — `each()` renders them at JSX-evaluation time inside a\n * try/catch so a throwing render falls back to the snapshot path (KF-99)\n * instead of leaving the signal and DOM divergent.\n */\nexport type ArrayPatchInternal =\n | { type: 'update'; index: number; item: object; html: string; bindings?: Binding[] }\n | { type: 'insert'; index: number; item: object; html: string; bindings?: Binding[] }\n | { type: 'remove'; index: number }\n | { type: 'move'; from: number; to: number }\n | { type: 'replace'; items: readonly object[] };\n\n/**\n * Narrowed patch aliases. Array indexing loses the union discriminant, so\n * the granular reconciler casts through these instead of restating the full\n * object type at every site — a field rename is then a one-place edit.\n */\nexport type UpdatePatch = Extract<ArrayPatchInternal, { type: 'update' }>;\nexport type InsertPatch = Extract<ArrayPatchInternal, { type: 'insert' }>;\n\nexport interface MixedSegment {\n kind: 'mixed';\n parts: Segment[];\n}\n\n/**\n * Flatten a segment to a complete HTML string. Used for first render\n * (bulk innerHTML), for SSR-style consumption via `toString()`, and\n * for diagnostics.\n *\n * If `withMarkers` is set, list segments are wrapped in\n * `<!--kf-list:{id}-->` comments so the post-parse walk can find each\n * list's live parent. Plain (non-marker) flatten is what JSX consumers\n * see when they call `.toString()` on the SafeHtml.\n */\n/**\n * Comment-marker prefix emitted before each list (`<!--kf-list:{id}-->`).\n * `mount()` consumes it when binding lists from the live DOM — the emitter\n * (here) and the consumer must agree byte-for-byte, so both import this one\n * constant. Part of the reserved marker namespace (see `bindings.ts`).\n */\nexport const LIST_MARKER_PREFIX = 'kf-list:';\n\nexport function flatten(segment: Segment, withMarkers: boolean): string {\n if (segment.kind === 'static') return segment.html;\n if (segment.kind === 'list') {\n const items = segment.items.map((i) => i.html).join('');\n return withMarkers ? `<!--${LIST_MARKER_PREFIX}${segment.id}-->${items}` : items;\n }\n return segment.parts.map((p) => flatten(p, withMarkers)).join('');\n}\n\n/**\n * Variant of `flatten` for the static-only diff path on subsequent\n * renders. Lists are reduced to a single marker comment with no items\n * inside — the actual list children stay in the live DOM and are\n * reconciled separately. Keeping list items out of this string is\n * what makes the morph cheap on huge lists where most rows are\n * unchanged.\n */\nexport function flattenWithoutListItems(segment: Segment): string {\n if (segment.kind === 'static') return segment.html;\n if (segment.kind === 'list') return `<!--${LIST_MARKER_PREFIX}${segment.id}-->`;\n return segment.parts.map(flattenWithoutListItems).join('');\n}\n\n/** Collect every `ListSegment` in the tree, keyed by its id. */\nexport function collectLists(\n segment: Segment,\n out: Map<string, ListSegment> = new Map(),\n): Map<string, ListSegment> {\n if (segment.kind === 'list') out.set(segment.id, segment);\n else if (segment.kind === 'mixed') {\n for (const part of segment.parts) collectLists(part, out);\n }\n return out;\n}\n\n/**\n * Combine a list of child segments into the smallest equivalent\n * representation: collapses adjacent statics into one static, returns\n * a single static if everything is static, otherwise a mixed segment\n * with statics coalesced.\n */\nexport function mergeChildSegments(parts: Segment[]): Segment {\n if (parts.length === 0) return { kind: 'static', html: '' };\n if (parts.every((p) => p.kind === 'static')) {\n return {\n kind: 'static',\n html: parts.map((p) => (p as StaticSegment).html).join(''),\n };\n }\n const merged: Segment[] = [];\n let coalesced = '';\n for (const p of parts) {\n if (p.kind === 'static') {\n coalesced += p.html;\n } else {\n if (coalesced !== '') {\n merged.push({ kind: 'static', html: coalesced });\n coalesced = '';\n }\n merged.push(p);\n }\n }\n if (coalesced !== '') merged.push({ kind: 'static', html: coalesced });\n return { kind: 'mixed', parts: merged };\n}\n\n/**\n * Wrap a child segment with surrounding open/close tags from the\n * parent JSX element. Used by the JSX runtime when constructing\n * `_jsx(tag, ...)` output.\n */\nexport function wrapWithTags(child: Segment, openTag: string, closeTag: string): Segment {\n if (child.kind === 'static') {\n return { kind: 'static', html: openTag + child.html + closeTag };\n }\n if (child.kind === 'mixed') {\n return {\n kind: 'mixed',\n parts: [\n { kind: 'static', html: openTag },\n ...child.parts,\n { kind: 'static', html: closeTag },\n ],\n };\n }\n return {\n kind: 'mixed',\n parts: [\n { kind: 'static', html: openTag },\n child,\n { kind: 'static', html: closeTag },\n ],\n };\n}\n"]}
|