@pyreon/reactivity 0.24.4 → 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.
Files changed (44) hide show
  1. package/package.json +1 -4
  2. package/src/batch.ts +0 -196
  3. package/src/cell.ts +0 -72
  4. package/src/computed.ts +0 -313
  5. package/src/createSelector.ts +0 -109
  6. package/src/debug.ts +0 -134
  7. package/src/effect.ts +0 -467
  8. package/src/env.d.ts +0 -6
  9. package/src/index.ts +0 -60
  10. package/src/lpih.ts +0 -227
  11. package/src/manifest.ts +0 -660
  12. package/src/reactive-devtools.ts +0 -494
  13. package/src/reactive-trace.ts +0 -142
  14. package/src/reconcile.ts +0 -118
  15. package/src/resource.ts +0 -84
  16. package/src/scope.ts +0 -123
  17. package/src/signal.ts +0 -261
  18. package/src/store.ts +0 -250
  19. package/src/tests/batch.test.ts +0 -751
  20. package/src/tests/bind.test.ts +0 -84
  21. package/src/tests/branches.test.ts +0 -343
  22. package/src/tests/cell.test.ts +0 -159
  23. package/src/tests/computed.test.ts +0 -436
  24. package/src/tests/coverage-hardening.test.ts +0 -471
  25. package/src/tests/createSelector.test.ts +0 -291
  26. package/src/tests/debug.test.ts +0 -196
  27. package/src/tests/effect.test.ts +0 -464
  28. package/src/tests/fanout-repro.test.ts +0 -179
  29. package/src/tests/lpih-source-location.test.ts +0 -277
  30. package/src/tests/lpih.test.ts +0 -351
  31. package/src/tests/manifest-snapshot.test.ts +0 -96
  32. package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
  33. package/src/tests/reactive-devtools.test.ts +0 -296
  34. package/src/tests/reactive-trace.test.ts +0 -102
  35. package/src/tests/reconcile-security.test.ts +0 -45
  36. package/src/tests/resource.test.ts +0 -326
  37. package/src/tests/scope.test.ts +0 -231
  38. package/src/tests/signal.test.ts +0 -368
  39. package/src/tests/store.test.ts +0 -286
  40. package/src/tests/tracking.test.ts +0 -158
  41. package/src/tests/vue-parity.test.ts +0 -191
  42. package/src/tests/watch.test.ts +0 -246
  43. package/src/tracking.ts +0 -139
  44. package/src/watch.ts +0 -68
@@ -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
- })