@pyreon/reactivity 0.7.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/reactivity",
3
- "version": "0.7.12",
3
+ "version": "0.7.13",
4
4
  "description": "Signals-based reactivity system for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  })
@@ -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
  })