@pyreon/reactivity 0.24.5 → 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
package/src/tests/bind.test.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { _bind } from '../effect'
|
|
2
|
-
import { signal } from '../signal'
|
|
3
|
-
|
|
4
|
-
describe('_bind (static-dep binding)', () => {
|
|
5
|
-
test('runs the function on first call and tracks deps', () => {
|
|
6
|
-
const s = signal(0)
|
|
7
|
-
let runs = 0
|
|
8
|
-
|
|
9
|
-
const dispose = _bind(() => {
|
|
10
|
-
s()
|
|
11
|
-
runs++
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
expect(runs).toBe(1)
|
|
15
|
-
|
|
16
|
-
// Deps tracked on first run, re-runs on signal change
|
|
17
|
-
s.set(1)
|
|
18
|
-
expect(runs).toBe(2)
|
|
19
|
-
|
|
20
|
-
dispose()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('dispose stops re-runs', () => {
|
|
24
|
-
const s = signal(0)
|
|
25
|
-
let runs = 0
|
|
26
|
-
|
|
27
|
-
const dispose = _bind(() => {
|
|
28
|
-
s()
|
|
29
|
-
runs++
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
expect(runs).toBe(1)
|
|
33
|
-
|
|
34
|
-
dispose()
|
|
35
|
-
s.set(1)
|
|
36
|
-
expect(runs).toBe(1) // no re-run
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('dispose is idempotent', () => {
|
|
40
|
-
const s = signal(0)
|
|
41
|
-
const dispose = _bind(() => {
|
|
42
|
-
s()
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
dispose()
|
|
46
|
-
dispose() // should not throw
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('does not re-run after dispose even with multiple deps', () => {
|
|
50
|
-
const a = signal(0)
|
|
51
|
-
const b = signal(0)
|
|
52
|
-
let runs = 0
|
|
53
|
-
|
|
54
|
-
const dispose = _bind(() => {
|
|
55
|
-
a()
|
|
56
|
-
b()
|
|
57
|
-
runs++
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
expect(runs).toBe(1)
|
|
61
|
-
|
|
62
|
-
dispose()
|
|
63
|
-
a.set(1)
|
|
64
|
-
b.set(1)
|
|
65
|
-
expect(runs).toBe(1)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('disposed run callback is a no-op', () => {
|
|
69
|
-
const s = signal(0)
|
|
70
|
-
let runs = 0
|
|
71
|
-
|
|
72
|
-
const dispose = _bind(() => {
|
|
73
|
-
s()
|
|
74
|
-
runs++
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
expect(runs).toBe(1)
|
|
78
|
-
|
|
79
|
-
// Dispose then trigger — the run function should bail out
|
|
80
|
-
dispose()
|
|
81
|
-
s.set(5)
|
|
82
|
-
expect(runs).toBe(1)
|
|
83
|
-
})
|
|
84
|
-
})
|
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Targeted tests for uncovered branches across reactivity package.
|
|
3
|
-
*/
|
|
4
|
-
import { Cell } from '../cell'
|
|
5
|
-
import { computed } from '../computed'
|
|
6
|
-
import { createSelector } from '../createSelector'
|
|
7
|
-
import { why } from '../debug'
|
|
8
|
-
import { _bind, effect, renderEffect } from '../effect'
|
|
9
|
-
import { reconcile } from '../reconcile'
|
|
10
|
-
import { signal } from '../signal'
|
|
11
|
-
import { createStore, isStore } from '../store'
|
|
12
|
-
|
|
13
|
-
// ── cell.ts branches: promote listener to Set ─────────────────────────────────
|
|
14
|
-
|
|
15
|
-
describe('Cell listener promotion', () => {
|
|
16
|
-
test('promotes single listener to Set when second listener added', () => {
|
|
17
|
-
const c = new Cell(0)
|
|
18
|
-
const calls: number[] = []
|
|
19
|
-
c.listen(() => calls.push(1))
|
|
20
|
-
c.listen(() => calls.push(2))
|
|
21
|
-
// Third listen: _s already exists (false branch of `if (!this._s)`)
|
|
22
|
-
c.listen(() => calls.push(3))
|
|
23
|
-
c.set(1)
|
|
24
|
-
expect(calls).toEqual([1, 2, 3])
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test('subscribe unsubscribes single listener', () => {
|
|
28
|
-
const c = new Cell(0)
|
|
29
|
-
const calls: number[] = []
|
|
30
|
-
const unsub = c.subscribe(() => calls.push(1))
|
|
31
|
-
c.set(1)
|
|
32
|
-
expect(calls).toEqual([1])
|
|
33
|
-
unsub()
|
|
34
|
-
c.set(2)
|
|
35
|
-
// Should not fire after unsubscribe
|
|
36
|
-
expect(calls).toEqual([1])
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('subscribe unsubscribes from Set', () => {
|
|
40
|
-
const c = new Cell(0)
|
|
41
|
-
const calls: number[] = []
|
|
42
|
-
c.listen(() => calls.push(1))
|
|
43
|
-
const unsub = c.subscribe(() => calls.push(2))
|
|
44
|
-
c.set(1)
|
|
45
|
-
expect(calls).toEqual([1, 2])
|
|
46
|
-
unsub()
|
|
47
|
-
c.set(2)
|
|
48
|
-
expect(calls).toEqual([1, 2, 1])
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('promote to Set when _l was unsubscribed (null _l, null _s)', () => {
|
|
52
|
-
const c = new Cell(0)
|
|
53
|
-
const fn1 = () => {}
|
|
54
|
-
// subscribe sets _l, unsub sets _l to null
|
|
55
|
-
const unsub = c.subscribe(fn1)
|
|
56
|
-
unsub()
|
|
57
|
-
// Now _l is null and _s is null — next listen goes to fast path (!_l && !_s)
|
|
58
|
-
const fn2 = () => {}
|
|
59
|
-
c.listen(fn2)
|
|
60
|
-
// Add another to force promotion — _l is fn2, _s is null → promotes with _l
|
|
61
|
-
c.listen(() => {})
|
|
62
|
-
c.set(1)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('double unsubscribe from single listener is safe', () => {
|
|
66
|
-
const c = new Cell(0)
|
|
67
|
-
const fn1 = () => {}
|
|
68
|
-
const unsub = c.subscribe(fn1)
|
|
69
|
-
unsub()
|
|
70
|
-
// Second call — _l is null, so `this._l === listener` is false
|
|
71
|
-
unsub()
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// ── computed.ts branches ──────────────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
describe('computed branches', () => {
|
|
78
|
-
test('disposed computed does not recompute', () => {
|
|
79
|
-
const s = signal(1)
|
|
80
|
-
const c = computed(() => s() * 2, { equals: Object.is })
|
|
81
|
-
expect(c()).toBe(2)
|
|
82
|
-
c.dispose()
|
|
83
|
-
s.set(5)
|
|
84
|
-
// After dispose, the computed should not update
|
|
85
|
-
// (it may return stale value or throw — just ensure no crash)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test('computed with custom equals and subscribers', () => {
|
|
89
|
-
const s = signal(1)
|
|
90
|
-
const c = computed(() => s() * 2, { equals: Object.is })
|
|
91
|
-
const values: number[] = []
|
|
92
|
-
effect(() => {
|
|
93
|
-
values.push(c())
|
|
94
|
-
})
|
|
95
|
-
expect(values).toEqual([2])
|
|
96
|
-
s.set(2)
|
|
97
|
-
expect(values).toEqual([2, 4])
|
|
98
|
-
// Same value — should not notify
|
|
99
|
-
s.set(2)
|
|
100
|
-
expect(values).toEqual([2, 4])
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('computed without custom equals notifies subscribers on dep change', () => {
|
|
104
|
-
const s = signal(1)
|
|
105
|
-
const c = computed(() => s() * 2)
|
|
106
|
-
const values: number[] = []
|
|
107
|
-
effect(() => {
|
|
108
|
-
values.push(c())
|
|
109
|
-
})
|
|
110
|
-
expect(values).toEqual([2])
|
|
111
|
-
s.set(2)
|
|
112
|
-
expect(values).toEqual([2, 4])
|
|
113
|
-
})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
// ── createSelector.ts branches ────────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
describe('createSelector branches', () => {
|
|
119
|
-
test('selector with no matching bucket on old value', () => {
|
|
120
|
-
const s = signal<string>('a')
|
|
121
|
-
const isSelected = createSelector(s)
|
|
122
|
-
// Read "a" — creates bucket for "a"
|
|
123
|
-
effect(() => {
|
|
124
|
-
isSelected('a')
|
|
125
|
-
})
|
|
126
|
-
// Change to "b" — old bucket "a" exists, new bucket "b" does not
|
|
127
|
-
s.set('b')
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
test('selector reuses existing host for same value', () => {
|
|
131
|
-
const s = signal<string>('a')
|
|
132
|
-
const isSelected = createSelector(s)
|
|
133
|
-
const results: boolean[] = []
|
|
134
|
-
effect(() => {
|
|
135
|
-
results.push(isSelected('a'))
|
|
136
|
-
})
|
|
137
|
-
// This second effect creates another subscription to same bucket
|
|
138
|
-
effect(() => {
|
|
139
|
-
results.push(isSelected('a'))
|
|
140
|
-
})
|
|
141
|
-
expect(results).toEqual([true, true])
|
|
142
|
-
s.set('b')
|
|
143
|
-
// Both should see false
|
|
144
|
-
expect(results).toEqual([true, true, false, false])
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
test('selector handles Object.is equality (no change)', () => {
|
|
148
|
-
const s = signal<string>('a')
|
|
149
|
-
const isSelected = createSelector(s)
|
|
150
|
-
let count = 0
|
|
151
|
-
effect(() => {
|
|
152
|
-
isSelected('a')
|
|
153
|
-
count++
|
|
154
|
-
})
|
|
155
|
-
expect(count).toBe(1)
|
|
156
|
-
// Same value — Object.is check should skip
|
|
157
|
-
s.set('a')
|
|
158
|
-
expect(count).toBe(1)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
test('selector query for value with no existing bucket creates one', () => {
|
|
162
|
-
const s = signal<string>('a')
|
|
163
|
-
const isSelected = createSelector(s)
|
|
164
|
-
// Query outside effect — creates a bucket for "z" that has no subscribers
|
|
165
|
-
const result = isSelected('z')
|
|
166
|
-
expect(result).toBe(false)
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
test('selector change when old value has no subscriber bucket', () => {
|
|
170
|
-
const s = signal<string>('a')
|
|
171
|
-
const isSelected = createSelector(s)
|
|
172
|
-
// Only subscribe to "b", not "a"
|
|
173
|
-
effect(() => {
|
|
174
|
-
isSelected('b')
|
|
175
|
-
})
|
|
176
|
-
// Change from "a" to "b" — old value "a" has no bucket (never queried in effect)
|
|
177
|
-
s.set('b')
|
|
178
|
-
})
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
// ── effect.ts branches ────────────────────────────────────────────────────────
|
|
182
|
-
|
|
183
|
-
describe('effect disposed branches', () => {
|
|
184
|
-
test('disposed effect does not re-run', () => {
|
|
185
|
-
const s = signal(0)
|
|
186
|
-
let count = 0
|
|
187
|
-
const e = effect(() => {
|
|
188
|
-
s()
|
|
189
|
-
count++
|
|
190
|
-
})
|
|
191
|
-
expect(count).toBe(1)
|
|
192
|
-
e.dispose()
|
|
193
|
-
s.set(1)
|
|
194
|
-
expect(count).toBe(1)
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
test('disposed _bind does not re-run', () => {
|
|
198
|
-
const s = signal(0)
|
|
199
|
-
let count = 0
|
|
200
|
-
const dispose = _bind(() => {
|
|
201
|
-
s()
|
|
202
|
-
count++
|
|
203
|
-
})
|
|
204
|
-
expect(count).toBe(1)
|
|
205
|
-
dispose()
|
|
206
|
-
s.set(1)
|
|
207
|
-
expect(count).toBe(1)
|
|
208
|
-
// Double dispose is safe
|
|
209
|
-
dispose()
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
test('disposed renderEffect does not re-run', () => {
|
|
213
|
-
const s = signal(0)
|
|
214
|
-
let count = 0
|
|
215
|
-
const dispose = renderEffect(() => {
|
|
216
|
-
s()
|
|
217
|
-
count++
|
|
218
|
-
})
|
|
219
|
-
expect(count).toBe(1)
|
|
220
|
-
dispose()
|
|
221
|
-
s.set(1)
|
|
222
|
-
expect(count).toBe(1)
|
|
223
|
-
})
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
// ── store.ts branches ─────────────────────────────────────────────────────────
|
|
227
|
-
|
|
228
|
-
describe('store branches', () => {
|
|
229
|
-
test('setting symbol property', () => {
|
|
230
|
-
const store = createStore({ a: 1 })
|
|
231
|
-
const sym = Symbol('test')
|
|
232
|
-
;(store as Record<symbol, unknown>)[sym] = 'hello'
|
|
233
|
-
expect((store as Record<symbol, unknown>)[sym]).toBe('hello')
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
test('deleteProperty on store', () => {
|
|
237
|
-
const store = createStore<Record<string, unknown>>({ a: 1, b: 2 })
|
|
238
|
-
delete store.b
|
|
239
|
-
expect(store.b).toBeUndefined()
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
test('deleteProperty on store array', () => {
|
|
243
|
-
const store = createStore([1, 2, 3])
|
|
244
|
-
delete (store as unknown as Record<string, unknown>)['1']
|
|
245
|
-
expect(store[1]).toBeUndefined()
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
test('deleteProperty on store with reactive subscriber', () => {
|
|
249
|
-
const store = createStore<Record<string, unknown>>({ a: 1, b: 2 })
|
|
250
|
-
// Read 'b' in effect to create propSignal
|
|
251
|
-
let val: unknown
|
|
252
|
-
effect(() => {
|
|
253
|
-
val = store.b
|
|
254
|
-
})
|
|
255
|
-
expect(val).toBe(2)
|
|
256
|
-
delete store.b
|
|
257
|
-
// Signal should be set to undefined and deleted from map
|
|
258
|
-
expect(val).toBeUndefined()
|
|
259
|
-
})
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
// ── reconcile.ts branches ─────────────────────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
describe('reconcile branches', () => {
|
|
265
|
-
test('reconcile array with non-object source items', () => {
|
|
266
|
-
const store = createStore([1, 2, 3])
|
|
267
|
-
reconcile([4, 5], store)
|
|
268
|
-
expect([...store]).toEqual([4, 5])
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
test('reconcile object with raw (non-store) target value', () => {
|
|
272
|
-
// Create store where nested value isn't yet a store proxy
|
|
273
|
-
const store = createStore<Record<string, unknown>>({ a: 1 })
|
|
274
|
-
// Reconcile with nested object — target.a is a number (not store), so it takes the else branch
|
|
275
|
-
reconcile({ a: { nested: true } }, store)
|
|
276
|
-
expect((store.a as Record<string, unknown>).nested).toBe(true)
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
test('reconcile array with null source entries', () => {
|
|
280
|
-
const store = createStore([1, null, 3])
|
|
281
|
-
reconcile([null, 2, null], store)
|
|
282
|
-
expect([...store]).toEqual([null, 2, null])
|
|
283
|
-
})
|
|
284
|
-
|
|
285
|
-
test('reconcile object with null source values', () => {
|
|
286
|
-
const store = createStore<Record<string, unknown>>({ a: { x: 1 }, b: 2 })
|
|
287
|
-
reconcile({ a: null, b: 2 }, store)
|
|
288
|
-
expect(store.a).toBeNull()
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
test('reconcile array with both source and target as objects (recursive)', () => {
|
|
292
|
-
const store = createStore([{ a: 1 }, { b: 2 }])
|
|
293
|
-
reconcile([{ a: 10 }, { b: 20 }], store)
|
|
294
|
-
expect(store[0]?.a).toBe(10)
|
|
295
|
-
expect(store[1]?.b).toBe(20)
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
test('reconcile object where target has store-proxied nested object', () => {
|
|
299
|
-
const store = createStore<Record<string, Record<string, number>>>({ nested: { x: 1 } })
|
|
300
|
-
// Access nested to ensure it's proxied as store
|
|
301
|
-
const _val = store.nested?.x
|
|
302
|
-
expect(isStore(store.nested!)).toBe(true)
|
|
303
|
-
reconcile({ nested: { x: 99 } }, store)
|
|
304
|
-
expect(store.nested?.x).toBe(99)
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
test('reconcile object where target has raw (non-store) nested object', () => {
|
|
308
|
-
// Don't access nested, so it stays as raw object (not proxied)
|
|
309
|
-
const store = createStore<Record<string, Record<string, number>>>({ nested: { x: 1 } })
|
|
310
|
-
// nested has not been accessed via proxy, so isStore(target.nested) is false
|
|
311
|
-
// This should hit the `else { target[key] = sv }` branch at line 78
|
|
312
|
-
reconcile({ nested: { x: 99 } }, store)
|
|
313
|
-
expect(store.nested?.x).toBe(99)
|
|
314
|
-
})
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
// ── debug.ts branches ─────────────────────────────────────────────────────────
|
|
318
|
-
|
|
319
|
-
describe('debug branches', () => {
|
|
320
|
-
test('why with exactly 1 subscriber shows singular', async () => {
|
|
321
|
-
const s = signal(0, { name: 'single' })
|
|
322
|
-
// Add exactly 1 subscriber
|
|
323
|
-
effect(() => {
|
|
324
|
-
s()
|
|
325
|
-
})
|
|
326
|
-
const logs: string[] = []
|
|
327
|
-
const origLog = console.log
|
|
328
|
-
console.log = (...args: unknown[]) => logs.push(args.join(' '))
|
|
329
|
-
why()
|
|
330
|
-
s.set(1)
|
|
331
|
-
// why() auto-disposes via microtask
|
|
332
|
-
await new Promise((r) => setTimeout(r, 10))
|
|
333
|
-
console.log = origLog
|
|
334
|
-
expect(logs.some((l) => l.includes('1 subscriber'))).toBe(true)
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
test('_notifyTraceListeners with no active listeners is noop', () => {
|
|
338
|
-
// When no listeners registered, this should not throw
|
|
339
|
-
// (tests the early return / null check)
|
|
340
|
-
const s = signal(0)
|
|
341
|
-
s.set(1) // triggers _notifyTraceListeners internally but no listeners active
|
|
342
|
-
})
|
|
343
|
-
})
|
package/src/tests/cell.test.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { Cell, cell } from '../cell'
|
|
2
|
-
|
|
3
|
-
describe('Cell', () => {
|
|
4
|
-
test('stores and reads initial value', () => {
|
|
5
|
-
const c = cell(42)
|
|
6
|
-
expect(c.peek()).toBe(42)
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
test('set() updates value', () => {
|
|
10
|
-
const c = cell('hello')
|
|
11
|
-
c.set('world')
|
|
12
|
-
expect(c.peek()).toBe('world')
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
test('set() skips when value is the same (Object.is)', () => {
|
|
16
|
-
const c = cell(1)
|
|
17
|
-
let calls = 0
|
|
18
|
-
c.listen(() => calls++)
|
|
19
|
-
c.set(1)
|
|
20
|
-
expect(calls).toBe(0)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
test('update() applies function to current value', () => {
|
|
24
|
-
const c = cell(10)
|
|
25
|
-
c.update((v) => v + 5)
|
|
26
|
-
expect(c.peek()).toBe(15)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test('listen() fires on set()', () => {
|
|
30
|
-
const c = cell('a')
|
|
31
|
-
let fired = false
|
|
32
|
-
c.listen(() => {
|
|
33
|
-
fired = true
|
|
34
|
-
})
|
|
35
|
-
c.set('b')
|
|
36
|
-
expect(fired).toBe(true)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('listen() single-listener fast path (no Set allocated)', () => {
|
|
40
|
-
const c = cell(0)
|
|
41
|
-
let count = 0
|
|
42
|
-
c.listen(() => count++)
|
|
43
|
-
// Should use _l fast path, not _s Set
|
|
44
|
-
expect(c._s).toBeNull()
|
|
45
|
-
expect(c._l).not.toBeNull()
|
|
46
|
-
c.set(1)
|
|
47
|
-
expect(count).toBe(1)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
test('listen() promotes to Set with multiple listeners', () => {
|
|
51
|
-
const c = cell(0)
|
|
52
|
-
let a = 0
|
|
53
|
-
let b = 0
|
|
54
|
-
c.listen(() => a++)
|
|
55
|
-
c.listen(() => b++)
|
|
56
|
-
expect(c._s).not.toBeNull()
|
|
57
|
-
expect(c._l).toBeNull()
|
|
58
|
-
c.set(1)
|
|
59
|
-
expect(a).toBe(1)
|
|
60
|
-
expect(b).toBe(1)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('subscribe() returns working unsubscribe (single listener)', () => {
|
|
64
|
-
const c = cell(0)
|
|
65
|
-
let count = 0
|
|
66
|
-
const unsub = c.subscribe(() => count++)
|
|
67
|
-
c.set(1)
|
|
68
|
-
expect(count).toBe(1)
|
|
69
|
-
unsub()
|
|
70
|
-
c.set(2)
|
|
71
|
-
expect(count).toBe(1) // no more notifications
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('subscribe() returns working unsubscribe (multi listener)', () => {
|
|
75
|
-
const c = cell(0)
|
|
76
|
-
let a = 0
|
|
77
|
-
let b = 0
|
|
78
|
-
c.listen(() => a++)
|
|
79
|
-
const unsub = c.subscribe(() => b++)
|
|
80
|
-
c.set(1)
|
|
81
|
-
expect(a).toBe(1)
|
|
82
|
-
expect(b).toBe(1)
|
|
83
|
-
unsub()
|
|
84
|
-
c.set(2)
|
|
85
|
-
expect(a).toBe(2)
|
|
86
|
-
expect(b).toBe(1) // unsubscribed
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
test('cell() factory returns Cell instance', () => {
|
|
90
|
-
const c = cell('x')
|
|
91
|
-
expect(c).toBeInstanceOf(Cell)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test('multiple rapid updates notify correctly', () => {
|
|
95
|
-
const c = cell(0)
|
|
96
|
-
const values: number[] = []
|
|
97
|
-
c.listen(() => values.push(c.peek()))
|
|
98
|
-
c.set(1)
|
|
99
|
-
c.set(2)
|
|
100
|
-
c.set(3)
|
|
101
|
-
expect(values).toEqual([1, 2, 3])
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('NaN equality (Object.is)', () => {
|
|
105
|
-
const c = cell(Number.NaN)
|
|
106
|
-
let calls = 0
|
|
107
|
-
c.listen(() => calls++)
|
|
108
|
-
c.set(Number.NaN)
|
|
109
|
-
expect(calls).toBe(0) // Object.is(NaN, NaN) is true
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('subscribe() unsubscribe works after promotion to Set (regression)', () => {
|
|
113
|
-
// Bug: first subscriber's disposer became stale after second subscriber
|
|
114
|
-
// promoted _l → _s. The disposer checked _l which was now null.
|
|
115
|
-
const c = cell(0)
|
|
116
|
-
let count1 = 0
|
|
117
|
-
let count2 = 0
|
|
118
|
-
const unsub1 = c.subscribe(() => count1++)
|
|
119
|
-
const unsub2 = c.subscribe(() => count2++)
|
|
120
|
-
|
|
121
|
-
c.set(1)
|
|
122
|
-
expect(count1).toBe(1)
|
|
123
|
-
expect(count2).toBe(1)
|
|
124
|
-
|
|
125
|
-
// Unsubscribe first listener — must remove from _s Set
|
|
126
|
-
unsub1()
|
|
127
|
-
c.set(2)
|
|
128
|
-
expect(count1).toBe(1) // should NOT fire again
|
|
129
|
-
expect(count2).toBe(2)
|
|
130
|
-
|
|
131
|
-
// Unsubscribe second listener
|
|
132
|
-
unsub2()
|
|
133
|
-
c.set(3)
|
|
134
|
-
expect(count1).toBe(1)
|
|
135
|
-
expect(count2).toBe(2)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
test('subscribe() unsubscribe order: second before first', () => {
|
|
139
|
-
const c = cell(0)
|
|
140
|
-
let count1 = 0
|
|
141
|
-
let count2 = 0
|
|
142
|
-
const unsub1 = c.subscribe(() => count1++)
|
|
143
|
-
const unsub2 = c.subscribe(() => count2++)
|
|
144
|
-
|
|
145
|
-
c.set(1)
|
|
146
|
-
expect(count1).toBe(1)
|
|
147
|
-
expect(count2).toBe(1)
|
|
148
|
-
|
|
149
|
-
unsub2()
|
|
150
|
-
c.set(2)
|
|
151
|
-
expect(count1).toBe(2)
|
|
152
|
-
expect(count2).toBe(1)
|
|
153
|
-
|
|
154
|
-
unsub1()
|
|
155
|
-
c.set(3)
|
|
156
|
-
expect(count1).toBe(2)
|
|
157
|
-
expect(count2).toBe(1)
|
|
158
|
-
})
|
|
159
|
-
})
|