Skip to content

Commit 7ccd188

Browse files
committed
re-entrancy tests & fixes
1 parent 51975bf commit 7ccd188

2 files changed

Lines changed: 219 additions & 11 deletions

File tree

packages/store/src/atom.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,22 @@ function addWatch(node: WatchableNode) {
5959
deps = deps.nextDep
6060
}
6161

62-
// 2. start/run own watch effects
62+
// 2. start/run own watch effects.
63+
// Assign `_watchCleanups` BEFORE invoking any effect and seed it with
64+
// packed `undefined` slots (avoid `new Array(len)` which gives a holey
65+
// SMI array in V8). A re-entrant `whileWatched()` during `ef()` pushes
66+
// into this same array at `i >= len`, keeping indices aligned with
67+
// `_watchEffects`; the outer loop writes the pre-reserved slot by index.
6368
const watchEffects = node._watchEffects
6469
if (watchEffects?.length) {
65-
node._watchCleanups = watchEffects.map((ef) => ef())
70+
const len = watchEffects.length
71+
const cleanups: Array<(() => void) | void | undefined> = []
72+
node._watchCleanups = cleanups
73+
for (let i = 0; i < len; i++) cleanups.push(undefined)
74+
for (let i = 0; i < len; i++) {
75+
const ef = watchEffects[i]
76+
if (ef) cleanups[i] = ef()
77+
}
6678
}
6779
}
6880
}
@@ -73,17 +85,27 @@ function removeWatch(node: WatchableNode) {
7385

7486
// On last unwatch, node becomes dead:
7587
if (next === 0) {
76-
// 1. Clean up effects
77-
// We clean up subs before we clean up their deps (no use after free)
78-
node._watchCleanups?.forEach((cleanup) => cleanup?.())
88+
// 1. Clean up effects.
89+
// Snapshot and clear `_watchCleanups` BEFORE invoking any cleanup, so a
90+
// re-entrant subscribe during cleanup sees a consistent (empty) state and
91+
// can rebuild cleanups into a fresh array via addWatch.
92+
const cleanups = node._watchCleanups
7993
node._watchCleanups = undefined
94+
if (cleanups) {
95+
for (let i = cleanups.length - 1; i >= 0; i--) {
96+
cleanups[i]?.()
97+
}
98+
}
8099

81-
// 2. propagate unwatch to deps
100+
// 2. propagate unwatch to deps.
101+
// Capture `prevDep` BEFORE recursing, so cleanups that mutate the dep
102+
// list can't redirect our traversal.
82103
let deps = node.depsTail
83-
while (deps !== undefined) {
84-
removeWatch(deps.dep)
85-
deps = deps.prevDep
86-
}
104+
while (deps !== undefined) {
105+
const prev = deps.prevDep
106+
removeWatch(deps.dep)
107+
deps = prev
108+
}
87109
}
88110
}
89111

@@ -503,6 +525,7 @@ function effect<T>(fn: () => T): Effect {
503525
this.flags = ReactiveFlags.None
504526
this.depsTail = undefined
505527
purgeDeps(this)
528+
removeWatch(this)
506529
},
507530
}
508531

packages/store/tests/while-watched.test.ts

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
import { describe, expect, test, vi } from 'vitest'
2-
import { createAtom } from '../src'
2+
import { createAtom, createExternalStoreAtom } from '../src'
3+
4+
function makeExternalStore<T>(initial: T) {
5+
let value = initial
6+
const listeners = new Set<() => void>()
7+
return {
8+
getSnapshot: () => value,
9+
set(next: T) {
10+
value = next
11+
listeners.forEach((l) => l())
12+
},
13+
subscribe(cb: () => void) {
14+
listeners.add(cb)
15+
return () => {
16+
listeners.delete(cb)
17+
}
18+
},
19+
listenerCount: () => listeners.size,
20+
}
21+
}
322

423
describe('whileWatched', () => {
524
test('does not fire before any subscriber', () => {
@@ -168,4 +187,170 @@ describe('whileWatched', () => {
168187
subDerived.unsubscribe()
169188
subSource.unsubscribe()
170189
})
190+
191+
test('long chain sub/unsub cycles do not drift _watches', () => {
192+
const ext = makeExternalStore(1)
193+
const a = createExternalStoreAtom(ext.getSnapshot, ext.subscribe)
194+
const b = createAtom(() => a.get() * 2)
195+
const c = createAtom(() => `${b.get()} dogs`)
196+
197+
const effect = vi.fn()
198+
const cleanup = vi.fn()
199+
a.whileWatched(() => {
200+
effect()
201+
return cleanup
202+
})
203+
204+
for (let i = 0; i < 10; i++) {
205+
const sub = c.subscribe(() => {})
206+
expect(ext.listenerCount()).toBe(1)
207+
sub.unsubscribe()
208+
expect(ext.listenerCount()).toBe(0)
209+
}
210+
211+
expect(effect).toHaveBeenCalledTimes(10)
212+
expect(cleanup).toHaveBeenCalledTimes(10)
213+
})
214+
215+
test('conditional deps: dropped branch cleanup fires on recompute', () => {
216+
const cond = createAtom(true)
217+
const a = createAtom(1)
218+
const b = createAtom(10)
219+
const pick = createAtom(() => (cond.get() ? a.get() : b.get()))
220+
221+
const aCleanup = vi.fn()
222+
const bCleanup = vi.fn()
223+
const aEffect = vi.fn(() => aCleanup)
224+
const bEffect = vi.fn(() => bCleanup)
225+
a.whileWatched(aEffect)
226+
b.whileWatched(bEffect)
227+
228+
const sub = pick.subscribe(() => {})
229+
expect(aEffect).toHaveBeenCalledTimes(1)
230+
expect(bEffect).not.toHaveBeenCalled()
231+
232+
cond.set(false)
233+
expect(aCleanup).toHaveBeenCalledTimes(1)
234+
expect(bEffect).toHaveBeenCalledTimes(1)
235+
expect(bCleanup).not.toHaveBeenCalled()
236+
237+
cond.set(true)
238+
expect(bCleanup).toHaveBeenCalledTimes(1)
239+
expect(aEffect).toHaveBeenCalledTimes(2)
240+
241+
sub.unsubscribe()
242+
expect(aCleanup).toHaveBeenCalledTimes(2)
243+
expect(bCleanup).toHaveBeenCalledTimes(1)
244+
})
245+
246+
test('diamond graph: shared source counted once per activation', () => {
247+
const ext = makeExternalStore(1)
248+
const source = createExternalStoreAtom(ext.getSnapshot, ext.subscribe)
249+
const left = createAtom(() => source.get() + 1)
250+
const right = createAtom(() => source.get() + 2)
251+
252+
const subL = left.subscribe(() => {})
253+
expect(ext.listenerCount()).toBe(1)
254+
const subR = right.subscribe(() => {})
255+
expect(ext.listenerCount()).toBe(1)
256+
257+
subL.unsubscribe()
258+
expect(ext.listenerCount()).toBe(1)
259+
subR.unsubscribe()
260+
expect(ext.listenerCount()).toBe(0)
261+
262+
// Re-activation cycle works
263+
const subL2 = left.subscribe(() => {})
264+
expect(ext.listenerCount()).toBe(1)
265+
subL2.unsubscribe()
266+
expect(ext.listenerCount()).toBe(0)
267+
})
268+
269+
test('stop() while watched runs cleanup immediately and only once', () => {
270+
const atom = createAtom(0)
271+
const cleanup = vi.fn()
272+
const stop = atom.whileWatched(() => cleanup)
273+
274+
const sub = atom.subscribe(() => {})
275+
expect(cleanup).not.toHaveBeenCalled()
276+
277+
stop()
278+
expect(cleanup).toHaveBeenCalledTimes(1)
279+
280+
sub.unsubscribe()
281+
expect(cleanup).toHaveBeenCalledTimes(1)
282+
})
283+
284+
test('re-entrant subscribe during cleanup leaves graph consistent', () => {
285+
const ext = makeExternalStore(0)
286+
const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe)
287+
288+
const activate = vi.fn()
289+
let reenter = true
290+
291+
atom.whileWatched(() => {
292+
activate()
293+
return () => {
294+
if (reenter) {
295+
reenter = false
296+
// briefly resubscribe during cleanup — activates a second cycle
297+
const innerSub = atom.subscribe(() => {})
298+
innerSub.unsubscribe()
299+
}
300+
}
301+
})
302+
303+
const sub = atom.subscribe(() => {})
304+
expect(activate).toHaveBeenCalledTimes(1)
305+
expect(ext.listenerCount()).toBe(1)
306+
307+
sub.unsubscribe()
308+
// The re-entry created a 2nd activation, then cleaned up.
309+
expect(activate).toHaveBeenCalledTimes(2)
310+
// After all unwinding, external store must have zero listeners.
311+
expect(ext.listenerCount()).toBe(0)
312+
313+
// _watches must not be stuck: a fresh subscribe re-activates.
314+
const sub2 = atom.subscribe(() => {})
315+
expect(activate).toHaveBeenCalledTimes(3)
316+
expect(ext.listenerCount()).toBe(1)
317+
sub2.unsubscribe()
318+
expect(ext.listenerCount()).toBe(0)
319+
})
320+
321+
test('adding a whileWatched during another effect runs it immediately', () => {
322+
const atom = createAtom(0)
323+
const lateEffect = vi.fn()
324+
const lateCleanup = vi.fn()
325+
326+
atom.whileWatched(() => {
327+
atom.whileWatched(() => {
328+
lateEffect()
329+
return lateCleanup
330+
})
331+
})
332+
333+
const sub = atom.subscribe(() => {})
334+
expect(lateEffect).toHaveBeenCalledTimes(1)
335+
expect(lateCleanup).not.toHaveBeenCalled()
336+
337+
sub.unsubscribe()
338+
expect(lateCleanup).toHaveBeenCalledTimes(1)
339+
})
340+
341+
test('many subscribers release source exactly when the last one leaves', () => {
342+
const ext = makeExternalStore(0)
343+
const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe)
344+
345+
const subs = Array.from({ length: 20 }, () => atom.subscribe(() => {}))
346+
expect(ext.listenerCount()).toBe(1)
347+
348+
for (let i = 0; i < subs.length - 1; i++) {
349+
subs[i].unsubscribe()
350+
expect(ext.listenerCount()).toBe(1)
351+
}
352+
353+
subs[subs.length - 1].unsubscribe()
354+
expect(ext.listenerCount()).toBe(0)
355+
})
171356
})

0 commit comments

Comments
 (0)