Przeglądaj źródła

fix(ssr): prevent watch from firing after async setup await (#14547)

close #14546
edison 1 miesiąc temu
rodzic
commit
6cda71d48b

+ 18 - 2
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)
     },

+ 1 - 1
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

+ 84 - 0
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', () => {