@cplieger/reactive 2.2.0-dev.1 → 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 +32 -5
- package/THIRD_PARTY_NOTICES.md +3 -3
- package/jsr.json +1 -1
- package/package.json +7 -7
- package/src/bind-list.ts +66 -74
- package/src/collection.ts +11 -10
- package/src/index.ts +5 -0
- package/src/resource.ts +102 -0
- package/src/signal-map.ts +8 -1
- package/src/signal.ts +321 -113
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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
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
|
|
8
|
-
- The explicit dependency helper `on()`
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
24
|
+
"@vitest/browser-playwright": "5.0.4",
|
|
25
25
|
"eslint": "10.12.0",
|
|
26
26
|
"fast-check": "4.10.2",
|
|
27
|
-
"knip": "6.
|
|
28
|
-
"playwright": "1.
|
|
29
|
-
"prettier": "3.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.
|
|
32
|
-
"vitest": "5.0.
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
34
|
-
*
|
|
35
|
-
|
|
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
|
|
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
|
|
46
|
-
*
|
|
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
|
-
|
|
48
|
+
return createRoot((dispose, listOwner) => {
|
|
49
|
+
const rowDisposers = new WeakMap<HTMLElement, () => void>();
|
|
54
50
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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";
|
package/src/resource.ts
ADDED
|
@@ -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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
273
|
-
// so the next read
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
466
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.
|
|
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.
|
|
648
|
-
|
|
868
|
+
if (eff._scope !== undefined) {
|
|
869
|
+
markClosing(eff._scope);
|
|
649
870
|
}
|
|
650
|
-
|
|
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
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
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
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
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,
|