@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.
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,471 +0,0 @@
1
- /**
2
- * Coverage-hardening suite for @pyreon/reactivity.
3
- *
4
- * The reactivity core is the foundation every other Pyreon package depends
5
- * on. Its branch coverage had drifted to 87.38% — below the 90% global
6
- * threshold (the package's own `bun run test` exited non-zero) — leaving
7
- * error-handler branches, batching/non-batching dual paths, the
8
- * snapshot-restore DI hook, and the MAX_PASSES infinite-loop guard
9
- * unexercised. Each test here asserts real observable behaviour of one of
10
- * those previously-uncovered branches; none games coverage.
11
- */
12
- import { batch } from '../batch'
13
- import { Cell } from '../cell'
14
- import { computed } from '../computed'
15
- import { createSelector } from '../createSelector'
16
- import {
17
- type ReactiveSnapshotCapture,
18
- effect,
19
- renderEffect,
20
- setErrorHandler,
21
- setSnapshotCapture,
22
- _bind,
23
- } from '../effect'
24
- import { clearReactiveTrace, getReactiveTrace } from '../reactive-trace'
25
- import { signal } from '../signal'
26
-
27
- // ── effect.ts: error handler on a throwing inner effect during disposal ──────
28
-
29
- describe('effect — inner-effect disposal error handling', () => {
30
- test('a throwing inner-effect cleanup is routed to the error handler, not swallowed', () => {
31
- const caught: unknown[] = []
32
- setErrorHandler((err) => caught.push(err))
33
-
34
- const trigger = signal(0)
35
- let inner: ReturnType<typeof effect> | undefined
36
-
37
- const outer = effect(() => {
38
- trigger() // re-runs outer → runCleanup disposes the inner effect
39
- inner = effect(() => {})
40
- // Make the inner effect's dispose throw so the catch + _errorHandler
41
- // branch in runCleanup (effect.ts:183-190) is exercised.
42
- const orig = inner.dispose
43
- inner.dispose = () => {
44
- orig()
45
- throw new Error('inner-dispose-boom')
46
- }
47
- })
48
-
49
- trigger.set(1) // outer re-runs → runCleanup → inner.dispose() throws
50
-
51
- expect(caught).toHaveLength(1)
52
- expect((caught[0] as Error).message).toBe('inner-dispose-boom')
53
-
54
- outer.dispose()
55
- setErrorHandler((_err) => {})
56
- })
57
- })
58
-
59
- // ── effect.ts: snapshot-restore DI hook (setSnapshotCapture) ─────────────────
60
-
61
- describe('effect — snapshot-capture restore branch', () => {
62
- afterEach(() => setSnapshotCapture(null))
63
-
64
- test('effect / _bind / renderEffect route re-runs through capture.restore', () => {
65
- const restoreCalls: string[] = []
66
- const hook: ReactiveSnapshotCapture = {
67
- capture: () => ({ tag: 'snap' }),
68
- restore: (snap, fn) => {
69
- restoreCalls.push((snap as { tag: string }).tag)
70
- return fn()
71
- },
72
- }
73
- setSnapshotCapture(hook)
74
-
75
- const s = signal(0)
76
- let effRuns = 0
77
- let bindRuns = 0
78
- let reRuns = 0
79
-
80
- const eff = effect(() => {
81
- s()
82
- effRuns++
83
- })
84
- const bindStop = _bind(() => {
85
- s()
86
- bindRuns++
87
- })
88
- const reStop = renderEffect(() => {
89
- s()
90
- reRuns++
91
- })
92
-
93
- // First runs do NOT restore (synchronous mount stack already correct).
94
- expect(restoreCalls).toEqual([])
95
-
96
- s.set(1) // every effect re-runs — each goes through capture.restore
97
-
98
- expect(effRuns).toBe(2)
99
- expect(bindRuns).toBe(2)
100
- expect(reRuns).toBe(2)
101
- // Three re-runs, each restored against the captured snapshot.
102
- expect(restoreCalls).toEqual(['snap', 'snap', 'snap'])
103
-
104
- eff.dispose()
105
- bindStop()
106
- reStop()
107
- })
108
- })
109
-
110
- // ── effect.ts: multi-dep renderEffect cleanup (deps.length > 1) ──────────────
111
-
112
- describe('effect — renderEffect multi-dependency cleanup', () => {
113
- test('disposing a renderEffect with >1 tracked signal unsubscribes from all', () => {
114
- const a = signal(0)
115
- const b = signal(0)
116
- let runs = 0
117
-
118
- const stop = renderEffect(() => {
119
- a()
120
- b()
121
- runs++
122
- })
123
- expect(runs).toBe(1)
124
-
125
- a.set(1)
126
- b.set(1)
127
- expect(runs).toBe(3)
128
-
129
- stop() // deps.length === 2 → the `for (const s of deps)` cleanup branch
130
-
131
- a.set(2)
132
- b.set(2)
133
- expect(runs).toBe(3) // no further runs — fully unsubscribed
134
- })
135
- })
136
-
137
- // ── batch.ts: MAX_PASSES infinite re-enqueue guard ──────────────────────────
138
-
139
- describe('batch — MAX_PASSES guard on a non-converging effect', () => {
140
- test('an effect that unconditionally re-writes a signal it reads is capped, warned, and the queue is cleared', () => {
141
- const warns: string[] = []
142
- const origWarn = console.warn
143
- console.warn = (...args: unknown[]) => {
144
- warns.push(args.join(' '))
145
- }
146
-
147
- const s = signal(0)
148
- let runs = 0
149
- const eff = effect(() => {
150
- const v = s()
151
- runs++
152
- // Always writes a new value → never converges → trips MAX_PASSES.
153
- s.set(v + 1)
154
- })
155
-
156
- try {
157
- // The initial run already self-schedules; force a flush via batch.
158
- batch(() => {
159
- s.set(1)
160
- })
161
- } finally {
162
- console.warn = origWarn
163
- }
164
-
165
- // Capped: tier-2 stops after MAX_PASSES (32) — NOT an unbounded loop.
166
- expect(runs).toBeGreaterThan(1)
167
- expect(runs).toBeLessThan(200)
168
- expect(warns.some((w) => w.includes('MAX_PASSES'))).toBe(true)
169
-
170
- eff.dispose()
171
-
172
- // Queue was cleared on the trip — a fresh, converging batch works.
173
- const t = signal(0)
174
- let tRuns = 0
175
- const eff2 = effect(() => {
176
- t()
177
- tRuns++
178
- })
179
- batch(() => t.set(1))
180
- expect(tRuns).toBe(2)
181
- eff2.dispose()
182
- })
183
- })
184
-
185
- // ── computed.ts: error handler + direct-updater registration ────────────────
186
-
187
- describe('computed — error handler and direct updaters', () => {
188
- test('a throwing computed routes the error to the handler and stays readable', () => {
189
- const caught: unknown[] = []
190
- setErrorHandler((err) => caught.push(err))
191
-
192
- const src = signal(0)
193
- const c = computed(() => {
194
- if (src() === 1) throw new Error('compute-boom')
195
- return src() * 10
196
- })
197
-
198
- expect(c()).toBe(0)
199
- src.set(1)
200
- // Recompute throws → _errorHandler branch (computed.ts:203-204) fires.
201
- void c()
202
- expect(caught.some((e) => (e as Error).message === 'compute-boom')).toBe(true)
203
-
204
- src.set(2)
205
- expect(c()).toBe(20) // recovers cleanly after the throwing input passes
206
-
207
- setErrorHandler((_err) => {})
208
- })
209
-
210
- test('computed.direct registers / fires / unsubscribes a direct updater and exposes _d', () => {
211
- const src = signal(1)
212
- const c = computed(() => src() * 2)
213
- void c() // initialize
214
-
215
- const seen: number[] = []
216
- const unsub = c.direct(() => seen.push(c()))
217
-
218
- // `_d` getter (computed.ts:243-247) exposes the live direct-updater set.
219
- expect((c as unknown as { _d: Set<unknown> })._d.size).toBe(1)
220
-
221
- src.set(5)
222
- expect(seen).toEqual([10])
223
-
224
- unsub()
225
- expect((c as unknown as { _d: Set<unknown> })._d.size).toBe(0)
226
- src.set(7)
227
- expect(seen).toEqual([10]) // no further direct notifications
228
- })
229
- })
230
-
231
- // ── signal.ts: notifyDirect non-batching path ───────────────────────────────
232
-
233
- describe('signal — direct updater non-batching notification', () => {
234
- test('signal.direct updater fires synchronously on a non-batched set', () => {
235
- const s = signal(0)
236
- const seen: number[] = []
237
- const unsub = s.direct(() => seen.push(s()))
238
-
239
- s.set(1) // non-batched → `for (const fn of updaters) fn()` (signal.ts:186)
240
- s.set(2)
241
- expect(seen).toEqual([1, 2])
242
-
243
- unsub()
244
- s.set(3)
245
- expect(seen).toEqual([1, 2])
246
- })
247
- })
248
-
249
- // ── createSelector.ts: single-bucket notify + selection change ──────────────
250
-
251
- describe('createSelector — bucket notification branches', () => {
252
- test('selecting / deselecting notifies only the two affected single-entry buckets', () => {
253
- const selected = signal('a')
254
- const isSelected = createSelector(selected)
255
-
256
- const aRuns: number[] = []
257
- const bRuns: number[] = []
258
- const aStop = renderEffect(() => {
259
- aRuns.push(isSelected('a') ? 1 : 0)
260
- })
261
- const bStop = renderEffect(() => {
262
- bRuns.push(isSelected('b') ? 1 : 0)
263
- })
264
-
265
- expect(aRuns).toEqual([1])
266
- expect(bRuns).toEqual([0])
267
-
268
- // 'a' → 'b': old bucket ('a', size 1) + new bucket ('b', size 1) each
269
- // hit the `bucket.size === 1` fast path (createSelector.ts:10-13).
270
- selected.set('b')
271
- expect(aRuns).toEqual([1, 0])
272
- expect(bRuns).toEqual([0, 1])
273
-
274
- // Selecting an unobserved value: no bucket for it → no spurious notifies.
275
- selected.set('c')
276
- expect(aRuns).toEqual([1, 0])
277
- expect(bRuns).toEqual([0, 1, 0])
278
-
279
- aStop()
280
- bStop()
281
- isSelected.dispose()
282
- // Idempotent dispose.
283
- isSelected.dispose()
284
- })
285
-
286
- test('multi-subscriber bucket uses the iteration-capped loop', () => {
287
- const selected = signal('x')
288
- const isSelected = createSelector(selected)
289
- const hits: string[] = []
290
-
291
- // Three subscribers all querying 'x' → bucket size 3 → capped-loop path.
292
- const stops = [1, 2, 3].map((n) =>
293
- renderEffect(() => {
294
- if (isSelected('x')) hits.push(`s${n}`)
295
- }),
296
- )
297
- expect(hits).toEqual(['s1', 's2', 's3'])
298
-
299
- selected.set('y') // old bucket 'x' has 3 entries → multi-entry branch
300
- selected.set('x')
301
- expect(hits).toEqual(['s1', 's2', 's3', 's1', 's2', 's3'])
302
-
303
- stops.forEach((s) => s())
304
- isSelected.dispose()
305
- })
306
- })
307
-
308
- // ── cell.ts: listener → Set promotion when a single listener already exists ──
309
-
310
- describe('Cell — single-listener promotion to Set', () => {
311
- test('a second subscribe promotes the lazy _l slot into a Set (cell.ts:49)', () => {
312
- const c = new Cell(0)
313
- const seen: string[] = []
314
- // First subscribe: stored in the single-listener `_l` fast-path slot.
315
- const off1 = c.subscribe(() => seen.push('a'))
316
- // Second subscribe: `!this._s` is true AND `this._l` is set → the
317
- // promotion branch `this._s.add(this._l); this._l = null` runs.
318
- const off2 = c.subscribe(() => seen.push('b'))
319
-
320
- c.set(1)
321
- expect(seen).toEqual(['a', 'b'])
322
-
323
- off1() // unsubscribe the promoted listener from the Set
324
- c.set(2)
325
- expect(seen).toEqual(['a', 'b', 'b'])
326
- off2()
327
- c.set(3)
328
- expect(seen).toEqual(['a', 'b', 'b'])
329
- })
330
- })
331
-
332
- // ── reactive-trace.ts: preview() value-shape branches ───────────────────────
333
-
334
- describe('reactive-trace — preview of every value shape', () => {
335
- beforeEach(() => clearReactiveTrace())
336
-
337
- test('arrays, functions, symbols, bigint, plain objects, named instances, >4 keys, long strings', () => {
338
- const s = signal<unknown>(null, { name: 'v' })
339
-
340
- s.set([1, 2, 3]) // Array(3)
341
- s.set(function namedFn() {}) // [Function namedFn]
342
- s.set(Symbol('sym')) // Symbol(sym)
343
- s.set(10n) // bigint → String()
344
- s.set({ a: 1, b: 2 }) // plain object → { a, b }
345
- class Point {
346
- x = 1
347
- }
348
- s.set(new Point()) // named ctor → "Point { x }"
349
- s.set({ a: 1, b: 2, c: 3, d: 4, e: 5 }) // >4 keys → trailing ", …"
350
- s.set('z'.repeat(500)) // long string → truncated with "…"
351
-
352
- const trace = getReactiveTrace()
353
- const nexts = trace.map((e) => e.next)
354
-
355
- expect(nexts).toContain('Array(3)')
356
- expect(nexts.some((n) => n!.includes('[Function namedFn]'))).toBe(true)
357
- expect(nexts.some((n) => n!.includes('Symbol(sym)'))).toBe(true)
358
- expect(nexts).toContain('10')
359
- expect(nexts.some((n) => n === '{a, b}' || n === '{ a, b }' || /\{a, b\}/.test(n!))).toBe(true)
360
- expect(nexts.some((n) => n!.startsWith('Point '))).toBe(true)
361
- expect(nexts.some((n) => n!.includes('…'))).toBe(true) // both >4-keys and long-string
362
- })
363
-
364
- test('an object whose Object.keys throws falls back gracefully (no throw, recorded)', () => {
365
- const s = signal<unknown>(null, { name: 'p' })
366
- // A revoked Proxy throws on every trap including ownKeys → the inner
367
- // try/catch returns [] and the outer try/catch keeps preview total.
368
- const { proxy, revoke } = Proxy.revocable({}, {})
369
- revoke()
370
- expect(() => s.set(proxy)).not.toThrow()
371
- expect(getReactiveTrace()).toHaveLength(1)
372
- })
373
- })
374
-
375
- // ── tracking.ts: cleanupEffect WeakMap path + batched single-subscriber ──────
376
-
377
- describe('tracking — cleanup + batched single-subscriber', () => {
378
- test('an effect re-run clears its WeakMap-tracked deps before re-subscribing', () => {
379
- const a = signal(0)
380
- const b = signal(100)
381
- const useA = signal(true)
382
- let runs = 0
383
-
384
- const eff = effect(() => {
385
- // Dynamic dependency: switches which signal it tracks. The re-run
386
- // path goes through cleanupEffect's `for (const sub of deps)` +
387
- // deps.clear() branch (tracking.ts:72-73).
388
- if (useA()) a()
389
- else b()
390
- runs++
391
- })
392
- expect(runs).toBe(1)
393
-
394
- useA.set(false) // re-run, now tracks b not a
395
- expect(runs).toBe(2)
396
-
397
- a.set(1) // a is no longer a dep → no run
398
- expect(runs).toBe(2)
399
-
400
- b.set(101) // b IS the dep now → runs
401
- expect(runs).toBe(3)
402
-
403
- eff.dispose()
404
- })
405
-
406
- test('a computed with ONE subscriber recomputed under batch enqueues via notifySubscribers (tracking.ts:83)', () => {
407
- const src = signal(0)
408
- const c = computed(() => src() * 2)
409
- const order: string[] = []
410
-
411
- const eff = effect(() => {
412
- order.push(`run:${c()}`)
413
- })
414
- expect(order).toEqual(['run:0'])
415
-
416
- batch(() => {
417
- src.set(5) // computed.recompute → notifySubscribers(host._s), size 1, batching
418
- order.push('mid-batch')
419
- })
420
- // The single computed-subscriber fired AFTER the batch (enqueued, not inline).
421
- expect(order).toEqual(['run:0', 'mid-batch', 'run:10'])
422
-
423
- eff.dispose()
424
- })
425
-
426
- test('a computed with TWO+ subscribers recomputed without batch hits the multi-sub inline loop (tracking.ts:97-102)', () => {
427
- const src = signal(1)
428
- const c = computed(() => src() + 1)
429
- const a: number[] = []
430
- const b: number[] = []
431
-
432
- const effA = effect(() => {
433
- a.push(c())
434
- })
435
- const effB = effect(() => {
436
- b.push(c())
437
- })
438
- expect(a).toEqual([2])
439
- expect(b).toEqual([2])
440
-
441
- // Non-batched source change → computed.recompute → notifySubscribers
442
- // with host._s.size === 2 → the originalSize-capped inline `for` loop.
443
- src.set(9)
444
- expect(a).toEqual([2, 10])
445
- expect(b).toEqual([2, 10])
446
-
447
- effA.dispose()
448
- effB.dispose()
449
- })
450
-
451
- test('a single-subscriber signal written inside batch() enqueues (not inline)', () => {
452
- const s = signal(0)
453
- const order: string[] = []
454
- const eff = effect(() => {
455
- s()
456
- order.push(`run:${s()}`)
457
- })
458
- expect(order).toEqual(['run:0'])
459
-
460
- batch(() => {
461
- s.set(1)
462
- order.push('mid-batch') // effect must NOT have run yet (enqueued)
463
- })
464
-
465
- // tracking.ts:83 — single subscriber under batch → enqueuePendingNotification,
466
- // so the effect fires AFTER the batch body, not inline at .set().
467
- expect(order).toEqual(['run:0', 'mid-batch', 'run:1'])
468
-
469
- eff.dispose()
470
- })
471
- })