@pyreon/reactivity 0.14.0 → 0.15.0
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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +87 -24
- package/lib/types/index.d.ts +14 -1
- package/package.json +5 -4
- package/src/batch.ts +135 -32
- package/src/computed.ts +21 -6
- package/src/effect.ts +149 -14
- package/src/env.d.ts +6 -0
- package/src/index.ts +10 -1
- package/src/signal.ts +3 -10
- package/src/tests/batch.test.ts +418 -0
- package/src/tests/computed.test.ts +32 -0
- package/src/tests/effect.test.ts +65 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/tests/batch.test.ts
CHANGED
|
@@ -144,3 +144,421 @@ describe('batch', () => {
|
|
|
144
144
|
expect(seen).toBe(42)
|
|
145
145
|
})
|
|
146
146
|
})
|
|
147
|
+
|
|
148
|
+
// ─── Regression: cascade-depth-asymmetry double-fire ─────────────────────────
|
|
149
|
+
//
|
|
150
|
+
// Pre-fix: batch flush used two pre-allocated Sets (setA, setB) swapped on
|
|
151
|
+
// each round. Cascade notifications enqueued during round 1 went to setB and
|
|
152
|
+
// were processed in round 2. When a single subscriber had BOTH a 0-hop signal
|
|
153
|
+
// dependency AND a 1-hop indirection (computed, createSelector predicate,
|
|
154
|
+
// derived signal) and BOTH paths were triggered in the same batch, the
|
|
155
|
+
// subscriber was queued in DIFFERENT rounds — once via the direct enqueue
|
|
156
|
+
// (round 1) and once via the cascade (round 2). Cross-round Set-dedup didn't
|
|
157
|
+
// work because each round used a fresh Set; the subscriber fired twice.
|
|
158
|
+
//
|
|
159
|
+
// In real usage: list rendering with N items each tracking `isSelected(item.id)`
|
|
160
|
+
// plus a shared signal — every batched selection change scaled to O(N)
|
|
161
|
+
// wasted re-runs.
|
|
162
|
+
//
|
|
163
|
+
// Post-fix: single-Set iteration with cascade-during-iteration. Set-dedup
|
|
164
|
+
// handles ALL cases (diamond, multi-dep selector, self-modifying effect)
|
|
165
|
+
// uniformly — adding an entry already in the Set is a no-op; adding a new
|
|
166
|
+
// entry during iteration is visited exactly once.
|
|
167
|
+
describe('batch — cascade-depth-asymmetry dedup (regression)', () => {
|
|
168
|
+
test('subscriber with 0-hop + 1-hop deps both written in batch fires once', async () => {
|
|
169
|
+
const { computed } = await import('../computed')
|
|
170
|
+
const source = signal(1)
|
|
171
|
+
const other = signal(0)
|
|
172
|
+
// 1-hop indirection via a computed (mirrors createSelector's predicate shape)
|
|
173
|
+
const isTarget = computed(() => Object.is(source(), 2), { equals: (a, b) => a === b })
|
|
174
|
+
|
|
175
|
+
let runs = 0
|
|
176
|
+
effect(() => {
|
|
177
|
+
isTarget()
|
|
178
|
+
other()
|
|
179
|
+
runs++
|
|
180
|
+
})
|
|
181
|
+
runs = 0
|
|
182
|
+
|
|
183
|
+
batch(() => {
|
|
184
|
+
source.set(2)
|
|
185
|
+
other.set(1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(runs).toBe(1)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('diamond cascade still dedupes correctly (a → b, c → d → effect)', async () => {
|
|
192
|
+
const { computed } = await import('../computed')
|
|
193
|
+
const a = signal(0)
|
|
194
|
+
const b = computed(() => a() * 2)
|
|
195
|
+
const c = computed(() => a() + 1)
|
|
196
|
+
const d = computed(() => b() + c())
|
|
197
|
+
|
|
198
|
+
let runs = 0
|
|
199
|
+
effect(() => {
|
|
200
|
+
d()
|
|
201
|
+
runs++
|
|
202
|
+
})
|
|
203
|
+
runs = 0
|
|
204
|
+
|
|
205
|
+
a.set(5)
|
|
206
|
+
expect(runs).toBe(1)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Scale-up of Test 1: 50 list items each tracking the SAME 1-hop indirection
|
|
210
|
+
// (`isTarget` memo) + the same shared signal. This is the real-world list-
|
|
211
|
+
// rendering shape that motivated the fix. Pre-fix: each effect fired 2× →
|
|
212
|
+
// 100 total runs per click. Post-fix: 50 (one per item).
|
|
213
|
+
test('many list-item subscribers all sharing one indirection + shared signal — all fire once', async () => {
|
|
214
|
+
const { computed } = await import('../computed')
|
|
215
|
+
const selectedId = signal(1)
|
|
216
|
+
const other = signal(0)
|
|
217
|
+
const isTarget = computed(() => Object.is(selectedId(), 2), { equals: (a, b) => a === b })
|
|
218
|
+
|
|
219
|
+
const counts: number[] = []
|
|
220
|
+
for (let i = 0; i < 50; i++) {
|
|
221
|
+
const idx = i
|
|
222
|
+
counts.push(0)
|
|
223
|
+
effect(() => {
|
|
224
|
+
isTarget()
|
|
225
|
+
other()
|
|
226
|
+
counts[idx]!++
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
counts.fill(0)
|
|
230
|
+
|
|
231
|
+
batch(() => {
|
|
232
|
+
selectedId.set(2)
|
|
233
|
+
other.set(1)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const totalRuns = counts.reduce((a, b) => a + b, 0)
|
|
237
|
+
expect(totalRuns).toBe(50)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// ─── Edge-case shapes the swap-removal must also handle correctly ───────────
|
|
242
|
+
//
|
|
243
|
+
// The single-Set iteration design relies on JS Set semantics. Each test below
|
|
244
|
+
// pins a structural property the implementation must hold across the cases
|
|
245
|
+
// that aren't covered by the canonical bug repro above. They form a
|
|
246
|
+
// quasi-fence: changes that break any one of these would break a real
|
|
247
|
+
// usage shape we know exists.
|
|
248
|
+
describe('batch — additional cascade shapes', () => {
|
|
249
|
+
test('3-hop chain + 0-hop direct dep: subscriber fires once (canonical bug at depth 3)', async () => {
|
|
250
|
+
const { computed } = await import('../computed')
|
|
251
|
+
const source = signal(0)
|
|
252
|
+
const other = signal(0)
|
|
253
|
+
// 3-hop chain: source → a → b → c → effect
|
|
254
|
+
const a = computed(() => source() * 2, { equals: (x, y) => x === y })
|
|
255
|
+
const b = computed(() => a() + 1, { equals: (x, y) => x === y })
|
|
256
|
+
const c = computed(() => b() * 3, { equals: (x, y) => x === y })
|
|
257
|
+
|
|
258
|
+
let runs = 0
|
|
259
|
+
effect(() => {
|
|
260
|
+
c()
|
|
261
|
+
other()
|
|
262
|
+
runs++
|
|
263
|
+
})
|
|
264
|
+
runs = 0
|
|
265
|
+
|
|
266
|
+
// Both writes in one batch. Pre-removal of swap, the deeper indirection
|
|
267
|
+
// would land in an even-later round than the 0-hop direct path — same
|
|
268
|
+
// bug shape, just more rounds apart.
|
|
269
|
+
batch(() => {
|
|
270
|
+
source.set(5)
|
|
271
|
+
other.set(1)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
expect(runs).toBe(1)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('effect that throws during flush — iteration continues for remaining queued effects', () => {
|
|
278
|
+
const a = signal(0)
|
|
279
|
+
const b = signal(0)
|
|
280
|
+
let bRuns = 0
|
|
281
|
+
|
|
282
|
+
// First effect throws; the queue must still drain.
|
|
283
|
+
effect(() => {
|
|
284
|
+
if (a() > 0) throw new Error('boom')
|
|
285
|
+
})
|
|
286
|
+
effect(() => {
|
|
287
|
+
b()
|
|
288
|
+
bRuns++
|
|
289
|
+
})
|
|
290
|
+
bRuns = 0
|
|
291
|
+
|
|
292
|
+
// Both queued in same batch. The throwing effect is FIRST in pending
|
|
293
|
+
// (declared first → tracks first → enqueued first). It throws; the loop
|
|
294
|
+
// must continue and run the second effect.
|
|
295
|
+
batch(() => {
|
|
296
|
+
a.set(1)
|
|
297
|
+
b.set(1)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// Second effect ran exactly once despite the first effect throwing.
|
|
301
|
+
expect(bRuns).toBe(1)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('effect creating a new effect during flush — new effect runs and tracks correctly', () => {
|
|
305
|
+
const trigger = signal(0)
|
|
306
|
+
const inner = signal(100)
|
|
307
|
+
const innerSeen: number[] = []
|
|
308
|
+
|
|
309
|
+
let outerRuns = 0
|
|
310
|
+
effect(() => {
|
|
311
|
+
trigger()
|
|
312
|
+
outerRuns++
|
|
313
|
+
// First trigger.set() creates a new effect during flush. The new
|
|
314
|
+
// effect must run and pick up tracked deps.
|
|
315
|
+
if (outerRuns === 2) {
|
|
316
|
+
effect(() => {
|
|
317
|
+
innerSeen.push(inner())
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
trigger.set(1)
|
|
323
|
+
expect(outerRuns).toBe(2)
|
|
324
|
+
// Inner effect was created → ran initially with inner=100.
|
|
325
|
+
expect(innerSeen).toEqual([100])
|
|
326
|
+
|
|
327
|
+
// Inner effect should track `inner`. Updating inner() after creation
|
|
328
|
+
// re-fires it.
|
|
329
|
+
inner.set(101)
|
|
330
|
+
expect(innerSeen).toEqual([100, 101])
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('effect disposed during another effect\'s flush — disposed one does not fire', () => {
|
|
334
|
+
const a = signal(0)
|
|
335
|
+
const b = signal(0)
|
|
336
|
+
|
|
337
|
+
let aRuns = 0
|
|
338
|
+
let bRuns = 0
|
|
339
|
+
|
|
340
|
+
let bEffect: ReturnType<typeof effect> | null = null
|
|
341
|
+
|
|
342
|
+
// First effect disposes the second one whenever it fires (after init).
|
|
343
|
+
effect(() => {
|
|
344
|
+
a()
|
|
345
|
+
aRuns++
|
|
346
|
+
if (bEffect) {
|
|
347
|
+
bEffect.dispose()
|
|
348
|
+
bEffect = null
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// bEffect created AFTER the first effect's initial run, so the first
|
|
353
|
+
// effect's initial run sees bEffect=null (no dispose). Both effects
|
|
354
|
+
// exist after this line.
|
|
355
|
+
bEffect = effect(() => {
|
|
356
|
+
b()
|
|
357
|
+
bRuns++
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
aRuns = 0
|
|
361
|
+
bRuns = 0
|
|
362
|
+
|
|
363
|
+
// Both signals change in one batch. pending=[aEffect.run, bEffect.run].
|
|
364
|
+
// aEffect.run runs first → disposes bEffect (sets bEffect.disposed=true).
|
|
365
|
+
// bEffect.run runs next → early-returns on `if (disposed) return`.
|
|
366
|
+
batch(() => {
|
|
367
|
+
a.set(1)
|
|
368
|
+
b.set(1)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(aRuns).toBe(1)
|
|
372
|
+
expect(bRuns).toBe(0)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('self-modifying effect: writes a tracked signal mid-run, no infinite loop, settles', () => {
|
|
376
|
+
const counter = signal(0)
|
|
377
|
+
let runs = 0
|
|
378
|
+
|
|
379
|
+
effect(() => {
|
|
380
|
+
const v = counter()
|
|
381
|
+
runs++
|
|
382
|
+
// Write to a tracked signal — should NOT re-queue this effect within
|
|
383
|
+
// the same batch (already-iterated entries don't re-fire).
|
|
384
|
+
if (v < 3) {
|
|
385
|
+
counter.set(v + 1)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// Initial run: reads 0, writes 1. No re-fire in same flush.
|
|
390
|
+
// External reset:
|
|
391
|
+
runs = 0
|
|
392
|
+
counter.set(10)
|
|
393
|
+
// counter triggers effect. effect writes counter.set(11). Within one
|
|
394
|
+
// batch boundary (the one signal.set wraps with), the second write
|
|
395
|
+
// would re-queue effect — but Set says "already in queue" / "already
|
|
396
|
+
// iterated" → no infinite loop.
|
|
397
|
+
expect(runs).toBeGreaterThanOrEqual(1)
|
|
398
|
+
expect(runs).toBeLessThan(50) // safety: no runaway
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// ─── Property-based fuzz: random cascade graphs maintain the invariant ──────
|
|
403
|
+
//
|
|
404
|
+
// The CORE invariant the batch flush must guarantee:
|
|
405
|
+
//
|
|
406
|
+
// Each effect fires AT MOST ONCE per batch, with the final state of all
|
|
407
|
+
// its tracked dependencies.
|
|
408
|
+
//
|
|
409
|
+
// Counter-based tests above check specific shapes. This generates random
|
|
410
|
+
// dep graphs (signals + computeds + effects with random edges) and asserts
|
|
411
|
+
// the invariant holds across many topologies. Catches structural
|
|
412
|
+
// regressions that a hand-picked test wouldn't anticipate.
|
|
413
|
+
describe('batch — property-based: random cascade graph maintains invariant', () => {
|
|
414
|
+
// Deterministic RNG so failures are reproducible from the seed.
|
|
415
|
+
function mulberry32(seed: number): () => number {
|
|
416
|
+
let a = seed
|
|
417
|
+
return () => {
|
|
418
|
+
a |= 0
|
|
419
|
+
a = (a + 0x6d2ae53f) | 0
|
|
420
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
421
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
422
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
test('25 random cascade graphs across various sizes — every effect fires ≤1× per batch', async () => {
|
|
427
|
+
const { computed } = await import('../computed')
|
|
428
|
+
const SEEDS = 25
|
|
429
|
+
|
|
430
|
+
for (let seed = 1; seed <= SEEDS; seed++) {
|
|
431
|
+
const rand = mulberry32(seed)
|
|
432
|
+
const numSignals = 2 + Math.floor(rand() * 4) // 2-5 signals
|
|
433
|
+
const numComputeds = 1 + Math.floor(rand() * 5) // 1-5 computeds
|
|
434
|
+
const numEffects = 2 + Math.floor(rand() * 6) // 2-7 effects
|
|
435
|
+
|
|
436
|
+
const signals = Array.from({ length: numSignals }, () => signal(0))
|
|
437
|
+
const computeds: (() => number)[] = []
|
|
438
|
+
// Each computed depends on 1-2 random previous nodes (signals or earlier computeds).
|
|
439
|
+
// Copy signals into allReadable — pushing computeds to allReadable must NOT
|
|
440
|
+
// mutate the signals array (the bug that broke this test on first write).
|
|
441
|
+
const allReadable: (() => number)[] = [...signals]
|
|
442
|
+
for (let i = 0; i < numComputeds; i++) {
|
|
443
|
+
const dep1 = allReadable[Math.floor(rand() * allReadable.length)]!
|
|
444
|
+
const dep2 = allReadable[Math.floor(rand() * allReadable.length)]!
|
|
445
|
+
const c = computed(() => dep1() + dep2(), { equals: (a, b) => a === b })
|
|
446
|
+
computeds.push(c)
|
|
447
|
+
allReadable.push(c)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Each effect tracks 2-4 random readable nodes.
|
|
451
|
+
const counts = Array<number>(numEffects).fill(0)
|
|
452
|
+
for (let i = 0; i < numEffects; i++) {
|
|
453
|
+
const idx = i
|
|
454
|
+
const numDeps = 2 + Math.floor(rand() * 3)
|
|
455
|
+
const deps: (() => number)[] = []
|
|
456
|
+
for (let j = 0; j < numDeps; j++) {
|
|
457
|
+
deps.push(allReadable[Math.floor(rand() * allReadable.length)]!)
|
|
458
|
+
}
|
|
459
|
+
effect(() => {
|
|
460
|
+
for (const d of deps) d()
|
|
461
|
+
counts[idx]!++
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
counts.fill(0)
|
|
465
|
+
|
|
466
|
+
// Random batch: 1-3 signal writes.
|
|
467
|
+
const numWrites = 1 + Math.floor(rand() * 3)
|
|
468
|
+
batch(() => {
|
|
469
|
+
for (let i = 0; i < numWrites; i++) {
|
|
470
|
+
const sig = signals[Math.floor(rand() * signals.length)]!
|
|
471
|
+
sig.set(Math.floor(rand() * 100))
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
// Invariant: every effect fired AT MOST ONCE per batch.
|
|
476
|
+
for (let i = 0; i < numEffects; i++) {
|
|
477
|
+
if (counts[i]! > 1) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`seed=${seed} effect[${i}] fired ${counts[i]} times (>1). ` +
|
|
480
|
+
`numSignals=${numSignals} numComputeds=${numComputeds} numEffects=${numEffects}`,
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
// ─── Audit bug #19: stale-Set leak on subscriber throw ────────────────────────
|
|
489
|
+
//
|
|
490
|
+
// Pre-fix: `pendingNotifications.clear()` was inside the try block of the
|
|
491
|
+
// flush loop. If a subscriber threw mid-iteration, the for-loop exited and
|
|
492
|
+
// the clear() never ran, leaking the unflushed remainder into the next
|
|
493
|
+
// batch. The next batch's `size > 0` check then re-entered flush mode and
|
|
494
|
+
// REFIRED the stale entries.
|
|
495
|
+
//
|
|
496
|
+
// Effect callbacks wrap their internals in try/catch so the bug rarely
|
|
497
|
+
// surfaces from `effect()`, but raw `signal.subscribe(fn)` callbacks (and
|
|
498
|
+
// any future internal consumer that doesn't pre-wrap) throw straight
|
|
499
|
+
// through. Fix: move `clear()` to the finally block.
|
|
500
|
+
|
|
501
|
+
describe('batch — subscriber-throw stale-Set leak (audit bug #19)', () => {
|
|
502
|
+
test('throwing subscriber does not leak stale notifications into next batch', () => {
|
|
503
|
+
const a = signal(0)
|
|
504
|
+
const b = signal(0)
|
|
505
|
+
|
|
506
|
+
let aFires = 0
|
|
507
|
+
let bFires = 0
|
|
508
|
+
|
|
509
|
+
a.subscribe(() => {
|
|
510
|
+
aFires++
|
|
511
|
+
throw new Error('boom')
|
|
512
|
+
})
|
|
513
|
+
b.subscribe(() => {
|
|
514
|
+
bFires++
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
// Batch 1 — A's subscriber throws mid-flush.
|
|
518
|
+
expect(() => {
|
|
519
|
+
batch(() => {
|
|
520
|
+
a.set(1)
|
|
521
|
+
b.set(1)
|
|
522
|
+
})
|
|
523
|
+
}).toThrow('boom')
|
|
524
|
+
|
|
525
|
+
// Sanity: a fired once and threw; b may or may not have fired depending
|
|
526
|
+
// on iteration order. The bug manifests in the SECOND batch — refiring
|
|
527
|
+
// an already-fired-and-cleared entry.
|
|
528
|
+
const aFiresAfterFirst = aFires
|
|
529
|
+
const bFiresAfterFirst = bFires
|
|
530
|
+
|
|
531
|
+
// Batch 2 — only b changes. Without the fix, A's stale subscriber
|
|
532
|
+
// still in pendingNotifications fires AGAIN.
|
|
533
|
+
try {
|
|
534
|
+
batch(() => {
|
|
535
|
+
b.set(2)
|
|
536
|
+
})
|
|
537
|
+
} catch {
|
|
538
|
+
// If A's subscriber refires, the throw escapes here. Catch + assert
|
|
539
|
+
// below so the test reports the right reason.
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
expect(aFires).toBe(aFiresAfterFirst) // A must NOT refire
|
|
543
|
+
expect(bFires).toBeGreaterThan(bFiresAfterFirst) // B should fire for its update
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test('multiple consecutive throws stay isolated to their own batch', () => {
|
|
547
|
+
const sig = signal(0)
|
|
548
|
+
let fires = 0
|
|
549
|
+
sig.subscribe(() => {
|
|
550
|
+
fires++
|
|
551
|
+
throw new Error('always')
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// Three batches, each with a throw — fires count must equal batch count,
|
|
555
|
+
// not multiply per leak.
|
|
556
|
+
for (let i = 1; i <= 3; i++) {
|
|
557
|
+
expect(() => {
|
|
558
|
+
batch(() => sig.set(i))
|
|
559
|
+
}).toThrow('always')
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
expect(fires).toBe(3)
|
|
563
|
+
})
|
|
564
|
+
})
|
|
@@ -316,4 +316,36 @@ describe('computed', () => {
|
|
|
316
316
|
expect(dRuns).toBe(3)
|
|
317
317
|
})
|
|
318
318
|
})
|
|
319
|
+
|
|
320
|
+
// ─── Audit bug #1 (extension): async computed warning ─────────────────
|
|
321
|
+
describe('async function warning', () => {
|
|
322
|
+
test('warns when called with an async arrow function', () => {
|
|
323
|
+
const warns: string[] = []
|
|
324
|
+
const orig = console.warn
|
|
325
|
+
console.warn = (...args: unknown[]) => warns.push(args.join(' '))
|
|
326
|
+
try {
|
|
327
|
+
const asyncFn = async (): Promise<number> => 42
|
|
328
|
+
// Cast through `unknown` because async is intentionally NOT in
|
|
329
|
+
// computed()'s type signature — that's the point. The runtime
|
|
330
|
+
// warn catches what the type system would normally reject.
|
|
331
|
+
computed(asyncFn as unknown as () => number)
|
|
332
|
+
} finally {
|
|
333
|
+
console.warn = orig
|
|
334
|
+
}
|
|
335
|
+
expect(warns.some((m) => m.includes('computed'))).toBe(true)
|
|
336
|
+
expect(warns.some((m) => m.includes('createResource'))).toBe(true)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('does NOT warn for synchronous computed callbacks', () => {
|
|
340
|
+
const warns: string[] = []
|
|
341
|
+
const orig = console.warn
|
|
342
|
+
console.warn = (...args: unknown[]) => warns.push(args.join(' '))
|
|
343
|
+
try {
|
|
344
|
+
computed(() => 42)
|
|
345
|
+
} finally {
|
|
346
|
+
console.warn = orig
|
|
347
|
+
}
|
|
348
|
+
expect(warns.some((m) => m.includes('async function'))).toBe(false)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
319
351
|
})
|
package/src/tests/effect.test.ts
CHANGED
|
@@ -397,3 +397,68 @@ describe('effect — error handling', () => {
|
|
|
397
397
|
setErrorHandler((_err) => {})
|
|
398
398
|
})
|
|
399
399
|
})
|
|
400
|
+
|
|
401
|
+
// ─── Audit bug #1: async effect runtime warning ──────────────────────────────
|
|
402
|
+
//
|
|
403
|
+
// Companion to the `pyreon/no-async-effect` lint rule. The runtime warn
|
|
404
|
+
// catches the case where the lint rule was suppressed or the effect call
|
|
405
|
+
// site was constructed dynamically (e.g. via a higher-order helper that
|
|
406
|
+
// the lint rule's static check doesn't see).
|
|
407
|
+
|
|
408
|
+
describe('effect — async function warning (audit bug #1)', () => {
|
|
409
|
+
test('warns when called with an async arrow function', () => {
|
|
410
|
+
const warns: string[] = []
|
|
411
|
+
const orig = console.warn
|
|
412
|
+
console.warn = (...args: unknown[]) => warns.push(args.join(' '))
|
|
413
|
+
try {
|
|
414
|
+
// Async effect callbacks are intentionally NOT in `effect()`'s type
|
|
415
|
+
// signature — that's the point. The test deliberately misuses the
|
|
416
|
+
// API to verify the runtime warning catches what the type system
|
|
417
|
+
// would normally reject. Cast through `unknown` to silence TS.
|
|
418
|
+
const asyncFn = async (): Promise<void> => {}
|
|
419
|
+
effect(asyncFn as unknown as () => void)
|
|
420
|
+
} finally {
|
|
421
|
+
console.warn = orig
|
|
422
|
+
}
|
|
423
|
+
expect(warns.some((m) => m.includes('async function'))).toBe(true)
|
|
424
|
+
expect(warns.some((m) => m.includes('await'))).toBe(true)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('does NOT warn for synchronous effect callbacks', () => {
|
|
428
|
+
const warns: string[] = []
|
|
429
|
+
const orig = console.warn
|
|
430
|
+
console.warn = (...args: unknown[]) => warns.push(args.join(' '))
|
|
431
|
+
try {
|
|
432
|
+
effect(() => {})
|
|
433
|
+
} finally {
|
|
434
|
+
console.warn = orig
|
|
435
|
+
}
|
|
436
|
+
expect(warns.some((m) => m.includes('async function'))).toBe(false)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('renderEffect warns when called with an async arrow function', () => {
|
|
440
|
+
const warns: string[] = []
|
|
441
|
+
const orig = console.warn
|
|
442
|
+
console.warn = (...args: unknown[]) => warns.push(args.join(' '))
|
|
443
|
+
try {
|
|
444
|
+
const asyncFn = async (): Promise<void> => {}
|
|
445
|
+
renderEffect(asyncFn as unknown as () => void)
|
|
446
|
+
} finally {
|
|
447
|
+
console.warn = orig
|
|
448
|
+
}
|
|
449
|
+
expect(warns.some((m) => m.includes('renderEffect'))).toBe(true)
|
|
450
|
+
expect(warns.some((m) => m.includes('async function'))).toBe(true)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test('renderEffect does NOT warn for synchronous callbacks', () => {
|
|
454
|
+
const warns: string[] = []
|
|
455
|
+
const orig = console.warn
|
|
456
|
+
console.warn = (...args: unknown[]) => warns.push(args.join(' '))
|
|
457
|
+
try {
|
|
458
|
+
renderEffect(() => {})
|
|
459
|
+
} finally {
|
|
460
|
+
console.warn = orig
|
|
461
|
+
}
|
|
462
|
+
expect(warns.some((m) => m.includes('async function'))).toBe(false)
|
|
463
|
+
})
|
|
464
|
+
})
|