@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,326 +0,0 @@
1
- import { createResource } from '../resource'
2
- import { signal } from '../signal'
3
-
4
- describe('createResource', () => {
5
- test('fetches data when source changes', async () => {
6
- const userId = signal(1)
7
- const resource = createResource(
8
- () => userId(),
9
- (id) => Promise.resolve(`user-${id}`),
10
- )
11
-
12
- expect(resource.loading()).toBe(true)
13
- expect(resource.data()).toBeUndefined()
14
- expect(resource.error()).toBeUndefined()
15
-
16
- await new Promise((r) => setTimeout(r, 10))
17
-
18
- expect(resource.data()).toBe('user-1')
19
- expect(resource.loading()).toBe(false)
20
- expect(resource.error()).toBeUndefined()
21
- })
22
-
23
- test('re-fetches when source signal changes', async () => {
24
- const userId = signal(1)
25
- const resource = createResource(
26
- () => userId(),
27
- (id) => Promise.resolve(`user-${id}`),
28
- )
29
-
30
- await new Promise((r) => setTimeout(r, 10))
31
- expect(resource.data()).toBe('user-1')
32
-
33
- userId.set(2)
34
- expect(resource.loading()).toBe(true)
35
-
36
- await new Promise((r) => setTimeout(r, 10))
37
- expect(resource.data()).toBe('user-2')
38
- expect(resource.loading()).toBe(false)
39
- })
40
-
41
- test('handles fetcher errors', async () => {
42
- const userId = signal(1)
43
- const resource = createResource(
44
- () => userId(),
45
- (_id) => Promise.reject(new Error('network error')),
46
- )
47
-
48
- await new Promise((r) => setTimeout(r, 10))
49
-
50
- expect(resource.error()).toBeInstanceOf(Error)
51
- expect((resource.error() as Error).message).toBe('network error')
52
- expect(resource.loading()).toBe(false)
53
- expect(resource.data()).toBeUndefined()
54
- })
55
-
56
- test('refetch re-runs the fetcher with current source', async () => {
57
- let fetchCount = 0
58
- const userId = signal(1)
59
- const resource = createResource(
60
- () => userId(),
61
- (id) => {
62
- fetchCount++
63
- return Promise.resolve(`user-${id}-${fetchCount}`)
64
- },
65
- )
66
-
67
- await new Promise((r) => setTimeout(r, 10))
68
- expect(resource.data()).toBe('user-1-1')
69
-
70
- resource.refetch()
71
- await new Promise((r) => setTimeout(r, 10))
72
- expect(resource.data()).toBe('user-1-2')
73
- })
74
-
75
- test('ignores stale responses (race condition)', async () => {
76
- const userId = signal(1)
77
- const resolvers: ((v: string) => void)[] = []
78
-
79
- const resource = createResource(
80
- () => userId(),
81
- (_id) =>
82
- new Promise<string>((resolve) => {
83
- resolvers.push((v) => resolve(v))
84
- }),
85
- )
86
-
87
- // First fetch is in flight
88
- expect(resolvers.length).toBe(1)
89
-
90
- // Change source — triggers second fetch
91
- userId.set(2)
92
- expect(resolvers.length).toBe(2)
93
-
94
- // Resolve the SECOND request first
95
- resolvers[1]?.('user-2')
96
- await new Promise((r) => setTimeout(r, 10))
97
- expect(resource.data()).toBe('user-2')
98
-
99
- // Now resolve the FIRST (stale) request — should be ignored
100
- resolvers[0]?.('user-1')
101
- await new Promise((r) => setTimeout(r, 10))
102
- expect(resource.data()).toBe('user-2') // still user-2, not user-1
103
- })
104
-
105
- test('ignores stale errors (race condition)', async () => {
106
- const userId = signal(1)
107
- const rejecters: ((e: Error) => void)[] = []
108
- const resolvers: ((v: string) => void)[] = []
109
-
110
- const resource = createResource(
111
- () => userId(),
112
- (_id) =>
113
- new Promise<string>((resolve, reject) => {
114
- resolvers.push(resolve)
115
- rejecters.push(reject)
116
- }),
117
- )
118
-
119
- // First fetch in flight, change source
120
- userId.set(2)
121
-
122
- // Resolve second request
123
- resolvers[1]?.('user-2')
124
- await new Promise((r) => setTimeout(r, 10))
125
- expect(resource.data()).toBe('user-2')
126
-
127
- // Reject first (stale) request — should be ignored
128
- rejecters[0]?.(new Error('stale error'))
129
- await new Promise((r) => setTimeout(r, 10))
130
- expect(resource.error()).toBeUndefined()
131
- expect(resource.data()).toBe('user-2')
132
- })
133
-
134
- test('loading returns to true on refetch', async () => {
135
- const userId = signal(1)
136
- const resource = createResource(
137
- () => userId(),
138
- (id) => Promise.resolve(`user-${id}`),
139
- )
140
-
141
- await new Promise((r) => setTimeout(r, 10))
142
- expect(resource.loading()).toBe(false)
143
- expect(resource.data()).toBe('user-1')
144
-
145
- resource.refetch()
146
- expect(resource.loading()).toBe(true)
147
-
148
- await new Promise((r) => setTimeout(r, 10))
149
- expect(resource.loading()).toBe(false)
150
- })
151
-
152
- test('error is cleared on successful refetch', async () => {
153
- let shouldFail = true
154
- const src = signal(1)
155
- const resource = createResource(
156
- () => src(),
157
- (_id) => (shouldFail ? Promise.reject(new Error('fail')) : Promise.resolve('ok')),
158
- )
159
-
160
- await new Promise((r) => setTimeout(r, 10))
161
- expect(resource.error()).toBeInstanceOf(Error)
162
-
163
- shouldFail = false
164
- resource.refetch()
165
- await new Promise((r) => setTimeout(r, 10))
166
- expect(resource.error()).toBeUndefined()
167
- expect(resource.data()).toBe('ok')
168
- })
169
-
170
- test('error is cleared before each fetch attempt', async () => {
171
- let callCount = 0
172
- const src = signal(1)
173
- const resource = createResource(
174
- () => src(),
175
- (_id) => {
176
- callCount++
177
- if (callCount === 1) return Promise.reject(new Error('first fail'))
178
- return Promise.resolve('success')
179
- },
180
- )
181
-
182
- await new Promise((r) => setTimeout(r, 10))
183
- expect(resource.error()).toBeInstanceOf(Error)
184
-
185
- // Trigger re-fetch by changing source
186
- src.set(2)
187
- // Error should be cleared immediately when new fetch starts
188
- expect(resource.error()).toBeUndefined()
189
- expect(resource.loading()).toBe(true)
190
-
191
- await new Promise((r) => setTimeout(r, 10))
192
- expect(resource.data()).toBe('success')
193
- })
194
-
195
- test('data is undefined initially and after error', async () => {
196
- const src = signal(1)
197
- const resource = createResource(
198
- () => src(),
199
- (_id) => Promise.reject(new Error('always fails')),
200
- )
201
-
202
- expect(resource.data()).toBeUndefined()
203
-
204
- await new Promise((r) => setTimeout(r, 10))
205
- expect(resource.data()).toBeUndefined()
206
- expect(resource.error()).toBeInstanceOf(Error)
207
- })
208
-
209
- test('refetch uses current source value', async () => {
210
- const src = signal(1)
211
- const results: string[] = []
212
- const resource = createResource(
213
- () => src(),
214
- (id) => {
215
- const val = `user-${id}`
216
- results.push(val)
217
- return Promise.resolve(val)
218
- },
219
- )
220
-
221
- await new Promise((r) => setTimeout(r, 10))
222
- expect(results).toEqual(['user-1'])
223
-
224
- src.set(5)
225
- await new Promise((r) => setTimeout(r, 10))
226
- expect(results).toEqual(['user-1', 'user-5'])
227
-
228
- // Refetch should use current source value (5)
229
- resource.refetch()
230
- await new Promise((r) => setTimeout(r, 10))
231
- expect(results).toEqual(['user-1', 'user-5', 'user-5'])
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
- })
326
- })
@@ -1,231 +0,0 @@
1
- import { effect } from '../effect'
2
- import { EffectScope, effectScope, getCurrentScope, setCurrentScope } from '../scope'
3
- import { signal } from '../signal'
4
-
5
- describe('effectScope', () => {
6
- test('creates an EffectScope instance', () => {
7
- const scope = effectScope()
8
- expect(scope).toBeInstanceOf(EffectScope)
9
- })
10
-
11
- test('getCurrentScope returns null by default', () => {
12
- expect(getCurrentScope()).toBeNull()
13
- })
14
-
15
- test('setCurrentScope sets and clears the current scope', () => {
16
- const scope = effectScope()
17
- setCurrentScope(scope)
18
- expect(getCurrentScope()).toBe(scope)
19
- setCurrentScope(null)
20
- expect(getCurrentScope()).toBeNull()
21
- })
22
-
23
- test('effects created within a scope are disposed on stop', () => {
24
- const scope = effectScope()
25
- setCurrentScope(scope)
26
-
27
- const s = signal(0)
28
- let count = 0
29
- effect(() => {
30
- s()
31
- count++
32
- })
33
-
34
- setCurrentScope(null)
35
-
36
- expect(count).toBe(1)
37
- s.set(1)
38
- expect(count).toBe(2)
39
-
40
- scope.stop()
41
- s.set(2)
42
- expect(count).toBe(2) // effect disposed, no re-run
43
- })
44
-
45
- test('stop is idempotent — second call does nothing', () => {
46
- const scope = effectScope()
47
- setCurrentScope(scope)
48
-
49
- const s = signal(0)
50
- let count = 0
51
- effect(() => {
52
- s()
53
- count++
54
- })
55
-
56
- setCurrentScope(null)
57
- scope.stop()
58
- scope.stop() // should not throw
59
- s.set(1)
60
- expect(count).toBe(1)
61
- })
62
-
63
- test('add is ignored after scope is stopped', () => {
64
- const scope = effectScope()
65
- scope.stop()
66
- // Should not throw — add is silently ignored
67
- scope.add({ dispose() {} })
68
- })
69
-
70
- test('runInScope temporarily re-activates the scope', () => {
71
- const scope = effectScope()
72
- setCurrentScope(null)
73
-
74
- const s = signal(0)
75
- let count = 0
76
-
77
- scope.runInScope(() => {
78
- effect(() => {
79
- s()
80
- count++
81
- })
82
- })
83
-
84
- expect(getCurrentScope()).toBeNull() // restored
85
- expect(count).toBe(1)
86
- s.set(1)
87
- expect(count).toBe(2)
88
-
89
- scope.stop()
90
- s.set(2)
91
- expect(count).toBe(2) // disposed via scope
92
- })
93
-
94
- test('runInScope restores previous scope even on error', () => {
95
- const scope = effectScope()
96
- const prevScope = effectScope()
97
- setCurrentScope(prevScope)
98
-
99
- try {
100
- scope.runInScope(() => {
101
- expect(getCurrentScope()).toBe(scope)
102
- throw new Error('test')
103
- })
104
- } catch {
105
- // expected
106
- }
107
-
108
- expect(getCurrentScope()).toBe(prevScope)
109
- setCurrentScope(null)
110
- })
111
-
112
- test("runInScope returns the function's return value", () => {
113
- const scope = effectScope()
114
- const result = scope.runInScope(() => 42)
115
- expect(result).toBe(42)
116
- })
117
-
118
- test('addUpdateHook + notifyEffectRan fires hooks via microtask', async () => {
119
- const scope = effectScope()
120
- let hookCalled = 0
121
-
122
- scope.addUpdateHook(() => {
123
- hookCalled++
124
- })
125
-
126
- scope.notifyEffectRan()
127
- expect(hookCalled).toBe(0) // not yet — microtask
128
-
129
- await new Promise((r) => setTimeout(r, 10))
130
- expect(hookCalled).toBe(1)
131
- })
132
-
133
- test('notifyEffectRan does nothing when no update hooks', async () => {
134
- const scope = effectScope()
135
- // Should not throw — early return when _updateHooks is empty
136
- scope.notifyEffectRan()
137
- await new Promise((r) => setTimeout(r, 10))
138
- })
139
-
140
- test('notifyEffectRan does nothing after scope is stopped', async () => {
141
- const scope = effectScope()
142
- let hookCalled = 0
143
-
144
- scope.addUpdateHook(() => {
145
- hookCalled++
146
- })
147
-
148
- scope.stop()
149
- scope.notifyEffectRan()
150
-
151
- await new Promise((r) => setTimeout(r, 10))
152
- expect(hookCalled).toBe(0)
153
- })
154
-
155
- test('notifyEffectRan deduplicates — only one microtask while pending', async () => {
156
- const scope = effectScope()
157
- let hookCalled = 0
158
-
159
- scope.addUpdateHook(() => {
160
- hookCalled++
161
- })
162
-
163
- scope.notifyEffectRan()
164
- scope.notifyEffectRan()
165
- scope.notifyEffectRan()
166
-
167
- await new Promise((r) => setTimeout(r, 10))
168
- expect(hookCalled).toBe(1) // only fired once
169
- })
170
-
171
- test('notifyEffectRan skips hooks if scope stopped before microtask fires', async () => {
172
- const scope = effectScope()
173
- let hookCalled = 0
174
-
175
- scope.addUpdateHook(() => {
176
- hookCalled++
177
- })
178
-
179
- scope.notifyEffectRan()
180
- scope.stop() // stop before microtask runs
181
-
182
- await new Promise((r) => setTimeout(r, 10))
183
- expect(hookCalled).toBe(0)
184
- })
185
-
186
- test('onUpdate hook errors are caught and logged', async () => {
187
- const scope = effectScope()
188
- const errors: unknown[] = []
189
- const origError = console.error
190
- console.error = (...args: unknown[]) => errors.push(args)
191
-
192
- scope.addUpdateHook(() => {
193
- throw new Error('hook error')
194
- })
195
-
196
- scope.notifyEffectRan()
197
- await new Promise((r) => setTimeout(r, 10))
198
-
199
- expect(errors.length).toBe(1)
200
- console.error = origError
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
- })
231
- })