bireactive 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/animation/anim.js +4 -0
  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/coll.d.ts +7 -7
  9. package/dist/core/_counts.d.ts +48 -0
  10. package/dist/core/_counts.js +58 -0
  11. package/dist/core/cell.d.ts +182 -123
  12. package/dist/core/cell.js +1140 -721
  13. package/dist/core/debug.d.ts +25 -0
  14. package/dist/core/debug.js +121 -0
  15. package/dist/core/index.d.ts +9 -14
  16. package/dist/core/index.js +9 -14
  17. package/dist/core/lenses/aggregates.d.ts +1 -1
  18. package/dist/core/lenses/aggregates.js +4 -3
  19. package/dist/core/lenses/closed-form-policies.js +14 -9
  20. package/dist/core/lenses/decompositions.js +3 -3
  21. package/dist/core/lenses/domain-aggregates.js +5 -5
  22. package/dist/core/lenses/geometry.d.ts +1 -1
  23. package/dist/core/lenses/geometry.js +6 -7
  24. package/dist/core/lenses/index.d.ts +1 -0
  25. package/dist/core/lenses/index.js +1 -0
  26. package/dist/core/lenses/memory.d.ts +2 -2
  27. package/dist/core/lenses/memory.js +3 -3
  28. package/dist/core/lenses/snap.d.ts +18 -0
  29. package/dist/core/lenses/snap.js +145 -0
  30. package/dist/core/lenses/typed-factor.js +4 -3
  31. package/dist/core/optic.d.ts +13 -0
  32. package/dist/core/optic.js +44 -0
  33. package/dist/core/optics.d.ts +10 -0
  34. package/dist/core/optics.js +30 -0
  35. package/dist/core/store.d.ts +10 -0
  36. package/dist/core/store.js +85 -0
  37. package/dist/core/traits.d.ts +1 -0
  38. package/dist/core/values/audio.js +4 -5
  39. package/dist/core/values/box.js +7 -7
  40. package/dist/core/values/canvas.js +15 -18
  41. package/dist/core/values/color.js +5 -5
  42. package/dist/core/values/field.d.ts +70 -0
  43. package/dist/core/values/field.js +230 -0
  44. package/dist/core/values/gpu.d.ts +4 -2
  45. package/dist/core/values/gpu.js +11 -4
  46. package/dist/core/values/matrix.js +7 -7
  47. package/dist/core/values/num.d.ts +1 -1
  48. package/dist/core/values/num.js +1 -1
  49. package/dist/core/values/pose.js +4 -4
  50. package/dist/core/values/range.js +6 -6
  51. package/dist/core/values/str.js +8 -8
  52. package/dist/core/values/template.d.ts +1 -1
  53. package/dist/core/values/template.js +2 -1
  54. package/dist/core/values/transform.js +7 -7
  55. package/dist/core/values/tri.js +3 -3
  56. package/dist/core/values/vec.js +8 -12
  57. package/dist/ext/timeline.js +2 -2
  58. package/dist/formats/cst.d.ts +127 -0
  59. package/dist/formats/cst.js +280 -0
  60. package/dist/formats/edn.d.ts +2 -0
  61. package/dist/formats/edn.js +301 -0
  62. package/dist/formats/index.d.ts +6 -0
  63. package/dist/formats/index.js +8 -0
  64. package/dist/formats/json.d.ts +2 -0
  65. package/dist/formats/json.js +332 -0
  66. package/dist/formats/lens.d.ts +8 -0
  67. package/dist/formats/lens.js +51 -0
  68. package/dist/formats/toml.d.ts +2 -0
  69. package/dist/formats/toml.js +526 -0
  70. package/dist/formats/yaml.d.ts +2 -0
  71. package/dist/formats/yaml.js +661 -0
  72. package/dist/index.d.ts +10 -0
  73. package/dist/index.js +10 -0
  74. package/dist/jsx-dev-runtime.d.ts +2 -0
  75. package/dist/jsx-dev-runtime.js +5 -0
  76. package/dist/jsx-runtime.d.ts +54 -0
  77. package/dist/jsx-runtime.js +219 -0
  78. package/dist/learn/data.d.ts +49 -0
  79. package/dist/learn/data.js +181 -0
  80. package/dist/learn/index.d.ts +3 -0
  81. package/dist/learn/index.js +6 -0
  82. package/dist/learn/lens-net.d.ts +63 -0
  83. package/dist/learn/lens-net.js +219 -0
  84. package/dist/learn/mlp.d.ts +77 -0
  85. package/dist/learn/mlp.js +292 -0
  86. package/dist/propagators/csp.d.ts +13 -0
  87. package/dist/propagators/csp.js +52 -0
  88. package/dist/propagators/flex.d.ts +31 -0
  89. package/dist/propagators/flex.js +189 -0
  90. package/dist/propagators/graph.d.ts +73 -0
  91. package/dist/propagators/graph.js +543 -0
  92. package/dist/propagators/index.d.ts +8 -6
  93. package/dist/propagators/index.js +15 -6
  94. package/dist/propagators/lattice.d.ts +45 -0
  95. package/dist/propagators/lattice.js +113 -0
  96. package/dist/propagators/layout.d.ts +1 -27
  97. package/dist/propagators/layout.js +6 -175
  98. package/dist/propagators/numeric.d.ts +17 -0
  99. package/dist/propagators/numeric.js +93 -0
  100. package/dist/propagators/solver.d.ts +51 -0
  101. package/dist/propagators/solver.js +175 -0
  102. package/dist/schema/index.d.ts +1 -0
  103. package/dist/schema/index.js +3 -0
  104. package/dist/schema/lens.d.ts +121 -0
  105. package/dist/schema/lens.js +429 -0
  106. package/dist/shapes/annular-sector.js +4 -4
  107. package/dist/shapes/button.js +1 -1
  108. package/dist/shapes/circle.js +1 -1
  109. package/dist/shapes/drag-behaviors.d.ts +56 -0
  110. package/dist/shapes/drag-behaviors.js +102 -0
  111. package/dist/shapes/drag-spec.d.ts +52 -0
  112. package/dist/shapes/drag-spec.js +112 -0
  113. package/dist/shapes/handle.js +2 -2
  114. package/dist/shapes/index.d.ts +3 -1
  115. package/dist/shapes/index.js +3 -1
  116. package/dist/shapes/interaction.d.ts +2 -3
  117. package/dist/shapes/interaction.js +77 -56
  118. package/dist/shapes/label.js +7 -1
  119. package/dist/shapes/layout.d.ts +47 -1
  120. package/dist/shapes/layout.js +60 -2
  121. package/dist/shapes/rect.js +7 -7
  122. package/dist/shapes/shape.js +8 -8
  123. package/dist/web/diagram.js +2 -2
  124. package/package.json +24 -2
  125. package/dist/propagators/network.d.ts +0 -52
  126. package/dist/propagators/network.js +0 -185
  127. package/dist/propagators/propagator.d.ts +0 -12
  128. package/dist/propagators/propagator.js +0 -16
  129. package/dist/propagators/range.d.ts +0 -45
  130. package/dist/propagators/range.js +0 -147
  131. package/dist/propagators/relations.d.ts +0 -60
  132. package/dist/propagators/relations.js +0 -343
package/dist/core/cell.js CHANGED
@@ -1,56 +1,46 @@
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
- // Backward is not a second engine: a write "compiles" a view-edit into
6
- // source-edits by walking up `_bwdParent`, applying each lens's `put` to
7
- // compute what the source(s) must become, committing via the SAME
8
- // forward write path. So views are never sticky (a view is always
9
- // `get(source)`; lossy lenses snap), no-op deltas short-circuit for free
10
- // via equality, and backward cost ≤ forward cost.
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:
11
8
  //
12
- // Duals:
13
- // * merge N→1 backward aggregation (dual of computed's N→1 forward).
14
- // Contributions land in a slot map keyed by contributor identity,
15
- // fold via a user policy, reset per settle.
16
- // * multi-parent lens a write that SPLITS across N parents
17
- // (`_put(target)` per-parent update array, `propagateSplit`); the
18
- // dual of a getter reading N parents. Covers coupled writables
19
- // (N→M, e.g. mean/diff). Info the source can't hold lives in a
20
- // stateful-lens complement, not a bespoke engine kind.
9
+ // role forward (source → view) backward (view → source)
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`)
14
+ // commit/compute _update / getter writeBack
15
+ // "dirty" flag F.Dirty (source staged) BF.Dirty (view holds target)
16
+ // "pending" flag F.Pending (on the cone) BF.Pending (on the back-path)
21
17
  //
22
- // Core asymmetry: forward deps are IMPLICIT (auto-tracked reads of
23
- // `.value` under `activeSub`); backward targets are EXPLICIT (declared
24
- // at construction in `_bwdParent`). Hence no `activeBwdWrite` global.
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`.
25
25
  //
26
- // Mode table — a cell's role is fully determined by which fields are set:
27
- // source getter undefined (truth in currentValue)
28
- // derived getter, no _bwd (read-only derived)
29
- // lens 1→1 getter + _bwd{ put, parent: Cell }
30
- // multi-out getter + _bwd{ put, parent: Cell[] } (1→N / N→M bwd)
31
- // merge getter + _bwd{ merge } (N→1 backward fold)
32
- // stateful getter + _bwd{ put, parent, stateful } (complement-carrying)
33
- // A cell is writable iff `_bwd !== undefined` (the backward sidecar; see
34
- // `BwdSpec`). `pendingValue` is dual-keyed: a staged forward write for a
35
- // source, a deferred backward target for a getter cell (never both).
36
- //
37
- // Batching: outside a batch a write propagates backward eagerly and
38
- // flushes (alien's synchronous per-write semantics). Inside batch/flush,
39
- // lens writes deposit their latest value and queue (last-write-wins via
40
- // `_queueIdx`) and merge folds defer until all contributors land; the
41
- // flush loop alternates bwd-drain / effect-drain to a fixpoint.
42
- // Flag bits (alien-signals v2).
43
- const F = {
44
- None: 0,
45
- Mutable: 1,
46
- Watching: 2,
47
- RecursedCheck: 4,
48
- Recursed: 8,
49
- Dirty: 16,
50
- Pending: 32,
51
- /** Backward-only: cell has a pending backward contribution queued. */
52
- BwdQueued: 64,
53
- };
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
+ // ─────────────────────────────────────────────────────────────────────────
54
44
  let cycle = 0;
55
45
  let runDepth = 0;
56
46
  let batchDepth = 0;
@@ -58,18 +48,189 @@ let notifyIndex = 0;
58
48
  let queuedLength = 0;
59
49
  let activeSub;
60
50
  let flushing = false;
61
- /** Network running its body, if any. Source writes self-exclude it so a
62
- * network reading+writing a cell doesn't re-trigger itself. */
63
- let activeNetwork;
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;
64
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 = [];
65
85
  const EMPTY_DIRTY = new Set();
66
- /** Backward worklist: lens cells with deferred writes, merge cells
67
- * awaiting fold. Drained to a fixpoint with effects by flush. */
68
- const bwdQueue = [];
69
- // Fires on every SOURCE value-change (the one place truth mutates).
70
- // Backward writes reach it via `_writeSource`, attributing lens edits to
71
- // the source they resolve to.
86
+ /** Fires on every source value-change. Backward writes reach it via `_writeSource`. */
72
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`.
92
+ const F = {
93
+ None: 0,
94
+ Mutable: 1,
95
+ Watching: 2,
96
+ RecursedCheck: 4,
97
+ Recursed: 8,
98
+ Dirty: 16,
99
+ Pending: 32,
100
+ };
101
+ // Backward flag bits, on a Cell's own `bflags` word (the dual of `flags`).
102
+ const BF = {
103
+ None: 0,
104
+ /** Dual of `F.Dirty`: this view holds an unresolved back-target in `pendingValue`. */
105
+ Dirty: 1,
106
+ /** Dual of `F.Pending`: this node is on the back-path to its sources. */
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,
112
+ };
113
+ /** Armed root OR on a back-path — i.e. a read must `backResolve` first. */
114
+ const BACK_MARKED = BF.Dirty | BF.Pending;
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.
132
+ /** Source (truth leaf): no forward derivation. */
133
+ function isSource(c) {
134
+ return c.getter === undefined;
135
+ }
136
+ /** Writable: carries a backward sidecar (lens / multi-out / merge / stateful / pin). */
137
+ function isWritable(c) {
138
+ return c._bwd !== undefined;
139
+ }
140
+ /** Read-only derived: a `derive` with no backward path (back-walk throws on it). */
141
+ function isReadOnlyDerived(c) {
142
+ return !isSource(c) && !isWritable(c);
143
+ }
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). */
147
+ function backPrimal(c) {
148
+ if (c.getter === undefined || c.flags & F.Dirty)
149
+ return c.value;
150
+ return c.currentValue;
151
+ }
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)
177
+ return;
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;
185
+ else
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
+ }
233
+ }
73
234
  /** Install a hook fired on every source value-change; returns a restore fn. */
74
235
  export function setCellWriteHook(fn) {
75
236
  const prev = writeHook;
@@ -78,6 +239,9 @@ export function setCellWriteHook(fn) {
78
239
  writeHook = prev;
79
240
  };
80
241
  }
242
+ // ─────────────────────────────────────────────────────────────────────────
243
+ // Forward graph engine (internal) — alien-signals verbatim.
244
+ // ─────────────────────────────────────────────────────────────────────────
81
245
  // alien-signals algorithm (verbatim): link / unlink / propagate / checkDirty.
82
246
  function link(dep, sub, version) {
83
247
  const prevDep = sub.depsTail;
@@ -92,6 +256,8 @@ function link(dep, sub, version) {
92
256
  const prevSub = dep.subsTail;
93
257
  if (prevSub !== undefined && prevSub.version === version && prevSub.sub === sub)
94
258
  return;
259
+ if (COUNTS)
260
+ counts.link++;
95
261
  const isFirstSub = dep.subs === undefined;
96
262
  const newLink = (sub.depsTail =
97
263
  dep.subsTail =
@@ -122,6 +288,8 @@ function link(dep, sub, version) {
122
288
  }
123
289
  }
124
290
  function unlink(l, sub = l.sub) {
291
+ if (COUNTS)
292
+ counts.unlink++;
125
293
  const { dep, prevDep, nextDep, nextSub, prevSub } = l;
126
294
  if (nextDep !== undefined)
127
295
  nextDep.prevDep = prevDep;
@@ -142,13 +310,14 @@ function unlink(l, sub = l.sub) {
142
310
  return nextDep;
143
311
  }
144
312
  function propagate(start, innerWrite, excluding) {
313
+ if (COUNTS)
314
+ counts.propagate++;
145
315
  let l = start;
146
316
  let next = start.nextSub;
147
317
  let stack;
148
318
  top: do {
149
319
  const sub = l.sub;
150
- // `excluding` skips one subscriber (used by `network()` so a body
151
- // writing a cell it subscribes to doesn't re-trigger itself).
320
+ // `excluding` skips one subscriber (a `network` not re-triggering itself).
152
321
  if (sub !== excluding) {
153
322
  let flags = sub.flags;
154
323
  if (!(flags & (F.RecursedCheck | F.Recursed | F.Dirty | F.Pending))) {
@@ -199,6 +368,8 @@ function propagate(start, innerWrite, excluding) {
199
368
  } while (true);
200
369
  }
201
370
  function checkDirty(startLink, startSub) {
371
+ if (COUNTS)
372
+ counts.checkDirty++;
202
373
  let l = startLink, sub = startSub;
203
374
  let stack;
204
375
  let checkDepth = 0, dirty = false;
@@ -207,7 +378,14 @@ function checkDirty(startLink, startSub) {
207
378
  const flags = dep.flags;
208
379
  if (sub.flags & F.Dirty)
209
380
  dirty = true;
210
- else if ((flags & (F.Mutable | F.Dirty)) === (F.Mutable | F.Dirty)) {
381
+ else if ((flags & (F.Mutable | F.Dirty)) === (F.Mutable | F.Dirty) ||
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`.
386
+ (flags & F.Mutable &&
387
+ dep.bflags & BF.Pending &&
388
+ isSource(dep))) {
211
389
  const subs = dep.subs;
212
390
  if (dep._update()) {
213
391
  if (subs.nextSub !== undefined)
@@ -232,11 +410,15 @@ function checkDirty(startLink, startSub) {
232
410
  while (checkDepth--) {
233
411
  l = stack.value;
234
412
  stack = stack.prev;
235
- if (dirty) {
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.
416
+ if (dirty || sub.flags & F.Dirty) {
236
417
  const subs = sub.subs;
237
418
  if (sub._update()) {
238
419
  if (subs.nextSub !== undefined)
239
420
  shallowPropagate(subs);
421
+ dirty = true;
240
422
  sub = l.sub;
241
423
  continue;
242
424
  }
@@ -289,101 +471,65 @@ function disposeAllDepsInReverse(sub) {
289
471
  l = prev;
290
472
  }
291
473
  }
292
- export const DIRECT_SLOT = Symbol("merge:direct-slot");
293
474
  class MergeNode {
294
- parent;
295
- policy;
296
- slots = new Map();
297
- hasIncrementalAcc;
298
- acc;
299
- constructor(parent, policy) {
300
- this.parent = parent;
301
- this.policy = policy;
302
- this.hasIncrementalAcc = policy.remove !== undefined;
303
- this.acc = policy.identity;
304
- }
305
- receive(slot, next) {
306
- if (this.hasIncrementalAcc) {
307
- const remove = this.policy.remove;
308
- const prior = this.slots.get(slot);
309
- if (prior === undefined)
310
- this.acc = this.policy.combine(this.acc, next);
311
- else
312
- this.acc = this.policy.combine(remove(this.acc, prior), next);
313
- }
314
- this.slots.set(slot, next);
315
- }
316
- fold() {
317
- if (this.hasIncrementalAcc)
318
- return this.acc;
319
- let acc = this.policy.identity;
320
- for (const v of this.slots.values())
321
- acc = this.policy.combine(acc, v);
322
- return acc;
323
- }
324
- reset() {
325
- this.slots.clear();
326
- this.acc = this.policy.identity;
475
+ foldFn;
476
+ /** Contributions gathered as the cone resolves; folded and cleared in `foldMerge`. */
477
+ contributions = [];
478
+ constructor(fold) {
479
+ this.foldFn = fold;
327
480
  }
328
481
  }
329
- // BwdSpec — the backward sidecar.
330
- //
331
- // Every cell carries the forward fields (links, getter, value cache).
332
- // Only a WRITABLE derived cell 1→1 lens, multi-output lens, merge,
333
- // stateful lens, or `pin` needs a backward target and the closures to
334
- // drive it. Those fields live here, off a single `_bwd` pointer, rather
335
- // than inline on `Cell`, so a source/computed stays lean: the forward hot
336
- // path never touches them, and a plain node drops ~64 B. A cell is
337
- // writable iff `_bwd !== undefined`.
338
- //
339
- // Two mode payloads hang off named fields rather than one union, so each
340
- // stays distinctly typed: `merge` (the N→1 fold node) and `stateful` (the
341
- // complement machinery of a complement-carrying lens). Both are rare, so
342
- // a plain 1→1 / multi-out lens leaves them `undefined` and pays only
343
- // `parent` + `put` + `queueIdx`.
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`.
344
489
  class BwdSpec {
345
- /** Backward target(s): one `Cell` (1→1 / merge) or `Cell[]` (multi-out). */
346
- parent = undefined;
347
- /** Lens `put` backward derivation (dual of `getter`). Always called by
348
- * the engine in 1-arg form `put(target)`; a source-reading lens bakes
349
- * `settled(parent)` into this closure at build time. Multi-output:
350
- * returns a per-parent update array. Stateful: the spec's `bwd`. */
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). */
351
493
  // biome-ignore lint/suspicious/noExplicitAny: put fn is opaque shape
352
494
  put = undefined;
353
- /** Backward aggregation node; presence IS the merge-mode discriminant. */
495
+ /** Fold payload; present a fan-in merge. */
354
496
  merge = undefined;
355
- /** Complement machinery; presence IS the stateful-mode discriminant. */
497
+ /** Complement state; present a complement-carrying (stateful) lens. */
356
498
  stateful = undefined;
357
- /** Index in `bwdQueue` of this cell's latest push; the drain skips stale
358
- * entries so each cell propagates backward once per flush, last-write. */
359
- queueIdx = -1;
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;
360
502
  }
361
- /** Runtime state of a stateful (complement-carrying) lens the rare
362
- * backward mode, kept off `BwdSpec` so plain lenses don't carry its slots.
363
- * `put` (the spec's `bwd`) and `parent` stay on `BwdSpec`; this holds the
364
- * 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. */
365
506
  class StatefulCore {
366
507
  /** Engine-owned memory the view discards. */
367
508
  complement;
368
- /** Forward projection `fwd(sources, complement) view`. */
369
- // biome-ignore lint/suspicious/noExplicitAny: opaque fwd shape
370
- fwd;
371
- /** Advance the complement: `step(sources, complement, external)`. */
509
+ /** Advance the complement: `step(sources, complement)`. Run only when the
510
+ * sources actually moved (the engine gates it; see the stateful header). */
372
511
  // biome-ignore lint/suspicious/noExplicitAny: opaque step shape
373
512
  step;
374
- /** Source values last written back (own-vs-external test); `undefined`
375
- * until the first back-write. */
376
- lastBwd = 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;
377
519
  constructor(complement,
378
- // biome-ignore lint/suspicious/noExplicitAny: opaque fwd shape
379
- fwd,
380
520
  // biome-ignore lint/suspicious/noExplicitAny: opaque step shape
381
521
  step) {
382
522
  this.complement = complement;
383
- this.fwd = fwd;
384
523
  this.step = step;
385
524
  }
386
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");
387
533
  /** Snapshot a `Val<T>` to plain `T` (one-shot, no tracking). */
388
534
  export function readNow(v) {
389
535
  if (v instanceof Cell)
@@ -413,35 +559,58 @@ export const isCell = (v) => v instanceof Cell;
413
559
  export const isLens = (v) => v instanceof Cell && v.getter !== undefined && v._bwd !== undefined;
414
560
  /** Read-only mode: derived with no backward path. */
415
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
+ // ─────────────────────────────────────────────────────────────────────────
416
565
  export class Cell {
417
- flags = F.Mutable;
566
+ /** @internal */
567
+ flags;
568
+ /** @internal */
418
569
  subs;
570
+ /** @internal */
419
571
  subsTail;
572
+ /** @internal */
420
573
  deps;
574
+ /** @internal */
421
575
  depsTail;
422
- /** Forward derivation (computed/lens/merge). `undefined` ⇒ source. */
576
+ /** @internal Forward derivation (computed/lens/merge). `undefined` ⇒ source. */
423
577
  getter;
424
- /** Per-instance equality, always defined (defaults to `Object.is` at
425
- * construction) so hot paths call it without an `undefined` branch. */
578
+ /** @internal Per-instance equality; always defined (defaults to `Object.is`). */
426
579
  _equals;
427
- /** First-subscriber / last-subscriber lifecycle hooks. */
580
+ /** @internal First-subscriber / last-subscriber lifecycle hooks. */
428
581
  _watched;
582
+ /** @internal */
429
583
  _unwatchedHook;
430
- /** Source: `currentValue` = committed, `pendingValue` = staged write.
431
- * Getter cell: `currentValue` = last derived cache, `pendingValue`
432
- * reused as the deferred backward target (see `set value`). The two
433
- * roles never coexist, so two fields suffice for four. */
584
+ /** @internal Source: committed value + staged write. */
434
585
  currentValue;
586
+ /** @internal */
435
587
  pendingValue;
436
- /** Backward sidecar: target(s) + lens closures + queue slot, or
437
- * `undefined` for a read-only cell (source or computed). Allocated only
438
- * for writable derived cells, keeping the common node lean. Writability
439
- * is exactly `_bwd !== undefined`. See `BwdSpec`. */
588
+ /** @internal Backward sidecar; `undefined` iff read-only. Writability is `_bwd !== undefined`. */
440
589
  _bwd;
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;
600
+ /** @internal Backward flag word (`BF`), dual of forward `flags`. */
601
+ bflags;
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.
441
612
  constructor(initial, opts) {
442
- this.currentValue = initial;
443
- this.pendingValue = initial;
444
- // Pre-init every optional slot for a stable V8 hidden class across variants.
613
+ this.flags = F.Mutable;
445
614
  this.subs = undefined;
446
615
  this.subsTail = undefined;
447
616
  this.deps = undefined;
@@ -450,7 +619,17 @@ export class Cell {
450
619
  this._equals = Object.is;
451
620
  this._watched = undefined;
452
621
  this._unwatchedHook = undefined;
622
+ this.currentValue = initial;
623
+ this.pendingValue = initial;
453
624
  this._bwd = undefined;
625
+ this.parentEdges = undefined;
626
+ this.parentEdgesTail = undefined;
627
+ this.childEdges = undefined;
628
+ this.childEdgesTail = undefined;
629
+ this.bflags = BF.None;
630
+ this.bEpoch = 0;
631
+ this.version = 0;
632
+ this.name = undefined;
454
633
  if (opts !== undefined) {
455
634
  if (opts.equals !== undefined)
456
635
  this._equals = opts.equals;
@@ -458,32 +637,37 @@ export class Cell {
458
637
  this._watched = opts.watched;
459
638
  if (opts.unwatched !== undefined)
460
639
  this._unwatchedHook = opts.unwatched;
640
+ if (opts.name !== undefined)
641
+ this.name = opts.name;
461
642
  }
462
643
  }
463
- _enqueueBwd() {
464
- this.flags |= F.BwdQueued;
465
- this._bwd.queueIdx = bwdQueue.length;
466
- bwdQueue.push(this);
467
- }
468
- /** Source write (alien's signal setter). Self-excludes the active
469
- * network so a body writing its own dep doesn't re-trigger itself. */
644
+ /** @internal Single write-commit point; self-excludes the active network. */
470
645
  _writeSource(next) {
646
+ // Resolve any pending back-write first, so the later forward write wins (LWW).
647
+ if (this.bflags & BF.Pending && !draining)
648
+ backResolve(this);
471
649
  const prev = this.pendingValue;
472
650
  this.pendingValue = next;
473
651
  if (!this._equals(prev, next)) {
652
+ this.version++; // stamp the change for stateful-lens provenance (sum-of-versions)
474
653
  this.flags = F.Mutable | F.Dirty;
475
654
  if (writeHook !== undefined)
476
655
  writeHook(this);
477
656
  const subs = this.subs;
478
- if (subs !== undefined)
479
- propagate(subs, runDepth > 0, activeNetwork);
480
- if (batchDepth === 0 && !flushing && subs !== undefined)
481
- flush();
657
+ if (subs !== undefined) {
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);
662
+ autoFlush();
663
+ }
482
664
  }
483
665
  }
666
+ /** @internal */
484
667
  _update() {
485
668
  if (this.getter !== undefined) {
486
- // Computed/lens/merge: re-run the forward derivation.
669
+ if (COUNTS)
670
+ counts.recompute++;
487
671
  this.depsTail = undefined;
488
672
  this.flags = F.Mutable | F.RecursedCheck;
489
673
  const prev = activeSub;
@@ -494,7 +678,10 @@ export class Cell {
494
678
  const old = this.currentValue;
495
679
  const next = (this.currentValue = this.getter());
496
680
  threw = false;
497
- 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;
498
685
  }
499
686
  finally {
500
687
  activeSub = prev;
@@ -502,13 +689,30 @@ export class Cell {
502
689
  purgeDeps(this);
503
690
  }
504
691
  }
692
+ // A back-`Pending` source resolves its armed back-write first, so
693
+ // `pendingValue` reflects it before we commit.
694
+ if (this.bflags & BF.Pending && !draining)
695
+ backResolve(this);
505
696
  this.flags = F.Mutable;
506
697
  const prevV = this.currentValue;
507
698
  this.currentValue = this.pendingValue;
508
699
  return !this._equals(prevV, this.currentValue);
509
700
  }
701
+ /** @internal */
510
702
  _notify() { }
703
+ /** @internal */
511
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
+ }
512
716
  if (this.getter !== undefined && this.depsTail !== undefined) {
513
717
  this.flags = F.Mutable | F.Dirty;
514
718
  disposeAllDepsInReverse(this);
@@ -527,28 +731,32 @@ export class Cell {
527
731
  activeSub = prev;
528
732
  }
529
733
  }
530
- /** Guard: silent coercion to string/number is almost always a bug. */
531
- [Symbol.toPrimitive](hint) {
532
- throw new TypeError(`Cell cannot be coerced to ${hint} — use \`.value\``);
533
- }
534
734
  // Construction helpers build via `new this()` so a subclass static
535
735
  // (`Vec.lens(...)`) yields a `Vec` with its constructor-set equality.
536
- // Every lens has a structural backward target (`_bwd.parent`), which is
537
- // what makes the backward pass well-defined.
538
736
  /** Endomorphic lens. A 2-arg `bwd(view, current)` consults the current
539
737
  * source; a 1-arg `bwd(view)` reconstructs it from the view alone. */
540
738
  lens(fwd, bwd) {
541
- return buildLens1(this.constructor, this, fwd, bwd, bwd.length >= 2);
739
+ return buildLens(this.constructor, [this, fwd, bwd]);
542
740
  }
543
741
  /** Read-only same-type view: the RO dual of the endo `.lens`. For a cross-type view use the typed static
544
742
  * `Target.derive(src, fn)`. */
545
743
  derive(fn) {
546
744
  return buildDerived(this.constructor, () => fn(this.value));
547
745
  }
548
- /** Backward-aggregating node bwd dual of computed. Forward, the
549
- * identity view of its parent; backward, folds contributions from
550
- * upstream lenses (slot-keyed) and direct writes (DIRECT_SLOT). */
551
- merge(policy) {
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: forward the identity view of its parent, backward the point
758
+ * where N contributors fold into one value. `fold` defaults to last-writer-wins. */
759
+ merge(fold) {
552
760
  if (this.getter !== undefined && this._bwd === undefined) {
553
761
  throw new TypeError("merge: receiver is read-only");
554
762
  }
@@ -557,28 +765,27 @@ export class Cell {
557
765
  cell.flags = F.Mutable | F.Dirty;
558
766
  cell.getter = () => parent.value;
559
767
  const b = (cell._bwd = new BwdSpec());
560
- b.parent = parent;
561
- b.merge = new MergeNode(parent, policy);
768
+ b.merge = new MergeNode(fold);
769
+ linkLens(cell, parent, 0);
770
+ setWriteBlocked(cell);
562
771
  return cell;
563
772
  }
564
773
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
565
774
  static derive(...args) {
566
- return dispatchDerive(this, args);
775
+ return buildDerive(this, args);
567
776
  }
568
777
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
569
778
  static lens(...args) {
570
- return dispatchLens(this, args);
779
+ return buildLens(this, args);
571
780
  }
572
781
  /** Type predicate against this class: `Vec.is(x)` narrows `x` to `Vec`.
573
782
  * Inherited static; works for any subclass via polymorphic `this`. */
574
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
575
783
  static is(v) {
576
784
  return v instanceof this;
577
785
  }
578
- /** Lift `Val<Inner<Cls>>` → `Cls`: instance → identity, RO cell →
786
+ /** Coerce `Val<Inner<Cls>>` → `Cls`: instance → identity, RO cell →
579
787
  * tracked `derive`, literal → fresh seed. */
580
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
581
- static from(v) {
788
+ static coerce(v) {
582
789
  if (v instanceof this)
583
790
  return v;
584
791
  if (v instanceof Cell) {
@@ -589,32 +796,37 @@ export class Cell {
589
796
  }
590
797
  /** Writable-shaped constant: always reads `v`, absorbs writes
591
798
  * (parentless sink lens), for APIs demanding bidirectionality. */
592
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
593
799
  static pin(v) {
594
800
  const cell = new this();
595
801
  cell.flags = F.Mutable | F.Dirty;
596
802
  cell.getter = () => v;
597
- const b = (cell._bwd = new BwdSpec());
598
- b.put = () => undefined; // absorb (no parent → sink)
803
+ // Parentless `_bwd`: `writeBack` absorbs it (no parent edges, no closure).
804
+ cell._bwd = new BwdSpec();
599
805
  return cell;
600
806
  }
601
- /** Typed field lens onto `parent.value[key]`. A read-only computed
602
- * parent yields a RO derive view; any writable parent yields a
603
- * bidirectional field lens with spread-replace `put`. */
604
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
605
- static fieldOf(
606
- // biome-ignore lint/suspicious/noExplicitAny: parent is contravariant on put
607
- parent, key, Cls) {
608
- const ctor = Cls;
609
- const get = (s) => s[key];
610
- // Read-only ⇔ computed: a getter with no backward sidecar.
611
- const ro = parent.getter !== undefined && parent._bwd === undefined;
612
- if (ro) {
613
- return buildDerived(ctor, () => get(parent.value));
614
- }
615
- // Spread-replace reads the current source ⇒ source-reading (lens) form.
616
- return buildLens1(ctor, parent, get, (v, s) => ({ ...s, [key]: v }), true);
807
+ }
808
+ /** Typed field lens onto `parent.value[key]`. RO parent RO derive;
809
+ * writable parent → bidirectional lens with spread-replace `put`. */
810
+ export function fieldOf(
811
+ // biome-ignore lint/suspicious/noExplicitAny: parent is contravariant on put
812
+ parent, key, Cls) {
813
+ const ctor = Cls;
814
+ const get = (s) => s[key];
815
+ const ro = parent.getter !== undefined && parent._bwd === undefined;
816
+ if (ro) {
817
+ return buildDerived(ctor, () => get(parent.value));
617
818
  }
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]);
618
830
  }
619
831
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
620
832
  function buildDerived(Cls, getter) {
@@ -623,40 +835,95 @@ function buildDerived(Cls, getter) {
623
835
  cell.flags = F.Mutable | F.Dirty;
624
836
  return cell;
625
837
  }
626
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
627
- function buildLens1(Cls, parent, fwd, bwd, readsSource) {
628
- const cell = new Cls();
629
- cell.flags = F.Mutable | F.Dirty;
630
- cell.getter = (() => fwd(parent.value));
631
- const b = (cell._bwd = new BwdSpec());
632
- // Source-reading lenses bake the (non-committing) current source into the
633
- // closure so the engine always calls the 1-arg form (no arity branch).
634
- b.put = readsSource ? (t) => bwd(t, settled(parent)) : bwd;
635
- b.parent = parent;
636
- return cell;
637
- }
638
- // biome-ignore lint/suspicious/noExplicitAny: variance escape
639
- 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) {
640
842
  const n = parents.length;
641
843
  const vals = new Array(n);
642
- const cell = new Cls();
643
- cell.flags = F.Mutable | F.Dirty;
644
- cell.getter = (() => {
844
+ return () => {
645
845
  for (let i = 0; i < n; i++)
646
846
  vals[i] = parents[i].value;
647
847
  return fwd(vals);
648
- });
649
- if (bwd === undefined)
650
- return cell; // read-only derive-N
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;
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;
651
894
  const b = (cell._bwd = new BwdSpec());
652
- b.parent = parents;
653
- b.put = readsSource
654
- ? (target) => {
655
- for (let i = 0; i < n; i++)
656
- vals[i] = parents[i].peek();
657
- return bwd(target, vals);
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);
658
916
  }
659
- : (target) => bwd(target);
917
+ }
918
+ else {
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);
925
+ }
926
+ setWriteBlocked(cell);
660
927
  return cell;
661
928
  }
662
929
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
@@ -671,35 +938,41 @@ spec) {
671
938
  const seed = new Array(n);
672
939
  for (let i = 0; i < n; i++)
673
940
  seed[i] = parents[i].peek();
674
- const sc = (b.stateful = new StatefulCore(spec.init(seed), spec.fwd, 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;
950
+ const fwd = spec.fwd;
675
951
  b.put = spec.bwd;
676
- b.parent = parents;
952
+ b.scatter = true;
953
+ for (let i = 0; i < n; i++)
954
+ linkLens(cell, parents[i], i);
677
955
  cell.getter = (() => {
678
- for (let i = 0; i < n; i++)
956
+ let ver = 0;
957
+ for (let i = 0; i < n; i++) {
679
958
  vals[i] = parents[i].value;
680
- // External unless the live sources still equal this lens's own last
681
- // back-write.
682
- let external = true;
683
- const lb = sc.lastBwd;
684
- if (lb !== undefined) {
685
- external = false;
686
- for (let i = 0; i < n; i++) {
687
- if (vals[i] !== lb[i]) {
688
- external = true;
689
- break;
690
- }
691
- }
959
+ ver += parents[i].version;
692
960
  }
693
- sc.complement = sc.step(vals, sc.complement, external);
694
- return sc.fwd(vals, sc.complement);
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;
967
+ }
968
+ return fwd(vals, sc.complement);
695
969
  });
970
+ setWriteBlocked(cell);
696
971
  return cell;
697
972
  }
698
- // Single-source stateful lens: the `buildLens1` of the complement path.
699
- // Drops the per-read copy/external loops to direct index-0 access; the
700
- // spec stays array-shaped (`init: ([s]) => …`), so a reused length-1
701
- // `vals` still feeds the closures and `b.parent` stays an array for the
702
- // 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.
703
976
  // biome-ignore lint/suspicious/noExplicitAny: variance escape
704
977
  function buildStateful1(Cls, parent,
705
978
  // biome-ignore lint/suspicious/noExplicitAny: opaque spec
@@ -707,52 +980,54 @@ spec) {
707
980
  const cell = new Cls();
708
981
  cell.flags = F.Mutable | F.Dirty;
709
982
  const b = (cell._bwd = new BwdSpec());
710
- const sc = (b.stateful = new StatefulCore(spec.init([parent.peek()]), spec.fwd, 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`)
987
+ const fwd = spec.fwd;
711
988
  b.put = spec.bwd;
712
- b.parent = [parent];
713
- const vals = [undefined];
989
+ // `scatter` stays false: writeBack routes this through the scalar stateful branch.
990
+ linkLens(cell, parent, 0);
714
991
  cell.getter = (() => {
715
- const v = (vals[0] = parent.value);
716
- const lb = sc.lastBwd;
717
- const external = lb === undefined || lb[0] !== v;
718
- sc.complement = sc.step(vals, sc.complement, external);
719
- return sc.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);
720
1001
  });
1002
+ setWriteBlocked(cell);
721
1003
  return cell;
722
1004
  }
723
- // Shared runtime dispatch for `derive`/`lens`, parameterized by the cell
724
- // constructor: the polymorphic-`this` statics pass the typed subclass
725
- // (`Vec.derive` Vec), the free functions pass the plain `Cell`. One body
726
- // each so the static and free forms can't drift (typed overloads live at
727
- // the call sites; only these `any` runtime bodies are shared).
728
- // biome-ignore lint/suspicious/noExplicitAny: dispatch
729
- 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) {
730
1011
  if (args.length === 1)
731
- return buildDerived(ctor, args[0]);
732
- const [parent, fn] = args;
733
- if (Array.isArray(parent))
734
- return buildLensN(ctor, parent, fn, undefined, false);
735
- return buildDerived(ctor, () => fn(parent.value));
736
- }
737
- // biome-ignore lint/suspicious/noExplicitAny: dispatch
738
- function dispatchLens(ctor, args) {
739
- const [parent, a, b] = args;
740
- if (args.length === 2) {
741
- const ps = Array.isArray(parent) ? parent : [parent];
742
- return ps.length === 1 ? buildStateful1(ctor, ps[0], a) : buildStateful(ctor, ps, a);
743
- }
744
- const readsSource = b.length >= 2;
1012
+ return buildDerived(Cls, args[0]);
1013
+ const parent = args[0];
1014
+ const fn = args[1];
745
1015
  if (Array.isArray(parent))
746
- return buildLensN(ctor, parent, a, b, readsSource);
747
- return buildLens1(ctor, parent, a, b, readsSource);
1016
+ return buildDerived(Cls, arrayGetter(parent, fn));
1017
+ return buildDerived(Cls, () => fn(parent.value));
748
1018
  }
749
- // Install `value` on the prototype (for silly TypeScript inference reasons I'd like to avoid if we can figure it out).
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.
750
1021
  Object.defineProperty(Cell.prototype, "value", {
751
1022
  get() {
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.
1025
+ if (this.bflags & BACK_MARKED && !draining)
1026
+ backResolve(this);
752
1027
  const flags = this.flags;
753
1028
  if (this.getter !== undefined) {
754
1029
  if (flags & F.RecursedCheck) {
755
- 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`);
756
1031
  }
757
1032
  if (flags & F.Dirty ||
758
1033
  (flags & F.Pending &&
@@ -763,26 +1038,11 @@ Object.defineProperty(Cell.prototype, "value", {
763
1038
  shallowPropagate(subs);
764
1039
  }
765
1040
  }
766
- else if (!flags) {
767
- // First read: lazy init.
768
- this.flags = F.Mutable | F.RecursedCheck;
769
- const prev = activeSub;
770
- activeSub = this;
771
- let threw = true;
772
- try {
773
- this.currentValue = this.getter();
774
- threw = false;
775
- }
776
- finally {
777
- activeSub = prev;
778
- this.flags = threw ? F.Mutable | F.Dirty : this.flags & ~F.RecursedCheck;
779
- }
780
- }
781
1041
  if (activeSub !== undefined)
782
1042
  link(this, activeSub, cycle);
783
1043
  return this.currentValue;
784
1044
  }
785
- // Cell path.
1045
+ // Source path.
786
1046
  if (flags & F.Dirty) {
787
1047
  this.flags = F.Mutable;
788
1048
  const prevV = this.currentValue;
@@ -802,248 +1062,441 @@ Object.defineProperty(Cell.prototype, "value", {
802
1062
  this._writeSource(next);
803
1063
  return;
804
1064
  }
805
- // Backward write. Deferred while batching/flushing so repeated writes
806
- // coalesce (last-write-wins) and merge folds wait for all
807
- // contributors; eager + synchronous otherwise. Exception: inside a
808
- // network body writes are eager so the body's fixpoint loop observes
809
- // its own edits via `peek()` between steps.
810
1065
  const b = this._bwd;
811
1066
  if (b === undefined) {
812
1067
  throw new TypeError("Cannot write to a computed");
813
1068
  }
814
- const deferred = (batchDepth > 0 || flushing) && activeNetwork === undefined;
815
- if (b.merge !== undefined) {
816
- b.merge.receive(DIRECT_SLOT, next);
817
- if (deferred)
818
- this._enqueueBwd();
819
- else
820
- bwdUntracked(this, undefined, false);
821
- return;
822
- }
823
- if (deferred && (Array.isArray(b.parent) || b.stateful !== undefined)) {
824
- // Multi-parent / stateful: defer to flush so a split coalesces, a
825
- // merge folds after all contributors land, and a complement steps
826
- // once. Reuse `pendingValue` (unused by a getter's forward path) as
827
- // the deferred backward target; drained by flush. Entry no-op vs the
828
- // current view (GetPut) skips the walk.
829
- if (this._equals(next, this.peek()))
830
- return;
831
- this.pendingValue = next;
832
- this._enqueueBwd();
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())) {
833
1073
  return;
834
1074
  }
835
- // Single-parent lens: run the walk now. Inside a batch `_writeSource`
836
- // stages the source (Dirty + pending) and defers only the flush, so
837
- // the view reads back consistently and a later write supersedes via
838
- // the source's pending value — last-write-wins, no queue, no lost
839
- // revert. We must NOT peek the view here when batching: that would
840
- // commit the source's pending value and break net-zero revert
841
- // coalescing — `propagateBwd`'s `settled` no-op stop prunes a true
842
- // no-op without committing. Outside a batch, the O(1) GetPut check is
843
- // safe (no source is staged) and worth keeping.
844
- if (!deferred && this._equals(next, this.peek()))
845
- return;
846
- bwdUntracked(this, next, deferred);
1075
+ arm(this, next);
847
1076
  },
848
1077
  enumerable: false,
849
1078
  configurable: false,
850
1079
  });
851
- // Backward pass (propagateBwd).
852
- //
853
- // Walk up `_bwdParent`, applying `put` at each lens / folding at each
854
- // merge, until a source is committed (via the forward write path) or a
855
- // parent merge is reached. `deferred` (inside batch/flush) stops at a
856
- // parent merge after depositing so it folds once all contributors land;
857
- // eager folds merges inline. Not a second engine: every path terminates
858
- // in `_writeSource`.
859
- // Backward evaluation runs UNTRACKED so `bwd`/`step`/`fwd` reads don't
860
- // establish forward deps on whatever `activeSub` is writing (e.g. an
861
- // effect that writes a lens). All backward entry points route through here.
862
- function bwdUntracked(cell, target, deferred) {
863
- const prev = activeSub;
864
- activeSub = undefined;
865
- try {
866
- propagateBwd(cell, target, deferred);
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. */
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");
867
1093
  }
868
- finally {
869
- activeSub = prev;
1094
+ if (COUNTS)
1095
+ counts.arm++;
1096
+ if (!(node.bflags & BF.Dirty)) {
1097
+ markDown(node); // flag path + wake cones FIRST (a throw arms nothing)
1098
+ node.bflags |= BF.Dirty;
870
1099
  }
1100
+ node.pendingValue = target;
1101
+ autoFlush();
871
1102
  }
872
- /** A cell's current value for the backward pass's internal no-op checks,
873
- * WITHOUT side effects. A source staged earlier in this batch reads its
874
- * pending value directly; reading it via `peek` would COMMIT the pending
875
- * value (`_update`: currentValue = pendingValue), so a later net-zero
876
- * revert would look like a real change and over-fire downstream. A
877
- * non-source (lens/computed) has no such hazard and recomputes via peek. */
878
- function settled(cell) {
879
- return cell.getter === undefined && (cell.flags & F.Dirty) !== 0
880
- ? cell.pendingValue
881
- : cell.peek();
882
- }
883
- function propagateBwd(start, target, deferred) {
884
- let cell = start;
885
- let v = target;
886
- while (true) {
887
- // Multi-parent lens: SPLIT the write into each parent (dual of a
888
- // getter reading N parents — the `put` yields N upstream values).
889
- const cb = cell._bwd;
890
- const parent = cb.parent;
891
- if (Array.isArray(parent)) {
892
- propagateSplit(cell, v, deferred);
893
- return;
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`.
1106
+ *
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. */
1111
+ function markDown(start) {
1112
+ let node = start;
1113
+ let stack;
1114
+ for (;;) {
1115
+ if (COUNTS)
1116
+ counts.markDownVisit++;
1117
+ let next;
1118
+ if (isSource(node)) {
1119
+ // Leaf (dual of a `Dirty` source): wake its cone ONCE.
1120
+ if (!(node.bflags & BF.Pending)) {
1121
+ node.bflags |= BF.Pending;
1122
+ const subs = node.subs;
1123
+ if (subs !== undefined)
1124
+ propagate(subs, runDepth > 0, activeExcluded);
1125
+ }
894
1126
  }
895
- let push;
896
- if (cb.merge !== undefined) {
897
- const node = cb.merge;
898
- push = node.fold();
899
- node.reset();
1127
+ else if (node === start || !(node.bflags & BF.Pending)) {
1128
+ // On the back-path. An already-marked intermediate (≠ start) has its
1129
+ // subtree marked — stop (diamond dedup).
1130
+ if (node !== start)
1131
+ node.bflags |= BF.Pending;
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);
1144
+ }
900
1145
  }
901
- else {
902
- // Single-arg always: a source-reading lens baked `settled(parent)`
903
- // into `put` at build time (see `buildLens1`); `pin` ignores `v`.
904
- push = cb.put(v);
1146
+ if (next !== undefined) {
1147
+ node = next;
905
1148
  }
906
- // Parentless lens (e.g. `pin`): no upstream, write absorbed. Sink.
907
- if (parent === undefined)
908
- return;
909
- // Concrete no-op stop: if the parent already holds `push`, committing
910
- // changes nothing upstream, so the walk stops. Sound for ANY topology
911
- // (no speculation). A lossy lens hides an off-grid edit by returning
912
- // the current source from `put`. Merge parents fold instead. `settled`
913
- // reads a batched source's pending value WITHOUT committing it, so a
914
- // net-zero revert leaves the source unchanged and downstream un-fired.
915
- const pb = parent._bwd;
916
- const parentMerge = pb !== undefined ? pb.merge : undefined;
917
- if (parentMerge === undefined && parent._equals(push, settled(parent)))
918
- return;
919
- if (parentMerge !== undefined) {
920
- parentMerge.receive(cell, push);
921
- if (deferred) {
922
- if (!(parent.flags & F.BwdQueued))
923
- parent._enqueueBwd();
924
- return;
925
- }
926
- cell = parent;
927
- continue;
1149
+ else if (stack !== undefined && stack.length > 0) {
1150
+ node = stack.pop();
928
1151
  }
929
- if (parent.getter === undefined) {
930
- // Source: commit + forward-propagate (the forward write).
931
- parent._writeSource(push);
1152
+ else {
932
1153
  return;
933
1154
  }
934
- // Parent is a lens: keep walking, carrying its new view value.
935
- cell = parent;
936
- v = push;
937
1155
  }
938
1156
  }
939
- /** Split a multi-parent cell's write across its N parents. `_put(target)`
940
- * returns the per-parent update array (`undefined` leave parent);
941
- * each defined update recurses via `propagateBwd`. Eager splits coalesce
942
- * under one flush (same guarantee as `batch()`); the coalescing lives
943
- * here since such a cell may be reached as a write start or mid-chain. */
944
- function propagateSplit(cell, target, deferred) {
945
- const b = cell._bwd;
946
- const parents = b.parent;
947
- const n = parents.length;
948
- // STATEFUL lens: `bwd` reads the complement and returns per-parent
949
- // updates plus the post-write complement. We commit the stepped
950
- // complement and fork the source updates; absorption is the lens's job
951
- // (its `bwd` returns `undefined` updates, forked as no-ops).
952
- const sc = b.stateful;
953
- if (sc !== undefined) {
954
- // Bring the complement current with the sources before the back-write
955
- // (a source may have changed without the view being read, leaving
956
- // `step` un-run). Untracked, so reading `.value` adds no dependency.
957
- void cell.value;
958
- const vals = new Array(n);
959
- for (let i = 0; i < n; i++)
960
- vals[i] = parents[i].peek();
961
- const res = b.put(target, vals, sc.complement);
962
- const updates = res.updates;
963
- const cand = new Array(n);
964
- let anyWrite = false;
965
- for (let i = 0; i < n; i++) {
966
- const u = updates[i];
967
- if (u === undefined) {
968
- cand[i] = vals[i];
969
- }
970
- else {
971
- cand[i] = u;
972
- anyWrite = true;
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.
1161
+ *
1162
+ * Iterative post-order over the back-cone (was a recursion bounded by lens-nesting
1163
+ * depth), via an explicit frame stack of {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;
973
1192
  }
974
1193
  }
975
- sc.complement = sc.step(cand, res.complement, false);
976
- if (!anyWrite) {
977
- // Complement-only change (no source moves): mark dirty for a correct next read.
978
- cell.flags = F.Mutable | F.Dirty;
979
- return;
980
- }
981
- sc.lastBwd = cand;
982
- if (deferred) {
983
- forkInto(parents, updates, n);
984
- return;
985
- }
986
- ++batchDepth;
987
- try {
988
- forkInto(parents, updates, n);
989
- }
990
- finally {
991
- if (!--batchDepth)
992
- flush();
993
- }
994
- return;
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);
995
1203
  }
996
- const updates = b.put(target);
997
- // No speculation: each defined update forks to its parent, where
998
- // `_writeSource`'s equality check prunes no-op sources and the forward
999
- // pass prunes unchanged views. Absorption ⇒ `undefined` updates.
1000
- if (deferred) {
1001
- forkInto(parents, updates, n);
1002
- return;
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++;
1209
+ const b = node._bwd;
1210
+ if (b !== undefined && b.merge !== undefined)
1211
+ b.merge.contributions.length = 0;
1212
+ if (node.bflags & BF.Dirty) {
1213
+ node.bflags &= ~BF.Dirty;
1214
+ writeBack(node, node.pendingValue);
1003
1215
  }
1216
+ }
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). */
1225
+ function backResolve(start) {
1226
+ draining = true;
1004
1227
  ++batchDepth;
1228
+ const prev = activeSub;
1229
+ activeSub = undefined;
1230
+ const sourcesBase = backSources.length;
1231
+ const epoch = ++backCycle;
1005
1232
  try {
1006
- forkInto(parents, updates, n);
1233
+ if (isSource(start)) {
1234
+ resolveCone(start);
1235
+ return;
1236
+ }
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);
1252
+ }
1253
+ else if (next === undefined)
1254
+ next = p;
1255
+ else
1256
+ (stack ??= []).push(p);
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);
1271
+ }
1007
1272
  }
1008
1273
  finally {
1009
- if (!--batchDepth)
1010
- flush();
1274
+ backSources.length = sourcesBase;
1275
+ activeSub = prev;
1276
+ --batchDepth;
1277
+ draining = false;
1278
+ }
1279
+ }
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. */
1284
+ function resolveBackDeps(node) {
1285
+ for (let l = node.deps; l !== undefined; l = l.nextDep) {
1286
+ const d = l.dep;
1287
+ if (d.bflags & BACK_MARKED && !draining)
1288
+ backResolve(d);
1011
1289
  }
1012
1290
  }
1013
- /** Route each defined update to its parent: a source commits directly, a
1014
- * lens/multi-parent/merge re-enters the backward pass. Always called
1015
- * under a bumped `batchDepth` so commits coalesce into one flush. */
1016
- function forkInto(parents, updates, n) {
1017
- for (let i = 0; i < n; i++) {
1018
- const u = updates[i];
1019
- if (u === undefined)
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 exactly as the old recursion did — bounded by stack memory, not
1299
+ * the call stack. */
1300
+ function writeBack(node, target) {
1301
+ wbNode[0] = node;
1302
+ wbTarget[0] = target;
1303
+ let top = 1;
1304
+ let sTop = 0;
1305
+ while (top > 0) {
1306
+ if (COUNTS)
1307
+ counts.writeBackVisit++;
1308
+ const cur = wbNode[--top];
1309
+ const tgt = wbTarget[top];
1310
+ if (isSource(cur)) {
1311
+ cur._writeSource(tgt); // staged now, visible to later siblings
1312
+ // Clear this source's `BF.Pending`, then re-assert iff a lens-child is STILL
1313
+ // armed (an overlapping co-writer) — else that write is lost, and leaving it
1314
+ // set unconditionally would strand `BF.Pending` on every fan-in source.
1315
+ // Scan from the TAIL: `resolveCone` drives children head→tail, so the last
1316
+ // still-armed co-writer sits near the tail — found in O(1) until the final
1317
+ // one, turning a fan-in's re-assert from O(N²) into O(N). (Order is
1318
+ // irrelevant; this is a find-any.)
1319
+ cur.bflags &= ~BF.Pending;
1320
+ for (let e = cur.childEdgesTail; e !== undefined; e = e.prevChild) {
1321
+ if (COUNTS)
1322
+ counts.reassertScan++;
1323
+ if (e.child.bflags & BACK_MARKED) {
1324
+ cur.bflags |= BF.Pending;
1325
+ break;
1326
+ }
1327
+ }
1328
+ continue;
1329
+ }
1330
+ cur.bflags &= ~BF.Pending; // passing through clears the path marker
1331
+ const b = cur._bwd;
1332
+ if (b === undefined)
1333
+ throw new TypeError("Cannot write through to a computed");
1334
+ const mn = b.merge;
1335
+ if (mn !== undefined) {
1336
+ mn.contributions.push(tgt); // gathered here; `resolveCone` folds post-order
1337
+ continue;
1338
+ }
1339
+ const pe = cur.parentEdges;
1340
+ if (pe === undefined)
1341
+ continue; // pin sink (parentless): absorb
1342
+ const sc = b.stateful;
1343
+ if (sc !== undefined && !b.scatter) {
1344
+ // Single-source stateful fast-path (scalar `bwd`); one index-0 parent edge.
1345
+ const p = pe.parent;
1346
+ const x = p.value;
1347
+ const ver = p.version;
1348
+ if (ver !== sc.stamp) {
1349
+ if (COUNTS)
1350
+ counts.step++;
1351
+ sc.complement = sc.step(x, sc.complement);
1352
+ }
1353
+ if (COUNTS)
1354
+ counts.put++;
1355
+ const res = b.put(tgt, x, sc.complement);
1356
+ sc.complement = res.complement;
1357
+ wbStateful[sTop++] = cur;
1358
+ const u = res.update;
1359
+ if (u !== SKIP) {
1360
+ wbNode[top] = p;
1361
+ wbTarget[top] = u;
1362
+ top++;
1363
+ }
1364
+ else {
1365
+ // Stash: the view moved through the complement alone (see the scatter case).
1366
+ cur.flags |= F.Dirty;
1367
+ const subs = cur.subs;
1368
+ if (subs !== undefined)
1369
+ propagate(subs, runDepth > 0, activeExcluded);
1370
+ }
1020
1371
  continue;
1021
- const parent = parents[i];
1022
- if (parent.getter === undefined)
1023
- parent._writeSource(u);
1024
- else
1025
- propagateBwd(parent, u, true);
1372
+ }
1373
+ if (b.scatter) {
1374
+ // Gather ordered parents (index-ordered edges) for the tuple `put`.
1375
+ let n = 0;
1376
+ for (let e = pe; e !== undefined; e = e.nextParent)
1377
+ n++;
1378
+ const parents = new Array(n);
1379
+ for (let e = pe; e !== undefined; e = e.nextParent)
1380
+ parents[e.index] = e.parent;
1381
+ let out;
1382
+ if (sc !== undefined) {
1383
+ const vals = new Array(n);
1384
+ let ver = 0;
1385
+ for (let i = 0; i < n; i++) {
1386
+ vals[i] = parents[i].value;
1387
+ ver += parents[i].version;
1388
+ }
1389
+ // Refresh the complement only if a source moved since the last sync — e.g.
1390
+ // a prior sibling co-writer bumped a shared source. A pure own re-write
1391
+ // (sum unchanged) skips it: `bwd` already gets the settled complement.
1392
+ if (ver !== sc.stamp) {
1393
+ if (COUNTS)
1394
+ counts.step++;
1395
+ sc.complement = sc.step(vals, sc.complement);
1396
+ }
1397
+ if (COUNTS)
1398
+ counts.put++;
1399
+ const res = b.put(tgt, vals, sc.complement);
1400
+ const upd = res.updates;
1401
+ // Commit `bwd`'s complement directly; it must be consistent with `updates`
1402
+ // (no reliance on a post-write `step`). The stamp is re-set post-order (after
1403
+ // the sources are written and their versions bumped) so the next forward read
1404
+ // sees an unchanged sum and skips `step` — own-write provenance.
1405
+ sc.complement = res.complement;
1406
+ wbStateful[sTop++] = cur;
1407
+ out = upd;
1408
+ }
1409
+ else {
1410
+ if (COUNTS)
1411
+ counts.put++;
1412
+ out = b.put(tgt);
1413
+ }
1414
+ // Push non-SKIP children in REVERSE so index 0 is popped (processed) first
1415
+ // — depth-first, left-to-right. A short `out` skips the trailing parents.
1416
+ let wrote = false;
1417
+ const m = out.length < n ? out.length : n;
1418
+ for (let i = m - 1; i >= 0; i--) {
1419
+ const u = out[i];
1420
+ if (u !== SKIP) {
1421
+ wrote = true;
1422
+ wbNode[top] = parents[i];
1423
+ wbTarget[top] = u;
1424
+ top++;
1425
+ }
1426
+ }
1427
+ // A stateful lens can change its VIEW through the complement alone, moving no
1428
+ // source (a "stash"; `!wrote` ⇒ no children pushed). The forward cone never
1429
+ // fires, so invalidate this node's cache and propagate to its observers here.
1430
+ if (!wrote && sc !== undefined) {
1431
+ cur.flags |= F.Dirty;
1432
+ const subs = cur.subs;
1433
+ if (subs !== undefined)
1434
+ propagate(subs, runDepth > 0, activeExcluded);
1435
+ }
1436
+ continue;
1437
+ }
1438
+ // 1→1 lens (single index-0 parent-edge).
1439
+ if (COUNTS)
1440
+ counts.put++;
1441
+ wbNode[top] = pe.parent;
1442
+ wbTarget[top] = b.put(tgt);
1443
+ top++;
1444
+ }
1445
+ // Post-order re-stamp: now the sources are written (versions bumped), record
1446
+ // each on-path stateful lens's parent-version sum, so its next forward read
1447
+ // sees an unchanged sum and skips `step`. Integers only — no `fwd`, no commit.
1448
+ for (let i = 0; i < sTop; i++) {
1449
+ const sc = wbStateful[i]._bwd.stateful;
1450
+ let ver = 0;
1451
+ for (let e = wbStateful[i].parentEdges; e !== undefined; e = e.nextParent) {
1452
+ ver += e.parent.version;
1453
+ }
1454
+ sc.stamp = ver;
1455
+ wbStateful[i] = undefined;
1026
1456
  }
1027
1457
  }
1458
+ /** Fold a merge's contributions once (policy; default last-writer-wins) and write
1459
+ * the result up to its parent. Called post-order from `resolveCone`. */
1460
+ function foldMerge(parent, mn) {
1461
+ if (COUNTS)
1462
+ counts.fold++;
1463
+ const vals = mn.contributions;
1464
+ const fold = mn.foldFn;
1465
+ let folded;
1466
+ if (fold !== undefined)
1467
+ folded = fold(vals);
1468
+ else if (vals.length > 0)
1469
+ folded = vals[vals.length - 1];
1470
+ else
1471
+ return; // last-writer-wins with no contributor: leave the parent
1472
+ vals.length = 0; // reuse the merge-owned buffer in place (fold must not retain it)
1473
+ writeBack(parent, folded);
1474
+ }
1475
+ // ─────────────────────────────────────────────────────────────────────────
1476
+ // Public API — factories (cell / derive / lens) over the builders above.
1477
+ // ─────────────────────────────────────────────────────────────────────────
1028
1478
  /** Writable source; passes an existing `Writable` through (idempotent). */
1029
1479
  export function cell(initial, opts) {
1030
1480
  if (initial instanceof Cell)
1031
1481
  return initial;
1032
1482
  return new Cell(initial, opts);
1033
1483
  }
1034
- // Bare (untyped) factories. Construct a plain `Cell`, inferring `R`
1035
- // from the closures (the polymorphic-`this` statics are for typed
1036
- // subclasses like `Vec.lens`).
1484
+ // Bare (untyped) factories: plain `Cell`, inferring `R` from the closures.
1037
1485
  const CELL_CTOR = Cell;
1038
1486
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
1039
1487
  export function derive(...args) {
1040
- return dispatchDerive(CELL_CTOR, args);
1488
+ return buildDerive(CELL_CTOR, args);
1041
1489
  }
1042
1490
  // biome-ignore lint/suspicious/noExplicitAny: dispatch
1043
1491
  export function lens(...args) {
1044
- return dispatchLens(CELL_CTOR, args);
1492
+ return buildLens(CELL_CTOR, args);
1045
1493
  }
1046
- // Effect — alien-signals verbatim.
1494
+ // ─────────────────────────────────────────────────────────────────────────
1495
+ // Effects & schedulers — the Effect watcher (internal) and the public
1496
+ // effect / batch / network / flush surface built on it.
1497
+ // ─────────────────────────────────────────────────────────────────────────
1498
+ // Effect — one watcher class for both auto-tracked effects and explicit-topology
1499
+ // networks: alien-signals' effect plus the `EM` mode toggles `network()` needs.
1047
1500
  class Effect {
1048
1501
  flags = F.Watching | F.RecursedCheck;
1049
1502
  subs = undefined;
@@ -1052,26 +1505,31 @@ class Effect {
1052
1505
  depsTail = undefined;
1053
1506
  fn;
1054
1507
  cleanup = undefined;
1055
- constructor(fn) {
1508
+ /** Watcher-behavior bits (`EM`); `EM.None` for a plain effect. */
1509
+ mode;
1510
+ constructor(fn, mode = EM.None) {
1056
1511
  this.fn = fn;
1057
- const prev = activeSub;
1058
- activeSub = this;
1059
- try {
1060
- ++runDepth;
1061
- const ret = fn();
1062
- this.cleanup = typeof ret === "function" ? ret : undefined;
1063
- }
1064
- finally {
1065
- --runDepth;
1066
- activeSub = prev;
1067
- this.flags &= ~F.RecursedCheck;
1068
- }
1512
+ this.mode = mode;
1069
1513
  }
1070
1514
  _update() {
1071
1515
  this.flags = F.Mutable;
1072
1516
  return true;
1073
1517
  }
1074
1518
  _notify() {
1519
+ const mode = this.mode;
1520
+ if (mode & EM.Manual) {
1521
+ this.flags |= F.Watching; // re-arm but don't queue; only `flush()` advances
1522
+ return;
1523
+ }
1524
+ if (mode & EM.Sync) {
1525
+ // Eager watcher (network): append + force a synchronous flush.
1526
+ queued[queuedLength++] = this;
1527
+ syncFlush = true;
1528
+ this.flags &= ~F.Watching;
1529
+ return;
1530
+ }
1531
+ // Plain effect: batch-insert this effect and any subscribed to it, in
1532
+ // dependency order (alien-signals).
1075
1533
  let e = this;
1076
1534
  let insertIndex = queuedLength;
1077
1535
  const firstInsertedIndex = insertIndex;
@@ -1101,6 +1559,10 @@ class Effect {
1101
1559
  this._runCleanup();
1102
1560
  }
1103
1561
  _run() {
1562
+ // Resolve back-writes this node reads directly (incl. view-only stashes);
1563
+ // `checkDirty` resolves any back-`Pending` source reached deeper.
1564
+ if (this.deps !== undefined)
1565
+ resolveBackDeps(this);
1104
1566
  const flags = this.flags;
1105
1567
  if (flags & F.Dirty || (flags & F.Pending && checkDirty(this.deps, this))) {
1106
1568
  if (this.cleanup) {
@@ -1108,27 +1570,39 @@ class Effect {
1108
1570
  if (!this.flags)
1109
1571
  return;
1110
1572
  }
1111
- this.depsTail = undefined;
1112
- this.flags = F.Watching | F.RecursedCheck;
1113
- const prev = activeSub;
1114
- activeSub = this;
1115
- try {
1116
- ++cycle;
1117
- ++runDepth;
1118
- const ret = this.fn();
1119
- this.cleanup = typeof ret === "function" ? ret : undefined;
1120
- }
1121
- finally {
1122
- --runDepth;
1123
- activeSub = prev;
1124
- this.flags &= ~F.RecursedCheck;
1125
- purgeDeps(this);
1126
- }
1573
+ this._invoke();
1127
1574
  }
1128
1575
  else if (this.deps !== undefined) {
1129
1576
  this.flags = F.Watching;
1130
1577
  }
1131
1578
  }
1579
+ /** Run the body — the single path for first fire, scheduled re-run, and manual
1580
+ * `flush()`. Auto-tracks deps unless `NoTrack`; self-excludes writes under `Exclude`. */
1581
+ _invoke() {
1582
+ const noTrack = this.mode & EM.NoTrack;
1583
+ if (!noTrack)
1584
+ this.depsTail = undefined;
1585
+ this.flags = F.Watching | F.RecursedCheck;
1586
+ const prevSub = activeSub;
1587
+ const prevExc = activeExcluded;
1588
+ activeSub = noTrack ? undefined : this;
1589
+ if (this.mode & EM.Exclude)
1590
+ activeExcluded = this;
1591
+ try {
1592
+ ++cycle;
1593
+ ++runDepth;
1594
+ const ret = this.fn();
1595
+ this.cleanup = typeof ret === "function" ? ret : undefined;
1596
+ }
1597
+ finally {
1598
+ --runDepth;
1599
+ activeSub = prevSub;
1600
+ activeExcluded = prevExc;
1601
+ this.flags &= ~F.RecursedCheck;
1602
+ if (!noTrack)
1603
+ purgeDeps(this);
1604
+ }
1605
+ }
1132
1606
  _runCleanup() {
1133
1607
  const c = this.cleanup;
1134
1608
  this.cleanup = undefined;
@@ -1144,49 +1618,76 @@ class Effect {
1144
1618
  }
1145
1619
  export function effect(fn) {
1146
1620
  const e = new Effect(fn);
1621
+ e._invoke();
1147
1622
  return () => e._unwatched();
1148
1623
  }
1149
- // Alternates backward-drain and effect-drain to a fixpoint: backward
1150
- // commits sources (queuing effects); effects may write (more effects /
1151
- // more bwd entries). Loops until both queues are exhausted.
1624
+ /** Run effects woken by a write. Backward work is pulled lazily per read, so
1625
+ * flush owns no backward bookkeeping just the effect queue. */
1152
1626
  function flush() {
1153
1627
  if (flushing)
1154
1628
  return;
1155
1629
  flushing = true;
1156
- let bwdIndex = 0;
1630
+ // Error locality: one effect throwing must not strand its siblings. Drain the
1631
+ // whole queue, catching each body; surface the first error after the queue is
1632
+ // empty (later errors are dropped — the engine stays consistent, the user still
1633
+ // sees a failure). A throwing body isn't re-queued (its `F.Watching` is already
1634
+ // cleared); it re-arms on the next wake.
1635
+ let err;
1636
+ let threw = false;
1157
1637
  try {
1158
- // Head-checked so the common already-drained case skips the body.
1159
- while (bwdIndex < bwdQueue.length || notifyIndex < queuedLength) {
1160
- while (bwdIndex < bwdQueue.length) {
1161
- const cell = bwdQueue[bwdIndex];
1162
- const cb = cell._bwd;
1163
- if (cb.queueIdx !== bwdIndex || !(cell.flags & F.BwdQueued)) {
1164
- bwdIndex++;
1165
- continue;
1166
- }
1167
- bwdIndex++;
1168
- cell.flags &= ~F.BwdQueued;
1169
- if (cb.merge !== undefined) {
1170
- bwdUntracked(cell, undefined, true);
1171
- }
1172
- else {
1173
- bwdUntracked(cell, cell.pendingValue, true);
1174
- }
1175
- }
1176
- while (notifyIndex < queuedLength) {
1177
- const e = queued[notifyIndex];
1178
- queued[notifyIndex++] = undefined;
1638
+ while (notifyIndex < queuedLength) {
1639
+ const e = queued[notifyIndex];
1640
+ queued[notifyIndex++] = undefined;
1641
+ try {
1179
1642
  e._run();
1180
1643
  }
1644
+ catch (ex) {
1645
+ if (!threw) {
1646
+ err = ex;
1647
+ threw = true;
1648
+ }
1649
+ }
1181
1650
  }
1182
1651
  }
1183
1652
  finally {
1184
- bwdQueue.length = 0;
1185
1653
  notifyIndex = 0;
1186
1654
  queuedLength = 0;
1655
+ syncFlush = false;
1187
1656
  flushing = false;
1188
1657
  }
1658
+ if (threw)
1659
+ throw err;
1189
1660
  }
1661
+ /** Queue an effect flush for the end of the current microtask turn (idempotent).
1662
+ * A write wakes effects asynchronously; many writes in one turn coalesce. */
1663
+ function schedule() {
1664
+ if (scheduled)
1665
+ return;
1666
+ scheduled = true;
1667
+ queueMicrotask(() => {
1668
+ scheduled = false;
1669
+ flush();
1670
+ });
1671
+ }
1672
+ /** Resolve the queue after a write: no-op inside a `batch`/flush (the barrier
1673
+ * owns flushing), else synchronously if a `Sync` watcher is waiting (eager
1674
+ * solve) or deferred to the microtask (coalesced effects). */
1675
+ function autoFlush() {
1676
+ if (batchDepth !== 0 || flushing)
1677
+ return;
1678
+ if (syncFlush)
1679
+ flush();
1680
+ else
1681
+ schedule();
1682
+ }
1683
+ /** Run all pending effects now, synchronously — the escape hatch for code that
1684
+ * must observe effect side-effects before yielding. Reads never need it. */
1685
+ export function settle() {
1686
+ flush();
1687
+ }
1688
+ /** Group writes and flush effects synchronously at the end of `fn`. Effects
1689
+ * coalesce on the microtask turn anyway; reach for `batch` only to run the woken
1690
+ * effects before the call returns. */
1190
1691
  export function batch(fn) {
1191
1692
  ++batchDepth;
1192
1693
  try {
@@ -1207,191 +1708,114 @@ export function untracked(fn) {
1207
1708
  activeSub = prev;
1208
1709
  }
1209
1710
  }
1210
- class _NetworkNode {
1211
- subs = undefined;
1212
- subsTail = undefined;
1213
- deps = undefined;
1214
- depsTail = undefined;
1215
- flags = F.Watching | F.RecursedCheck;
1216
- body;
1217
- manual;
1218
- /** Per-instance last-seen dep values; used to compute `dirty`. */
1219
- lastValues = new Map();
1220
- pending = false;
1221
- disposed = false;
1222
- _ownCycle = 0;
1223
- _depsSet = new Set();
1224
- _handle;
1225
- constructor(body, manual) {
1226
- this.body = body;
1227
- this.manual = manual;
1228
- }
1229
- /** Two-phase init so the body sees its own handle on the first fire. */
1230
- _initWithHandle(handle, initialDeps) {
1231
- this._handle = handle;
1232
- this._linkBatch(initialDeps);
1233
- this._runBody(EMPTY_DIRTY);
1234
- }
1235
- _update() {
1236
- this.flags = F.Mutable;
1237
- return true;
1238
- }
1239
- _notify() {
1240
- if (this.manual) {
1241
- this.pending = true;
1242
- this.flags |= F.Watching;
1243
- return;
1244
- }
1245
- queued[queuedLength++] = this;
1246
- this.flags &= ~F.Watching;
1247
- }
1248
- _unwatched() {
1249
- this.disposed = true;
1250
- this.flags = F.None;
1251
- disposeAllDepsInReverse(this);
1252
- const sub = this.subs;
1253
- if (sub !== undefined)
1254
- unlink(sub);
1255
- this.lastValues.clear();
1256
- }
1257
- _run() {
1258
- if (this.disposed)
1259
- return;
1260
- const flags = this.flags;
1261
- if (flags & F.Dirty || (flags & F.Pending && checkDirty(this.deps, this))) {
1262
- this._runBody(this._computeDirty());
1263
- }
1264
- else if (this.deps !== undefined) {
1265
- this.flags = F.Watching;
1266
- }
1267
- }
1268
- _computeDirty() {
1711
+ /** Build a reactive sub-DAG. The body fires when any subscribed dep changes
1712
+ * (`dirty` = the changed subset), self-excludes its own writes, and (auto mode)
1713
+ * resolves synchronously. `manual: true` defers firing so only `flush()` advances;
1714
+ * `flush()` from inside the body throws. Network-specific state (last-values,
1715
+ * handle) lives in this closure, so the shared `Effect` carries none of it. */
1716
+ export function network(
1717
+ // biome-ignore lint/suspicious/noExplicitAny: deps come in many flavours
1718
+ deps, body, opts) {
1719
+ const lastValues = new Map();
1720
+ const depsSet = new Set();
1721
+ let ownCycle = 0;
1722
+ let disposed = false;
1723
+ // Forward-declared so the closures below can reach the node; assigned before
1724
+ // any runs (the first `_invoke` happens after construction).
1725
+ let node;
1726
+ const computeDirty = () => {
1269
1727
  let dirty;
1270
- for (const [cell, lastVal] of this.lastValues) {
1271
- if (cell.peek() !== lastVal) {
1272
- if (dirty === undefined)
1273
- dirty = new Set();
1274
- dirty.add(cell);
1275
- }
1728
+ for (const [c, last] of lastValues) {
1729
+ if (c.peek() !== last)
1730
+ (dirty ??= new Set()).add(c);
1276
1731
  }
1277
1732
  return dirty ?? EMPTY_DIRTY;
1278
- }
1279
- _runBody(dirty) {
1280
- // RecursedCheck doubles as the "body running" guard (see flush()).
1281
- this.flags = F.Watching | F.RecursedCheck;
1282
- const prevSettler = activeNetwork;
1283
- activeNetwork = this;
1284
- try {
1285
- ++cycle;
1286
- ++runDepth;
1287
- ++batchDepth;
1288
- try {
1289
- this.body(dirty, this._handle);
1290
- }
1291
- finally {
1292
- if (!--batchDepth)
1293
- flush();
1294
- }
1295
- }
1296
- finally {
1297
- --runDepth;
1298
- activeNetwork = prevSettler;
1299
- this.flags &= ~F.RecursedCheck;
1300
- this.lastValues.clear();
1301
- let l = this.deps;
1302
- while (l !== undefined) {
1303
- const cell = l.dep;
1304
- this.lastValues.set(cell, cell.peek());
1305
- l = l.nextDep;
1306
- }
1307
- }
1308
- this.pending = false;
1309
- }
1310
- flush() {
1311
- if (this.disposed)
1312
- return;
1313
- if (this.flags & F.RecursedCheck) {
1314
- throw new Error("network: flush() called from inside body — would recurse infinitely. " +
1315
- "Return from the body and let the next dep change drive the next fire.");
1733
+ };
1734
+ const linkDeps = (cells) => {
1735
+ let tail = node.deps;
1736
+ if (tail !== undefined)
1737
+ while (tail.nextDep !== undefined)
1738
+ tail = tail.nextDep;
1739
+ node.depsTail = tail;
1740
+ for (const s of cells) {
1741
+ if (depsSet.has(s))
1742
+ continue;
1743
+ depsSet.add(s);
1744
+ link(s, node, ++ownCycle);
1316
1745
  }
1317
- this._runBody(this._computeDirty());
1318
- }
1319
- subscribe(cells) {
1320
- if (this.disposed)
1321
- return;
1322
- this._linkBatch(cells);
1323
- }
1324
- unsubscribe(cells) {
1325
- if (this.disposed)
1326
- return;
1327
- const set = this._depsSet;
1746
+ };
1747
+ const unlinkDeps = (cells) => {
1328
1748
  for (const s of cells) {
1329
- if (!set.has(s))
1749
+ if (!depsSet.has(s))
1330
1750
  continue;
1331
- set.delete(s);
1332
- let l = this.deps;
1333
- while (l !== undefined) {
1751
+ depsSet.delete(s);
1752
+ for (let l = node.deps; l !== undefined; l = l.nextDep) {
1334
1753
  if (l.dep === s) {
1335
- unlink(l, this);
1754
+ unlink(l, node);
1336
1755
  break;
1337
1756
  }
1338
- l = l.nextDep;
1339
1757
  }
1340
1758
  }
1341
- }
1342
- _linkBatch(cells) {
1343
- const set = this._depsSet;
1344
- let tail = this.deps;
1345
- if (tail !== undefined) {
1346
- while (tail.nextDep !== undefined)
1347
- tail = tail.nextDep;
1759
+ };
1760
+ const handle = {
1761
+ dispose: () => {
1762
+ if (disposed)
1763
+ return;
1764
+ disposed = true;
1765
+ node._unwatched();
1766
+ lastValues.clear();
1767
+ },
1768
+ flush: () => {
1769
+ if (disposed)
1770
+ return;
1771
+ // RecursedCheck doubles as the "body running" guard.
1772
+ if (node.flags & F.RecursedCheck) {
1773
+ throw new Error("network: flush() called from inside body — would recurse infinitely.");
1774
+ }
1775
+ batch(() => node._invoke());
1776
+ },
1777
+ subscribe: (...cells) => {
1778
+ if (!disposed)
1779
+ linkDeps(cells);
1780
+ },
1781
+ unsubscribe: (...cells) => {
1782
+ if (!disposed)
1783
+ unlinkDeps(cells);
1784
+ },
1785
+ };
1786
+ // The Effect body: hand the changed subset to the user body, then re-snapshot
1787
+ // the deps for the next fire.
1788
+ const run = () => {
1789
+ const dirty = computeDirty();
1790
+ try {
1791
+ body(dirty, handle);
1348
1792
  }
1349
- this.depsTail = tail;
1350
- for (const s of cells) {
1351
- if (set.has(s))
1352
- continue;
1353
- set.add(s);
1354
- link(s, this, ++this._ownCycle);
1793
+ finally {
1794
+ lastValues.clear();
1795
+ for (let l = node.deps; l !== undefined; l = l.nextDep) {
1796
+ const c = l.dep;
1797
+ lastValues.set(c, c.peek());
1798
+ }
1355
1799
  }
1356
- }
1357
- }
1358
- /** Build a reactive sub-DAG node with explicit topology. The body fires
1359
- * when any subscribed dep changes (`dirty` = the changed subset), runs
1360
- * inside `batch()`, and self-excludes its own writes. Topology is the
1361
- * deps array + later subscribe/unsubscribe (body reads add no deps).
1362
- * `flush()` from inside the body throws; `manual: true` defers
1363
- * auto-firing so only `flush()` advances. */
1364
- export function network(
1365
- // biome-ignore lint/suspicious/noExplicitAny: deps come in many flavours
1366
- deps, body, opts) {
1367
- const node = new _NetworkNode(body, opts?.manual ?? false);
1368
- const handle = {
1369
- dispose: () => node._unwatched(),
1370
- flush: () => node.flush(),
1371
- subscribe: (...cells) => node.subscribe(cells),
1372
- unsubscribe: (...cells) => node.unsubscribe(cells),
1373
1800
  };
1374
- node._initWithHandle(handle, deps);
1801
+ node = new Effect(run, EM.NoTrack | EM.Exclude | (opts?.manual ? EM.Manual : EM.Sync));
1802
+ linkDeps(deps);
1803
+ batch(() => node._invoke()); // first fire (lastValues empty ⇒ EMPTY_DIRTY)
1375
1804
  return handle;
1376
1805
  }
1377
- // MISC stuff used by a few places, to revisit...
1378
1806
  // ── value-class authoring helpers ──────────────────────────────────
1379
- //
1380
- // `field`/`cachedDerive` are the two getter forms a value class declares.
1381
- // The choice between them IS the local declaration of writability at each
1382
- // getter (mirroring `: this` invertible method returns). For arbitrary
1807
+ // `fieldLens`/`cachedDerive` are the two getter forms a value class declares;
1808
+ // the choice between them is the local declaration of writability. For arbitrary
1383
1809
  // cached views, use `lazy()` directly.
1384
- /** Bidirectional field lens onto `parent.value[key]`; write spread-
1385
- * replaces the composite. Cached per (instance, key). Return type is
1386
- * conditional: `Writable<Cls>` on a writable parent, bare `Cls` on RO.
1810
+ /** Bidirectional field lens onto `parent.value[key]` (write spread-replaces),
1811
+ * cached per (instance, key). `Writable<Cls>` on a writable parent, bare `Cls` on RO.
1387
1812
  *
1388
- * get x() { return field(this, "x", Num); } */
1389
- export function field(parent, key, Cls) {
1390
- return lazy(parent, key, () => Cell.fieldOf(parent, key, Cls));
1813
+ * get x() { return fieldLens(this, "x", Num); } */
1814
+ export function fieldLens(parent, key, Cls) {
1815
+ return lazy(parent, key, () => fieldOf(parent, key, Cls));
1391
1816
  }
1392
1817
  /** Read-only derived view via `Cls.derive(parent, fn)`, memoized per
1393
- * (instance, key); always bare `Cls` (RO). The cache is the point — the
1394
- * getter form, not a new kind of cell.
1818
+ * (instance, key). The cache is the point.
1395
1819
  *
1396
1820
  * get magnitude() {
1397
1821
  * return cachedDerive(this, "magnitude", Num, v => Math.hypot(v.x, v.y));
@@ -1401,12 +1825,8 @@ export function cachedDerive(parent, key, Cls, fn) {
1401
1825
  // biome-ignore lint/suspicious/noExplicitAny: variance escape on Cls.derive
1402
1826
  return lazy(parent, key, () => Cls.derive(parent, fn));
1403
1827
  }
1404
- /** Every cell `s` transitively depends on, including itself. Raw cells
1405
- * return `{s}`; lens chains return the chain plus all parents. BFS,
1406
- * peeking each Computed to populate deps; the `seen` set breaks cycles.
1407
- * Used by `Propagators` to expand declared reads into their transitive
1408
- * parent set. Inspection is safe: it only reads engine state and peeks
1409
- * `.value` (idempotent for lazy Computeds). */
1828
+ /** Every cell `s` transitively depends on, including itself (BFS, peeking each
1829
+ * computed to populate deps; `seen` breaks cycles). */
1410
1830
  export function transitiveDeps(s) {
1411
1831
  const seen = new Set();
1412
1832
  const queue = [s];
@@ -1415,7 +1835,6 @@ export function transitiveDeps(s) {
1415
1835
  if (seen.has(cur))
1416
1836
  continue;
1417
1837
  seen.add(cur);
1418
- // Cast to reach engine fields the typed Cell<T> shape doesn't surface.
1419
1838
  const c = cur;
1420
1839
  if (c.getter !== undefined) {
1421
1840
  void cur.value;