Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/runtime-core/__tests__/apiSetupHelpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,86 @@ describe('SFC <script setup> helpers', () => {
expect(serializeInner(root)).toBe('hello')
})

test('should not leak instance to user microtasks after restore', async () => {
let leakedToUserMicrotask = false

const Comp = defineComponent({
async setup() {
let __temp: any, __restore: any
;[__temp, __restore] = withAsyncContext(() => Promise.resolve())
__temp = await __temp
__restore()

Promise.resolve().then(() => {
leakedToUserMicrotask = getCurrentInstance() !== null
})

return () => ''
},
})

const root = nodeOps.createElement('div')
render(
h(() => h(Suspense, () => h(Comp))),
root,
)

await new Promise(r => setTimeout(r))
expect(leakedToUserMicrotask).toBe(false)
})

test('should not leak sibling instance in concurrent restores', async () => {
let resolveOne: () => void
let resolveTwo: () => void
let done!: () => void
let pending = 2
const ready = new Promise<void>(r => {
done = r
})
const seenUid: Record<'one' | 'two', number | null> = {
one: null,
two: null,
}

const makeComp = (name: 'one' | 'two', wait: Promise<void>) =>
defineComponent({
async setup() {
let __temp: any, __restore: any
;[__temp, __restore] = withAsyncContext(() => wait)
__temp = await __temp
__restore()

Promise.resolve().then(() => {
seenUid[name] = getCurrentInstance()?.uid ?? null
if (--pending === 0) done()
})

return () => ''
},
})

const oneReady = new Promise<void>(r => {
resolveOne = r
})
const twoReady = new Promise<void>(r => {
resolveTwo = r
})
const CompOne = makeComp('one', oneReady)
const CompTwo = makeComp('two', twoReady)

const root = nodeOps.createElement('div')
render(
h(() => h(Suspense, () => h('div', [h(CompOne), h(CompTwo)]))),
root,
)

resolveOne!()
resolveTwo!()
await ready
expect(seenUid.one).toBeNull()
expect(seenUid.two).toBeNull()
})

test('error handling', async () => {
const spy = vi.fn()

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

test('should not leak instance on multiple awaits', async () => {
Expand Down
22 changes: 21 additions & 1 deletion packages/runtime-core/src/apiSetupHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,11 +514,31 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] {
}
let awaitable = getAwaitable()
unsetCurrentInstance()

// Never restore a captured "prev" instance here: in concurrent async setup
// continuations it may belong to a sibling component and cause leaks.
// We only need to balance ctx.scope.on() from setCurrentInstance(ctx),
// then clear global currentInstance for user microtasks.
const cleanup = () => {
if (getCurrentInstance() !== ctx) ctx.scope.off()
unsetCurrentInstance()
}

if (isPromise(awaitable)) {
awaitable = awaitable.catch(e => {
setCurrentInstance(ctx)
// Defer cleanup so the async function's catch continuation
// still runs with the restored instance.
Promise.resolve().then(() => Promise.resolve().then(cleanup))
throw e
})
}
return [awaitable, () => setCurrentInstance(ctx)]
return [
awaitable,
() => {
setCurrentInstance(ctx)
// Keep instance for the current continuation, then cleanup.
Promise.resolve().then(cleanup)
},
]
}
Loading