kerfjs 0.3.1 → 0.5.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 +45 -0
- package/README.md +86 -54
- package/dist/array-signal.d.ts +86 -0
- package/dist/array-signal.js +98 -0
- package/dist/array-signal.js.map +1 -0
- package/dist/chunk-FN2ID4QO.js +3 -0
- package/dist/chunk-FN2ID4QO.js.map +1 -0
- package/dist/{chunk-IZJIKRCE.js → chunk-GQGJFCWL.js} +3 -6
- package/dist/chunk-GQGJFCWL.js.map +1 -0
- package/dist/{chunk-WK5D3OO7.js → chunk-WYJTMERY.js} +14 -3
- package/dist/chunk-WYJTMERY.js.map +1 -0
- package/dist/index.d.ts +25 -12
- package/dist/index.js +492 -77
- package/dist/index.js.map +1 -1
- package/dist/jsx-runtime.d.ts +674 -3
- package/dist/jsx-runtime.js +1 -1
- package/dist/testing.js +2 -1
- package/package.json +17 -5
- package/dist/chunk-IZJIKRCE.js.map +0 -1
- package/dist/chunk-WK5D3OO7.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ All notable changes to **kerf** are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-05-10
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
- Add `arraySignal` (`kerfjs/array-signal` subpath) — granular collection signal that drives O(patches) DOM updates for keyed lists
|
|
11
|
+
- Faster keyed-list updates: bulk-parse contiguous insert runs and consecutive update patches in the granular reconcile path
|
|
12
|
+
- Perf optimisations on the `each()` / `mount()` update path; benchmarks now competitive with Solid/Vue on swap/remove/clear
|
|
13
|
+
- Preserve uncontrolled `<details open>` and `<dialog open>` state across re-renders
|
|
14
|
+
- `each()` now reconciles correctly when list rows have non-list siblings under the same parent
|
|
15
|
+
- Enforce the "exactly one top-level element per row" contract in `each()` with clearer errors
|
|
16
|
+
- Typed JSX `IntrinsicElements` table; custom elements extend it via declaration merging
|
|
17
|
+
- JSX runtime hardens URL attributes against `javascript:` XSS
|
|
18
|
+
- Widen `mount()` return type for better tooling/typed usage
|
|
19
|
+
- Add `kerfjs/testing` subpath exposing `clearStoreRegistry` for unit-test isolation
|
|
20
|
+
|
|
21
|
+
## [0.4.2] - 2026-05-09
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
- No user-facing changes in this release.
|
|
25
|
+
|
|
26
|
+
## [0.4.1] - 2026-05-09
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
- Just fixing the build
|
|
30
|
+
|
|
31
|
+
## [0.4.0] - 2026-05-09
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
- Auto-promote known non-bubbling events (focus, blur, scroll, etc.) to capture phase in `delegate()`
|
|
35
|
+
|
|
36
|
+
## [0.4.0] - 2026-05-09
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
- `delegate()` now auto-promotes the seven well-known non-bubbling events (`focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave`) to capture phase, with `closest()`-style selector matching preserved
|
|
40
|
+
- Fixed focus and caret position loss when reordering keyed `each()` rows on engines that drop focus on `insertBefore` (older Safari, happy-dom)
|
|
41
|
+
- `mount()` now throws a descriptive error when the root element is null/undefined instead of a generic "Cannot set properties of null"
|
|
42
|
+
- `each()` now throws a descriptive error naming the offending index when an item is a primitive (per-item cache requires objects)
|
|
43
|
+
- Minimum Node version bumped to 22.12+
|
|
44
|
+
- New Starlight-powered docs site at `/kerf/` with inline live examples and runnable complete apps; the reactivity demo moved to `/kerf/demo/`
|
|
45
|
+
- New kerf brand identity: production logo, full favicon set (SVG + PNG + ICO + Apple touch icon), and PWA manifest
|
|
46
|
+
|
|
7
47
|
## [0.3.1] - 2026-05-08
|
|
8
48
|
|
|
9
49
|
|
|
@@ -41,11 +81,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
41
81
|
|
|
42
82
|
### Fixed
|
|
43
83
|
|
|
84
|
+
- **Focus was sometimes lost on `each()` reorders (KF-65).** When the keyed list reconciler moved a row whose descendant held focus, `insertBefore` blurred the element on engines that don't preserve focus across DOM moves (older Safari, happy-dom). The element survived in the live tree, but `document.activeElement` reverted to `<body>` and the user's typing was interrupted. The reconciler now snapshots the active element + its selection range (when applicable) before the move pass and re-applies them after, so focus and caret position survive a reorder uniformly across engines. Engines that already preserve focus across moves see a no-op — the snapshot only takes effect when the active element changed. `docs/4-render.md` §4.4 and `docs/8-api-reference.md` updated. New regression tests in `tests/unit/mount.test.ts` cover reorder, top-insert, focused-row removal, non-text focused elements, selection-API rejection (e.g. `type=number`), and the "active element is outside the list" path.
|
|
44
85
|
- **`Fragment` was missing from the `kerfjs` barrel (KF-24).** `Fragment` was implemented in `src/jsx-runtime.ts`, exported from `kerfjs/jsx-runtime`, and present in the shared chunk — but the barrel `src/index.ts` didn't re-export it. Importing `Fragment` from `'kerfjs'` resolved to `undefined`, so a manual `<Fragment>...</Fragment>` rendered as `<undefined>...</undefined>`. The `<>...</>` shorthand was unaffected because the JSX transform pulls `Fragment` from `kerfjs/jsx-runtime` directly. Added `Fragment` to the barrel re-export, and pinned the entire public-API contract with a new `tests/dist/barrel-completeness.test.ts` so any future omission fails CI loudly. Docs updated to list `Fragment` in the public API surface (`CLAUDE.md`, `llms.txt`, `docs/ai/usage-guide.md`, `docs/ai/code-summary.md`, `docs/6-jsx-runtime.md`, `docs/8-api-reference.md`).
|
|
45
86
|
- **Focused contenteditable was being morphed, clobbering in-progress edits (KF-19).** The docs claimed contenteditable elements got focus + selection preservation alongside `<input>` and `<textarea>`, but the implementation only handled the latter two — a focused contenteditable's typed content was overwritten by morphdom on the next re-render. `mount()` now short-circuits the morph entirely when the active element is a contenteditable (same mechanism as `data-morph-skip`), so the user's edit, caret position, and any multi-range selection survive verbatim. Attribute updates are deferred until the next render after blur — that's the explicit trade-off, and matches what you want for in-progress rich-text editing. `docs/4-render.md` §4.4 and `docs/8-api-reference.md` §8.7 updated to describe the per-element-kind behaviour. The check uses the `contenteditable` attribute directly (the spec's source of truth) rather than the derived `isContentEditable` property, so test environments that don't populate the latter still get correct behaviour.
|
|
46
87
|
- **`clearStoreRegistry` was a no-op in the published bundle (KF-15).** `dist/testing.js` shipped an empty function body. Root cause: `tsup` bundled each entry independently with `splitting: false`, so the testing entry tree-shook the module-level `REGISTRY` array out as unreferenced — leaving `REGISTRY.length = 0` as dead code. Same root cause as KF-14. Fixed by enabling `splitting: true` in `tsup.config.ts`: shared modules now live in chunk files that all entries import, so `defineStore`'s registry and `clearStoreRegistry`'s reference are the same array. Side benefit: the duplicate `SafeHtml` class definition is gone too — there's now exactly one copy across the whole dist. Build output now includes `dist/chunk-*.js` files (covered by the existing `"files": ["dist"]` in `package.json`). New regression test in `tests/dist/store-registry-shared.test.ts` exercises the cross-entry registry from the built bundles.
|
|
47
88
|
- **`SafeHtml` cross-bundle identity (KF-14).** When a consumer's bundler ended up loading two copies of kerf — for example, the barrel (`kerfjs`) and the JSX-runtime entry (`kerfjs/jsx-runtime`) resolving as separate modules — `instanceof SafeHtml` failed inside the JSX runtime because the two `SafeHtml` classes were structurally identical but referentially distinct. The renderer would then throw `JSX: unsupported child of type object (SafeHtml)` on perfectly valid JSX. `SafeHtml` instances now carry a `Symbol.for('kerfjs.SafeHtml')` brand and the runtime checks for the brand instead of using `instanceof`. New unit tests simulate the duplicate-class scenario, and a new `npm run test:dist` job exercises the actual built bundles in CI.
|
|
48
89
|
|
|
90
|
+
### Changed
|
|
91
|
+
|
|
92
|
+
- **`delegate()` now auto-promotes the well-known non-bubbling event types to capture phase (KF-56).** `focus`, `blur`, `scroll`, `load`, `error`, `mouseenter`, `mouseleave` previously needed `delegateCapture()`; with auto-promotion the call site is identical to bubbling events. Selector matching stays `closest()`-style for every type, including the auto-promoted ones — so `delegate(root, 'focus', '.field-row', ...)` fires when a descendant `<input>` is focused, with the row as the matched element (not the input). `delegateCapture()` remains as the explicit-capture escape hatch with its `target.matches()`-style direct matching. No bundle-size change worth measuring. `docs/5-event-delegation.md` and `docs/8-api-reference.md` §8.4 updated. New unit tests in `tests/unit/delegate.test.ts` cover the `closest()` walk-up on auto-promoted events, the disposer path, and a phase-check for bubbling events to confirm the promotion is type-gated.
|
|
93
|
+
|
|
49
94
|
### Added
|
|
50
95
|
|
|
51
96
|
- **`each(items, render, key?)` list primitive** exported from `kerfjs`. Keyed list iteration with per-item memoisation: skips re-running `render` for items whose object identity (and optional `key`) are unchanged since the previous call. Targets the partial-update / select-row / swap-rows perf path, where today's `mount()` re-runs the render for the full list on any signal change. On the js-framework-benchmark suite this drops kerfjs's partial-update from 87 → 64 ms (-27%), select-row from 69 → 42 ms (-38%), swap-rows from 86 → 58 ms (-33%), and remove-row from 49 → 35 ms (-29%); creates and bundle size are unaffected (+0.2 KB gz for the WeakMap memoiser). See `docs/8-api-reference.md` §8.3 and `bench/` for the benchmark harness.
|
package/README.md
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./site/src/assets/logo.svg" alt="Kerf logo" width="96" height="96" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
>
|
|
5
|
+
<h1 align="center">Kerf</h1>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p align="center"><em>The smallest cut.</em></p>
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
> Introducing Kerf.
|
|
12
|
+
> The smallest cut.
|
|
13
|
+
>
|
|
14
|
+
> 6.1 KB. No virtual DOM. No compiler. No magic.
|
|
15
|
+
> Reactive UI that touches only the bytes that changed.
|
|
8
16
|
|
|
9
17
|
```ts
|
|
10
18
|
import { signal, mount } from 'kerfjs';
|
|
@@ -19,41 +27,42 @@ mount(document.getElementById('app')!, () => (
|
|
|
19
27
|
));
|
|
20
28
|
```
|
|
21
29
|
|
|
22
|
-
That's it.
|
|
30
|
+
That's it. Your JSX renders to HTML strings, kerf's native diff applies the minimum DOM mutations to make the live tree match, and signals re-run the render only when something they read actually changed.
|
|
23
31
|
|
|
24
|
-
## Why
|
|
32
|
+
## Why Kerf
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
1. **Built for the AI-assisted era.** Tiny public surface (15 exports), no compiler magic, no hidden lifecycle. An LLM holds the framework in context and predicts behaviour — your AI agent generates code that works the first time. Ships [`llms.txt`](./llms.txt) and a dedicated AI usage guide; the [Built by an AI · Pomodoro](https://brianwestphal.github.io/kerf/examples/complete/built-by-an-ai/) example is a working app one-shotted by Claude with `llms.txt` as its only kerf knowledge.
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
- **Stores** built on signals — composable, testable units of state.
|
|
30
|
-
- **Render** — a `mount(el, () => jsx)` helper that diffs the new HTML against the live DOM with kerf's native, segment-aware reconciler. Preserves focus, selection, in-flight pointer interactions, and event listeners on identity-preserved nodes. Lists rendered with `each(...)` go through a keyed reconciler that does O(changes) work, not O(rows).
|
|
31
|
-
- **Event delegation** — small `delegate` / `delegateCapture` helpers that survive every re-render because they live on the morph root, not on individual nodes.
|
|
36
|
+
2. **Smallest cut.** 6.1 KB gzipped including signals (6.5 KB with `arraySignal`). Fine-grained reactivity re-runs only what changed; the diff touches only the DOM nodes that differ. On the [krausest js-framework-benchmark](./bench/results.md) kerf is competitive with Solid and Vue on swap-rows, remove-row, and clear — no compiler required.
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
3. **No virtual DOM, no compiler.** JSX → HTML strings → native diff. DevTools shows the real DOM because it *is* the DOM.
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
4. **Focus, selection, listeners survive re-renders.** We morph instead of rebuilding — your caret stays where you put it, your in-progress drag keeps moving, your delegated handlers keep firing.
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
npm install kerfjs
|
|
39
|
-
```
|
|
42
|
+
5. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain.
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
## When to use Kerf
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
- **AI-generated apps** — your LLM/agent holds the framework in context; no hallucinated APIs.
|
|
47
|
+
- **Hybrid desktop apps (Tauri / Electron)** — small bundle, predictable diff, debuggable runtime; ideal for the embedded webview.
|
|
48
|
+
- **Embedded widgets** — chat bubbles, comment boxes, dashboards dropped into someone else's page.
|
|
49
|
+
- **Server-rendered apps with islands** — Rails / Phoenix / Django / Hono. `mount` per island; `delegate` survives turbo-frame swaps.
|
|
50
|
+
- **Admin panels & internal tools** — reactivity without 200 KB of framework + state lib + router.
|
|
51
|
+
- **Replacing jQuery** — incremental migration; same delegation mental model, modern primitives.
|
|
52
|
+
- **Prototyping** — entire mental model on a postcard.
|
|
53
|
+
|
|
54
|
+
### When to reach for something else
|
|
55
|
+
|
|
56
|
+
- Need a full ecosystem (router + forms + data + SSR streaming) → **Next.js / Remix / SolidStart**.
|
|
57
|
+
- Building a deeply componentised design-system app → **React / Solid / Svelte**.
|
|
58
|
+
- Need React Native / cross-platform mobile → **React** (Kerf + Tauri/Electron also covers many of these cases).
|
|
59
|
+
- Building a static site → **Astro** (we use it for *this* project's site).
|
|
60
|
+
- Already invested in a framework where switching cost outweighs the ~6 KB win.
|
|
52
61
|
|
|
53
62
|
## Quick tour
|
|
54
63
|
|
|
55
64
|
```ts
|
|
56
|
-
import { signal, computed, effect, defineStore, mount, delegate } from 'kerfjs';
|
|
65
|
+
import { signal, computed, effect, defineStore, mount, each, delegate } from 'kerfjs';
|
|
57
66
|
|
|
58
67
|
// 1. A signal — single piece of reactive state.
|
|
59
68
|
const count = signal(0);
|
|
@@ -77,12 +86,16 @@ mount(root, () => (
|
|
|
77
86
|
<div>
|
|
78
87
|
<h1>Cart ({cart.state.value.items.length})</h1>
|
|
79
88
|
<ul>
|
|
80
|
-
{
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
{each(
|
|
90
|
+
cart.state.value.items,
|
|
91
|
+
(item) => (
|
|
92
|
+
<li>
|
|
93
|
+
{item.name}
|
|
94
|
+
<button data-action="remove" data-id={item.id}>×</button>
|
|
95
|
+
</li>
|
|
96
|
+
),
|
|
97
|
+
(item) => item.id,
|
|
98
|
+
)}
|
|
86
99
|
</ul>
|
|
87
100
|
<p>Doubled count: {doubled.value}</p>
|
|
88
101
|
</div>
|
|
@@ -94,40 +107,59 @@ delegate(root, 'click', '[data-action="remove"]', (_e, btn) => {
|
|
|
94
107
|
});
|
|
95
108
|
```
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
The numbered docs in [`docs/`](./docs/) cover the design and rationale:
|
|
110
|
+
### Long keyed lists: `arraySignal`
|
|
100
111
|
|
|
101
|
-
|
|
102
|
-
2. [Reactivity](./docs/2-reactivity.md) — `signal`, `computed`, `effect`, `batch`.
|
|
103
|
-
3. [Stores](./docs/3-stores.md) — `defineStore`, `resetAllStores`.
|
|
104
|
-
4. [Render](./docs/4-render.md) — `mount`, segments, the native diff, and the list reconciler.
|
|
105
|
-
5. [Event delegation](./docs/5-event-delegation.md) — Tier 1 / Tier 2 / Tier 3 patterns.
|
|
106
|
-
6. [JSX runtime](./docs/6-jsx-runtime.md) — `SafeHtml`, `raw`, server-rendering.
|
|
107
|
-
7. [SVG handling](./docs/7-svg.md) — namespace propagation, `toElement`.
|
|
108
|
-
8. [API reference](./docs/8-api-reference.md) — every export, every option.
|
|
109
|
-
9. [Live demo](./docs/9-live-demo.md) — the GitHub Pages deploy of `examples/reactivity-demo`.
|
|
112
|
+
For lists where most updates are pointwise (single-row edits, append-to-end, selection flips on individual rows), reach for `arraySignal` from the `kerfjs/array-signal` subpath. Mutators emit typed patches that `each()` applies in O(patches), not O(N):
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
```ts
|
|
115
|
+
import { arraySignal } from 'kerfjs/array-signal';
|
|
112
116
|
|
|
113
|
-
|
|
117
|
+
const rows = arraySignal<{ id: number; label: string }>([]);
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
mount(root, () => (
|
|
120
|
+
<ul>{each(rows, (r) => <li data-key={r.id}>{r.label}</li>)}</ul>
|
|
121
|
+
));
|
|
117
122
|
|
|
118
|
-
|
|
123
|
+
rows.push({ id: 1, label: 'a' }); // 1 insert patch
|
|
124
|
+
rows.update(0, (r) => ({ ...r, label: 'A' })); // 1 update patch
|
|
125
|
+
rows.move(0, 1); // 1 move patch
|
|
126
|
+
```
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
The class lives in its own subpath so apps that don't need it shed ~1 KB. Reads on `rows.value` are tracking, so `computed(() => rows.value.filter(...))` works as expected. See [`docs/2-reactivity.md`](./docs/2-reactivity.md) §2.6.
|
|
121
129
|
|
|
122
|
-
|
|
130
|
+
## Install
|
|
123
131
|
|
|
124
132
|
```bash
|
|
125
|
-
npm
|
|
133
|
+
npm install kerfjs
|
|
126
134
|
```
|
|
127
135
|
|
|
136
|
+
```jsonc
|
|
137
|
+
// tsconfig.json
|
|
138
|
+
{
|
|
139
|
+
"compilerOptions": {
|
|
140
|
+
"jsx": "react-jsx",
|
|
141
|
+
"jsxImportSource": "kerfjs"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Links
|
|
147
|
+
|
|
148
|
+
- **Site:** [brianwestphal.github.io/kerf](https://brianwestphal.github.io/kerf/)
|
|
149
|
+
- **Docs:** [`docs/`](./docs/) — overview · reactivity · stores · render · events · jsx · svg · [API reference](./docs/8-api-reference.md)
|
|
150
|
+
- **AI guide:** [`docs/ai/usage-guide.md`](./docs/ai/usage-guide.md) — read once before writing kerf code with an LLM
|
|
151
|
+
- **Demo:** [live demo](https://brianwestphal.github.io/kerf/demo/) — eight sections exercising every primitive (counter, store-backed cart, focus survival, keyed list, morph-skip, SVG render, Tier-2 capture, `arraySignal` patches)
|
|
152
|
+
- **Repo:** [github.com/brianwestphal/kerf](https://github.com/brianwestphal/kerf)
|
|
153
|
+
|
|
154
|
+
## Why "kerf"?
|
|
155
|
+
|
|
156
|
+
A *kerf* is the narrow strip of material a saw blade removes when cutting — the smallest possible cut. The framework's job is the same: apply the smallest possible mutation to update your DOM.
|
|
157
|
+
|
|
158
|
+
(And yes, ~~kerformance~~ → *performance* jokes were written. They were also rejected.)
|
|
159
|
+
|
|
128
160
|
## Status
|
|
129
161
|
|
|
130
|
-
|
|
162
|
+
Pre-1.0 — API may evolve. See [CHANGELOG.md](./CHANGELOG.md) for the current version and what's shipped.
|
|
131
163
|
|
|
132
164
|
## License
|
|
133
165
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `arraySignal(initial)` — granular collection signal.
|
|
3
|
+
*
|
|
4
|
+
* A keyed-list-friendly variant of `signal()` that emits typed patch events
|
|
5
|
+
* for every mutation (update / insert / remove / move / replace). When such
|
|
6
|
+
* a signal is bound to `each(...)` inside a `mount()`, the keyed list
|
|
7
|
+
* reconciler applies just the patches against the live DOM — no per-item
|
|
8
|
+
* iteration, no `classifyItems` Map build, no LIS pass over unchanged rows.
|
|
9
|
+
*
|
|
10
|
+
* const rows = arraySignal<Row>([]);
|
|
11
|
+
*
|
|
12
|
+
* rows.update(42, (r) => ({ ...r, label: 'changed' })); // 1 update event
|
|
13
|
+
* rows.insert(0, { id: 'x', ... }); // 1 insert event
|
|
14
|
+
* rows.remove(7); // 1 remove event
|
|
15
|
+
* rows.move(3, 0); // 1 move event
|
|
16
|
+
* rows.replace([...]); // falls back to snapshot reconcile
|
|
17
|
+
*
|
|
18
|
+
* Read-side semantics match a regular signal: `arraySig.value` is a
|
|
19
|
+
* snapshot, and reads inside `effect()` / `computed()` register as
|
|
20
|
+
* dependencies, so derived values keep working.
|
|
21
|
+
*/
|
|
22
|
+
/** A single granular mutation event. */
|
|
23
|
+
type ArrayPatch<T> = {
|
|
24
|
+
type: 'update';
|
|
25
|
+
index: number;
|
|
26
|
+
item: T;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'insert';
|
|
29
|
+
index: number;
|
|
30
|
+
item: T;
|
|
31
|
+
} | {
|
|
32
|
+
type: 'remove';
|
|
33
|
+
index: number;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'move';
|
|
36
|
+
from: number;
|
|
37
|
+
to: number;
|
|
38
|
+
} | {
|
|
39
|
+
type: 'replace';
|
|
40
|
+
items: readonly T[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Cross-bundle brand for `ArraySignal` instances. `each()` and the
|
|
44
|
+
* granular reconciler check for this brand instead of `instanceof
|
|
45
|
+
* ArraySignal`, so the main `kerfjs` barrel can detect arraySignal
|
|
46
|
+
* inputs without importing the class at runtime — the class lives
|
|
47
|
+
* only in the `kerfjs/array-signal` subpath, so apps that don't need
|
|
48
|
+
* granular collections shed ~1 KB.
|
|
49
|
+
*
|
|
50
|
+
* Same `Symbol.for(...)`-based pattern as `SafeHtml` (KF-14): cross-
|
|
51
|
+
* bundle-safe, zero-cost runtime check.
|
|
52
|
+
*/
|
|
53
|
+
declare const ARRAY_SIGNAL_BRAND: unique symbol;
|
|
54
|
+
declare class ArraySignal<T> {
|
|
55
|
+
private _items;
|
|
56
|
+
private _version;
|
|
57
|
+
private _patches;
|
|
58
|
+
readonly [ARRAY_SIGNAL_BRAND]: true;
|
|
59
|
+
constructor(initial?: readonly T[]);
|
|
60
|
+
/** Read-only snapshot. Reads inside an effect/computed register a dependency. */
|
|
61
|
+
get value(): readonly T[];
|
|
62
|
+
/** Replace the item at `index` with `fn(currentItem)`. Emits one `update` patch. */
|
|
63
|
+
update(index: number, fn: (item: T) => T): void;
|
|
64
|
+
/** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */
|
|
65
|
+
insert(index: number, item: T): void;
|
|
66
|
+
/** Append `item` at the end. Sugar for `insert(items.length, item)`. */
|
|
67
|
+
push(item: T): void;
|
|
68
|
+
/** Remove and return the item at `index`. Emits one `remove` patch. */
|
|
69
|
+
remove(index: number): T;
|
|
70
|
+
/** Move the item at `from` to position `to`. Emits one `move` patch (no-op when from === to). */
|
|
71
|
+
move(from: number, to: number): void;
|
|
72
|
+
/** Replace every item. Emits one `replace` patch — the granular reconciler falls back to a full keyed diff for this case. */
|
|
73
|
+
replace(items: readonly T[]): void;
|
|
74
|
+
/**
|
|
75
|
+
* @internal Used by `each()` when binding this signal to a list. Returns
|
|
76
|
+
* the queue of granular patches issued since the previous call, then
|
|
77
|
+
* clears the queue. Best paired with a single binding — a second consumer
|
|
78
|
+
* in the same render gets an empty array (which forces the snapshot
|
|
79
|
+
* fall-back path, which is correct but slower).
|
|
80
|
+
*/
|
|
81
|
+
_consumePatches(): ArrayPatch<T>[];
|
|
82
|
+
}
|
|
83
|
+
/** Construct an array signal seeded with `initial`. */
|
|
84
|
+
declare function arraySignal<T>(initial?: readonly T[]): ArraySignal<T>;
|
|
85
|
+
|
|
86
|
+
export { ARRAY_SIGNAL_BRAND, type ArrayPatch, ArraySignal, arraySignal };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { signal } from './chunk-FN2ID4QO.js';
|
|
2
|
+
|
|
3
|
+
// src/array-signal.ts
|
|
4
|
+
var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
|
|
5
|
+
var ArraySignal = class {
|
|
6
|
+
_items;
|
|
7
|
+
_version;
|
|
8
|
+
_patches;
|
|
9
|
+
// Branded so `isArraySignal()` recognises instances from any copy of this module.
|
|
10
|
+
[ARRAY_SIGNAL_BRAND] = true;
|
|
11
|
+
constructor(initial = []) {
|
|
12
|
+
this._items = [...initial];
|
|
13
|
+
this._version = signal(0);
|
|
14
|
+
this._patches = [];
|
|
15
|
+
}
|
|
16
|
+
/** Read-only snapshot. Reads inside an effect/computed register a dependency. */
|
|
17
|
+
get value() {
|
|
18
|
+
void this._version.value;
|
|
19
|
+
return this._items;
|
|
20
|
+
}
|
|
21
|
+
/** Replace the item at `index` with `fn(currentItem)`. Emits one `update` patch. */
|
|
22
|
+
update(index, fn) {
|
|
23
|
+
if (index < 0 || index >= this._items.length) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`arraySignal.update: index ${index} out of bounds [0, ${this._items.length}).`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const next = fn(this._items[index]);
|
|
29
|
+
this._items[index] = next;
|
|
30
|
+
this._patches.push({ type: "update", index, item: next });
|
|
31
|
+
this._version.value++;
|
|
32
|
+
}
|
|
33
|
+
/** Insert `item` at `index`. Existing items at index..N shift right. Emits one `insert` patch. */
|
|
34
|
+
insert(index, item) {
|
|
35
|
+
if (index < 0 || index > this._items.length) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`arraySignal.insert: index ${index} out of bounds [0, ${this._items.length}].`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
this._items.splice(index, 0, item);
|
|
41
|
+
this._patches.push({ type: "insert", index, item });
|
|
42
|
+
this._version.value++;
|
|
43
|
+
}
|
|
44
|
+
/** Append `item` at the end. Sugar for `insert(items.length, item)`. */
|
|
45
|
+
push(item) {
|
|
46
|
+
this.insert(this._items.length, item);
|
|
47
|
+
}
|
|
48
|
+
/** Remove and return the item at `index`. Emits one `remove` patch. */
|
|
49
|
+
remove(index) {
|
|
50
|
+
if (index < 0 || index >= this._items.length) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`arraySignal.remove: index ${index} out of bounds [0, ${this._items.length}).`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const [removed] = this._items.splice(index, 1);
|
|
56
|
+
this._patches.push({ type: "remove", index });
|
|
57
|
+
this._version.value++;
|
|
58
|
+
return removed;
|
|
59
|
+
}
|
|
60
|
+
/** Move the item at `from` to position `to`. Emits one `move` patch (no-op when from === to). */
|
|
61
|
+
move(from, to) {
|
|
62
|
+
if (from === to) return;
|
|
63
|
+
if (from < 0 || from >= this._items.length || to < 0 || to >= this._items.length) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`arraySignal.move: indices out of bounds (from=${from}, to=${to}, length=${this._items.length}).`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const [item] = this._items.splice(from, 1);
|
|
69
|
+
this._items.splice(to, 0, item);
|
|
70
|
+
this._patches.push({ type: "move", from, to });
|
|
71
|
+
this._version.value++;
|
|
72
|
+
}
|
|
73
|
+
/** Replace every item. Emits one `replace` patch — the granular reconciler falls back to a full keyed diff for this case. */
|
|
74
|
+
replace(items) {
|
|
75
|
+
this._items = [...items];
|
|
76
|
+
this._patches.push({ type: "replace", items: this._items });
|
|
77
|
+
this._version.value++;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* @internal Used by `each()` when binding this signal to a list. Returns
|
|
81
|
+
* the queue of granular patches issued since the previous call, then
|
|
82
|
+
* clears the queue. Best paired with a single binding — a second consumer
|
|
83
|
+
* in the same render gets an empty array (which forces the snapshot
|
|
84
|
+
* fall-back path, which is correct but slower).
|
|
85
|
+
*/
|
|
86
|
+
_consumePatches() {
|
|
87
|
+
const out = this._patches;
|
|
88
|
+
this._patches = [];
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
function arraySignal(initial = []) {
|
|
93
|
+
return new ArraySignal(initial);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { ARRAY_SIGNAL_BRAND, ArraySignal, arraySignal };
|
|
97
|
+
//# sourceMappingURL=array-signal.js.map
|
|
98
|
+
//# sourceMappingURL=array-signal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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 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()` recognises 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 /** Replace the item at `index` with `fn(currentItem)`. Emits one `update` patch. */\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 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 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"chunk-FN2ID4QO.js","sourcesContent":[]}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import { signal } from '
|
|
2
|
-
export { batch, computed, effect, signal } from '@preact/signals-core';
|
|
3
|
-
|
|
4
|
-
// src/reactive.ts
|
|
1
|
+
import { signal } from './chunk-FN2ID4QO.js';
|
|
5
2
|
|
|
6
3
|
// src/store.ts
|
|
7
4
|
var REGISTRY = [];
|
|
@@ -30,5 +27,5 @@ function clearStoreRegistry() {
|
|
|
30
27
|
}
|
|
31
28
|
|
|
32
29
|
export { clearStoreRegistry, defineStore, resetAllStores };
|
|
33
|
-
//# sourceMappingURL=chunk-
|
|
34
|
-
//# sourceMappingURL=chunk-
|
|
30
|
+
//# sourceMappingURL=chunk-GQGJFCWL.js.map
|
|
31
|
+
//# sourceMappingURL=chunk-GQGJFCWL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/store.ts"],"names":[],"mappings":";;;AAqCA,IAAM,WAAyC,EAAC;AAEzC,SAAS,YACd,IAAA,EACyB;AACzB,EAAA,MAAM,QAAA,GAA2B,MAAA,CAAO,IAAA,CAAK,OAAA,EAAS,CAAA;AAEtD,EAAA,MAAM,GAAA,GAAM,CAAC,IAAA,KAAuB;AAClC,IAAA,QAAA,CAAS,KAAA,GAAQ,IAAA;AAAA,EACnB,CAAA;AACA,EAAA,MAAM,GAAA,GAAM,MAAc,QAAA,CAAS,KAAA;AAEnC,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,OAAA,CAAQ,GAAA,EAAK,GAAG,CAAA;AAErC,EAAA,MAAM,KAAA,GAAiC;AAAA,IACrC,KAAA,EAAO,QAAA;AAAA,IACP,OAAA;AAAA,IACA,KAAA,GAAQ;AACN,MAAA,QAAA,CAAS,KAAA,GAAQ,KAAK,OAAA,EAAQ;AAAA,IAChC;AAAA,GACF;AAEA,EAAA,QAAA,CAAS,KAAK,KAAK,CAAA;AACnB,EAAA,OAAO,KAAA;AACT;AAOO,SAAS,cAAA,GAAuB;AACrC,EAAA,KAAA,MAAW,CAAA,IAAK,QAAA,EAAU,CAAA,CAAE,KAAA,EAAM;AACpC;AAMO,SAAS,kBAAA,GAA2B;AACzC,EAAA,QAAA,CAAS,MAAA,GAAS,CAAA;AACpB","file":"chunk-GQGJFCWL.js","sourcesContent":["/**\n * `defineStore({ initial, actions })` — composable testable stores layered on\n * top of `reactive.ts`'s signals.\n *\n * Three rules:\n * 1. `state` is read-only. Consumers read via `state.value` or subscribe via\n * `effect()`. They cannot write directly.\n * 2. `actions` is the only mutation surface. All writes go through named\n * action functions. This is what makes stores testable — assert against\n * actions, not against arbitrary writes.\n * 3. `reset()` resets to `initial()`. Always defined; tests use it for\n * setup, lifecycle hooks (route change, sign-out, etc.) use it for\n * tear-down.\n *\n * A module-level registry tracks every store created via `defineStore()`;\n * `resetAllStores()` walks the registry and calls each `reset()`. Useful for\n * tests + project-switch / logout / route-reset scenarios where every piece\n * of client state should return to its initial shape.\n */\n\nimport type { ReadonlySignal, Signal } from './reactive.js';\nimport { signal } from './reactive.js';\n\nexport interface Store<TState, TActions> {\n /** Read-only reactive view. Consumers read `state.value` or subscribe via `effect()`. */\n readonly state: ReadonlySignal<TState>;\n /** Named mutators — the only way to change state. */\n readonly actions: TActions;\n /** Reset state to `initial()`. Used by tests and lifecycle hooks. */\n reset(): void;\n}\n\ninterface DefineStoreSpec<TState, TActions> {\n initial: () => TState;\n actions: (set: (next: TState) => void, get: () => TState) => TActions;\n}\n\nconst REGISTRY: Array<{ reset: () => void }> = [];\n\nexport function defineStore<TState, TActions>(\n spec: DefineStoreSpec<TState, TActions>,\n): Store<TState, TActions> {\n const internal: Signal<TState> = signal(spec.initial());\n\n const set = (next: TState): void => {\n internal.value = next;\n };\n const get = (): TState => internal.value;\n\n const actions = spec.actions(set, get);\n\n const store: Store<TState, TActions> = {\n state: internal,\n actions,\n reset() {\n internal.value = spec.initial();\n },\n };\n\n REGISTRY.push(store);\n return store;\n}\n\n/**\n * Reset every store registered via `defineStore()` to its `initial()` value.\n * Used by tests and by application lifecycle hooks (project switch, logout,\n * route reset).\n */\nexport function resetAllStores(): void {\n for (const s of REGISTRY) s.reset();\n}\n\n/**\n * Test helper — clears the registry. Exposed via the `kerfjs/testing` subpath,\n * not the main `kerfjs` entry. Unit tests use it to isolate stores between cases.\n */\nexport function clearStoreRegistry(): void {\n REGISTRY.length = 0;\n}\n"]}
|
|
@@ -201,6 +201,9 @@ function raw(html) {
|
|
|
201
201
|
function listSafeHtml(id, items) {
|
|
202
202
|
return new SafeHtml({ kind: "list", id, items });
|
|
203
203
|
}
|
|
204
|
+
function granularListSafeHtml(id, items, patches) {
|
|
205
|
+
return new SafeHtml({ kind: "list", id, items, patches });
|
|
206
|
+
}
|
|
204
207
|
var VOID_TAGS = /* @__PURE__ */ new Set([
|
|
205
208
|
"area",
|
|
206
209
|
"base",
|
|
@@ -242,6 +245,8 @@ function describeValue(v) {
|
|
|
242
245
|
}
|
|
243
246
|
return typeof v;
|
|
244
247
|
}
|
|
248
|
+
var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "xlink:href", "formaction", "action"]);
|
|
249
|
+
var DANGEROUS_URL_RE = /^\s*(?:(?:java|vb)script:|data:text\/html[;,])/i;
|
|
245
250
|
function renderAttr(key, value) {
|
|
246
251
|
const name = ATTR_ALIASES[key] ?? key;
|
|
247
252
|
if (value == null || value === false) return "";
|
|
@@ -252,6 +257,12 @@ function renderAttr(key, value) {
|
|
|
252
257
|
} else if (typeof value === "number") {
|
|
253
258
|
strValue = String(value);
|
|
254
259
|
} else if (typeof value === "string") {
|
|
260
|
+
if (URL_ATTRS.has(name) && DANGEROUS_URL_RE.test(value)) {
|
|
261
|
+
console.warn(
|
|
262
|
+
`JSX: dropped dangerous URL value for ${name}=${JSON.stringify(value.slice(0, 80))}. kerf blocks javascript:, vbscript:, and data:text/html URLs in href/src/formaction/action/xlink:href by default. Wrap in raw() if this is intentional (e.g. bookmarklets), or sanitise upstream.`
|
|
263
|
+
);
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
255
266
|
strValue = escapeAttr(value);
|
|
256
267
|
} else {
|
|
257
268
|
throw new Error(
|
|
@@ -272,6 +283,6 @@ function Fragment({ children }) {
|
|
|
272
283
|
return new SafeHtml(children != null ? toSegment(children) : { kind: "static", html: "" });
|
|
273
284
|
}
|
|
274
285
|
|
|
275
|
-
export { Fragment, SafeHtml, collectLists, flatten, flattenWithoutListItems, isSafeHtml, jsx, listSafeHtml, raw };
|
|
276
|
-
//# sourceMappingURL=chunk-
|
|
277
|
-
//# sourceMappingURL=chunk-
|
|
286
|
+
export { Fragment, SafeHtml, collectLists, flatten, flattenWithoutListItems, granularListSafeHtml, isSafeHtml, jsx, listSafeHtml, raw };
|
|
287
|
+
//# sourceMappingURL=chunk-WYJTMERY.js.map
|
|
288
|
+
//# sourceMappingURL=chunk-WYJTMERY.js.map
|