@pyreon/reactivity 0.15.0 → 0.18.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.
@@ -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
  })
@@ -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
  })
@@ -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
  })
@@ -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[] = []
@@ -0,0 +1,191 @@
1
+ // Tests for the Vue-parity APIs added in M4: markRaw, shallowReactive,
2
+ // onScopeDispose. Each surface is independent; grouped here for legibility
3
+ // rather than spreading across signal/store/scope test files.
4
+
5
+ import { effect } from '../effect'
6
+ import { effectScope, getCurrentScope, onScopeDispose } from '../scope'
7
+ import { signal } from '../signal'
8
+ import { createStore, markRaw, shallowReactive } from '../store'
9
+
10
+ describe('markRaw', () => {
11
+ test('marks an object as raw — createStore returns it unwrapped', () => {
12
+ const raw = markRaw({ x: 1 })
13
+ const store = createStore({ inner: raw })
14
+ // Reading store.inner returns the raw reference, not a proxy.
15
+ expect(store.inner).toBe(raw)
16
+ // Mutating raw does NOT trigger reactivity (it's not proxied).
17
+ let runs = 0
18
+ effect(() => {
19
+ void store.inner.x
20
+ runs++
21
+ })
22
+ expect(runs).toBe(1)
23
+ raw.x = 99
24
+ expect(runs).toBe(1) // not reactive — raw bypasses proxy
25
+ })
26
+
27
+ test('also opts out of shallowReactive wrapping', () => {
28
+ const raw = markRaw({ y: 2 })
29
+ const store = shallowReactive({ inner: raw })
30
+ expect(store.inner).toBe(raw)
31
+ })
32
+
33
+ test('class instances marked raw are usable without proxy quirks', () => {
34
+ class Editor {
35
+ cursor = 0
36
+ moveTo(pos: number): void {
37
+ this.cursor = pos
38
+ }
39
+ }
40
+ const ed = markRaw(new Editor())
41
+ const store = createStore({ editor: ed })
42
+ expect(() => store.editor.moveTo(5)).not.toThrow()
43
+ expect(store.editor.cursor).toBe(5)
44
+ expect(store.editor).toBe(ed) // identity preserved
45
+ })
46
+
47
+ test('markRaw is idempotent', () => {
48
+ const obj = { x: 1 }
49
+ expect(() => {
50
+ markRaw(obj)
51
+ markRaw(obj)
52
+ }).not.toThrow()
53
+ })
54
+ })
55
+
56
+ describe('shallowReactive', () => {
57
+ test('top-level mutations trigger reactivity', () => {
58
+ const store = shallowReactive({ count: 0 })
59
+ let seen = -1
60
+ effect(() => {
61
+ seen = store.count
62
+ })
63
+ expect(seen).toBe(0)
64
+ store.count = 5
65
+ expect(seen).toBe(5)
66
+ })
67
+
68
+ test('nested mutations do NOT trigger reactivity (shallow)', () => {
69
+ const store = shallowReactive({ user: { name: 'Alice' } })
70
+ let runs = 0
71
+ effect(() => {
72
+ void store.user.name
73
+ runs++
74
+ })
75
+ expect(runs).toBe(1)
76
+ // Nested mutation — should NOT re-run because nested object is raw.
77
+ store.user.name = 'Bob'
78
+ expect(runs).toBe(1)
79
+ })
80
+
81
+ test('replacing a top-level reference DOES trigger reactivity', () => {
82
+ const store = shallowReactive({ user: { name: 'Alice' } })
83
+ const names: string[] = []
84
+ effect(() => {
85
+ names.push(store.user.name)
86
+ })
87
+ expect(names).toEqual(['Alice'])
88
+ store.user = { name: 'Bob' }
89
+ expect(names).toEqual(['Alice', 'Bob'])
90
+ })
91
+
92
+ test('nested reads return raw references, not proxies', () => {
93
+ const inner = { x: 1 }
94
+ const store = shallowReactive({ inner })
95
+ expect(store.inner).toBe(inner)
96
+ })
97
+
98
+ test('separate cache from createStore — same raw can be both shallow and deep', () => {
99
+ const raw = { x: 1 }
100
+ const deep = createStore(raw)
101
+ const shallow = shallowReactive({ wrapper: raw })
102
+ // The shallow store returns raw nested, not the deep proxy.
103
+ expect(shallow.wrapper).toBe(raw)
104
+ // The deep store wraps raw.
105
+ expect(deep).not.toBe(raw)
106
+ })
107
+ })
108
+
109
+ describe('onScopeDispose', () => {
110
+ test('callback fires when scope stops', () => {
111
+ const scope = effectScope()
112
+ let disposed = 0
113
+ scope.runInScope(() => {
114
+ onScopeDispose(() => {
115
+ disposed++
116
+ })
117
+ })
118
+ expect(disposed).toBe(0)
119
+ scope.stop()
120
+ expect(disposed).toBe(1)
121
+ })
122
+
123
+ test('multiple callbacks all fire, in registration order', () => {
124
+ const scope = effectScope()
125
+ const order: string[] = []
126
+ scope.runInScope(() => {
127
+ onScopeDispose(() => order.push('first'))
128
+ onScopeDispose(() => order.push('second'))
129
+ onScopeDispose(() => order.push('third'))
130
+ })
131
+ scope.stop()
132
+ expect(order).toEqual(['first', 'second', 'third'])
133
+ })
134
+
135
+ test('warns when called outside any scope (dev mode)', () => {
136
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
137
+ let called = false
138
+ onScopeDispose(() => {
139
+ called = true
140
+ })
141
+ expect(warnSpy).toHaveBeenCalledWith(
142
+ expect.stringContaining('without an active EffectScope'),
143
+ )
144
+ expect(called).toBe(false)
145
+ warnSpy.mockRestore()
146
+ })
147
+
148
+ test('captures the SCOPE active at registration time', () => {
149
+ const scopeA = effectScope()
150
+ const scopeB = effectScope()
151
+ const log: string[] = []
152
+ scopeA.runInScope(() => {
153
+ onScopeDispose(() => log.push('A'))
154
+ })
155
+ scopeB.runInScope(() => {
156
+ onScopeDispose(() => log.push('B'))
157
+ })
158
+ scopeA.stop()
159
+ expect(log).toEqual(['A'])
160
+ scopeB.stop()
161
+ expect(log).toEqual(['A', 'B'])
162
+ })
163
+
164
+ test('integrates with effect lifecycle — disposes alongside scope effects', () => {
165
+ const s = signal(0)
166
+ const scope = effectScope()
167
+ const events: string[] = []
168
+ scope.runInScope(() => {
169
+ effect(() => {
170
+ events.push(`effect:${s()}`)
171
+ })
172
+ onScopeDispose(() => events.push('disposed'))
173
+ })
174
+ s.set(1)
175
+ expect(events).toEqual(['effect:0', 'effect:1'])
176
+ scope.stop()
177
+ expect(events).toEqual(['effect:0', 'effect:1', 'disposed'])
178
+ s.set(2)
179
+ // Both effect and dispose callback are inactive after stop.
180
+ expect(events).toEqual(['effect:0', 'effect:1', 'disposed'])
181
+ })
182
+
183
+ test('captures null-scope correctly — no-op without throwing', () => {
184
+ expect(getCurrentScope()).toBeNull()
185
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
186
+ expect(() => {
187
+ onScopeDispose(() => {})
188
+ }).not.toThrow()
189
+ warnSpy.mockRestore()
190
+ })
191
+ })