kerfjs 4.2.0-beta.1 → 4.2.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,10 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
8
8
 
9
9
  - **`trustedRaw(html)`** (main barrel) — the intention-revealing, lint-exempt escape hatch for injecting a **server-trusted dynamic** value (a CSRF token, a trusted `<script src>`, a server-issued id). Identical to `raw()` at runtime, but because it isn't named `raw`, the `kerfjs/no-raw-with-dynamic-arg` rule leaves it alone — replacing scattered `eslint-disable` comments with one explicit call. Not a sanitizer; only pass values you control.
10
10
  - **`renderDocument(node, options?)`** (main barrel) — a tiny SSR helper that prepends the doctype to a rendered document, so server routes stop reinventing `"<!DOCTYPE html>" + page.toString()`. Takes a `SafeHtml` or string; optional `{ doctype }` (default `'html'`). Pure string work, no DOM dependency.
11
- - **New `kerfjs/list` subpath** — `bindList(parent, source, options)`, a keyed list distinct from `each()`: each row is individually `mount()`ed, so a signal a row reads updates just that row (fine-grained, no full-list pass), and it can **virtualize** the viewport (`virtualize: { rowHeight }` renders only visible rows, padding keeps `scrollHeight` honest). `source` is a `signal<readonly T[]>` or an `arraySignal<T>` — a non-virtualized `arraySignal` source applies its structural patches **granularly** (O(patches)), everything else uses a keyed diff (transparent optimization). Reach for it for surgical per-row updates or long/windowed lists; `each()` stays the default for item-owned-state lists rendered to HTML strings. Optional and tree-shakeable.
12
- - **New `kerfjs/async` subpath** — `resource<T>()` models async state (`{ status, data, error, progress }`) with the stale-response guard built in. You write the fetch (Node `fetch` for SSR, browser `fetch` client-side); `.run(fetcher)` drives `idle` → `running` → `completed`/`failed` and drops out-of-order responses (only the latest run resolves the state). It never rejects — a failure lands in `value.error` — keeps previous data across a re-run (stale-while-revalidate), and supports opt-in progress via a callback the fetcher receives. `value` is a tracking read. Signals only (no render core); tiny.
11
+ - **New `kerfjs/list` subpath** — `bindList(parent, source, options)`, a keyed list distinct from `each()`: each row is individually `mount()`ed, so a signal a row reads updates just that row (fine-grained, no full-list pass), and it can **virtualize** the viewport (`virtualize: { rowHeight }` renders only visible rows, padding keeps `scrollHeight` honest). `render(item)` returns a `MountResult` (**content mode** — kerf creates the row element and mounts your content inside it) **or** an `HTMLElement` / `{ el, dispose? }` (**element mode** — the element you return IS the row, so you own its tag / class / `data-*` / listeners; kerf keys / moves / reuses it and runs your `dispose` on removal or rebuild). A list may mix the two. `source` is a `signal<readonly T[]>` or an `arraySignal<T>` — a non-virtualized `arraySignal` source applies its structural patches **granularly** (O(patches)), everything else uses a keyed diff (transparent optimization). Reach for it for surgical per-row updates, app-owned row elements, or long/windowed lists; `each()` stays the default for item-owned-state lists rendered to HTML strings. Optional and tree-shakeable.
12
+ - **New `kerfjs/async` subpath** — `resource<T, I = void>()` models async state (`{ status, data, error, progress, input }`) with the stale-response guard built in. You write the fetch (Node `fetch` for SSR, browser `fetch` client-side); `.run(fetcher)` drives `idle` → `running` → `completed`/`failed` and drops out-of-order responses (only the latest run resolves the state). It never rejects — a failure lands in `value.error` — keeps previous data across a re-run (stale-while-revalidate), and supports opt-in progress via a callback the fetcher receives. The `.run(input, fetcher)` form threads the run's `input` to `value.input` for `running`/`completed`/`failed` (latest-wins under the stale guard), so a failure handler can recover **which request failed** (e.g. an inline error keyed by `value.input.fileId`) without reintroducing module-scope bookkeeping. Pass `resource({ cacheKey, equals })` for a real SWR section: **`cacheKey(input)`** keeps the last value **per key** (revisiting a loaded key paints its cached slice instantly while it revalidates; `reset()` clears the cache), and **`value.revision`** bumps only when `data` actually changes (by `equals`, default `Object.is`) so a consumer can skip a redundant paint (a poll returning identical data leaves it untouched). `value` is a tracking read. Signals only (no render core); tiny.
13
+ - **New `kerfjs/imperative` subpath** — `imperative(node, setup)`, a `useEffect`-with-cleanup bound to a single DOM node, purpose-built for the `data-morph-skip` escape hatch. `setup(node)` runs immediately and may return a teardown; the teardown runs once when the node leaves the document (detected by a `MutationObserver`, so a morph swap, a `remountOn` replacement, or any removal triggers it) or when the returned idempotent disposer is called. Turns the "library owns this subtree" convention into a supported seam with real lifecycle guarantees. Re-creation is handled by pairing with `kerfjs/remount`. DOM only (no signals, no render core) — the smallest subpath. Optional and tree-shakeable.
14
+ - **New `kerfjs/remount` subpath** — `remountOn(parent, key, render)`, the opposite of kerf's morph-by-default: **replace** a subtree wholesale when `key` changes instead of morphing it. Names the hand-rolled `data-key={`gen-${n}`}` + `data-morph-skip` counter trick, for library-owned subtrees (a highlighted diff, a chart, an editor) that must tear down and re-initialize on fresh DOM. `key` is a signal or a thunk `() => K`; an unchanged key (including a thunk whose inputs moved but whose value stayed equal) leaves the subtree alone, so per-row reactivity inside `render` still updates in place. An optional `onMount(root)` callback runs after each (re)mount with the live subtree — the place to bind an imperative widget (`kerfjs/imperative`) to the fresh DOM; returning `imperative`'s disposer from it makes teardown synchronous. `remountOn` owns `parent`'s children and returns a disposer. Optional and tree-shakeable.
15
+ - **New `kerfjs/timing` subpath** — the `let timer; clearTimeout(timer); timer = setTimeout(…)` pattern every app hand-rolls, blessed with disposer-shaped ergonomics. `debounce(fn, ms)` is trailing-edge (runs once `ms` after calls stop, latest args); `throttle(fn, ms)` is leading-plus-trailing (fires immediately, then at most once per `ms`, collapsing a burst to one trailing call). Both return a callable with `cancel()` / `flush()`. `debouncedSignal(source, ms)` is a read-only signal that trails `source` by `ms` so it composes inside the reactive graph (`computed`/`effect`/`mount`) instead of beside it. `debounce`/`throttle` are dependency-free; `debouncedSignal` pulls in signals only (no render core). Optional and tree-shakeable; tiny.
13
16
  - **New `kerfjs/scope` subpath** — tie disposers to a DOM element's lifetime, so append-heavy UIs stop leaking detached-but-subscribed effects/listeners. `disposeScope(el)` returns a WeakMap-keyed, accumulating scope whose `add(disposer)` (plus convenience `mount` / `effect` / `delegate` wrappers that register their own disposer) collects teardown; `dispose()` runs it all best-effort and idempotently. `disposeSubtree(root)` sweeps a subtree before removal; `observeRemovals(root)` installs one `MutationObserver` that auto-disposes on removal. No module-level mutable state. Optional and tree-shakeable.
14
- - **New `kerfjs/overlay` subpath** — the blessed modal/overlay + dismiss manager that every real kerf app hand-rolls. `overlay(content, options?)` appends a wrapper, `mount()`s content inside it (owning the disposal), wires dismissals (Escape / backdrop / outside-click, with `outsideIgnore`), a focus trap (`role="dialog"` / `aria-modal`, Tab wrap-around, restore-focus-on-close), and returns `{ el, close(result?), result }`. `confirm(message, options?)` is a promise-based `window.confirm` replacement (that global is a no-op in Tauri webviews); `toast(content, options?)` is an auto-dismissing notification. Structural only — kerf ships no CSS. Optional and tree-shakeable; shares the core with the main barrel via code-splitting.
17
+ - **New `kerfjs/overlay` subpath** — the blessed modal/overlay + dismiss manager that every real kerf app hand-rolls. `overlay(content, options?)` appends a wrapper, `mount()`s content inside it (owning the disposal), wires dismissals (Escape / backdrop / outside-click, with `outsideIgnore`), a focus trap (`role="dialog"` / `aria-modal`, Tab wrap-around, restore-focus-on-close), and returns `{ el, close(result?), result }`. `confirm(message, options?)` is a promise-based `window.confirm` replacement, and `prompt(message, options?)` → `Promise<string | null>` its `window.prompt` counterpart (both globals are no-ops in Tauri webviews); `form(fields, options?)` → `Promise<Record<string, string> | null>` collects a two-or-three-field dialog. `prompt`/`form` submit on Enter and take an inline `validate`; all auto-escape their content. `confirm` / `prompt` / `form` also accept a **`render` slot option** for design-system teams — return your own markup and spread the provided `ok`/`cancel` (+ `input`/`error`) wiring onto it; kerf keeps owning the promise, `validate`, Enter-submit, dismiss, focus-trap, and focus-restore, so you adopt the batteries-included dialogs without a CSS rewrite. `popover(anchor, content, options?)` → `OverlayHandle` is a non-modal **anchored** overlay: it positions the content relative to `anchor` (below by default, flipping above on viewport overflow, clamped horizontally), defaults to dismiss-on-outside with the anchor exempt, and repositions on scroll / resize. `popover`'s placement core is also exported standalone: **`positionAnchored(el, anchor, options?)`** (one-shot) and **`autoReposition(el, anchor, options?)`** (keeps an element positioned on scroll/resize, returns a disposer) position *your own* element with no overlay lifecycle. **`tooltip(anchor, content, options?)`** is a hover/focus-triggered, non-modal, auto-hiding tooltip built on them. `toast(content, options?)` now returns a **`ToastHandle` (`{ el, dismiss }`)** instead of a bare dismiss function, so callers can inspect the node, wire an action button, or run entrance/exit transitions; new options: `mode: 'replace'` (collapse a rapid sequence to the latest), `variant` (`'info'`/`'success'`/`'warning'` → a `${className}--${variant}` accent class), `enterClass` (added on the next animation frame for a CSS entrance) and `exitClass` + `exitDuration` (added on dismiss so CSS owns the exit; the node is removed after the delay). **Breaking (beta):** `toast()`'s return type changed from `() => void` to `{ el, dismiss }` — call `toast(...).dismiss()` or destructure `{ dismiss }`. Structural only — kerf ships no CSS. Optional and tree-shakeable; shares the core with the main barrel via code-splitting.
15
18
  - **New `kerfjs/actions` subpath** — the blessed delegated action-table helper. `action(value)` returns a `data-action` `AttrSpec` (a thin specialization of `attr()`); `delegateActions(root, eventType, table, options?)` wires a whole table of `data-action` handlers with one delegated listener (built on `delegate()`) and returns a disposer. Formalizes the most-reinvented idiom in real kerf apps — one `attr('data-action', …)` table as the single source of truth for both the JSX attribute and the delegate dispatch. Optional and tree-shakeable; adds nothing to the main barrel.
16
19
 
17
20
  ## [4.1.1] - 2026-08-14
package/dist/async.d.ts CHANGED
@@ -6,7 +6,7 @@ interface ResourceProgress {
6
6
  total: number;
7
7
  }
8
8
  /** The reactive state a {@link Resource} exposes. */
9
- interface ResourceState<T> {
9
+ interface ResourceState<T, I = void> {
10
10
  status: ResourceStatus;
11
11
  /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */
12
12
  data: T | undefined;
@@ -14,6 +14,40 @@ interface ResourceState<T> {
14
14
  error: unknown;
15
15
  /** Latest reported progress while running, or `undefined`. */
16
16
  progress: ResourceProgress | undefined;
17
+ /**
18
+ * The input of the LATEST run — the value passed to {@link Resource.run} as
19
+ * `run(input, fetcher)`. Set for `running`, `completed`, AND `failed` (same
20
+ * stale-guard rule as the rest of the state), so an effect can branch on
21
+ * `status === 'failed'` and still know which request failed. `undefined` in
22
+ * `idle`, and for the no-input `run(fetcher)` form.
23
+ */
24
+ input: I | undefined;
25
+ /**
26
+ * A monotonic counter that increments only when `data` actually CHANGES (by
27
+ * the resource's `equals`, default `Object.is`). Compare it against the value
28
+ * you last painted to skip a redundant re-render — e.g. a 30s poll returning
29
+ * identical data leaves `revision` untouched, so you can bail before wiping
30
+ * scroll / sort / hover state. Starts at `0`.
31
+ */
32
+ revision: number;
33
+ }
34
+ /** Construction options for {@link resource}. */
35
+ interface ResourceOptions<T, I = void> {
36
+ /**
37
+ * Derive a cache key from a run's `input`. When set, the resource keeps the
38
+ * last successful value PER key: starting a run for a key that was loaded
39
+ * before paints its cached slice immediately (still `running`) while the fetch
40
+ * revalidates in the background; a never-loaded key starts with no `data`.
41
+ * Without `cacheKey`, a run keeps the previous run's `data` (single-slot
42
+ * stale-while-revalidate), as before.
43
+ */
44
+ cacheKey?: (input: I) => string;
45
+ /**
46
+ * Equality used to decide whether `data` changed (drives `value.revision`).
47
+ * Default `Object.is`. Pass a structural comparison to dedup a poll that
48
+ * returns a fresh-but-equal object.
49
+ */
50
+ equals?: (a: T, b: T) => boolean;
17
51
  }
18
52
  /**
19
53
  * The fetcher passed to {@link Resource.run}. You own the transport. It receives
@@ -21,10 +55,14 @@ interface ResourceState<T> {
21
55
  * don't need progress (a plain `() => Promise<T>` is assignable here).
22
56
  */
23
57
  type ResourceFetcher<T> = (report: (completed: number, total: number) => void) => Promise<T>;
24
- /** An async-state container. Its `value` is a tracking read; drive UI off `value.status`. */
25
- interface Resource<T> {
58
+ /**
59
+ * An async-state container. Its `value` is a tracking read; drive UI off
60
+ * `value.status`. `I` is the run-input type — parametrize it (`resource<T, I>()`)
61
+ * to carry a typed `run(input, fetcher)` input through to `value.input`.
62
+ */
63
+ interface Resource<T, I = void> {
26
64
  /** Tracking read of the current {@link ResourceState}. */
27
- readonly value: ResourceState<T>;
65
+ readonly value: ResourceState<T, I>;
28
66
  /**
29
67
  * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding
30
68
  * against stale responses (only the latest run resolves the state). Never
@@ -32,10 +70,16 @@ interface Resource<T> {
32
70
  * `undefined` on failure) for callers who want to await it.
33
71
  */
34
72
  run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;
35
- /** Reset to `idle` (clearing data/error/progress) and invalidate any in-flight run. */
73
+ /**
74
+ * Run `fetcher` for a given `input`, exposing it as `value.input` for the
75
+ * `running`/`completed`/`failed` states of THIS run — so a failure handler can
76
+ * recover which request failed. Same stale guard: only the latest run resolves.
77
+ */
78
+ run(input: I, fetcher: ResourceFetcher<T>): Promise<T | undefined>;
79
+ /** Reset to `idle` (clearing data/error/progress/input, and the per-key cache) and invalidate any in-flight run. */
36
80
  reset(): void;
37
81
  }
38
82
  /** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */
39
- declare function resource<T>(): Resource<T>;
83
+ declare function resource<T, I = void>(options?: ResourceOptions<T, I>): Resource<T, I>;
40
84
 
41
- export { type Resource, type ResourceFetcher, type ResourceProgress, type ResourceState, type ResourceStatus, resource };
85
+ export { type Resource, type ResourceFetcher, type ResourceOptions, type ResourceProgress, type ResourceState, type ResourceStatus, resource };
package/dist/async.js CHANGED
@@ -2,18 +2,46 @@ import { signal } from './chunk-3APBEVHF.js';
2
2
  import './chunk-VVDJLWMP.js';
3
3
 
4
4
  // src/async.ts
5
- var IDLE = () => ({
6
- status: "idle",
7
- data: void 0,
8
- error: void 0,
9
- progress: void 0
10
- });
11
- function resource() {
12
- const state = signal(IDLE());
5
+ function resource(options = {}) {
6
+ const { cacheKey, equals } = options;
7
+ const eq = equals ?? Object.is;
8
+ const cache = /* @__PURE__ */ new Map();
9
+ let revision = 0;
10
+ let lastData;
11
+ const changed = (next) => (
12
+ // undefined transitions are handled by reference; two defined values by `eq`.
13
+ lastData === void 0 || next === void 0 ? lastData !== next : !eq(lastData, next)
14
+ );
15
+ const commit = (next) => {
16
+ if (changed(next)) {
17
+ revision++;
18
+ lastData = next;
19
+ }
20
+ return revision;
21
+ };
22
+ const state = signal({
23
+ status: "idle",
24
+ data: void 0,
25
+ error: void 0,
26
+ progress: void 0,
27
+ input: void 0,
28
+ revision: 0
29
+ });
13
30
  let generation = 0;
14
- function run(fetcher) {
31
+ function run(inputOrFetcher, maybeFetcher) {
32
+ const fetcher = maybeFetcher ?? inputOrFetcher;
33
+ const input = maybeFetcher === void 0 ? void 0 : inputOrFetcher;
34
+ const key = cacheKey !== void 0 && maybeFetcher !== void 0 ? cacheKey(input) : void 0;
35
+ const runningData = cacheKey !== void 0 ? key !== void 0 ? cache.get(key) : void 0 : state.value.data;
15
36
  const gen = ++generation;
16
- state.value = { ...state.value, status: "running", error: void 0, progress: void 0 };
37
+ state.value = {
38
+ status: "running",
39
+ data: runningData,
40
+ error: void 0,
41
+ progress: void 0,
42
+ input,
43
+ revision: commit(runningData)
44
+ };
17
45
  const report = (completed, total) => {
18
46
  if (gen === generation) {
19
47
  state.value = { ...state.value, progress: { completed, total } };
@@ -22,13 +50,21 @@ function resource() {
22
50
  return fetcher(report).then(
23
51
  (data) => {
24
52
  if (gen === generation) {
25
- state.value = { status: "completed", data, error: void 0, progress: void 0 };
53
+ if (key !== void 0) cache.set(key, data);
54
+ state.value = {
55
+ status: "completed",
56
+ data,
57
+ error: void 0,
58
+ progress: void 0,
59
+ input,
60
+ revision: commit(data)
61
+ };
26
62
  }
27
63
  return data;
28
64
  },
29
65
  (error) => {
30
66
  if (gen === generation) {
31
- state.value = { ...state.value, status: "failed", error, progress: void 0 };
67
+ state.value = { ...state.value, status: "failed", error, progress: void 0, input };
32
68
  }
33
69
  return void 0;
34
70
  }
@@ -36,7 +72,15 @@ function resource() {
36
72
  }
37
73
  function reset() {
38
74
  generation++;
39
- state.value = IDLE();
75
+ cache.clear();
76
+ state.value = {
77
+ status: "idle",
78
+ data: void 0,
79
+ error: void 0,
80
+ progress: void 0,
81
+ input: void 0,
82
+ revision: commit(void 0)
83
+ };
40
84
  }
41
85
  return {
42
86
  get value() {
package/dist/async.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/async.ts"],"names":[],"mappings":";;;;AA+DA,IAAM,OAAO,OAA4B;AAAA,EACvC,MAAA,EAAQ,MAAA;AAAA,EACR,IAAA,EAAM,MAAA;AAAA,EACN,KAAA,EAAO,MAAA;AAAA,EACP,QAAA,EAAU;AACZ,CAAA,CAAA;AAGO,SAAS,QAAA,GAA2B;AACzC,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAyB,IAAA,EAAS,CAAA;AAEhD,EAAA,IAAI,UAAA,GAAa,CAAA;AAEjB,EAAA,SAAS,IAAI,OAAA,EAAqD;AAChE,IAAA,MAAM,MAAM,EAAE,UAAA;AACd,IAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,GAAG,KAAA,CAAM,KAAA,EAAO,QAAQ,SAAA,EAAW,KAAA,EAAO,MAAA,EAAW,QAAA,EAAU,MAAA,EAAU;AAEzF,IAAA,MAAM,MAAA,GAAS,CAAC,SAAA,EAAmB,KAAA,KAAwB;AACzD,MAAA,IAAI,QAAQ,UAAA,EAAY;AACtB,QAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,GAAG,KAAA,CAAM,OAAO,QAAA,EAAU,EAAE,SAAA,EAAW,KAAA,EAAM,EAAE;AAAA,MACjE;AAAA,IACF,CAAA;AAEA,IAAA,OAAO,OAAA,CAAQ,MAAM,CAAA,CAAE,IAAA;AAAA,MACrB,CAAC,IAAA,KAAS;AACR,QAAA,IAAI,QAAQ,UAAA,EAAY;AACtB,UAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,MAAA,EAAQ,WAAA,EAAa,MAAM,KAAA,EAAO,MAAA,EAAW,UAAU,MAAA,EAAU;AAAA,QACnF;AACA,QAAA,OAAO,IAAA;AAAA,MACT,CAAA;AAAA,MACA,CAAC,KAAA,KAAmB;AAClB,QAAA,IAAI,QAAQ,UAAA,EAAY;AACtB,UAAA,KAAA,CAAM,KAAA,GAAQ,EAAE,GAAG,KAAA,CAAM,OAAO,MAAA,EAAQ,QAAA,EAAU,KAAA,EAAO,QAAA,EAAU,MAAA,EAAU;AAAA,QAC/E;AACA,QAAA,OAAO,MAAA;AAAA,MACT;AAAA,KACF;AAAA,EACF;AAEA,EAAA,SAAS,KAAA,GAAc;AACrB,IAAA,UAAA,EAAA;AACA,IAAA,KAAA,CAAM,QAAQ,IAAA,EAAQ;AAAA,EACxB;AAEA,EAAA,OAAO;AAAA,IACL,IAAI,KAAA,GAAQ;AACV,MAAA,OAAO,KAAA,CAAM,KAAA;AAAA,IACf,CAAA;AAAA,IACA,GAAA;AAAA,IACA;AAAA,GACF;AACF","file":"async.js","sourcesContent":["/**\n * `kerfjs/async` — model async state, with the stale-response guard built in.\n *\n * Every real kerf app reproduces the same shape — `{ status, data, error }` —\n * for loading/error UI, each paired with a hand-rolled generation counter so a\n * slow response can't overwrite a newer one. This subpath blesses exactly that,\n * and no more: you still write the fetch (Node `fetch` for SSR, browser `fetch`\n * client-side), and `.run()` owns the status transitions plus the stale guard.\n *\n * import { resource } from 'kerfjs/async';\n *\n * const users = resource<User[]>();\n * users.run(() => fetch('/api/users').then((r) => r.json()));\n * // render off users.value.status: 'idle' | 'running' | 'completed' | 'failed'\n *\n * Only the LATEST run may resolve the state, so out-of-order responses are\n * dropped automatically. Optional progress: declare the `report` parameter on\n * your fetcher and call it (e.g. from an upload's progress events).\n */\nimport { signal } from './reactive.js';\n\n/** The lifecycle status of a {@link Resource}. */\nexport type ResourceStatus = 'idle' | 'running' | 'completed' | 'failed';\n\n/** Optional progress for a long-running fetch (uploads, chunked work). */\nexport interface ResourceProgress {\n completed: number;\n total: number;\n}\n\n/** The reactive state a {@link Resource} exposes. */\nexport interface ResourceState<T> {\n status: ResourceStatus;\n /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */\n data: T | undefined;\n /** The rejection from the most recent failed run. */\n error: unknown;\n /** Latest reported progress while running, or `undefined`. */\n progress: ResourceProgress | undefined;\n}\n\n/**\n * The fetcher passed to {@link Resource.run}. You own the transport. It receives\n * a `report(completed, total)` callback for optional progress — ignore it if you\n * don't need progress (a plain `() => Promise<T>` is assignable here).\n */\nexport type ResourceFetcher<T> = (report: (completed: number, total: number) => void) => Promise<T>;\n\n/** An async-state container. Its `value` is a tracking read; drive UI off `value.status`. */\nexport interface Resource<T> {\n /** Tracking read of the current {@link ResourceState}. */\n readonly value: ResourceState<T>;\n /**\n * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding\n * against stale responses (only the latest run resolves the state). Never\n * rejects — a failure lands in `value.error`; resolves with the data (or\n * `undefined` on failure) for callers who want to await it.\n */\n run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /** Reset to `idle` (clearing data/error/progress) and invalidate any in-flight run. */\n reset(): void;\n}\n\nconst IDLE = <T>(): ResourceState<T> => ({\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n});\n\n/** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */\nexport function resource<T>(): Resource<T> {\n const state = signal<ResourceState<T>>(IDLE<T>());\n // Per-resource run counter (closure-local, not module state) — the stale guard.\n let generation = 0;\n\n function run(fetcher: ResourceFetcher<T>): Promise<T | undefined> {\n const gen = ++generation;\n state.value = { ...state.value, status: 'running', error: undefined, progress: undefined };\n\n const report = (completed: number, total: number): void => {\n if (gen === generation) {\n state.value = { ...state.value, progress: { completed, total } };\n }\n };\n\n return fetcher(report).then(\n (data) => {\n if (gen === generation) {\n state.value = { status: 'completed', data, error: undefined, progress: undefined };\n }\n return data;\n },\n (error: unknown) => {\n if (gen === generation) {\n state.value = { ...state.value, status: 'failed', error, progress: undefined };\n }\n return undefined;\n },\n );\n }\n\n function reset(): void {\n generation++; // invalidate any in-flight run\n state.value = IDLE<T>();\n }\n\n return {\n get value() {\n return state.value;\n },\n run,\n reset,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/async.ts"],"names":[],"mappings":"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","file":"async.js","sourcesContent":["/**\n * `kerfjs/async` — model async state, with the stale-response guard built in.\n *\n * Every real kerf app reproduces the same shape — `{ status, data, error }` —\n * for loading/error UI, each paired with a hand-rolled generation counter so a\n * slow response can't overwrite a newer one. This subpath blesses exactly that,\n * and no more: you still write the fetch (Node `fetch` for SSR, browser `fetch`\n * client-side), and `.run()` owns the status transitions plus the stale guard.\n *\n * import { resource } from 'kerfjs/async';\n *\n * const users = resource<User[]>();\n * users.run(() => fetch('/api/users').then((r) => r.json()));\n * // render off users.value.status: 'idle' | 'running' | 'completed' | 'failed'\n *\n * Only the LATEST run may resolve the state, so out-of-order responses are\n * dropped automatically. Optional progress: declare the `report` parameter on\n * your fetcher and call it (e.g. from an upload's progress events).\n *\n * Pass an input — `run(input, fetcher)` — to carry which request a run is for\n * through to `value.input` (set for `running`/`completed`/`failed`), so a\n * failure handler can recover the id/params of the run that failed:\n *\n * const diff = resource<Diff, { fileId: string }>();\n * diff.run({ fileId }, (report) => fetchDiff(fileId, report));\n * // on failure: diff.value.status === 'failed' && diff.value.input.fileId\n *\n * For a real SWR-with-cache section, pass `cacheKey` to keep the last value PER\n * input key (switching back to a loaded key paints its cached slice instantly\n * while it revalidates), and read `value.revision` — a counter that bumps only\n * when `data` actually CHANGES (by `equals`, default `Object.is`) — to skip a\n * redundant paint when a poll tick returns identical data:\n *\n * const win = resource<Slice, string>({ cacheKey: (w) => w, equals: sameSlice });\n * win.run(w, () => fetchSlice(w)); // instant cached paint for a revisited w\n */\nimport { signal } from './reactive.js';\n\n/** The lifecycle status of a {@link Resource}. */\nexport type ResourceStatus = 'idle' | 'running' | 'completed' | 'failed';\n\n/** Optional progress for a long-running fetch (uploads, chunked work). */\nexport interface ResourceProgress {\n completed: number;\n total: number;\n}\n\n/** The reactive state a {@link Resource} exposes. */\nexport interface ResourceState<T, I = void> {\n status: ResourceStatus;\n /** The last successful value. Kept across a re-run (stale-while-revalidate) and on failure. */\n data: T | undefined;\n /** The rejection from the most recent failed run. */\n error: unknown;\n /** Latest reported progress while running, or `undefined`. */\n progress: ResourceProgress | undefined;\n /**\n * The input of the LATEST run — the value passed to {@link Resource.run} as\n * `run(input, fetcher)`. Set for `running`, `completed`, AND `failed` (same\n * stale-guard rule as the rest of the state), so an effect can branch on\n * `status === 'failed'` and still know which request failed. `undefined` in\n * `idle`, and for the no-input `run(fetcher)` form.\n */\n input: I | undefined;\n /**\n * A monotonic counter that increments only when `data` actually CHANGES (by\n * the resource's `equals`, default `Object.is`). Compare it against the value\n * you last painted to skip a redundant re-render — e.g. a 30s poll returning\n * identical data leaves `revision` untouched, so you can bail before wiping\n * scroll / sort / hover state. Starts at `0`.\n */\n revision: number;\n}\n\n/** Construction options for {@link resource}. */\nexport interface ResourceOptions<T, I = void> {\n /**\n * Derive a cache key from a run's `input`. When set, the resource keeps the\n * last successful value PER key: starting a run for a key that was loaded\n * before paints its cached slice immediately (still `running`) while the fetch\n * revalidates in the background; a never-loaded key starts with no `data`.\n * Without `cacheKey`, a run keeps the previous run's `data` (single-slot\n * stale-while-revalidate), as before.\n */\n cacheKey?: (input: I) => string;\n /**\n * Equality used to decide whether `data` changed (drives `value.revision`).\n * Default `Object.is`. Pass a structural comparison to dedup a poll that\n * returns a fresh-but-equal object.\n */\n equals?: (a: T, b: T) => boolean;\n}\n\n/**\n * The fetcher passed to {@link Resource.run}. You own the transport. It receives\n * a `report(completed, total)` callback for optional progress — ignore it if you\n * don't need progress (a plain `() => Promise<T>` is assignable here).\n */\nexport type ResourceFetcher<T> = (report: (completed: number, total: number) => void) => Promise<T>;\n\n/**\n * An async-state container. Its `value` is a tracking read; drive UI off\n * `value.status`. `I` is the run-input type — parametrize it (`resource<T, I>()`)\n * to carry a typed `run(input, fetcher)` input through to `value.input`.\n */\nexport interface Resource<T, I = void> {\n /** Tracking read of the current {@link ResourceState}. */\n readonly value: ResourceState<T, I>;\n /**\n * Run `fetcher`, driving `idle`/`running` → `completed`/`failed` and guarding\n * against stale responses (only the latest run resolves the state). Never\n * rejects — a failure lands in `value.error`; resolves with the data (or\n * `undefined` on failure) for callers who want to await it.\n */\n run(fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /**\n * Run `fetcher` for a given `input`, exposing it as `value.input` for the\n * `running`/`completed`/`failed` states of THIS run — so a failure handler can\n * recover which request failed. Same stale guard: only the latest run resolves.\n */\n run(input: I, fetcher: ResourceFetcher<T>): Promise<T | undefined>;\n /** Reset to `idle` (clearing data/error/progress/input, and the per-key cache) and invalidate any in-flight run. */\n reset(): void;\n}\n\n/** Create an async-state {@link Resource}. No per-instance framework state — it's a closure over a signal. */\nexport function resource<T, I = void>(options: ResourceOptions<T, I> = {}): Resource<T, I> {\n const { cacheKey, equals } = options;\n const eq: (a: T, b: T) => boolean = equals ?? Object.is;\n const cache = new Map<string, T>(); // per-key SWR cache (GC-tied to the resource)\n\n // Revision tracking: `revision` bumps only when `data` changes (by `eq`).\n let revision = 0;\n let lastData: T | undefined;\n const changed = (next: T | undefined): boolean =>\n // undefined transitions are handled by reference; two defined values by `eq`.\n lastData === undefined || next === undefined ? lastData !== next : !eq(lastData, next);\n const commit = (next: T | undefined): number => {\n if (changed(next)) {\n revision++;\n lastData = next;\n }\n return revision;\n };\n\n const state = signal<ResourceState<T, I>>({\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n input: undefined,\n revision: 0,\n });\n // Per-resource run counter (closure-local, not module state) — the stale guard.\n let generation = 0;\n\n function run(\n inputOrFetcher: I | ResourceFetcher<T>,\n maybeFetcher?: ResourceFetcher<T>,\n ): Promise<T | undefined> {\n // Two-arg form is (input, fetcher); one-arg form is (fetcher) with no input.\n // A fetcher is always a function, so `maybeFetcher === undefined` uniquely\n // identifies the one-arg call — even when the input value is itself undefined.\n const fetcher = (maybeFetcher ?? inputOrFetcher) as ResourceFetcher<T>;\n const input = (maybeFetcher === undefined ? undefined : inputOrFetcher) as I | undefined;\n const key = cacheKey !== undefined && maybeFetcher !== undefined ? cacheKey(input as I) : undefined;\n\n // What `data` shows while running: the cached slice for this key (per-key\n // SWR), or the previous run's data (single-slot SWR) when no cacheKey.\n const runningData = cacheKey !== undefined\n ? (key !== undefined ? cache.get(key) : undefined)\n : state.value.data;\n\n const gen = ++generation;\n state.value = {\n status: 'running',\n data: runningData,\n error: undefined,\n progress: undefined,\n input,\n revision: commit(runningData),\n };\n\n const report = (completed: number, total: number): void => {\n if (gen === generation) {\n state.value = { ...state.value, progress: { completed, total } };\n }\n };\n\n return fetcher(report).then(\n (data) => {\n if (gen === generation) {\n if (key !== undefined) cache.set(key, data);\n state.value = {\n status: 'completed',\n data,\n error: undefined,\n progress: undefined,\n input,\n revision: commit(data),\n };\n }\n return data;\n },\n (error: unknown) => {\n if (gen === generation) {\n // Keep `data` (and thus `revision`) on failure — stale-while-error.\n state.value = { ...state.value, status: 'failed', error, progress: undefined, input };\n }\n return undefined;\n },\n );\n }\n\n function reset(): void {\n generation++; // invalidate any in-flight run\n cache.clear();\n state.value = {\n status: 'idle',\n data: undefined,\n error: undefined,\n progress: undefined,\n input: undefined,\n revision: commit(undefined),\n };\n }\n\n return {\n get value() {\n return state.value;\n },\n run,\n reset,\n };\n}\n"]}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `kerfjs/imperative` — bind a non-kerf widget's lifecycle to a single DOM node.
3
+ *
4
+ * `data-morph-skip` lets a library own a subtree so kerf won't touch it — but
5
+ * nothing manages that widget's LIFECYCLE. You set it up imperatively after
6
+ * render and must remember to tear it down when the node is replaced/removed
7
+ * (dropping document-level listeners the widget added, etc.). `imperative` closes
8
+ * that seam: it's a `useEffect`-with-cleanup bound to one node.
9
+ *
10
+ * import { imperative } from 'kerfjs/imperative';
11
+ *
12
+ * imperative(canvasEl, (el) => {
13
+ * const chart = D3.mount(el);
14
+ * return () => chart.destroy(); // runs when el leaves the DOM (or on dispose)
15
+ * });
16
+ *
17
+ * `setup(node)` runs immediately and may return a teardown function. The teardown
18
+ * runs once — whichever comes first — when the node leaves the document (detected
19
+ * by a `MutationObserver`, so a morph swap, a `remountOn` replacement, or any
20
+ * removal triggers it) or when the returned disposer is called. Re-creation is
21
+ * NOT handled here: a fresh node is a fresh `imperative()` call — pair it with
22
+ * `kerfjs/remount`, which replaces the node and re-runs your render (and thus
23
+ * this call) on the new one.
24
+ */
25
+ /** The setup callback for {@link imperative}: run against `node`, optionally return a teardown. */
26
+ type ImperativeSetup = (node: Element) => (() => void) | void;
27
+ /**
28
+ * Run `setup(node)` now, and its returned teardown once — when `node` leaves the
29
+ * document, or when the returned disposer is called, whichever is first. Returns
30
+ * a disposer (idempotent) so a `mount()` / `Scope` can drive teardown explicitly.
31
+ */
32
+ declare function imperative(node: Element, setup: ImperativeSetup): () => void;
33
+
34
+ export { type ImperativeSetup, imperative };
@@ -0,0 +1,20 @@
1
+ // src/imperative.ts
2
+ function imperative(node, setup) {
3
+ const teardown = setup(node);
4
+ let done = false;
5
+ const finish = () => {
6
+ if (done) return;
7
+ done = true;
8
+ observer.disconnect();
9
+ if (typeof teardown === "function") teardown();
10
+ };
11
+ const observer = new MutationObserver(() => {
12
+ if (!node.isConnected) finish();
13
+ });
14
+ observer.observe(node.getRootNode(), { childList: true, subtree: true });
15
+ return finish;
16
+ }
17
+
18
+ export { imperative };
19
+ //# sourceMappingURL=imperative.js.map
20
+ //# sourceMappingURL=imperative.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/imperative.ts"],"names":[],"mappings":";AAiCO,SAAS,UAAA,CAAW,MAAe,KAAA,EAAoC;AAC5E,EAAA,MAAM,QAAA,GAAW,MAAM,IAAI,CAAA;AAC3B,EAAA,IAAI,IAAA,GAAO,KAAA;AAEX,EAAA,MAAM,SAAS,MAAY;AACzB,IAAA,IAAI,IAAA,EAAM;AACV,IAAA,IAAA,GAAO,IAAA;AACP,IAAA,QAAA,CAAS,UAAA,EAAW;AACpB,IAAA,IAAI,OAAO,QAAA,KAAa,UAAA,EAAY,QAAA,EAAS;AAAA,EAC/C,CAAA;AAMA,EAAA,MAAM,QAAA,GAAW,IAAI,gBAAA,CAAiB,MAAM;AAC1C,IAAA,IAAI,CAAC,IAAA,CAAK,WAAA,EAAa,MAAA,EAAO;AAAA,EAChC,CAAC,CAAA;AACD,EAAA,QAAA,CAAS,OAAA,CAAQ,KAAK,WAAA,EAAY,EAAG,EAAE,SAAA,EAAW,IAAA,EAAM,OAAA,EAAS,IAAA,EAAM,CAAA;AAEvE,EAAA,OAAO,MAAA;AACT","file":"imperative.js","sourcesContent":["/**\n * `kerfjs/imperative` — bind a non-kerf widget's lifecycle to a single DOM node.\n *\n * `data-morph-skip` lets a library own a subtree so kerf won't touch it — but\n * nothing manages that widget's LIFECYCLE. You set it up imperatively after\n * render and must remember to tear it down when the node is replaced/removed\n * (dropping document-level listeners the widget added, etc.). `imperative` closes\n * that seam: it's a `useEffect`-with-cleanup bound to one node.\n *\n * import { imperative } from 'kerfjs/imperative';\n *\n * imperative(canvasEl, (el) => {\n * const chart = D3.mount(el);\n * return () => chart.destroy(); // runs when el leaves the DOM (or on dispose)\n * });\n *\n * `setup(node)` runs immediately and may return a teardown function. The teardown\n * runs once — whichever comes first — when the node leaves the document (detected\n * by a `MutationObserver`, so a morph swap, a `remountOn` replacement, or any\n * removal triggers it) or when the returned disposer is called. Re-creation is\n * NOT handled here: a fresh node is a fresh `imperative()` call — pair it with\n * `kerfjs/remount`, which replaces the node and re-runs your render (and thus\n * this call) on the new one.\n */\n\n/** The setup callback for {@link imperative}: run against `node`, optionally return a teardown. */\nexport type ImperativeSetup = (node: Element) => (() => void) | void;\n\n/**\n * Run `setup(node)` now, and its returned teardown once — when `node` leaves the\n * document, or when the returned disposer is called, whichever is first. Returns\n * a disposer (idempotent) so a `mount()` / `Scope` can drive teardown explicitly.\n */\nexport function imperative(node: Element, setup: ImperativeSetup): () => void {\n const teardown = setup(node);\n let done = false;\n\n const finish = (): void => {\n if (done) return;\n done = true;\n observer.disconnect();\n if (typeof teardown === 'function') teardown();\n };\n\n // Observe the node's live tree (the document when connected) with subtree, so\n // an ANCESTOR removal — not just a direct one — is caught. Each mutation just\n // re-checks `node.isConnected`, which is true until the node (or an ancestor)\n // is removed, so a morph swap / remountOn replacement / manual removal all fire.\n const observer = new MutationObserver(() => {\n if (!node.isConnected) finish();\n });\n observer.observe(node.getRootNode(), { childList: true, subtree: true });\n\n return finish;\n}\n"]}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
+ export { each, morph, mount } from './chunk-4MY2656S.js';
1
2
  export { defineStore, resetAllStores } from './chunk-SAYPJ6XR.js';
2
3
  export { attr } from './chunk-U32TFTGZ.js';
3
4
  export { delegate, delegateCapture } from './chunk-KEZTD6H4.js';
4
- export { each, morph, mount } from './chunk-4MY2656S.js';
5
5
  import './chunk-QIP723L4.js';
6
6
  import './chunk-YHH7OUFA.js';
7
7
  export { Fragment, SafeHtml, isSafeHtml, raw, trustedRaw } from './chunk-FSAQR6IU.js';
package/dist/list.d.ts CHANGED
@@ -9,13 +9,31 @@ type ListKey = string | number;
9
9
  interface ListSource<T> {
10
10
  readonly value: readonly T[];
11
11
  }
12
+ /**
13
+ * A row built imperatively by `render`: return the row **element** itself (kerf
14
+ * keys/moves/reuses it and owns nothing inside it), or `{ el, dispose? }` to also
15
+ * hand back a teardown that runs when the row is removed or rebuilt.
16
+ */
17
+ type RowElement = HTMLElement | {
18
+ el: HTMLElement;
19
+ dispose?: () => void;
20
+ };
12
21
  /** Options for {@link bindList}. */
13
22
  interface BindListOptions<T> {
14
23
  /** Stable per-row key. Rows are matched, moved, and reused by this. */
15
24
  key: (item: T) => ListKey;
16
- /** Renders a row's content into its (individually mounted) row element. Read signals here for per-row reactivity. */
17
- render: (item: T) => MountResult;
18
- /** Row element tag. Default `'div'` (use `'li'` inside a `<ul>`, `'tr'` inside a `<tbody>`, …). */
25
+ /**
26
+ * Build a row. Two modes, chosen per call by what you return:
27
+ * - **Content mode** (a `MountResult` — JSX / `SafeHtml`): kerf creates the
28
+ * row element (`tag`) and `mount()`s your content inside it, so signals your
29
+ * content reads drive per-row reactivity.
30
+ * - **Element mode** (an `HTMLElement`, or `{ el, dispose? }`): the element you
31
+ * return IS the row, so you own its tag, class, `data-*`, and listeners
32
+ * (kerf keys/moves/reuses it). Reactivity + cleanup are yours — return a
33
+ * `dispose` to tear down listeners when the row is removed or rebuilt.
34
+ */
35
+ render: (item: T) => MountResult | RowElement;
36
+ /** Row element tag for **content mode**. Default `'div'` (use `'li'` inside a `<ul>`, `'tr'` inside a `<tbody>`, …). Ignored in element mode. */
19
37
  tag?: string;
20
38
  /**
21
39
  * Turn on viewport virtualization. `rowHeight` is the fixed pixel height of
@@ -36,4 +54,4 @@ interface BindListOptions<T> {
36
54
  */
37
55
  declare function bindList<T>(parent: HTMLElement, source: ListSource<T>, options: BindListOptions<T>): () => void;
38
56
 
39
- export { type BindListOptions, type ListKey, type ListSource, bindList };
57
+ export { type BindListOptions, type ListKey, type ListSource, type RowElement, bindList };
package/dist/list.js CHANGED
@@ -21,7 +21,22 @@ function bindList(parent, source, options) {
21
21
  const granularEligible = virtualize === void 0 && patchSource[ARRAY_SIGNAL_BRAND] === true;
22
22
  const container = virtualize === void 0 ? parent : document.createElement("div");
23
23
  if (virtualize !== void 0) parent.appendChild(container);
24
+ const NOOP = () => {
25
+ };
26
+ const asElementRow = (rendered) => {
27
+ if (rendered instanceof HTMLElement) return { el: rendered, dispose: NOOP };
28
+ if (rendered !== null && typeof rendered === "object" && "el" in rendered && rendered.el instanceof HTMLElement) {
29
+ const r = rendered;
30
+ return { el: r.el, dispose: r.dispose ?? NOOP };
31
+ }
32
+ return null;
33
+ };
24
34
  const makeRow = (item) => {
35
+ const elementRow = asElementRow(render(item));
36
+ if (elementRow !== null) {
37
+ if (virtualize !== void 0) elementRow.el.style.height = `${virtualize.rowHeight}px`;
38
+ return { el: elementRow.el, item, dispose: elementRow.dispose };
39
+ }
25
40
  const el = document.createElement(tag);
26
41
  if (virtualize !== void 0) el.style.height = `${virtualize.rowHeight}px`;
27
42
  const dispose = mount(el, () => render(item));
package/dist/list.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/list.ts"],"names":[],"mappings":"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","file":"list.js","sourcesContent":["/**\n * `kerfjs/list` — `bindList`, a keyed list with a live per-row mount and\n * optional viewport virtualization.\n *\n * This is a DELIBERATE second list API, distinct from `each()`. It does two\n * things `each()` structurally cannot:\n * 1. **Per-row reactivity.** Every row is individually `mount()`ed, so a signal\n * the row's `render` reads updates just that row (fine-grained binding or a\n * one-row morph) without touching its siblings — no full-list pass.\n * 2. **Virtualization.** With `{ virtualize: { rowHeight } }` only the rows in\n * the scroll viewport are rendered; padding on the scroll container keeps\n * `scrollHeight` honest.\n *\n * `each()` stays the choice for item-owned-state lists rendered to HTML strings;\n * reach for `bindList` when you need surgical per-row updates or windowing.\n *\n * import { bindList } from 'kerfjs/list';\n *\n * const dispose = bindList(listEl, itemsSignal, {\n * key: (row) => row.id,\n * render: (row) => <span class={selected} data-id={row.id}>{row.label}</span>,\n * tag: 'li',\n * virtualize: { rowHeight: 32 },\n * });\n *\n * `render` reads signals for reactivity (external state like a `selectedId`, or\n * signals the item carries) — keep the item OBJECTS stable across renders and\n * drive structure (add/remove/move) through `itemsSignal`. A row whose item\n * object identity changes is rebuilt (same rule as `each()`'s memo). `bindList`\n * OWNS `parent`'s children. It reads `itemsSignal.value`, so a plain\n * `signal<T[]>` or an `arraySignal<T>` both work.\n */\nimport { ARRAY_SIGNAL_BRAND, type ArrayPatch } from './array-signal.js';\nimport { mount, type MountResult } from './mount.js';\nimport { effect } from './reactive.js';\n\n/** A row's stable key. */\nexport type ListKey = string | number;\n\n/** Anything with a tracking `.value` array read — a `signal<readonly T[]>` or an `arraySignal<T>`. */\nexport interface ListSource<T> {\n readonly value: readonly T[];\n}\n\n/** Options for {@link bindList}. */\nexport interface BindListOptions<T> {\n /** Stable per-row key. Rows are matched, moved, and reused by this. */\n key: (item: T) => ListKey;\n /** Renders a row's content into its (individually mounted) row element. Read signals here for per-row reactivity. */\n render: (item: T) => MountResult;\n /** Row element tag. Default `'div'` (use `'li'` inside a `<ul>`, `'tr'` inside a `<tbody>`, …). */\n tag?: string;\n /**\n * Turn on viewport virtualization. `rowHeight` is the fixed pixel height of\n * every row; `overscan` (default 3) is how many extra rows to render above and\n * below the viewport. `parent` must be a scroll container (your CSS: a fixed\n * height + `overflow: auto`).\n */\n virtualize?: { rowHeight: number; overscan?: number };\n}\n\ninterface Row<T> {\n el: HTMLElement;\n item: T;\n dispose: () => void;\n}\n\n/**\n * Bind a keyed, per-row-reactive list to `parent`, driven by `source` (a\n * `signal<readonly T[]>` or an `arraySignal<T>`). Returns a disposer that tears\n * down every row mount, the scroll listener (if virtualized), and the source\n * subscription.\n */\nexport function bindList<T>(\n parent: HTMLElement,\n source: ListSource<T>,\n options: BindListOptions<T>,\n): () => void {\n const { key, render, tag = 'div', virtualize } = options;\n const overscan = virtualize?.overscan ?? 3;\n\n const rows = new Map<ListKey, Row<T>>();\n // The current DOM order of rows, kept in step by both the keyed-diff and the\n // granular patch paths so index-based patches can address rows directly.\n const order: Array<Row<T>> = [];\n let items: readonly T[] = [];\n let disposed = false;\n let rafPending = false;\n let firstRender = true;\n\n // Granular fast path (KF-478): when the source is an `arraySignal` and the\n // list is NOT virtualized, apply its insert/remove/move/update patches\n // directly in O(patches) instead of diffing the whole snapshot. Virtualized\n // lists keep the keyed diff — their visible set is just the window (cheap),\n // and absolute-index patches don't compose with a shifting window. A plain\n // `signal<T[]>` has no patches, so it always uses the keyed diff.\n const patchSource = source as {\n [ARRAY_SIGNAL_BRAND]?: boolean;\n _consumePatches?: () => ArrayPatch<T>[];\n };\n const granularEligible = virtualize === undefined && patchSource[ARRAY_SIGNAL_BRAND] === true;\n\n // Virtualized lists put the windowing padding + rows on an INNER sizer, so the\n // padding never inflates the scroll container's clientHeight (padding counts\n // toward clientHeight). `parent` stays the clean scroll viewport; `container`\n // holds the rows. Non-virtualized lists render straight into `parent`.\n const container: HTMLElement = virtualize === undefined ? parent : document.createElement('div');\n if (virtualize !== undefined) parent.appendChild(container);\n\n const makeRow = (item: T): Row<T> => {\n const el = document.createElement(tag);\n // bindList knows the fixed row height, so it sizes rows itself — no\n // consumer CSS needed for the windowing math to line up.\n if (virtualize !== undefined) el.style.height = `${virtualize.rowHeight}px`;\n const dispose = mount(el, () => render(item));\n return { el, item, dispose };\n };\n\n // Reconcile the live rows to exactly `visible`, in order, keyed.\n const syncRows = (visible: readonly T[]): void => {\n const wanted = new Set<ListKey>();\n for (const item of visible) wanted.add(key(item));\n\n // Remove rows that are gone from the window.\n for (const [k, row] of rows) {\n if (!wanted.has(k)) {\n row.dispose();\n row.el.remove();\n rows.delete(k);\n }\n }\n\n // Create missing rows (and rebuild a row whose item OBJECT changed identity).\n order.length = 0;\n for (const item of visible) {\n const k = key(item);\n let row = rows.get(k);\n if (row !== undefined && row.item !== item) {\n row.dispose();\n row.el.remove();\n rows.delete(k);\n row = undefined;\n }\n if (row === undefined) {\n row = makeRow(item);\n rows.set(k, row);\n }\n order.push(row);\n }\n\n // Reverse pass: move only rows that are out of position.\n let ref: Node | null = null;\n for (let i = order.length - 1; i >= 0; i--) {\n const el = order[i].el;\n if (el.parentNode !== container || el.nextSibling !== ref) {\n container.insertBefore(el, ref);\n }\n ref = el;\n }\n };\n\n // Apply arraySignal structural patches directly to `order` + the DOM, in\n // O(patches). Indices are always valid by construction: `order` reflects the\n // last-rendered state and the patches are exactly the delta from it (bindList\n // drains the queue every render, and `replace` is filtered out by the caller,\n // which snapshots instead). The `splice()`s mirror `arraySignal`'s own\n // `_items` mutations exactly.\n const applyPatches = (patches: readonly ArrayPatch<T>[]): void => {\n for (const patch of patches) {\n if (patch.type === 'insert') {\n const row = makeRow(patch.item);\n rows.set(key(patch.item), row);\n order.splice(patch.index, 0, row);\n container.insertBefore(row.el, order[patch.index + 1]?.el ?? null);\n } else if (patch.type === 'remove') {\n const [row] = order.splice(patch.index, 1);\n row.dispose();\n row.el.remove();\n rows.delete(key(row.item));\n } else if (patch.type === 'move') {\n const [row] = order.splice(patch.from, 1);\n order.splice(patch.to, 0, row);\n container.insertBefore(row.el, order[patch.to + 1]?.el ?? null);\n } else if (patch.type === 'update') {\n // Decision #3: an item whose OBJECT identity changed rebuilds the row\n // (matching the keyed-diff rule). A same-ref update needs nothing here —\n // the row's own mount reacts to whatever signals its render reads.\n const current = order[patch.index];\n if (current.item !== patch.item) {\n current.dispose();\n current.el.remove();\n rows.delete(key(current.item));\n const row = makeRow(patch.item);\n rows.set(key(patch.item), row);\n order[patch.index] = row;\n container.insertBefore(row.el, order[patch.index + 1]?.el ?? null);\n }\n }\n // 'replace' never reaches here — the caller snapshots on it.\n }\n };\n\n const renderWindow = (): void => {\n if (virtualize === undefined) {\n if (granularEligible) {\n // Always drain to keep the single patch queue clean (so patches never\n // double-apply). Take the granular path past the first render, when\n // there are patches, and none is a `replace` (which reshapes the whole\n // array — snapshot instead). Otherwise fall through to a keyed diff.\n const patches = patchSource._consumePatches!();\n if (\n !firstRender\n && patches.length > 0\n && !patches.some((p) => p.type === 'replace')\n ) {\n applyPatches(patches);\n return;\n }\n }\n syncRows(items);\n firstRender = false;\n return;\n }\n const { rowHeight } = virtualize;\n const total = items.length;\n const start = Math.max(0, Math.floor(parent.scrollTop / rowHeight) - overscan);\n const end = Math.min(total, Math.ceil((parent.scrollTop + parent.clientHeight) / rowHeight) + overscan);\n syncRows(items.slice(start, end));\n container.style.paddingTop = `${start * rowHeight}px`;\n container.style.paddingBottom = `${Math.max(0, total - end) * rowHeight}px`;\n };\n\n const stopEffect = effect(() => {\n items = source.value; // tracking read — re-runs on any structural change\n renderWindow();\n });\n\n const onScroll = (): void => {\n if (rafPending) return;\n rafPending = true;\n globalThis.requestAnimationFrame(() => {\n rafPending = false;\n if (!disposed) renderWindow();\n });\n };\n if (virtualize !== undefined) parent.addEventListener('scroll', onScroll);\n\n return () => {\n disposed = true;\n stopEffect();\n for (const row of rows.values()) {\n row.dispose();\n if (virtualize === undefined) row.el.remove();\n }\n rows.clear();\n if (virtualize !== undefined) {\n parent.removeEventListener('scroll', onScroll);\n container.remove(); // removes the inner sizer and its rows in one go\n }\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/list.ts"],"names":[],"mappings":"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","file":"list.js","sourcesContent":["/**\n * `kerfjs/list` — `bindList`, a keyed list with a live per-row mount and\n * optional viewport virtualization.\n *\n * This is a DELIBERATE second list API, distinct from `each()`. It does two\n * things `each()` structurally cannot:\n * 1. **Per-row reactivity.** Every row is individually `mount()`ed, so a signal\n * the row's `render` reads updates just that row (fine-grained binding or a\n * one-row morph) without touching its siblings — no full-list pass.\n * 2. **Virtualization.** With `{ virtualize: { rowHeight } }` only the rows in\n * the scroll viewport are rendered; padding on the scroll container keeps\n * `scrollHeight` honest.\n *\n * `each()` stays the choice for item-owned-state lists rendered to HTML strings;\n * reach for `bindList` when you need surgical per-row updates or windowing.\n *\n * import { bindList } from 'kerfjs/list';\n *\n * const dispose = bindList(listEl, itemsSignal, {\n * key: (row) => row.id,\n * render: (row) => <span class={selected} data-id={row.id}>{row.label}</span>,\n * tag: 'li',\n * virtualize: { rowHeight: 32 },\n * });\n *\n * `render` reads signals for reactivity (external state like a `selectedId`, or\n * signals the item carries) — keep the item OBJECTS stable across renders and\n * drive structure (add/remove/move) through `itemsSignal`. A row whose item\n * object identity changes is rebuilt (same rule as `each()`'s memo). `bindList`\n * OWNS `parent`'s children. It reads `itemsSignal.value`, so a plain\n * `signal<T[]>` or an `arraySignal<T>` both work.\n */\nimport { ARRAY_SIGNAL_BRAND, type ArrayPatch } from './array-signal.js';\nimport { mount, type MountResult } from './mount.js';\nimport { effect } from './reactive.js';\n\n/** A row's stable key. */\nexport type ListKey = string | number;\n\n/** Anything with a tracking `.value` array read — a `signal<readonly T[]>` or an `arraySignal<T>`. */\nexport interface ListSource<T> {\n readonly value: readonly T[];\n}\n\n/**\n * A row built imperatively by `render`: return the row **element** itself (kerf\n * keys/moves/reuses it and owns nothing inside it), or `{ el, dispose? }` to also\n * hand back a teardown that runs when the row is removed or rebuilt.\n */\nexport type RowElement = HTMLElement | { el: HTMLElement; dispose?: () => void };\n\n/** Options for {@link bindList}. */\nexport interface BindListOptions<T> {\n /** Stable per-row key. Rows are matched, moved, and reused by this. */\n key: (item: T) => ListKey;\n /**\n * Build a row. Two modes, chosen per call by what you return:\n * - **Content mode** (a `MountResult` — JSX / `SafeHtml`): kerf creates the\n * row element (`tag`) and `mount()`s your content inside it, so signals your\n * content reads drive per-row reactivity.\n * - **Element mode** (an `HTMLElement`, or `{ el, dispose? }`): the element you\n * return IS the row, so you own its tag, class, `data-*`, and listeners\n * (kerf keys/moves/reuses it). Reactivity + cleanup are yours — return a\n * `dispose` to tear down listeners when the row is removed or rebuilt.\n */\n render: (item: T) => MountResult | RowElement;\n /** Row element tag for **content mode**. Default `'div'` (use `'li'` inside a `<ul>`, `'tr'` inside a `<tbody>`, …). Ignored in element mode. */\n tag?: string;\n /**\n * Turn on viewport virtualization. `rowHeight` is the fixed pixel height of\n * every row; `overscan` (default 3) is how many extra rows to render above and\n * below the viewport. `parent` must be a scroll container (your CSS: a fixed\n * height + `overflow: auto`).\n */\n virtualize?: { rowHeight: number; overscan?: number };\n}\n\ninterface Row<T> {\n el: HTMLElement;\n item: T;\n dispose: () => void;\n}\n\n/**\n * Bind a keyed, per-row-reactive list to `parent`, driven by `source` (a\n * `signal<readonly T[]>` or an `arraySignal<T>`). Returns a disposer that tears\n * down every row mount, the scroll listener (if virtualized), and the source\n * subscription.\n */\nexport function bindList<T>(\n parent: HTMLElement,\n source: ListSource<T>,\n options: BindListOptions<T>,\n): () => void {\n const { key, render, tag = 'div', virtualize } = options;\n const overscan = virtualize?.overscan ?? 3;\n\n const rows = new Map<ListKey, Row<T>>();\n // The current DOM order of rows, kept in step by both the keyed-diff and the\n // granular patch paths so index-based patches can address rows directly.\n const order: Array<Row<T>> = [];\n let items: readonly T[] = [];\n let disposed = false;\n let rafPending = false;\n let firstRender = true;\n\n // Granular fast path (KF-478): when the source is an `arraySignal` and the\n // list is NOT virtualized, apply its insert/remove/move/update patches\n // directly in O(patches) instead of diffing the whole snapshot. Virtualized\n // lists keep the keyed diff — their visible set is just the window (cheap),\n // and absolute-index patches don't compose with a shifting window. A plain\n // `signal<T[]>` has no patches, so it always uses the keyed diff.\n const patchSource = source as {\n [ARRAY_SIGNAL_BRAND]?: boolean;\n _consumePatches?: () => ArrayPatch<T>[];\n };\n const granularEligible = virtualize === undefined && patchSource[ARRAY_SIGNAL_BRAND] === true;\n\n // Virtualized lists put the windowing padding + rows on an INNER sizer, so the\n // padding never inflates the scroll container's clientHeight (padding counts\n // toward clientHeight). `parent` stays the clean scroll viewport; `container`\n // holds the rows. Non-virtualized lists render straight into `parent`.\n const container: HTMLElement = virtualize === undefined ? parent : document.createElement('div');\n if (virtualize !== undefined) parent.appendChild(container);\n\n const NOOP = (): void => { /* element-mode rows with no caller teardown */ };\n\n // Detect element mode from a render result: a raw `HTMLElement`, or a\n // `{ el, dispose? }` object. Everything else (SafeHtml / string / nullish) is\n // content mode. SafeHtml is an object but has no `el`, so it never matches.\n const asElementRow = (\n rendered: MountResult | RowElement,\n ): { el: HTMLElement; dispose: () => void } | null => {\n if (rendered instanceof HTMLElement) return { el: rendered, dispose: NOOP };\n if (\n rendered !== null\n && typeof rendered === 'object'\n && 'el' in rendered\n && (rendered as { el: unknown }).el instanceof HTMLElement\n ) {\n const r = rendered as { el: HTMLElement; dispose?: () => void };\n return { el: r.el, dispose: r.dispose ?? NOOP };\n }\n return null;\n };\n\n const makeRow = (item: T): Row<T> => {\n // One call decides the mode per row (so a list may mix element + content rows).\n const elementRow = asElementRow(render(item));\n if (elementRow !== null) {\n // Element mode: the returned element IS the row; the caller owns its\n // content + cleanup. bindList still sizes it for the windowing math.\n if (virtualize !== undefined) elementRow.el.style.height = `${virtualize.rowHeight}px`;\n return { el: elementRow.el, item, dispose: elementRow.dispose };\n }\n // Content mode: kerf creates the row element and mounts `render` inside it,\n // so the content is per-row reactive. (In content mode `render` runs once\n // more here for the mode probe than the mount itself needs — keep it a pure\n // projection, which bindList already requires.)\n const el = document.createElement(tag);\n if (virtualize !== undefined) el.style.height = `${virtualize.rowHeight}px`;\n // Content mode: `render` returns a MountResult here (element results were\n // handled above), so narrowing it for `mount` is sound.\n const dispose = mount(el, () => render(item) as MountResult);\n return { el, item, dispose };\n };\n\n // Reconcile the live rows to exactly `visible`, in order, keyed.\n const syncRows = (visible: readonly T[]): void => {\n const wanted = new Set<ListKey>();\n for (const item of visible) wanted.add(key(item));\n\n // Remove rows that are gone from the window.\n for (const [k, row] of rows) {\n if (!wanted.has(k)) {\n row.dispose();\n row.el.remove();\n rows.delete(k);\n }\n }\n\n // Create missing rows (and rebuild a row whose item OBJECT changed identity).\n order.length = 0;\n for (const item of visible) {\n const k = key(item);\n let row = rows.get(k);\n if (row !== undefined && row.item !== item) {\n row.dispose();\n row.el.remove();\n rows.delete(k);\n row = undefined;\n }\n if (row === undefined) {\n row = makeRow(item);\n rows.set(k, row);\n }\n order.push(row);\n }\n\n // Reverse pass: move only rows that are out of position.\n let ref: Node | null = null;\n for (let i = order.length - 1; i >= 0; i--) {\n const el = order[i].el;\n if (el.parentNode !== container || el.nextSibling !== ref) {\n container.insertBefore(el, ref);\n }\n ref = el;\n }\n };\n\n // Apply arraySignal structural patches directly to `order` + the DOM, in\n // O(patches). Indices are always valid by construction: `order` reflects the\n // last-rendered state and the patches are exactly the delta from it (bindList\n // drains the queue every render, and `replace` is filtered out by the caller,\n // which snapshots instead). The `splice()`s mirror `arraySignal`'s own\n // `_items` mutations exactly.\n const applyPatches = (patches: readonly ArrayPatch<T>[]): void => {\n for (const patch of patches) {\n if (patch.type === 'insert') {\n const row = makeRow(patch.item);\n rows.set(key(patch.item), row);\n order.splice(patch.index, 0, row);\n container.insertBefore(row.el, order[patch.index + 1]?.el ?? null);\n } else if (patch.type === 'remove') {\n const [row] = order.splice(patch.index, 1);\n row.dispose();\n row.el.remove();\n rows.delete(key(row.item));\n } else if (patch.type === 'move') {\n const [row] = order.splice(patch.from, 1);\n order.splice(patch.to, 0, row);\n container.insertBefore(row.el, order[patch.to + 1]?.el ?? null);\n } else if (patch.type === 'update') {\n // Decision #3: an item whose OBJECT identity changed rebuilds the row\n // (matching the keyed-diff rule). A same-ref update needs nothing here —\n // the row's own mount reacts to whatever signals its render reads.\n const current = order[patch.index];\n if (current.item !== patch.item) {\n current.dispose();\n current.el.remove();\n rows.delete(key(current.item));\n const row = makeRow(patch.item);\n rows.set(key(patch.item), row);\n order[patch.index] = row;\n container.insertBefore(row.el, order[patch.index + 1]?.el ?? null);\n }\n }\n // 'replace' never reaches here — the caller snapshots on it.\n }\n };\n\n const renderWindow = (): void => {\n if (virtualize === undefined) {\n if (granularEligible) {\n // Always drain to keep the single patch queue clean (so patches never\n // double-apply). Take the granular path past the first render, when\n // there are patches, and none is a `replace` (which reshapes the whole\n // array — snapshot instead). Otherwise fall through to a keyed diff.\n const patches = patchSource._consumePatches!();\n if (\n !firstRender\n && patches.length > 0\n && !patches.some((p) => p.type === 'replace')\n ) {\n applyPatches(patches);\n return;\n }\n }\n syncRows(items);\n firstRender = false;\n return;\n }\n const { rowHeight } = virtualize;\n const total = items.length;\n const start = Math.max(0, Math.floor(parent.scrollTop / rowHeight) - overscan);\n const end = Math.min(total, Math.ceil((parent.scrollTop + parent.clientHeight) / rowHeight) + overscan);\n syncRows(items.slice(start, end));\n container.style.paddingTop = `${start * rowHeight}px`;\n container.style.paddingBottom = `${Math.max(0, total - end) * rowHeight}px`;\n };\n\n const stopEffect = effect(() => {\n items = source.value; // tracking read — re-runs on any structural change\n renderWindow();\n });\n\n const onScroll = (): void => {\n if (rafPending) return;\n rafPending = true;\n globalThis.requestAnimationFrame(() => {\n rafPending = false;\n if (!disposed) renderWindow();\n });\n };\n if (virtualize !== undefined) parent.addEventListener('scroll', onScroll);\n\n return () => {\n disposed = true;\n stopEffect();\n for (const row of rows.values()) {\n row.dispose();\n if (virtualize === undefined) row.el.remove();\n }\n rows.clear();\n if (virtualize !== undefined) {\n parent.removeEventListener('scroll', onScroll);\n container.remove(); // removes the inner sizer and its rows in one go\n }\n };\n}\n"]}