Skip to content

Commit c24a93a

Browse files
fix(utils): prevent watch context leak across async callbacks (#1229)
The `watch` util used a module-level `currentCleanups` to attach nested watches to their parent. `revalidate` is async and kept this global set across the `await` of a promise-returning callback, so any unrelated watch created while the first was suspended had its cleanup attached to the wrong watch. When that parent later revalidated or cleaned up, it ran the unrelated watch's cleanup and unsubscribed it, leaving it permanently unreactive. Nested watches can only be registered during the synchronous part of the callback, so restore the parent context immediately after the callback returns, before awaiting any returned promise.
1 parent 38e9a87 commit c24a93a

2 files changed

Lines changed: 62 additions & 15 deletions

File tree

src/vanilla/utils/watch.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,17 @@ export function watch(
7575

7676
// Setup watch context, this allows us to automatically capture
7777
// watch cleanups if the watch callback itself has watch calls.
78+
// Nested watches can only be created during the synchronous part of the
79+
// callback, so the global context is restored right after the callback
80+
// returns (before awaiting any returned promise). Keeping it set across
81+
// the await would leak this watch's cleanups set into unrelated watches
82+
// created while this one is suspended.
7883
const parent = currentCleanups
7984
currentCleanups = cleanups
8085

81-
// Ensures that the parent is reset if the callback throws an error.
86+
let promiseOrPossibleCleanup: ReturnType<WatchCallback>
8287
try {
83-
const promiseOrPossibleCleanup = callback((proxyObject) => {
88+
promiseOrPossibleCleanup = callback((proxyObject) => {
8489
proxiesToSubscribe.add(proxyObject)
8590
// in case the callback is a promise and the watch has ended
8691
if (alive && !subscriptions.has(proxyObject)) {
@@ -90,23 +95,26 @@ export function watch(
9095
}
9196
return proxyObject
9297
})
93-
const couldBeCleanup =
94-
promiseOrPossibleCleanup && promiseOrPossibleCleanup instanceof Promise
95-
? await promiseOrPossibleCleanup
96-
: promiseOrPossibleCleanup
97-
98-
// If there's a cleanup, we add this to the cleanups set
99-
if (couldBeCleanup) {
100-
if (alive) {
101-
cleanups.add(couldBeCleanup)
102-
} else {
103-
cleanup()
104-
}
105-
}
10698
} finally {
99+
// Restore the parent context before awaiting, so it is not corrupted by
100+
// (or does not corrupt) revalidations that run while we are suspended.
107101
currentCleanups = parent
108102
}
109103

104+
const couldBeCleanup =
105+
promiseOrPossibleCleanup && promiseOrPossibleCleanup instanceof Promise
106+
? await promiseOrPossibleCleanup
107+
: promiseOrPossibleCleanup
108+
109+
// If there's a cleanup, we add this to the cleanups set
110+
if (couldBeCleanup) {
111+
if (alive) {
112+
cleanups.add(couldBeCleanup)
113+
} else {
114+
cleanup()
115+
}
116+
}
117+
110118
// Unsubscribe old subscriptions
111119
subscriptions.forEach((unsubscribe, proxyObject) => {
112120
if (!proxiesToSubscribe.has(proxyObject)) {

tests/watch.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,43 @@ describe('watch', () => {
164164
await vi.advanceTimersByTimeAsync(10000)
165165
expect(callback).toBeCalledTimes(1)
166166
})
167+
168+
it('should not capture unrelated watches created while an async watch is pending (#1183)', async () => {
169+
const A = proxy({ value: 0 })
170+
const B = proxy({ value: 0 })
171+
172+
const cbA = vi.fn()
173+
const cbB = vi.fn()
174+
175+
// watch1 is async and suspends at its await. While it is suspended, the
176+
// shared watch context must not leak watch1's cleanups set.
177+
watch(async (get) => {
178+
get(A)
179+
await sleep(1000)
180+
cbA()
181+
})
182+
183+
// While watch1 is suspended, create an independent watch2. Its cleanup
184+
// must not be attached to watch1.
185+
await vi.advanceTimersByTimeAsync(500)
186+
watch((get) => {
187+
get(B)
188+
cbB()
189+
})
190+
expect(cbB).toBeCalledTimes(1)
191+
192+
await vi.advanceTimersByTimeAsync(500)
193+
expect(cbA).toBeCalledTimes(1)
194+
195+
// Trigger watch1 to revalidate, which runs its own cleanups. If watch2's
196+
// cleanup had leaked into watch1, this would unsubscribe watch2.
197+
A.value = 1
198+
await vi.advanceTimersByTimeAsync(2000)
199+
200+
// watch2 must still react to B updates.
201+
cbB.mockClear()
202+
B.value = 1
203+
await vi.advanceTimersByTimeAsync(0)
204+
expect(cbB).toBeCalledTimes(1)
205+
})
167206
})

0 commit comments

Comments
 (0)