@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
|
@@ -1,471 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Coverage-hardening suite for @pyreon/reactivity.
|
|
3
|
-
*
|
|
4
|
-
* The reactivity core is the foundation every other Pyreon package depends
|
|
5
|
-
* on. Its branch coverage had drifted to 87.38% — below the 90% global
|
|
6
|
-
* threshold (the package's own `bun run test` exited non-zero) — leaving
|
|
7
|
-
* error-handler branches, batching/non-batching dual paths, the
|
|
8
|
-
* snapshot-restore DI hook, and the MAX_PASSES infinite-loop guard
|
|
9
|
-
* unexercised. Each test here asserts real observable behaviour of one of
|
|
10
|
-
* those previously-uncovered branches; none games coverage.
|
|
11
|
-
*/
|
|
12
|
-
import { batch } from '../batch'
|
|
13
|
-
import { Cell } from '../cell'
|
|
14
|
-
import { computed } from '../computed'
|
|
15
|
-
import { createSelector } from '../createSelector'
|
|
16
|
-
import {
|
|
17
|
-
type ReactiveSnapshotCapture,
|
|
18
|
-
effect,
|
|
19
|
-
renderEffect,
|
|
20
|
-
setErrorHandler,
|
|
21
|
-
setSnapshotCapture,
|
|
22
|
-
_bind,
|
|
23
|
-
} from '../effect'
|
|
24
|
-
import { clearReactiveTrace, getReactiveTrace } from '../reactive-trace'
|
|
25
|
-
import { signal } from '../signal'
|
|
26
|
-
|
|
27
|
-
// ── effect.ts: error handler on a throwing inner effect during disposal ──────
|
|
28
|
-
|
|
29
|
-
describe('effect — inner-effect disposal error handling', () => {
|
|
30
|
-
test('a throwing inner-effect cleanup is routed to the error handler, not swallowed', () => {
|
|
31
|
-
const caught: unknown[] = []
|
|
32
|
-
setErrorHandler((err) => caught.push(err))
|
|
33
|
-
|
|
34
|
-
const trigger = signal(0)
|
|
35
|
-
let inner: ReturnType<typeof effect> | undefined
|
|
36
|
-
|
|
37
|
-
const outer = effect(() => {
|
|
38
|
-
trigger() // re-runs outer → runCleanup disposes the inner effect
|
|
39
|
-
inner = effect(() => {})
|
|
40
|
-
// Make the inner effect's dispose throw so the catch + _errorHandler
|
|
41
|
-
// branch in runCleanup (effect.ts:183-190) is exercised.
|
|
42
|
-
const orig = inner.dispose
|
|
43
|
-
inner.dispose = () => {
|
|
44
|
-
orig()
|
|
45
|
-
throw new Error('inner-dispose-boom')
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
trigger.set(1) // outer re-runs → runCleanup → inner.dispose() throws
|
|
50
|
-
|
|
51
|
-
expect(caught).toHaveLength(1)
|
|
52
|
-
expect((caught[0] as Error).message).toBe('inner-dispose-boom')
|
|
53
|
-
|
|
54
|
-
outer.dispose()
|
|
55
|
-
setErrorHandler((_err) => {})
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
// ── effect.ts: snapshot-restore DI hook (setSnapshotCapture) ─────────────────
|
|
60
|
-
|
|
61
|
-
describe('effect — snapshot-capture restore branch', () => {
|
|
62
|
-
afterEach(() => setSnapshotCapture(null))
|
|
63
|
-
|
|
64
|
-
test('effect / _bind / renderEffect route re-runs through capture.restore', () => {
|
|
65
|
-
const restoreCalls: string[] = []
|
|
66
|
-
const hook: ReactiveSnapshotCapture = {
|
|
67
|
-
capture: () => ({ tag: 'snap' }),
|
|
68
|
-
restore: (snap, fn) => {
|
|
69
|
-
restoreCalls.push((snap as { tag: string }).tag)
|
|
70
|
-
return fn()
|
|
71
|
-
},
|
|
72
|
-
}
|
|
73
|
-
setSnapshotCapture(hook)
|
|
74
|
-
|
|
75
|
-
const s = signal(0)
|
|
76
|
-
let effRuns = 0
|
|
77
|
-
let bindRuns = 0
|
|
78
|
-
let reRuns = 0
|
|
79
|
-
|
|
80
|
-
const eff = effect(() => {
|
|
81
|
-
s()
|
|
82
|
-
effRuns++
|
|
83
|
-
})
|
|
84
|
-
const bindStop = _bind(() => {
|
|
85
|
-
s()
|
|
86
|
-
bindRuns++
|
|
87
|
-
})
|
|
88
|
-
const reStop = renderEffect(() => {
|
|
89
|
-
s()
|
|
90
|
-
reRuns++
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// First runs do NOT restore (synchronous mount stack already correct).
|
|
94
|
-
expect(restoreCalls).toEqual([])
|
|
95
|
-
|
|
96
|
-
s.set(1) // every effect re-runs — each goes through capture.restore
|
|
97
|
-
|
|
98
|
-
expect(effRuns).toBe(2)
|
|
99
|
-
expect(bindRuns).toBe(2)
|
|
100
|
-
expect(reRuns).toBe(2)
|
|
101
|
-
// Three re-runs, each restored against the captured snapshot.
|
|
102
|
-
expect(restoreCalls).toEqual(['snap', 'snap', 'snap'])
|
|
103
|
-
|
|
104
|
-
eff.dispose()
|
|
105
|
-
bindStop()
|
|
106
|
-
reStop()
|
|
107
|
-
})
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
// ── effect.ts: multi-dep renderEffect cleanup (deps.length > 1) ──────────────
|
|
111
|
-
|
|
112
|
-
describe('effect — renderEffect multi-dependency cleanup', () => {
|
|
113
|
-
test('disposing a renderEffect with >1 tracked signal unsubscribes from all', () => {
|
|
114
|
-
const a = signal(0)
|
|
115
|
-
const b = signal(0)
|
|
116
|
-
let runs = 0
|
|
117
|
-
|
|
118
|
-
const stop = renderEffect(() => {
|
|
119
|
-
a()
|
|
120
|
-
b()
|
|
121
|
-
runs++
|
|
122
|
-
})
|
|
123
|
-
expect(runs).toBe(1)
|
|
124
|
-
|
|
125
|
-
a.set(1)
|
|
126
|
-
b.set(1)
|
|
127
|
-
expect(runs).toBe(3)
|
|
128
|
-
|
|
129
|
-
stop() // deps.length === 2 → the `for (const s of deps)` cleanup branch
|
|
130
|
-
|
|
131
|
-
a.set(2)
|
|
132
|
-
b.set(2)
|
|
133
|
-
expect(runs).toBe(3) // no further runs — fully unsubscribed
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
// ── batch.ts: MAX_PASSES infinite re-enqueue guard ──────────────────────────
|
|
138
|
-
|
|
139
|
-
describe('batch — MAX_PASSES guard on a non-converging effect', () => {
|
|
140
|
-
test('an effect that unconditionally re-writes a signal it reads is capped, warned, and the queue is cleared', () => {
|
|
141
|
-
const warns: string[] = []
|
|
142
|
-
const origWarn = console.warn
|
|
143
|
-
console.warn = (...args: unknown[]) => {
|
|
144
|
-
warns.push(args.join(' '))
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const s = signal(0)
|
|
148
|
-
let runs = 0
|
|
149
|
-
const eff = effect(() => {
|
|
150
|
-
const v = s()
|
|
151
|
-
runs++
|
|
152
|
-
// Always writes a new value → never converges → trips MAX_PASSES.
|
|
153
|
-
s.set(v + 1)
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
// The initial run already self-schedules; force a flush via batch.
|
|
158
|
-
batch(() => {
|
|
159
|
-
s.set(1)
|
|
160
|
-
})
|
|
161
|
-
} finally {
|
|
162
|
-
console.warn = origWarn
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Capped: tier-2 stops after MAX_PASSES (32) — NOT an unbounded loop.
|
|
166
|
-
expect(runs).toBeGreaterThan(1)
|
|
167
|
-
expect(runs).toBeLessThan(200)
|
|
168
|
-
expect(warns.some((w) => w.includes('MAX_PASSES'))).toBe(true)
|
|
169
|
-
|
|
170
|
-
eff.dispose()
|
|
171
|
-
|
|
172
|
-
// Queue was cleared on the trip — a fresh, converging batch works.
|
|
173
|
-
const t = signal(0)
|
|
174
|
-
let tRuns = 0
|
|
175
|
-
const eff2 = effect(() => {
|
|
176
|
-
t()
|
|
177
|
-
tRuns++
|
|
178
|
-
})
|
|
179
|
-
batch(() => t.set(1))
|
|
180
|
-
expect(tRuns).toBe(2)
|
|
181
|
-
eff2.dispose()
|
|
182
|
-
})
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
// ── computed.ts: error handler + direct-updater registration ────────────────
|
|
186
|
-
|
|
187
|
-
describe('computed — error handler and direct updaters', () => {
|
|
188
|
-
test('a throwing computed routes the error to the handler and stays readable', () => {
|
|
189
|
-
const caught: unknown[] = []
|
|
190
|
-
setErrorHandler((err) => caught.push(err))
|
|
191
|
-
|
|
192
|
-
const src = signal(0)
|
|
193
|
-
const c = computed(() => {
|
|
194
|
-
if (src() === 1) throw new Error('compute-boom')
|
|
195
|
-
return src() * 10
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
expect(c()).toBe(0)
|
|
199
|
-
src.set(1)
|
|
200
|
-
// Recompute throws → _errorHandler branch (computed.ts:203-204) fires.
|
|
201
|
-
void c()
|
|
202
|
-
expect(caught.some((e) => (e as Error).message === 'compute-boom')).toBe(true)
|
|
203
|
-
|
|
204
|
-
src.set(2)
|
|
205
|
-
expect(c()).toBe(20) // recovers cleanly after the throwing input passes
|
|
206
|
-
|
|
207
|
-
setErrorHandler((_err) => {})
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
test('computed.direct registers / fires / unsubscribes a direct updater and exposes _d', () => {
|
|
211
|
-
const src = signal(1)
|
|
212
|
-
const c = computed(() => src() * 2)
|
|
213
|
-
void c() // initialize
|
|
214
|
-
|
|
215
|
-
const seen: number[] = []
|
|
216
|
-
const unsub = c.direct(() => seen.push(c()))
|
|
217
|
-
|
|
218
|
-
// `_d` getter (computed.ts:243-247) exposes the live direct-updater set.
|
|
219
|
-
expect((c as unknown as { _d: Set<unknown> })._d.size).toBe(1)
|
|
220
|
-
|
|
221
|
-
src.set(5)
|
|
222
|
-
expect(seen).toEqual([10])
|
|
223
|
-
|
|
224
|
-
unsub()
|
|
225
|
-
expect((c as unknown as { _d: Set<unknown> })._d.size).toBe(0)
|
|
226
|
-
src.set(7)
|
|
227
|
-
expect(seen).toEqual([10]) // no further direct notifications
|
|
228
|
-
})
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
// ── signal.ts: notifyDirect non-batching path ───────────────────────────────
|
|
232
|
-
|
|
233
|
-
describe('signal — direct updater non-batching notification', () => {
|
|
234
|
-
test('signal.direct updater fires synchronously on a non-batched set', () => {
|
|
235
|
-
const s = signal(0)
|
|
236
|
-
const seen: number[] = []
|
|
237
|
-
const unsub = s.direct(() => seen.push(s()))
|
|
238
|
-
|
|
239
|
-
s.set(1) // non-batched → `for (const fn of updaters) fn()` (signal.ts:186)
|
|
240
|
-
s.set(2)
|
|
241
|
-
expect(seen).toEqual([1, 2])
|
|
242
|
-
|
|
243
|
-
unsub()
|
|
244
|
-
s.set(3)
|
|
245
|
-
expect(seen).toEqual([1, 2])
|
|
246
|
-
})
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
// ── createSelector.ts: single-bucket notify + selection change ──────────────
|
|
250
|
-
|
|
251
|
-
describe('createSelector — bucket notification branches', () => {
|
|
252
|
-
test('selecting / deselecting notifies only the two affected single-entry buckets', () => {
|
|
253
|
-
const selected = signal('a')
|
|
254
|
-
const isSelected = createSelector(selected)
|
|
255
|
-
|
|
256
|
-
const aRuns: number[] = []
|
|
257
|
-
const bRuns: number[] = []
|
|
258
|
-
const aStop = renderEffect(() => {
|
|
259
|
-
aRuns.push(isSelected('a') ? 1 : 0)
|
|
260
|
-
})
|
|
261
|
-
const bStop = renderEffect(() => {
|
|
262
|
-
bRuns.push(isSelected('b') ? 1 : 0)
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
expect(aRuns).toEqual([1])
|
|
266
|
-
expect(bRuns).toEqual([0])
|
|
267
|
-
|
|
268
|
-
// 'a' → 'b': old bucket ('a', size 1) + new bucket ('b', size 1) each
|
|
269
|
-
// hit the `bucket.size === 1` fast path (createSelector.ts:10-13).
|
|
270
|
-
selected.set('b')
|
|
271
|
-
expect(aRuns).toEqual([1, 0])
|
|
272
|
-
expect(bRuns).toEqual([0, 1])
|
|
273
|
-
|
|
274
|
-
// Selecting an unobserved value: no bucket for it → no spurious notifies.
|
|
275
|
-
selected.set('c')
|
|
276
|
-
expect(aRuns).toEqual([1, 0])
|
|
277
|
-
expect(bRuns).toEqual([0, 1, 0])
|
|
278
|
-
|
|
279
|
-
aStop()
|
|
280
|
-
bStop()
|
|
281
|
-
isSelected.dispose()
|
|
282
|
-
// Idempotent dispose.
|
|
283
|
-
isSelected.dispose()
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
test('multi-subscriber bucket uses the iteration-capped loop', () => {
|
|
287
|
-
const selected = signal('x')
|
|
288
|
-
const isSelected = createSelector(selected)
|
|
289
|
-
const hits: string[] = []
|
|
290
|
-
|
|
291
|
-
// Three subscribers all querying 'x' → bucket size 3 → capped-loop path.
|
|
292
|
-
const stops = [1, 2, 3].map((n) =>
|
|
293
|
-
renderEffect(() => {
|
|
294
|
-
if (isSelected('x')) hits.push(`s${n}`)
|
|
295
|
-
}),
|
|
296
|
-
)
|
|
297
|
-
expect(hits).toEqual(['s1', 's2', 's3'])
|
|
298
|
-
|
|
299
|
-
selected.set('y') // old bucket 'x' has 3 entries → multi-entry branch
|
|
300
|
-
selected.set('x')
|
|
301
|
-
expect(hits).toEqual(['s1', 's2', 's3', 's1', 's2', 's3'])
|
|
302
|
-
|
|
303
|
-
stops.forEach((s) => s())
|
|
304
|
-
isSelected.dispose()
|
|
305
|
-
})
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
// ── cell.ts: listener → Set promotion when a single listener already exists ──
|
|
309
|
-
|
|
310
|
-
describe('Cell — single-listener promotion to Set', () => {
|
|
311
|
-
test('a second subscribe promotes the lazy _l slot into a Set (cell.ts:49)', () => {
|
|
312
|
-
const c = new Cell(0)
|
|
313
|
-
const seen: string[] = []
|
|
314
|
-
// First subscribe: stored in the single-listener `_l` fast-path slot.
|
|
315
|
-
const off1 = c.subscribe(() => seen.push('a'))
|
|
316
|
-
// Second subscribe: `!this._s` is true AND `this._l` is set → the
|
|
317
|
-
// promotion branch `this._s.add(this._l); this._l = null` runs.
|
|
318
|
-
const off2 = c.subscribe(() => seen.push('b'))
|
|
319
|
-
|
|
320
|
-
c.set(1)
|
|
321
|
-
expect(seen).toEqual(['a', 'b'])
|
|
322
|
-
|
|
323
|
-
off1() // unsubscribe the promoted listener from the Set
|
|
324
|
-
c.set(2)
|
|
325
|
-
expect(seen).toEqual(['a', 'b', 'b'])
|
|
326
|
-
off2()
|
|
327
|
-
c.set(3)
|
|
328
|
-
expect(seen).toEqual(['a', 'b', 'b'])
|
|
329
|
-
})
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
// ── reactive-trace.ts: preview() value-shape branches ───────────────────────
|
|
333
|
-
|
|
334
|
-
describe('reactive-trace — preview of every value shape', () => {
|
|
335
|
-
beforeEach(() => clearReactiveTrace())
|
|
336
|
-
|
|
337
|
-
test('arrays, functions, symbols, bigint, plain objects, named instances, >4 keys, long strings', () => {
|
|
338
|
-
const s = signal<unknown>(null, { name: 'v' })
|
|
339
|
-
|
|
340
|
-
s.set([1, 2, 3]) // Array(3)
|
|
341
|
-
s.set(function namedFn() {}) // [Function namedFn]
|
|
342
|
-
s.set(Symbol('sym')) // Symbol(sym)
|
|
343
|
-
s.set(10n) // bigint → String()
|
|
344
|
-
s.set({ a: 1, b: 2 }) // plain object → { a, b }
|
|
345
|
-
class Point {
|
|
346
|
-
x = 1
|
|
347
|
-
}
|
|
348
|
-
s.set(new Point()) // named ctor → "Point { x }"
|
|
349
|
-
s.set({ a: 1, b: 2, c: 3, d: 4, e: 5 }) // >4 keys → trailing ", …"
|
|
350
|
-
s.set('z'.repeat(500)) // long string → truncated with "…"
|
|
351
|
-
|
|
352
|
-
const trace = getReactiveTrace()
|
|
353
|
-
const nexts = trace.map((e) => e.next)
|
|
354
|
-
|
|
355
|
-
expect(nexts).toContain('Array(3)')
|
|
356
|
-
expect(nexts.some((n) => n!.includes('[Function namedFn]'))).toBe(true)
|
|
357
|
-
expect(nexts.some((n) => n!.includes('Symbol(sym)'))).toBe(true)
|
|
358
|
-
expect(nexts).toContain('10')
|
|
359
|
-
expect(nexts.some((n) => n === '{a, b}' || n === '{ a, b }' || /\{a, b\}/.test(n!))).toBe(true)
|
|
360
|
-
expect(nexts.some((n) => n!.startsWith('Point '))).toBe(true)
|
|
361
|
-
expect(nexts.some((n) => n!.includes('…'))).toBe(true) // both >4-keys and long-string
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
test('an object whose Object.keys throws falls back gracefully (no throw, recorded)', () => {
|
|
365
|
-
const s = signal<unknown>(null, { name: 'p' })
|
|
366
|
-
// A revoked Proxy throws on every trap including ownKeys → the inner
|
|
367
|
-
// try/catch returns [] and the outer try/catch keeps preview total.
|
|
368
|
-
const { proxy, revoke } = Proxy.revocable({}, {})
|
|
369
|
-
revoke()
|
|
370
|
-
expect(() => s.set(proxy)).not.toThrow()
|
|
371
|
-
expect(getReactiveTrace()).toHaveLength(1)
|
|
372
|
-
})
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
// ── tracking.ts: cleanupEffect WeakMap path + batched single-subscriber ──────
|
|
376
|
-
|
|
377
|
-
describe('tracking — cleanup + batched single-subscriber', () => {
|
|
378
|
-
test('an effect re-run clears its WeakMap-tracked deps before re-subscribing', () => {
|
|
379
|
-
const a = signal(0)
|
|
380
|
-
const b = signal(100)
|
|
381
|
-
const useA = signal(true)
|
|
382
|
-
let runs = 0
|
|
383
|
-
|
|
384
|
-
const eff = effect(() => {
|
|
385
|
-
// Dynamic dependency: switches which signal it tracks. The re-run
|
|
386
|
-
// path goes through cleanupEffect's `for (const sub of deps)` +
|
|
387
|
-
// deps.clear() branch (tracking.ts:72-73).
|
|
388
|
-
if (useA()) a()
|
|
389
|
-
else b()
|
|
390
|
-
runs++
|
|
391
|
-
})
|
|
392
|
-
expect(runs).toBe(1)
|
|
393
|
-
|
|
394
|
-
useA.set(false) // re-run, now tracks b not a
|
|
395
|
-
expect(runs).toBe(2)
|
|
396
|
-
|
|
397
|
-
a.set(1) // a is no longer a dep → no run
|
|
398
|
-
expect(runs).toBe(2)
|
|
399
|
-
|
|
400
|
-
b.set(101) // b IS the dep now → runs
|
|
401
|
-
expect(runs).toBe(3)
|
|
402
|
-
|
|
403
|
-
eff.dispose()
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
test('a computed with ONE subscriber recomputed under batch enqueues via notifySubscribers (tracking.ts:83)', () => {
|
|
407
|
-
const src = signal(0)
|
|
408
|
-
const c = computed(() => src() * 2)
|
|
409
|
-
const order: string[] = []
|
|
410
|
-
|
|
411
|
-
const eff = effect(() => {
|
|
412
|
-
order.push(`run:${c()}`)
|
|
413
|
-
})
|
|
414
|
-
expect(order).toEqual(['run:0'])
|
|
415
|
-
|
|
416
|
-
batch(() => {
|
|
417
|
-
src.set(5) // computed.recompute → notifySubscribers(host._s), size 1, batching
|
|
418
|
-
order.push('mid-batch')
|
|
419
|
-
})
|
|
420
|
-
// The single computed-subscriber fired AFTER the batch (enqueued, not inline).
|
|
421
|
-
expect(order).toEqual(['run:0', 'mid-batch', 'run:10'])
|
|
422
|
-
|
|
423
|
-
eff.dispose()
|
|
424
|
-
})
|
|
425
|
-
|
|
426
|
-
test('a computed with TWO+ subscribers recomputed without batch hits the multi-sub inline loop (tracking.ts:97-102)', () => {
|
|
427
|
-
const src = signal(1)
|
|
428
|
-
const c = computed(() => src() + 1)
|
|
429
|
-
const a: number[] = []
|
|
430
|
-
const b: number[] = []
|
|
431
|
-
|
|
432
|
-
const effA = effect(() => {
|
|
433
|
-
a.push(c())
|
|
434
|
-
})
|
|
435
|
-
const effB = effect(() => {
|
|
436
|
-
b.push(c())
|
|
437
|
-
})
|
|
438
|
-
expect(a).toEqual([2])
|
|
439
|
-
expect(b).toEqual([2])
|
|
440
|
-
|
|
441
|
-
// Non-batched source change → computed.recompute → notifySubscribers
|
|
442
|
-
// with host._s.size === 2 → the originalSize-capped inline `for` loop.
|
|
443
|
-
src.set(9)
|
|
444
|
-
expect(a).toEqual([2, 10])
|
|
445
|
-
expect(b).toEqual([2, 10])
|
|
446
|
-
|
|
447
|
-
effA.dispose()
|
|
448
|
-
effB.dispose()
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
test('a single-subscriber signal written inside batch() enqueues (not inline)', () => {
|
|
452
|
-
const s = signal(0)
|
|
453
|
-
const order: string[] = []
|
|
454
|
-
const eff = effect(() => {
|
|
455
|
-
s()
|
|
456
|
-
order.push(`run:${s()}`)
|
|
457
|
-
})
|
|
458
|
-
expect(order).toEqual(['run:0'])
|
|
459
|
-
|
|
460
|
-
batch(() => {
|
|
461
|
-
s.set(1)
|
|
462
|
-
order.push('mid-batch') // effect must NOT have run yet (enqueued)
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
// tracking.ts:83 — single subscriber under batch → enqueuePendingNotification,
|
|
466
|
-
// so the effect fires AFTER the batch body, not inline at .set().
|
|
467
|
-
expect(order).toEqual(['run:0', 'mid-batch', 'run:1'])
|
|
468
|
-
|
|
469
|
-
eff.dispose()
|
|
470
|
-
})
|
|
471
|
-
})
|