@pyreon/reactivity 0.7.11 → 0.7.13
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 -1
- package/src/tests/computed.test.ts +119 -0
- package/src/tests/createSelector.test.ts +113 -0
- package/src/tests/signal.test.ts +58 -0
package/package.json
CHANGED
|
@@ -197,4 +197,123 @@ describe("computed", () => {
|
|
|
197
197
|
s.set(11) // clamped to 10, same as before
|
|
198
198
|
expect(called).toBe(2) // equals suppresses
|
|
199
199
|
})
|
|
200
|
+
|
|
201
|
+
describe("_v with equals after disposal", () => {
|
|
202
|
+
test("_v returns last cached value after dispose()", () => {
|
|
203
|
+
const s = signal(5)
|
|
204
|
+
const doubled = computed(() => s() * 2)
|
|
205
|
+
expect(doubled._v).toBe(10) // triggers initial computation
|
|
206
|
+
|
|
207
|
+
s.set(7)
|
|
208
|
+
expect(doubled._v).toBe(14) // recomputes
|
|
209
|
+
|
|
210
|
+
doubled.dispose()
|
|
211
|
+
s.set(100)
|
|
212
|
+
// After dispose, _v returns the last cached value (14)
|
|
213
|
+
// because dirty flag is not set (no subscription) and the
|
|
214
|
+
// disposed guard prevents recomputation
|
|
215
|
+
expect(doubled._v).toBe(14)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test("computed with equals: _v only updates when equality check fails", () => {
|
|
219
|
+
const s = signal(3)
|
|
220
|
+
const floored = computed(() => Math.floor(s() / 10), {
|
|
221
|
+
equals: (a, b) => a === b,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(floored._v).toBe(0) // Math.floor(3/10) = 0
|
|
225
|
+
|
|
226
|
+
s.set(5) // Math.floor(5/10) = 0, same
|
|
227
|
+
expect(floored._v).toBe(0)
|
|
228
|
+
|
|
229
|
+
s.set(15) // Math.floor(15/10) = 1, different
|
|
230
|
+
expect(floored._v).toBe(1)
|
|
231
|
+
|
|
232
|
+
s.set(19) // Math.floor(19/10) = 1, same
|
|
233
|
+
expect(floored._v).toBe(1)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test("multiple .direct() updaters on computed, dispose one", () => {
|
|
237
|
+
const s = signal(1)
|
|
238
|
+
const doubled = computed(() => s() * 2)
|
|
239
|
+
doubled() // initialize
|
|
240
|
+
|
|
241
|
+
let calls1 = 0
|
|
242
|
+
let calls2 = 0
|
|
243
|
+
let calls3 = 0
|
|
244
|
+
|
|
245
|
+
const dispose1 = doubled.direct(() => {
|
|
246
|
+
calls1++
|
|
247
|
+
})
|
|
248
|
+
doubled.direct(() => {
|
|
249
|
+
calls2++
|
|
250
|
+
})
|
|
251
|
+
doubled.direct(() => {
|
|
252
|
+
calls3++
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
s.set(2)
|
|
256
|
+
expect(calls1).toBe(1)
|
|
257
|
+
expect(calls2).toBe(1)
|
|
258
|
+
expect(calls3).toBe(1)
|
|
259
|
+
|
|
260
|
+
dispose1()
|
|
261
|
+
// Read to reset dirty flag so next change triggers recompute notification
|
|
262
|
+
doubled()
|
|
263
|
+
s.set(3)
|
|
264
|
+
expect(calls1).toBe(1) // disposed
|
|
265
|
+
expect(calls2).toBe(2) // still active
|
|
266
|
+
expect(calls3).toBe(2) // still active
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe("diamond pattern cleanup", () => {
|
|
271
|
+
test("a -> b, c -> d diamond: d only recomputes once per a change", () => {
|
|
272
|
+
const a = signal(1)
|
|
273
|
+
const b = computed(() => a() + 1)
|
|
274
|
+
const c = computed(() => a() + 2)
|
|
275
|
+
|
|
276
|
+
let dComputations = 0
|
|
277
|
+
const d = computed(() => {
|
|
278
|
+
dComputations++
|
|
279
|
+
return b() + c()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
expect(d()).toBe(5) // b=2 + c=3
|
|
283
|
+
expect(dComputations).toBe(1)
|
|
284
|
+
|
|
285
|
+
a.set(2) // b=3, c=4
|
|
286
|
+
expect(d()).toBe(7)
|
|
287
|
+
// d should only recompute once (lazy evaluation avoids double recompute)
|
|
288
|
+
expect(dComputations).toBe(2)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test("dispose middle node in diamond, verify no stale subscriptions", () => {
|
|
292
|
+
const a = signal(1)
|
|
293
|
+
const b = computed(() => a() * 2)
|
|
294
|
+
const c = computed(() => a() * 3)
|
|
295
|
+
|
|
296
|
+
let dRuns = 0
|
|
297
|
+
const d = computed(() => {
|
|
298
|
+
dRuns++
|
|
299
|
+
return b() + c()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(d()).toBe(5) // b=2 + c=3
|
|
303
|
+
expect(dRuns).toBe(1)
|
|
304
|
+
|
|
305
|
+
// Dispose b — it will no longer recompute from a
|
|
306
|
+
b.dispose()
|
|
307
|
+
|
|
308
|
+
a.set(2)
|
|
309
|
+
// d still recomputes because c changed, but b returns stale value (2)
|
|
310
|
+
expect(d()).toBe(8) // b=2 (stale) + c=6
|
|
311
|
+
expect(dRuns).toBe(2)
|
|
312
|
+
|
|
313
|
+
// Verify a no longer notifies b's subscribers
|
|
314
|
+
a.set(3)
|
|
315
|
+
expect(d()).toBe(11) // b=2 (still stale) + c=9
|
|
316
|
+
expect(dRuns).toBe(3)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
200
319
|
})
|
|
@@ -116,4 +116,117 @@ describe("createSelector", () => {
|
|
|
116
116
|
expect(isSelected(1)).toBe(true)
|
|
117
117
|
expect(isSelected(2)).toBe(false)
|
|
118
118
|
})
|
|
119
|
+
|
|
120
|
+
describe("large subscriber sets", () => {
|
|
121
|
+
test("many subscribers (20+), only affected buckets notified", () => {
|
|
122
|
+
const selected = signal(0)
|
|
123
|
+
const isSelected = createSelector(() => selected())
|
|
124
|
+
|
|
125
|
+
const runCounts: number[] = []
|
|
126
|
+
for (let i = 0; i < 25; i++) {
|
|
127
|
+
const idx = i
|
|
128
|
+
runCounts.push(0)
|
|
129
|
+
effect(() => {
|
|
130
|
+
isSelected(idx)
|
|
131
|
+
runCounts[idx] = (runCounts[idx] ?? 0) + 1
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// All effects ran once
|
|
136
|
+
const allOnes = runCounts.every((c) => c === 1)
|
|
137
|
+
expect(allOnes).toBe(true)
|
|
138
|
+
|
|
139
|
+
// Change from 0 to 5: only buckets 0 and 5 should re-run
|
|
140
|
+
selected.set(5)
|
|
141
|
+
expect(runCounts[0]).toBe(2) // deselected
|
|
142
|
+
expect(runCounts[5]).toBe(2) // newly selected
|
|
143
|
+
const unaffectedAfterFirst = runCounts.every((c, i) => i === 0 || i === 5 || c === 1)
|
|
144
|
+
expect(unaffectedAfterFirst).toBe(true)
|
|
145
|
+
|
|
146
|
+
// Change from 5 to 24: only buckets 5 and 24 re-run
|
|
147
|
+
selected.set(24)
|
|
148
|
+
expect(runCounts[5]).toBe(3)
|
|
149
|
+
expect(runCounts[24]).toBe(2)
|
|
150
|
+
const unaffectedAfterSecond = runCounts.every((c, i) => [0, 5, 24].includes(i) || c === 1)
|
|
151
|
+
expect(unaffectedAfterSecond).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test("selector with undefined and null values", () => {
|
|
155
|
+
const selected = signal<string | null | undefined>("a")
|
|
156
|
+
const isSelected = createSelector(() => selected())
|
|
157
|
+
|
|
158
|
+
let nullRuns = 0
|
|
159
|
+
let undefRuns = 0
|
|
160
|
+
let aRuns = 0
|
|
161
|
+
|
|
162
|
+
effect(() => {
|
|
163
|
+
isSelected(null)
|
|
164
|
+
nullRuns++
|
|
165
|
+
})
|
|
166
|
+
effect(() => {
|
|
167
|
+
isSelected(undefined)
|
|
168
|
+
undefRuns++
|
|
169
|
+
})
|
|
170
|
+
effect(() => {
|
|
171
|
+
isSelected("a")
|
|
172
|
+
aRuns++
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(nullRuns).toBe(1)
|
|
176
|
+
expect(undefRuns).toBe(1)
|
|
177
|
+
expect(aRuns).toBe(1)
|
|
178
|
+
|
|
179
|
+
// Switch to null
|
|
180
|
+
selected.set(null)
|
|
181
|
+
expect(aRuns).toBe(2) // deselected
|
|
182
|
+
expect(nullRuns).toBe(2) // newly selected
|
|
183
|
+
expect(undefRuns).toBe(1) // unaffected
|
|
184
|
+
|
|
185
|
+
// Switch to undefined
|
|
186
|
+
selected.set(undefined)
|
|
187
|
+
expect(nullRuns).toBe(3) // deselected
|
|
188
|
+
expect(undefRuns).toBe(2) // newly selected
|
|
189
|
+
expect(aRuns).toBe(2) // unaffected
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("rapid selector changes", () => {
|
|
193
|
+
const selected = signal(0)
|
|
194
|
+
const isSelected = createSelector(() => selected())
|
|
195
|
+
|
|
196
|
+
let runs0 = 0
|
|
197
|
+
let runs1 = 0
|
|
198
|
+
let runs2 = 0
|
|
199
|
+
|
|
200
|
+
effect(() => {
|
|
201
|
+
isSelected(0)
|
|
202
|
+
runs0++
|
|
203
|
+
})
|
|
204
|
+
effect(() => {
|
|
205
|
+
isSelected(1)
|
|
206
|
+
runs1++
|
|
207
|
+
})
|
|
208
|
+
effect(() => {
|
|
209
|
+
isSelected(2)
|
|
210
|
+
runs2++
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Rapid changes: 0 -> 1 -> 2 -> 0
|
|
214
|
+
selected.set(1)
|
|
215
|
+
selected.set(2)
|
|
216
|
+
selected.set(0)
|
|
217
|
+
|
|
218
|
+
// Each affected bucket should have been notified for each change
|
|
219
|
+
// 0->1: runs0+1, runs1+1
|
|
220
|
+
// 1->2: runs1+1, runs2+1
|
|
221
|
+
// 2->0: runs2+1, runs0+1
|
|
222
|
+
expect(runs0).toBe(3) // initial + deselected + reselected
|
|
223
|
+
expect(runs1).toBe(3) // initial + selected + deselected
|
|
224
|
+
expect(runs2).toBe(3) // initial + selected + deselected
|
|
225
|
+
|
|
226
|
+
// Final state
|
|
227
|
+
expect(isSelected(0)).toBe(true)
|
|
228
|
+
expect(isSelected(1)).toBe(false)
|
|
229
|
+
expect(isSelected(2)).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
119
232
|
})
|
package/src/tests/signal.test.ts
CHANGED
|
@@ -117,4 +117,62 @@ describe("signal", () => {
|
|
|
117
117
|
const info = s.debug()
|
|
118
118
|
expect(info.name).toBeUndefined()
|
|
119
119
|
})
|
|
120
|
+
|
|
121
|
+
describe("direct updater disposal", () => {
|
|
122
|
+
test("disposed direct updater is not called on subsequent updates", () => {
|
|
123
|
+
const s = signal(0)
|
|
124
|
+
let called = 0
|
|
125
|
+
const dispose = s.direct(() => {
|
|
126
|
+
called++
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
s.set(1)
|
|
130
|
+
expect(called).toBe(1)
|
|
131
|
+
|
|
132
|
+
dispose()
|
|
133
|
+
s.set(2)
|
|
134
|
+
expect(called).toBe(1) // not called after disposal
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("multiple direct updaters, dispose one, others still fire", () => {
|
|
138
|
+
const s = signal(0)
|
|
139
|
+
let calls1 = 0
|
|
140
|
+
let calls2 = 0
|
|
141
|
+
let calls3 = 0
|
|
142
|
+
|
|
143
|
+
const dispose1 = s.direct(() => {
|
|
144
|
+
calls1++
|
|
145
|
+
})
|
|
146
|
+
s.direct(() => {
|
|
147
|
+
calls2++
|
|
148
|
+
})
|
|
149
|
+
s.direct(() => {
|
|
150
|
+
calls3++
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
s.set(1)
|
|
154
|
+
expect(calls1).toBe(1)
|
|
155
|
+
expect(calls2).toBe(1)
|
|
156
|
+
expect(calls3).toBe(1)
|
|
157
|
+
|
|
158
|
+
dispose1()
|
|
159
|
+
s.set(2)
|
|
160
|
+
expect(calls1).toBe(1) // disposed — not called
|
|
161
|
+
expect(calls2).toBe(2) // still active
|
|
162
|
+
expect(calls3).toBe(2) // still active
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test("direct updater slot is null after disposal", () => {
|
|
166
|
+
const s = signal(0)
|
|
167
|
+
const dispose = s.direct(() => {})
|
|
168
|
+
|
|
169
|
+
// Access internal _d array via cast
|
|
170
|
+
const internal = s as unknown as { _d: ((() => void) | null)[] | null }
|
|
171
|
+
expect(internal._d).not.toBeNull()
|
|
172
|
+
expect(internal._d![0]).toBeTypeOf("function")
|
|
173
|
+
|
|
174
|
+
dispose()
|
|
175
|
+
expect(internal._d![0]).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
120
178
|
})
|