From 6b53d0ed349fa5fe7500576f89c34f6de880ec0b Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 9 Mar 2026 17:39:48 +0800 Subject: [PATCH 1/2] fix(ssr): prevent watch() from firing after async setup await --- packages/runtime-core/src/apiSetupHelpers.ts | 20 +++++++++- packages/runtime-core/src/component.ts | 2 +- .../__tests__/ssrWatch.spec.ts | 40 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) 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..a12f9fca87f 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,45 @@ 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) + }) }) describe('ssr: watchEffect', () => { From 4fbd0d539fde02eb2e4fca1a4d95c288066eeac8 Mon Sep 17 00:00:00 2001 From: daiwei Date: Mon, 9 Mar 2026 17:56:20 +0800 Subject: [PATCH 2/2] test: add more test --- .../__tests__/ssrWatch.spec.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/server-renderer/__tests__/ssrWatch.spec.ts b/packages/server-renderer/__tests__/ssrWatch.spec.ts index a12f9fca87f..4cf4b2952f7 100644 --- a/packages/server-renderer/__tests__/ssrWatch.spec.ts +++ b/packages/server-renderer/__tests__/ssrWatch.spec.ts @@ -159,6 +159,50 @@ describe('ssr: watch', () => { 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', () => {