@cplieger/reactive 2.1.1 → 2.1.3-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/NOTICE ADDED
@@ -0,0 +1,3 @@
1
+ reactive
2
+ Copyright 2026 cplieger
3
+ https://github.com/cplieger/reactive
package/README.md CHANGED
@@ -1,60 +1,21 @@
1
1
  # reactive
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@cplieger/reactive)](https://www.npmjs.com/package/@cplieger/reactive)
4
- [![JSR](https://jsr.io/badges/@cplieger/reactive)](https://jsr.io/@cplieger/reactive)
5
- [![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/reactive/badges/coverage.json)](https://github.com/cplieger/reactive/actions/workflows/coverage.yml)
6
- [![Mutation (TS)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/reactive/badges/mutation-ts.json)](https://github.com/cplieger/reactive/issues?q=label%3Astryker-tracker)
7
- [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13218/badge)](https://www.bestpractices.dev/projects/13218)
8
- [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/reactive/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/reactive)
3
+ [![npm](https://img.shields.io/npm/v/@cplieger/reactive)](https://www.npmjs.com/package/@cplieger/reactive) [![JSR](https://jsr.io/badges/@cplieger/reactive)](https://jsr.io/@cplieger/reactive) [![Mutation (TS)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/reactive/badges/mutation-ts.json)](https://github.com/cplieger/reactive/issues?q=label%3Astryker-tracker)
9
4
 
10
- > Signals + collections + DOM-reconciliation micro-framework for TypeScript
5
+ reactive lets you build a framework-free TypeScript browser UI with signals, keyed list rendering and in-place DOM updates, in one package with no runtime dependencies.
11
6
 
12
- A standalone reactive app-skeleton:
7
+ It replaces the change listeners, list re-renders and element builder you would otherwise write between state and the page. It ships as ESM TypeScript source rather than compiled JavaScript, so it needs a bundler or runtime that compiles TypeScript. Releases follow semantic versioning, and it is licensed under Apache-2.0.
13
8
 
14
- - fine-grained signals with automatic dependency tracking and synchronous batched effects
15
- - a typed per-key reactive store
16
- - a dynamic per-id signal registry (`SignalMap`)
17
- - a reactive ordered keyed `Collection`
18
- - a two-tier list render binding (`bindList`)
19
- - a CSP-safe element factory (`el`)
20
- - keyed-list DOM reconciliation
21
- - structural tree-diffing
22
- - a typed event bus (`createBus`)
9
+ ## Why use it
23
10
 
24
- The store, `SignalMap`, and `Collection` are thin facades over a single signal engine: one reactive core, not several. Zero dependencies beyond the DOM API.
11
+ reactive is built for a small browser app in plain TypeScript that needs no component framework.
25
12
 
26
- Mirrors semantics from @preact/signals-core and solid-js reactivity.
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.
14
+ - `createCollection` and `bindList` render a keyed list. Changing one item repaints only its row, and a reorder keeps the existing rows.
15
+ - `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
+ - The signals, stores, collections and event bus need no DOM, so they also run under Node, Deno, Bun or Workers.
27
17
 
28
- ## Flush model
29
-
30
- One phase, and this is the whole of it.
31
-
32
- **A write flushes.** When `sig.value = x` returns, every effect that depends on
33
- `sig` has already re-run. The setter notifies and drains before it returns, so
34
- there is no pending-effect state a caller can observe and no queue to drain. Read
35
- the DOM on the next statement and you are reading what the effects wrote. Same
36
- contract as [@preact/signals-core](https://github.com/preactjs/signals/blob/main/packages/core/README.md),
37
- this engine's baseline.
38
-
39
- **`batch(fn)` is the only deferral.** Inside a batch, writes coalesce and effects
40
- are held; they run once, synchronously, when the _outermost_ batch returns. A
41
- nested batch defers to its outermost, and a throwing body still flushes.
42
-
43
- **Deferral beyond that is yours.** To get effects on a later task, write on a
44
- later task:
45
-
46
- ```typescript
47
- queueMicrotask(() => {
48
- count.value = next; // effects run inside this microtask
49
- });
50
- ```
51
-
52
- There is deliberately **no flush barrier** — no `flushSync()`, no `nextTick()`.
53
- A barrier is the API a _deferred_ model owes its callers (Vue's `nextTick`,
54
- React's `flushSync`); an eager model never holds work, so it has nothing to
55
- report on and nothing to force. `flushSync()` existed through v1.x, could never
56
- find work pending, and refused to act when work was pending inside a batch; it
57
- was removed in v2.0.0. If you were calling it, delete the call — it was a no-op.
18
+ Consider [Solid](https://github.com/solidjs/solid) if you want a full UI framework, with components, JSX, Suspense for async data and server-side rendering.
58
19
 
59
20
  ## Install
60
21
 
@@ -66,132 +27,132 @@ npm i @cplieger/reactive
66
27
 
67
28
  ## Usage
68
29
 
30
+ A signal holds a value, a computed value derives from signals, and an effect re-runs when what it read changes:
31
+
69
32
  ```typescript
70
- import {
71
- signal,
72
- effect,
73
- batch,
74
- computed,
75
- untracked,
76
- subscribe,
77
- isSignal,
78
- isComputed,
79
- } from "@cplieger/reactive";
33
+ import { signal, computed, effect, batch } from "@cplieger/reactive";
80
34
 
81
35
  const count = signal(0);
82
36
  const doubled = computed(() => count.value * 2);
83
37
 
84
- effect(() => {
85
- console.log("count:", count.value, "doubled:", doubled.value);
86
- return undefined;
38
+ const dispose = effect(() => {
39
+ console.log(count.value, doubled.value);
40
+ return undefined; // an effect returns undefined or a cleanup function
87
41
  });
88
42
 
89
43
  batch(() => {
90
44
  count.value = 1;
91
- count.value = 2; // effect fires once with value 2, synchronously at batch end
45
+ count.value = 2;
46
+ }); // the effect ran once, with 2 and 4, before batch returned
47
+
48
+ dispose();
49
+ ```
50
+
51
+ A collection and `bindList` render a list. Updating one item repaints only its row:
52
+
53
+ ```typescript
54
+ import { bindList, createCollection, el } from "@cplieger/reactive";
55
+
56
+ interface Todo {
57
+ id: string;
58
+ title: string;
59
+ done: boolean;
60
+ }
61
+
62
+ const todos = createCollection<Todo>((todo) => todo.id);
63
+ const list = el("ul");
64
+ document.body.append(list);
65
+
66
+ bindList(list, todos, {
67
+ mount: () => el("li"),
68
+ update: (row, todo) => {
69
+ row.textContent = todo.done ? `${todo.title} (done)` : todo.title;
70
+ },
92
71
  });
93
72
 
94
- // Read without tracking
73
+ todos.setAll([
74
+ { id: "a", title: "Write docs", done: false },
75
+ { id: "b", title: "Ship", done: false },
76
+ ]);
77
+ todos.update("a", (todo) => ({ ...todo, done: true })); // only the first row repaints
78
+ ```
79
+
80
+ `patch` takes freshly built elements and applies them to a subtree, keeping the elements already on the page:
81
+
82
+ ```typescript
83
+ import { effect, el, patch, signal } from "@cplieger/reactive";
84
+
85
+ const name = signal("world");
86
+ const header = el("header");
87
+ document.body.append(header);
88
+
95
89
  effect(() => {
96
- const tracked = count.value;
97
- const notTracked = untracked(() => doubled.value);
98
- console.log(tracked, notTracked);
90
+ patch(header, el("h1", { className: "title" }, `Hello, ${name.value}`));
99
91
  return undefined;
100
92
  });
101
93
 
102
- // Subscribe utility
103
- const dispose = subscribe(count, (v) => console.log("count changed:", v));
104
- dispose();
105
-
106
- // Type guards
107
- isSignal(count); // true
108
- isComputed(doubled); // true
94
+ name.value = "reactive"; // the same <h1> stays on the page with its new text
109
95
  ```
110
96
 
111
97
  ## API
112
98
 
113
- ### Signals
99
+ - Signals: `signal`, `computed`, `effect`, `batch`, `untracked`, `touch`, `on`, `subscribe`, `isSignal`, `isComputed` and `setEffectErrorHandler`.
100
+ - 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
+ - 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
+ - Events: `createBus` for typed events that hold no value.
114
103
 
115
- - `signal<T>(initial, options?): Signal<T>`: reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
116
- - `computed<T>(fn, options?): ReadonlySignal<T>`: lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
117
- - `effect(fn: () => Cleanup): () => void`: auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
118
- - `batch(fn)`: coalesce signal writes; effects flush synchronously at end of outermost batch. The only deferral — see [Flush model](#flush-model)
119
- - `untracked<T>(fn): T`: run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
120
- - `touch(...signals): void`: read signals for their tracking effect alone, discarding the values — the complement of `peek()` (`peek` reads without subscribing, `touch` subscribes without keeping). `undefined` entries are skipped, so a per-key lookup that may miss (`Collection.signalFor(id)`, `SignalMap.get(id)`) can be passed straight in. Use it where a scope must re-run on a signal whose value it has no use for: most often a two-tier read, where a computed or effect takes per-entity values through `signalFor` and so must depend on the structure signal (`Collection.ids`) separately to re-derive when the id set changes. Unlike `on()` it ADDS a dependency and leaves every other read in the scope tracked
121
- - `on(deps, fn, options?): () => U`: explicit dependency declaration helper (like Solid `on()`); pass into `effect()` or `computed()`. Its body runs untracked, so the declared deps are the scope's ONLY dependencies — reach for `touch()` instead when the rest of the scope should stay tracked. Passed to `effect()`, the body owes an explicit `return undefined` (or a cleanup function) like any effect callback, since a body with no return statement infers `() => void`, which the `Cleanup` contract rejects. With `{ defer: true }` the accessor returns `() => U | undefined`: the deferred first call yields `undefined`.
122
- - `subscribe(signal, cb): () => void`: subscribe to a signal, calling cb immediately and on every change. The callback runs untracked: signals read inside it do not become dependencies of the subscription
123
- - `isSignal(value): boolean`: type guard for signals created by `signal()`
124
- - `isComputed(value): boolean`: type guard for computed signals
125
- - `setEffectErrorHandler(handler): EffectErrorHandler`: set global error handler for effects; returns the previous handler
104
+ The full reference is on [JSR](https://jsr.io/@cplieger/reactive/doc). [Signals, stores and collections](docs/signals.md) and [Building and updating the DOM](docs/dom.md) describe how each one behaves.
126
105
 
127
- ### DOM building & reconciliation
106
+ ## A write runs its effects before it returns
128
107
 
129
- - `el(tag, attrs?, ...children)`: CSP-safe element factory (the build half; `reconcile`/`patch` are the commit half). `className` → class; `on*` → handler property + `trackHandler` (so `patch` reconciles handlers); boolean DOM props (`hidden`/`disabled`/`checked`/`selected`/…) and `value`/`colSpan`/`rowSpan`/`tabIndex`/`htmlFor` → properties; everything else (`data-*`, `aria-*`, `style`, `id`) → `setAttribute`. String children become text nodes (never parsed as HTML); null/undefined attrs and children are skipped.
130
- - `reconcile<T>(parent, items, spec)`: keyed-list DOM reconciliation with mount/update/onRemove lifecycle
131
- - `patch(parent, ...children)`: structural tree-diff, replacing a parent's children with reconciled new nodes. Correspondence between a child the parent has and a child you asked for is decided strongest-first: the same **node** (you handed back a node that is already a child of `parent` — it is placed at the index you passed it at, unmodified, so handing a parent its own children reordered is supported), then the same **key** (a `*-id` attribute, a specific entity id which takes precedence, or failing that `data-col`; duplicate-key siblings match in document order), then the same **position**. Any node that is not already a child of `parent` is a TEMPLATE: its shape may be copied into a reused child and the node itself never inserted. A reused element takes the new node's attributes, its tracked `on*` handlers, and the unreflected live properties the attribute pass cannot reach: `checked` (input), `selected` (option) and the `value` of an `input`/`textarea`. Those reflect to `defaultChecked`/`defaultSelected`/`defaultValue`, so without this a re-render could never turn a checkbox off or replace the text in a field. Syncing `value` overwrites what a user has typed and moves the caret to the end, which is correct only where a re-render cannot land mid-keystroke: a form re-rendered by a click is safe, one re-rendered by a timer or a server push is not, and that shape must drive the field from an effect instead (see [Unsupported by Design](#unsupported-by-design)).
132
- - `trackHandler(el, key)`: register an `on*` property for handler reconciliation during tree-diff
108
+ When `count.value = 1` returns, every effect that depends on `count` has already run, so the next line reads the DOM those effects wrote. There is no queue and no pending state to wait for.
133
109
 
134
- ### Store
110
+ `batch(fn)` is the only way to defer. Writes inside it coalesce, and the held effects run once, synchronously, when the outermost batch returns, even when the body throws. To run effects on a later task, make the write on a later task:
135
111
 
136
- `createStore` and `SignalMap` are thin facades over the one signal engine. `createStore` lazily backs each key with a signal; `SignalMap` is a registry of signals keyed by a runtime string id.
137
-
138
- - `createStore<M>(): Store<M>`: typed, fixed-key reactive store with `get`, `set`, `subscribe`, and `computed`. A member exists here only where it does something the engine's namesake does not, so `effect` and `batch` are **not** on the store (removed in v2.0.0) — import them from the package root; `store.batch(…)` read as "batch this store's writes" while batching the whole graph. `subscribe` notifies on change only (not immediately on subscribe), and its callback runs untracked. `computed(outputKey, fn)` is an eager effect, not a lazy engine computed: it writes `outputKey` on every dependency change. So `fn` runs whether or not anyone reads the key; a throwing `fn` routes to the effect error handler instead of being cached and rethrown at the read site; `set(outputKey, …)` still works between recomputes. A `computed` key whose fn reads its own output does not loop unbounded: a self-read that keeps yielding new values surfaces `Error("Cycle detected")` through the effect error handler, while a stable self-read settles. The guard re-arms per write: a key that is still cyclic reports again on the next write to it rather than reporting once and going quiet, and effects merely queued alongside the cycle (a `subscribe` on the same key, say) stay reactive.
139
- - `SignalMap<V>`: dynamic per-id signal registry: `get(id)`, `ensure(id, initial)`, `clear(id)`, `clearAll()`. For reactive state whose key set isn't known at the type level (per-message streaming text, per-row state, …); complements `createStore`'s fixed key set.
112
+ ```typescript
113
+ queueMicrotask(() => {
114
+ count.value = next; // effects run inside this microtask
115
+ });
116
+ ```
140
117
 
141
- ### Collections
118
+ This is the contract [@preact/signals-core](https://github.com/preactjs/signals/blob/main/packages/core/README.md) documents too. The package has no `flushSync()` or `nextTick()`, because it never holds work, so there is nothing to flush.
142
119
 
143
- A reactive ordered collection of keyed entities: the data half of the two-tier list pattern. A per-entity content update touches only that entity's subscribers; add/remove/reorder bumps the structure signal (`ids`). Built on `signal` + `SignalMap`.
120
+ ## Correctness guarantees
144
121
 
145
- - `createCollection<T>(keyOf: (item: T) => string): Collection<T>`: returns a collection with:
146
- - `setAll(items)`: replace everything (same-order replacement does not bump `ids`; duplicate keys deduplicate: first occurrence keeps the position, last value wins)
147
- - `upsert(item)`: add/replace one (appends new ids); `prepend(items)`: add to the front (scroll-up/load-older pagination)
148
- - `update(id, next | updater)` / `remove(id)` / `clear()`
149
- - `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
150
- - `ids: ReadonlySignal<readonly string[]>`: structure tier (add/remove/reorder only)
151
- - `items()`: ordered reactive snapshot (tracks order + every entity)
122
+ - Effects downstream of a `computed` run only when its value changes, compared with `Object.is` unless you pass `equals`. A diamond-shaped dependency graph never shows a half-updated value.
123
+ - Reading a `computed` while it is computing throws `Error("Cycle detected")`.
124
+ - When a computed function throws, every read rethrows that error until a dependency changes.
125
+ - Setting `.value` on a `computed` throws `Error("Cannot set a computed signal")`.
126
+ - `batch()` runs the held effects synchronously at the end of the outermost batch, as @preact/signals-core and solid-js do.
152
127
 
153
- ### List rendering
128
+ ## Unsupported by design
154
129
 
155
- - `bindList<T>(parent, source, spec): () => void`: two-tier render binding. `source` is a `ListSource<T>` = `{ ids, signalFor }`; a `Collection` satisfies it directly, and a filtered/sorted/paginated view is just `{ ids: computed(...), signalFor: collection.signalFor }` (pagination is a sliced view, not a separate primitive). A structural effect tracks `source.ids` and reconciles the row list; each row's own effect tracks only its entity signal, so a per-entity change repaints just that row with no structural reconcile. An id whose `signalFor` is `undefined` (an inconsistent source) is skipped rather than rendered. `spec`: `{ mount(item, id) => HTMLElement; update?(el, item, id); onRemove?(el, id) }` (`update` runs at mount and on every later change). Returns a dispose that tears down the structural effect and every row effect.
130
+ The package leaves these out on purpose. [Non-goals](docs/non-goals.md) gives the reason for each.
156
131
 
157
- ### Event bus
132
+ - An ownership tree or `createRoot`, and automatic disposal of nested effects.
133
+ - Lazy activation and an `onMount` lifecycle.
134
+ - `Signal.subtle.Watcher` and notify-on-dirty.
135
+ - Introspection APIs, and explicit disposal of a computed.
136
+ - Server-side rendering and per-request isolation.
137
+ - Async signals and resources.
138
+ - Transactions, a custom scheduler, and a flush barrier such as `flushSync` or `nextTick`.
139
+ - 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
+ - Writing a signal from inside a computed. The computed's own subscribers are not woken, so make that write in an effect.
158
141
 
159
- State lives in signals; discrete events go on a bus. `createBus` is the typed event primitive (not reactive state; no retained value).
142
+ ## Documentation
160
143
 
161
- - `createBus<EventMap>(options?): Bus<EventMap>`: `on`/`once`/`off`/`emit`/`clear`. Handlers are snapshot-cached (rebuilt only on mutation; a handler unsubscribed mid-emit still fires for that emit). Events whose payload type is `undefined` emit with no payload argument. A throwing handler is isolated via `options.onError` (default `console.error`).
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.
145
+ - [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
+ - [Non-goals](docs/non-goals.md) lists what the package leaves out and why.
162
147
 
163
- ## Correctness guarantees
148
+ ## Credits
164
149
 
165
- - **Glitch-freedom**: Computed signals use equality dedup (`Object.is`), so downstream effects only fire when the computed value actually changes. Diamond dependency graphs are handled correctly.
166
- - **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
167
- - **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
168
- - **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
169
- - **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js). It is the only deferral the library has; a bare write flushes before it returns. See [Flush model](#flush-model).
170
-
171
- ## Unsupported by Design
172
-
173
- The following features are intentionally NOT implemented:
174
-
175
- | Feature | Reason |
176
- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
177
- | **Effect ownership tree / `createRoot`** | Consumers manage disposal explicitly. Adding ownership changes the mental model. |
178
- | **Nested effect auto-disposal** | Each effect is independent and returns its own dispose function. Compose with arrays or helper functions. |
179
- | **Lazy activation / `onMount` lifecycle** | Signals are always active. Resource management is the consumer's responsibility. |
180
- | **`Signal.subtle.Watcher` / notify-on-dirty** | This library IS the framework layer. The Watcher pattern is for frameworks that sit on top of a signals primitive. |
181
- | **Introspection APIs** | Dev-tools concern. Not needed for production. |
182
- | **Explicit computed disposal** | Computed signals are GC'd when unreferenced. No explicit teardown needed. |
183
- | **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
184
- | **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
185
- | **Transactions** | Framework-level concern. Not a signals primitive. |
186
- | **Custom scheduler / `setScheduler()`** | Batch is synchronous and a write flushes before it returns, so there is no queue to schedule. Deferral is the caller's: write on a later task. See [Flush model](#flush-model). |
187
- | **A flush barrier (`flushSync`, `nextTick`)** | A barrier is what a deferred model owes its callers; this model never holds work, so a barrier has nothing to report on. `flushSync()` shipped through v1.x and was a no-op from every position a caller could occupy; removed in v2.0.0. |
188
- | **Controlled form inputs** | `patch()` syncs `checked`, `selected` and `input`/`textarea` `value`, so the render owns them. It has no change routing or state ownership, so a surface re-rendered while a user types must own the field itself. |
189
- | **Waking subscribers of a self-writing computed** | A computed that writes a signal it reads is re-read correctly (it is marked stale so the next read re-runs it), but its own subscribers are not woken, so an effect depending on it does not re-run until something else notifies it. Writing a source from a computed body is outside the intended shape; do it in an effect. |
150
+ - 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.
190
152
 
191
153
  ## Contributing
192
154
 
193
- Issues and PRs are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
194
- conventions and how to run the checks locally.
155
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
195
156
 
196
157
  ## Disclaimer
197
158
 
@@ -202,3 +163,5 @@ This project was built with AI-assisted tooling using [Claude](https://claude.co
202
163
  ## License
203
164
 
204
165
  Apache-2.0. See [LICENSE](LICENSE).
166
+
167
+ Third-party attributions are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,8 @@
1
+ # Third-party notices
2
+
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
+
5
+ Two designs are followed, each named at the line of ours that follows it:
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.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "2.1.1",
3
+ "version": "2.1.3-dev.1",
4
4
  "license": "Apache-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -8,7 +8,9 @@
8
8
  "publish": {
9
9
  "include": [
10
10
  "LICENSE",
11
+ "NOTICE",
11
12
  "README.md",
13
+ "THIRD_PARTY_NOTICES.md",
12
14
  "src/**/*.ts"
13
15
  ],
14
16
  "exclude": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "2.1.1",
3
+ "version": "2.1.3-dev.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -9,7 +9,11 @@
9
9
  "scripts": {
10
10
  "typecheck": "tsc -p tsconfig.json",
11
11
  "typecheck:tests": "tsc -p tsconfig.tests.json",
12
- "test": "vitest --run"
12
+ "test": "vitest --run",
13
+ "lint:eslint": "eslint .",
14
+ "lint:prettier": "prettier --check .",
15
+ "lint:knip": "knip",
16
+ "lint:fix": "eslint . --fix && prettier --write ."
13
17
  },
14
18
  "devDependencies": {
15
19
  "@eslint/js": "10.0.1",
@@ -17,16 +21,17 @@
17
21
  "@stryker-mutator/typescript-checker": "10.0.0",
18
22
  "@stryker-mutator/vitest-runner": "10.0.0",
19
23
  "@typescript/native": "npm:typescript@7.0.2",
20
- "@vitest/browser": "5.0.0",
21
- "@vitest/browser-playwright": "5.0.0",
22
- "@vitest/coverage-v8": "5.0.0",
23
- "eslint": "10.10.0",
24
- "fast-check": "4.10.0",
24
+ "@vitest/browser": "5.0.3",
25
+ "@vitest/browser-playwright": "5.0.3",
26
+ "@vitest/coverage-v8": "5.0.3",
27
+ "eslint": "10.12.0",
28
+ "fast-check": "4.10.2",
29
+ "knip": "6.39.0",
25
30
  "playwright": "1.63.0",
26
- "prettier": "3.9.6",
31
+ "prettier": "3.9.9",
27
32
  "typescript": "npm:@typescript/typescript6@6.0.2",
28
- "typescript-eslint": "8.70.0",
29
- "vitest": "5.0.0"
33
+ "typescript-eslint": "8.71.0",
34
+ "vitest": "5.0.3"
30
35
  },
31
36
  "overrides": {
32
37
  "qs": "^6.15.2"
@@ -61,6 +66,9 @@
61
66
  "files": [
62
67
  "src/",
63
68
  "jsr.json",
64
- "!src/**/*.test.ts"
69
+ "!src/**/*.test.ts",
70
+ "LICENSE",
71
+ "NOTICE",
72
+ "THIRD_PARTY_NOTICES.md"
65
73
  ]
66
74
  }
package/src/signal.ts CHANGED
@@ -746,19 +746,27 @@ export function on<T, U>(
746
746
  fn: (value: T, prev: T | undefined, prevResult: U | undefined) => U,
747
747
  options?: { defer?: false },
748
748
  ): () => U;
749
- // Single accessor, deferred: first call returns undefined.
749
+ /** Single accessor, deferred: the first call returns undefined. A `defer`
750
+ * typed as `boolean` selects this overload even when the value is `false`,
751
+ * because the type cannot rule the deferred call out; pass a literal
752
+ * `false` (or omit `options`) to keep the non-undefined return. */
750
753
  export function on<T, U>(
751
754
  deps: () => T,
752
755
  fn: (value: T, prev: T | undefined, prevResult: U | undefined) => U,
753
756
  options: { defer: boolean },
754
757
  ): () => U | undefined;
755
- // Array of accessors, non-deferred (or explicit `{ defer: false }`): never undefined.
758
+ /** Array of accessors, non-deferred (or explicit `{ defer: false }`): never
759
+ * undefined. `value` and `prev` are the accessors' results in declaration
760
+ * order, typed `unknown[]` — a heterogeneous list is not correlated
761
+ * positionally, so narrow each element before use. */
756
762
  export function on<U>(
757
763
  deps: (() => unknown)[],
758
764
  fn: (value: unknown[], prev: unknown[] | undefined, prevResult: U | undefined) => U,
759
765
  options?: { defer?: false },
760
766
  ): () => U;
761
- // Array of accessors, deferred: first call returns undefined.
767
+ /** Array of accessors, deferred: the first call returns undefined, having read
768
+ * every accessor to seed `prev`. Same `boolean`-widening caveat as the single
769
+ * deferred overload. */
762
770
  export function on<U>(
763
771
  deps: (() => unknown)[],
764
772
  fn: (value: unknown[], prev: unknown[] | undefined, prevResult: U | undefined) => U,