@cplieger/ui 0.0.0 → 1.0.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/NOTICE +3 -0
- package/README.md +145 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/css/ui-primitives.css +344 -0
- package/jsr.json +36 -4
- package/package.json +86 -4
- package/src/actions/api.ts +268 -0
- package/src/actions/async-feedback.ts +217 -0
- package/src/actions/cleanup.ts +66 -0
- package/src/actions/debounce.ts +150 -0
- package/src/actions/define-helpers.ts +87 -0
- package/src/actions/define.ts +851 -0
- package/src/actions/error.ts +161 -0
- package/src/actions/index.ts +60 -0
- package/src/actions/loading.ts +131 -0
- package/src/actions/notifier.ts +61 -0
- package/src/actions/poll-until.ts +133 -0
- package/src/actions/poll.ts +156 -0
- package/src/actions/registry.ts +259 -0
- package/src/actions/retry.ts +79 -0
- package/src/actions/testing.ts +29 -0
- package/src/actions/transport.ts +104 -0
- package/src/actions/types.ts +249 -0
- package/src/dom/bind-list.ts +103 -0
- package/src/dom/el.ts +67 -0
- package/src/dom/index.ts +9 -0
- package/src/dom/reconcile-tree.ts +268 -0
- package/src/dom/reconcile.ts +76 -0
- package/src/fetch/index.ts +18 -0
- package/src/fetch/instance.ts +37 -0
- package/src/fetch/request.ts +437 -0
- package/src/fetch/timeout.ts +67 -0
- package/src/fetch/types.ts +206 -0
- package/src/fetch/verbs.ts +103 -0
- package/src/index.ts +4 -8
- package/src/primitives/announce.ts +134 -0
- package/src/primitives/ask.ts +406 -0
- package/src/primitives/dialog-engine.ts +500 -0
- package/src/primitives/dialog.ts +263 -0
- package/src/primitives/disclosure.ts +287 -0
- package/src/primitives/focus-trap.ts +157 -0
- package/src/primitives/fx.ts +204 -0
- package/src/primitives/live-regions.ts +83 -0
- package/src/primitives/modal-host.ts +214 -0
- package/src/primitives/modal.ts +247 -0
- package/src/primitives/owner.ts +322 -0
- package/src/primitives/popover.ts +500 -0
- package/src/primitives/popup-core.ts +603 -0
- package/src/primitives/popup.ts +23 -0
- package/src/primitives/roving-focus.ts +198 -0
- package/src/primitives/scroll-lock.ts +119 -0
- package/src/primitives/skeleton.ts +142 -0
- package/src/primitives/theme.ts +202 -0
- package/src/primitives/toast/engine.ts +327 -0
- package/src/primitives/toast/index.ts +139 -0
- package/src/primitives/toast/view.ts +244 -0
- package/src/primitives/tooltip.ts +377 -0
- package/src/primitives/transition.ts +119 -0
- package/src/primitives/view-transition.ts +57 -0
- package/src/signal/bus.ts +126 -0
- package/src/signal/collection.ts +182 -0
- package/src/signal/index.ts +34 -0
- package/src/signal/resource.ts +102 -0
- package/src/signal/signal-map.ts +46 -0
- package/src/signal/signal.ts +1002 -0
- package/src/signal/store.ts +84 -0
|
@@ -0,0 +1,1002 @@
|
|
|
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.
|
|
8
|
+
|
|
9
|
+
const SIGNAL_BRAND = Symbol("signal");
|
|
10
|
+
const COMPUTED_BRAND = Symbol("computed");
|
|
11
|
+
|
|
12
|
+
// --- Bitfield flags ---
|
|
13
|
+
const DIRTY = 1;
|
|
14
|
+
const RUNNING = 4;
|
|
15
|
+
const DISPOSED = 8;
|
|
16
|
+
const HAS_ERROR = 16;
|
|
17
|
+
const RERUN = 32;
|
|
18
|
+
const NOTIFIED = 64;
|
|
19
|
+
|
|
20
|
+
// --- Linked-list Node for source↔target edges ---
|
|
21
|
+
interface DepNode {
|
|
22
|
+
_source: SourceNode;
|
|
23
|
+
_prevSource: DepNode | undefined;
|
|
24
|
+
_nextSource: DepNode | undefined;
|
|
25
|
+
_target: TargetNode;
|
|
26
|
+
_prevTarget: DepNode | undefined;
|
|
27
|
+
_nextTarget: DepNode | undefined;
|
|
28
|
+
_version: number;
|
|
29
|
+
_rollbackNode: DepNode | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Anything that can be depended on (signal or computed)
|
|
33
|
+
interface SourceNode {
|
|
34
|
+
_value: unknown;
|
|
35
|
+
_version: number;
|
|
36
|
+
_node: DepNode | undefined;
|
|
37
|
+
_targets: DepNode | undefined;
|
|
38
|
+
_isComputed: boolean; // discriminator
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Anything that can depend on sources (computed or effect)
|
|
42
|
+
interface TargetNode {
|
|
43
|
+
_flags: number;
|
|
44
|
+
_sources: DepNode | undefined;
|
|
45
|
+
_isComputed: boolean; // discriminator: true=computed, false=effect
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Computed is BOTH source and target (same object)
|
|
49
|
+
interface ComputedNode extends SourceNode, TargetNode {
|
|
50
|
+
_isComputed: true;
|
|
51
|
+
_fn: () => unknown;
|
|
52
|
+
_equals: ((a: unknown, b: unknown) => boolean) | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface EffectNode {
|
|
56
|
+
_fn: (() => Cleanup) | undefined;
|
|
57
|
+
_cleanup: Cleanup;
|
|
58
|
+
_flags: number;
|
|
59
|
+
_sources: DepNode | undefined;
|
|
60
|
+
_nextBatchedEffect: EffectNode | undefined;
|
|
61
|
+
_isComputed: false;
|
|
62
|
+
_owner: OwnerNode | undefined;
|
|
63
|
+
_scope: OwnerNode | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Globals ---
|
|
67
|
+
let evalContext: TargetNode | undefined;
|
|
68
|
+
let batchDepth = 0;
|
|
69
|
+
let batchedEffect: EffectNode | undefined;
|
|
70
|
+
let batchIteration = 0;
|
|
71
|
+
|
|
72
|
+
// Max effect-scheduling passes within one endBatch before assuming a cyclic
|
|
73
|
+
// effect-write loop and bailing with "Cycle detected".
|
|
74
|
+
const MAX_BATCH_ITERATIONS = 100;
|
|
75
|
+
|
|
76
|
+
// --- Effect error handler ---
|
|
77
|
+
/** Cleanup function returned by an effect callback, called before re-execution or disposal. */
|
|
78
|
+
export type Cleanup = undefined | (() => void);
|
|
79
|
+
|
|
80
|
+
/** Handler invoked when an effect throws an error. */
|
|
81
|
+
export type EffectErrorHandler = (error: unknown) => void;
|
|
82
|
+
let effectErrorHandler: EffectErrorHandler = (e) => {
|
|
83
|
+
console.error("effect error:", e);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function safeCallHandler(e: unknown): void {
|
|
87
|
+
try {
|
|
88
|
+
effectErrorHandler(e);
|
|
89
|
+
} catch {
|
|
90
|
+
// swallow
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Set a global error handler for effect errors. Returns the previous handler. */
|
|
95
|
+
export function setEffectErrorHandler(handler: EffectErrorHandler): EffectErrorHandler {
|
|
96
|
+
const prev = effectErrorHandler;
|
|
97
|
+
effectErrorHandler = handler;
|
|
98
|
+
return prev;
|
|
99
|
+
}
|
|
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
|
+
|
|
311
|
+
// --- Dependency tracking ---
|
|
312
|
+
function addDependency(source: SourceNode): DepNode | undefined {
|
|
313
|
+
if (evalContext === undefined) {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
let node = source._node;
|
|
317
|
+
if (node?._target !== evalContext) {
|
|
318
|
+
// Create a new node
|
|
319
|
+
node = {
|
|
320
|
+
_version: 0,
|
|
321
|
+
_source: source,
|
|
322
|
+
_prevSource: undefined,
|
|
323
|
+
_nextSource: undefined,
|
|
324
|
+
_target: evalContext,
|
|
325
|
+
_prevTarget: undefined,
|
|
326
|
+
_nextTarget: undefined,
|
|
327
|
+
_rollbackNode: source._node, // F3: save for rollback
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Link into target's sources (prepend)
|
|
331
|
+
const evalSources = evalContext._sources;
|
|
332
|
+
if (evalSources !== undefined) {
|
|
333
|
+
evalSources._prevSource = node;
|
|
334
|
+
}
|
|
335
|
+
node._nextSource = evalSources;
|
|
336
|
+
evalContext._sources = node;
|
|
337
|
+
|
|
338
|
+
// Link into source's targets (prepend)
|
|
339
|
+
const sourceTargets = source._targets;
|
|
340
|
+
if (sourceTargets !== undefined) {
|
|
341
|
+
sourceTargets._prevTarget = node;
|
|
342
|
+
}
|
|
343
|
+
node._nextTarget = sourceTargets;
|
|
344
|
+
source._targets = node;
|
|
345
|
+
|
|
346
|
+
source._node = node;
|
|
347
|
+
return node;
|
|
348
|
+
} else if (node._version === -1) {
|
|
349
|
+
// Recycle existing node
|
|
350
|
+
node._version = 0;
|
|
351
|
+
|
|
352
|
+
// Move to head of sources if not already there
|
|
353
|
+
if (node._prevSource !== undefined) {
|
|
354
|
+
node._prevSource._nextSource = node._nextSource;
|
|
355
|
+
if (node._nextSource !== undefined) {
|
|
356
|
+
node._nextSource._prevSource = node._prevSource;
|
|
357
|
+
}
|
|
358
|
+
node._prevSource = undefined;
|
|
359
|
+
const evalSources = evalContext._sources;
|
|
360
|
+
if (evalSources !== undefined) {
|
|
361
|
+
evalSources._prevSource = node;
|
|
362
|
+
}
|
|
363
|
+
node._nextSource = evalSources;
|
|
364
|
+
evalContext._sources = node;
|
|
365
|
+
}
|
|
366
|
+
return node;
|
|
367
|
+
}
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- Source preparation/cleanup for eval cycle ---
|
|
372
|
+
function prepareSources(target: TargetNode): void {
|
|
373
|
+
let node = target._sources;
|
|
374
|
+
while (node !== undefined) {
|
|
375
|
+
const rollback = node._source._node;
|
|
376
|
+
if (rollback !== undefined) {
|
|
377
|
+
node._rollbackNode = rollback;
|
|
378
|
+
}
|
|
379
|
+
node._source._node = node;
|
|
380
|
+
node._version = -1;
|
|
381
|
+
node = node._nextSource;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function cleanupSources(target: TargetNode): void {
|
|
386
|
+
let node = target._sources;
|
|
387
|
+
let head = target._sources;
|
|
388
|
+
while (node !== undefined) {
|
|
389
|
+
const next = node._nextSource;
|
|
390
|
+
if (node._version === -1) {
|
|
391
|
+
// Not re-used — unlink from source's targets
|
|
392
|
+
if (node._prevTarget !== undefined) {
|
|
393
|
+
node._prevTarget._nextTarget = node._nextTarget;
|
|
394
|
+
} else {
|
|
395
|
+
node._source._targets = node._nextTarget;
|
|
396
|
+
}
|
|
397
|
+
if (node._nextTarget !== undefined) {
|
|
398
|
+
node._nextTarget._prevTarget = node._prevTarget;
|
|
399
|
+
}
|
|
400
|
+
// Remove from target's sources
|
|
401
|
+
if (node._prevSource !== undefined) {
|
|
402
|
+
node._prevSource._nextSource = node._nextSource;
|
|
403
|
+
} else {
|
|
404
|
+
head = node._nextSource;
|
|
405
|
+
}
|
|
406
|
+
if (node._nextSource !== undefined) {
|
|
407
|
+
node._nextSource._prevSource = node._prevSource;
|
|
408
|
+
}
|
|
409
|
+
// Restore rollback
|
|
410
|
+
node._source._node = node._rollbackNode;
|
|
411
|
+
node._rollbackNode = undefined;
|
|
412
|
+
} else {
|
|
413
|
+
// Kept — restore rollback pointer (F3)
|
|
414
|
+
node._source._node = node._rollbackNode;
|
|
415
|
+
node._rollbackNode = undefined;
|
|
416
|
+
}
|
|
417
|
+
node = next;
|
|
418
|
+
}
|
|
419
|
+
target._sources = head;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- Notification propagation ---
|
|
423
|
+
function notifyTargets(source: SourceNode): void {
|
|
424
|
+
let node = source._targets;
|
|
425
|
+
while (node !== undefined) {
|
|
426
|
+
const target = node._target;
|
|
427
|
+
if (target._isComputed) {
|
|
428
|
+
// It's a ComputedNode
|
|
429
|
+
if (target._flags & RUNNING) {
|
|
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.
|
|
437
|
+
target._flags |= DIRTY;
|
|
438
|
+
} else if (!(target._flags & NOTIFIED)) {
|
|
439
|
+
target._flags |= DIRTY | NOTIFIED;
|
|
440
|
+
notifyTargets(target as unknown as SourceNode);
|
|
441
|
+
target._flags &= ~NOTIFIED;
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
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)) {
|
|
451
|
+
target._flags |= NOTIFIED;
|
|
452
|
+
const eff = target as unknown as EffectNode;
|
|
453
|
+
eff._nextBatchedEffect = batchedEffect;
|
|
454
|
+
batchedEffect = eff;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
node = node._nextTarget;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// --- Staleness check ---
|
|
462
|
+
function needsToRecompute(target: TargetNode): boolean {
|
|
463
|
+
let node = target._sources;
|
|
464
|
+
while (node !== undefined) {
|
|
465
|
+
const source = node._source;
|
|
466
|
+
if (source._isComputed) {
|
|
467
|
+
const comp = source as unknown as ComputedNode;
|
|
468
|
+
if (comp._flags & DIRTY) {
|
|
469
|
+
refreshComputed(comp);
|
|
470
|
+
}
|
|
471
|
+
if (comp._flags & HAS_ERROR) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (source._version !== node._version) {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
node = node._nextSource;
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
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.
|
|
486
|
+
function refreshComputed(comp: ComputedNode): void {
|
|
487
|
+
if (comp._flags & RUNNING) {
|
|
488
|
+
throw new Error("Cycle detected");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const prevContext = evalContext;
|
|
492
|
+
const prevOwner = currentOwner;
|
|
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;
|
|
497
|
+
const prevFlags = comp._flags;
|
|
498
|
+
comp._flags = RUNNING;
|
|
499
|
+
|
|
500
|
+
prepareSources(comp);
|
|
501
|
+
|
|
502
|
+
let newValue: unknown;
|
|
503
|
+
let fnThrew = false;
|
|
504
|
+
try {
|
|
505
|
+
newValue = comp._fn();
|
|
506
|
+
} catch (err: unknown) {
|
|
507
|
+
// PATH A: fn() threw — cache the error
|
|
508
|
+
fnThrew = true;
|
|
509
|
+
comp._value = err;
|
|
510
|
+
comp._flags = HAS_ERROR;
|
|
511
|
+
comp._version++;
|
|
512
|
+
// Keep old deps (version=-1 nodes) so computed retries when sources change.
|
|
513
|
+
let sn = comp._sources;
|
|
514
|
+
while (sn !== undefined) {
|
|
515
|
+
if (sn._version === -1) {
|
|
516
|
+
sn._version = 0;
|
|
517
|
+
}
|
|
518
|
+
sn = sn._nextSource;
|
|
519
|
+
}
|
|
520
|
+
} finally {
|
|
521
|
+
evalContext = prevContext;
|
|
522
|
+
currentOwner = prevOwner;
|
|
523
|
+
cleanupSources(comp);
|
|
524
|
+
if (!fnThrew) {
|
|
525
|
+
comp._flags = comp._flags & ~RUNNING;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// PATH B: fn() succeeded — compare with equals() (F2: separate try/catch)
|
|
530
|
+
if (!fnThrew) {
|
|
531
|
+
const oldHadError = prevFlags & HAS_ERROR;
|
|
532
|
+
let changed: boolean;
|
|
533
|
+
|
|
534
|
+
if (oldHadError || comp._version === 0) {
|
|
535
|
+
changed = true;
|
|
536
|
+
} else {
|
|
537
|
+
try {
|
|
538
|
+
const eq = comp._equals;
|
|
539
|
+
changed = eq ? !eq(comp._value, newValue) : !Object.is(comp._value, newValue);
|
|
540
|
+
} catch {
|
|
541
|
+
// equals() threw — treat as changed (F2: successful value NOT poisoned)
|
|
542
|
+
changed = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (changed) {
|
|
547
|
+
comp._value = newValue;
|
|
548
|
+
comp._flags &= ~HAS_ERROR;
|
|
549
|
+
comp._version++;
|
|
550
|
+
} else {
|
|
551
|
+
comp._flags &= ~HAS_ERROR;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- Batch / Effect scheduling ---
|
|
557
|
+
function startBatch(): void {
|
|
558
|
+
batchDepth++;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function endBatch(): void {
|
|
562
|
+
if (--batchDepth > 0) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// The drain is re-entrant: a write or a batch() from an effect body sees
|
|
566
|
+
// batchDepth back at 0 and starts a nested drain, and the cycle guard below
|
|
567
|
+
// counts on those nested passes — a two-effect ping-pong recurses one level per
|
|
568
|
+
// pass rather than looping here. So a nested drain must RESTORE the count it
|
|
569
|
+
// inherited instead of zeroing it: zeroing let any nested drain, including an
|
|
570
|
+
// empty `batch(() => {})`, wipe the passes the enclosing drain had accumulated
|
|
571
|
+
// and multiply the effective cap.
|
|
572
|
+
const enclosingIteration = batchIteration;
|
|
573
|
+
while (batchedEffect !== undefined) {
|
|
574
|
+
let eff: EffectNode | undefined = batchedEffect;
|
|
575
|
+
batchedEffect = undefined;
|
|
576
|
+
batchIteration++;
|
|
577
|
+
if (batchIteration > MAX_BATCH_ITERATIONS) {
|
|
578
|
+
batchIteration = enclosingIteration;
|
|
579
|
+
// Un-notify the chain we are dropping: notifyTargets skips a NOTIFIED
|
|
580
|
+
// effect, so an effect left NOTIFIED here could never be queued again —
|
|
581
|
+
// bailing would silently deafen every effect in flight, including
|
|
582
|
+
// bystanders that merely share a signal with the cycling one.
|
|
583
|
+
let drop: EffectNode | undefined = eff;
|
|
584
|
+
while (drop !== undefined) {
|
|
585
|
+
const nextDrop: EffectNode | undefined = drop._nextBatchedEffect;
|
|
586
|
+
drop._nextBatchedEffect = undefined;
|
|
587
|
+
drop._flags &= ~NOTIFIED;
|
|
588
|
+
drop = nextDrop;
|
|
589
|
+
}
|
|
590
|
+
throw new Error("Cycle detected");
|
|
591
|
+
}
|
|
592
|
+
while (eff !== undefined) {
|
|
593
|
+
const next: EffectNode | undefined = eff._nextBatchedEffect;
|
|
594
|
+
eff._nextBatchedEffect = undefined;
|
|
595
|
+
eff._flags &= ~NOTIFIED;
|
|
596
|
+
if (!(eff._flags & DISPOSED) && needsToRecompute(eff)) {
|
|
597
|
+
runEffect(eff);
|
|
598
|
+
}
|
|
599
|
+
eff = next;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
batchIteration = enclosingIteration;
|
|
603
|
+
}
|
|
604
|
+
|
|
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 {
|
|
608
|
+
if (eff._cleanup) {
|
|
609
|
+
const c = eff._cleanup;
|
|
610
|
+
eff._cleanup = undefined;
|
|
611
|
+
const prevContext = evalContext;
|
|
612
|
+
const prevOwner = currentOwner;
|
|
613
|
+
evalContext = undefined;
|
|
614
|
+
currentOwner = scope;
|
|
615
|
+
try {
|
|
616
|
+
c();
|
|
617
|
+
} catch (e) {
|
|
618
|
+
safeCallHandler(e);
|
|
619
|
+
} finally {
|
|
620
|
+
evalContext = prevContext;
|
|
621
|
+
currentOwner = prevOwner;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Contract: runEffect NEVER throws, and both callers depend on it.
|
|
627
|
+
// Its body and its cleanup each route to safeCallHandler, which swallows even a
|
|
628
|
+
// throwing handler; everything else here is a pointer walk over a well-formed
|
|
629
|
+
// list. endBatch's drain and the effect() factory therefore call it bare. A
|
|
630
|
+
// throw escaping this function would abandon the rest of the queued chain
|
|
631
|
+
// mid-drain — the same shape of failure as the cycle bail's NOTIFIED leak. If
|
|
632
|
+
// you add a statement here that can throw, wrap it HERE, not at the call sites.
|
|
633
|
+
function runEffect(eff: EffectNode): void {
|
|
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);
|
|
643
|
+
|
|
644
|
+
// Track dependencies
|
|
645
|
+
const prevContext = evalContext;
|
|
646
|
+
const prevOwner = currentOwner;
|
|
647
|
+
const scope = eff._owner === undefined ? undefined : new OwnerNode(undefined);
|
|
648
|
+
eff._scope = scope;
|
|
649
|
+
evalContext = eff;
|
|
650
|
+
currentOwner = scope;
|
|
651
|
+
prepareSources(eff);
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
if (eff._fn) {
|
|
655
|
+
eff._cleanup = eff._fn();
|
|
656
|
+
}
|
|
657
|
+
} catch (e) {
|
|
658
|
+
safeCallHandler(e);
|
|
659
|
+
} finally {
|
|
660
|
+
eff._flags &= ~RUNNING;
|
|
661
|
+
evalContext = prevContext;
|
|
662
|
+
currentOwner = prevOwner;
|
|
663
|
+
cleanupSources(eff);
|
|
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
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
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);
|
|
701
|
+
|
|
702
|
+
// Unsubscribe from all sources
|
|
703
|
+
let node = eff._sources;
|
|
704
|
+
while (node !== undefined) {
|
|
705
|
+
const next = node._nextSource;
|
|
706
|
+
if (node._prevTarget !== undefined) {
|
|
707
|
+
node._prevTarget._nextTarget = node._nextTarget;
|
|
708
|
+
} else {
|
|
709
|
+
node._source._targets = node._nextTarget;
|
|
710
|
+
}
|
|
711
|
+
if (node._nextTarget !== undefined) {
|
|
712
|
+
node._nextTarget._prevTarget = node._prevTarget;
|
|
713
|
+
}
|
|
714
|
+
node._source._node = node._rollbackNode;
|
|
715
|
+
node = next;
|
|
716
|
+
}
|
|
717
|
+
eff._sources = undefined;
|
|
718
|
+
// F5: null closures for GC
|
|
719
|
+
eff._fn = undefined;
|
|
720
|
+
eff._cleanup = undefined;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// --- Public API ---
|
|
724
|
+
|
|
725
|
+
/** Options for configuring signal equality semantics. */
|
|
726
|
+
export interface SignalOptions<T> {
|
|
727
|
+
equals?: false | ((prev: T, next: T) => boolean);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** A reactive signal holding a mutable value that notifies subscribers on change. */
|
|
731
|
+
export interface Signal<T> {
|
|
732
|
+
value: T;
|
|
733
|
+
peek(): T;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** A read-only reactive signal (e.g. from computed). */
|
|
737
|
+
export interface ReadonlySignal<T> {
|
|
738
|
+
readonly value: T;
|
|
739
|
+
peek(): T;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Create a reactive signal with an initial value. Reads are tracked; writes notify subscribers. */
|
|
743
|
+
export function signal<T>(initial: T, options?: SignalOptions<T>): Signal<T> {
|
|
744
|
+
const eq = options?.equals;
|
|
745
|
+
const node: SourceNode = {
|
|
746
|
+
_value: initial,
|
|
747
|
+
_version: 0,
|
|
748
|
+
_node: undefined,
|
|
749
|
+
_targets: undefined,
|
|
750
|
+
_isComputed: false,
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const s = {
|
|
754
|
+
[SIGNAL_BRAND]: true,
|
|
755
|
+
get value(): T {
|
|
756
|
+
const dep = addDependency(node);
|
|
757
|
+
if (dep !== undefined) {
|
|
758
|
+
dep._version = node._version;
|
|
759
|
+
}
|
|
760
|
+
return node._value as T;
|
|
761
|
+
},
|
|
762
|
+
set value(v: T) {
|
|
763
|
+
if (eq === false ? false : eq ? eq(node._value as T, v) : Object.is(node._value, v)) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
node._value = v;
|
|
767
|
+
node._version++;
|
|
768
|
+
startBatch();
|
|
769
|
+
try {
|
|
770
|
+
notifyTargets(node);
|
|
771
|
+
} finally {
|
|
772
|
+
endBatch();
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
peek(): T {
|
|
776
|
+
return node._value as T;
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
return s;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** Lazy cached derived signal with equality dedup (glitch-free). */
|
|
783
|
+
export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySignal<T> {
|
|
784
|
+
const eq = options?.equals;
|
|
785
|
+
const node: ComputedNode = {
|
|
786
|
+
// SourceNode fields
|
|
787
|
+
_value: undefined,
|
|
788
|
+
_version: 0,
|
|
789
|
+
_node: undefined,
|
|
790
|
+
_targets: undefined,
|
|
791
|
+
_isComputed: true,
|
|
792
|
+
// TargetNode fields
|
|
793
|
+
_flags: DIRTY,
|
|
794
|
+
_sources: undefined,
|
|
795
|
+
// ComputedNode-specific fields
|
|
796
|
+
_fn: fn,
|
|
797
|
+
_equals:
|
|
798
|
+
eq === false ? () => false : eq ? (a: unknown, b: unknown) => eq(a as T, b as T) : undefined,
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const c = {
|
|
802
|
+
[COMPUTED_BRAND]: true,
|
|
803
|
+
get value(): T {
|
|
804
|
+
// Cycle detection
|
|
805
|
+
if (node._flags & RUNNING) {
|
|
806
|
+
throw new Error("Cycle detected");
|
|
807
|
+
}
|
|
808
|
+
// Refresh before tracking (pull-based glitch-free)
|
|
809
|
+
if (node._flags & DIRTY) {
|
|
810
|
+
refreshComputed(node);
|
|
811
|
+
}
|
|
812
|
+
const dep = addDependency(node);
|
|
813
|
+
if (dep !== undefined) {
|
|
814
|
+
dep._version = node._version;
|
|
815
|
+
}
|
|
816
|
+
if (node._flags & HAS_ERROR) {
|
|
817
|
+
throw node._value;
|
|
818
|
+
}
|
|
819
|
+
return node._value as T;
|
|
820
|
+
},
|
|
821
|
+
set value(_v: T) {
|
|
822
|
+
throw new Error("Cannot set a computed signal");
|
|
823
|
+
},
|
|
824
|
+
peek(): T {
|
|
825
|
+
// F6: refresh WITHOUT tracking
|
|
826
|
+
if (node._flags & DIRTY) {
|
|
827
|
+
const prevContext = evalContext;
|
|
828
|
+
evalContext = undefined;
|
|
829
|
+
try {
|
|
830
|
+
refreshComputed(node);
|
|
831
|
+
} finally {
|
|
832
|
+
evalContext = prevContext;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (node._flags & HAS_ERROR) {
|
|
836
|
+
throw node._value;
|
|
837
|
+
}
|
|
838
|
+
return node._value as T;
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
return c;
|
|
842
|
+
}
|
|
843
|
+
|
|
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. */
|
|
848
|
+
export function effect(fn: () => Cleanup): () => void {
|
|
849
|
+
const owner = currentOwner;
|
|
850
|
+
assertOpen(owner, "an effect");
|
|
851
|
+
const eff: EffectNode = {
|
|
852
|
+
_fn: fn,
|
|
853
|
+
_cleanup: undefined,
|
|
854
|
+
_flags: 0,
|
|
855
|
+
_sources: undefined,
|
|
856
|
+
_nextBatchedEffect: undefined,
|
|
857
|
+
_isComputed: false,
|
|
858
|
+
_owner: owner,
|
|
859
|
+
_scope: undefined,
|
|
860
|
+
};
|
|
861
|
+
owner?._adopt(eff);
|
|
862
|
+
|
|
863
|
+
// Run immediately
|
|
864
|
+
runEffect(eff);
|
|
865
|
+
|
|
866
|
+
// releaseEffect is idempotent, so a second dispose needs no guard.
|
|
867
|
+
return () => {
|
|
868
|
+
if (eff._scope !== undefined) {
|
|
869
|
+
markClosing(eff._scope);
|
|
870
|
+
}
|
|
871
|
+
releaseEffect(eff);
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Coalesce signal writes; effects flush synchronously at end of outermost batch.
|
|
876
|
+
* This is the library's ONLY deferral: see the flush model in the module header. */
|
|
877
|
+
export function batch(fn: () => void): void {
|
|
878
|
+
startBatch();
|
|
879
|
+
try {
|
|
880
|
+
fn();
|
|
881
|
+
} finally {
|
|
882
|
+
endBatch();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Run fn without tracking any signal reads. */
|
|
887
|
+
export function untracked<T>(fn: () => T): T {
|
|
888
|
+
const prev = evalContext;
|
|
889
|
+
evalContext = undefined;
|
|
890
|
+
try {
|
|
891
|
+
return fn();
|
|
892
|
+
} finally {
|
|
893
|
+
evalContext = prev;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
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. */
|
|
904
|
+
export function touch(
|
|
905
|
+
...signals: readonly (Signal<unknown> | ReadonlySignal<unknown> | undefined)[]
|
|
906
|
+
): void {
|
|
907
|
+
for (const sig of signals) {
|
|
908
|
+
readForDependency(sig);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// deadset:ignore DS1803 -- returning the value keeps the tracking read from being a discarded expression; typescript-eslint rejects void on a property read
|
|
913
|
+
function readForDependency(sig: Signal<unknown> | ReadonlySignal<unknown> | undefined): unknown {
|
|
914
|
+
return sig?.value;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Subscribe to a signal, calling cb on every change. Returns dispose function.
|
|
918
|
+
* The callback runs untracked: reading other signals inside it does not add
|
|
919
|
+
* them as dependencies of the subscription (matches upstream
|
|
920
|
+
* preactjs/signals#188 semantics). */
|
|
921
|
+
export function subscribe<T>(
|
|
922
|
+
sig: Signal<T> | ReadonlySignal<T>,
|
|
923
|
+
cb: (value: T) => void,
|
|
924
|
+
): () => void {
|
|
925
|
+
return effect(() => {
|
|
926
|
+
const value = sig.value;
|
|
927
|
+
untracked(() => {
|
|
928
|
+
cb(value);
|
|
929
|
+
});
|
|
930
|
+
return undefined;
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Type guard: returns true if value is a Signal created by signal(). */
|
|
935
|
+
export function isSignal(value: unknown): value is Signal<unknown> {
|
|
936
|
+
return value !== null && typeof value === "object" && SIGNAL_BRAND in value;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Type guard: returns true if value is a computed ReadonlySignal. */
|
|
940
|
+
export function isComputed(value: unknown): value is ReadonlySignal<unknown> {
|
|
941
|
+
return value !== null && typeof value === "object" && COMPUTED_BRAND in value;
|
|
942
|
+
}
|
|
943
|
+
|
|
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[]`. */
|
|
949
|
+
// Single accessor, non-deferred (or explicit `{ defer: false }`): never undefined.
|
|
950
|
+
export function on<T, U>(
|
|
951
|
+
deps: () => T,
|
|
952
|
+
fn: (value: T, prev: T | undefined, prevResult: U | undefined) => U,
|
|
953
|
+
options?: { defer?: false },
|
|
954
|
+
): () => U;
|
|
955
|
+
/** Single accessor, deferred: the first call returns undefined. A `defer`
|
|
956
|
+
* typed as `boolean` selects this overload even when the value is `false`,
|
|
957
|
+
* because the type cannot rule the deferred call out; pass a literal
|
|
958
|
+
* `false` (or omit `options`) to keep the non-undefined return. */
|
|
959
|
+
export function on<T, U>(
|
|
960
|
+
deps: () => T,
|
|
961
|
+
fn: (value: T, prev: T | undefined, prevResult: U | undefined) => U,
|
|
962
|
+
options: { defer: boolean },
|
|
963
|
+
): () => U | undefined;
|
|
964
|
+
/** Array of accessors, non-deferred (or explicit `{ defer: false }`): never
|
|
965
|
+
* undefined. `value` and `prev` are the accessors' results in declaration
|
|
966
|
+
* order, typed `unknown[]` — a heterogeneous list is not correlated
|
|
967
|
+
* positionally, so narrow each element before use. */
|
|
968
|
+
export function on<U>(
|
|
969
|
+
deps: (() => unknown)[],
|
|
970
|
+
fn: (value: unknown[], prev: unknown[] | undefined, prevResult: U | undefined) => U,
|
|
971
|
+
options?: { defer?: false },
|
|
972
|
+
): () => U;
|
|
973
|
+
/** Array of accessors, deferred: the first call returns undefined, having read
|
|
974
|
+
* every accessor to seed `prev`. Same `boolean`-widening caveat as the single
|
|
975
|
+
* deferred overload. */
|
|
976
|
+
export function on<U>(
|
|
977
|
+
deps: (() => unknown)[],
|
|
978
|
+
fn: (value: unknown[], prev: unknown[] | undefined, prevResult: U | undefined) => U,
|
|
979
|
+
options: { defer: boolean },
|
|
980
|
+
): () => U | undefined;
|
|
981
|
+
export function on<T, U>(
|
|
982
|
+
deps: (() => T) | (() => unknown)[],
|
|
983
|
+
fn: (value: T | unknown[], prev: T | unknown[] | undefined, prevResult: U | undefined) => U,
|
|
984
|
+
options?: { defer?: boolean },
|
|
985
|
+
): () => U | undefined {
|
|
986
|
+
let prevInput: T | unknown[] | undefined;
|
|
987
|
+
let prevResult: U | undefined;
|
|
988
|
+
let first = true;
|
|
989
|
+
return () => {
|
|
990
|
+
const input: T | unknown[] = Array.isArray(deps) ? deps.map((d) => d()) : deps();
|
|
991
|
+
if (first && options?.defer) {
|
|
992
|
+
first = false;
|
|
993
|
+
prevInput = input;
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
const result = untracked(() => fn(input, prevInput, prevResult));
|
|
997
|
+
prevInput = input;
|
|
998
|
+
prevResult = result;
|
|
999
|
+
first = false;
|
|
1000
|
+
return result;
|
|
1001
|
+
};
|
|
1002
|
+
}
|