@esportsplus/reactivity 0.31.3 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +43 -6
  2. package/bench/cellx.bench.ts +61 -0
  3. package/bench/dynamic.bench.ts +103 -0
  4. package/bench/kairo.bench.ts +368 -0
  5. package/bench/lib/reactive-adapter.ts +51 -0
  6. package/bench/molbench.bench.ts +75 -0
  7. package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
  8. package/build/compiler/array.d.ts +1 -1
  9. package/build/compiler/constants.d.ts +7 -6
  10. package/build/compiler/constants.js +6 -7
  11. package/build/compiler/object.d.ts +1 -1
  12. package/build/compiler/object.js +1 -2
  13. package/build/compiler/primitives.d.ts +1 -1
  14. package/build/constants.d.ts +3 -1
  15. package/build/constants.js +3 -1
  16. package/build/reactive/array.d.ts +7 -3
  17. package/build/reactive/array.js +32 -24
  18. package/build/reactive/index.d.ts +2 -2
  19. package/build/reactive/index.js +1 -1
  20. package/build/reactive/object.d.ts +4 -4
  21. package/build/reactive/object.js +8 -23
  22. package/build/system.d.ts +15 -6
  23. package/build/system.js +432 -69
  24. package/build/types.d.ts +22 -4
  25. package/package.json +10 -7
  26. package/src/compiler/array.ts +1 -1
  27. package/src/compiler/constants.ts +8 -6
  28. package/src/compiler/index.ts +2 -1
  29. package/src/compiler/object.ts +2 -2
  30. package/src/compiler/plugins/vite.ts +1 -0
  31. package/src/compiler/primitives.ts +1 -1
  32. package/src/constants.ts +6 -2
  33. package/src/reactive/array.ts +50 -35
  34. package/src/reactive/index.ts +6 -6
  35. package/src/reactive/object.ts +16 -41
  36. package/src/system.ts +635 -80
  37. package/src/types.ts +38 -9
  38. package/test/async-computed.test.ts +409 -0
  39. package/test/async-errors.test.ts +146 -0
  40. package/test/async-hardening.test.ts +73 -0
  41. package/test/async-iterable.test.ts +221 -0
  42. package/test/async-nested.test.ts +19 -0
  43. package/test/deep-graphs.test.ts +215 -0
  44. package/{tests/effects.ts → test/effects.test.ts} +122 -0
  45. package/test/equals.test.ts +142 -0
  46. package/test/errors.test.ts +201 -0
  47. package/test/flush.test.ts +185 -0
  48. package/test/glitch-freedom.test.ts +115 -0
  49. package/test/invalidate.test.ts +147 -0
  50. package/test/lib/wait-for.ts +28 -0
  51. package/test/pending-writes.test.ts +139 -0
  52. package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
  53. package/test/read-dedup.test.ts +120 -0
  54. package/test/signal-selector.test.ts +187 -0
  55. package/{tests/system.ts → test/system.test.ts} +210 -19
  56. package/test/tsconfig.json +16 -0
  57. package/test/untrack.test.ts +146 -0
  58. package/vitest.config.ts +2 -2
  59. package/tests/async-computed.ts +0 -239
  60. /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
  61. /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
  62. /package/{tests → bench}/tsconfig.json +0 -0
  63. /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
  64. /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
  65. /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
  66. /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
  67. /package/{tests/objects.ts → test/reactive/objects.test.ts} +0 -0
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @esportsplus/reactivity
2
2
 
3
+ Project doc bundle: [docs/index.md](./docs/index.md) — rejected dead-ends, skip reasons, completed-work index.
4
+
3
5
  A fine-grained reactivity system with compile-time transformations. Write reactive code with natural JavaScript syntax while the compiler generates optimized signal-based code.
4
6
 
5
7
  ## Installation
@@ -221,22 +223,27 @@ let user = new ReactiveObject_1();
221
223
  | `reactive(() => expr)` | Creates a computed value (compile-time only) |
222
224
  | `reactive({...})` | Creates a reactive object with signals and computeds |
223
225
  | `reactive([...])` | Creates a reactive array |
224
- | `effect(fn)` | Runs a function that re-executes when dependencies change. Returns a dispose function |
226
+ | `effect(fn, onError?)` | Runs a function that re-executes when dependencies change. Returns a dispose function. Optional `onError` receives thrown or rejected errors |
225
227
  | `root(fn)` | Creates an untracked scope. If `fn` accepts an argument, a dispose function is provided |
226
228
  | `onCleanup(fn)` | Registers a cleanup function for the current effect/computed |
227
229
 
228
230
  ### Low-Level Functions
229
231
 
230
- These are typically only used by the transformer output:
232
+ These operate directly on signal/computed nodes the transformer emits them, and they can be called by hand:
231
233
 
232
234
  | Function | Description |
233
235
  |----------|-------------|
234
- | `signal(value)` | Creates a raw signal |
235
- | `computed(fn)` | Creates a raw computed |
236
- | `read(node)` | Reads a signal or computed value |
236
+ | `signal(value, equals?)` | Creates a raw signal. Optional `equals` comparator suppresses writes it deems equal (default `===`) |
237
+ | `computed(fn, equals?)` | Creates a raw computed. A `fn` returning a `Promise` or `AsyncIterable` becomes an async computed automatically (initial value `undefined`, unwrapped to `Settled<T>`). Optional `equals` comparator |
238
+ | `read(node)` | Reads a signal or computed value, subscribing the current scope |
237
239
  | `write(signal, value)` | Sets a signal value |
238
- | `asyncComputed(fn)` | Creates a signal that resolves an async computed. Initial value is `undefined` |
240
+ | `peek(node)` | Reads a signal/computed's current value **without** subscribing (returns an up-to-date value even for a dirty computed) |
241
+ | `untrack(fn)` | Runs `fn` without tracking dependencies; returns `fn`'s result |
242
+ | `batch(fn)` | Groups writes so dependent effects defer until `fn` returns; pair with `flush()` for a synchronous transaction |
243
+ | `flush()` | Synchronously settles all pending computed/effect updates |
239
244
  | `dispose(computed)` | Disposes a computed and its dependencies |
245
+ | `computed.invalidate(computed)` | Forces re-derivation of a computed on next read (re-dispatches an async computed's promise for a refetch) |
246
+ | `signal.selector(signal, key)` | Per-key selector: within a tracking scope, subscribes to whether `signal`'s value equals `key`, so only key-matching observers re-run on write (O(1) fan-out) |
240
247
 
241
248
  ### Type Guards
242
249
 
@@ -272,6 +279,8 @@ Symbol constants for type identification:
272
279
  |------|-------------|
273
280
  | `Signal<T>` | Signal node type |
274
281
  | `Computed<T>` | Computed node type |
282
+ | `Settled<T>` | Resolved value type of a computed (unwraps `Promise<T>`/`AsyncIterable<T>` to `T`) |
283
+ | `SelectorSignal<T>` | Signal variant backing `signal.selector` per-key subscriptions |
275
284
  | `Link` | Dependency graph link between nodes |
276
285
  | `Reactive<T>` | Utility type for inferring reactive object/array types |
277
286
  | `TransformResult` | Compiler transform output metadata |
@@ -306,6 +315,34 @@ All standard array methods (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`,
306
315
  | `splice` | `{ start, deleteCount, items }` | Array was spliced |
307
316
  | `unshift` | `{ items: T[] }` | Items were unshifted |
308
317
 
318
+ ## Architecture
319
+
320
+ - `src/system.ts` — the core signal/computed/effect engine: height-bucketed heap scheduler,
321
+ generation-version dependency dedup (read-version dedup), and iterative (non-recursive)
322
+ notify/update/dispose walks. Also supports async (Promise/AsyncIterable) computeds and
323
+ per-key selector signals (`signal.selector`).
324
+ - `src/constants.ts` — shared state bitmasks and symbol constants consumed by every other module.
325
+ - `src/types.ts` — shared `Signal`/`Computed`/`Link`/`Reactive` types.
326
+ - `src/reactive/index.ts`, `src/reactive/array.ts`, `src/reactive/object.ts` — the runtime
327
+ `reactive()` dispatch and the `ReactiveObject`/`ReactiveArray` classes it builds on.
328
+ - `src/compiler/*` — the build-time transform pipeline (`constants.ts`, `primitives.ts`,
329
+ `object.ts`, `array.ts`, `index.ts`) plus the `plugins/tsc.ts` and `plugins/vite.ts`
330
+ entrypoints that wire it into a build (see Transformer Plugins above).
331
+
332
+ ## Development
333
+
334
+ ```bash
335
+ pnpm install
336
+ pnpm build # tsc
337
+ pnpm test # vitest run
338
+ pnpm bench # vitest bench --run
339
+ ```
340
+
341
+ `agent:test` (`tsc --noEmit && vitest run`) and `agent:bench` (`vitest bench --run`) are the
342
+ CI-facing aliases of the same build/test/bench commands.
343
+
309
344
  ## License
310
345
 
311
346
  MIT
347
+
348
+ <!-- claude-code:readme-source-hash: 189167a17e9be8c6 -->
@@ -0,0 +1,61 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { assert, framework, ReactiveComputed } from './lib/reactive-adapter';
3
+
4
+
5
+ // Ported from milomg/js-reactivity-benchmark packages/core/src/benches/cellxBench.ts (MIT)
6
+
7
+ interface Layer {
8
+ prop1: ReactiveComputed<number>;
9
+ prop2: ReactiveComputed<number>;
10
+ prop3: ReactiveComputed<number>;
11
+ prop4: ReactiveComputed<number>;
12
+ }
13
+
14
+
15
+ function cellx(layers: number, before: readonly number[], after: readonly number[]) {
16
+ let start = {
17
+ prop1: framework.signal(1),
18
+ prop2: framework.signal(2),
19
+ prop3: framework.signal(3),
20
+ prop4: framework.signal(4)
21
+ };
22
+
23
+ let layer: Layer = start;
24
+
25
+ for (let i = layers; i > 0; i--) {
26
+ let m = layer;
27
+
28
+ layer = {
29
+ prop1: framework.computed(() => m.prop2.read()),
30
+ prop2: framework.computed(() => m.prop1.read() - m.prop3.read()),
31
+ prop3: framework.computed(() => m.prop2.read() + m.prop4.read()),
32
+ prop4: framework.computed(() => m.prop3.read())
33
+ };
34
+ }
35
+
36
+ let end = layer;
37
+
38
+ assert(end.prop1.read() === before[0], 'cellx: unexpected initial prop1');
39
+ assert(end.prop2.read() === before[1], 'cellx: unexpected initial prop2');
40
+ assert(end.prop3.read() === before[2], 'cellx: unexpected initial prop3');
41
+ assert(end.prop4.read() === before[3], 'cellx: unexpected initial prop4');
42
+
43
+ return framework.withBatch(() => {
44
+ start.prop1.write(4);
45
+ start.prop2.write(3);
46
+ start.prop3.write(2);
47
+ start.prop4.write(1);
48
+ }).then(() => {
49
+ assert(end.prop1.read() === after[0], 'cellx: unexpected final prop1');
50
+ assert(end.prop2.read() === after[1], 'cellx: unexpected final prop2');
51
+ assert(end.prop3.read() === after[2], 'cellx: unexpected final prop3');
52
+ assert(end.prop4.read() === after[3], 'cellx: unexpected final prop4');
53
+ });
54
+ }
55
+
56
+
57
+ describe('cellx', () => {
58
+ bench('cellx1000', async () => {
59
+ await framework.withBuild(() => cellx(1000, [-3, -6, -2, 2], [-2, -4, 2, 3]));
60
+ });
61
+ });
@@ -0,0 +1,103 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { assert, framework, ReactiveComputed } from './lib/reactive-adapter';
3
+
4
+
5
+ // Dynamic-graph workloads: dependency SETS change between runs (branch on a control
6
+ // signal), exercising link/unlink churn and the link pool
7
+
8
+ function dependencySwap() {
9
+ let a = [],
10
+ b = [],
11
+ computeds: ReactiveComputed<number>[] = [],
12
+ control = framework.signal(true);
13
+
14
+ for (let i = 0; i < 10; i++) {
15
+ a.push(framework.signal(i));
16
+ b.push(framework.signal(i * 2));
17
+ }
18
+
19
+ for (let i = 0; i < 20; i++) {
20
+ computeds.push(framework.computed(() => {
21
+ let source = control.read() ? a : b,
22
+ sum = 0;
23
+
24
+ for (let j = 0, n = source.length; j < n; j++) {
25
+ sum += source[j].read();
26
+ }
27
+
28
+ return sum;
29
+ }));
30
+ }
31
+
32
+ for (let i = 0, n = computeds.length; i < n; i++) {
33
+ let c = computeds[i];
34
+
35
+ framework.effect(() => c.read());
36
+ }
37
+
38
+ let flip = true;
39
+
40
+ return async () => {
41
+ flip = !flip;
42
+
43
+ await framework.withBatch(() => {
44
+ control.write(flip);
45
+ });
46
+
47
+ let expected = flip ? 45 : 90;
48
+
49
+ for (let i = 0, n = computeds.length; i < n; i++) {
50
+ assert(computeds[i].read() === expected, 'dynamic swap: unexpected sum');
51
+ }
52
+ };
53
+ }
54
+
55
+ function movingBranchPoint() {
56
+ let base = framework.signal(100),
57
+ chain: ReactiveComputed<number>[] = [],
58
+ cut = framework.signal(0),
59
+ depth = 30,
60
+ source = framework.signal(1);
61
+
62
+ // Each node conditionally links `base` (a signal — safe to drop, signals never
63
+ // auto-dispose) while ALWAYS reading its predecessor so the chain stays alive
64
+ chain.push(framework.computed(() => (cut.read() > 0 ? base.read() : 0) + source.read()));
65
+
66
+ for (let i = 1; i < depth; i++) {
67
+ let prev = chain[i - 1];
68
+
69
+ chain.push(framework.computed(() => (cut.read() > i ? base.read() : 0) + prev.read() + 1));
70
+ }
71
+
72
+ let tail = chain[depth - 1];
73
+
74
+ framework.effect(() => tail.read());
75
+
76
+ return async () => {
77
+ await framework.withBatch(() => {
78
+ cut.write(15);
79
+ });
80
+
81
+ assert(tail.read() === 15 * 100 + 1 + (depth - 1), 'dynamic branch: unexpected tail after cut');
82
+
83
+ await framework.withBatch(() => {
84
+ cut.write(0);
85
+ });
86
+
87
+ assert(tail.read() === 1 + (depth - 1), 'dynamic branch: unexpected tail after restore');
88
+ };
89
+ }
90
+
91
+
92
+ describe('dynamic graphs', () => {
93
+ let branchRun = framework.withBuild(movingBranchPoint),
94
+ swapRun = framework.withBuild(dependencySwap);
95
+
96
+ bench('dependency set swap (20 computeds x 10 deps)', async () => {
97
+ await swapRun();
98
+ });
99
+
100
+ bench('moving branch point (30-deep chain re-link)', async () => {
101
+ await branchRun();
102
+ });
103
+ });
@@ -0,0 +1,368 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { assert, framework, ReactiveComputed } from './lib/reactive-adapter';
3
+
4
+
5
+ // Ported from milomg/js-reactivity-benchmark packages/core/src/benches/kairo/* (MIT)
6
+
7
+ function avoidablePropagation() {
8
+ let head = framework.signal(0),
9
+ computed1 = framework.computed(() => head.read()),
10
+ computed2 = framework.computed(() => (computed1.read(), 0)),
11
+ computed3 = framework.computed(() => (busy(), computed2.read() + 1)),
12
+ computed4 = framework.computed(() => computed3.read() + 2),
13
+ computed5 = framework.computed(() => computed4.read() + 3);
14
+
15
+ framework.effect(() => {
16
+ computed5.read();
17
+ busy();
18
+ });
19
+
20
+ return async () => {
21
+ await framework.withBatch(() => {
22
+ head.write(1);
23
+ });
24
+
25
+ assert(computed5.read() === 6, 'kairo avoidable: unexpected terminal value');
26
+
27
+ for (let i = 0; i < 1000; i++) {
28
+ await framework.withBatch(() => {
29
+ head.write(i);
30
+ });
31
+
32
+ assert(computed5.read() === 6, 'kairo avoidable: propagation was not avoided');
33
+ }
34
+ };
35
+ }
36
+
37
+ function broadPropagation() {
38
+ let counter = { count: 0 },
39
+ head = framework.signal(0),
40
+ last: ReactiveComputed<number> = head;
41
+
42
+ for (let i = 0; i < 50; i++) {
43
+ let current = framework.computed(() => head.read() + i),
44
+ current2 = framework.computed(() => current.read() + 1);
45
+
46
+ framework.effect(() => {
47
+ current2.read();
48
+ counter.count++;
49
+ });
50
+
51
+ last = current2;
52
+ }
53
+
54
+ return async () => {
55
+ await framework.withBatch(() => {
56
+ head.write(1);
57
+ });
58
+
59
+ counter.count = 0;
60
+
61
+ for (let i = 0; i < 50; i++) {
62
+ await framework.withBatch(() => {
63
+ head.write(i);
64
+ });
65
+
66
+ assert(last.read() === i + 50, 'kairo broad: unexpected tail value');
67
+ }
68
+
69
+ assert(counter.count === 50 * 50, 'kairo broad: unexpected effect run count');
70
+ };
71
+ }
72
+
73
+ function busy() {
74
+ let a = 0;
75
+
76
+ for (let i = 0; i < 100; i++) {
77
+ a++;
78
+ }
79
+
80
+ return a;
81
+ }
82
+
83
+ function deepPropagation() {
84
+ let counter = { count: 0 },
85
+ head = framework.signal(0),
86
+ len = 50,
87
+ current: ReactiveComputed<number> = head;
88
+
89
+ for (let i = 0; i < len; i++) {
90
+ let c = current;
91
+
92
+ current = framework.computed(() => c.read() + 1);
93
+ }
94
+
95
+ framework.effect(() => {
96
+ current.read();
97
+ counter.count++;
98
+ });
99
+
100
+ return async () => {
101
+ let iter = 50;
102
+
103
+ await framework.withBatch(() => {
104
+ head.write(1);
105
+ });
106
+
107
+ counter.count = 0;
108
+
109
+ for (let i = 0; i < iter; i++) {
110
+ await framework.withBatch(() => {
111
+ head.write(i);
112
+ });
113
+
114
+ assert(current.read() === len + i, 'kairo deep: unexpected tail value');
115
+ }
116
+
117
+ assert(counter.count === iter, 'kairo deep: unexpected effect run count');
118
+ };
119
+ }
120
+
121
+ function diamond() {
122
+ let counter = { count: 0 },
123
+ current: ReactiveComputed<number>[] = [],
124
+ head = framework.signal(0),
125
+ width = 5;
126
+
127
+ for (let i = 0; i < width; i++) {
128
+ current.push(framework.computed(() => head.read() + 1));
129
+ }
130
+
131
+ let sum = framework.computed(() => current.map((x) => x.read()).reduce((a, b) => a + b, 0));
132
+
133
+ framework.effect(() => {
134
+ sum.read();
135
+ counter.count++;
136
+ });
137
+
138
+ return async () => {
139
+ await framework.withBatch(() => {
140
+ head.write(1);
141
+ });
142
+
143
+ assert(sum.read() === 2 * width, 'kairo diamond: unexpected initial sum');
144
+
145
+ counter.count = 0;
146
+
147
+ for (let i = 0; i < 500; i++) {
148
+ await framework.withBatch(() => {
149
+ head.write(i);
150
+ });
151
+
152
+ assert(sum.read() === (i + 1) * width, 'kairo diamond: unexpected sum');
153
+ }
154
+
155
+ assert(counter.count === 500, 'kairo diamond: unexpected effect run count');
156
+ };
157
+ }
158
+
159
+ function mux() {
160
+ let heads = new Array(100).fill(null).map(() => framework.signal(0));
161
+
162
+ let muxed = framework.computed(() => Object.fromEntries(heads.map((h) => h.read()).entries()));
163
+
164
+ let splited = heads
165
+ .map((_, index) => framework.computed(() => muxed.read()[index]))
166
+ .map((x) => framework.computed(() => x.read() + 1));
167
+
168
+ for (let i = 0, n = splited.length; i < n; i++) {
169
+ let x = splited[i];
170
+
171
+ framework.effect(() => x.read());
172
+ }
173
+
174
+ return async () => {
175
+ for (let i = 0; i < 10; i++) {
176
+ await framework.withBatch(() => {
177
+ heads[i].write(i);
178
+ });
179
+
180
+ assert(splited[i].read() === i + 1, 'kairo mux: unexpected split value');
181
+ }
182
+
183
+ for (let i = 0; i < 10; i++) {
184
+ await framework.withBatch(() => {
185
+ heads[i].write(i * 2);
186
+ });
187
+
188
+ assert(splited[i].read() === i * 2 + 1, 'kairo mux: unexpected split value');
189
+ }
190
+ };
191
+ }
192
+
193
+ function repeatedObservers() {
194
+ let counter = { count: 0 },
195
+ head = framework.signal(0),
196
+ size = 30;
197
+
198
+ let current = framework.computed(() => {
199
+ let result = 0;
200
+
201
+ for (let i = 0; i < size; i++) {
202
+ result += head.read();
203
+ }
204
+
205
+ return result;
206
+ });
207
+
208
+ framework.effect(() => {
209
+ current.read();
210
+ counter.count++;
211
+ });
212
+
213
+ return async () => {
214
+ await framework.withBatch(() => {
215
+ head.write(1);
216
+ });
217
+
218
+ assert(current.read() === size, 'kairo repeated: unexpected initial value');
219
+
220
+ counter.count = 0;
221
+
222
+ for (let i = 0; i < 100; i++) {
223
+ await framework.withBatch(() => {
224
+ head.write(i);
225
+ });
226
+
227
+ assert(current.read() === i * size, 'kairo repeated: unexpected value');
228
+ }
229
+
230
+ assert(counter.count === 100, 'kairo repeated: unexpected effect run count');
231
+ };
232
+ }
233
+
234
+ function triangle() {
235
+ let counter = { count: 0 },
236
+ head = framework.signal(0),
237
+ list: ReactiveComputed<number>[] = [],
238
+ width = 10,
239
+ current: ReactiveComputed<number> = head;
240
+
241
+ for (let i = 0; i < width; i++) {
242
+ let c = current;
243
+
244
+ list.push(current);
245
+ current = framework.computed(() => c.read() + 1);
246
+ }
247
+
248
+ let sum = framework.computed(() => list.map((x) => x.read()).reduce((a, b) => a + b, 0));
249
+
250
+ framework.effect(() => {
251
+ sum.read();
252
+ counter.count++;
253
+ });
254
+
255
+ return async () => {
256
+ let constant = (width * (width + 1)) / 2;
257
+
258
+ await framework.withBatch(() => {
259
+ head.write(1);
260
+ });
261
+
262
+ assert(sum.read() === constant, 'kairo triangle: unexpected initial sum');
263
+
264
+ counter.count = 0;
265
+
266
+ for (let i = 0; i < 100; i++) {
267
+ await framework.withBatch(() => {
268
+ head.write(i);
269
+ });
270
+
271
+ assert(sum.read() === constant - width + i * width, 'kairo triangle: unexpected sum');
272
+ }
273
+
274
+ assert(counter.count === 100, 'kairo triangle: unexpected effect run count');
275
+ };
276
+ }
277
+
278
+ function unstable() {
279
+ let counter = { count: 0 },
280
+ head = framework.signal(0);
281
+
282
+ let double = framework.computed(() => head.read() * 2);
283
+
284
+ let inverse = framework.computed(() => -head.read());
285
+
286
+ let current = framework.computed(() => {
287
+ let result = 0;
288
+
289
+ for (let i = 0; i < 20; i++) {
290
+ result += head.read() % 2 ? double.read() : inverse.read();
291
+ }
292
+
293
+ return result;
294
+ });
295
+
296
+ // Keepers: this system auto-disposes a computed on last-sub unlink, so the
297
+ // inactive branch would go permanently stale without a live subscriber
298
+ framework.effect(() => double.read());
299
+
300
+ framework.effect(() => inverse.read());
301
+
302
+ framework.effect(() => {
303
+ current.read();
304
+ counter.count++;
305
+ });
306
+
307
+ return async () => {
308
+ await framework.withBatch(() => {
309
+ head.write(1);
310
+ });
311
+
312
+ assert(current.read() === 40, 'kairo unstable: unexpected initial value');
313
+
314
+ counter.count = 0;
315
+
316
+ for (let i = 0; i < 100; i++) {
317
+ await framework.withBatch(() => {
318
+ head.write(i);
319
+ });
320
+ }
321
+
322
+ assert(counter.count === 100, 'kairo unstable: unexpected effect run count');
323
+ };
324
+ }
325
+
326
+
327
+ describe('kairo', () => {
328
+ let avoidableRun = framework.withBuild(avoidablePropagation),
329
+ broadRun = framework.withBuild(broadPropagation),
330
+ deepRun = framework.withBuild(deepPropagation),
331
+ diamondRun = framework.withBuild(diamond),
332
+ muxRun = framework.withBuild(mux),
333
+ repeatedRun = framework.withBuild(repeatedObservers),
334
+ triangleRun = framework.withBuild(triangle),
335
+ unstableRun = framework.withBuild(unstable);
336
+
337
+ bench('avoidablePropagation', async () => {
338
+ await avoidableRun();
339
+ });
340
+
341
+ bench('broad', async () => {
342
+ await broadRun();
343
+ });
344
+
345
+ bench('deep', async () => {
346
+ await deepRun();
347
+ });
348
+
349
+ bench('diamond', async () => {
350
+ await diamondRun();
351
+ });
352
+
353
+ bench('mux', async () => {
354
+ await muxRun();
355
+ });
356
+
357
+ bench('repeatedObservers', async () => {
358
+ await repeatedRun();
359
+ });
360
+
361
+ bench('triangle', async () => {
362
+ await triangleRun();
363
+ });
364
+
365
+ bench('unstable', async () => {
366
+ await unstableRun();
367
+ });
368
+ });
@@ -0,0 +1,51 @@
1
+ import { computed, effect, read, root, signal, write } from '~/system';
2
+
3
+ import type { Settled } from '~/types';
4
+
5
+
6
+ interface ReactiveComputed<T> {
7
+ read: () => T;
8
+ }
9
+
10
+ interface ReactiveSignal<T> extends ReactiveComputed<T> {
11
+ write: (value: T) => void;
12
+ }
13
+
14
+
15
+ // Mirrors js-reactivity-benchmark's ReactiveFramework so a future external-package swap is mechanical
16
+ const framework = {
17
+ computed: <T>(fn: () => T): ReactiveComputed<Settled<T>> => {
18
+ let node = computed(fn);
19
+
20
+ return {
21
+ read: () => read(node)
22
+ };
23
+ },
24
+ effect: (fn: () => void): VoidFunction => effect(fn),
25
+ signal: <T>(value: T): ReactiveSignal<T> => {
26
+ let node = signal(value);
27
+
28
+ return {
29
+ read: () => read(node),
30
+ write: (v: T) => write(node, v)
31
+ };
32
+ },
33
+ withBatch: async (fn: () => void): Promise<void> => {
34
+ fn();
35
+
36
+ // Propagation is microtask-scheduled (schedule/stabilize) — two ticks cover a reschedule
37
+ await Promise.resolve();
38
+ await Promise.resolve();
39
+ },
40
+ withBuild: <T>(fn: () => T): T => root(fn)
41
+ };
42
+
43
+ const assert = (condition: boolean, message: string): void => {
44
+ if (!condition) {
45
+ throw new Error(`bench: ${message}`);
46
+ }
47
+ };
48
+
49
+
50
+ export { assert, framework };
51
+ export type { ReactiveComputed, ReactiveSignal };