@cplieger/ui 0.0.0-stage → 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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +3 -0
  3. package/README.md +157 -2
  4. package/THIRD_PARTY_NOTICES.md +8 -0
  5. package/css/ui-primitives.css +344 -0
  6. package/jsr.json +44 -0
  7. package/package.json +105 -4
  8. package/src/actions/api.ts +268 -0
  9. package/src/actions/async-feedback.ts +217 -0
  10. package/src/actions/cleanup.ts +66 -0
  11. package/src/actions/debounce.ts +150 -0
  12. package/src/actions/define-helpers.ts +87 -0
  13. package/src/actions/define.ts +851 -0
  14. package/src/actions/error.ts +161 -0
  15. package/src/actions/index.ts +60 -0
  16. package/src/actions/loading.ts +131 -0
  17. package/src/actions/notifier.ts +61 -0
  18. package/src/actions/poll-until.ts +133 -0
  19. package/src/actions/poll.ts +156 -0
  20. package/src/actions/registry.ts +259 -0
  21. package/src/actions/retry.ts +79 -0
  22. package/src/actions/testing.ts +29 -0
  23. package/src/actions/transport.ts +104 -0
  24. package/src/actions/types.ts +249 -0
  25. package/src/dom/bind-list.ts +103 -0
  26. package/src/dom/el.ts +67 -0
  27. package/src/dom/index.ts +9 -0
  28. package/src/dom/reconcile-tree.ts +268 -0
  29. package/src/dom/reconcile.ts +76 -0
  30. package/src/fetch/index.ts +18 -0
  31. package/src/fetch/instance.ts +37 -0
  32. package/src/fetch/request.ts +437 -0
  33. package/src/fetch/timeout.ts +67 -0
  34. package/src/fetch/types.ts +206 -0
  35. package/src/fetch/verbs.ts +103 -0
  36. package/src/index.ts +4 -0
  37. package/src/primitives/announce.ts +134 -0
  38. package/src/primitives/ask.ts +406 -0
  39. package/src/primitives/dialog-engine.ts +500 -0
  40. package/src/primitives/dialog.ts +263 -0
  41. package/src/primitives/disclosure.ts +287 -0
  42. package/src/primitives/focus-trap.ts +157 -0
  43. package/src/primitives/fx.ts +204 -0
  44. package/src/primitives/live-regions.ts +83 -0
  45. package/src/primitives/modal-host.ts +214 -0
  46. package/src/primitives/modal.ts +247 -0
  47. package/src/primitives/owner.ts +322 -0
  48. package/src/primitives/popover.ts +500 -0
  49. package/src/primitives/popup-core.ts +603 -0
  50. package/src/primitives/popup.ts +23 -0
  51. package/src/primitives/roving-focus.ts +198 -0
  52. package/src/primitives/scroll-lock.ts +119 -0
  53. package/src/primitives/skeleton.ts +142 -0
  54. package/src/primitives/theme.ts +202 -0
  55. package/src/primitives/toast/engine.ts +327 -0
  56. package/src/primitives/toast/index.ts +139 -0
  57. package/src/primitives/toast/view.ts +244 -0
  58. package/src/primitives/tooltip.ts +377 -0
  59. package/src/primitives/transition.ts +119 -0
  60. package/src/primitives/view-transition.ts +57 -0
  61. package/src/signal/bus.ts +126 -0
  62. package/src/signal/collection.ts +182 -0
  63. package/src/signal/index.ts +34 -0
  64. package/src/signal/resource.ts +102 -0
  65. package/src/signal/signal-map.ts +46 -0
  66. package/src/signal/signal.ts +1002 -0
  67. 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
+ }