@cplieger/reactive 2.2.0 → 2.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,8 +10,9 @@ It replaces the change listeners, list re-renders and element builder you would
10
10
 
11
11
  reactive is built for a small browser app in plain TypeScript that needs no component framework.
12
12
 
13
- - Signals, computed values and effects track their dependencies on their own. When a write returns, every effect that depends on it has already run.
13
+ - Signals, computed values and effects track their dependencies on their own. When a write returns, every effect that depends on it has already run, except those still running, which each finish before any re-run they need.
14
14
  - `createCollection` and `bindList` render a keyed list. Changing one item repaints only its row, and a reorder keeps the existing rows.
15
+ - `createRoot` disposes a view's effects at once, and `resource` loads async data without painting a late answer over the next view.
15
16
  - `el` builds elements without HTML strings, so it works under a strict Content Security Policy. `patch` updates a subtree by reusing the elements on the page, so focus and scroll position survive a re-render.
16
17
  - The signals, stores, collections and event bus need no DOM, so they also run under Node, Deno, Bun or Workers.
17
18
 
@@ -94,9 +95,35 @@ effect(() => {
94
95
  name.value = "reactive"; // the same <h1> stays on the page with its new text
95
96
  ```
96
97
 
98
+ `createRoot` owns what a view starts, and `resource` loads data for it. Disposing the root aborts the request, and a late answer writes nothing:
99
+
100
+ ```typescript
101
+ import { createRoot, effect, el, resource, signal } from "@cplieger/reactive";
102
+
103
+ const id = signal("a");
104
+ const view = el("p");
105
+ document.body.append(view);
106
+
107
+ const dispose = createRoot((dispose) => {
108
+ const item = resource(
109
+ () => id.value,
110
+ (value, signal) => fetch(`/items/${value}`, { signal }).then((res) => res.text()),
111
+ );
112
+ effect(() => {
113
+ const s = item.value;
114
+ view.textContent = s.state === "ready" ? s.data : s.state;
115
+ });
116
+ return dispose;
117
+ });
118
+
119
+ id.value = "b"; // the request for "a" is aborted and its answer is dropped
120
+ dispose();
121
+ ```
122
+
97
123
  ## API
98
124
 
99
125
  - Signals: `signal`, `computed`, `effect`, `batch`, `untracked`, `touch`, `on`, `subscribe`, `isSignal`, `isComputed` and `setEffectErrorHandler`.
126
+ - Ownership and async: `createRoot` and `runWithOwner` for owners that dispose a tree of effects, and `resource` for async data as a `Loadable`.
100
127
  - State: `createStore` for a fixed set of typed keys, `SignalMap` for keys known only at run time, and `createCollection` for an ordered list of keyed items.
101
128
  - DOM: `el` builds elements, `reconcile` and `bindList` render keyed lists, and `patch` updates a subtree in place. `trackHandler` registers an `on*` handler on an element you built without `el`, so `patch` keeps it in step.
102
129
  - Events: `createBus` for typed events that hold no value.
@@ -129,26 +156,26 @@ This is the contract [@preact/signals-core](https://github.com/preactjs/signals/
129
156
 
130
157
  The package leaves these out on purpose. [Non-goals](docs/non-goals.md) gives the reason for each.
131
158
 
132
- - An ownership tree or `createRoot`, and automatic disposal of nested effects.
159
+ - Ownership outside a root, and a public `getOwner()`.
133
160
  - Lazy activation and an `onMount` lifecycle.
134
161
  - `Signal.subtle.Watcher` and notify-on-dirty.
135
162
  - Introspection APIs, and explicit disposal of a computed.
136
163
  - Server-side rendering and per-request isolation.
137
- - Async signals and resources.
164
+ - Suspense, or a resource that throws while loading.
138
165
  - Transactions, a custom scheduler, and a flush barrier such as `flushSync` or `nextTick`.
139
166
  - Controlled form inputs. `patch` syncs form values, so if a view can re-render while a user types, drive that field from an effect instead.
140
167
  - Writing a signal from inside a computed. The computed's own subscribers are not woken, so make that write in an effect.
141
168
 
142
169
  ## Documentation
143
170
 
144
- - [Signals, stores and collections](docs/signals.md) covers the flush model, effects, stores, collections and the event bus, for a reader writing state code.
171
+ - [Signals, stores and collections](docs/signals.md) covers the flush model, effects, owners, resources, stores, collections and the event bus, for a reader writing state code.
145
172
  - [Building and updating the DOM](docs/dom.md) covers how `el`, `reconcile`, `bindList` and `patch` decide what to keep, for a reader rendering with them.
146
173
  - [Non-goals](docs/non-goals.md) lists what the package leaves out and why.
147
174
 
148
175
  ## Credits
149
176
 
150
177
  - The signal engine follows the design of [@preact/signals-core](https://github.com/preactjs/signals) in its dependency graph, its glitch-free computed refresh, its run-on-write effects and its untracked `subscribe` callback.
151
- - The `on()` helper follows [Solid](https://github.com/solidjs/solid)'s `on()`, and `untracked()` is Solid's `untrack()` under the name Preact gives it.
178
+ - The `on()` helper follows [Solid](https://github.com/solidjs/solid)'s `on()`, and `untracked()` is Solid's `untrack()` under the name Preact gives it. `createRoot`, `runWithOwner` and owned nested effects follow Solid's ownership model.
152
179
 
153
180
  ## Contributing
154
181
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  No third-party code is included in this repository. The signal engine is a re-derivation of an upstream design, not a copy of its source, so no license text is reproduced here.
4
4
 
5
- Two designs are followed, each named at the line of ours that follows it:
5
+ Two designs are followed:
6
6
 
7
- - The dependency graph and the scheduler in `src/signal.ts` follow [@preact/signals-core](https://github.com/preactjs/signals) (MIT): the doubly-linked source/target edge nodes (`DepNode`, `SourceNode`, `TargetNode` at `src/signal.ts:75-100`), the pull-based glitch-free `refreshComputed` (`src/signal.ts:327`), the eager drain in `endBatch` (`src/signal.ts:397`), and the untracked `subscribe` callback (`src/signal.ts:711`, upstream `preactjs/signals#188`). The pinned baseline is `@preact/signals-core@1.14.4`; the header of `src/signal.ts` records every deliberate delta from it and the upstream machinery this engine does not carry, and `CONTRIBUTING.md` records the drift-audit procedure.
8
- - The explicit dependency helper `on()` (`src/signal.ts:734`) follows [solid-js](https://github.com/solidjs/solid) (MIT), whose `on()` has the same deps-plus-body shape; `untracked()` (`src/signal.ts:666`) is Solid's `untrack()` under the name Preact gives it.
7
+ - The dependency graph and the scheduler in `src/signal.ts` follow [@preact/signals-core](https://github.com/preactjs/signals) (MIT): the doubly-linked source and target edge nodes (`DepNode`, `SourceNode` and `TargetNode`), the pull-based glitch-free `refreshComputed`, the eager drain in `endBatch`, and the untracked `subscribe` callback (upstream `preactjs/signals#188`). The baseline is `@preact/signals-core@1.14.4`. `CONTRIBUTING.md` lists every deliberate delta from it and the upstream machinery this engine does not carry.
8
+ - The explicit dependency helper `on()` in `src/signal.ts` follows [solid-js](https://github.com/solidjs/solid) (MIT), whose `on()` has the same deps-plus-body shape. `untracked()` is Solid's `untrack()` under the name Preact gives it.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "2.2.0",
3
+ "version": "2.3.0-dev.1",
4
4
  "license": "Apache-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "2.2.0",
3
+ "version": "2.3.0-dev.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -21,15 +21,15 @@
21
21
  "@stryker-mutator/typescript-checker": "10.0.0",
22
22
  "@stryker-mutator/vitest-runner": "10.0.0",
23
23
  "@typescript/native": "npm:typescript@7.0.2",
24
- "@vitest/browser-playwright": "5.0.3",
24
+ "@vitest/browser-playwright": "5.0.4",
25
25
  "eslint": "10.12.0",
26
26
  "fast-check": "4.10.2",
27
- "knip": "6.40.0",
28
- "playwright": "1.64.0",
29
- "prettier": "3.9.9",
27
+ "knip": "6.41.0",
28
+ "playwright": "1.64.1",
29
+ "prettier": "3.9.10",
30
30
  "typescript": "npm:@typescript/typescript6@6.0.2",
31
- "typescript-eslint": "8.71.1",
32
- "vitest": "5.0.3"
31
+ "typescript-eslint": "8.71.2",
32
+ "vitest": "5.0.4"
33
33
  },
34
34
  "overrides": {
35
35
  "qs": "^6.15.2"
package/src/bind-list.ts CHANGED
@@ -1,21 +1,13 @@
1
- // bind-list.ts — two-tier render binding for a reactive list source.
2
- //
3
- // Tier 1 (structure): one effect tracks `source.ids` and runs `reconcile`
4
- // over the id list, so add/remove/reorder touch the DOM minimally.
5
- // Tier 2 (content): each row owns a private effect that tracks ONLY its own
6
- // entity signal, so a per-entity update repaints just that row without a
7
- // structural reconcile. The engine isolates nested-effect tracking
8
- // (evalContext save/restore in signal.ts), so the per-row effect's reads never
9
- // leak into the structural effect.
10
- //
11
- // The source is a `ListSource<T>` — anything exposing a structure signal
12
- // (`ids`) plus a per-id content signal lookup (`signalFor`). A `Collection<T>`
13
- // satisfies it directly; a filtered/sorted/paginated view is just a `computed`
14
- // id-list paired with the collection's `signalFor` (so pagination is a sliced
15
- // view, not a separate primitive).
1
+ // Two-tier render binding for a reactive list source. One structural effect
2
+ // tracks `source.ids` and reconciles the id list; each row owns an effect that
3
+ // tracks only its entity signal, so an entity update repaints just that row.
4
+ // The engine saves and restores evalContext around nested effects, so a row
5
+ // effect's reads never leak into the structural effect. A `Collection<T>` is a
6
+ // `ListSource<T>`; a filtered, sorted or paginated view is a `computed` id
7
+ // list paired with the collection's `signalFor`.
16
8
 
17
9
  import { reconcile } from "./reconcile.js";
18
- import { effect, type ReadonlySignal } from "./signal.js";
10
+ import { createRoot, createRootUnder, effect, type Owner, type ReadonlySignal } from "./signal.js";
19
11
 
20
12
  /** The minimal reactive surface `bindList` renders: a structure signal (the
21
13
  * ordered ids) and a per-id content-signal lookup. `Collection<T>` satisfies
@@ -27,79 +19,79 @@ export interface ListSource<T> {
27
19
  signalFor(id: string): ReadonlySignal<T> | undefined;
28
20
  }
29
21
 
30
- /** Row lifecycle for `bindList`. */
22
+ /** Row lifecycle for `bindList`. Each row has its own owner, so effects and
23
+ * roots created in `mount` and `update` are disposed when the row leaves. */
31
24
  export interface ListSpec<T> {
32
25
  /** Create the row element for a new entity. Receives the entity's current
33
- * value and id. Keep this to structural shell creation; let `update` fill
34
- * reactive content (it runs once at mount and on every later change). */
35
- mount: (item: T, id: string) => HTMLElement;
26
+ * value, its id and the row's owner, whose `signal` aborts when the row
27
+ * leaves or the list is disposed. Keep this to structural shell creation;
28
+ * let `update` fill reactive content (it runs once at mount and on every
29
+ * later change). */
30
+ mount: (item: T, id: string, owner: Owner) => HTMLElement;
36
31
  /** Update a row when its entity changes. Runs immediately at mount and again
37
32
  * on every per-entity change (the content tier). Omit for immutable rows
38
33
  * whose `mount` renders everything. */
39
34
  update?: (el: HTMLElement, item: T, id: string) => void;
40
- /** Optional cleanup when a row leaves the list (fires before the element is
41
- * removed from the DOM). */
35
+ /** Optional cleanup when a row leaves the list, after its owner is disposed
36
+ * and before the element is removed from the DOM. */
42
37
  onRemove?: (el: HTMLElement, id: string) => void;
43
38
  }
44
39
 
45
- /** Bind a list source to a parent node with two-tier reactivity. Returns a
46
- * dispose function that tears down the structural effect and every row
47
- * effect. */
40
+ /** Bind a list source to a parent node with two-tier reactivity, in a root
41
+ * owned by the current owner when there is one. Returns a dispose function
42
+ * that tears down the structural effect and every row. */
48
43
  export function bindList<T>(
49
44
  parent: ParentNode,
50
45
  source: ListSource<T>,
51
46
  spec: ListSpec<T>,
52
47
  ): () => void {
53
- const rowDisposers = new Map<string, () => void>();
48
+ return createRoot((dispose, listOwner) => {
49
+ const rowDisposers = new WeakMap<HTMLElement, () => void>();
54
50
 
55
- const disposeStructural = effect(() => {
56
- // Structure tier: re-runs on add/remove/reorder only. An id whose entity
57
- // signal is missing (an inconsistent ListSource) is skipped instead of
58
- // mounting a row with a manufactured `undefined as T` value; it renders
59
- // once the source becomes consistent (the next `ids` change).
60
- const ids = source.ids.value.filter((id) => source.signalFor(id) !== undefined);
61
- reconcile<string>(parent, ids, {
62
- key: (id) => id,
63
- mount: (id) => {
64
- const sig = source.signalFor(id);
65
- if (sig === undefined) {
66
- // Unreachable via the structural filter above (same synchronous
67
- // pass); reachable only by a source whose signalFor is not stable
68
- // within one read — surface that loudly instead of manufacturing
69
- // an entity value. Routed to the effect error handler.
70
- throw new Error(`bindList: no entity signal for id "${id}"`);
71
- }
72
- const el = spec.mount(sig.peek(), id);
73
- if (spec.update !== undefined) {
74
- const update = spec.update;
75
- // content tier: isolated per-row effect; runs now (fills content)
76
- // and on every later change to THIS entity.
77
- rowDisposers.set(
78
- id,
79
- effect(() => {
80
- const cur = source.signalFor(id);
81
- if (cur !== undefined) {
82
- update(el, cur.value, id);
83
- }
84
- }),
85
- );
86
- }
87
- return el;
88
- },
89
- // No structural `update`: content is owned entirely by the per-row effect.
90
- onRemove: (el, id) => {
91
- rowDisposers.get(id)?.();
92
- rowDisposers.delete(id);
93
- spec.onRemove?.(el, id);
94
- },
51
+ effect(() => {
52
+ // Structure tier: re-runs on add/remove/reorder only. An id whose entity
53
+ // signal is missing (an inconsistent ListSource) is skipped instead of
54
+ // mounting a row with a manufactured `undefined as T` value; it renders
55
+ // once the source becomes consistent (the next `ids` change).
56
+ const ids = source.ids.value.filter((id) => source.signalFor(id) !== undefined);
57
+ reconcile<string>(parent, ids, {
58
+ key: (id) => id,
59
+ mount: (id) => {
60
+ const sig = source.signalFor(id);
61
+ if (sig === undefined) {
62
+ // Unreachable via the structural filter above (same synchronous
63
+ // pass); reachable only by a source whose signalFor is not stable
64
+ // within one read — surface that loudly instead of manufacturing
65
+ // an entity value. Routed to the effect error handler.
66
+ throw new Error(`bindList: no entity signal for id "${id}"`);
67
+ }
68
+ // A row belongs to the list, not to this structural run, whose
69
+ // scope is disposed on every add, remove or reorder.
70
+ return createRootUnder(listOwner, (disposeRow, rowOwner) => {
71
+ const el = spec.mount(sig.peek(), id, rowOwner);
72
+ rowDisposers.set(el, disposeRow);
73
+ if (spec.update !== undefined) {
74
+ const update = spec.update;
75
+ // content tier: isolated per-row effect; runs now (fills content)
76
+ // and on every later change to THIS entity.
77
+ effect(() => {
78
+ const cur = source.signalFor(id);
79
+ if (cur !== undefined) {
80
+ update(el, cur.value, id);
81
+ }
82
+ });
83
+ }
84
+ return el;
85
+ });
86
+ },
87
+ // No structural `update`: content is owned entirely by the per-row effect.
88
+ onRemove: (el, id) => {
89
+ rowDisposers.get(el)?.();
90
+ spec.onRemove?.(el, id);
91
+ },
92
+ });
95
93
  });
96
- });
97
94
 
98
- return () => {
99
- disposeStructural();
100
- for (const dispose of rowDisposers.values()) {
101
- dispose();
102
- }
103
- rowDisposers.clear();
104
- };
95
+ return dispose;
96
+ });
105
97
  }
package/src/collection.ts CHANGED
@@ -12,10 +12,9 @@ import { SignalMap } from "./signal-map.js";
12
12
  /** A reactive, ordered, keyed collection of entities of type T. */
13
13
  export interface Collection<T> {
14
14
  /** Replace the whole collection (e.g. initial load). Entities no longer
15
- * present are dropped; the order becomes `items`, deduplicated by key
16
- * (first occurrence keeps the position, the last value wins — Map
17
- * semantics), so the order tier can never list an id twice while the
18
- * entity tier holds a single signal for it. */
15
+ * present are dropped; the order becomes `items`, deduplicated by key:
16
+ * the first occurrence keeps the position, and a later value replaces the
17
+ * held one only when `equals` reports a change. */
19
18
  setAll(items: readonly T[]): void;
20
19
  /** Add a new entity or replace an existing one (keyed by `keyOf`). New
21
20
  * entities are appended to the order. */
@@ -49,15 +48,19 @@ export interface Collection<T> {
49
48
  }
50
49
 
51
50
  /** Create a reactive ordered keyed collection. `keyOf` derives a stable
52
- * string id from an entity. */
53
- export function createCollection<T>(keyOf: (item: T) => string): Collection<T> {
51
+ * string id from an entity; `equals` decides when a write of an entity is a
52
+ * change to notify, `Object.is` when omitted. */
53
+ export function createCollection<T>(
54
+ keyOf: (item: T) => string,
55
+ options?: { readonly equals?: (a: T, b: T) => boolean },
56
+ ): Collection<T> {
54
57
  // Shallow-equal so the structure tier fires only when the id sequence
55
58
  // actually changes (a same-order setAll updates entities without a
56
59
  // spurious structural re-render).
57
60
  const order = signal<readonly string[]>([], {
58
61
  equals: (a, b) => a.length === b.length && a.every((x, i) => x === b[i]),
59
62
  });
60
- const sigs = new SignalMap<T>();
63
+ const sigs = new SignalMap<T>(options);
61
64
 
62
65
  function setAll(items: readonly T[]): void {
63
66
  // Atomic: batch the per-entity writes + the order swap so effects (and any
@@ -68,9 +71,7 @@ export function createCollection<T>(keyOf: (item: T) => string): Collection<T> {
68
71
  const seen = new Set<string>();
69
72
  for (const item of items) {
70
73
  const id = keyOf(item);
71
- // Deduplicate repeated keys (first occurrence keeps the position, the
72
- // last value wins) so the structure and entity tiers cannot disagree:
73
- // one signal per id, one order entry per id.
74
+ // One order slot per id, matching the one signal per id.
74
75
  if (!seen.has(id)) {
75
76
  seen.add(id);
76
77
  ids.push(id);
package/src/index.ts CHANGED
@@ -10,14 +10,19 @@ export {
10
10
  isSignal,
11
11
  isComputed,
12
12
  on,
13
+ createRoot,
14
+ runWithOwner,
13
15
  } from "./signal.js";
14
16
  export type {
15
17
  Signal,
16
18
  Cleanup,
17
19
  EffectErrorHandler,
20
+ Owner,
18
21
  ReadonlySignal,
19
22
  SignalOptions,
20
23
  } from "./signal.js";
24
+ export { resource } from "./resource.js";
25
+ export type { Loadable, Resource, ResourceOptions } from "./resource.js";
21
26
  export { createStore } from "./store.js";
22
27
  export type { Store } from "./store.js";
23
28
  export { SignalMap } from "./signal-map.js";
@@ -0,0 +1,102 @@
1
+ import {
2
+ createRoot,
3
+ effect,
4
+ requireOwner,
5
+ runWithOwner,
6
+ signal,
7
+ type ReadonlySignal,
8
+ } from "./signal.js";
9
+
10
+ /** The state of an async value: loading, ready or failed. `stale` is the last
11
+ * ready value, kept while a later run loads or after it fails. */
12
+ export type Loadable<T, E = unknown> =
13
+ | { readonly state: "loading"; readonly stale?: T }
14
+ | { readonly state: "ready"; readonly data: T }
15
+ | { readonly state: "error"; readonly error: E; readonly stale?: T };
16
+
17
+ /** A signal of the latest run's `Loadable`, with `refetch` to start a new run. */
18
+ export interface Resource<T, E = unknown> extends ReadonlySignal<Loadable<T, E>> {
19
+ /** Start a new run for the current source value, superseding the running
20
+ * one. Does nothing once the resource's owner is being disposed. */
21
+ refetch(): void;
22
+ }
23
+
24
+ /** Options for `resource`. */
25
+ export interface ResourceOptions<S> {
26
+ /** Source equality; an equal new source value starts no run. Defaults to
27
+ * `Object.is`. */
28
+ readonly equals?: (a: S, b: S) => boolean;
29
+ }
30
+
31
+ /** Load `load(source(), signal)` now and again whenever the tracked `source`
32
+ * changes, owned by the current owner. Each run has its own owner, whose
33
+ * `signal` is the one passed to `load` and which is current while `load`
34
+ * runs: a new source value, `refetch()` or the resource's disposal disposes
35
+ * the previous run, and a disposed run's settlement writes nothing. `E` is
36
+ * whatever `load` throws or rejects with. A throw from `source` goes to the
37
+ * effect error handler and starts no run. Throws outside an owner, or under
38
+ * one being disposed. */
39
+ export function resource<S, T, E = unknown>(
40
+ source: () => S,
41
+ load: (source: S, signal: AbortSignal) => Promise<T>,
42
+ options?: ResourceOptions<S>,
43
+ ): Resource<T, E> {
44
+ const owner = requireOwner("a resource");
45
+ const equals = options?.equals ?? Object.is;
46
+ const state = signal<Loadable<T, E>>({ state: "loading" });
47
+ let good: { readonly data: T } | undefined;
48
+ let current: { readonly value: S } | undefined;
49
+ let disposeRun: (() => void) | undefined;
50
+
51
+ const start = (value: S): void => {
52
+ runWithOwner(owner, () => {
53
+ disposeRun?.();
54
+ createRoot((dispose, run) => {
55
+ disposeRun = dispose;
56
+ state.value =
57
+ good === undefined ? { state: "loading" } : { state: "loading", stale: good.data };
58
+ // The async wrapper turns a synchronous throw from `load` into a rejection.
59
+ const pending = (async (): Promise<T> => load(value, run.signal))();
60
+ pending.then(
61
+ (data) => {
62
+ if (!run.signal.aborted) {
63
+ good = { data };
64
+ state.value = { state: "ready", data };
65
+ }
66
+ },
67
+ (error: unknown) => {
68
+ if (!run.signal.aborted) {
69
+ state.value =
70
+ good === undefined
71
+ ? { state: "error", error: error as E }
72
+ : { state: "error", error: error as E, stale: good.data };
73
+ }
74
+ },
75
+ );
76
+ });
77
+ });
78
+ };
79
+
80
+ effect(() => {
81
+ const value = source();
82
+ const prev = current;
83
+ current = { value };
84
+ if (prev === undefined || !equals(prev.value, value)) {
85
+ start(value);
86
+ }
87
+ });
88
+
89
+ return {
90
+ get value(): Loadable<T, E> {
91
+ return state.value;
92
+ },
93
+ peek(): Loadable<T, E> {
94
+ return state.peek();
95
+ },
96
+ refetch(): void {
97
+ if (current !== undefined) {
98
+ start(current.value);
99
+ }
100
+ },
101
+ };
102
+ }
package/src/signal-map.ts CHANGED
@@ -11,6 +11,13 @@ import { signal, type Signal } from "./signal.js";
11
11
  * get / ensure / clear / clearAll over any value type. */
12
12
  export class SignalMap<V> {
13
13
  private readonly map = new Map<string, Signal<V>>();
14
+ private readonly equals: ((a: V, b: V) => boolean) | undefined;
15
+
16
+ /** `equals` decides when a write to one of the map's signals is a change;
17
+ * `Object.is` when omitted. */
18
+ constructor(options?: { readonly equals?: (a: V, b: V) => boolean }) {
19
+ this.equals = options?.equals;
20
+ }
14
21
 
15
22
  /** The signal for `id`, or undefined if none has been created yet. */
16
23
  get(id: string): Signal<V> | undefined {
@@ -21,7 +28,7 @@ export class SignalMap<V> {
21
28
  ensure(id: string, initial: V): Signal<V> {
22
29
  let sig = this.map.get(id);
23
30
  if (sig === undefined) {
24
- sig = signal(initial);
31
+ sig = this.equals === undefined ? signal(initial) : signal(initial, { equals: this.equals });
25
32
  this.map.set(id, sig);
26
33
  }
27
34
  return sig;
package/src/signal.ts CHANGED
@@ -1,65 +1,10 @@
1
- // Reactive signals: signal<T>, effect (with cleanup), batch, computed, untracked.
2
- // Preact-style doubly-linked source/target edges, pull-based glitch-free refresh,
3
- // per-node version fast-skip, bitfield flags.
4
- // Apache-2.0.
5
- //
6
- // FLUSH MODEL — one phase, and the whole of it:
7
- // 1. A write flushes. When `sig.value = x` returns, every effect that depends
8
- // on `sig` has already re-run. The setter opens an implicit batch, notifies,
9
- // and drains before it returns, so there is no pending-effect state a caller
10
- // can observe and no queue to drain.
11
- // 2. `batch(fn)` is the only deferral. Writes inside it coalesce; effects are
12
- // held and run once, synchronously, when the OUTERMOST batch returns.
13
- // 3. Deferral beyond that is the caller's. To get effects on a later task,
14
- // write on a later task (queueMicrotask, requestAnimationFrame). This module
15
- // owns no scheduler and offers no flush barrier, because it never holds work.
16
- //
17
- // Clause 3 is why there is no `flushSync()`. A flush barrier is the API a
18
- // DEFERRED model owes its callers — Vue's `nextTick`, React's `flushSync` — and
19
- // an eager one owes none. The export existed until v2.0.0, could never find work
20
- // pending (endBatch drains the moment the depth reaches 0) and refused to act
21
- // when work WAS pending (it returned early above depth 0), so it was a no-op from
22
- // every position a caller could occupy; six mutants over its body were
23
- // permanently unkillable, and 71 consumer call sites had never done anything.
24
- // Anything reintroducing a barrier here is reintroducing that: the barrier is not
25
- // the missing piece, clause 1 is.
26
- //
27
- // Port provenance: graph/scheduler semantics verified against
28
- // @preact/signals-core@1.14.4 (drift audit 2026-07-17). The flush model above is
29
- // upstream's, including the absence of a barrier: upstream documents the write
30
- // contract in prose ("Changing a signal's value synchronously updates every
31
- // computed and effect that depends on that signal") and its surface is
32
- // signal/computed/effect/batch/untracked/createModel. Deliberate deltas from
33
- // upstream, pinned by tests: Object.is equality incl. NaN (upstream:
34
- // `!==`); custom `equals` / `equals: false` options (upstream has none);
35
- // isolate-and-continue effect error policy via setEffectErrorHandler
36
- // (upstream disposes-and-rethrows); the cycle guard lives in endBatch's drain
37
- // and un-notifies the chain it drops (upstream guards ONLY in the signal
38
- // setter, evaluated before the write, so it never drops a chain); and the drain
39
- // is re-entrant — `if (--batchDepth > 0) return` lets a write from an effect
40
- // body start a fresh drain, where upstream's `if (batchDepth > 1) return` plus
41
- // a post-drain `batchDepth--` keeps one drain per outermost batch.
42
- //
43
- // Upstream machinery NOT ported by design:
44
- // - watched/unwatched hooks, createModel/action (1.13+);
45
- // - the 1.14.x batch-snapshot A->B->A revert optimization — the
46
- // version-monotonicity bug its first release carried
47
- // (preactjs/signals#947) never existed here;
48
- // - the `globalVersion` epoch fast-skip in refreshComputed. All three
49
- // refresh call sites pre-check DIRTY, and because this port never
50
- // unsubscribes a computed from its sources, notifyTargets always reaches
51
- // every dependent computed — so DIRTY is a complete staleness signal.
52
- // Upstream needs the epoch for computeds with no subscribers, which have
53
- // no live edges. Do NOT try to revive it by deleting the DIRTY pre-checks:
54
- // this refreshComputed has neither upstream's TRACKING shortcut nor its
55
- // internal needsToRecompute gate, so an epoch miss recomputes
56
- // unconditionally and six recompute-gating tests go red.
57
- // - upstream's endBatch error capture and rethrow. Effect errors are
58
- // isolated inside runEffect (see its contract below), so nothing can throw
59
- // out of the drain and there was never anything to capture.
60
- //
61
- // Re-audit on upstream releases: diff src/index.ts against this file's
62
- // mechanisms and harvest engine-internal fixes (see CONTRIBUTING.md).
1
+ // Signal engine: preact-style linked edges, pull-based glitch-free refresh.
2
+ // User contract: docs/signals.md; upstream deltas: CONTRIBUTING.md. Invariants:
3
+ // - A write flushes: when the setter returns, every dependent effect has run,
4
+ // except those still running, which are never interrupted; each one whose
5
+ // current run had already read the written signal re-runs once it returns.
6
+ // - batch() is the only deferral; its effects run when the outermost returns.
7
+ // - The drain is re-entrant: a write inside an effect starts a nested drain.
63
8
 
64
9
  const SIGNAL_BRAND = Symbol("signal");
65
10
  const COMPUTED_BRAND = Symbol("computed");
@@ -69,6 +14,7 @@ const DIRTY = 1;
69
14
  const RUNNING = 4;
70
15
  const DISPOSED = 8;
71
16
  const HAS_ERROR = 16;
17
+ const RERUN = 32;
72
18
  const NOTIFIED = 64;
73
19
 
74
20
  // --- Linked-list Node for source↔target edges ---
@@ -113,6 +59,8 @@ interface EffectNode {
113
59
  _sources: DepNode | undefined;
114
60
  _nextBatchedEffect: EffectNode | undefined;
115
61
  _isComputed: false;
62
+ _owner: OwnerNode | undefined;
63
+ _scope: OwnerNode | undefined;
116
64
  }
117
65
 
118
66
  // --- Globals ---
@@ -150,6 +98,216 @@ export function setEffectErrorHandler(handler: EffectErrorHandler): EffectErrorH
150
98
  return prev;
151
99
  }
152
100
 
101
+ // --- Ownership ---
102
+ const OPEN = 0;
103
+ const CLOSING = 1;
104
+ const CLOSED = 2;
105
+
106
+ let currentOwner: OwnerNode | undefined;
107
+
108
+ /** A disposal scope made by `createRoot`. Effects and roots created while it is
109
+ * current are disposed with it. */
110
+ export interface Owner {
111
+ /** Aborted when the owner is disposed, after its children and callbacks. */
112
+ readonly signal: AbortSignal;
113
+ /** Run `fn` when the owner is disposed, after its children, in reverse
114
+ * registration order. On an owner already being disposed, or disposed, `fn`
115
+ * runs at once. A throw goes to the effect error handler. */
116
+ onDispose(fn: () => void): void;
117
+ }
118
+
119
+ class OwnerNode implements Owner {
120
+ _state = OPEN;
121
+ _parent: OwnerNode | undefined;
122
+ _children: Set<OwnerNode | EffectNode> | undefined;
123
+ _callbacks: (() => void)[] | undefined;
124
+ // Created on first read: every run of an owned effect makes a scope, and
125
+ // few of them are ever asked for a signal.
126
+ _controller: AbortController | undefined;
127
+
128
+ constructor(parent: OwnerNode | undefined) {
129
+ this._parent = parent;
130
+ parent?._adopt(this);
131
+ }
132
+
133
+ get signal(): AbortSignal {
134
+ if (this._controller === undefined) {
135
+ this._controller = new AbortController();
136
+ if (this._state === CLOSED) {
137
+ this._controller.abort();
138
+ }
139
+ }
140
+ return this._controller.signal;
141
+ }
142
+
143
+ onDispose(fn: () => void): void {
144
+ if (this._state === OPEN) {
145
+ (this._callbacks ??= []).push(fn);
146
+ return;
147
+ }
148
+ runLateDisposeCallback(this, fn);
149
+ }
150
+
151
+ _adopt(child: OwnerNode | EffectNode): void {
152
+ (this._children ??= new Set()).add(child);
153
+ }
154
+ }
155
+
156
+ function assertOpen(owner: OwnerNode | undefined, what: string): void {
157
+ if (owner !== undefined && owner._state !== OPEN) {
158
+ throw new Error(`reactive: cannot create ${what} under an owner that is being disposed`);
159
+ }
160
+ }
161
+
162
+ function ownerNode(owner: Owner): OwnerNode {
163
+ if (owner instanceof OwnerNode) {
164
+ return owner;
165
+ }
166
+ throw new TypeError("reactive: not an owner made by createRoot");
167
+ }
168
+
169
+ function runDisposeCallback(fn: () => void): void {
170
+ try {
171
+ fn();
172
+ } catch (e) {
173
+ safeCallHandler(e);
174
+ }
175
+ }
176
+
177
+ function runLateDisposeCallback(owner: OwnerNode, fn: () => void): void {
178
+ const prevOwner = currentOwner;
179
+ const prevContext = evalContext;
180
+ currentOwner = owner;
181
+ evalContext = undefined;
182
+ runDisposeCallback(fn);
183
+ currentOwner = prevOwner;
184
+ evalContext = prevContext;
185
+ }
186
+
187
+ function disposeOwner(owner: OwnerNode): void {
188
+ if (owner._state === OPEN) {
189
+ markClosing(owner);
190
+ teardownOwner(owner);
191
+ }
192
+ }
193
+
194
+ // The whole subtree is marked before anything is torn down, so a write made by
195
+ // a cleanup or callback mid-walk cannot re-run an effect of this subtree: the
196
+ // drain skips DISPOSED effects.
197
+ function markClosing(owner: OwnerNode): void {
198
+ owner._state = CLOSING;
199
+ if (owner._children === undefined) {
200
+ return;
201
+ }
202
+ for (const child of owner._children) {
203
+ if (child instanceof OwnerNode) {
204
+ markClosing(child);
205
+ } else {
206
+ child._flags |= DISPOSED;
207
+ if (child._scope !== undefined) {
208
+ markClosing(child._scope);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ // Invariant: nothing in this walk throws (callbacks are isolated, effect
215
+ // teardown never throws), so the context restore and the abort need no finally.
216
+ function teardownOwner(owner: OwnerNode): void {
217
+ owner._parent?._children?.delete(owner);
218
+ owner._parent = undefined;
219
+ const prevOwner = currentOwner;
220
+ const prevContext = evalContext;
221
+ currentOwner = owner;
222
+ evalContext = undefined;
223
+ const children = owner._children;
224
+ owner._children = undefined;
225
+ if (children !== undefined) {
226
+ for (const child of [...children].reverse()) {
227
+ if (child instanceof OwnerNode) {
228
+ teardownOwner(child);
229
+ } else {
230
+ releaseEffect(child);
231
+ }
232
+ }
233
+ }
234
+ const callbacks = owner._callbacks;
235
+ owner._callbacks = undefined;
236
+ if (callbacks !== undefined) {
237
+ for (const fn of callbacks.reverse()) {
238
+ runDisposeCallback(fn);
239
+ }
240
+ }
241
+ currentOwner = prevOwner;
242
+ evalContext = prevContext;
243
+ owner._controller?.abort();
244
+ owner._state = CLOSED;
245
+ }
246
+
247
+ /** @internal The current owner, for a primitive that refuses to exist without
248
+ * one. Throws outside any owner, or under one being disposed. */
249
+ export function requireOwner(what: string): Owner {
250
+ if (currentOwner === undefined) {
251
+ throw new Error(`reactive: cannot create ${what} outside an owner`);
252
+ }
253
+ assertOpen(currentOwner, what);
254
+ return currentOwner;
255
+ }
256
+
257
+ /** @internal `createRoot` with an explicit parent, for `bindList`'s rows. */
258
+ export function createRootUnder<T>(
259
+ parent: Owner | undefined,
260
+ fn: (dispose: () => void, owner: Owner) => T,
261
+ ): T {
262
+ const parentNode = parent === undefined ? undefined : ownerNode(parent);
263
+ assertOpen(parentNode, "a root");
264
+ const owner = new OwnerNode(parentNode);
265
+ const prevOwner = currentOwner;
266
+ const prevContext = evalContext;
267
+ currentOwner = owner;
268
+ evalContext = undefined;
269
+ try {
270
+ return fn(() => {
271
+ disposeOwner(owner);
272
+ }, owner);
273
+ } catch (e) {
274
+ disposeOwner(owner);
275
+ throw e;
276
+ } finally {
277
+ currentOwner = prevOwner;
278
+ evalContext = prevContext;
279
+ }
280
+ }
281
+
282
+ /** Run `fn` untracked in a new owner, child of the current one when there is
283
+ * one, and return its result. Effects and roots created inside are disposed by
284
+ * `dispose`, or with the parent. A throw from `fn` disposes the root and
285
+ * rethrows. Throws when the current owner is being disposed. */
286
+ export function createRoot<T>(fn: (dispose: () => void, owner: Owner) => T): T {
287
+ return createRootUnder(currentOwner, fn);
288
+ }
289
+
290
+ /** Run `fn` untracked with `owner` current, so what it creates is owned by
291
+ * `owner`. Returns `undefined` without calling `fn` once `owner` is being
292
+ * disposed or is disposed. Throws a TypeError for an object `createRoot` did
293
+ * not make. */
294
+ export function runWithOwner<T>(owner: Owner, fn: () => T): T | undefined {
295
+ const node = ownerNode(owner);
296
+ if (node._state !== OPEN) {
297
+ return undefined;
298
+ }
299
+ const prevOwner = currentOwner;
300
+ const prevContext = evalContext;
301
+ currentOwner = node;
302
+ evalContext = undefined;
303
+ try {
304
+ return fn();
305
+ } finally {
306
+ currentOwner = prevOwner;
307
+ evalContext = prevContext;
308
+ }
309
+ }
310
+
153
311
  // --- Dependency tracking ---
154
312
  function addDependency(source: SourceNode): DepNode | undefined {
155
313
  if (evalContext === undefined) {
@@ -269,19 +427,13 @@ function notifyTargets(source: SourceNode): void {
269
427
  if (target._isComputed) {
270
428
  // It's a ComputedNode
271
429
  if (target._flags & RUNNING) {
272
- // A source written while this computed is mid-evaluation. Mark it stale
273
- // so the next read re-runs it — every refresh call site pre-checks
274
- // DIRTY, so without this the computed serves its pre-write value
275
- // forever — but do NOT recurse: its own version has not moved yet, and
276
- // recursing out of a RUNNING node is how a notify ping-pong is built.
277
- //
278
- // This repairs the PULL side only, deliberately. Two limits the next
279
- // reader should not have to rediscover: (1) the computed's own
280
- // subscribers are not woken here, so an effect that depends on it will
281
- // not re-run until something else notifies it; (2) a computed that
282
- // writes its own source unconditionally costs one extra recompute per
283
- // read, and inside an effect it degrades into the documented
284
- // MAX_BATCH_ITERATIONS "Cycle detected" path rather than hanging.
430
+ // A source written while this computed is mid-evaluation: mark it DIRTY
431
+ // so the next read recomputes (refresh callers check DIRTY first), but
432
+ // do not recurse: its version has not moved, and recursing out of a
433
+ // RUNNING node builds a notify ping-pong. Pull side only: its subscribers
434
+ // are not woken here, and a computed writing its own source costs one
435
+ // extra recompute per read, ending in the MAX_BATCH_ITERATIONS "Cycle
436
+ // detected" path inside an effect rather than hanging.
285
437
  target._flags |= DIRTY;
286
438
  } else if (!(target._flags & NOTIFIED)) {
287
439
  target._flags |= DIRTY | NOTIFIED;
@@ -289,8 +441,13 @@ function notifyTargets(source: SourceNode): void {
289
441
  target._flags &= ~NOTIFIED;
290
442
  }
291
443
  } else {
292
- // It's an EffectNode
293
- if (!(target._flags & NOTIFIED)) {
444
+ // It's an EffectNode. A running one, from its previous run's teardown to
445
+ // the end of its body, is never queued: a nested drain re-entering it
446
+ // would overwrite the scope and cleanup of the run still on the stack,
447
+ // so runEffect re-queues it once that run returns.
448
+ if (target._flags & RUNNING) {
449
+ target._flags |= RERUN;
450
+ } else if (!(target._flags & NOTIFIED)) {
294
451
  target._flags |= NOTIFIED;
295
452
  const eff = target as unknown as EffectNode;
296
453
  eff._nextBatchedEffect = batchedEffect;
@@ -324,13 +481,19 @@ function needsToRecompute(target: TargetNode): boolean {
324
481
  }
325
482
 
326
483
  // --- Computed refresh (pull-based, depth-first) ---
484
+ // Callers check DIRTY first: there is no epoch fast-skip or staleness gate in
485
+ // here, so a call on a clean computed recomputes it.
327
486
  function refreshComputed(comp: ComputedNode): void {
328
487
  if (comp._flags & RUNNING) {
329
488
  throw new Error("Cycle detected");
330
489
  }
331
490
 
332
491
  const prevContext = evalContext;
492
+ const prevOwner = currentOwner;
333
493
  evalContext = comp;
494
+ // A computed runs whenever it is read, so the reader's owner says nothing
495
+ // about it: what it creates is unowned.
496
+ currentOwner = undefined;
334
497
  const prevFlags = comp._flags;
335
498
  comp._flags = RUNNING;
336
499
 
@@ -356,6 +519,7 @@ function refreshComputed(comp: ComputedNode): void {
356
519
  }
357
520
  } finally {
358
521
  evalContext = prevContext;
522
+ currentOwner = prevOwner;
359
523
  cleanupSources(comp);
360
524
  if (!fnThrew) {
361
525
  comp._flags = comp._flags & ~RUNNING;
@@ -438,18 +602,23 @@ function endBatch(): void {
438
602
  batchIteration = enclosingIteration;
439
603
  }
440
604
 
441
- function runCleanup(eff: EffectNode): void {
605
+ // `scope` is the disposed scope of the run being cleaned up, current during
606
+ // the cleanup so an effect or root created there throws instead of escaping.
607
+ function runCleanup(eff: EffectNode, scope: OwnerNode | undefined): void {
442
608
  if (eff._cleanup) {
443
609
  const c = eff._cleanup;
444
610
  eff._cleanup = undefined;
445
611
  const prevContext = evalContext;
612
+ const prevOwner = currentOwner;
446
613
  evalContext = undefined;
614
+ currentOwner = scope;
447
615
  try {
448
616
  c();
449
617
  } catch (e) {
450
618
  safeCallHandler(e);
451
619
  } finally {
452
620
  evalContext = prevContext;
621
+ currentOwner = prevOwner;
453
622
  }
454
623
  }
455
624
  }
@@ -462,12 +631,23 @@ function runCleanup(eff: EffectNode): void {
462
631
  // mid-drain — the same shape of failure as the cycle bail's NOTIFIED leak. If
463
632
  // you add a statement here that can throw, wrap it HERE, not at the call sites.
464
633
  function runEffect(eff: EffectNode): void {
465
- // Run cleanup first (untracked)
466
- runCleanup(eff);
634
+ // Set before the previous run's teardown, so a write from its cleanup or
635
+ // disposal callbacks finds this effect RUNNING (see notifyTargets).
636
+ eff._flags |= RUNNING;
637
+ const prevScope = eff._scope;
638
+ if (prevScope !== undefined) {
639
+ eff._scope = undefined;
640
+ disposeOwner(prevScope);
641
+ }
642
+ runCleanup(eff, prevScope);
467
643
 
468
644
  // Track dependencies
469
645
  const prevContext = evalContext;
646
+ const prevOwner = currentOwner;
647
+ const scope = eff._owner === undefined ? undefined : new OwnerNode(undefined);
648
+ eff._scope = scope;
470
649
  evalContext = eff;
650
+ currentOwner = scope;
471
651
  prepareSources(eff);
472
652
 
473
653
  try {
@@ -477,14 +657,47 @@ function runEffect(eff: EffectNode): void {
477
657
  } catch (e) {
478
658
  safeCallHandler(e);
479
659
  } finally {
660
+ eff._flags &= ~RUNNING;
480
661
  evalContext = prevContext;
662
+ currentOwner = prevOwner;
481
663
  cleanupSources(eff);
482
664
  }
665
+ if (eff._flags & DISPOSED) {
666
+ // Disposed during its own run, alone or with its owner. The cleanup this
667
+ // run returned runs now, like a late onDispose, with the run's closed
668
+ // scope current; the edges the rest of the run made are dropped.
669
+ runCleanup(eff, scope);
670
+ releaseEffect(eff);
671
+ } else if (eff._flags & RERUN) {
672
+ eff._flags &= ~RERUN;
673
+ eff._flags |= NOTIFIED;
674
+ eff._nextBatchedEffect = batchedEffect;
675
+ batchedEffect = eff;
676
+ // Inside a batch this only queues it. Otherwise it drains now, one level
677
+ // deeper, so a self-write loop still meets the cycle cap, whose throw is
678
+ // routed here because this function never throws.
679
+ startBatch();
680
+ try {
681
+ endBatch();
682
+ } catch (e) {
683
+ safeCallHandler(e);
684
+ }
685
+ }
483
686
  }
484
687
 
485
- function disposeEffect(eff: EffectNode): void {
486
- // Run cleanup (untracked)
487
- runCleanup(eff);
688
+ // Disposes the run's scope first, then the cleanup, then the subscriptions.
689
+ // The caller has already marked the scope closing. Idempotent, and never
690
+ // throws: teardownOwner's walk relies on it.
691
+ function releaseEffect(eff: EffectNode): void {
692
+ eff._flags |= DISPOSED;
693
+ eff._owner?._children?.delete(eff);
694
+ eff._owner = undefined;
695
+ const scope = eff._scope;
696
+ eff._scope = undefined;
697
+ if (scope !== undefined) {
698
+ teardownOwner(scope);
699
+ }
700
+ runCleanup(eff, scope);
488
701
 
489
702
  // Unsubscribe from all sources
490
703
  let node = eff._sources;
@@ -505,7 +718,6 @@ function disposeEffect(eff: EffectNode): void {
505
718
  // F5: null closures for GC
506
719
  eff._fn = undefined;
507
720
  eff._cleanup = undefined;
508
- eff._flags |= DISPOSED;
509
721
  }
510
722
 
511
723
  // --- Public API ---
@@ -629,8 +841,13 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
629
841
  return c;
630
842
  }
631
843
 
632
- /** Create a reactive effect that re-runs when its tracked signals change. Returns a dispose function. */
844
+ /** Create a reactive effect that re-runs when its tracked signals change.
845
+ * Returns a dispose function. Under an owner the effect is disposed with it,
846
+ * and each run owns the effects and roots it creates, disposed before the next
847
+ * run. Throws when the current owner is being disposed. */
633
848
  export function effect(fn: () => Cleanup): () => void {
849
+ const owner = currentOwner;
850
+ assertOpen(owner, "an effect");
634
851
  const eff: EffectNode = {
635
852
  _fn: fn,
636
853
  _cleanup: undefined,
@@ -638,16 +855,20 @@ export function effect(fn: () => Cleanup): () => void {
638
855
  _sources: undefined,
639
856
  _nextBatchedEffect: undefined,
640
857
  _isComputed: false,
858
+ _owner: owner,
859
+ _scope: undefined,
641
860
  };
861
+ owner?._adopt(eff);
642
862
 
643
863
  // Run immediately
644
864
  runEffect(eff);
645
865
 
866
+ // releaseEffect is idempotent, so a second dispose needs no guard.
646
867
  return () => {
647
- if (eff._flags & DISPOSED) {
648
- return;
868
+ if (eff._scope !== undefined) {
869
+ markClosing(eff._scope);
649
870
  }
650
- disposeEffect(eff);
871
+ releaseEffect(eff);
651
872
  };
652
873
  }
653
874
 
@@ -673,22 +894,13 @@ export function untracked<T>(fn: () => T): T {
673
894
  }
674
895
  }
675
896
 
676
- /** Read a signal for its tracking effect alone, discarding the value.
677
- *
678
- * The complement of `peek()`: `peek` reads without subscribing, `touch`
679
- * subscribes without keeping. Outside a reactive scope it does nothing
680
- * observable.
681
- *
682
- * This exists because the two-tier shape `createCollection` and `bindList` are
683
- * built on produces it: a scope reads per-entity values through `signalFor`,
684
- * so it must depend on the structure signal separately to re-run when the id
685
- * set changes, and that dependency has no value the body wants. `undefined`
686
- * entries are accepted so a per-key lookup that may miss (`signalFor(id)`,
687
- * `SignalMap.get(id)`) can be passed straight in.
688
- *
689
- * Not a substitute for `on()`, which declares an exhaustive dependency list
690
- * and runs its body untracked. `touch` ADDS a dependency and leaves every
691
- * other read in the scope tracked as usual. */
897
+ /** Read signals for their tracking alone, discarding the values: `peek` reads
898
+ * without subscribing, `touch` subscribes without keeping. Outside a reactive
899
+ * scope it does nothing observable. A scope reading per-entity values through
900
+ * `signalFor` uses it to also re-run when the id set changes. `undefined`
901
+ * entries are skipped, so a lookup that may miss (`signalFor(id)`,
902
+ * `SignalMap.get(id)`) can be passed straight in. Unlike `on()`, it adds a
903
+ * dependency and leaves the scope's other reads tracked. */
692
904
  export function touch(
693
905
  ...signals: readonly (Signal<unknown> | ReadonlySignal<unknown> | undefined)[]
694
906
  ): void {
@@ -729,15 +941,11 @@ export function isComputed(value: unknown): value is ReadonlySignal<unknown> {
729
941
  return value !== null && typeof value === "object" && COMPUTED_BRAND in value;
730
942
  }
731
943
 
732
- /** Explicit dependency declaration helper. Mirrors Solid's on().
733
- *
734
- * Overloaded so the return type and the `value`/`prev` parameter types stay
735
- * tight:
736
- * - Without `defer` (or `{ defer: false }`) the accessor always returns `U`;
737
- * only the `{ defer: true | boolean }` form widens to `U | undefined`
738
- * (the deferred first call yields `undefined`).
739
- * - A single accessor `() => T` correlates `value`/`prev` to `T`; an array of
740
- * accessors correlates them to `unknown[]`. */
944
+ /** Explicit dependency declaration helper, like Solid's `on()`. Without `defer`
945
+ * (or with `{ defer: false }`) the accessor returns `U`; `{ defer: true |
946
+ * boolean }` widens it to `U | undefined`, since the deferred first call yields
947
+ * `undefined`. A single accessor `() => T` types `value` and `prev` as `T`; an
948
+ * array of accessors types them as `unknown[]`. */
741
949
  // Single accessor, non-deferred (or explicit `{ defer: false }`): never undefined.
742
950
  export function on<T, U>(
743
951
  deps: () => T,