Skip to content

Commit 702284f

Browse files
authored
fix(runtime-core): prevent instance leak in withAsyncContext (#14445)
fix nuxt/nuxt#33644
1 parent da6690c commit 702284f

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

packages/runtime-core/__tests__/apiSetupHelpers.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,86 @@ describe('SFC <script setup> helpers', () => {
250250
expect(serializeInner(root)).toBe('hello')
251251
})
252252

253+
test('should not leak instance to user microtasks after restore', async () => {
254+
let leakedToUserMicrotask = false
255+
256+
const Comp = defineComponent({
257+
async setup() {
258+
let __temp: any, __restore: any
259+
;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
260+
__temp = await __temp
261+
__restore()
262+
263+
Promise.resolve().then(() => {
264+
leakedToUserMicrotask = getCurrentInstance() !== null
265+
})
266+
267+
return () => ''
268+
},
269+
})
270+
271+
const root = nodeOps.createElement('div')
272+
render(
273+
h(() => h(Suspense, () => h(Comp))),
274+
root,
275+
)
276+
277+
await new Promise(r => setTimeout(r))
278+
expect(leakedToUserMicrotask).toBe(false)
279+
})
280+
281+
test('should not leak sibling instance in concurrent restores', async () => {
282+
let resolveOne: () => void
283+
let resolveTwo: () => void
284+
let done!: () => void
285+
let pending = 2
286+
const ready = new Promise<void>(r => {
287+
done = r
288+
})
289+
const seenUid: Record<'one' | 'two', number | null> = {
290+
one: null,
291+
two: null,
292+
}
293+
294+
const makeComp = (name: 'one' | 'two', wait: Promise<void>) =>
295+
defineComponent({
296+
async setup() {
297+
let __temp: any, __restore: any
298+
;[__temp, __restore] = withAsyncContext(() => wait)
299+
__temp = await __temp
300+
__restore()
301+
302+
Promise.resolve().then(() => {
303+
seenUid[name] = getCurrentInstance()?.uid ?? null
304+
if (--pending === 0) done()
305+
})
306+
307+
return () => ''
308+
},
309+
})
310+
311+
const oneReady = new Promise<void>(r => {
312+
resolveOne = r
313+
})
314+
const twoReady = new Promise<void>(r => {
315+
resolveTwo = r
316+
})
317+
const CompOne = makeComp('one', oneReady)
318+
const CompTwo = makeComp('two', twoReady)
319+
320+
const root = nodeOps.createElement('div')
321+
render(
322+
h(() => h(Suspense, () => h('div', [h(CompOne), h(CompTwo)]))),
323+
root,
324+
)
325+
326+
resolveOne!()
327+
resolveTwo!()
328+
await ready
329+
expect(seenUid.one).toBeNull()
330+
expect(seenUid.two).toBeNull()
331+
})
332+
253333
test('error handling', async () => {
254334
const spy = vi.fn()
255335

@@ -295,6 +375,8 @@ describe('SFC <script setup> helpers', () => {
295375
expect(spy).toHaveBeenCalled()
296376
// should retain same instance before/after the await call
297377
expect(beforeInstance).toBe(afterInstance)
378+
// instance scope should be fully restored/cleaned after async ticks
379+
expect((beforeInstance!.scope as any)._on).toBe(0)
298380
})
299381

300382
test('should not leak instance on multiple awaits', async () => {

packages/runtime-core/src/apiSetupHelpers.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,11 +514,31 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
514514
}
515515
let awaitable = getAwaitable()
516516
unsetCurrentInstance()
517+
518+
// Never restore a captured "prev" instance here: in concurrent async setup
519+
// continuations it may belong to a sibling component and cause leaks.
520+
// We only need to balance ctx.scope.on() from setCurrentInstance(ctx),
521+
// then clear global currentInstance for user microtasks.
522+
const cleanup = () => {
523+
if (getCurrentInstance() !== ctx) ctx.scope.off()
524+
unsetCurrentInstance()
525+
}
526+
517527
if (isPromise(awaitable)) {
518528
awaitable = awaitable.catch(e => {
519529
setCurrentInstance(ctx)
530+
// Defer cleanup so the async function's catch continuation
531+
// still runs with the restored instance.
532+
Promise.resolve().then(() => Promise.resolve().then(cleanup))
520533
throw e
521534
})
522535
}
523-
return [awaitable, () => setCurrentInstance(ctx)]
536+
return [
537+
awaitable,
538+
() => {
539+
setCurrentInstance(ctx)
540+
// Keep instance for the current continuation, then cleanup.
541+
Promise.resolve().then(cleanup)
542+
},
543+
]
524544
}

0 commit comments

Comments
 (0)