@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,464 +0,0 @@
1
- import { effect, onCleanup, renderEffect, setErrorHandler } from '../effect'
2
- import { effectScope, setCurrentScope } from '../scope'
3
- import { signal } from '../signal'
4
-
5
- describe('effect', () => {
6
- test('runs immediately', () => {
7
- let ran = false
8
- effect(() => {
9
- ran = true
10
- })
11
- expect(ran).toBe(true)
12
- })
13
-
14
- test('re-runs when tracked signal changes', () => {
15
- const s = signal(0)
16
- let count = 0
17
- effect(() => {
18
- s() // track
19
- count++
20
- })
21
- expect(count).toBe(1)
22
- s.set(1)
23
- expect(count).toBe(2)
24
- s.set(2)
25
- expect(count).toBe(3)
26
- })
27
-
28
- test('does not re-run after dispose', () => {
29
- const s = signal(0)
30
- let count = 0
31
- const e = effect(() => {
32
- s()
33
- count++
34
- })
35
- e.dispose()
36
- s.set(1)
37
- expect(count).toBe(1) // only the initial run
38
- })
39
-
40
- test('tracks multiple signals', () => {
41
- const a = signal(1)
42
- const b = signal(2)
43
- let result = 0
44
- effect(() => {
45
- result = a() + b()
46
- })
47
- expect(result).toBe(3)
48
- a.set(10)
49
- expect(result).toBe(12)
50
- b.set(20)
51
- expect(result).toBe(30)
52
- })
53
-
54
- test('does not track signals accessed after conditional branch', () => {
55
- const toggle = signal(true)
56
- const a = signal(1)
57
- const b = signal(100)
58
- let result = 0
59
- effect(() => {
60
- result = toggle() ? a() : b()
61
- })
62
- expect(result).toBe(1)
63
- a.set(2)
64
- expect(result).toBe(2)
65
- toggle.set(false)
66
- expect(result).toBe(100)
67
- // a is no longer tracked
68
- a.set(999)
69
- expect(result).toBe(100)
70
- })
71
-
72
- test('catches errors via default error handler', () => {
73
- const errors: unknown[] = []
74
- const origError = console.error
75
- console.error = (...args: unknown[]) => errors.push(args)
76
-
77
- const s = signal(0)
78
- effect(() => {
79
- s()
80
- throw new Error('boom')
81
- })
82
-
83
- expect(errors.length).toBe(1)
84
- console.error = origError
85
- })
86
-
87
- test('calls cleanup before re-run', () => {
88
- const s = signal(0)
89
- let cleanups = 0
90
- effect(() => {
91
- s()
92
- return () => {
93
- cleanups++
94
- }
95
- })
96
- expect(cleanups).toBe(0)
97
- s.set(1) // re-run: previous cleanup fires
98
- expect(cleanups).toBe(1)
99
- s.set(2)
100
- expect(cleanups).toBe(2)
101
- })
102
-
103
- test('calls cleanup on dispose', () => {
104
- const s = signal(0)
105
- let cleanups = 0
106
- const e = effect(() => {
107
- s()
108
- return () => {
109
- cleanups++
110
- }
111
- })
112
- expect(cleanups).toBe(0)
113
- e.dispose()
114
- expect(cleanups).toBe(1)
115
- // Disposing again should not call cleanup again
116
- e.dispose()
117
- expect(cleanups).toBe(1)
118
- })
119
-
120
- test('cleanup errors are caught by error handler', () => {
121
- const caught: unknown[] = []
122
- setErrorHandler((err) => caught.push(err))
123
-
124
- const s = signal(0)
125
- effect(() => {
126
- s()
127
- return () => {
128
- throw new Error('cleanup boom')
129
- }
130
- })
131
- s.set(1) // triggers cleanup which throws
132
- expect(caught.length).toBe(1)
133
- expect((caught[0] as Error).message).toBe('cleanup boom')
134
-
135
- // Restore default handler
136
- setErrorHandler((_err) => {})
137
- })
138
-
139
- test('works with no cleanup return (backwards compatible)', () => {
140
- const s = signal(0)
141
- let count = 0
142
- effect(() => {
143
- s()
144
- count++
145
- // no return
146
- })
147
- expect(count).toBe(1)
148
- s.set(1)
149
- expect(count).toBe(2)
150
- })
151
-
152
- test('setErrorHandler replaces the error handler', () => {
153
- const caught: unknown[] = []
154
- setErrorHandler((err) => caught.push(err))
155
-
156
- const s = signal(0)
157
- effect(() => {
158
- s()
159
- throw new Error('custom')
160
- })
161
-
162
- expect(caught.length).toBe(1)
163
- expect((caught[0] as Error).message).toBe('custom')
164
-
165
- // Restore default handler
166
- setErrorHandler((_err) => {})
167
- })
168
-
169
- test('effect notifies scope on re-run (not first run)', async () => {
170
- const scope = effectScope()
171
- setCurrentScope(scope)
172
-
173
- let updateCount = 0
174
- scope.addUpdateHook(() => {
175
- updateCount++
176
- })
177
-
178
- const s = signal(0)
179
- effect(() => {
180
- s()
181
- })
182
-
183
- setCurrentScope(null)
184
-
185
- expect(updateCount).toBe(0) // first run does not notify
186
-
187
- s.set(1) // re-run triggers notifyEffectRan
188
- await new Promise((r) => setTimeout(r, 10))
189
- expect(updateCount).toBe(1)
190
-
191
- scope.stop()
192
- })
193
- })
194
-
195
- describe('renderEffect', () => {
196
- test('runs immediately and tracks signals', () => {
197
- const s = signal(0)
198
- let count = 0
199
- renderEffect(() => {
200
- s()
201
- count++
202
- })
203
- expect(count).toBe(1)
204
- s.set(1)
205
- expect(count).toBe(2)
206
- })
207
-
208
- test('dispose stops tracking', () => {
209
- const s = signal(0)
210
- let count = 0
211
- const dispose = renderEffect(() => {
212
- s()
213
- count++
214
- })
215
- expect(count).toBe(1)
216
- dispose()
217
- s.set(1)
218
- expect(count).toBe(1)
219
- })
220
-
221
- test('dispose is idempotent', () => {
222
- const s = signal(0)
223
- const dispose = renderEffect(() => {
224
- s()
225
- })
226
- dispose()
227
- dispose() // should not throw
228
- })
229
-
230
- test('tracks dynamic dependencies', () => {
231
- const toggle = signal(true)
232
- const a = signal(1)
233
- const b = signal(100)
234
- let result = 0
235
- renderEffect(() => {
236
- result = toggle() ? a() : b()
237
- })
238
- expect(result).toBe(1)
239
- toggle.set(false)
240
- expect(result).toBe(100)
241
- a.set(999) // no longer tracked
242
- expect(result).toBe(100)
243
- })
244
-
245
- test('does not re-run after disposed during signal update', () => {
246
- const s = signal(0)
247
- let count = 0
248
- const dispose = renderEffect(() => {
249
- s()
250
- count++
251
- })
252
- dispose()
253
- s.set(5)
254
- expect(count).toBe(1)
255
- })
256
- })
257
-
258
- describe('onCleanup', () => {
259
- test('runs cleanup before effect re-runs', () => {
260
- const s = signal(0)
261
- const log: string[] = []
262
- effect(() => {
263
- const val = s()
264
- onCleanup(() => log.push(`cleanup-${val}`))
265
- log.push(`run-${val}`)
266
- })
267
- expect(log).toEqual(['run-0'])
268
- s.set(1)
269
- expect(log).toEqual(['run-0', 'cleanup-0', 'run-1'])
270
- s.set(2)
271
- expect(log).toEqual(['run-0', 'cleanup-0', 'run-1', 'cleanup-1', 'run-2'])
272
- })
273
-
274
- test('runs cleanup on dispose', () => {
275
- let cleaned = false
276
- const e = effect(() => {
277
- onCleanup(() => {
278
- cleaned = true
279
- })
280
- })
281
- expect(cleaned).toBe(false)
282
- e.dispose()
283
- expect(cleaned).toBe(true)
284
- })
285
-
286
- test('supports multiple onCleanup calls', () => {
287
- const s = signal(0)
288
- const log: string[] = []
289
- effect(() => {
290
- s()
291
- onCleanup(() => log.push('a'))
292
- onCleanup(() => log.push('b'))
293
- })
294
- s.set(1)
295
- expect(log).toEqual(['a', 'b'])
296
- })
297
-
298
- test('works alongside return cleanup', () => {
299
- const s = signal(0)
300
- const log: string[] = []
301
- effect(() => {
302
- s()
303
- onCleanup(() => log.push('onCleanup'))
304
- return () => log.push('return')
305
- })
306
- s.set(1)
307
- expect(log).toEqual(['onCleanup', 'return'])
308
- })
309
-
310
- test('no-ops outside effect', () => {
311
- // Should not throw
312
- onCleanup(() => {})
313
- })
314
-
315
- test('cleanup ordering: onCleanup runs before return cleanup', () => {
316
- const s = signal(0)
317
- const log: string[] = []
318
- effect(() => {
319
- const val = s()
320
- onCleanup(() => log.push(`onCleanup-${val}`))
321
- return () => log.push(`return-${val}`)
322
- })
323
- s.set(1)
324
- // onCleanup should fire first, then return cleanup
325
- expect(log).toEqual(['onCleanup-0', 'return-0'])
326
- s.set(2)
327
- expect(log).toEqual(['onCleanup-0', 'return-0', 'onCleanup-1', 'return-1'])
328
- })
329
-
330
- test('multiple onCleanup callbacks run in registration order', () => {
331
- const s = signal(0)
332
- const log: string[] = []
333
- effect(() => {
334
- s()
335
- onCleanup(() => log.push('first'))
336
- onCleanup(() => log.push('second'))
337
- onCleanup(() => log.push('third'))
338
- })
339
- s.set(1)
340
- expect(log).toEqual(['first', 'second', 'third'])
341
- })
342
-
343
- test('cleanup runs on dispose even when effect never re-ran', () => {
344
- const log: string[] = []
345
- const e = effect(() => {
346
- onCleanup(() => log.push('disposed'))
347
- })
348
- expect(log).toEqual([])
349
- e.dispose()
350
- expect(log).toEqual(['disposed'])
351
- })
352
- })
353
-
354
- describe('effect — error handling', () => {
355
- test('error in effect does not prevent other effects from running', () => {
356
- const caught: unknown[] = []
357
- setErrorHandler((err) => caught.push(err))
358
-
359
- const s = signal(0)
360
- let goodEffectRuns = 0
361
-
362
- effect(() => {
363
- s()
364
- throw new Error('bad effect')
365
- })
366
- effect(() => {
367
- s()
368
- goodEffectRuns++
369
- })
370
-
371
- expect(caught).toHaveLength(1)
372
- expect(goodEffectRuns).toBe(1)
373
-
374
- s.set(1)
375
- expect(caught).toHaveLength(2)
376
- expect(goodEffectRuns).toBe(2)
377
-
378
- setErrorHandler((_err) => {})
379
- })
380
-
381
- test('error during cleanup is caught by error handler', () => {
382
- const caught: unknown[] = []
383
- setErrorHandler((err) => caught.push(err))
384
-
385
- const s = signal(0)
386
- effect(() => {
387
- s()
388
- onCleanup(() => {
389
- throw new Error('cleanup error')
390
- })
391
- })
392
-
393
- s.set(1) // triggers cleanup which throws
394
- expect(caught).toHaveLength(1)
395
- expect((caught[0] as Error).message).toBe('cleanup error')
396
-
397
- setErrorHandler((_err) => {})
398
- })
399
- })
400
-
401
- // ─── Audit bug #1: async effect runtime warning ──────────────────────────────
402
- //
403
- // Companion to the `pyreon/no-async-effect` lint rule. The runtime warn
404
- // catches the case where the lint rule was suppressed or the effect call
405
- // site was constructed dynamically (e.g. via a higher-order helper that
406
- // the lint rule's static check doesn't see).
407
-
408
- describe('effect — async function warning (audit bug #1)', () => {
409
- test('warns when called with an async arrow function', () => {
410
- const warns: string[] = []
411
- const orig = console.warn
412
- console.warn = (...args: unknown[]) => warns.push(args.join(' '))
413
- try {
414
- // Async effect callbacks are intentionally NOT in `effect()`'s type
415
- // signature — that's the point. The test deliberately misuses the
416
- // API to verify the runtime warning catches what the type system
417
- // would normally reject. Cast through `unknown` to silence TS.
418
- const asyncFn = async (): Promise<void> => {}
419
- effect(asyncFn as unknown as () => void)
420
- } finally {
421
- console.warn = orig
422
- }
423
- expect(warns.some((m) => m.includes('async function'))).toBe(true)
424
- expect(warns.some((m) => m.includes('await'))).toBe(true)
425
- })
426
-
427
- test('does NOT warn for synchronous effect callbacks', () => {
428
- const warns: string[] = []
429
- const orig = console.warn
430
- console.warn = (...args: unknown[]) => warns.push(args.join(' '))
431
- try {
432
- effect(() => {})
433
- } finally {
434
- console.warn = orig
435
- }
436
- expect(warns.some((m) => m.includes('async function'))).toBe(false)
437
- })
438
-
439
- test('renderEffect warns when called with an async arrow function', () => {
440
- const warns: string[] = []
441
- const orig = console.warn
442
- console.warn = (...args: unknown[]) => warns.push(args.join(' '))
443
- try {
444
- const asyncFn = async (): Promise<void> => {}
445
- renderEffect(asyncFn as unknown as () => void)
446
- } finally {
447
- console.warn = orig
448
- }
449
- expect(warns.some((m) => m.includes('renderEffect'))).toBe(true)
450
- expect(warns.some((m) => m.includes('async function'))).toBe(true)
451
- })
452
-
453
- test('renderEffect does NOT warn for synchronous callbacks', () => {
454
- const warns: string[] = []
455
- const orig = console.warn
456
- console.warn = (...args: unknown[]) => warns.push(args.join(' '))
457
- try {
458
- renderEffect(() => {})
459
- } finally {
460
- console.warn = orig
461
- }
462
- expect(warns.some((m) => m.includes('async function'))).toBe(false)
463
- })
464
- })
@@ -1,179 +0,0 @@
1
- /**
2
- * Reproduction of the deferred bug from PR #490 (queryReactiveKey-1000 journey).
3
- *
4
- * Symptom from real-app stress: a `reactKey` signal subscribed to by ~100
5
- * effects (each useQuery's setOptions effect) sees `signalWrite` increment
6
- * on every `.set(i)` in a tight external loop, but only 1 of N effect runs
7
- * propagates per .set — `effectRun` stays at the initial-mount count.
8
- *
9
- * Hypothesis to test: `notifySubscribers` in tracking.ts iterates the live
10
- * Set with `originalSize` cap. If an effect's body calls cleanupEffect (which
11
- * removes itself from the Set) AND re-subscribes (adds itself back at the
12
- * end), the iteration order shifts so subsequent effects' positions move
13
- * BEFORE `i`, causing them to be skipped on this pass.
14
- *
15
- * See packages/core/reactivity/src/tracking.ts:77-105 (notifySubscribers).
16
- */
17
- import { describe, expect, it } from 'vitest'
18
- import { effect, effectScope, signal } from '../index'
19
-
20
- describe('signal fan-out under tight external write loop', () => {
21
- it('100 effects subscribing to same signal — each fires on every external .set', () => {
22
- const sig = signal(0)
23
- const counts = new Array(100).fill(0)
24
-
25
- for (let i = 0; i < 100; i++) {
26
- const idx = i
27
- effect(() => {
28
- sig() // subscribe
29
- counts[idx]++
30
- })
31
- }
32
-
33
- // Each effect ran ONCE at registration.
34
- for (const c of counts) expect(c).toBe(1)
35
-
36
- // 10 external writes from outside any batch.
37
- for (let i = 1; i <= 10; i++) sig.set(i)
38
-
39
- // Each effect should have re-fired 10 times → total = 11.
40
- for (let i = 0; i < counts.length; i++) {
41
- expect(counts[i], `effect[${i}] runs after 10 sets`).toBe(11)
42
- }
43
- })
44
-
45
- it('1 effect — fires on every external .set (control)', () => {
46
- const sig = signal(0)
47
- let count = 0
48
-
49
- effect(() => {
50
- sig()
51
- count++
52
- })
53
-
54
- expect(count).toBe(1)
55
- for (let i = 1; i <= 10; i++) sig.set(i)
56
- expect(count).toBe(11)
57
- })
58
-
59
- it('5 effects + 5 external sets — each effect fires per set', () => {
60
- const sig = signal(0)
61
- const counts = new Array(5).fill(0)
62
-
63
- for (let i = 0; i < 5; i++) {
64
- const idx = i
65
- effect(() => {
66
- sig()
67
- counts[idx]++
68
- })
69
- }
70
-
71
- for (let i = 1; i <= 5; i++) sig.set(i)
72
-
73
- // Each effect: 1 initial + 5 re-runs = 6.
74
- for (let i = 0; i < counts.length; i++) {
75
- expect(counts[i], `effect[${i}]`).toBe(6)
76
- }
77
- })
78
-
79
- it('100 effects inside an EffectScope — each fires on every external .set', () => {
80
- const sig = signal(0)
81
- const counts = new Array(100).fill(0)
82
- const scope = effectScope()
83
-
84
- scope.runInScope(() => {
85
- for (let i = 0; i < 100; i++) {
86
- const idx = i
87
- effect(() => {
88
- sig()
89
- counts[idx]++
90
- })
91
- }
92
- })
93
-
94
- for (const c of counts) expect(c).toBe(1)
95
-
96
- for (let i = 1; i <= 10; i++) sig.set(i)
97
-
98
- for (let i = 0; i < counts.length; i++) {
99
- expect(counts[i], `effect[${i}] runs after 10 sets`).toBe(11)
100
- }
101
- })
102
-
103
- it('1000 effects subscribing to same signal — each fires per .set', () => {
104
- const sig = signal(0)
105
- const counts = new Array(1000).fill(0)
106
- for (let i = 0; i < 1000; i++) {
107
- const idx = i
108
- effect(() => {
109
- sig()
110
- counts[idx]++
111
- })
112
- }
113
- for (let i = 1; i <= 10; i++) sig.set(i)
114
- let failed = 0
115
- for (let i = 0; i < counts.length; i++) {
116
- if (counts[i] !== 11) failed++
117
- }
118
- expect(failed, `effects with wrong count`).toBe(0)
119
- })
120
-
121
- it('effects created INSIDE another effect run (inner-effect collector path)', () => {
122
- const trigger = signal(0)
123
- const sig = signal(0)
124
- const counts = new Array(50).fill(0)
125
- let outerRuns = 0
126
-
127
- // Outer effect creates 50 INNER effects on each run.
128
- effect(() => {
129
- trigger()
130
- outerRuns++
131
- for (let i = 0; i < 50; i++) {
132
- const idx = i
133
- effect(() => {
134
- sig()
135
- counts[idx]++
136
- })
137
- }
138
- })
139
-
140
- // Reset counts after initial outer run created the inners with their initial run.
141
- expect(outerRuns).toBe(1)
142
- for (const c of counts) expect(c).toBe(1)
143
-
144
- // Now flip sig 10 times — every inner should fire 10 more times.
145
- for (let i = 1; i <= 10; i++) sig.set(i)
146
-
147
- let failed = 0
148
- for (let i = 0; i < counts.length; i++) {
149
- if (counts[i] !== 11) failed++
150
- }
151
- expect(failed, `inner effects with wrong count`).toBe(0)
152
- })
153
-
154
- it('100 effects whose body writes to an unrelated signal during run', () => {
155
- // Mirrors useQuery's effect: body calls observer.setOptions(options())
156
- // which fires the observer's subscribe callback, which calls batch()
157
- // and writes to N "result slot" signals (which may have 0 or N subscribers).
158
- const sig = signal(0)
159
- const slot = signal('')
160
- const counts = new Array(100).fill(0)
161
-
162
- for (let i = 0; i < 100; i++) {
163
- const idx = i
164
- effect(() => {
165
- sig() // subscribe to sig
166
- // Simulate observer.setOptions's downstream subscribe-callback work:
167
- // an inner write to a different signal that has no subscribers.
168
- slot.set(`run-${idx}`) // 100 different values, each Object.is fails
169
- counts[idx]++
170
- })
171
- }
172
-
173
- for (let i = 1; i <= 10; i++) sig.set(i)
174
-
175
- for (let i = 0; i < counts.length; i++) {
176
- expect(counts[i], `effect[${i}] runs after 10 sets`).toBe(11)
177
- }
178
- })
179
- })