diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts index 090c91ae27a..8b5eaa31c2d 100644 --- a/packages/runtime-core/src/apiSetupHelpers.ts +++ b/packages/runtime-core/src/apiSetupHelpers.ts @@ -12,7 +12,9 @@ import { type SetupContext, createSetupContext, getCurrentInstance, + isInSSRComponentSetup, setCurrentInstance, + setInSSRSetupState, unsetCurrentInstance, } from './component' import type { EmitFn, EmitsOptions, ObjectEmitsOptions } from './componentEmits' @@ -506,6 +508,7 @@ export function createPropsRestProxy( */ export function withAsyncContext(getAwaitable: () => any): [any, () => void] { const ctx = getCurrentInstance()! + const inSSRSetup = isInSSRComponentSetup if (__DEV__ && !ctx) { warn( `withAsyncContext called without active current instance. ` + @@ -514,6 +517,16 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] { } let awaitable = getAwaitable() unsetCurrentInstance() + if (inSSRSetup) { + setInSSRSetupState(false) + } + + const restore = () => { + setCurrentInstance(ctx) + if (inSSRSetup) { + setInSSRSetupState(true) + } + } // Never restore a captured "prev" instance here: in concurrent async setup // continuations it may belong to a sibling component and cause leaks. @@ -522,11 +535,14 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] { const cleanup = () => { if (getCurrentInstance() !== ctx) ctx.scope.off() unsetCurrentInstance() + if (inSSRSetup) { + setInSSRSetupState(false) + } } if (isPromise(awaitable)) { awaitable = awaitable.catch(e => { - setCurrentInstance(ctx) + restore() // Defer cleanup so the async function's catch continuation // still runs with the restored instance. Promise.resolve().then(() => Promise.resolve().then(cleanup)) @@ -536,7 +552,7 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] { return [ awaitable, () => { - setCurrentInstance(ctx) + restore() // Keep instance for the current continuation, then cleanup. Promise.resolve().then(cleanup) }, diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 85aaccad939..c46741bee80 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -723,7 +723,7 @@ export const getCurrentInstance: () => ComponentInternalInstance | null = () => let internalSetCurrentInstance: ( instance: ComponentInternalInstance | null, ) => void -let setInSSRSetupState: (state: boolean) => void +export let setInSSRSetupState: (state: boolean) => void /** * The following makes getCurrentInstance() usage across multiple copies of Vue diff --git a/packages/server-renderer/__tests__/ssrWatch.spec.ts b/packages/server-renderer/__tests__/ssrWatch.spec.ts index 40c49740d3e..4cf4b2952f7 100644 --- a/packages/server-renderer/__tests__/ssrWatch.spec.ts +++ b/packages/server-renderer/__tests__/ssrWatch.spec.ts @@ -6,6 +6,7 @@ import { ref, watch, watchEffect, + withAsyncContext, } from 'vue' import { type SSRContext, renderToString } from '../src' @@ -119,6 +120,89 @@ describe('ssr: watch', () => { await nextTick() expect(msg).toBe('start') }) + + test('should not run non-immediate watchers registered after async context restore', async () => { + const text = ref('start') + let beforeAwaitTriggered = false + let afterAwaitTriggered = false + + const App = defineComponent({ + async setup() { + let __temp: any, __restore: any + + watch(text, () => { + beforeAwaitTriggered = true + }) + ;[__temp, __restore] = withAsyncContext(() => Promise.resolve()) + __temp = await __temp + __restore() + + watch(text, () => { + afterAwaitTriggered = true + }) + + text.value = 'changed' + expect(beforeAwaitTriggered).toBe(false) + expect(afterAwaitTriggered).toBe(false) + + return () => h('div', null, text.value) + }, + }) + + const app = createSSRApp(App) + const ctx: SSRContext = {} + const html = await renderToString(app, ctx) + + expect(ctx.__watcherHandles).toBeUndefined() + expect(html).toMatch('changed') + await nextTick() + expect(beforeAwaitTriggered).toBe(false) + expect(afterAwaitTriggered).toBe(false) + }) + + test('should not run non-immediate watchers registered after async context restore on rejection', async () => { + const text = ref('start') + let beforeAwaitTriggered = false + let afterAwaitTriggered = false + + const App = defineComponent({ + async setup() { + let __temp: any, __restore: any + + watch(text, () => { + beforeAwaitTriggered = true + }) + + try { + ;[__temp, __restore] = withAsyncContext(() => + Promise.reject(new Error('failed')), + ) + __temp = await __temp + __restore() + } catch {} + + watch(text, () => { + afterAwaitTriggered = true + }) + + text.value = 'changed' + expect(beforeAwaitTriggered).toBe(false) + expect(afterAwaitTriggered).toBe(false) + + return () => h('div', null, text.value) + }, + }) + + const app = createSSRApp(App) + const ctx: SSRContext = {} + const html = await renderToString(app, ctx) + + expect(ctx.__watcherHandles).toBeUndefined() + expect(html).toMatch('changed') + await nextTick() + expect(beforeAwaitTriggered).toBe(false) + expect(afterAwaitTriggered).toBe(false) + }) }) describe('ssr: watchEffect', () => {