@pyreon/reactivity 0.24.5 → 0.24.6
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/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- package/src/watch.ts +0 -68
package/src/tests/batch.test.ts
DELETED
|
@@ -1,751 +0,0 @@
|
|
|
1
|
-
import { batch, nextTick } from '../batch'
|
|
2
|
-
import { effect } from '../effect'
|
|
3
|
-
import { signal } from '../signal'
|
|
4
|
-
|
|
5
|
-
describe('batch', () => {
|
|
6
|
-
test('defers notifications until end of batch', () => {
|
|
7
|
-
const a = signal(1)
|
|
8
|
-
const b = signal(2)
|
|
9
|
-
let runs = 0
|
|
10
|
-
effect(() => {
|
|
11
|
-
a()
|
|
12
|
-
b()
|
|
13
|
-
runs++
|
|
14
|
-
})
|
|
15
|
-
expect(runs).toBe(1) // initial run
|
|
16
|
-
|
|
17
|
-
batch(() => {
|
|
18
|
-
a.set(10)
|
|
19
|
-
b.set(20)
|
|
20
|
-
})
|
|
21
|
-
// should only re-run once despite two updates
|
|
22
|
-
expect(runs).toBe(2)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
test('effect sees final values after batch', () => {
|
|
26
|
-
const s = signal(0)
|
|
27
|
-
let seen = 0
|
|
28
|
-
effect(() => {
|
|
29
|
-
seen = s()
|
|
30
|
-
})
|
|
31
|
-
batch(() => {
|
|
32
|
-
s.set(1)
|
|
33
|
-
s.set(2)
|
|
34
|
-
s.set(3)
|
|
35
|
-
})
|
|
36
|
-
expect(seen).toBe(3)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('nested batches flush at outermost end', () => {
|
|
40
|
-
const s = signal(0)
|
|
41
|
-
let runs = 0
|
|
42
|
-
effect(() => {
|
|
43
|
-
s()
|
|
44
|
-
runs++
|
|
45
|
-
})
|
|
46
|
-
expect(runs).toBe(1)
|
|
47
|
-
|
|
48
|
-
batch(() => {
|
|
49
|
-
batch(() => {
|
|
50
|
-
s.set(1)
|
|
51
|
-
s.set(2)
|
|
52
|
-
})
|
|
53
|
-
s.set(3)
|
|
54
|
-
})
|
|
55
|
-
expect(runs).toBe(2)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('batch propagates exceptions and still flushes', () => {
|
|
59
|
-
const s = signal(0)
|
|
60
|
-
let seen = 0
|
|
61
|
-
effect(() => {
|
|
62
|
-
seen = s()
|
|
63
|
-
})
|
|
64
|
-
expect(seen).toBe(0)
|
|
65
|
-
|
|
66
|
-
expect(() => {
|
|
67
|
-
batch(() => {
|
|
68
|
-
s.set(42)
|
|
69
|
-
throw new Error('boom')
|
|
70
|
-
})
|
|
71
|
-
}).toThrow('boom')
|
|
72
|
-
|
|
73
|
-
// The batch should still have flushed notifications in the finally block
|
|
74
|
-
expect(seen).toBe(42)
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
test('batch with no signal changes is a no-op', () => {
|
|
78
|
-
let runs = 0
|
|
79
|
-
const s = signal(0)
|
|
80
|
-
effect(() => {
|
|
81
|
-
s()
|
|
82
|
-
runs++
|
|
83
|
-
})
|
|
84
|
-
expect(runs).toBe(1)
|
|
85
|
-
|
|
86
|
-
batch(() => {
|
|
87
|
-
// no updates
|
|
88
|
-
})
|
|
89
|
-
expect(runs).toBe(1)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('batch deduplicates same subscriber across multiple signals', () => {
|
|
93
|
-
const a = signal(1)
|
|
94
|
-
const b = signal(2)
|
|
95
|
-
let runs = 0
|
|
96
|
-
effect(() => {
|
|
97
|
-
a()
|
|
98
|
-
b()
|
|
99
|
-
runs++
|
|
100
|
-
})
|
|
101
|
-
expect(runs).toBe(1)
|
|
102
|
-
|
|
103
|
-
batch(() => {
|
|
104
|
-
a.set(10)
|
|
105
|
-
b.set(20)
|
|
106
|
-
a.set(100) // same signal updated again
|
|
107
|
-
})
|
|
108
|
-
// Effect should only run once despite 3 updates
|
|
109
|
-
expect(runs).toBe(2)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('notifications enqueued during flush land in alternate set', () => {
|
|
113
|
-
const a = signal(0)
|
|
114
|
-
const b = signal(0)
|
|
115
|
-
const log: string[] = []
|
|
116
|
-
|
|
117
|
-
effect(() => {
|
|
118
|
-
const val = a()
|
|
119
|
-
log.push(`a=${val}`)
|
|
120
|
-
// When a changes, update b inside the effect (enqueue during flush)
|
|
121
|
-
if (val > 0) b.set(val * 10)
|
|
122
|
-
})
|
|
123
|
-
effect(() => {
|
|
124
|
-
log.push(`b=${b()}`)
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
batch(() => {
|
|
128
|
-
a.set(1)
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
expect(log).toContain('a=1')
|
|
132
|
-
expect(log).toContain('b=10')
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test('nextTick resolves after microtasks flush', async () => {
|
|
136
|
-
const s = signal(0)
|
|
137
|
-
let seen = 0
|
|
138
|
-
effect(() => {
|
|
139
|
-
seen = s()
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
s.set(42)
|
|
143
|
-
await nextTick()
|
|
144
|
-
expect(seen).toBe(42)
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// ─── Regression: cascade-depth-asymmetry double-fire ─────────────────────────
|
|
149
|
-
//
|
|
150
|
-
// Pre-fix: batch flush used two pre-allocated Sets (setA, setB) swapped on
|
|
151
|
-
// each round. Cascade notifications enqueued during round 1 went to setB and
|
|
152
|
-
// were processed in round 2. When a single subscriber had BOTH a 0-hop signal
|
|
153
|
-
// dependency AND a 1-hop indirection (computed, createSelector predicate,
|
|
154
|
-
// derived signal) and BOTH paths were triggered in the same batch, the
|
|
155
|
-
// subscriber was queued in DIFFERENT rounds — once via the direct enqueue
|
|
156
|
-
// (round 1) and once via the cascade (round 2). Cross-round Set-dedup didn't
|
|
157
|
-
// work because each round used a fresh Set; the subscriber fired twice.
|
|
158
|
-
//
|
|
159
|
-
// In real usage: list rendering with N items each tracking `isSelected(item.id)`
|
|
160
|
-
// plus a shared signal — every batched selection change scaled to O(N)
|
|
161
|
-
// wasted re-runs.
|
|
162
|
-
//
|
|
163
|
-
// Post-fix: single-Set iteration with cascade-during-iteration. Set-dedup
|
|
164
|
-
// handles ALL cases (diamond, multi-dep selector, self-modifying effect)
|
|
165
|
-
// uniformly — adding an entry already in the Set is a no-op; adding a new
|
|
166
|
-
// entry during iteration is visited exactly once.
|
|
167
|
-
describe('batch — cascade-depth-asymmetry dedup (regression)', () => {
|
|
168
|
-
test('subscriber with 0-hop + 1-hop deps both written in batch fires once', async () => {
|
|
169
|
-
const { computed } = await import('../computed')
|
|
170
|
-
const source = signal(1)
|
|
171
|
-
const other = signal(0)
|
|
172
|
-
// 1-hop indirection via a computed (mirrors createSelector's predicate shape)
|
|
173
|
-
const isTarget = computed(() => Object.is(source(), 2), { equals: (a, b) => a === b })
|
|
174
|
-
|
|
175
|
-
let runs = 0
|
|
176
|
-
effect(() => {
|
|
177
|
-
isTarget()
|
|
178
|
-
other()
|
|
179
|
-
runs++
|
|
180
|
-
})
|
|
181
|
-
runs = 0
|
|
182
|
-
|
|
183
|
-
batch(() => {
|
|
184
|
-
source.set(2)
|
|
185
|
-
other.set(1)
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
expect(runs).toBe(1)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
test('diamond cascade still dedupes correctly (a → b, c → d → effect)', async () => {
|
|
192
|
-
const { computed } = await import('../computed')
|
|
193
|
-
const a = signal(0)
|
|
194
|
-
const b = computed(() => a() * 2)
|
|
195
|
-
const c = computed(() => a() + 1)
|
|
196
|
-
const d = computed(() => b() + c())
|
|
197
|
-
|
|
198
|
-
let runs = 0
|
|
199
|
-
effect(() => {
|
|
200
|
-
d()
|
|
201
|
-
runs++
|
|
202
|
-
})
|
|
203
|
-
runs = 0
|
|
204
|
-
|
|
205
|
-
a.set(5)
|
|
206
|
-
expect(runs).toBe(1)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
// Scale-up of Test 1: 50 list items each tracking the SAME 1-hop indirection
|
|
210
|
-
// (`isTarget` memo) + the same shared signal. This is the real-world list-
|
|
211
|
-
// rendering shape that motivated the fix. Pre-fix: each effect fired 2× →
|
|
212
|
-
// 100 total runs per click. Post-fix: 50 (one per item).
|
|
213
|
-
test('many list-item subscribers all sharing one indirection + shared signal — all fire once', async () => {
|
|
214
|
-
const { computed } = await import('../computed')
|
|
215
|
-
const selectedId = signal(1)
|
|
216
|
-
const other = signal(0)
|
|
217
|
-
const isTarget = computed(() => Object.is(selectedId(), 2), { equals: (a, b) => a === b })
|
|
218
|
-
|
|
219
|
-
const counts: number[] = []
|
|
220
|
-
for (let i = 0; i < 50; i++) {
|
|
221
|
-
const idx = i
|
|
222
|
-
counts.push(0)
|
|
223
|
-
effect(() => {
|
|
224
|
-
isTarget()
|
|
225
|
-
other()
|
|
226
|
-
counts[idx]!++
|
|
227
|
-
})
|
|
228
|
-
}
|
|
229
|
-
counts.fill(0)
|
|
230
|
-
|
|
231
|
-
batch(() => {
|
|
232
|
-
selectedId.set(2)
|
|
233
|
-
other.set(1)
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
const totalRuns = counts.reduce((a, b) => a + b, 0)
|
|
237
|
-
expect(totalRuns).toBe(50)
|
|
238
|
-
})
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
// ─── Edge-case shapes the swap-removal must also handle correctly ───────────
|
|
242
|
-
//
|
|
243
|
-
// The single-Set iteration design relies on JS Set semantics. Each test below
|
|
244
|
-
// pins a structural property the implementation must hold across the cases
|
|
245
|
-
// that aren't covered by the canonical bug repro above. They form a
|
|
246
|
-
// quasi-fence: changes that break any one of these would break a real
|
|
247
|
-
// usage shape we know exists.
|
|
248
|
-
describe('batch — additional cascade shapes', () => {
|
|
249
|
-
test('3-hop chain + 0-hop direct dep: subscriber fires once (canonical bug at depth 3)', async () => {
|
|
250
|
-
const { computed } = await import('../computed')
|
|
251
|
-
const source = signal(0)
|
|
252
|
-
const other = signal(0)
|
|
253
|
-
// 3-hop chain: source → a → b → c → effect
|
|
254
|
-
const a = computed(() => source() * 2, { equals: (x, y) => x === y })
|
|
255
|
-
const b = computed(() => a() + 1, { equals: (x, y) => x === y })
|
|
256
|
-
const c = computed(() => b() * 3, { equals: (x, y) => x === y })
|
|
257
|
-
|
|
258
|
-
let runs = 0
|
|
259
|
-
effect(() => {
|
|
260
|
-
c()
|
|
261
|
-
other()
|
|
262
|
-
runs++
|
|
263
|
-
})
|
|
264
|
-
runs = 0
|
|
265
|
-
|
|
266
|
-
// Both writes in one batch. Pre-removal of swap, the deeper indirection
|
|
267
|
-
// would land in an even-later round than the 0-hop direct path — same
|
|
268
|
-
// bug shape, just more rounds apart.
|
|
269
|
-
batch(() => {
|
|
270
|
-
source.set(5)
|
|
271
|
-
other.set(1)
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
expect(runs).toBe(1)
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
test('effect that throws during flush — iteration continues for remaining queued effects', () => {
|
|
278
|
-
const a = signal(0)
|
|
279
|
-
const b = signal(0)
|
|
280
|
-
let bRuns = 0
|
|
281
|
-
|
|
282
|
-
// First effect throws; the queue must still drain.
|
|
283
|
-
effect(() => {
|
|
284
|
-
if (a() > 0) throw new Error('boom')
|
|
285
|
-
})
|
|
286
|
-
effect(() => {
|
|
287
|
-
b()
|
|
288
|
-
bRuns++
|
|
289
|
-
})
|
|
290
|
-
bRuns = 0
|
|
291
|
-
|
|
292
|
-
// Both queued in same batch. The throwing effect is FIRST in pending
|
|
293
|
-
// (declared first → tracks first → enqueued first). It throws; the loop
|
|
294
|
-
// must continue and run the second effect.
|
|
295
|
-
batch(() => {
|
|
296
|
-
a.set(1)
|
|
297
|
-
b.set(1)
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
// Second effect ran exactly once despite the first effect throwing.
|
|
301
|
-
expect(bRuns).toBe(1)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test('effect creating a new effect during flush — new effect runs and tracks correctly', () => {
|
|
305
|
-
const trigger = signal(0)
|
|
306
|
-
const inner = signal(100)
|
|
307
|
-
const innerSeen: number[] = []
|
|
308
|
-
|
|
309
|
-
let outerRuns = 0
|
|
310
|
-
effect(() => {
|
|
311
|
-
trigger()
|
|
312
|
-
outerRuns++
|
|
313
|
-
// First trigger.set() creates a new effect during flush. The new
|
|
314
|
-
// effect must run and pick up tracked deps.
|
|
315
|
-
if (outerRuns === 2) {
|
|
316
|
-
effect(() => {
|
|
317
|
-
innerSeen.push(inner())
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
trigger.set(1)
|
|
323
|
-
expect(outerRuns).toBe(2)
|
|
324
|
-
// Inner effect was created → ran initially with inner=100.
|
|
325
|
-
expect(innerSeen).toEqual([100])
|
|
326
|
-
|
|
327
|
-
// Inner effect should track `inner`. Updating inner() after creation
|
|
328
|
-
// re-fires it.
|
|
329
|
-
inner.set(101)
|
|
330
|
-
expect(innerSeen).toEqual([100, 101])
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
test('effect disposed during another effect\'s flush — disposed one does not fire', () => {
|
|
334
|
-
const a = signal(0)
|
|
335
|
-
const b = signal(0)
|
|
336
|
-
|
|
337
|
-
let aRuns = 0
|
|
338
|
-
let bRuns = 0
|
|
339
|
-
|
|
340
|
-
let bEffect: ReturnType<typeof effect> | null = null
|
|
341
|
-
|
|
342
|
-
// First effect disposes the second one whenever it fires (after init).
|
|
343
|
-
effect(() => {
|
|
344
|
-
a()
|
|
345
|
-
aRuns++
|
|
346
|
-
if (bEffect) {
|
|
347
|
-
bEffect.dispose()
|
|
348
|
-
bEffect = null
|
|
349
|
-
}
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
// bEffect created AFTER the first effect's initial run, so the first
|
|
353
|
-
// effect's initial run sees bEffect=null (no dispose). Both effects
|
|
354
|
-
// exist after this line.
|
|
355
|
-
bEffect = effect(() => {
|
|
356
|
-
b()
|
|
357
|
-
bRuns++
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
aRuns = 0
|
|
361
|
-
bRuns = 0
|
|
362
|
-
|
|
363
|
-
// Both signals change in one batch. pending=[aEffect.run, bEffect.run].
|
|
364
|
-
// aEffect.run runs first → disposes bEffect (sets bEffect.disposed=true).
|
|
365
|
-
// bEffect.run runs next → early-returns on `if (disposed) return`.
|
|
366
|
-
batch(() => {
|
|
367
|
-
a.set(1)
|
|
368
|
-
b.set(1)
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
expect(aRuns).toBe(1)
|
|
372
|
-
expect(bRuns).toBe(0)
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
test('self-modifying effect: writes a tracked signal mid-run, no infinite loop, settles', () => {
|
|
376
|
-
const counter = signal(0)
|
|
377
|
-
let runs = 0
|
|
378
|
-
|
|
379
|
-
effect(() => {
|
|
380
|
-
const v = counter()
|
|
381
|
-
runs++
|
|
382
|
-
// Write to a tracked signal — should NOT re-queue this effect within
|
|
383
|
-
// the same batch (already-iterated entries don't re-fire).
|
|
384
|
-
if (v < 3) {
|
|
385
|
-
counter.set(v + 1)
|
|
386
|
-
}
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
// Initial run: reads 0, writes 1. No re-fire in same flush.
|
|
390
|
-
// External reset:
|
|
391
|
-
runs = 0
|
|
392
|
-
counter.set(10)
|
|
393
|
-
// counter triggers effect. effect writes counter.set(11). Within one
|
|
394
|
-
// batch boundary (the one signal.set wraps with), the second write
|
|
395
|
-
// would re-queue effect — but Set says "already in queue" / "already
|
|
396
|
-
// iterated" → no infinite loop.
|
|
397
|
-
expect(runs).toBeGreaterThanOrEqual(1)
|
|
398
|
-
expect(runs).toBeLessThan(50) // safety: no runaway
|
|
399
|
-
})
|
|
400
|
-
})
|
|
401
|
-
|
|
402
|
-
// ─── Property-based fuzz: random cascade graphs maintain the invariant ──────
|
|
403
|
-
//
|
|
404
|
-
// The CORE invariant the batch flush must guarantee:
|
|
405
|
-
//
|
|
406
|
-
// Each effect fires AT MOST ONCE per batch, with the final state of all
|
|
407
|
-
// its tracked dependencies.
|
|
408
|
-
//
|
|
409
|
-
// Counter-based tests above check specific shapes. This generates random
|
|
410
|
-
// dep graphs (signals + computeds + effects with random edges) and asserts
|
|
411
|
-
// the invariant holds across many topologies. Catches structural
|
|
412
|
-
// regressions that a hand-picked test wouldn't anticipate.
|
|
413
|
-
describe('batch — property-based: random cascade graph maintains invariant', () => {
|
|
414
|
-
// Deterministic RNG so failures are reproducible from the seed.
|
|
415
|
-
function mulberry32(seed: number): () => number {
|
|
416
|
-
let a = seed
|
|
417
|
-
return () => {
|
|
418
|
-
a |= 0
|
|
419
|
-
a = (a + 0x6d2ae53f) | 0
|
|
420
|
-
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
421
|
-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
422
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
test('25 random cascade graphs across various sizes — every effect fires ≤1× per batch', async () => {
|
|
427
|
-
const { computed } = await import('../computed')
|
|
428
|
-
const SEEDS = 25
|
|
429
|
-
|
|
430
|
-
for (let seed = 1; seed <= SEEDS; seed++) {
|
|
431
|
-
const rand = mulberry32(seed)
|
|
432
|
-
const numSignals = 2 + Math.floor(rand() * 4) // 2-5 signals
|
|
433
|
-
const numComputeds = 1 + Math.floor(rand() * 5) // 1-5 computeds
|
|
434
|
-
const numEffects = 2 + Math.floor(rand() * 6) // 2-7 effects
|
|
435
|
-
|
|
436
|
-
const signals = Array.from({ length: numSignals }, () => signal(0))
|
|
437
|
-
const computeds: (() => number)[] = []
|
|
438
|
-
// Each computed depends on 1-2 random previous nodes (signals or earlier computeds).
|
|
439
|
-
// Copy signals into allReadable — pushing computeds to allReadable must NOT
|
|
440
|
-
// mutate the signals array (the bug that broke this test on first write).
|
|
441
|
-
const allReadable: (() => number)[] = [...signals]
|
|
442
|
-
for (let i = 0; i < numComputeds; i++) {
|
|
443
|
-
const dep1 = allReadable[Math.floor(rand() * allReadable.length)]!
|
|
444
|
-
const dep2 = allReadable[Math.floor(rand() * allReadable.length)]!
|
|
445
|
-
const c = computed(() => dep1() + dep2(), { equals: (a, b) => a === b })
|
|
446
|
-
computeds.push(c)
|
|
447
|
-
allReadable.push(c)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Each effect tracks 2-4 random readable nodes.
|
|
451
|
-
const counts = Array<number>(numEffects).fill(0)
|
|
452
|
-
for (let i = 0; i < numEffects; i++) {
|
|
453
|
-
const idx = i
|
|
454
|
-
const numDeps = 2 + Math.floor(rand() * 3)
|
|
455
|
-
const deps: (() => number)[] = []
|
|
456
|
-
for (let j = 0; j < numDeps; j++) {
|
|
457
|
-
deps.push(allReadable[Math.floor(rand() * allReadable.length)]!)
|
|
458
|
-
}
|
|
459
|
-
effect(() => {
|
|
460
|
-
for (const d of deps) d()
|
|
461
|
-
counts[idx]!++
|
|
462
|
-
})
|
|
463
|
-
}
|
|
464
|
-
counts.fill(0)
|
|
465
|
-
|
|
466
|
-
// Random batch: 1-3 signal writes.
|
|
467
|
-
const numWrites = 1 + Math.floor(rand() * 3)
|
|
468
|
-
batch(() => {
|
|
469
|
-
for (let i = 0; i < numWrites; i++) {
|
|
470
|
-
const sig = signals[Math.floor(rand() * signals.length)]!
|
|
471
|
-
sig.set(Math.floor(rand() * 100))
|
|
472
|
-
}
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
// Invariant: every effect fired AT MOST ONCE per batch.
|
|
476
|
-
for (let i = 0; i < numEffects; i++) {
|
|
477
|
-
if (counts[i]! > 1) {
|
|
478
|
-
throw new Error(
|
|
479
|
-
`seed=${seed} effect[${i}] fired ${counts[i]} times (>1). ` +
|
|
480
|
-
`numSignals=${numSignals} numComputeds=${numComputeds} numEffects=${numEffects}`,
|
|
481
|
-
)
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
})
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
// ─── Audit bug #19: stale-Set leak on subscriber throw ────────────────────────
|
|
489
|
-
//
|
|
490
|
-
// Pre-fix: `pendingNotifications.clear()` was inside the try block of the
|
|
491
|
-
// flush loop. If a subscriber threw mid-iteration, the for-loop exited and
|
|
492
|
-
// the clear() never ran, leaking the unflushed remainder into the next
|
|
493
|
-
// batch. The next batch's `size > 0` check then re-entered flush mode and
|
|
494
|
-
// REFIRED the stale entries.
|
|
495
|
-
//
|
|
496
|
-
// Effect callbacks wrap their internals in try/catch so the bug rarely
|
|
497
|
-
// surfaces from `effect()`, but raw `signal.subscribe(fn)` callbacks (and
|
|
498
|
-
// any future internal consumer that doesn't pre-wrap) throw straight
|
|
499
|
-
// through. Fix: move `clear()` to the finally block.
|
|
500
|
-
|
|
501
|
-
describe('batch — subscriber-throw stale-Set leak (audit bug #19)', () => {
|
|
502
|
-
test('throwing subscriber does not leak stale notifications into next batch', () => {
|
|
503
|
-
const a = signal(0)
|
|
504
|
-
const b = signal(0)
|
|
505
|
-
|
|
506
|
-
let aFires = 0
|
|
507
|
-
let bFires = 0
|
|
508
|
-
|
|
509
|
-
a.subscribe(() => {
|
|
510
|
-
aFires++
|
|
511
|
-
throw new Error('boom')
|
|
512
|
-
})
|
|
513
|
-
b.subscribe(() => {
|
|
514
|
-
bFires++
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
// Batch 1 — A's subscriber throws mid-flush.
|
|
518
|
-
expect(() => {
|
|
519
|
-
batch(() => {
|
|
520
|
-
a.set(1)
|
|
521
|
-
b.set(1)
|
|
522
|
-
})
|
|
523
|
-
}).toThrow('boom')
|
|
524
|
-
|
|
525
|
-
// Sanity: a fired once and threw; b may or may not have fired depending
|
|
526
|
-
// on iteration order. The bug manifests in the SECOND batch — refiring
|
|
527
|
-
// an already-fired-and-cleared entry.
|
|
528
|
-
const aFiresAfterFirst = aFires
|
|
529
|
-
const bFiresAfterFirst = bFires
|
|
530
|
-
|
|
531
|
-
// Batch 2 — only b changes. Without the fix, A's stale subscriber
|
|
532
|
-
// still in pendingNotifications fires AGAIN.
|
|
533
|
-
try {
|
|
534
|
-
batch(() => {
|
|
535
|
-
b.set(2)
|
|
536
|
-
})
|
|
537
|
-
} catch {
|
|
538
|
-
// If A's subscriber refires, the throw escapes here. Catch + assert
|
|
539
|
-
// below so the test reports the right reason.
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
expect(aFires).toBe(aFiresAfterFirst) // A must NOT refire
|
|
543
|
-
expect(bFires).toBeGreaterThan(bFiresAfterFirst) // B should fire for its update
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
test('multiple consecutive throws stay isolated to their own batch', () => {
|
|
547
|
-
const sig = signal(0)
|
|
548
|
-
let fires = 0
|
|
549
|
-
sig.subscribe(() => {
|
|
550
|
-
fires++
|
|
551
|
-
throw new Error('always')
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
// Three batches, each with a throw — fires count must equal batch count,
|
|
555
|
-
// not multiply per leak.
|
|
556
|
-
for (let i = 1; i <= 3; i++) {
|
|
557
|
-
expect(() => {
|
|
558
|
-
batch(() => sig.set(i))
|
|
559
|
-
}).toThrow('always')
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
expect(fires).toBe(3)
|
|
563
|
-
})
|
|
564
|
-
})
|
|
565
|
-
|
|
566
|
-
// Regression: pre-fix, MAX_PASSES=32 exhaustion was a bisect blind spot — no
|
|
567
|
-
// test verified the warning fires AND that the queue is cleared so the next
|
|
568
|
-
// batch starts clean. Without queue clearing, the offending effect immediately
|
|
569
|
-
// re-trips MAX_PASSES on every subsequent batch, masking the original cause.
|
|
570
|
-
describe('batch — MAX_PASSES exhaustion (regression)', () => {
|
|
571
|
-
test('infinite re-enqueue loop is contained: warns, drops, next batch clean', () => {
|
|
572
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
573
|
-
|
|
574
|
-
// Two signals — the offending effect reads `a` and writes `b`, and a
|
|
575
|
-
// sibling effect reads `b` and writes `a`. This is the classic
|
|
576
|
-
// ping-pong infinite loop. Within a single batch, each pass re-enqueues
|
|
577
|
-
// both effects; MAX_PASSES caps the cascade.
|
|
578
|
-
const a = signal(0)
|
|
579
|
-
const b = signal(0)
|
|
580
|
-
let aReads = 0
|
|
581
|
-
let bReads = 0
|
|
582
|
-
|
|
583
|
-
effect(() => {
|
|
584
|
-
aReads++
|
|
585
|
-
// Write to `b` based on `a` — re-enqueues the b-reading effect.
|
|
586
|
-
b.set(a() + 1)
|
|
587
|
-
})
|
|
588
|
-
effect(() => {
|
|
589
|
-
bReads++
|
|
590
|
-
// Write to `a` based on `b` — re-enqueues the a-reading effect.
|
|
591
|
-
a.set(b() + 1)
|
|
592
|
-
})
|
|
593
|
-
|
|
594
|
-
// Initial reads.
|
|
595
|
-
expect(aReads).toBeGreaterThan(0)
|
|
596
|
-
expect(bReads).toBeGreaterThan(0)
|
|
597
|
-
aReads = 0
|
|
598
|
-
bReads = 0
|
|
599
|
-
|
|
600
|
-
// Trigger the loop. The batch should max out at MAX_PASSES and warn.
|
|
601
|
-
batch(() => {
|
|
602
|
-
a.set(100)
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
// Warning fires with the actionable hint.
|
|
606
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
607
|
-
expect.stringContaining('exceeded MAX_PASSES'),
|
|
608
|
-
)
|
|
609
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
610
|
-
expect.stringContaining('Common cause'),
|
|
611
|
-
)
|
|
612
|
-
|
|
613
|
-
// Bisect contract: the queue is cleared after the cap, so a fresh
|
|
614
|
-
// unrelated batch is NOT immediately re-tripped. Without the clear,
|
|
615
|
-
// any subsequent batch would re-encounter the still-pending effects
|
|
616
|
-
// and re-fire the warning instantly.
|
|
617
|
-
warnSpy.mockClear()
|
|
618
|
-
const unrelated = signal(0)
|
|
619
|
-
let unrelatedRuns = 0
|
|
620
|
-
effect(() => {
|
|
621
|
-
unrelatedRuns++
|
|
622
|
-
void unrelated()
|
|
623
|
-
})
|
|
624
|
-
const baseline = unrelatedRuns
|
|
625
|
-
batch(() => {
|
|
626
|
-
unrelated.set(1)
|
|
627
|
-
})
|
|
628
|
-
expect(unrelatedRuns).toBe(baseline + 1)
|
|
629
|
-
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
630
|
-
expect.stringContaining('exceeded MAX_PASSES'),
|
|
631
|
-
)
|
|
632
|
-
|
|
633
|
-
warnSpy.mockRestore()
|
|
634
|
-
})
|
|
635
|
-
})
|
|
636
|
-
|
|
637
|
-
// M6 audit gap (b): writing a signal from inside a `signal.subscribe` listener
|
|
638
|
-
// (raw subscriber, not effect). Writes during dispatch must batch correctly
|
|
639
|
-
// AND not infinite-loop the dispatcher.
|
|
640
|
-
describe('batch — write from raw signal.subscribe listener (regression)', () => {
|
|
641
|
-
test('listener that writes another signal does not infinite-loop', () => {
|
|
642
|
-
const a = signal(0)
|
|
643
|
-
const b = signal(0)
|
|
644
|
-
const seen: number[] = []
|
|
645
|
-
a.subscribe(() => {
|
|
646
|
-
// Write a different signal from inside the dispatch
|
|
647
|
-
b.set(a.peek() + 100)
|
|
648
|
-
})
|
|
649
|
-
b.subscribe(() => {
|
|
650
|
-
seen.push(b.peek())
|
|
651
|
-
})
|
|
652
|
-
|
|
653
|
-
a.set(5)
|
|
654
|
-
expect(b.peek()).toBe(105)
|
|
655
|
-
expect(seen).toEqual([105])
|
|
656
|
-
|
|
657
|
-
a.set(7)
|
|
658
|
-
expect(b.peek()).toBe(107)
|
|
659
|
-
expect(seen).toEqual([105, 107])
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
test('listener writing the SAME signal short-circuits via Object.is dedup', () => {
|
|
663
|
-
const s = signal(0)
|
|
664
|
-
let listenerRuns = 0
|
|
665
|
-
s.subscribe(() => {
|
|
666
|
-
listenerRuns++
|
|
667
|
-
// Re-write same value — _set short-circuits via Object.is
|
|
668
|
-
s.set(s.peek())
|
|
669
|
-
})
|
|
670
|
-
|
|
671
|
-
s.set(5)
|
|
672
|
-
// Listener fires once for the user write; the in-listener re-write is
|
|
673
|
-
// a no-op via Object.is. Without the no-op, this would infinite-loop.
|
|
674
|
-
expect(listenerRuns).toBe(1)
|
|
675
|
-
})
|
|
676
|
-
|
|
677
|
-
test('listener writing a DIFFERENT value to its own signal is contained by MAX_PASSES', () => {
|
|
678
|
-
// This IS an infinite re-write loop — listener writes increment, which
|
|
679
|
-
// re-fires the listener, which writes increment, etc. The MAX_PASSES cap
|
|
680
|
-
// (PR #462) keeps it bounded. Without it, this test hangs.
|
|
681
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
682
|
-
const s = signal(0)
|
|
683
|
-
let listenerRuns = 0
|
|
684
|
-
s.subscribe(() => {
|
|
685
|
-
listenerRuns++
|
|
686
|
-
if (listenerRuns < 1000) s.set(s.peek() + 1)
|
|
687
|
-
})
|
|
688
|
-
|
|
689
|
-
s.set(1)
|
|
690
|
-
// Bounded — bisect-verifiable: the contract is "doesn't hang" rather
|
|
691
|
-
// than "fires N times" (the exact count depends on MAX_PASSES timing).
|
|
692
|
-
expect(listenerRuns).toBeGreaterThan(0)
|
|
693
|
-
expect(listenerRuns).toBeLessThan(10000) // would be infinite without the cap
|
|
694
|
-
warnSpy.mockRestore()
|
|
695
|
-
})
|
|
696
|
-
})
|
|
697
|
-
|
|
698
|
-
// M6 audit gap (d): cross-batch interleaving — outer batch starts flushing,
|
|
699
|
-
// an inner batch begins inside an effect that fired during flush. The inner
|
|
700
|
-
// batch must drain its own writes correctly without leaking into the outer.
|
|
701
|
-
describe('batch — cross-batch interleaving (regression)', () => {
|
|
702
|
-
test('inner batch inside effect drains independently', () => {
|
|
703
|
-
const a = signal(0)
|
|
704
|
-
const b = signal(0)
|
|
705
|
-
const c = signal(0)
|
|
706
|
-
const seen: { a: number; b: number; c: number }[] = []
|
|
707
|
-
|
|
708
|
-
effect(() => {
|
|
709
|
-
const av = a()
|
|
710
|
-
const bv = b()
|
|
711
|
-
const cv = c()
|
|
712
|
-
seen.push({ a: av, b: bv, c: cv })
|
|
713
|
-
if (av > 0) {
|
|
714
|
-
batch(() => {
|
|
715
|
-
b.set(av * 10)
|
|
716
|
-
c.set(av * 100)
|
|
717
|
-
})
|
|
718
|
-
}
|
|
719
|
-
})
|
|
720
|
-
seen.length = 0
|
|
721
|
-
|
|
722
|
-
batch(() => {
|
|
723
|
-
a.set(1)
|
|
724
|
-
})
|
|
725
|
-
|
|
726
|
-
// Inner batch's b/c writes should have applied. The effect re-ran with
|
|
727
|
-
// propagated values (subject to dedup). Final state proves drain.
|
|
728
|
-
const final = seen[seen.length - 1]
|
|
729
|
-
expect(final).toEqual({ a: 1, b: 10, c: 100 })
|
|
730
|
-
})
|
|
731
|
-
|
|
732
|
-
test('nested batch with overlapping writes does not double-flush', () => {
|
|
733
|
-
const s = signal(0)
|
|
734
|
-
let runs = 0
|
|
735
|
-
effect(() => {
|
|
736
|
-
void s()
|
|
737
|
-
runs++
|
|
738
|
-
})
|
|
739
|
-
runs = 0
|
|
740
|
-
|
|
741
|
-
batch(() => {
|
|
742
|
-
s.set(1)
|
|
743
|
-
batch(() => {
|
|
744
|
-
s.set(2)
|
|
745
|
-
})
|
|
746
|
-
s.set(3)
|
|
747
|
-
})
|
|
748
|
-
expect(runs).toBe(1)
|
|
749
|
-
expect(s.peek()).toBe(3)
|
|
750
|
-
})
|
|
751
|
-
})
|