bireactive 0.3.0 → 0.3.2

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 (117) hide show
  1. package/README.md +14 -7
  2. package/dist/automerge/doc-cell.d.ts +20 -0
  3. package/dist/automerge/doc-cell.js +80 -0
  4. package/dist/automerge/index.d.ts +3 -0
  5. package/dist/automerge/index.js +12 -0
  6. package/dist/automerge/reconcile.d.ts +5 -0
  7. package/dist/automerge/reconcile.js +63 -0
  8. package/dist/core/_counts.d.ts +48 -0
  9. package/dist/core/_counts.js +51 -0
  10. package/dist/core/cell.d.ts +148 -112
  11. package/dist/core/cell.js +945 -768
  12. package/dist/core/debug.d.ts +25 -0
  13. package/dist/core/debug.js +121 -0
  14. package/dist/core/derived-geometry.js +4 -7
  15. package/dist/core/index.d.ts +9 -2
  16. package/dist/core/index.js +8 -1
  17. package/dist/core/lenses/aggregates.d.ts +42 -52
  18. package/dist/core/lenses/aggregates.js +225 -116
  19. package/dist/core/lenses/geometry.d.ts +22 -4
  20. package/dist/core/lenses/geometry.js +59 -27
  21. package/dist/core/lenses/index.d.ts +6 -6
  22. package/dist/core/lenses/index.js +6 -6
  23. package/dist/core/lenses/memory.js +4 -17
  24. package/dist/core/lenses/numerical.d.ts +100 -0
  25. package/dist/core/lenses/{typed-factor.js → numerical.js} +136 -34
  26. package/dist/core/lenses/point-cloud.d.ts +67 -0
  27. package/dist/core/lenses/{closed-form-policies.js → point-cloud.js} +226 -84
  28. package/dist/core/lenses/snap.d.ts +18 -0
  29. package/dist/core/lenses/snap.js +138 -0
  30. package/dist/core/lenses/text.d.ts +40 -0
  31. package/dist/core/lenses/text.js +202 -0
  32. package/dist/core/lifecycle.js +3 -6
  33. package/dist/core/linalg.js +5 -11
  34. package/dist/core/optic.d.ts +13 -0
  35. package/dist/core/optic.js +39 -0
  36. package/dist/core/optics.d.ts +10 -0
  37. package/dist/core/optics.js +26 -0
  38. package/dist/core/store.d.ts +9 -0
  39. package/dist/core/store.js +77 -0
  40. package/dist/core/traits.d.ts +4 -7
  41. package/dist/core/traits.js +8 -12
  42. package/dist/core/values/anchor.js +0 -4
  43. package/dist/core/values/arr.d.ts +110 -0
  44. package/dist/core/values/arr.js +336 -0
  45. package/dist/core/values/audio.d.ts +8 -9
  46. package/dist/core/values/audio.js +11 -28
  47. package/dist/core/values/bool.d.ts +11 -11
  48. package/dist/core/values/bool.js +12 -22
  49. package/dist/core/values/box.d.ts +15 -20
  50. package/dist/core/values/box.js +20 -33
  51. package/dist/core/values/canvas.d.ts +18 -25
  52. package/dist/core/values/canvas.js +32 -66
  53. package/dist/core/values/color.d.ts +5 -7
  54. package/dist/core/values/color.js +5 -11
  55. package/dist/core/values/field.d.ts +6 -7
  56. package/dist/core/values/field.js +10 -35
  57. package/dist/core/values/flags.d.ts +1 -2
  58. package/dist/core/values/flags.js +1 -17
  59. package/dist/core/values/gpu.d.ts +6 -10
  60. package/dist/core/values/gpu.js +8 -22
  61. package/dist/core/values/matrix.d.ts +2 -4
  62. package/dist/core/values/matrix.js +2 -12
  63. package/dist/core/values/num.d.ts +19 -28
  64. package/dist/core/values/num.js +23 -41
  65. package/dist/core/values/pose.d.ts +2 -4
  66. package/dist/core/values/pose.js +3 -12
  67. package/dist/core/values/range.d.ts +18 -26
  68. package/dist/core/values/range.js +22 -39
  69. package/dist/core/values/reg/ambiguity.d.ts +8 -0
  70. package/dist/core/values/reg/ambiguity.js +131 -0
  71. package/dist/core/values/reg/engine.d.ts +91 -0
  72. package/dist/core/values/reg/engine.js +373 -0
  73. package/dist/core/values/reg/nfa.d.ts +42 -0
  74. package/dist/core/values/reg/nfa.js +391 -0
  75. package/dist/core/values/reg/regex.d.ts +7 -0
  76. package/dist/core/values/reg/regex.js +318 -0
  77. package/dist/core/values/reg/types.d.ts +60 -0
  78. package/dist/core/values/reg/types.js +3 -0
  79. package/dist/core/values/reg.d.ts +250 -0
  80. package/dist/core/values/reg.js +649 -0
  81. package/dist/core/values/str.d.ts +16 -60
  82. package/dist/core/values/str.js +133 -315
  83. package/dist/core/values/template.js +1 -24
  84. package/dist/core/values/transform.d.ts +3 -5
  85. package/dist/core/values/transform.js +3 -12
  86. package/dist/core/values/tri.d.ts +9 -10
  87. package/dist/core/values/tri.js +9 -15
  88. package/dist/core/values/vec.d.ts +9 -24
  89. package/dist/core/values/vec.js +9 -64
  90. package/dist/formats/lens.js +6 -9
  91. package/dist/index.d.ts +0 -11
  92. package/dist/index.js +1 -11
  93. package/dist/jsx-dev-runtime.d.ts +2 -0
  94. package/dist/jsx-dev-runtime.js +5 -0
  95. package/dist/jsx-runtime.d.ts +54 -0
  96. package/dist/jsx-runtime.js +219 -0
  97. package/dist/schema/lens.js +5 -5
  98. package/dist/shapes/drag-behaviors.d.ts +56 -0
  99. package/dist/shapes/drag-behaviors.js +102 -0
  100. package/dist/shapes/drag-spec.d.ts +52 -0
  101. package/dist/shapes/drag-spec.js +112 -0
  102. package/dist/shapes/index.d.ts +3 -1
  103. package/dist/shapes/index.js +3 -1
  104. package/dist/shapes/interaction.d.ts +2 -3
  105. package/dist/shapes/interaction.js +77 -56
  106. package/dist/shapes/label.js +6 -0
  107. package/dist/shapes/layout.d.ts +47 -1
  108. package/dist/shapes/layout.js +59 -1
  109. package/package.json +22 -1
  110. package/dist/coll.d.ts +0 -74
  111. package/dist/coll.js +0 -210
  112. package/dist/core/lenses/closed-form-policies.d.ts +0 -57
  113. package/dist/core/lenses/decompositions.d.ts +0 -14
  114. package/dist/core/lenses/decompositions.js +0 -224
  115. package/dist/core/lenses/domain-aggregates.d.ts +0 -42
  116. package/dist/core/lenses/domain-aggregates.js +0 -245
  117. package/dist/core/lenses/typed-factor.d.ts +0 -40
package/dist/core/cell.js CHANGED
@@ -1,78 +1,94 @@
1
1
  // cell.ts — symmetric bidirectional reactive engine.
2
2
  //
3
- // Forward propagation is alien-signals verbatim (link/propagate/
4
- // checkDirty/shallowPropagate, Dirty/Pending/Recursed flags, lazy pull).
5
- //
6
- // Backward is the SAME shape as forward a lazy push-pull — not a second
7
- // engine, and it carries NO dynamic side tables: state lives on flags plus two
8
- // STATIC adjacencies that mirror forward's `deps`/`subs`. The lens graph is
9
- // stored both ways at construction: `_bwd.parent` is the down edge (a view's
10
- // declared parents, the dual of `deps`) and `_lensSubs` is its transpose, the
11
- // up edge (a cell's direct lens-children, the dual of `subs`). Backward is then
12
- // the forward engine run on this reverse graph — one traversal each direction:
3
+ // Forward propagation is alien-signals verbatim. Backward is the same lazy
4
+ // push-pull run on the transpose of the lens graph, carried by one `LensLink`
5
+ // structure (the backward dual of forward's `Link`): `parentEdges` down,
6
+ // `childEdges` up, created eagerly at lens construction. One traversal each
7
+ // direction:
13
8
  //
14
9
  // role forward (source → view) backward (view → source)
15
- // ---- ------------------------ ------------------------
16
- // down edge subs (who reads me) _bwd.parent (my parents)
17
- // up edge deps (my deps) _lensSubs (my lens-children)
18
- // push (mark) propagate (down `subs`) markDown (down `_bwd.parent`)
19
- // pull (resolve) checkDirty (up `deps`) resolveCone (up `_lensSubs`)
10
+ // down edge subs (who reads me) parentEdges (my parents)
11
+ // up edge deps (my deps) childEdges (my lens-children)
12
+ // push (mark) propagate (down `subs`) markDown (down `parentEdges`)
13
+ // pull (resolve) checkDirty (up `deps`) resolveCone (up `childEdges`)
20
14
  // commit/compute _update / getter writeBack
21
15
  // "dirty" flag F.Dirty (source staged) BF.Dirty (view holds target)
22
16
  // "pending" flag F.Pending (on the cone) BF.Pending (on the back-path)
23
17
  //
24
- // Forward flags live on `flags`, backward on a SEPARATE `bflags` word the two
25
- // engines never share a bit, so a forward reset can't disturb a back-write.
26
- //
27
- // Forward: a source write PUSHES dirtiness down the cone (`Pending`, cheap
28
- // flagging) and the value is PULLED up on read (`checkDirty` ascends `deps` to
29
- // the `Dirty` source). Backward is the exact dual. A write to a view PUSHES
30
- // (`markDown`): stash the `target` on the view itself (`pendingValue` +
31
- // `BF.Dirty`, the dual of a source's `F.Dirty`), descend the static backward
32
- // path flagging each node down to its sources `BF.Pending` (the dual of
33
- // `F.Pending`), and wake each source's forward cone so observers re-fire. No `put`
34
- // runs, no source moves. The work is PULLED per read, source-CENTRIC: a read
35
- // that reaches a back-marked cell `backResolve`s — for a source directly, or by
36
- // DESCENDING a view's marked back-path to its sources first. Each source then
37
- // `resolveCone`s: ASCEND `_lensSubs` through the `BF.Pending` cone to the armed
38
- // views (the dual of `checkDirty` ascending `deps`) and `writeBack` each,
39
- // applying its lens's `put` and staging the source via the SAME forward
40
- // `_writeSource`. A source reflects ALL its writers (they compose into one
41
- // committed value), so a source's cone resolves together and commits once.
42
- // Because resolution follows only the sources the read DEPENDS on, reading one
43
- // chain leaves unrelated sources armed (GRANULAR — does only the work a read
44
- // demands); overlapping co-writers on a shared source all compose; an
45
- // UNOBSERVED write does no backward work; re-writing a view before any read
46
- // keeps only the last target. Reads pull at clean entry points
47
- // (getter top, source `_update`/`_writeSource`, effect `_run`) — never
48
- // mid-compute, so a source-reading `put` never re-enters a half-computed cell.
49
- // Views are never sticky (a view is always `get(source)`; lossy lenses snap),
50
- // no-op deltas short-circuit via equality (the GetPut law).
51
- //
52
- // The ONE irreducibly non-dual ingredient is fan-in accumulation: where the
53
- // forward engine broadcasts one source to N readers, the backward engine
54
- // ACCUMULATES N contributors into one value (the transpose of fan-out). A
55
- // `merge` folds POST-ORDER inside `resolveCone`: its contributors are resolved
56
- // first (each cascades a `put` into the node's `MergeNode`), then it folds ONCE
57
- // by the node's policy (default last-writer-wins) and writes to its parent — the
58
- // dual of a forward getter reading its deps then computing. Everything else —
59
- // 1→1 chains, multi-parent splits (1→N / N→M, e.g. mean/diff), complement-
60
- // carrying stateful lenses — resolves during the walk.
18
+ // Forward flags live on `flags`, backward on a separate `bflags` word, so the
19
+ // two never share a bit. A view write marks the back-path `BF.Pending` and wakes
20
+ // each source's forward cone; nothing runs until a read pulls (source-centric: a
21
+ // source resolves ALL its writers together and commits once). Reads pull only at
22
+ // clean entry points (getter top, source `_update`/`_writeSource`, effect
23
+ // `_run`), never mid-compute. Fan-in is the one non-dual piece: a `merge`
24
+ // accumulates N contributors and folds once, post-order, inside `resolveCone`.
61
25
  //
62
- // Mode table — a cell's role is fully determined by which fields are set:
63
- // source getter undefined (truth in currentValue)
64
- // derived getter, no _bwd (read-only derived)
65
- // lens 1→1 getter + _bwd{ put, parent: Cell }
66
- // multi-out getter + _bwd{ put, parent: Cell[] } (1→N / N→M bwd)
67
- // merge getter + _bwd{ merge } (N→1 backward fold)
68
- // stateful getter + _bwd{ put, parent, stateful } (complement-carrying)
69
- // A cell is writable iff `_bwd !== undefined` (the backward sidecar; see
70
- // `BwdSpec`). `pendingValue` is a source's staged forward write only; a getter
71
- // cell never uses it.
72
- // Forward flag bits (alien-signals v2), on `flags`. Backward state lives in a
73
- // SEPARATE word (`bflags`, `BF` below) so the two engines never share a bit: a
74
- // forward recompute that resets `flags` can't disturb a pending back-write, and
75
- // vice versa — the duals are fully decoupled (no preservation hacks).
26
+ // Mode table — `getter`/`_bwd` fix forward/writable; the backward shape is read off
27
+ // `_bwd` field presence (`merge` / `stateful` / `parentEdges` / `scatter`):
28
+ // source getter undefined (truth in currentValue)
29
+ // derived getter, no _bwd (read-only derived)
30
+ // lens 1→1 getter + _bwd{ put } (scalar put)
31
+ // multi-out getter + _bwd{ put, scatter } (1→N / NM tuple put)
32
+ // merge getter + _bwd{ merge } (N→1 backward fold)
33
+ // stateful getter + _bwd{ put, scatter, stateful } (complement-carrying)
34
+ // pin getter + _bwd{} no parentEdges (parentless sink)
35
+ // Writable iff `_bwd !== undefined`. `pendingValue` is a source's staged write
36
+ // (and a view's armed back-target); a derived cell never uses it forward.
37
+ // Counts-first instrumentation: off by default, one branch per site (see _counts).
38
+ import { COUNTS, counts } from "./_counts.js";
39
+ // ─────────────────────────────────────────────────────────────────────────
40
+ // Module state — mutable engine-wide variables and pooled scratch buffers.
41
+ // Every pool is non-reentrant: forward and backward runs never nest, so one
42
+ // shared buffer per role suffices (no per-call allocation).
43
+ // ─────────────────────────────────────────────────────────────────────────
44
+ let cycle = 0;
45
+ let runDepth = 0;
46
+ let batchDepth = 0;
47
+ let notifyIndex = 0;
48
+ let queuedLength = 0;
49
+ let activeSub;
50
+ let flushing = false;
51
+ /** A microtask flush is queued. Effects run asynchronously (end of turn), so a
52
+ * burst of writes wakes each at most once; reads stay synchronous. */
53
+ let scheduled = false;
54
+ /** A `Sync` watcher (a `network`) is queued: a wake flushes the whole queue
55
+ * synchronously (eager solve), so a read right after the write sees post-solve
56
+ * state. Writes that wake plain effects alone defer to the microtask. */
57
+ let syncFlush = false;
58
+ /** The running self-excluding watcher (`Exclude`-mode `Effect`), passed as
59
+ * `propagate`'s `excluding` so its own writes don't re-trigger it. */
60
+ let activeExcluded;
61
+ const queued = [];
62
+ /** Re-entrancy guard: during a back-resolve a `put`'s source read commits
63
+ * normally but must NOT trigger a nested resolve. */
64
+ let draining = false;
65
+ // Pooled backward-traversal buffers (non-reentrant under `draining`, so reused
66
+ // across calls — no per-call allocation).
67
+ /** `backResolve` phase-1 source worklist (collect, then resolve in phase 2). */
68
+ const backSources = [];
69
+ /** Monotone epoch stamped onto `Cell.bEpoch` during a `backResolve` collect, so
70
+ * diamonds visit each node once without a Set (the backward dual of `cycle`). */
71
+ let backCycle = 0;
72
+ /** `writeBack`'s explicit descent stack (depth-first, left-to-right), as two
73
+ * parallel pooled columns. Non-reentrant — a `writeBack` triggers no nested
74
+ * `writeBack` — so one shared stack suffices (no per-call allocation). */
75
+ const wbNode = [];
76
+ const wbTarget = [];
77
+ /** Stateful lenses passed through in a `writeBack`, re-stamped post-order once
78
+ * their sources are written (versions bumped) so the next forward read sees an
79
+ * unchanged sum and skips `step` — own-write provenance. Pooled; non-reentrant. */
80
+ const wbStateful = [];
81
+ /** `resolveCone`'s pooled post-order frame stack: the node and its next-child
82
+ * cursor. Non-reentrant (no nested `resolveCone`), so shared pools suffice. */
83
+ const rcNode = [];
84
+ const rcEdge = [];
85
+ const EMPTY_DIRTY = new Set();
86
+ /** Fires on every source value-change. Backward writes reach it via `_writeSource`. */
87
+ let writeHook;
88
+ // ─────────────────────────────────────────────────────────────────────────
89
+ // Internal constants & types — flag bits, mode bits, and the node/edge records.
90
+ // ─────────────────────────────────────────────────────────────────────────
91
+ // Forward flag bits (alien-signals v2), on `flags`.
76
92
  const F = {
77
93
  None: 0,
78
94
  Mutable: 1,
@@ -85,31 +101,34 @@ const F = {
85
101
  // Backward flag bits, on a Cell's own `bflags` word (the dual of `flags`).
86
102
  const BF = {
87
103
  None: 0,
88
- /** Backward dual of `F.Dirty`: this VIEW holds an unresolved back-write target
89
- * in `pendingValue` (a getter cell never uses that field forward). The root
90
- * of a pending back-write; `writeBack` consumes the target and clears it. */
104
+ /** Dual of `F.Dirty`: this view holds an unresolved back-target in `pendingValue`. */
91
105
  Dirty: 1,
92
- /** Backward dual of `F.Pending`: this node lies on the back-path from a
93
- * `BF.Dirty` view down to its sources. `markDown` sets it on descent; it gates
94
- * `resolveCone`, which ascends `_lensSubs` only through `BF.Pending` nodes (so
95
- * a read resolves only its own back-cone), and `writeBack` clears it on the
96
- * way back down. */
106
+ /** Dual of `F.Pending`: this node is on the back-path to its sources. */
97
107
  Pending: 2,
108
+ /** Static (set once at construction): a write armed here is structurally
109
+ * impossible — its mandatory back-spine dead-ends at a sole read-only-derived
110
+ * parent. Checked atop `arm` so the throw lands before any backward mutation. */
111
+ WriteBlocked: 4,
98
112
  };
99
- // Named mask (legibility): a cell's whole backward state in one test.
100
113
  /** Armed root OR on a back-path — i.e. a read must `backResolve` first. */
101
114
  const BACK_MARKED = BF.Dirty | BF.Pending;
102
- /** Multi-out / stateful back-write sentinel: "leave this parent untouched."
103
- * A `bwd` returning per-parent updates yields `SKIP` for a parent it declines
104
- * to write; every other slot value is written verbatim — INCLUDING `undefined`,
105
- * which is a first-class cell value, not a hole. A SHORT array skips the trailing
106
- * parents (so writing only the leading few needs no `SKIP` padding); `[]` skips
107
- * all. (Single-out 1→1 `put` has no skip notion: it always writes its one parent,
108
- * so it stays `undefined`-safe by construction.) */
109
- export const SKIP = Symbol("bireactive.SKIP");
110
- // Mode predicates the single place a cell's role is read off its fields (see
111
- // the mode table by `BwdSpec`). V8 inlines these; the backward walk reads them
112
- // instead of duplicating `getter`/`_bwd` field probes.
115
+ // Effect mode bits (on `Effect.mode`), so one watcher class serves both plain
116
+ // effects (`None`) and `network()` (which sets these).
117
+ const EM = {
118
+ None: 0,
119
+ /** Explicit topology: body reads don't auto-subscribe (no re-link / purge). */
120
+ NoTrack: 1,
121
+ /** Self-exclude the node's own writes (set `activeExcluded` during the body). */
122
+ Exclude: 2,
123
+ /** A wake forces a synchronous flush (eager solve), vs the microtask default. */
124
+ Sync: 4,
125
+ /** Don't auto-fire on a wake; only an explicit `flush()` advances the body. */
126
+ Manual: 8,
127
+ };
128
+ // ─────────────────────────────────────────────────────────────────────────
129
+ // Internal helpers — mode predicates, edge wiring, and the write-hook installer.
130
+ // ─────────────────────────────────────────────────────────────────────────
131
+ // Mode predicates — the single place a cell's role is read off its fields.
113
132
  /** Source (truth leaf): no forward derivation. */
114
133
  function isSource(c) {
115
134
  return c.getter === undefined;
@@ -118,68 +137,100 @@ function isSource(c) {
118
137
  function isWritable(c) {
119
138
  return c._bwd !== undefined;
120
139
  }
121
- /** Read-only derived: a `derive` with no backward path. A split routes around
122
- * it; a sole parent has nowhere to land (the back-walk throws). */
140
+ /** Read-only derived: a `derive` with no backward path (back-walk throws on it). */
123
141
  function isReadOnlyDerived(c) {
124
142
  return !isSource(c) && !isWritable(c);
125
143
  }
126
- /** Forward primal a source-reading `bwd` linearizes at, WITHOUT a cascading
127
- * recompute (the lazy dual of reverse-mode AD reusing a stored linearization
128
- * point). Source or realized derived its live/last-settled value (PutGet
129
- * holds for any source state, so a stale primal still round-trips); unrealized
130
- * derived (`Dirty`) → realize once via `.value`, seeding `currentValue`. */
144
+ /** Forward primal a source-reading `bwd` linearizes at, without a cascading
145
+ * recompute: live/last-settled value for a source or realized derived, else
146
+ * realize once via `.value` (PutGet holds for any source state). */
131
147
  function backPrimal(c) {
132
148
  if (c.getter === undefined || c.flags & F.Dirty)
133
149
  return c.value;
134
150
  return c.currentValue;
135
151
  }
136
- /** Register `node` on each backward parent's `_lensSubs` (the edge `resolveCone`
137
- * ascends), once, lazily on the first back-write so a lens only ever read
138
- * forward never allocates it. Idempotent via `_linkedBack`; persists for life. */
139
- function linkBack(node) {
140
- if (node._linkedBack)
141
- return;
142
- node._linkedBack = true;
143
- const parent = node._bwd.parent; // set for every mode except `pin` (parentless)
144
- if (parent === undefined)
152
+ /** Create a lens-edge `child →[index] parent`, appending it to `child`'s
153
+ * `parentEdges` (down) eagerly at construction, in tuple order (so
154
+ * `parentEdges` is index-ordered). The up-list (`parent.childEdges`) is spliced
155
+ * lazily on first back-mark (`linkChild`), so child order is arm-order. */
156
+ function linkLens(child, parent, index) {
157
+ const e = {
158
+ index,
159
+ parent,
160
+ child,
161
+ linked: false,
162
+ nextParent: undefined,
163
+ prevChild: undefined,
164
+ nextChild: undefined,
165
+ };
166
+ if (child.parentEdgesTail !== undefined)
167
+ child.parentEdgesTail.nextParent = e;
168
+ else
169
+ child.parentEdges = e;
170
+ child.parentEdgesTail = e;
171
+ }
172
+ /** Splice a lens-edge into its parent's `childEdges` (the up-traversal list),
173
+ * once, on first back-mark — so a parent's child order is arm-order and
174
+ * co-writer resolution is last-write-wins. Idempotent via `linked`. */
175
+ function linkChild(e) {
176
+ if (e.linked)
145
177
  return;
146
- if (Array.isArray(parent))
147
- for (let i = 0; i < parent.length; i++)
148
- (parent[i]._lensSubs ??= []).push(node);
178
+ if (COUNTS)
179
+ counts.linkChild++;
180
+ e.linked = true;
181
+ const parent = e.parent;
182
+ e.prevChild = parent.childEdgesTail;
183
+ if (parent.childEdgesTail !== undefined)
184
+ parent.childEdgesTail.nextChild = e;
149
185
  else
150
- (parent._lensSubs ??= []).push(node);
186
+ parent.childEdges = e;
187
+ parent.childEdgesTail = e;
188
+ }
189
+ /** Remove a lens-edge from its parent's `childEdges` up-list in O(1), and mark it
190
+ * re-linkable — the backward dual of `unlink` dropping a subscriber from `subs`.
191
+ * Called when a view is unwatched, to release the parent→child retaining edge (a
192
+ * later arm re-`linkChild`s). The child's own down-list (`parentEdges`) stays:
193
+ * it's intrinsic to the view and dies with it. */
194
+ function unlinkChild(e) {
195
+ if (COUNTS)
196
+ counts.unlinkChild++;
197
+ const { parent, prevChild, nextChild } = e;
198
+ if (nextChild !== undefined)
199
+ nextChild.prevChild = prevChild;
200
+ else
201
+ parent.childEdgesTail = prevChild;
202
+ if (prevChild !== undefined)
203
+ prevChild.nextChild = nextChild;
204
+ else
205
+ parent.childEdges = nextChild;
206
+ e.linked = false;
207
+ e.prevChild = undefined;
208
+ e.nextChild = undefined;
209
+ }
210
+ /** Precompute `BF.WriteBlocked` once, after a writable's `parentEdges` are linked.
211
+ * Mirrors `markDown`'s descent exactly: a sole read-only-derived parent dead-ends
212
+ * (block); a split routes around a read-only parent; otherwise the block is
213
+ * inherited from any non-read-only parent already flagged. Topology is immutable
214
+ * and parents are built first, so each node's bit is its parents' bits + one scan. */
215
+ function setWriteBlocked(cell) {
216
+ const pe = cell.parentEdges;
217
+ if (pe === undefined)
218
+ return; // parentless sink (pin): absorbs, never dead-ends
219
+ const sole = pe.nextParent === undefined;
220
+ for (let e = pe; e !== undefined; e = e.nextParent) {
221
+ const p = e.parent;
222
+ if (isReadOnlyDerived(p)) {
223
+ if (sole) {
224
+ cell.bflags |= BF.WriteBlocked; // markDown would throw at this dead-end
225
+ return;
226
+ }
227
+ }
228
+ else if (p.bflags & BF.WriteBlocked) {
229
+ cell.bflags |= BF.WriteBlocked; // a descended parent dead-ends deeper
230
+ return;
231
+ }
232
+ }
151
233
  }
152
- let cycle = 0;
153
- let runDepth = 0;
154
- let batchDepth = 0;
155
- let notifyIndex = 0;
156
- let queuedLength = 0;
157
- let activeSub;
158
- let flushing = false;
159
- /** A microtask is queued to flush the effect queue. Effects run ASYNCHRONOUSLY
160
- * (end of the current microtask turn), so a burst of synchronous writes wakes
161
- * each effect at most once — no `batch()` needed for coalescing. Reads stay
162
- * synchronous and pull-based (a `.value` reflects writes immediately); only
163
- * EFFECT side-effects defer. `settle()` forces a synchronous flush. */
164
- let scheduled = false;
165
- /** A `network` (constraint solver / explicit reactive sub-DAG) is in the queue.
166
- * Networks are EAGER — a write that wakes one flushes the whole queue
167
- * synchronously, so a read right after the write sees post-solve state (and any
168
- * effect woken alongside runs after the network, in queue order). Only writes
169
- * that wake effects ALONE defer to the microtask. */
170
- let networkQueued = false;
171
- /** Network running its body, if any. Source writes self-exclude it so a
172
- * network reading+writing a cell doesn't re-trigger itself. */
173
- let activeNetwork;
174
- const queued = [];
175
- /** Re-entrancy guard: while a back-resolve runs, a `put`'s source read commits
176
- * normally (in-order composition) but must NOT trigger a nested resolve. */
177
- let draining = false;
178
- const EMPTY_DIRTY = new Set();
179
- // Fires on every SOURCE value-change (the one place truth mutates).
180
- // Backward writes reach it via `_writeSource`, attributing lens edits to
181
- // the source they resolve to.
182
- let writeHook;
183
234
  /** Install a hook fired on every source value-change; returns a restore fn. */
184
235
  export function setCellWriteHook(fn) {
185
236
  const prev = writeHook;
@@ -188,6 +239,9 @@ export function setCellWriteHook(fn) {
188
239
  writeHook = prev;
189
240
  };
190
241
  }
242
+ // ─────────────────────────────────────────────────────────────────────────
243
+ // Forward graph engine (internal) — alien-signals verbatim.
244
+ // ─────────────────────────────────────────────────────────────────────────
191
245
  // alien-signals algorithm (verbatim): link / unlink / propagate / checkDirty.
192
246
  function link(dep, sub, version) {
193
247
  const prevDep = sub.depsTail;
@@ -202,6 +256,8 @@ function link(dep, sub, version) {
202
256
  const prevSub = dep.subsTail;
203
257
  if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub)
204
258
  return;
259
+ if (COUNTS)
260
+ counts.link++;
205
261
  const isFirstSub = dep.subs === undefined;
206
262
  const newLink = (sub.depsTail =
207
263
  dep.subsTail =
@@ -232,6 +288,8 @@ function link(dep, sub, version) {
232
288
  }
233
289
  }
234
290
  function unlink(l, sub = l.sub) {
291
+ if (COUNTS)
292
+ counts.unlink++;
235
293
  const { dep, prevDep, nextDep, nextSub, prevSub } = l;
236
294
  if (nextDep !== undefined)
237
295
  nextDep.prevDep = prevDep;
@@ -252,13 +310,14 @@ function unlink(l, sub = l.sub) {
252
310
  return nextDep;
253
311
  }
254
312
  function propagate(start, innerWrite, excluding) {
313
+ if (COUNTS)
314
+ counts.propagate++;
255
315
  let l = start;
256
316
  let next = start.nextSub;
257
317
  let stack;
258
318
  top: do {
259
319
  const sub = l.sub;
260
- // `excluding` skips one subscriber (used by `network()` so a body
261
- // writing a cell it subscribes to doesn't re-trigger itself).
320
+ // `excluding` skips one subscriber (a `network` not re-triggering itself).
262
321
  if (sub !== excluding) {
263
322
  let flags = sub.flags;
264
323
  if (!(flags & (F.RecursedCheck | F.Recursed | F.Dirty | F.Pending))) {
@@ -309,6 +368,8 @@ function propagate(start, innerWrite, excluding) {
309
368
  } while (true);
310
369
  }
311
370
  function checkDirty(startLink, startSub) {
371
+ if (COUNTS)
372
+ counts.checkDirty++;
312
373
  let l = startLink, sub = startSub;
313
374
  let stack;
314
375
  let checkDepth = 0, dirty = false;
@@ -318,11 +379,10 @@ function checkDirty(startLink, startSub) {
318
379
  if (sub.flags & F.Dirty)
319
380
  dirty = true;
320
381
  else if ((flags & (F.Mutable | F.Dirty)) === (F.Mutable | F.Dirty) ||
321
- // A back-`Pending` SOURCE (leaf: no getter) looks unchanged until its
322
- // back-write resolves; `_update` resolves it (pulls its registered views,
323
- // runs the `put`s), then reports whether it moved like a forward-`Dirty`
324
- // source. Intermediate views are also back-`Pending`, but they have a
325
- // getter and fall through to the `Pending` recurse below.
382
+ // A back-`Pending` source looks unchanged until `_update` resolves it
383
+ // (pulls its views, runs the `put`s, stages it) and reports if it moved —
384
+ // like a `Dirty` source. That resolve can re-mark nodes on this pull's
385
+ // stack; the unwind below honors any such `F.Dirty`.
326
386
  (flags & F.Mutable &&
327
387
  dep.bflags & BF.Pending &&
328
388
  isSource(dep))) {
@@ -350,12 +410,9 @@ function checkDirty(startLink, startSub) {
350
410
  while (checkDepth--) {
351
411
  l = stack.value;
352
412
  stack = stack.prev;
353
- // `dirty` tracks change down THIS branch, but a node may have been marked
354
- // `F.Dirty` independently `shallowPropagate` fires when a lazy back-write
355
- // commits its source mid-`checkDirty` (a hazard a forward-only engine never
356
- // sees, since sources commit before the pull). Honor that bit too, else we'd
357
- // clear its `F.Pending` below without recomputing — stranding a `Dirty` node
358
- // whose observers never re-run.
413
+ // `dirty` tracks change down this branch, but a node may have been marked
414
+ // `F.Dirty` independently (a stateful stash `writeBack` mid-pull) honor
415
+ // that too, else we'd clear its `F.Pending` without recomputing.
359
416
  if (dirty || sub.flags & F.Dirty) {
360
417
  const subs = sub.subs;
361
418
  if (sub._update()) {
@@ -416,62 +473,49 @@ function disposeAllDepsInReverse(sub) {
416
473
  }
417
474
  class MergeNode {
418
475
  foldFn;
419
- /** Contributions gathered as this merge's cone resolves; folded and cleared
420
- * in `foldMerge` (the merge-owned buffer, mutated in place). The parent it
421
- * writes to is just `b.parent` (a merge's `b.parent` IS its fold target), so
422
- * this node carries only the policy + buffer — no duplicate edge. */
476
+ /** Contributions gathered as the cone resolves; folded and cleared in `foldMerge`. */
423
477
  contributions = [];
424
478
  constructor(fold) {
425
479
  this.foldFn = fold;
426
480
  }
427
481
  }
428
- // BwdSpec — the backward sidecar.
429
- //
430
- // Every cell carries the forward fields (links, getter, value cache).
431
- // Only a WRITABLE derived cell 1→1 lens, multi-output lens, merge,
432
- // stateful lens, or `pin` needs a backward target and the closures to
433
- // drive it. Those fields live here, off a single `_bwd` pointer, rather
434
- // than inline on `Cell`, so a source/computed stays lean: the forward hot
435
- // path never touches them, and a plain node drops ~64 B. A cell is
436
- // writable iff `_bwd !== undefined`.
437
- //
438
- // Two mode payloads hang off named fields rather than one union, so each
439
- // stays distinctly typed: `merge` (the N→1 fold node) and `stateful` (the
440
- // complement machinery of a complement-carrying lens). Both are rare, so
441
- // a plain 1→1 / multi-out lens leaves them `undefined` and pays only
442
- // `parent` + `put`.
482
+ // BwdSpec — the backward sidecar, off a single `_bwd` pointer so a source/computed
483
+ // stays lean. Only a writable derived cell (lens / multi-out / merge / stateful /
484
+ // pin) carries one; writable iff `_bwd !== undefined`. The backward shape is read
485
+ // off field presence rather than a tag: `merge` set ⇒ fan-in fold; `stateful` set ⇒
486
+ // complement-carrying; no `parentEdges` pin sink; `scatter` tuple `put`. The
487
+ // one bit that isn't recoverable from topology is scalar-vs-tuple `put` (a 1-parent
488
+ // split still takes a tuple), hence `scatter`.
443
489
  class BwdSpec {
444
- /** Backward target(s): one `Cell` (1→1 / merge) or `Cell[]` (multi-out). */
445
- parent = undefined;
446
- /** Lens `put` backward derivation (dual of `getter`). A 1→1 / multi-out
447
- * lens is called as `put(target)` (a source-reading lens reads the current
448
- * parent(s) at walk time, untracked); multi-out returns a per-parent update
449
- * array. Stateful is the spec's `bwd`, called `put(target, sources, c)`. */
490
+ /** Lens `put` (dual of `getter`): `put(target)` for 1→1 / multi-out (a
491
+ * source-reading lens reads its parents at walk time), `put(target, sources, c)`
492
+ * for stateful. `undefined` for a merge (folds) or pin (absorbs). */
450
493
  // biome-ignore lint/suspicious/noExplicitAny: put fn is opaque shape
451
494
  put = undefined;
452
- /** Backward aggregation node; presence IS the merge-mode discriminant. */
495
+ /** Fold payload; present a fan-in merge. */
453
496
  merge = undefined;
454
- /** Complement machinery; presence IS the stateful-mode discriminant. */
497
+ /** Complement state; present a complement-carrying (stateful) lens. */
455
498
  stateful = undefined;
499
+ /** `put` yields a per-parent tuple (split / stateful) vs a scalar (1→1). The
500
+ * only discriminant not derivable from topology (a 1-parent split is a tuple). */
501
+ scatter = false;
456
502
  }
457
- /** Runtime state of a stateful (complement-carrying) lens the rare
458
- * backward mode, kept off `BwdSpec` so plain lenses don't carry its slots.
459
- * `put` (the spec's `bwd`) and `parent` stay on `BwdSpec`; this holds the
460
- * complement and the closures that project from / advance it. */
503
+ /** Runtime state of a symmetric-lens complement, kept off `BwdSpec` so plain
504
+ * lenses don't carry its slots. See the stateful-lens header for the theory
505
+ * (symmetric/edit lenses) and the version-stamp provenance. */
461
506
  class StatefulCore {
462
507
  /** Engine-owned memory the view discards. */
463
508
  complement;
464
- /** Advance the complement: `step(sources, complement, external)`. (The
465
- * forward projection `fwd` is captured directly in the getter closure — it
466
- * is only ever read there — so it costs no slot here.) */
509
+ /** Advance the complement: `step(sources, complement)`. Run only when the
510
+ * sources actually moved (the engine gates it; see the stateful header). */
467
511
  // biome-ignore lint/suspicious/noExplicitAny: opaque step shape
468
512
  step;
469
- /** Sources this lens last committed back (the own-vs-external test compares
470
- * live sources against these); `undefined` until the first back-write. A
471
- * back-write reads the live sources into a fresh array, builds the committed
472
- * candidate in place, and keeps it as `last` for the next own-vs-external
473
- * comparison. */
474
- last = undefined;
513
+ /** Sum of the parents' `version`s as of the last sync. Sources moved iff the
514
+ * live sum differs the lazy own-vs-external provenance that replaces a value
515
+ * witness. A read syncs it after stepping; a back-write re-stamps it post-order
516
+ * (own writes don't re-step, so `bwd` must leave the complement consistent).
517
+ * Seeded to `-1` (sums are ≥ 0) so the first use always folds the sources in. */
518
+ stamp = -1;
475
519
  constructor(complement,
476
520
  // biome-ignore lint/suspicious/noExplicitAny: opaque step shape
477
521
  step) {
@@ -479,6 +523,13 @@ class StatefulCore {
479
523
  this.step = step;
480
524
  }
481
525
  }
526
+ // ─────────────────────────────────────────────────────────────────────────
527
+ // Public API — sentinels, read/write shapes, and value-coercion helpers.
528
+ // ─────────────────────────────────────────────────────────────────────────
529
+ /** Multi-out / stateful back-write sentinel: "leave this parent untouched."
530
+ * Every non-`SKIP` slot is written verbatim, `undefined` included; a short array
531
+ * skips the trailing parents. (1→1 `put` always writes its one parent.) */
532
+ export const SKIP = Symbol("bireactive.SKIP");
482
533
  /** Snapshot a `Val<T>` to plain `T` (one-shot, no tracking). */
483
534
  export function readNow(v) {
484
535
  if (v instanceof Cell)
@@ -508,6 +559,9 @@ export const isCell = (v) => v instanceof Cell;
508
559
  export const isLens = (v) => v instanceof Cell && v.getter !== undefined && v._bwd !== undefined;
509
560
  /** Read-only mode: derived with no backward path. */
510
561
  export const isReadonly = (v) => v instanceof Cell && v.getter !== undefined && v._bwd === undefined;
562
+ // ─────────────────────────────────────────────────────────────────────────
563
+ // Public API — the Cell class (the one user-facing reactive primitive).
564
+ // ─────────────────────────────────────────────────────────────────────────
511
565
  export class Cell {
512
566
  /** @internal */
513
567
  flags;
@@ -533,14 +587,28 @@ export class Cell {
533
587
  pendingValue;
534
588
  /** @internal Backward sidecar; `undefined` iff read-only. Writability is `_bwd !== undefined`. */
535
589
  _bwd;
536
- /** @internal Backward dual of `subs`: direct lens-children for back-write cone traversal. */
537
- _lensSubs;
590
+ /** @internal Lens-edges to my back-targets (down); dual of `deps`. `markDown`/
591
+ * `backResolve` descend this toward sources. Index-ordered. */
592
+ parentEdges;
593
+ /** @internal */
594
+ parentEdgesTail;
595
+ /** @internal Lens-edges to my lens-children (up); dual of `subs`. `resolveCone`
596
+ * ascends this toward the armed views. */
597
+ childEdges;
598
+ /** @internal */
599
+ childEdgesTail;
538
600
  /** @internal Backward flag word (`BF`), dual of forward `flags`. */
539
601
  bflags;
540
- /** @internal Guards against `linkBack` re-registering a duplicate in `_lensSubs`. */
541
- _linkedBack;
542
- // Every slot is assigned exactly once below, in declaration order, for a stable
543
- // V8 hidden class across all variants (source / computed / lens / merge).
602
+ /** @internal Visit epoch for `backResolve`'s collect phase (dedups diamonds
603
+ * without a Set; compared against the global `backCycle`). */
604
+ bEpoch;
605
+ /** @internal Monotone committed-change counter. A stateful lens sums its
606
+ * parents' versions to detect "did my sources move since I last synced?" —
607
+ * the lazy provenance that replaces a value witness (see the stateful header). */
608
+ version;
609
+ /** Optional debug label (`cell(0, { name })`); used by errors and graph dumps. */
610
+ name;
611
+ // Every slot assigned once, in declaration order, for a stable V8 hidden class.
544
612
  constructor(initial, opts) {
545
613
  this.flags = F.Mutable;
546
614
  this.subs = undefined;
@@ -554,9 +622,14 @@ export class Cell {
554
622
  this.currentValue = initial;
555
623
  this.pendingValue = initial;
556
624
  this._bwd = undefined;
557
- this._lensSubs = undefined;
625
+ this.parentEdges = undefined;
626
+ this.parentEdgesTail = undefined;
627
+ this.childEdges = undefined;
628
+ this.childEdgesTail = undefined;
558
629
  this.bflags = BF.None;
559
- this._linkedBack = false;
630
+ this.bEpoch = 0;
631
+ this.version = 0;
632
+ this.name = undefined;
560
633
  if (opts !== undefined) {
561
634
  if (opts.equals !== undefined)
562
635
  this._equals = opts.equals;
@@ -564,23 +637,28 @@ export class Cell {
564
637
  this._watched = opts.watched;
565
638
  if (opts.unwatched !== undefined)
566
639
  this._unwatchedHook = opts.unwatched;
640
+ if (opts.name !== undefined)
641
+ this.name = opts.name;
567
642
  }
568
643
  }
569
644
  /** @internal Single write-commit point; self-excludes the active network. */
570
645
  _writeSource(next) {
571
- // A forward write to a source with an unresolved back-write demand resolves
572
- // it FIRST, so the later forward write wins (LWW).
646
+ // Resolve any pending back-write first, so the later forward write wins (LWW).
573
647
  if (this.bflags & BF.Pending && !draining)
574
648
  backResolve(this);
575
649
  const prev = this.pendingValue;
576
650
  this.pendingValue = next;
577
651
  if (!this._equals(prev, next)) {
652
+ this.version++; // stamp the change for stateful-lens provenance (sum-of-versions)
578
653
  this.flags = F.Mutable | F.Dirty;
579
654
  if (writeHook !== undefined)
580
655
  writeHook(this);
581
656
  const subs = this.subs;
582
657
  if (subs !== undefined) {
583
- propagate(subs, runDepth > 0, activeNetwork);
658
+ // Convert the cone's arm-time `Pending` into `Dirty` so a second observer
659
+ // (not just the first reader) sees the change. If this lands mid-pull, the
660
+ // freshly-`Dirty` nodes are honored by `checkDirty`'s unwind.
661
+ propagate(subs, runDepth > 0, activeExcluded);
584
662
  autoFlush();
585
663
  }
586
664
  }
@@ -588,6 +666,8 @@ export class Cell {
588
666
  /** @internal */
589
667
  _update() {
590
668
  if (this.getter !== undefined) {
669
+ if (COUNTS)
670
+ counts.recompute++;
591
671
  this.depsTail = undefined;
592
672
  this.flags = F.Mutable | F.RecursedCheck;
593
673
  const prev = activeSub;
@@ -598,7 +678,10 @@ export class Cell {
598
678
  const old = this.currentValue;
599
679
  const next = (this.currentValue = this.getter());
600
680
  threw = false;
601
- return !this._equals(old, next);
681
+ const changed = !this._equals(old, next);
682
+ if (changed)
683
+ this.version++; // derived commit: stamp for stateful provenance
684
+ return changed;
602
685
  }
603
686
  finally {
604
687
  activeSub = prev;
@@ -606,10 +689,8 @@ export class Cell {
606
689
  purgeDeps(this);
607
690
  }
608
691
  }
609
- // A back-`Pending` source first resolves its armed back-write (`backResolve`
610
- // pulls the views registered on it, runs the `put`s, stages it via
611
- // `_writeSource`) — so its `pendingValue` reflects the back-write before we
612
- // commit it.
692
+ // A back-`Pending` source resolves its armed back-write first, so
693
+ // `pendingValue` reflects it before we commit.
613
694
  if (this.bflags & BF.Pending && !draining)
614
695
  backResolve(this);
615
696
  this.flags = F.Mutable;
@@ -621,6 +702,17 @@ export class Cell {
621
702
  _notify() { }
622
703
  /** @internal */
623
704
  _unwatched() {
705
+ // Backward dual of `unlink` clearing us from each parent's `subs`: release
706
+ // the parent→child retaining edge (the `childEdges` up-list) so a disposed
707
+ // view stops being pinned by a long-lived source. Our own down-list
708
+ // (`parentEdges`) stays — a later arm re-links via `markDown`. Skip a still
709
+ // back-marked view (a pending write needs its edge); rare and bounded.
710
+ if (!(this.bflags & BACK_MARKED)) {
711
+ for (let e = this.parentEdges; e !== undefined; e = e.nextParent) {
712
+ if (e.linked)
713
+ unlinkChild(e);
714
+ }
715
+ }
624
716
  if (this.getter !== undefined && this.depsTail !== undefined) {
625
717
  this.flags = F.Mutable | F.Dirty;
626
718
  disposeAllDepsInReverse(this);
@@ -641,22 +733,29 @@ export class Cell {
641
733
  }
642
734
  // Construction helpers build via `new this()` so a subclass static
643
735
  // (`Vec.lens(...)`) yields a `Vec` with its constructor-set equality.
644
- // Every lens has a structural backward target (`_bwd.parent`), which is
645
- // what makes the backward pass well-defined.
646
736
  /** Endomorphic lens. A 2-arg `bwd(view, current)` consults the current
647
737
  * source; a 1-arg `bwd(view)` reconstructs it from the view alone. */
648
738
  lens(fwd, bwd) {
649
- return buildLens1(this.constructor, this, fwd, bwd, bwd.length >= 2);
739
+ return buildLens(this.constructor, [this, fwd, bwd]);
650
740
  }
651
741
  /** Read-only same-type view: the RO dual of the endo `.lens`. For a cross-type view use the typed static
652
742
  * `Target.derive(src, fn)`. */
653
743
  derive(fn) {
654
744
  return buildDerived(this.constructor, () => fn(this.value));
655
745
  }
656
- /** Backward fan-in node. Forward, the identity view of its parent;
657
- * backward, the convergence point where N contributors (upstream lenses
658
- * and direct writes) fold into one value for the parent. `fold` is handed
659
- * every live push at once; omitted, it is last-writer-wins. */
746
+ // biome-ignore lint/suspicious/noExplicitAny: heterogeneous optic chain
747
+ through(...optics) {
748
+ // Fold via each optic's own `through` (no import of optic.ts no cycle).
749
+ const o = optics.length === 1 ? optics[0] : optics.reduce((a, b) => a.through(b));
750
+ // Preserve put arity: a source-reading optic binds 2-arg; an iso binds 1-arg
751
+ // (reconstruct, no source read), matching `lens`'s `bwd.length` dispatch.
752
+ const bwd = o.readsSource
753
+ ? (target, cur) => o.put(target, cur)
754
+ : (target) => o.put(target, undefined);
755
+ return lens(this, o.get, bwd);
756
+ }
757
+ /** Backward fan-in: forwards its parent's value unchanged; on write, folds N
758
+ * contributors into one value. `fold` defaults to last-writer-wins. */
660
759
  merge(fold) {
661
760
  if (this.getter !== undefined && this._bwd === undefined) {
662
761
  throw new TypeError("merge: receiver is read-only");
@@ -666,27 +765,26 @@ export class Cell {
666
765
  cell.flags = F.Mutable | F.Dirty;
667
766
  cell.getter = () => parent.value;
668
767
  const b = (cell._bwd = new BwdSpec());
669
- b.parent = parent;
670
768
  b.merge = new MergeNode(fold);
769
+ linkLens(cell, parent, 0);
770
+ setWriteBlocked(cell);
671
771
  return cell;
672
772
  }
673
773
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
674
774
  static derive(...args) {
675
- return dispatchDerive(this, args);
775
+ return buildDerive(this, args);
676
776
  }
677
777
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
678
778
  static lens(...args) {
679
- return dispatchLens(this, args);
779
+ return buildLens(this, args);
680
780
  }
681
781
  /** Type predicate against this class: `Vec.is(x)` narrows `x` to `Vec`.
682
782
  * Inherited static; works for any subclass via polymorphic `this`. */
683
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
684
783
  static is(v) {
685
784
  return v instanceof this;
686
785
  }
687
786
  /** Coerce `Val<Inner<Cls>>` → `Cls`: instance → identity, RO cell →
688
787
  * tracked `derive`, literal → fresh seed. */
689
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
690
788
  static coerce(v) {
691
789
  if (v instanceof this)
692
790
  return v;
@@ -698,20 +796,17 @@ export class Cell {
698
796
  }
699
797
  /** Writable-shaped constant: always reads `v`, absorbs writes
700
798
  * (parentless sink lens), for APIs demanding bidirectionality. */
701
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
702
799
  static pin(v) {
703
800
  const cell = new this();
704
801
  cell.flags = F.Mutable | F.Dirty;
705
802
  cell.getter = () => v;
706
- // Parentless `_bwd`: `writeBack` absorbs at `parent === undefined` before any
707
- // `put`, so the sink needs no closure — writability is just `_bwd !== undefined`.
803
+ // Parentless `_bwd`: `writeBack` absorbs it (no parent edges, no closure).
708
804
  cell._bwd = new BwdSpec();
709
805
  return cell;
710
806
  }
711
807
  }
712
808
  /** Typed field lens onto `parent.value[key]`. RO parent → RO derive;
713
809
  * writable parent → bidirectional lens with spread-replace `put`. */
714
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
715
810
  export function fieldOf(
716
811
  // biome-ignore lint/suspicious/noExplicitAny: parent is contravariant on put
717
812
  parent, key, Cls) {
@@ -721,7 +816,17 @@ parent, key, Cls) {
721
816
  if (ro) {
722
817
  return buildDerived(ctor, () => get(parent.value));
723
818
  }
724
- return buildLens1(ctor, parent, get, (v, s) => ({ ...s, [key]: v }), true);
819
+ // Spread-replace put, array-aware: cloning an array with object spread would
820
+ // demote it to a plain record, so copy via `slice` and set the index.
821
+ const put = (v, s) => {
822
+ if (Array.isArray(s)) {
823
+ const next = s.slice();
824
+ next[key] = v;
825
+ return next;
826
+ }
827
+ return { ...s, [key]: v };
828
+ };
829
+ return buildLens(ctor, [parent, get, put]);
725
830
  }
726
831
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
727
832
  function buildDerived(Cls, getter) {
@@ -730,50 +835,95 @@ function buildDerived(Cls, getter) {
730
835
  cell.flags = F.Mutable | F.Dirty;
731
836
  return cell;
732
837
  }
733
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
734
- function buildLens1(Cls, parent, fwd, bwd, readsSource) {
735
- const cell = new Cls();
736
- cell.flags = F.Mutable | F.Dirty;
737
- cell.getter = (() => fwd(parent.value));
738
- const b = (cell._bwd = new BwdSpec());
739
- // Source-reading lenses linearize at the parent's primal (`backPrimal`: the
740
- // last-settled value for a derived, the staged truth for a source) so the
741
- // engine always calls the 1-arg form (no arity branch) and never recomputes a
742
- // derived parent's cone just to read it back.
743
- b.put = readsSource ? (t) => bwd(t, backPrimal(parent)) : bwd;
744
- b.parent = parent;
745
- return cell;
746
- }
747
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
748
- function buildLensN(Cls, parents, fwd, bwd, readsSource) {
838
+ // Shared N-input read getter: refill a construction-owned buffer from the parents
839
+ // each read (no per-read alloc), then apply `fwd`. Identical hot closure whether
840
+ // the node is a read-only derive-N or a writable split lens.
841
+ function arrayGetter(parents, fwd) {
749
842
  const n = parents.length;
750
843
  const vals = new Array(n);
751
- const cell = new Cls();
752
- cell.flags = F.Mutable | F.Dirty;
753
- cell.getter = (() => {
844
+ return () => {
754
845
  for (let i = 0; i < n; i++)
755
846
  vals[i] = parents[i].value;
756
847
  return fwd(vals);
757
- });
758
- if (bwd === undefined)
759
- return cell; // read-only derive-N
760
- const b = (cell._bwd = new BwdSpec());
761
- b.parent = parents;
762
- if (readsSource) {
763
- // Own reused buffer, NOT the getter's `vals`: the walk reads each parent's
764
- // primal (`backPrimal`), so a separate array avoids aliasing the getter's.
765
- // `bwd` consumes it synchronously and must not retain it (same as `fwd`);
766
- // re-entry through the same lens is impossible (the lens graph is a DAG).
767
- const args = new Array(n);
768
- b.put = (target) => {
769
- for (let i = 0; i < n; i++)
770
- args[i] = backPrimal(parents[i]);
771
- return bwd(target, args);
848
+ };
849
+ }
850
+ // One writable-lens constructor for both shapes. The `Array.isArray` branch is paid
851
+ // once at construction and installs the matching specialized hot closures — scalar
852
+ // scalar `getter`/`put` for 1→1, the buffer-loop getter + tuple `put` (`scatter`)
853
+ // for N→M — so neither hot path changes. A 2-arg call is the
854
+ // complement-carrying form and routes to `buildStateful`. `bwd` is always present
855
+ // (a read-only N view is a `derive`, built via `buildDerive`).
856
+ // biome-ignore lint/suspicious/noExplicitAny: dispatch over the untyped call forms
857
+ function buildLens(Cls, args) {
858
+ const parent0 = args[0];
859
+ if (args.length === 2) {
860
+ return Array.isArray(parent0)
861
+ ? buildStateful(Cls, parent0, args[1])
862
+ : buildStateful1(Cls, parent0, args[1]);
863
+ }
864
+ let parent = parent0;
865
+ let fwd = args[1];
866
+ let bwd = args[2];
867
+ // Object-keyed parents → rewrite to the positional array form (key order
868
+ // fixed once; omitted backward keys become SKIP). The tuple fast path below
869
+ // is untouched.
870
+ if (parent0 !== null &&
871
+ typeof parent0 === "object" &&
872
+ !Array.isArray(parent0) &&
873
+ !(parent0 instanceof Cell)) {
874
+ const keys = Object.keys(parent0);
875
+ const rec = parent0;
876
+ const fwdObj = fwd;
877
+ const bwdObj = bwd;
878
+ const toObj = (vals) => {
879
+ const o = {};
880
+ for (let i = 0; i < keys.length; i++)
881
+ o[keys[i]] = vals[i];
882
+ return o;
772
883
  };
884
+ parent = keys.map(k => rec[k]);
885
+ fwd = (vals) => fwdObj(toObj(vals));
886
+ bwd = ((t, vals) => {
887
+ const o = bwdObj(t, toObj(vals));
888
+ return keys.map(k => (k in o ? o[k] : SKIP));
889
+ });
890
+ }
891
+ const readsSource = bwd.length >= 2;
892
+ const cell = new Cls();
893
+ cell.flags = F.Mutable | F.Dirty;
894
+ const b = (cell._bwd = new BwdSpec());
895
+ if (Array.isArray(parent)) {
896
+ const parents = parent;
897
+ const n = parents.length;
898
+ cell.getter = arrayGetter(parents, fwd);
899
+ b.scatter = true;
900
+ for (let i = 0; i < n; i++)
901
+ linkLens(cell, parents[i], i);
902
+ if (readsSource) {
903
+ // Own reused buffer (not the getter's) to avoid aliasing; `bwd` consumes it
904
+ // synchronously and must not retain it.
905
+ const argbuf = new Array(n);
906
+ const bwdN = bwd;
907
+ b.put = (target) => {
908
+ for (let i = 0; i < n; i++)
909
+ argbuf[i] = backPrimal(parents[i]);
910
+ return bwdN(target, argbuf);
911
+ };
912
+ }
913
+ else {
914
+ const bwd0 = bwd;
915
+ b.put = (target) => bwd0(target);
916
+ }
773
917
  }
774
918
  else {
775
- b.put = (target) => bwd(target);
919
+ const p = parent;
920
+ cell.getter = (() => fwd(p.value));
921
+ // Source-reading lenses linearize at the parent's primal (`backPrimal`), so the
922
+ // engine always calls the 1-arg form and never recomputes the parent's cone.
923
+ b.put = readsSource ? (t) => bwd(t, backPrimal(p)) : bwd;
924
+ linkLens(cell, p, 0);
776
925
  }
926
+ setWriteBlocked(cell);
777
927
  return cell;
778
928
  }
779
929
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
@@ -788,36 +938,41 @@ spec) {
788
938
  const seed = new Array(n);
789
939
  for (let i = 0; i < n; i++)
790
940
  seed[i] = parents[i].peek();
791
- const sc = (b.stateful = new StatefulCore(spec.init(seed), spec.step));
941
+ // Default `step` is the memoryless refresh (`init`); the engine runs it only on
942
+ // an outside change, so the `external ? init(s) : c` idiom needs no user `step`.
943
+ const init = spec.init;
944
+ const step = (spec.step ?? init);
945
+ const sc = (b.stateful = new StatefulCore(spec.init(seed), step));
946
+ // Sentinel: version sums are ≥ 0, so the first read (or back-write) always
947
+ // steps once, folding the initial sources into the seed complement. The
948
+ // `init`/`step` split is seed-then-fold: `init` need not see the sources.
949
+ sc.stamp = -1;
792
950
  const fwd = spec.fwd;
793
951
  b.put = spec.bwd;
794
- b.parent = parents;
952
+ b.scatter = true;
953
+ for (let i = 0; i < n; i++)
954
+ linkLens(cell, parents[i], i);
795
955
  cell.getter = (() => {
796
- for (let i = 0; i < n; i++)
956
+ let ver = 0;
957
+ for (let i = 0; i < n; i++) {
797
958
  vals[i] = parents[i].value;
798
- // External unless the live sources still equal this lens's own last
799
- // back-write.
800
- let external = true;
801
- const lb = sc.last;
802
- if (lb !== undefined) {
803
- external = false;
804
- for (let i = 0; i < n; i++) {
805
- if (vals[i] !== lb[i]) {
806
- external = true;
807
- break;
808
- }
809
- }
959
+ ver += parents[i].version;
960
+ }
961
+ // Step only when sources moved since the last sync (lazy own-vs-external).
962
+ if (ver !== sc.stamp) {
963
+ if (COUNTS)
964
+ counts.step++;
965
+ sc.complement = sc.step(vals, sc.complement);
966
+ sc.stamp = ver;
810
967
  }
811
- sc.complement = sc.step(vals, sc.complement, external);
812
968
  return fwd(vals, sc.complement);
813
969
  });
970
+ setWriteBlocked(cell);
814
971
  return cell;
815
972
  }
816
- // Single-source stateful lens: the `buildLens1` of the complement path.
817
- // Drops the per-read copy/external loops to direct index-0 access; the
818
- // spec stays array-shaped (`init: ([s]) => …`), so a reused length-1
819
- // `vals` still feeds the closures and `b.parent` stays an array for the
820
- // shared split backward path.
973
+ // Single-source stateful fast-path: one parent, so no `vals` buffer and a scalar
974
+ // `step`/`fwd`/`bwd` the version stamp is just the parent's `version`. Same
975
+ // provenance and laziness as the N-source `buildStateful`, minus the array work.
821
976
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
822
977
  function buildStateful1(Cls, parent,
823
978
  // biome-ignore lint/suspicious/noExplicitAny: opaque spec
@@ -825,61 +980,54 @@ spec) {
825
980
  const cell = new Cls();
826
981
  cell.flags = F.Mutable | F.Dirty;
827
982
  const b = (cell._bwd = new BwdSpec());
828
- const sc = (b.stateful = new StatefulCore(spec.init([parent.peek()]), spec.step));
983
+ const init = spec.init;
984
+ const step = (spec.step ?? init);
985
+ const sc = (b.stateful = new StatefulCore(init(parent.peek()), step));
986
+ sc.stamp = -1; // sentinel: first use folds the source in (see `buildStateful`)
829
987
  const fwd = spec.fwd;
830
988
  b.put = spec.bwd;
831
- b.parent = [parent];
832
- const vals = [undefined];
989
+ // `scatter` stays false: writeBack routes this through the scalar stateful branch.
990
+ linkLens(cell, parent, 0);
833
991
  cell.getter = (() => {
834
- const v = (vals[0] = parent.value);
835
- const lb = sc.last;
836
- const external = lb === undefined || lb[0] !== v;
837
- sc.complement = sc.step(vals, sc.complement, external);
838
- return fwd(vals, sc.complement);
992
+ const x = parent.value;
993
+ const ver = parent.version;
994
+ if (ver !== sc.stamp) {
995
+ if (COUNTS)
996
+ counts.step++;
997
+ sc.complement = sc.step(x, sc.complement);
998
+ sc.stamp = ver;
999
+ }
1000
+ return fwd(x, sc.complement);
839
1001
  });
1002
+ setWriteBlocked(cell);
840
1003
  return cell;
841
1004
  }
842
- // Shared runtime dispatch for `derive`/`lens`, parameterized by the cell
843
- // constructor: the polymorphic-`this` statics pass the typed subclass
844
- // (`Vec.derive` Vec), the free functions pass the plain `Cell`. One body
845
- // each so the static and free forms can't drift (typed overloads live at
846
- // the call sites; only these `any` runtime bodies are shared).
847
- // biome-ignore lint/suspicious/noExplicitAny: dispatch
848
- function dispatchDerive(ctor, args) {
1005
+ // One read-only-derive constructor: a bare closure (`derive(fn)`), a single tracked
1006
+ // read (`derive(p, fn)`), or an N-parent read (`derive(ps, fn)`) — each lands in
1007
+ // `buildDerived` with the matching getter. (Writable `lens(...)` is `buildLens`;
1008
+ // statics pass the typed subclass, free functions plain `Cell`, so neither drifts.)
1009
+ // biome-ignore lint/suspicious/noExplicitAny: dispatch over the untyped call forms
1010
+ function buildDerive(Cls, args) {
849
1011
  if (args.length === 1)
850
- return buildDerived(ctor, args[0]);
851
- const [parent, fn] = args;
1012
+ return buildDerived(Cls, args[0]);
1013
+ const parent = args[0];
1014
+ const fn = args[1];
852
1015
  if (Array.isArray(parent))
853
- return buildLensN(ctor, parent, fn, undefined, false);
854
- return buildDerived(ctor, () => fn(parent.value));
1016
+ return buildDerived(Cls, arrayGetter(parent, fn));
1017
+ return buildDerived(Cls, () => fn(parent.value));
855
1018
  }
856
- // biome-ignore lint/suspicious/noExplicitAny: dispatch
857
- function dispatchLens(ctor, args) {
858
- const [parent, a, b] = args;
859
- if (args.length === 2) {
860
- const ps = Array.isArray(parent) ? parent : [parent];
861
- return ps.length === 1 ? buildStateful1(ctor, ps[0], a) : buildStateful(ctor, ps, a);
862
- }
863
- const readsSource = b.length >= 2;
864
- if (Array.isArray(parent))
865
- return buildLensN(ctor, parent, a, b, readsSource);
866
- return buildLens1(ctor, parent, a, b, readsSource);
867
- }
868
- // Installed on the prototype (not a class accessor): V8 JITs a prototype getter
869
- // better, and it keeps the field-only class shape for a stable hidden class.
1019
+ // Installed on the prototype (not a class accessor): V8 JITs it better and keeps
1020
+ // the field-only class shape for a stable hidden class.
870
1021
  Object.defineProperty(Cell.prototype, "value", {
871
1022
  get() {
872
- // Reading is the PULL. A back-marked cell resolves at this clean entry,
873
- // BEFORE its own compute, so a source-reading `put` never re-enters a
874
- // half-computed cell. `backResolve` descends `_bwd` to the sources and pulls
875
- // the writers registered there, touching only this cell's back-cone
876
- // (granular — sibling chains untouched).
1023
+ // Reading is the PULL: a back-marked cell resolves here, before its own
1024
+ // compute, so a source-reading `put` never re-enters a half-computed cell.
877
1025
  if (this.bflags & BACK_MARKED && !draining)
878
1026
  backResolve(this);
879
1027
  const flags = this.flags;
880
1028
  if (this.getter !== undefined) {
881
1029
  if (flags & F.RecursedCheck) {
882
- throw new RangeError(`Cyclic computed: ${this.constructor.name ?? "?"} read its own value`);
1030
+ throw new RangeError(`Cyclic computed: ${this.name ?? this.constructor.name ?? "?"} read its own value`);
883
1031
  }
884
1032
  if (flags & F.Dirty ||
885
1033
  (flags & F.Pending &&
@@ -918,11 +1066,10 @@ Object.defineProperty(Cell.prototype, "value", {
918
1066
  if (b === undefined) {
919
1067
  throw new TypeError("Cannot write to a computed");
920
1068
  }
921
- // GetPut for a multi-parent split: its `put` may move sources even when the
922
- // view is unchanged (a lossy redistribution that `_writeSource`'s per-source
923
- // equality can't catch), so absorb a write that maps to the current view
924
- // here. (Stateful excluded peeking would step its complement.)
925
- if (Array.isArray(b.parent) && b.stateful === undefined && this._equals(next, this.peek())) {
1069
+ // GetPut for a multi-parent split: absorb a write equal to the current view
1070
+ // (its `put` could redistribute sources past per-source equality). Stateful
1071
+ // excluded (`scatter` but `stateful` set) peeking would step its complement.
1072
+ if (b.scatter && b.stateful === undefined && this._equals(next, this.peek())) {
926
1073
  return;
927
1074
  }
928
1075
  arm(this, next);
@@ -930,32 +1077,43 @@ Object.defineProperty(Cell.prototype, "value", {
930
1077
  enumerable: false,
931
1078
  configurable: false,
932
1079
  });
933
- /** Backward push: arm a back-write of `target` on view `node` (the dual of a
934
- * source `set`). A re-write of a still-armed view (an unobserved drag) keeps
935
- * only the last target — the path down to the sources is already marked, so
936
- * skip the walk and coalesce. The trailing `schedule` wakes the effects the
937
- * push woke (on the microtask turn); each effect's read pulls its own cone. */
1080
+ // ─────────────────────────────────────────────────────────────────────────
1081
+ // Backward graph engine (internal) arm / markDown / resolveCone / writeBack.
1082
+ // ─────────────────────────────────────────────────────────────────────────
1083
+ /** Backward push: arm a back-write of `target` on view `node` (dual of a source
1084
+ * `set`). A re-write of a still-armed view keeps only the last target (the path
1085
+ * is already marked); `autoFlush` wakes the effects the push woke. */
938
1086
  function arm(node, target) {
1087
+ // Structural reject first: a write whose back-spine dead-ends throws before
1088
+ // touching any backward state (atomic — nothing armed, nothing marked).
1089
+ if (node.bflags & BF.WriteBlocked) {
1090
+ if (COUNTS)
1091
+ counts.armBlocked++;
1092
+ throw new TypeError("Cannot write through to a computed");
1093
+ }
1094
+ if (COUNTS)
1095
+ counts.arm++;
939
1096
  if (!(node.bflags & BF.Dirty)) {
940
- markDown(node); // flag path + wake cones, FIRST (a throw arms nothing)
1097
+ markDown(node); // flag path + wake cones FIRST (a throw arms nothing)
941
1098
  node.bflags |= BF.Dirty;
942
1099
  }
943
- node.pendingValue = target; // the view holds its own demand (getters ignore this field)
1100
+ node.pendingValue = target;
944
1101
  autoFlush();
945
1102
  }
946
- /** MARK (push), dual of forward `propagate`: descend `start`'s static backward
947
- * path down `_bwd.parent` to its sources, flag each node `BF.Pending`, register
948
- * the reverse edge (`linkBack`), and wake every source's forward cone. Runs no
949
- * `put` — an over-approximation `resolveCone` later resolves precisely.
1103
+ /** MARK (push), dual of `propagate`: descend `start`'s static back-path down
1104
+ * `parentEdges` to its sources, flag each `BF.Pending`, and wake every source's
1105
+ * forward cone. Runs no `put`.
950
1106
  *
951
- * `BF.Pending` is its own dedup: an already-marked node has its whole (static)
952
- * subtree marked, so descent stops there (diamonds cost one visit). A merge
953
- * relays to its parent; a sole read-only-derived parent has nowhere to land →
954
- * throw. The 1→1 spine allocates nothing (`stack` is built only on a branch). */
1107
+ * `BF.Pending` self-dedups: an already-marked node has its subtree marked, so
1108
+ * descent stops (diamonds cost one visit). A read-only-derived parent is skipped
1109
+ * (a split routes around it; a sole one is pre-rejected by `arm`'s
1110
+ * `BF.WriteBlocked` check). The 1→1 spine allocates nothing. */
955
1111
  function markDown(start) {
956
1112
  let node = start;
957
1113
  let stack;
958
1114
  for (;;) {
1115
+ if (COUNTS)
1116
+ counts.markDownVisit++;
959
1117
  let next;
960
1118
  if (isSource(node)) {
961
1119
  // Leaf (dual of a `Dirty` source): wake its cone ONCE.
@@ -963,40 +1121,26 @@ function markDown(start) {
963
1121
  node.bflags |= BF.Pending;
964
1122
  const subs = node.subs;
965
1123
  if (subs !== undefined)
966
- propagate(subs, runDepth > 0, activeNetwork);
1124
+ propagate(subs, runDepth > 0, activeExcluded);
967
1125
  }
968
1126
  }
969
1127
  else if (node === start || !(node.bflags & BF.Pending)) {
970
- // On the back-path (dual of a `Pending` intermediate). An already-marked
971
- // intermediate (≠ start) has its subtree marked — stop (diamond dedup).
1128
+ // On the back-path. An already-marked intermediate (≠ start) has its
1129
+ // subtree marked — stop (diamond dedup).
972
1130
  if (node !== start)
973
1131
  node.bflags |= BF.Pending;
974
- linkBack(node); // register the reverse edge lazily, on first back-write
975
- // `b.parent` is the back-target for EVERY mode (a merge's fold target is
976
- // just its single, always-writable parent), so one descent covers all.
977
- const parent = node._bwd.parent;
978
- if (parent !== undefined) {
979
- if (Array.isArray(parent)) {
980
- const multi = parent.length > 1;
981
- for (let i = 0; i < parent.length; i++) {
982
- const p = parent[i];
983
- if (isReadOnlyDerived(p)) {
984
- // A split routes around it; a sole parent can't.
985
- if (!multi)
986
- throw new TypeError("Cannot write through to a computed");
987
- }
988
- else if (next === undefined)
989
- next = p;
990
- else
991
- (stack ??= []).push(p);
992
- }
993
- }
994
- else if (isReadOnlyDerived(parent)) {
995
- throw new TypeError("Cannot write through to a computed");
996
- }
997
- else {
998
- next = parent;
999
- }
1132
+ for (let e = node.parentEdges; e !== undefined; e = e.nextParent) {
1133
+ linkChild(e); // register this view on the parent's up-list (arm-order)
1134
+ const p = e.parent;
1135
+ // Read-only parent: a split routes around it (its `put` SKIPs it). A sole
1136
+ // read-only parent can't be routed — but that's `BF.WriteBlocked`, already
1137
+ // rejected in `arm`, so the descent never reaches such a node here.
1138
+ if (isReadOnlyDerived(p))
1139
+ continue;
1140
+ if (next === undefined)
1141
+ next = p;
1142
+ else
1143
+ (stack ??= []).push(p);
1000
1144
  }
1001
1145
  }
1002
1146
  if (next !== undefined) {
@@ -1010,115 +1154,133 @@ function markDown(start) {
1010
1154
  }
1011
1155
  }
1012
1156
  }
1013
- /** RESOLVE (pull), dual of forward `checkDirty`: resolve ONE node's whole
1014
- * back-cone. Ascend the static reverse adjacency `_lensSubs` (only `BACK_MARKED`
1015
- * children) to the armed views above, `writeBack`ing each. Source-CENTRIC — a
1016
- * source must reflect ALL its writers (they compose into one committed value),
1017
- * so a call on the source resolves every co-writer together and commits once.
1018
- * `BF.Dirty` is the dedup.
1157
+ /** RESOLVE (pull), dual of `checkDirty`: resolve one node's whole back-cone.
1158
+ * Ascend `childEdges` (only `BACK_MARKED` children) to the armed views,
1159
+ * `writeBack`ing each. Source-centric — a source reflects all its writers, so a
1160
+ * call on it resolves every co-writer together and commits once.
1019
1161
  *
1020
- * Recursive so a MERGE folds POST-ORDER (the one non-dual ingredient): resolve
1021
- * its contributors first (each cascades a `put` into `contributions`), then fold
1022
- * once on the way back up and write the parent, as a getter reads deps then
1023
- * computes. */
1024
- function resolveCone(node) {
1162
+ * Iterative post-order over the back-cone, via an explicit frame stack of
1163
+ * {node, next-child cursor}. On entering a
1164
+ * node (pre): clear a merge's contributions, then `writeBack` if it holds an armed
1165
+ * target. After its children drain (post): clear `BF.Pending`, then fold a merge.
1166
+ * Children are walked in forward `childEdges` order (so a co-writer's last write
1167
+ * wins) and a per-call `bEpoch` dedups diamonds — the merge fold lands at its true
1168
+ * post-order position, interleaved with sibling writes, not deferred.
1169
+ * Idempotent, so phase-2 of `backResolve` can call it unconditionally. */
1170
+ function resolveCone(root) {
1171
+ const epoch = ++backCycle;
1172
+ root.bEpoch = epoch;
1173
+ enterCone(root);
1174
+ rcNode[0] = root;
1175
+ rcEdge[0] = root.childEdges;
1176
+ let fp = 1;
1177
+ while (fp > 0) {
1178
+ let e = rcEdge[fp - 1];
1179
+ let descended = false;
1180
+ while (e !== undefined) {
1181
+ const c = e.child;
1182
+ e = e.nextChild;
1183
+ if (c.bflags & BACK_MARKED && c.bEpoch !== epoch) {
1184
+ c.bEpoch = epoch;
1185
+ rcEdge[fp - 1] = e; // resume here when we pop back to this frame
1186
+ enterCone(c);
1187
+ rcNode[fp] = c;
1188
+ rcEdge[fp] = c.childEdges;
1189
+ fp++;
1190
+ descended = true;
1191
+ break;
1192
+ }
1193
+ }
1194
+ if (descended)
1195
+ continue;
1196
+ // Children exhausted → post-order work for this frame's node.
1197
+ const node = rcNode[--fp];
1198
+ node.bflags &= ~BF.Pending;
1199
+ const b = node._bwd;
1200
+ // A merge has exactly one parent-edge; fold its gathered contributions to it.
1201
+ if (b !== undefined && b.merge !== undefined)
1202
+ foldMerge(node.parentEdges.parent, b.merge);
1203
+ }
1204
+ }
1205
+ /** `resolveCone` pre-order work: reset a merge's buffer, drive an armed target. */
1206
+ function enterCone(node) {
1207
+ if (COUNTS)
1208
+ counts.resolveConeVisit++;
1025
1209
  const b = node._bwd;
1026
- const merge = b !== undefined ? b.merge : undefined;
1027
- if (merge !== undefined)
1028
- merge.contributions.length = 0; // self-heal contributions left by a prior throw
1029
- // Drive this node's own armed target first (composes before child writes —
1030
- // preserves last-writer-wins order among co-writers of a shared parent).
1210
+ if (b !== undefined && b.merge !== undefined)
1211
+ b.merge.contributions.length = 0;
1031
1212
  if (node.bflags & BF.Dirty) {
1032
1213
  node.bflags &= ~BF.Dirty;
1033
1214
  writeBack(node, node.pendingValue);
1034
1215
  }
1035
- const children = node._lensSubs;
1036
- if (children !== undefined) {
1037
- for (let i = 0; i < children.length; i++) {
1038
- const c = children[i];
1039
- if (c.bflags & BACK_MARKED)
1040
- resolveCone(c);
1041
- }
1042
- }
1043
- node.bflags &= ~BF.Pending; // resolved (idempotent with `writeBack`'s clear)
1044
- if (merge !== undefined)
1045
- foldMerge(b.parent, merge); // contributors in → fold once
1046
1216
  }
1047
- /** PULL entry for a back-marked `start`. Source-centric: a source read resolves
1048
- * its own cone; a VIEW read first DESCENDS its marked back-path (`_bwd.parent`
1049
- * through `BF.Pending`) to the sources, then resolves each source's whole cone
1050
- * (so co-writers compose and the source commits once). The `draining` guard
1051
- * stops a `put`'s source read from re-entering. */
1217
+ /** PULL entry for a back-marked `start`. A source resolves its own cone; a view
1218
+ * first descends its marked back-path to the sources, then resolves each. The
1219
+ * `draining` guard stops a `put`'s source read from re-entering.
1220
+ *
1221
+ * Two-phase: phase 1 collects the distinct sources (clearing nothing), phase 2
1222
+ * `resolveCone`s each. Capturing the full source set before any `writeBack` runs
1223
+ * means a sibling commit can't drop a co-writer's source from the worklist. A
1224
+ * per-call `bEpoch` stamp dedups the descent (diamonds visit each node once). */
1052
1225
  function backResolve(start) {
1053
1226
  draining = true;
1054
1227
  ++batchDepth;
1055
1228
  const prev = activeSub;
1056
1229
  activeSub = undefined;
1230
+ const sourcesBase = backSources.length;
1231
+ const epoch = ++backCycle;
1057
1232
  try {
1058
1233
  if (isSource(start)) {
1059
1234
  resolveCone(start);
1235
+ return;
1060
1236
  }
1061
- else {
1062
- // Descend the marked back-path to every source, resolving each source's
1063
- // cone. A view with no source to land on (a `pin` sink) resolves itself.
1064
- let node = start;
1065
- let stack;
1066
- let reached = false;
1067
- for (;;) {
1068
- let next;
1069
- const b = node._bwd;
1070
- const parent = b !== undefined ? b.parent : undefined; // merge's parent IS b.parent
1071
- if (parent !== undefined) {
1072
- if (Array.isArray(parent)) {
1073
- for (let i = 0; i < parent.length; i++) {
1074
- const p = parent[i];
1075
- if (isSource(p)) {
1076
- reached = true;
1077
- if (p.bflags & BF.Pending)
1078
- resolveCone(p);
1079
- }
1080
- else if (p.bflags & BF.Pending) {
1081
- if (next === undefined)
1082
- next = p;
1083
- else
1084
- (stack ??= []).push(p);
1085
- }
1086
- }
1087
- }
1088
- else if (isSource(parent)) {
1089
- reached = true;
1090
- if (parent.bflags & BF.Pending)
1091
- resolveCone(parent);
1092
- }
1093
- else if (parent.bflags & BF.Pending) {
1094
- next = parent;
1095
- }
1237
+ // Phase 1 (collect): descend the `BF.Pending` cone, gathering distinct
1238
+ // sources. `reached` = a source was found (else `start` is a `pin` sink).
1239
+ let node = start;
1240
+ let stack;
1241
+ let reached = false;
1242
+ for (;;) {
1243
+ let next;
1244
+ for (let e = node.parentEdges; e !== undefined; e = e.nextParent) {
1245
+ const p = e.parent;
1246
+ if (!(p.bflags & BF.Pending) || p.bEpoch === epoch)
1247
+ continue;
1248
+ p.bEpoch = epoch;
1249
+ if (isSource(p)) {
1250
+ reached = true;
1251
+ backSources.push(p);
1096
1252
  }
1097
- if (next !== undefined)
1098
- node = next;
1099
- else if (stack !== undefined && stack.length > 0)
1100
- node = stack.pop();
1253
+ else if (next === undefined)
1254
+ next = p;
1101
1255
  else
1102
- break;
1103
- }
1104
- if (!reached && start.bflags & BF.Dirty) {
1105
- start.bflags &= ~BF.Dirty;
1106
- writeBack(start, start.pendingValue);
1256
+ (stack ??= []).push(p);
1107
1257
  }
1258
+ if (next !== undefined)
1259
+ node = next;
1260
+ else if (stack !== undefined && stack.length > 0)
1261
+ node = stack.pop();
1262
+ else
1263
+ break;
1264
+ }
1265
+ // Phase 2 (resolve): each collected source's whole cone, once.
1266
+ for (let i = sourcesBase; i < backSources.length; i++)
1267
+ resolveCone(backSources[i]);
1268
+ if (!reached && start.bflags & BF.Dirty) {
1269
+ start.bflags &= ~BF.Dirty;
1270
+ writeBack(start, start.pendingValue);
1108
1271
  }
1109
1272
  }
1110
1273
  finally {
1274
+ backSources.length = sourcesBase;
1111
1275
  activeSub = prev;
1112
1276
  --batchDepth;
1113
1277
  draining = false;
1114
1278
  }
1115
1279
  }
1116
- /** Resolve any back-write a woken reactive node reads DIRECTLY. `checkDirty`
1117
- * alone catches back-writes that move a source (it ascends to the source),
1118
- * but a stateful "stash" moves only the VIEW (the complement echoes a value
1119
- * back, no source changes) invisible to a source-based check. Resolving the
1120
- * node's back-marked deps here makes that view-change visible before the
1121
- * dirtiness check. Granular: only this node's own deps. */
1280
+ /** Resolve any back-write a woken node reads directly. `checkDirty` catches
1281
+ * back-writes that move a source, but a stateful stash moves only the VIEW (no
1282
+ * source changes) invisible to a source-based check, so resolve this node's
1283
+ * back-marked deps here. A forward-only wake walks no cone and pays nothing. */
1122
1284
  function resolveBackDeps(node) {
1123
1285
  for (let l = node.deps; l !== undefined; l = l.nextDep) {
1124
1286
  const d = l.dep;
@@ -1126,116 +1288,177 @@ function resolveBackDeps(node) {
1126
1288
  backResolve(d);
1127
1289
  }
1128
1290
  }
1129
- /** Backward commit/compute (dual of forward `_update`): drive a back-write of
1130
- * `target` toward the sources, applying each lens's `put`, clearing `BF.Pending`
1131
- * on descent, and staging each source as it's reached (so a later sibling reading
1132
- * that SAME source composes rather than clobbers). A `SKIP` per-parent slot
1133
- * prunes a branch; every other slot is written verbatim, `undefined` included. A
1134
- * `merge` accumulates (folded post-order by `resolveCone`); a read-only parent
1135
- * throws (already caught at MARK time). */
1291
+ /** Backward commit/compute (dual of `_update`): drive a back-write of `target`
1292
+ * toward the sources, applying each lens's `put` and staging each source as it's
1293
+ * reached (so a later sibling composes rather than clobbers). A `SKIP` slot prunes
1294
+ * a branch; every other slot is written verbatim, `undefined` included.
1295
+ *
1296
+ * Iterative depth-first, left-to-right (children pushed in reverse onto the
1297
+ * pooled `wbNode`/`wbTarget` stack), so a sibling read sees a prior sibling's
1298
+ * staged write — bounded by pooled stack memory, not the call stack. */
1136
1299
  function writeBack(node, target) {
1137
- if (isSource(node)) {
1138
- node._writeSource(target); // source — staged now, visible to later siblings
1139
- // Clear this source's back-`Pending`, then re-assert it iff a lens-child is
1140
- // STILL armed (an overlapping co-writer through this same source) — a later
1141
- // read must still resolve them, else that write is lost. (Forward writes
1142
- // live on `flags`, back-state on `bflags`, so the source-stage above no
1143
- // longer touches `Pending`; we own clearing it here.) `_lensSubs` holds only
1144
- // ever-back-written children, so this is a short flag scan, not a registry walk.
1145
- node.bflags &= ~BF.Pending;
1146
- const subs = node._lensSubs;
1147
- if (subs !== undefined) {
1148
- for (let i = 0; i < subs.length; i++)
1149
- if (subs[i].bflags & BACK_MARKED) {
1150
- node.bflags |= BF.Pending;
1300
+ wbNode[0] = node;
1301
+ wbTarget[0] = target;
1302
+ let top = 1;
1303
+ let sTop = 0;
1304
+ while (top > 0) {
1305
+ if (COUNTS)
1306
+ counts.writeBackVisit++;
1307
+ const cur = wbNode[--top];
1308
+ const tgt = wbTarget[top];
1309
+ if (isSource(cur)) {
1310
+ cur._writeSource(tgt); // staged now, visible to later siblings
1311
+ // Clear this source's `BF.Pending`, then re-assert iff a lens-child is STILL
1312
+ // armed (an overlapping co-writer) — else that write is lost, and leaving it
1313
+ // set unconditionally would strand `BF.Pending` on every fan-in source.
1314
+ // Scan from the TAIL: `resolveCone` drives children head→tail, so the last
1315
+ // still-armed co-writer sits near the tail — found in O(1) until the final
1316
+ // one, turning a fan-in's re-assert from O(N²) into O(N). (Order is
1317
+ // irrelevant; this is a find-any.)
1318
+ cur.bflags &= ~BF.Pending;
1319
+ for (let e = cur.childEdgesTail; e !== undefined; e = e.prevChild) {
1320
+ if (COUNTS)
1321
+ counts.reassertScan++;
1322
+ if (e.child.bflags & BACK_MARKED) {
1323
+ cur.bflags |= BF.Pending;
1151
1324
  break;
1152
1325
  }
1153
- }
1154
- return;
1155
- }
1156
- node.bflags &= ~BF.Pending; // passing through clears the path marker
1157
- const b = node._bwd;
1158
- if (b === undefined)
1159
- throw new TypeError("Cannot write through to a computed");
1160
- if (b.merge !== undefined) {
1161
- b.merge.contributions.push(target); // gathered here; `resolveCone` folds post-order
1162
- return;
1163
- }
1164
- const parent = b.parent;
1165
- if (parent === undefined)
1166
- return; // pin sink: absorb
1167
- if (Array.isArray(parent)) {
1168
- const n = parent.length;
1169
- let out;
1170
- const sc = b.stateful;
1171
- if (sc !== undefined) {
1172
- const vals = new Array(n);
1173
- for (let i = 0; i < n; i++)
1174
- vals[i] = parent[i].value;
1175
- // Bring the complement to the current sources before `bwd` (the dual of
1176
- // the forward getter's step) so it measures devs/fracs from a prior
1177
- // sibling write, not a stale snapshot. External unless the sources still
1178
- // equal this lens's own last back-write.
1179
- const last = sc.last;
1180
- let external = last === undefined;
1181
- if (last !== undefined) {
1182
- for (let i = 0; i < n; i++)
1183
- if (vals[i] !== last[i]) {
1184
- external = true;
1185
- break;
1186
- }
1187
1326
  }
1188
- sc.complement = sc.step(vals, sc.complement, external);
1189
- const res = b.put(target, vals, sc.complement);
1190
- const upd = res.updates;
1191
- // Build the committed candidate IN `vals` (its source reads are spent) and
1192
- // keep it as `last` for the next own-vs-external comparison. A `SKIP` slot —
1193
- // or a slot past a short `upd` — leaves that parent at its current `vals[i]`.
1194
- const um = upd.length < n ? upd.length : n;
1195
- for (let i = 0; i < um; i++)
1196
- if (upd[i] !== SKIP)
1197
- vals[i] = upd[i];
1198
- sc.complement = sc.step(vals, res.complement, false);
1199
- sc.last = vals;
1200
- out = upd;
1327
+ continue;
1201
1328
  }
1202
- else {
1203
- out = b.put(target);
1329
+ cur.bflags &= ~BF.Pending; // passing through clears the path marker
1330
+ const b = cur._bwd;
1331
+ if (b === undefined)
1332
+ throw new TypeError("Cannot write through to a computed");
1333
+ const mn = b.merge;
1334
+ if (mn !== undefined) {
1335
+ mn.contributions.push(tgt); // gathered here; `resolveCone` folds post-order
1336
+ continue;
1204
1337
  }
1205
- // A short `out` skips the trailing parents (the dual of a forward getter
1206
- // reading fewer deps); `SKIP` skips a specific slot; a real `undefined` is
1207
- // written verbatim.
1208
- let wrote = false;
1209
- const m = out.length < n ? out.length : n;
1210
- for (let i = 0; i < m; i++) {
1211
- const u = out[i];
1338
+ const pe = cur.parentEdges;
1339
+ if (pe === undefined)
1340
+ continue; // pin sink (parentless): absorb
1341
+ const sc = b.stateful;
1342
+ if (sc !== undefined && !b.scatter) {
1343
+ // Single-source stateful fast-path (scalar `bwd`); one index-0 parent edge.
1344
+ const p = pe.parent;
1345
+ const x = p.value;
1346
+ const ver = p.version;
1347
+ if (ver !== sc.stamp) {
1348
+ if (COUNTS)
1349
+ counts.step++;
1350
+ sc.complement = sc.step(x, sc.complement);
1351
+ }
1352
+ if (COUNTS)
1353
+ counts.put++;
1354
+ const res = b.put(tgt, x, sc.complement);
1355
+ sc.complement = res.complement;
1356
+ wbStateful[sTop++] = cur;
1357
+ const u = res.update;
1212
1358
  if (u !== SKIP) {
1213
- wrote = true;
1214
- writeBack(parent[i], u);
1359
+ wbNode[top] = p;
1360
+ wbTarget[top] = u;
1361
+ top++;
1215
1362
  }
1363
+ else {
1364
+ // Stash: the view moved through the complement alone (see the scatter case).
1365
+ cur.flags |= F.Dirty;
1366
+ const subs = cur.subs;
1367
+ if (subs !== undefined)
1368
+ propagate(subs, runDepth > 0, activeExcluded);
1369
+ }
1370
+ continue;
1371
+ }
1372
+ if (b.scatter) {
1373
+ // Gather ordered parents (index-ordered edges) for the tuple `put`.
1374
+ let n = 0;
1375
+ for (let e = pe; e !== undefined; e = e.nextParent)
1376
+ n++;
1377
+ const parents = new Array(n);
1378
+ for (let e = pe; e !== undefined; e = e.nextParent)
1379
+ parents[e.index] = e.parent;
1380
+ let out;
1381
+ if (sc !== undefined) {
1382
+ const vals = new Array(n);
1383
+ let ver = 0;
1384
+ for (let i = 0; i < n; i++) {
1385
+ vals[i] = parents[i].value;
1386
+ ver += parents[i].version;
1387
+ }
1388
+ // Refresh the complement only if a source moved since the last sync — e.g.
1389
+ // a prior sibling co-writer bumped a shared source. A pure own re-write
1390
+ // (sum unchanged) skips it: `bwd` already gets the settled complement.
1391
+ if (ver !== sc.stamp) {
1392
+ if (COUNTS)
1393
+ counts.step++;
1394
+ sc.complement = sc.step(vals, sc.complement);
1395
+ }
1396
+ if (COUNTS)
1397
+ counts.put++;
1398
+ const res = b.put(tgt, vals, sc.complement);
1399
+ const upd = res.updates;
1400
+ // Commit `bwd`'s complement directly; it must be consistent with `updates`
1401
+ // (no reliance on a post-write `step`). The stamp is re-set post-order (after
1402
+ // the sources are written and their versions bumped) so the next forward read
1403
+ // sees an unchanged sum and skips `step` — own-write provenance.
1404
+ sc.complement = res.complement;
1405
+ wbStateful[sTop++] = cur;
1406
+ out = upd;
1407
+ }
1408
+ else {
1409
+ if (COUNTS)
1410
+ counts.put++;
1411
+ out = b.put(tgt);
1412
+ }
1413
+ // Push non-SKIP children in REVERSE so index 0 is popped (processed) first
1414
+ // — depth-first, left-to-right. A short `out` skips the trailing parents.
1415
+ let wrote = false;
1416
+ const m = out.length < n ? out.length : n;
1417
+ for (let i = m - 1; i >= 0; i--) {
1418
+ const u = out[i];
1419
+ if (u !== SKIP) {
1420
+ wrote = true;
1421
+ wbNode[top] = parents[i];
1422
+ wbTarget[top] = u;
1423
+ top++;
1424
+ }
1425
+ }
1426
+ // A stateful lens can change its VIEW through the complement alone, moving no
1427
+ // source (a "stash"; `!wrote` ⇒ no children pushed). The forward cone never
1428
+ // fires, so invalidate this node's cache and propagate to its observers here.
1429
+ if (!wrote && sc !== undefined) {
1430
+ cur.flags |= F.Dirty;
1431
+ const subs = cur.subs;
1432
+ if (subs !== undefined)
1433
+ propagate(subs, runDepth > 0, activeExcluded);
1434
+ }
1435
+ continue;
1216
1436
  }
1217
- // A stateful lens can change its VIEW through the complement alone, moving
1218
- // no source (a degenerate "stash" — e.g. a collapsed axis remembering the
1219
- // written angle, or a broken parse holding the typed text). No source write
1220
- // means the forward cone never fires, so invalidate the node's own cache and
1221
- // propagate to its observers here.
1222
- if (!wrote && sc !== undefined) {
1223
- node.flags |= F.Dirty;
1224
- const subs = node.subs;
1225
- if (subs !== undefined)
1226
- propagate(subs, runDepth > 0, activeNetwork);
1437
+ // 1→1 lens (single index-0 parent-edge).
1438
+ if (COUNTS)
1439
+ counts.put++;
1440
+ wbNode[top] = pe.parent;
1441
+ wbTarget[top] = b.put(tgt);
1442
+ top++;
1443
+ }
1444
+ // Post-order re-stamp: now the sources are written (versions bumped), record
1445
+ // each on-path stateful lens's parent-version sum, so its next forward read
1446
+ // sees an unchanged sum and skips `step`. Integers only — no `fwd`, no commit.
1447
+ for (let i = 0; i < sTop; i++) {
1448
+ const sc = wbStateful[i]._bwd.stateful;
1449
+ let ver = 0;
1450
+ for (let e = wbStateful[i].parentEdges; e !== undefined; e = e.nextParent) {
1451
+ ver += e.parent.version;
1227
1452
  }
1228
- return;
1453
+ sc.stamp = ver;
1454
+ wbStateful[i] = undefined;
1229
1455
  }
1230
- // 1→1 lens.
1231
- writeBack(parent, b.put(target));
1232
1456
  }
1233
- /** Fold one merge's gathered contributions ONCE (its policy; default
1234
- * last-writer-wins) and write the result up to its parent. Called post-order
1235
- * from `resolveCone` once every contributor has cascaded in — fan-in is the one
1236
- * non-dual ingredient. Runs untracked (`backResolve` already cleared
1237
- * `activeSub`). */
1457
+ /** Fold a merge's contributions once (policy; default last-writer-wins) and write
1458
+ * the result up to its parent. Called post-order from `resolveCone`. */
1238
1459
  function foldMerge(parent, mn) {
1460
+ if (COUNTS)
1461
+ counts.fold++;
1239
1462
  const vals = mn.contributions;
1240
1463
  const fold = mn.foldFn;
1241
1464
  let folded;
@@ -1248,25 +1471,31 @@ function foldMerge(parent, mn) {
1248
1471
  vals.length = 0; // reuse the merge-owned buffer in place (fold must not retain it)
1249
1472
  writeBack(parent, folded);
1250
1473
  }
1474
+ // ─────────────────────────────────────────────────────────────────────────
1475
+ // Public API — factories (cell / derive / lens) over the builders above.
1476
+ // ─────────────────────────────────────────────────────────────────────────
1251
1477
  /** Writable source; passes an existing `Writable` through (idempotent). */
1252
1478
  export function cell(initial, opts) {
1253
1479
  if (initial instanceof Cell)
1254
1480
  return initial;
1255
1481
  return new Cell(initial, opts);
1256
1482
  }
1257
- // Bare (untyped) factories. Construct a plain `Cell`, inferring `R`
1258
- // from the closures (the polymorphic-`this` statics are for typed
1259
- // subclasses like `Vec.lens`).
1483
+ // Bare (untyped) factories: plain `Cell`, inferring `R` from the closures.
1260
1484
  const CELL_CTOR = Cell;
1261
1485
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
1262
1486
  export function derive(...args) {
1263
- return dispatchDerive(CELL_CTOR, args);
1487
+ return buildDerive(CELL_CTOR, args);
1264
1488
  }
1265
1489
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
1266
1490
  export function lens(...args) {
1267
- return dispatchLens(CELL_CTOR, args);
1491
+ return buildLens(CELL_CTOR, args);
1268
1492
  }
1269
- // Effect — alien-signals verbatim.
1493
+ // ─────────────────────────────────────────────────────────────────────────
1494
+ // Effects & schedulers — the Effect watcher (internal) and the public
1495
+ // effect / batch / network / flush surface built on it.
1496
+ // ─────────────────────────────────────────────────────────────────────────
1497
+ // Effect — one watcher class for both auto-tracked effects and explicit-topology
1498
+ // networks: alien-signals' effect plus the `EM` mode toggles `network()` needs.
1270
1499
  class Effect {
1271
1500
  flags = F.Watching | F.RecursedCheck;
1272
1501
  subs = undefined;
@@ -1275,26 +1504,31 @@ class Effect {
1275
1504
  depsTail = undefined;
1276
1505
  fn;
1277
1506
  cleanup = undefined;
1278
- constructor(fn) {
1507
+ /** Watcher-behavior bits (`EM`); `EM.None` for a plain effect. */
1508
+ mode;
1509
+ constructor(fn, mode = EM.None) {
1279
1510
  this.fn = fn;
1280
- const prev = activeSub;
1281
- activeSub = this;
1282
- try {
1283
- ++runDepth;
1284
- const ret = fn();
1285
- this.cleanup = typeof ret === "function" ? ret : undefined;
1286
- }
1287
- finally {
1288
- --runDepth;
1289
- activeSub = prev;
1290
- this.flags &= ~F.RecursedCheck;
1291
- }
1511
+ this.mode = mode;
1292
1512
  }
1293
1513
  _update() {
1294
1514
  this.flags = F.Mutable;
1295
1515
  return true;
1296
1516
  }
1297
1517
  _notify() {
1518
+ const mode = this.mode;
1519
+ if (mode & EM.Manual) {
1520
+ this.flags |= F.Watching; // re-arm but don't queue; only `flush()` advances
1521
+ return;
1522
+ }
1523
+ if (mode & EM.Sync) {
1524
+ // Eager watcher (network): append + force a synchronous flush.
1525
+ queued[queuedLength++] = this;
1526
+ syncFlush = true;
1527
+ this.flags &= ~F.Watching;
1528
+ return;
1529
+ }
1530
+ // Plain effect: batch-insert this effect and any subscribed to it, in
1531
+ // dependency order (alien-signals).
1298
1532
  let e = this;
1299
1533
  let insertIndex = queuedLength;
1300
1534
  const firstInsertedIndex = insertIndex;
@@ -1324,8 +1558,8 @@ class Effect {
1324
1558
  this._runCleanup();
1325
1559
  }
1326
1560
  _run() {
1327
- // Resolve back-writes this effect reads directly (incl. view-only stashes),
1328
- // then let `checkDirty` resolve any back-`Pending` source reached deeper.
1561
+ // Resolve back-writes this node reads directly (incl. view-only stashes);
1562
+ // `checkDirty` resolves any back-`Pending` source reached deeper.
1329
1563
  if (this.deps !== undefined)
1330
1564
  resolveBackDeps(this);
1331
1565
  const flags = this.flags;
@@ -1335,27 +1569,39 @@ class Effect {
1335
1569
  if (!this.flags)
1336
1570
  return;
1337
1571
  }
1338
- this.depsTail = undefined;
1339
- this.flags = F.Watching | F.RecursedCheck;
1340
- const prev = activeSub;
1341
- activeSub = this;
1342
- try {
1343
- ++cycle;
1344
- ++runDepth;
1345
- const ret = this.fn();
1346
- this.cleanup = typeof ret === "function" ? ret : undefined;
1347
- }
1348
- finally {
1349
- --runDepth;
1350
- activeSub = prev;
1351
- this.flags &= ~F.RecursedCheck;
1352
- purgeDeps(this);
1353
- }
1572
+ this._invoke();
1354
1573
  }
1355
1574
  else if (this.deps !== undefined) {
1356
1575
  this.flags = F.Watching;
1357
1576
  }
1358
1577
  }
1578
+ /** Run the body — the single path for first fire, scheduled re-run, and manual
1579
+ * `flush()`. Auto-tracks deps unless `NoTrack`; self-excludes writes under `Exclude`. */
1580
+ _invoke() {
1581
+ const noTrack = this.mode & EM.NoTrack;
1582
+ if (!noTrack)
1583
+ this.depsTail = undefined;
1584
+ this.flags = F.Watching | F.RecursedCheck;
1585
+ const prevSub = activeSub;
1586
+ const prevExc = activeExcluded;
1587
+ activeSub = noTrack ? undefined : this;
1588
+ if (this.mode & EM.Exclude)
1589
+ activeExcluded = this;
1590
+ try {
1591
+ ++cycle;
1592
+ ++runDepth;
1593
+ const ret = this.fn();
1594
+ this.cleanup = typeof ret === "function" ? ret : undefined;
1595
+ }
1596
+ finally {
1597
+ --runDepth;
1598
+ activeSub = prevSub;
1599
+ activeExcluded = prevExc;
1600
+ this.flags &= ~F.RecursedCheck;
1601
+ if (!noTrack)
1602
+ purgeDeps(this);
1603
+ }
1604
+ }
1359
1605
  _runCleanup() {
1360
1606
  const c = this.cleanup;
1361
1607
  this.cleanup = undefined;
@@ -1371,28 +1617,45 @@ class Effect {
1371
1617
  }
1372
1618
  export function effect(fn) {
1373
1619
  const e = new Effect(fn);
1620
+ e._invoke();
1374
1621
  return () => e._unwatched();
1375
1622
  }
1376
- /** Run effects woken by a write. Backward work is pulled lazily per read
1377
- * (`resolveBackDeps` + `checkDirty` `backResolve`, which also folds merges),
1378
- * so flush owns NO backward bookkeeping — just the effect queue. */
1623
+ /** Run effects woken by a write. Backward work is pulled lazily per read, so
1624
+ * flush owns no backward bookkeeping just the effect queue. */
1379
1625
  function flush() {
1380
1626
  if (flushing)
1381
1627
  return;
1382
1628
  flushing = true;
1629
+ // Error locality: one effect throwing must not strand its siblings. Drain the
1630
+ // whole queue, catching each body; surface the first error after the queue is
1631
+ // empty (later errors are dropped — the engine stays consistent, the user still
1632
+ // sees a failure). A throwing body isn't re-queued (its `F.Watching` is already
1633
+ // cleared); it re-arms on the next wake.
1634
+ let err;
1635
+ let threw = false;
1383
1636
  try {
1384
1637
  while (notifyIndex < queuedLength) {
1385
1638
  const e = queued[notifyIndex];
1386
1639
  queued[notifyIndex++] = undefined;
1387
- e._run();
1640
+ try {
1641
+ e._run();
1642
+ }
1643
+ catch (ex) {
1644
+ if (!threw) {
1645
+ err = ex;
1646
+ threw = true;
1647
+ }
1648
+ }
1388
1649
  }
1389
1650
  }
1390
1651
  finally {
1391
1652
  notifyIndex = 0;
1392
1653
  queuedLength = 0;
1393
- networkQueued = false;
1654
+ syncFlush = false;
1394
1655
  flushing = false;
1395
1656
  }
1657
+ if (threw)
1658
+ throw err;
1396
1659
  }
1397
1660
  /** Queue an effect flush for the end of the current microtask turn (idempotent).
1398
1661
  * A write wakes effects asynchronously; many writes in one turn coalesce. */
@@ -1406,26 +1669,24 @@ function schedule() {
1406
1669
  });
1407
1670
  }
1408
1671
  /** Resolve the queue after a write: no-op inside a `batch`/flush (the barrier
1409
- * owns flushing), else synchronously if a network is waiting (eager solve) or
1410
- * deferred to the microtask (coalesced effects). */
1672
+ * owns flushing), else synchronously if a `Sync` watcher is waiting (eager
1673
+ * solve) or deferred to the microtask (coalesced effects). */
1411
1674
  function autoFlush() {
1412
1675
  if (batchDepth !== 0 || flushing)
1413
1676
  return;
1414
- if (networkQueued)
1677
+ if (syncFlush)
1415
1678
  flush();
1416
1679
  else
1417
1680
  schedule();
1418
1681
  }
1419
- /** Run all pending effects NOW, synchronously. The escape hatch for code (tests,
1420
- * imperative call sites) that must observe effect side-effects before yielding
1421
- * to the microtask queue. Reads never need it — they pull current values. */
1682
+ /** Run all pending effects now, synchronously the escape hatch for code that
1683
+ * must observe effect side-effects before yielding. Reads never need it. */
1422
1684
  export function settle() {
1423
1685
  flush();
1424
1686
  }
1425
- /** Group writes and flush effects SYNCHRONOUSLY at the end of `fn` — a sync
1426
- * barrier. Effects coalesce on the microtask turn anyway (see `schedule`), so
1427
- * `batch` is no longer needed for that; reach for it only when you must run the
1428
- * woken effects before the call returns (and don't want a `settle()`). */
1687
+ /** Group writes and flush effects synchronously at the end of `fn`. Effects
1688
+ * coalesce on the microtask turn anyway; reach for `batch` only to run the woken
1689
+ * effects before the call returns. */
1429
1690
  export function batch(fn) {
1430
1691
  ++batchDepth;
1431
1692
  try {
@@ -1446,193 +1707,114 @@ export function untracked(fn) {
1446
1707
  activeSub = prev;
1447
1708
  }
1448
1709
  }
1449
- class _NetworkNode {
1450
- subs = undefined;
1451
- subsTail = undefined;
1452
- deps = undefined;
1453
- depsTail = undefined;
1454
- flags = F.Watching | F.RecursedCheck;
1455
- body;
1456
- manual;
1457
- /** Per-instance last-seen dep values; used to compute `dirty`. */
1458
- lastValues = new Map();
1459
- pending = false;
1460
- disposed = false;
1461
- _ownCycle = 0;
1462
- _depsSet = new Set();
1463
- _handle;
1464
- constructor(body, manual) {
1465
- this.body = body;
1466
- this.manual = manual;
1467
- }
1468
- /** Two-phase init so the body sees its own handle on the first fire. */
1469
- _initWithHandle(handle, initialDeps) {
1470
- this._handle = handle;
1471
- this._linkBatch(initialDeps);
1472
- this._runBody(EMPTY_DIRTY);
1473
- }
1474
- _update() {
1475
- this.flags = F.Mutable;
1476
- return true;
1477
- }
1478
- _notify() {
1479
- if (this.manual) {
1480
- this.pending = true;
1481
- this.flags |= F.Watching;
1482
- return;
1483
- }
1484
- queued[queuedLength++] = this;
1485
- networkQueued = true; // eager: a queued network forces a synchronous flush
1486
- this.flags &= ~F.Watching;
1487
- }
1488
- _unwatched() {
1489
- this.disposed = true;
1490
- this.flags = F.None;
1491
- disposeAllDepsInReverse(this);
1492
- const sub = this.subs;
1493
- if (sub !== undefined)
1494
- unlink(sub);
1495
- this.lastValues.clear();
1496
- }
1497
- _run() {
1498
- if (this.disposed)
1499
- return;
1500
- if (this.deps !== undefined)
1501
- resolveBackDeps(this);
1502
- const flags = this.flags;
1503
- if (flags & F.Dirty || (flags & F.Pending && checkDirty(this.deps, this))) {
1504
- this._runBody(this._computeDirty());
1505
- }
1506
- else if (this.deps !== undefined) {
1507
- this.flags = F.Watching;
1508
- }
1509
- }
1510
- _computeDirty() {
1710
+ /** Build a reactive sub-DAG. The body fires when any subscribed dep changes
1711
+ * (`dirty` = the changed subset), self-excludes its own writes, and (auto mode)
1712
+ * resolves synchronously. `manual: true` defers firing so only `flush()` advances;
1713
+ * `flush()` from inside the body throws. Network-specific state (last-values,
1714
+ * handle) lives in this closure, so the shared `Effect` carries none of it. */
1715
+ export function network(
1716
+ // biome-ignore lint/suspicious/noExplicitAny: deps come in many flavours
1717
+ deps, body, opts) {
1718
+ const lastValues = new Map();
1719
+ const depsSet = new Set();
1720
+ let ownCycle = 0;
1721
+ let disposed = false;
1722
+ // Forward-declared so the closures below can reach the node; assigned before
1723
+ // any runs (the first `_invoke` happens after construction).
1724
+ let node;
1725
+ const computeDirty = () => {
1511
1726
  let dirty;
1512
- for (const [cell, lastVal] of this.lastValues) {
1513
- if (cell.peek() !== lastVal) {
1514
- if (dirty === undefined)
1515
- dirty = new Set();
1516
- dirty.add(cell);
1517
- }
1727
+ for (const [c, last] of lastValues) {
1728
+ if (c.peek() !== last)
1729
+ (dirty ??= new Set()).add(c);
1518
1730
  }
1519
1731
  return dirty ?? EMPTY_DIRTY;
1520
- }
1521
- _runBody(dirty) {
1522
- // RecursedCheck doubles as the "body running" guard (see flush()).
1523
- this.flags = F.Watching | F.RecursedCheck;
1524
- const prevNetwork = activeNetwork;
1525
- activeNetwork = this;
1526
- try {
1527
- ++cycle;
1528
- ++runDepth;
1529
- ++batchDepth;
1530
- try {
1531
- this.body(dirty, this._handle);
1532
- }
1533
- finally {
1534
- if (!--batchDepth)
1535
- flush();
1536
- }
1537
- }
1538
- finally {
1539
- --runDepth;
1540
- activeNetwork = prevNetwork;
1541
- this.flags &= ~F.RecursedCheck;
1542
- this.lastValues.clear();
1543
- let l = this.deps;
1544
- while (l !== undefined) {
1545
- const cell = l.dep;
1546
- this.lastValues.set(cell, cell.peek());
1547
- l = l.nextDep;
1548
- }
1549
- }
1550
- this.pending = false;
1551
- }
1552
- flush() {
1553
- if (this.disposed)
1554
- return;
1555
- if (this.flags & F.RecursedCheck) {
1556
- throw new Error("network: flush() called from inside body — would recurse infinitely. " +
1557
- "Return from the body and let the next dep change drive the next fire.");
1732
+ };
1733
+ const linkDeps = (cells) => {
1734
+ let tail = node.deps;
1735
+ if (tail !== undefined)
1736
+ while (tail.nextDep !== undefined)
1737
+ tail = tail.nextDep;
1738
+ node.depsTail = tail;
1739
+ for (const s of cells) {
1740
+ if (depsSet.has(s))
1741
+ continue;
1742
+ depsSet.add(s);
1743
+ link(s, node, ++ownCycle);
1558
1744
  }
1559
- this._runBody(this._computeDirty());
1560
- }
1561
- subscribe(cells) {
1562
- if (this.disposed)
1563
- return;
1564
- this._linkBatch(cells);
1565
- }
1566
- unsubscribe(cells) {
1567
- if (this.disposed)
1568
- return;
1569
- const set = this._depsSet;
1745
+ };
1746
+ const unlinkDeps = (cells) => {
1570
1747
  for (const s of cells) {
1571
- if (!set.has(s))
1748
+ if (!depsSet.has(s))
1572
1749
  continue;
1573
- set.delete(s);
1574
- let l = this.deps;
1575
- while (l !== undefined) {
1750
+ depsSet.delete(s);
1751
+ for (let l = node.deps; l !== undefined; l = l.nextDep) {
1576
1752
  if (l.dep === s) {
1577
- unlink(l, this);
1753
+ unlink(l, node);
1578
1754
  break;
1579
1755
  }
1580
- l = l.nextDep;
1581
1756
  }
1582
1757
  }
1583
- }
1584
- _linkBatch(cells) {
1585
- const set = this._depsSet;
1586
- let tail = this.deps;
1587
- if (tail !== undefined) {
1588
- while (tail.nextDep !== undefined)
1589
- tail = tail.nextDep;
1758
+ };
1759
+ const handle = {
1760
+ dispose: () => {
1761
+ if (disposed)
1762
+ return;
1763
+ disposed = true;
1764
+ node._unwatched();
1765
+ lastValues.clear();
1766
+ },
1767
+ flush: () => {
1768
+ if (disposed)
1769
+ return;
1770
+ // RecursedCheck doubles as the "body running" guard.
1771
+ if (node.flags & F.RecursedCheck) {
1772
+ throw new Error("network: flush() called from inside body — would recurse infinitely.");
1773
+ }
1774
+ batch(() => node._invoke());
1775
+ },
1776
+ subscribe: (...cells) => {
1777
+ if (!disposed)
1778
+ linkDeps(cells);
1779
+ },
1780
+ unsubscribe: (...cells) => {
1781
+ if (!disposed)
1782
+ unlinkDeps(cells);
1783
+ },
1784
+ };
1785
+ // The Effect body: hand the changed subset to the user body, then re-snapshot
1786
+ // the deps for the next fire.
1787
+ const run = () => {
1788
+ const dirty = computeDirty();
1789
+ try {
1790
+ body(dirty, handle);
1590
1791
  }
1591
- this.depsTail = tail;
1592
- for (const s of cells) {
1593
- if (set.has(s))
1594
- continue;
1595
- set.add(s);
1596
- link(s, this, ++this._ownCycle);
1792
+ finally {
1793
+ lastValues.clear();
1794
+ for (let l = node.deps; l !== undefined; l = l.nextDep) {
1795
+ const c = l.dep;
1796
+ lastValues.set(c, c.peek());
1797
+ }
1597
1798
  }
1598
- }
1599
- }
1600
- /** Build a reactive sub-DAG node with explicit topology. The body fires
1601
- * when any subscribed dep changes (`dirty` = the changed subset), runs
1602
- * inside `batch()`, and self-excludes its own writes. Topology is the
1603
- * deps array + later subscribe/unsubscribe (body reads add no deps).
1604
- * `flush()` from inside the body throws; `manual: true` defers
1605
- * auto-firing so only `flush()` advances. */
1606
- export function network(
1607
- // biome-ignore lint/suspicious/noExplicitAny: deps come in many flavours
1608
- deps, body, opts) {
1609
- const node = new _NetworkNode(body, opts?.manual ?? false);
1610
- const handle = {
1611
- dispose: () => node._unwatched(),
1612
- flush: () => node.flush(),
1613
- subscribe: (...cells) => node.subscribe(cells),
1614
- unsubscribe: (...cells) => node.unsubscribe(cells),
1615
1799
  };
1616
- node._initWithHandle(handle, deps);
1800
+ node = new Effect(run, EM.NoTrack | EM.Exclude | (opts?.manual ? EM.Manual : EM.Sync));
1801
+ linkDeps(deps);
1802
+ batch(() => node._invoke()); // first fire (lastValues empty ⇒ EMPTY_DIRTY)
1617
1803
  return handle;
1618
1804
  }
1619
1805
  // ── value-class authoring helpers ──────────────────────────────────
1620
- //
1621
- // `fieldLens`/`cachedDerive` are the two getter forms a value class declares.
1622
- // The choice between them IS the local declaration of writability at each
1623
- // getter (mirroring `: this` invertible method returns). For arbitrary
1806
+ // `fieldLens`/`cachedDerive` are the two getter forms a value class declares;
1807
+ // the choice between them is the local declaration of writability. For arbitrary
1624
1808
  // cached views, use `lazy()` directly.
1625
- /** Bidirectional field lens onto `parent.value[key]`; write spread-
1626
- * replaces the composite. Cached per (instance, key). Return type is
1627
- * conditional: `Writable<Cls>` on a writable parent, bare `Cls` on RO.
1809
+ /** Bidirectional field lens onto `parent.value[key]` (write spread-replaces),
1810
+ * cached per (instance, key). `Writable<Cls>` on a writable parent, bare `Cls` on RO.
1628
1811
  *
1629
1812
  * get x() { return fieldLens(this, "x", Num); } */
1630
1813
  export function fieldLens(parent, key, Cls) {
1631
1814
  return lazy(parent, key, () => fieldOf(parent, key, Cls));
1632
1815
  }
1633
1816
  /** Read-only derived view via `Cls.derive(parent, fn)`, memoized per
1634
- * (instance, key); always bare `Cls` (RO). The cache is the point — the
1635
- * getter form, not a new kind of cell.
1817
+ * (instance, key).
1636
1818
  *
1637
1819
  * get magnitude() {
1638
1820
  * return cachedDerive(this, "magnitude", Num, v => Math.hypot(v.x, v.y));
@@ -1642,12 +1824,8 @@ export function cachedDerive(parent, key, Cls, fn) {
1642
1824
  // biome-ignore lint/suspicious/noExplicitAny: variance escape on Cls.derive
1643
1825
  return lazy(parent, key, () => Cls.derive(parent, fn));
1644
1826
  }
1645
- /** Every cell `s` transitively depends on, including itself. Raw cells
1646
- * return `{s}`; lens chains return the chain plus all parents. BFS,
1647
- * peeking each Computed to populate deps; the `seen` set breaks cycles.
1648
- * Used by `Propagators` to expand declared reads into their transitive
1649
- * parent set. Inspection is safe: it only reads engine state and peeks
1650
- * `.value` (idempotent for lazy Computeds). */
1827
+ /** Every cell `s` transitively depends on, including itself (BFS, peeking each
1828
+ * computed to populate deps; `seen` breaks cycles). */
1651
1829
  export function transitiveDeps(s) {
1652
1830
  const seen = new Set();
1653
1831
  const queue = [s];
@@ -1656,7 +1834,6 @@ export function transitiveDeps(s) {
1656
1834
  if (seen.has(cur))
1657
1835
  continue;
1658
1836
  seen.add(cur);
1659
- // Cast to reach engine fields the typed Cell<T> shape doesn't surface.
1660
1837
  const c = cur;
1661
1838
  if (c.getter !== undefined) {
1662
1839
  void cur.value;