@pyreon/reactivity 0.15.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 +145 -25
- package/lib/types/index.d.ts +82 -3
- package/package.json +1 -1
- package/src/batch.ts +21 -1
- package/src/createSelector.ts +44 -12
- package/src/index.ts +8 -2
- 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 +26 -2
- package/src/store.ts +111 -11
- package/src/tests/batch.test.ts +187 -0
- package/src/tests/computed.test.ts +54 -0
- package/src/tests/createSelector.test.ts +59 -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/src/store.ts
CHANGED
|
@@ -17,8 +17,60 @@ import { type Signal, signal } from './signal'
|
|
|
17
17
|
|
|
18
18
|
// WeakMap: raw object → its reactive proxy (ensures each raw object gets one proxy)
|
|
19
19
|
const proxyCache = new WeakMap<object, object>()
|
|
20
|
+
// Separate cache for shallow proxies — same raw can produce different proxies
|
|
21
|
+
// depending on shallow vs deep mode, so we can't share proxyCache.
|
|
22
|
+
const shallowProxyCache = new WeakMap<object, object>()
|
|
20
23
|
|
|
21
24
|
const IS_STORE = Symbol('pyreon.store')
|
|
25
|
+
const IS_RAW = Symbol('pyreon.raw')
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mark an object as RAW — `createStore` and `shallowReactive` will return it
|
|
29
|
+
* unwrapped. Useful when storing class instances, third-party objects, or
|
|
30
|
+
* other shapes that shouldn't be deeply proxied (Vue 3 parity).
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const cm = markRaw(new CodeMirrorView(...))
|
|
34
|
+
* const store = createStore({ editor: cm })
|
|
35
|
+
* store.editor === cm // true (not wrapped)
|
|
36
|
+
*
|
|
37
|
+
* Note: marking is one-way — there's no `unmarkRaw`. Mark BEFORE the object
|
|
38
|
+
* enters a store; marking after wrap doesn't unwrap an existing proxy.
|
|
39
|
+
*/
|
|
40
|
+
export function markRaw<T extends object>(value: T): T {
|
|
41
|
+
Object.defineProperty(value, IS_RAW, {
|
|
42
|
+
value: true,
|
|
43
|
+
enumerable: false,
|
|
44
|
+
configurable: true,
|
|
45
|
+
writable: false,
|
|
46
|
+
})
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Returns true if the value was marked with `markRaw()`. */
|
|
51
|
+
function isMarkedRaw(value: object): boolean {
|
|
52
|
+
return (value as Record<symbol, unknown>)[IS_RAW] === true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Built-in object types that have internal slots and fail the Proxy
|
|
56
|
+
// internal-slot check on every method call (`Map.prototype.set` called on a
|
|
57
|
+
// Proxy → `TypeError: Method ... called on incompatible receiver`). Returning
|
|
58
|
+
// the raw instance keeps these usable but at the cost of fine-grained
|
|
59
|
+
// reactivity for their contents — write replace-the-whole-Map style if you
|
|
60
|
+
// need reactivity (`store.users = new Map(store.users)`). A future PR can
|
|
61
|
+
// add Vue-style collection-aware wrapping for Map/Set if demand emerges.
|
|
62
|
+
function isBuiltinNonProxiable(obj: object): boolean {
|
|
63
|
+
return (
|
|
64
|
+
obj instanceof Map ||
|
|
65
|
+
obj instanceof Set ||
|
|
66
|
+
obj instanceof WeakMap ||
|
|
67
|
+
obj instanceof WeakSet ||
|
|
68
|
+
obj instanceof Date ||
|
|
69
|
+
obj instanceof RegExp ||
|
|
70
|
+
obj instanceof Promise ||
|
|
71
|
+
obj instanceof Error
|
|
72
|
+
)
|
|
73
|
+
}
|
|
22
74
|
|
|
23
75
|
/** Returns true if the value is a createStore proxy. */
|
|
24
76
|
export function isStore(value: unknown): boolean {
|
|
@@ -34,11 +86,41 @@ export function isStore(value: unknown): boolean {
|
|
|
34
86
|
* Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
|
|
35
87
|
*/
|
|
36
88
|
export function createStore<T extends object>(initial: T): T {
|
|
37
|
-
return wrap(initial) as T
|
|
89
|
+
return wrap(initial, false) as T
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a SHALLOW reactive store — only top-level mutations trigger updates.
|
|
94
|
+
* Nested objects are NOT auto-wrapped; reading a nested object returns the
|
|
95
|
+
* raw reference. Use when:
|
|
96
|
+
* - the nested objects are immutable (frozen API responses)
|
|
97
|
+
* - you want explicit control over which subtrees are reactive
|
|
98
|
+
* - you need to store class instances or third-party objects without
|
|
99
|
+
* paying the deep-proxy overhead
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
|
|
103
|
+
* effect(() => console.log(store.user)) // tracks store.user reference
|
|
104
|
+
* effect(() => console.log(store.count)) // tracks store.count
|
|
105
|
+
* store.user.name = 'Bob' // does NOT trigger any effect
|
|
106
|
+
* store.count = 5 // triggers the count effect
|
|
107
|
+
* store.user = { name: 'Bob' } // triggers the user effect
|
|
108
|
+
*/
|
|
109
|
+
export function shallowReactive<T extends object>(initial: T): T {
|
|
110
|
+
return wrap(initial, true) as T
|
|
38
111
|
}
|
|
39
112
|
|
|
40
|
-
function wrap(raw: object): object {
|
|
41
|
-
|
|
113
|
+
function wrap(raw: object, shallow: boolean): object {
|
|
114
|
+
// Built-ins with internal slots (Map, Set, Date, …) can't be proxied: their
|
|
115
|
+
// methods fail the receiver check when called on the proxy. Return raw.
|
|
116
|
+
if (isBuiltinNonProxiable(raw)) return raw
|
|
117
|
+
// Vue parity — `markRaw()` opts an object out of proxying entirely. Useful
|
|
118
|
+
// for class instances, third-party objects, or any shape the consumer
|
|
119
|
+
// wants to keep unwrapped.
|
|
120
|
+
if (isMarkedRaw(raw)) return raw
|
|
121
|
+
|
|
122
|
+
const cache = shallow ? shallowProxyCache : proxyCache
|
|
123
|
+
const cached = cache.get(raw)
|
|
42
124
|
if (cached) return cached
|
|
43
125
|
|
|
44
126
|
// Per-property signals. Lazily created on first access.
|
|
@@ -63,19 +145,29 @@ function wrap(raw: object): object {
|
|
|
63
145
|
// Array length — tracked via dedicated signal for push/pop/splice reactivity
|
|
64
146
|
if (isArray && key === 'length') return lengthSig?.()
|
|
65
147
|
|
|
66
|
-
// Non-own properties
|
|
67
|
-
//
|
|
68
|
-
//
|
|
148
|
+
// Non-own properties without a tracked signal: prototype methods
|
|
149
|
+
// (forEach, map, push, …) returned untracked so array methods work.
|
|
150
|
+
// BUT if a signal already exists for this key, the property was tracked
|
|
151
|
+
// before — most likely the property is currently absent because of a
|
|
152
|
+
// `delete` operation. Continue tracking via the existing signal so that
|
|
153
|
+
// a subsequent reassign (`state.b = 99`) re-runs effects that read the
|
|
154
|
+
// key during its absent window. Without this branch, the `delete` →
|
|
155
|
+
// notify-undefined → effect-re-runs-and-reads-`undefined`-via-this-fast-
|
|
156
|
+
// path → effect-loses-subscription chain breaks reactivity for any
|
|
157
|
+
// delete-then-reassign cycle.
|
|
69
158
|
if (!Object.hasOwn(target, key)) {
|
|
159
|
+
if (propSignals.has(key)) return propSignals.get(key)?.()
|
|
70
160
|
return (target as Record<PropertyKey, unknown>)[key]
|
|
71
161
|
}
|
|
72
162
|
|
|
73
163
|
// Track via per-property signal
|
|
74
164
|
const value = getOrCreateSignal(key)()
|
|
75
165
|
|
|
76
|
-
// Deep reactivity: wrap nested objects/arrays transparently
|
|
77
|
-
|
|
78
|
-
|
|
166
|
+
// Deep reactivity: wrap nested objects/arrays transparently. Shallow
|
|
167
|
+
// mode skips this — nested objects are returned raw so consumers can
|
|
168
|
+
// mutate them outside the proxy without triggering effects.
|
|
169
|
+
if (!shallow && value !== null && typeof value === 'object') {
|
|
170
|
+
return wrap(value as object, false)
|
|
79
171
|
}
|
|
80
172
|
|
|
81
173
|
return value
|
|
@@ -113,9 +205,17 @@ function wrap(raw: object): object {
|
|
|
113
205
|
|
|
114
206
|
deleteProperty(target, key) {
|
|
115
207
|
delete (target as Record<PropertyKey, unknown>)[key]
|
|
208
|
+
// Notify subscribers that the property is now undefined, but KEEP the
|
|
209
|
+
// signal in `propSignals`. If we delete the entry, a later `set` on the
|
|
210
|
+
// same key creates a fresh signal — but every effect that previously
|
|
211
|
+
// read this key tracked the old (dropped) signal and never re-runs on
|
|
212
|
+
// the reassign. Keeping the entry preserves signal identity across
|
|
213
|
+
// delete-then-reassign cycles. The trade-off is that long-lived stores
|
|
214
|
+
// with high churn on transient keys retain those signal entries; for
|
|
215
|
+
// workloads where that's a real leak, reassign to undefined instead of
|
|
216
|
+
// delete.
|
|
116
217
|
if (typeof key !== 'symbol' && propSignals.has(key)) {
|
|
117
218
|
propSignals.get(key)?.set(undefined)
|
|
118
|
-
propSignals.delete(key)
|
|
119
219
|
}
|
|
120
220
|
if (isArray) lengthSig?.set((target as unknown[]).length)
|
|
121
221
|
return true
|
|
@@ -134,6 +234,6 @@ function wrap(raw: object): object {
|
|
|
134
234
|
},
|
|
135
235
|
})
|
|
136
236
|
|
|
137
|
-
|
|
237
|
+
cache.set(raw, proxy)
|
|
138
238
|
return proxy
|
|
139
239
|
}
|
package/src/tests/batch.test.ts
CHANGED
|
@@ -562,3 +562,190 @@ describe('batch — subscriber-throw stale-Set leak (audit bug #19)', () => {
|
|
|
562
562
|
expect(fires).toBe(3)
|
|
563
563
|
})
|
|
564
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
|
+
})
|
|
@@ -348,4 +348,58 @@ describe('computed', () => {
|
|
|
348
348
|
expect(warns.some((m) => m.includes('async function'))).toBe(false)
|
|
349
349
|
})
|
|
350
350
|
})
|
|
351
|
+
|
|
352
|
+
// M6 audit gap (c): creating a computed inside another computed's recompute
|
|
353
|
+
// body. Edge case — the inner computed registers its `recompute` as a
|
|
354
|
+
// signal subscriber DURING the outer's evaluation. The new computed should
|
|
355
|
+
// track the outer's source dependency correctly and not crash the recompute.
|
|
356
|
+
describe('computed-in-computed recompute (regression)', () => {
|
|
357
|
+
test('creating a computed inside a computed body is safe and tracks correctly', () => {
|
|
358
|
+
const source = signal(1)
|
|
359
|
+
let innerCreated = 0
|
|
360
|
+
|
|
361
|
+
// Outer computed creates a new computed each time it runs.
|
|
362
|
+
const outer = computed(() => {
|
|
363
|
+
const v = source()
|
|
364
|
+
innerCreated++
|
|
365
|
+
const inner = computed(() => v * 2)
|
|
366
|
+
return inner()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// First read — outer creates inner #1, returns 2.
|
|
370
|
+
expect(outer()).toBe(2)
|
|
371
|
+
expect(innerCreated).toBe(1)
|
|
372
|
+
|
|
373
|
+
// Source change — outer recomputes, creates inner #2, returns 4.
|
|
374
|
+
source.set(2)
|
|
375
|
+
expect(outer()).toBe(4)
|
|
376
|
+
expect(innerCreated).toBe(2)
|
|
377
|
+
|
|
378
|
+
// Verify the previously-created inner computeds didn't capture stale
|
|
379
|
+
// tracking — the latest outer() value reflects the latest source.
|
|
380
|
+
source.set(10)
|
|
381
|
+
expect(outer()).toBe(20)
|
|
382
|
+
expect(innerCreated).toBe(3)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('inner computed reads outer source signal — no double-track or recompute leak', () => {
|
|
386
|
+
const source = signal(1)
|
|
387
|
+
let outerRuns = 0
|
|
388
|
+
|
|
389
|
+
const outer = computed(() => {
|
|
390
|
+
outerRuns++
|
|
391
|
+
const v = source()
|
|
392
|
+
// Inner reads the same source — should not double-subscribe outer.
|
|
393
|
+
const inner = computed(() => source() + v)
|
|
394
|
+
return inner()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
expect(outer()).toBe(2)
|
|
398
|
+
expect(outerRuns).toBe(1)
|
|
399
|
+
|
|
400
|
+
source.set(5)
|
|
401
|
+
expect(outer()).toBe(10)
|
|
402
|
+
expect(outerRuns).toBe(2) // not 3 or more (no double-fire)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
351
405
|
})
|
|
@@ -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
|
})
|
|
@@ -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
|
})
|