@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.
- package/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- 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
|
-
})
|
package/src/tests/scope.test.ts
DELETED
|
@@ -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
|
-
})
|