Explorar el Código

fix(server-renderer): cleanup component effect scopes after SSR render (#14548)

SaeedNezafat hace 1 mes
padre
commit
862f11ee01

+ 80 - 0
packages/server-renderer/__tests__/render.spec.ts

@@ -10,9 +10,11 @@ import {
   createTextVNode,
   createVNode,
   defineComponent,
+  effectScope,
   getCurrentInstance,
   h,
   onErrorCaptured,
+  onScopeDispose,
   onServerPrefetch,
   reactive,
   ref,
@@ -1002,6 +1004,84 @@ function testRender(type: string, render: typeof renderToString) {
       expect(html).toBe(`<div>hello</div>`)
     })
 
+    test('cleans up component effect scopes after each render', async () => {
+      const cleanups: number[] = []
+      const app = createApp({
+        setup() {
+          onScopeDispose(() => {
+            cleanups.push(1)
+          })
+          return () => h('div', 'ok')
+        },
+      })
+
+      expect(cleanups).toEqual([])
+      expect(await render(app)).toBe(`<div>ok</div>`)
+      expect(cleanups).toEqual([1])
+    })
+
+    test('concurrent renders isolate scope cleanup ownership', async () => {
+      const cleaned: string[] = []
+
+      const deferred = () => {
+        let resolve!: () => void
+        const promise = new Promise<void>(r => {
+          resolve = r
+        })
+        return { promise, resolve }
+      }
+
+      const gateA = deferred()
+      const gateB = deferred()
+
+      const makeApp = (id: string, gate: ReturnType<typeof deferred>) =>
+        createApp({
+          async setup() {
+            onScopeDispose(() => {
+              cleaned.push(id)
+            })
+            await gate.promise
+            return () => h('div', id)
+          },
+        })
+
+      const pA = render(makeApp('A', gateA))
+      const pB = render(makeApp('B', gateB))
+
+      gateB.resolve()
+      expect(await pB).toBe(`<div>B</div>`)
+      expect(cleaned).toEqual(['B'])
+
+      gateA.resolve()
+      expect(await pA).toBe(`<div>A</div>`)
+      expect(cleaned.sort()).toEqual(['A', 'B'])
+    })
+
+    test('detached scopes created during SSR are not auto-stopped', async () => {
+      let detachedStopped = false
+      let detached: any
+
+      const app = createApp({
+        setup() {
+          detached = effectScope(true)
+          detached.run(() => {
+            onScopeDispose(() => {
+              detachedStopped = true
+            })
+          })
+          return () => h('div', 'detached')
+        },
+      })
+
+      expect(await render(app)).toBe(`<div>detached</div>`)
+      expect(detached.active).toBe(true)
+      expect(detachedStopped).toBe(false)
+
+      detached.stop()
+      expect(detached.active).toBe(false)
+      expect(detachedStopped).toBe(true)
+    })
+
     test('multiple onServerPrefetch', async () => {
       const msg = Promise.resolve('hello')
       const msg2 = Promise.resolve('hi')

+ 3 - 3
packages/server-renderer/__tests__/ssrWatch.spec.ts

@@ -32,7 +32,7 @@ describe('ssr: watch', () => {
     const ctx: SSRContext = {}
     const html = await renderToString(app, ctx)
 
-    expect(ctx.__watcherHandles!.length).toBe(1)
+    expect(ctx.__watcherHandles!.length).toBe(0)
 
     expect(html).toMatch('hello world')
   })
@@ -61,7 +61,7 @@ describe('ssr: watch', () => {
     const ctx: SSRContext = {}
     const html = await renderToString(app, ctx)
 
-    expect(ctx.__watcherHandles!.length).toBe(1)
+    expect(ctx.__watcherHandles!.length).toBe(0)
     expect(html).toMatch('changed again')
     await nextTick()
     expect(msg).toBe('changed again')
@@ -229,7 +229,7 @@ describe('ssr: watchEffect', () => {
     const ctx: SSRContext = {}
     const html = await renderToString(app, ctx)
 
-    expect(ctx.__watcherHandles!.length).toBe(1)
+    expect(ctx.__watcherHandles!.length).toBe(0)
     expect(html).toMatch('changed again')
     await nextTick()
     expect(msg).toBe('changed again')

+ 40 - 0
packages/server-renderer/src/render.ts

@@ -11,6 +11,7 @@ import {
   type VNodeArrayChildren,
   type VNodeProps,
   mergeProps,
+  ssrContextKey,
   ssrUtils,
   warn,
 } from 'vue'
@@ -55,6 +56,37 @@ export type SSRContext = {
    * @internal
    */
   __watcherHandles?: (() => void)[]
+  /**
+   * @internal
+   */
+  __instanceScopes?: { stop: () => void }[]
+}
+
+export function cleanupContext(context: SSRContext): void {
+  let firstError: unknown
+  if (context.__watcherHandles) {
+    for (const unwatch of context.__watcherHandles) {
+      try {
+        unwatch()
+      } catch (err) {
+        if (firstError === undefined) firstError = err
+      }
+    }
+    context.__watcherHandles.length = 0
+  }
+  if (context.__instanceScopes) {
+    for (const scope of context.__instanceScopes) {
+      try {
+        scope.stop()
+      } catch (err) {
+        if (firstError === undefined) firstError = err
+      }
+    }
+    context.__instanceScopes.length = 0
+  }
+  if (firstError !== undefined) {
+    throw firstError
+  }
 }
 
 // Each component has a buffer array.
@@ -98,6 +130,14 @@ export function renderComponentVNode(
     parentComponent,
     null,
   ))
+  const context = instance.appContext.provides[ssrContextKey as any] as
+    | SSRContext
+    | undefined
+  if (context) {
+    ;(context.__instanceScopes || (context.__instanceScopes = [])).push(
+      instance.scope,
+    )
+  }
   if (__DEV__) pushWarningContext(vnode)
   const res = setupComponent(instance, true /* isSSR */)
   if (__DEV__) popWarningContext()

+ 23 - 9
packages/server-renderer/src/renderToStream.ts

@@ -7,7 +7,12 @@ import {
   ssrUtils,
 } from 'vue'
 import { isPromise, isString } from '@vue/shared'
-import { type SSRBuffer, type SSRContext, renderComponentVNode } from './render'
+import {
+  type SSRBuffer,
+  type SSRContext,
+  cleanupContext,
+  renderComponentVNode,
+} from './render'
 import type { Readable, Writable } from 'node:stream'
 import { resolveTeleports } from './renderToString'
 
@@ -43,7 +48,7 @@ async function unrollBuffer(
 
 function unrollBufferSync(buffer: SSRBuffer, stream: SimpleReadable) {
   for (let i = 0; i < buffer.length; i++) {
-    let item = buffer[i]
+    const item = buffer[i]
     if (isString(item)) {
       stream.push(item)
     } else {
@@ -73,18 +78,27 @@ export function renderToSimpleStream<T extends SimpleReadable>(
   // provide the ssr context to the tree
   input.provide(ssrContextKey, context)
 
-  Promise.resolve(renderComponentVNode(vnode))
+  let cleaned = false
+  const finalize = () => {
+    if (cleaned) return
+    cleaned = true
+    cleanupContext(context)
+  }
+
+  Promise.resolve()
+    .then(() => renderComponentVNode(vnode))
     .then(buffer => unrollBuffer(buffer, stream))
     .then(() => resolveTeleports(context))
     .then(() => {
-      if (context.__watcherHandles) {
-        for (const unwatch of context.__watcherHandles) {
-          unwatch()
-        }
-      }
+      finalize()
+      return stream.push(null)
     })
-    .then(() => stream.push(null))
     .catch(error => {
+      try {
+        finalize()
+      } catch {
+        // preserve original render error as the stream failure reason
+      }
       stream.destroy(error)
     })
 

+ 13 - 10
packages/server-renderer/src/renderToString.ts

@@ -7,7 +7,12 @@ import {
   ssrUtils,
 } from 'vue'
 import { isPromise, isString } from '@vue/shared'
-import { type SSRBuffer, type SSRContext, renderComponentVNode } from './render'
+import {
+  type SSRBuffer,
+  type SSRContext,
+  cleanupContext,
+  renderComponentVNode,
+} from './render'
 
 const { isVNode } = ssrUtils
 
@@ -81,19 +86,17 @@ export async function renderToString(
   vnode.appContext = input._context
   // provide the ssr context to the tree
   input.provide(ssrContextKey, context)
-  const buffer = await renderComponentVNode(vnode)
+  try {
+    const buffer = await renderComponentVNode(vnode)
 
-  const result = await unrollBuffer(buffer as SSRBuffer)
+    const result = await unrollBuffer(buffer as SSRBuffer)
 
-  await resolveTeleports(context)
+    await resolveTeleports(context)
 
-  if (context.__watcherHandles) {
-    for (const unwatch of context.__watcherHandles) {
-      unwatch()
-    }
+    return result
+  } finally {
+    cleanupContext(context)
   }
-
-  return result
 }
 
 export async function resolveTeleports(context: SSRContext): Promise<void> {