@pyreon/reactivity 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +230 -47
- package/lib/types/index.d.ts +95 -3
- package/package.json +5 -4
- package/src/batch.ts +155 -32
- package/src/computed.ts +21 -6
- package/src/createSelector.ts +44 -12
- package/src/effect.ts +149 -14
- package/src/env.d.ts +6 -0
- package/src/index.ts +18 -3
- package/src/manifest.ts +372 -5
- package/src/reconcile.ts +9 -1
- package/src/resource.ts +19 -1
- package/src/scope.ts +38 -0
- package/src/signal.ts +29 -12
- package/src/store.ts +111 -11
- package/src/tests/batch.test.ts +605 -0
- package/src/tests/computed.test.ts +86 -0
- package/src/tests/createSelector.test.ts +59 -0
- package/src/tests/effect.test.ts +65 -0
- package/src/tests/fanout-repro.test.ts +179 -0
- package/src/tests/manifest-snapshot.test.ts +17 -1
- package/src/tests/resource.test.ts +93 -0
- package/src/tests/scope.test.ts +29 -0
- package/src/tests/signal.test.ts +108 -0
- package/src/tests/store.test.ts +54 -0
- package/src/tests/vue-parity.test.ts +191 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -229,4 +229,63 @@ describe('createSelector', () => {
|
|
|
229
229
|
expect(isSelected(2)).toBe(false)
|
|
230
230
|
})
|
|
231
231
|
})
|
|
232
|
+
|
|
233
|
+
// Regression: pre-fix, the source-tracking effect ran forever AND the
|
|
234
|
+
// per-value subs/hosts Maps grew unboundedly. With dynamic value spaces
|
|
235
|
+
// (UUIDs, ephemeral IDs) this leaked memory for the lifetime of the
|
|
236
|
+
// program. dispose() now stops the effect AND clears both Maps.
|
|
237
|
+
describe('dispose', () => {
|
|
238
|
+
test('stops source-tracking after dispose', () => {
|
|
239
|
+
const selected = signal(0)
|
|
240
|
+
const isSelected = createSelector(() => selected())
|
|
241
|
+
const runs: number[] = []
|
|
242
|
+
effect(() => {
|
|
243
|
+
runs.push(isSelected(1) ? 1 : 0)
|
|
244
|
+
})
|
|
245
|
+
expect(runs).toEqual([0])
|
|
246
|
+
selected.set(1)
|
|
247
|
+
expect(runs).toEqual([0, 1])
|
|
248
|
+
|
|
249
|
+
isSelected.dispose()
|
|
250
|
+
// Source change after dispose — the effect that tracks `isSelected(1)`
|
|
251
|
+
// should NOT re-run because the selector's internal effect was stopped.
|
|
252
|
+
selected.set(2)
|
|
253
|
+
expect(runs).toEqual([0, 1])
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('post-dispose calls return last-known result without tracking', () => {
|
|
257
|
+
const selected = signal(0)
|
|
258
|
+
const isSelected = createSelector(() => selected())
|
|
259
|
+
isSelected.dispose()
|
|
260
|
+
// Last known value was 0 — calls return its match against the query.
|
|
261
|
+
expect(isSelected(0)).toBe(true)
|
|
262
|
+
expect(isSelected(1)).toBe(false)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('dispose is idempotent', () => {
|
|
266
|
+
const selected = signal(0)
|
|
267
|
+
const isSelected = createSelector(() => selected())
|
|
268
|
+
expect(() => {
|
|
269
|
+
isSelected.dispose()
|
|
270
|
+
isSelected.dispose()
|
|
271
|
+
isSelected.dispose()
|
|
272
|
+
}).not.toThrow()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('post-dispose: source no longer propagates to selector', () => {
|
|
276
|
+
// Observable consequence of stopping the internal effect — proves
|
|
277
|
+
// the per-value Maps are no longer being populated either (because
|
|
278
|
+
// the tracking path is bypassed entirely after dispose).
|
|
279
|
+
const selected = signal('initial')
|
|
280
|
+
const isSelected = createSelector(() => selected())
|
|
281
|
+
// Build up the cache by querying many unique values.
|
|
282
|
+
for (let i = 0; i < 100; i++) isSelected(`uuid-${i}`)
|
|
283
|
+
isSelected.dispose()
|
|
284
|
+
// After dispose, the source is no longer tracked — changing it
|
|
285
|
+
// doesn't propagate to the selector's internal `current` value.
|
|
286
|
+
selected.set('changed')
|
|
287
|
+
expect(isSelected('initial')).toBe(true) // last-known wins
|
|
288
|
+
expect(isSelected('changed')).toBe(false) // never propagated
|
|
289
|
+
})
|
|
290
|
+
})
|
|
232
291
|
})
|
package/src/tests/effect.test.ts
CHANGED
|
@@ -397,3 +397,68 @@ describe('effect — error handling', () => {
|
|
|
397
397
|
setErrorHandler((_err) => {})
|
|
398
398
|
})
|
|
399
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
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
})
|
|
@@ -68,11 +68,27 @@ describe('gen-docs — reactivity snapshot', () => {
|
|
|
68
68
|
|
|
69
69
|
it('renders @pyreon/reactivity to MCP api-reference entries — one per api[] item', () => {
|
|
70
70
|
const record = renderApiReferenceEntries(reactivityManifest)
|
|
71
|
-
|
|
71
|
+
// 25 entries: 8 original (signal/computed/effect/batch/onCleanup/watch/
|
|
72
|
+
// createStore/untrack) + 1 createResource (PR #459) + 13 from M1
|
|
73
|
+
// enrichment (renderEffect, nextTick, createSelector, cell, reconcile,
|
|
74
|
+
// isStore, effectScope, getCurrentScope, setCurrentScope,
|
|
75
|
+
// onSignalUpdate, inspectSignal, why, setErrorHandler) + 3 from M4
|
|
76
|
+
// Vue parity (markRaw, shallowReactive, onScopeDispose).
|
|
77
|
+
expect(Object.keys(record).length).toBe(25)
|
|
72
78
|
expect(Object.keys(record)).toContain('reactivity/signal')
|
|
79
|
+
expect(Object.keys(record)).toContain('reactivity/createResource')
|
|
73
80
|
// Spot-check the flagship API — signal is the core primitive
|
|
74
81
|
const signal = record['reactivity/signal']!
|
|
75
82
|
expect(signal.mistakes?.split('\n').length).toBe(6)
|
|
76
83
|
expect(signal.notes).toContain('CALLABLE FUNCTION')
|
|
84
|
+
// Spot-check createResource has the dispose mistake (regression for H3)
|
|
85
|
+
const resource = record['reactivity/createResource']!
|
|
86
|
+
expect(resource.mistakes).toContain('Forgetting `dispose()`')
|
|
87
|
+
// Spot-check newly-added entries surface their key foot-guns
|
|
88
|
+
expect(record['reactivity/createSelector']!.mistakes).toContain(
|
|
89
|
+
'every row subscribes to source',
|
|
90
|
+
)
|
|
91
|
+
expect(record['reactivity/effectScope']!.mistakes).toContain('leak')
|
|
92
|
+
expect(record['reactivity/cell']!.notes).toContain('NOT callable')
|
|
77
93
|
})
|
|
78
94
|
})
|
|
@@ -230,4 +230,97 @@ describe('createResource', () => {
|
|
|
230
230
|
await new Promise((r) => setTimeout(r, 10))
|
|
231
231
|
expect(results).toEqual(['user-1', 'user-5', 'user-5'])
|
|
232
232
|
})
|
|
233
|
+
|
|
234
|
+
// Regression: pre-fix, the source-tracking effect ran forever with no API
|
|
235
|
+
// to stop it. Resources created outside an EffectScope leaked the effect
|
|
236
|
+
// for the lifetime of the program. dispose() now stops the effect AND
|
|
237
|
+
// marks pending in-flight responses as stale.
|
|
238
|
+
describe('dispose', () => {
|
|
239
|
+
test('stops source-tracking after dispose', async () => {
|
|
240
|
+
const src = signal(1)
|
|
241
|
+
const calls: number[] = []
|
|
242
|
+
const resource = createResource(
|
|
243
|
+
() => src(),
|
|
244
|
+
(id) => {
|
|
245
|
+
calls.push(id)
|
|
246
|
+
return Promise.resolve(`user-${id}`)
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
251
|
+
expect(calls).toEqual([1])
|
|
252
|
+
|
|
253
|
+
resource.dispose()
|
|
254
|
+
|
|
255
|
+
// Source change after dispose — should NOT trigger a new fetch.
|
|
256
|
+
src.set(2)
|
|
257
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
258
|
+
expect(calls).toEqual([1])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('refetch is a no-op after dispose', async () => {
|
|
262
|
+
const src = signal(1)
|
|
263
|
+
const calls: number[] = []
|
|
264
|
+
const resource = createResource(
|
|
265
|
+
() => src(),
|
|
266
|
+
(id) => {
|
|
267
|
+
calls.push(id)
|
|
268
|
+
return Promise.resolve(`user-${id}`)
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
273
|
+
expect(calls).toEqual([1])
|
|
274
|
+
|
|
275
|
+
resource.dispose()
|
|
276
|
+
resource.refetch()
|
|
277
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
278
|
+
expect(calls).toEqual([1])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('in-flight response is discarded after dispose', async () => {
|
|
282
|
+
const src = signal(1)
|
|
283
|
+
// Capture the resolver via a one-element holder so TS doesn't narrow
|
|
284
|
+
// the closure-assigned variable to `never` after the new Promise
|
|
285
|
+
// callback returns. `let r = null` then assignment inside the
|
|
286
|
+
// callback narrows to `never` post-callback (no follow-callback flow).
|
|
287
|
+
const holder: { resolve: (v: string) => void } = {
|
|
288
|
+
resolve: () => undefined,
|
|
289
|
+
}
|
|
290
|
+
const resource = createResource(
|
|
291
|
+
() => src(),
|
|
292
|
+
() =>
|
|
293
|
+
new Promise<string>((r) => {
|
|
294
|
+
holder.resolve = r
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
// Don't resolve yet — fetch is in flight.
|
|
299
|
+
expect(resource.loading()).toBe(true)
|
|
300
|
+
resource.dispose()
|
|
301
|
+
|
|
302
|
+
// Now resolve — handlers should see the bumped requestId and discard.
|
|
303
|
+
holder.resolve('late-result')
|
|
304
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
305
|
+
|
|
306
|
+
expect(resource.data()).toBeUndefined()
|
|
307
|
+
// loading was true at dispose; response was discarded so the
|
|
308
|
+
// post-fetch `loading.set(false)` never ran. That's the documented
|
|
309
|
+
// contract — dispose freezes the resource at its current state.
|
|
310
|
+
expect(resource.loading()).toBe(true)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('dispose is idempotent', () => {
|
|
314
|
+
const src = signal(1)
|
|
315
|
+
const resource = createResource(
|
|
316
|
+
() => src(),
|
|
317
|
+
(id) => Promise.resolve(`user-${id}`),
|
|
318
|
+
)
|
|
319
|
+
expect(() => {
|
|
320
|
+
resource.dispose()
|
|
321
|
+
resource.dispose()
|
|
322
|
+
resource.dispose()
|
|
323
|
+
}).not.toThrow()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
233
326
|
})
|
package/src/tests/scope.test.ts
CHANGED
|
@@ -199,4 +199,33 @@ describe('effectScope', () => {
|
|
|
199
199
|
expect(errors.length).toBe(1)
|
|
200
200
|
console.error = origError
|
|
201
201
|
})
|
|
202
|
+
|
|
203
|
+
// Regression: pre-fix, addUpdateHook always allocated `_updateHooks` and
|
|
204
|
+
// pushed the fn even after `stop()`. The fn never fired (because
|
|
205
|
+
// notifyEffectRan checks `_active` first), but the registration leaked
|
|
206
|
+
// an array allocation and gave the caller no signal that the hook was
|
|
207
|
+
// futile. Mirrors `add()`'s silent-no-op-when-stopped contract.
|
|
208
|
+
test('addUpdateHook honors _active — silently no-ops on stopped scope', () => {
|
|
209
|
+
const scope = effectScope()
|
|
210
|
+
scope.stop()
|
|
211
|
+
|
|
212
|
+
let hookFired = false
|
|
213
|
+
scope.addUpdateHook(() => {
|
|
214
|
+
hookFired = true
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Cause an update — the hook should NOT fire because scope is stopped.
|
|
218
|
+
scope.notifyEffectRan()
|
|
219
|
+
expect(hookFired).toBe(false)
|
|
220
|
+
|
|
221
|
+
// The internal `_updateHooks` should not have been allocated either.
|
|
222
|
+
// We can't introspect the private field, but the observable contract
|
|
223
|
+
// is "stopped scopes don't fire hooks under any circumstance" — which
|
|
224
|
+
// would have held pre-fix too because `notifyEffectRan` already
|
|
225
|
+
// gated on `_active`. The fix prevents the array allocation; we test
|
|
226
|
+
// the consequence: no fire, regardless of how often you push.
|
|
227
|
+
for (let i = 0; i < 100; i++) scope.addUpdateHook(() => {})
|
|
228
|
+
scope.notifyEffectRan()
|
|
229
|
+
expect(hookFired).toBe(false)
|
|
230
|
+
})
|
|
202
231
|
})
|
package/src/tests/signal.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { batch } from '../batch'
|
|
2
|
+
import { onSignalUpdate } from '../debug'
|
|
2
3
|
import { effect } from '../effect'
|
|
3
4
|
import { signal } from '../signal'
|
|
4
5
|
|
|
@@ -231,4 +232,111 @@ describe('signal', () => {
|
|
|
231
232
|
warnSpy.mockRestore()
|
|
232
233
|
})
|
|
233
234
|
})
|
|
235
|
+
|
|
236
|
+
// Regression: pre-fix, a throwing trace listener (registered via
|
|
237
|
+
// onSignalUpdate) was called inline between the `_v` write and subscriber
|
|
238
|
+
// notification. If it threw, `_v` was updated but no effects ran — divergent
|
|
239
|
+
// state. Fix wraps the trace dispatch in try/catch.
|
|
240
|
+
describe('throwing trace listener does not corrupt state', () => {
|
|
241
|
+
test('subscribers still fire when a trace listener throws', () => {
|
|
242
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
243
|
+
const s = signal(0)
|
|
244
|
+
const subscriberRuns: number[] = []
|
|
245
|
+
effect(() => {
|
|
246
|
+
subscriberRuns.push(s())
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const dispose = onSignalUpdate(() => {
|
|
250
|
+
throw new Error('trace listener boom')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// Pre-fix: the throwing listener would prevent the subscriber from
|
|
254
|
+
// firing. Post-fix: the listener throws, gets logged, subscriber runs.
|
|
255
|
+
s.set(5)
|
|
256
|
+
expect(subscriberRuns).toEqual([0, 5])
|
|
257
|
+
// Dev mode logs the listener error — bisect-verify the wrap is in place
|
|
258
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
259
|
+
expect.stringContaining('trace listener threw'),
|
|
260
|
+
expect.any(Error),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
dispose()
|
|
264
|
+
errorSpy.mockRestore()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('multiple writes survive a chronically-broken listener', () => {
|
|
268
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
269
|
+
const s = signal(0)
|
|
270
|
+
const seen: number[] = []
|
|
271
|
+
effect(() => {
|
|
272
|
+
seen.push(s())
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const dispose = onSignalUpdate(() => {
|
|
276
|
+
throw new Error('always')
|
|
277
|
+
})
|
|
278
|
+
s.set(1)
|
|
279
|
+
s.set(2)
|
|
280
|
+
s.set(3)
|
|
281
|
+
expect(seen).toEqual([0, 1, 2, 3])
|
|
282
|
+
|
|
283
|
+
dispose()
|
|
284
|
+
errorSpy.mockRestore()
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// L7 audit gap: cap-iteration in notifySubscribers caps at originalSize to
|
|
289
|
+
// avoid infinite loops if a subscriber re-inserts itself or others into the
|
|
290
|
+
// Set. The contract: subscribers added DURING notification fire next round,
|
|
291
|
+
// not this round (no double-fire). Pin the contract.
|
|
292
|
+
describe('subscriber cap-iteration during notification (L7)', () => {
|
|
293
|
+
test('subscriber added mid-notification fires on the NEXT write, not the current one', () => {
|
|
294
|
+
const s = signal(0)
|
|
295
|
+
const fires: string[] = []
|
|
296
|
+
|
|
297
|
+
const lateSubscriber = (): void => {
|
|
298
|
+
fires.push('late')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
s.subscribe(() => {
|
|
302
|
+
fires.push('first')
|
|
303
|
+
// Add a NEW subscriber during the notification. Per cap-iteration
|
|
304
|
+
// contract, it should NOT fire this round.
|
|
305
|
+
s.subscribe(lateSubscriber)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
s.set(1)
|
|
309
|
+
// Only "first" fires this round — "late" gets registered, doesn't run.
|
|
310
|
+
expect(fires).toEqual(['first'])
|
|
311
|
+
|
|
312
|
+
// Next write — both fire (first was already there, late is now registered).
|
|
313
|
+
fires.length = 0
|
|
314
|
+
s.set(2)
|
|
315
|
+
expect(fires.sort()).toEqual(['first', 'late'])
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('subscriber that disposes itself mid-iteration cleans up cleanly', () => {
|
|
319
|
+
const s = signal(0)
|
|
320
|
+
let disposeMe: (() => void) | null = null
|
|
321
|
+
let firstRuns = 0
|
|
322
|
+
let secondRuns = 0
|
|
323
|
+
|
|
324
|
+
s.subscribe(() => {
|
|
325
|
+
firstRuns++
|
|
326
|
+
})
|
|
327
|
+
disposeMe = s.subscribe(() => {
|
|
328
|
+
secondRuns++
|
|
329
|
+
disposeMe?.() // self-dispose
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
s.set(1)
|
|
333
|
+
expect(firstRuns).toBe(1)
|
|
334
|
+
expect(secondRuns).toBe(1)
|
|
335
|
+
|
|
336
|
+
// Next write — second is gone, first still fires.
|
|
337
|
+
s.set(2)
|
|
338
|
+
expect(firstRuns).toBe(2)
|
|
339
|
+
expect(secondRuns).toBe(1)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
234
342
|
})
|
package/src/tests/store.test.ts
CHANGED
|
@@ -143,6 +143,60 @@ describe('createStore', () => {
|
|
|
143
143
|
expect(bValues).toEqual([2, undefined])
|
|
144
144
|
})
|
|
145
145
|
|
|
146
|
+
// Regression: delete-then-reassign cycle. Pre-fix `deleteProperty` removed
|
|
147
|
+
// the entry from `propSignals`, so a later `set` on the same key created a
|
|
148
|
+
// FRESH signal — but every effect that previously read the key was tracked
|
|
149
|
+
// against the OLD signal, which had been dropped. Effects never re-ran on
|
|
150
|
+
// the reassign. Fix keeps the signal in `propSignals`, and `get` short-
|
|
151
|
+
// circuits to the existing signal when the key isn't `hasOwn` but a tracked
|
|
152
|
+
// signal exists. See store.ts (deleteProperty, get).
|
|
153
|
+
test('delete-then-reassign re-runs effects (signal identity preserved)', () => {
|
|
154
|
+
const state = createStore({ a: 1, b: 2 } as Record<string, number | undefined>)
|
|
155
|
+
const bValues: (number | undefined)[] = []
|
|
156
|
+
effect(() => {
|
|
157
|
+
bValues.push(state.b)
|
|
158
|
+
})
|
|
159
|
+
expect(bValues).toEqual([2])
|
|
160
|
+
delete state.b
|
|
161
|
+
expect(bValues).toEqual([2, undefined])
|
|
162
|
+
state.b = 99
|
|
163
|
+
expect(bValues).toEqual([2, undefined, 99])
|
|
164
|
+
// Cycle a second time to lock the contract — same signal identity reused.
|
|
165
|
+
delete state.b
|
|
166
|
+
state.b = 7
|
|
167
|
+
expect(bValues).toEqual([2, undefined, 99, undefined, 7])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Regression: built-in objects with internal slots (Map, Set, Date, …) used
|
|
171
|
+
// to be wrapped in the proxy, which broke their methods (`Map.prototype.set`
|
|
172
|
+
// called on a Proxy → `TypeError: incompatible receiver`). Fix returns the
|
|
173
|
+
// raw instance from `wrap()` for these types — no fine-grained reactivity
|
|
174
|
+
// for their contents, but they're at least usable. See store.ts
|
|
175
|
+
// (isBuiltinNonProxiable).
|
|
176
|
+
test('Map fields are returned raw, not wrapped (proxy-incompatible)', () => {
|
|
177
|
+
const state = createStore({ users: new Map<string, number>() })
|
|
178
|
+
expect(() => state.users.set('alice', 1)).not.toThrow()
|
|
179
|
+
expect(state.users.get('alice')).toBe(1)
|
|
180
|
+
expect(state.users.size).toBe(1)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('Set fields are returned raw, not wrapped', () => {
|
|
184
|
+
const state = createStore({ tags: new Set<string>() })
|
|
185
|
+
expect(() => state.tags.add('foo')).not.toThrow()
|
|
186
|
+
expect(state.tags.has('foo')).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('Date fields are returned raw, not wrapped', () => {
|
|
190
|
+
const d = new Date('2026-01-01')
|
|
191
|
+
const state = createStore({ created: d })
|
|
192
|
+
expect(state.created.getFullYear()).toBe(2026)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('RegExp fields are returned raw, not wrapped', () => {
|
|
196
|
+
const state = createStore({ rx: /abc/ })
|
|
197
|
+
expect(state.rx.test('abc')).toBe(true)
|
|
198
|
+
})
|
|
199
|
+
|
|
146
200
|
test('setting array length directly triggers reactivity', () => {
|
|
147
201
|
const state = createStore({ items: [1, 2, 3, 4, 5] })
|
|
148
202
|
const lengths: number[] = []
|