@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.
- package/README.md +43 -6
- package/bench/cellx.bench.ts +61 -0
- package/bench/dynamic.bench.ts +103 -0
- package/bench/kairo.bench.ts +368 -0
- package/bench/lib/reactive-adapter.ts +51 -0
- package/bench/molbench.bench.ts +75 -0
- package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
- package/build/compiler/array.d.ts +1 -1
- package/build/compiler/constants.d.ts +7 -6
- package/build/compiler/constants.js +6 -7
- package/build/compiler/object.d.ts +1 -1
- package/build/compiler/object.js +1 -2
- package/build/compiler/primitives.d.ts +1 -1
- package/build/constants.d.ts +3 -1
- package/build/constants.js +3 -1
- package/build/reactive/array.d.ts +7 -3
- package/build/reactive/array.js +32 -24
- package/build/reactive/index.d.ts +2 -2
- package/build/reactive/index.js +1 -1
- package/build/reactive/object.d.ts +4 -4
- package/build/reactive/object.js +8 -23
- package/build/system.d.ts +15 -6
- package/build/system.js +432 -69
- package/build/types.d.ts +22 -4
- package/package.json +10 -7
- package/src/compiler/array.ts +1 -1
- package/src/compiler/constants.ts +8 -6
- package/src/compiler/index.ts +2 -1
- package/src/compiler/object.ts +2 -2
- package/src/compiler/plugins/vite.ts +1 -0
- package/src/compiler/primitives.ts +1 -1
- package/src/constants.ts +6 -2
- package/src/reactive/array.ts +50 -35
- package/src/reactive/index.ts +6 -6
- package/src/reactive/object.ts +16 -41
- package/src/system.ts +635 -80
- package/src/types.ts +38 -9
- package/test/async-computed.test.ts +409 -0
- package/test/async-errors.test.ts +146 -0
- package/test/async-hardening.test.ts +73 -0
- package/test/async-iterable.test.ts +221 -0
- package/test/async-nested.test.ts +19 -0
- package/test/deep-graphs.test.ts +215 -0
- package/{tests/effects.ts → test/effects.test.ts} +122 -0
- package/test/equals.test.ts +142 -0
- package/test/errors.test.ts +201 -0
- package/test/flush.test.ts +185 -0
- package/test/glitch-freedom.test.ts +115 -0
- package/test/invalidate.test.ts +147 -0
- package/test/lib/wait-for.ts +28 -0
- package/test/pending-writes.test.ts +139 -0
- package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
- package/test/read-dedup.test.ts +120 -0
- package/test/signal-selector.test.ts +187 -0
- package/{tests/system.ts → test/system.test.ts} +210 -19
- package/test/tsconfig.json +16 -0
- package/test/untrack.test.ts +146 -0
- package/vitest.config.ts +2 -2
- package/tests/async-computed.ts +0 -239
- /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
- /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
- /package/{tests → bench}/tsconfig.json +0 -0
- /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
- /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
- /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
- /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
- /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
|
|
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
|
-
| `
|
|
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 };
|