Просмотр исходного кода

fix(server-renderer): render unresolved tag fallback as element (#14794)

edison 1 месяц назад
Родитель
Сommit
d9e0a5c79a

+ 32 - 0
packages/runtime-core/__tests__/hydration.spec.ts

@@ -809,6 +809,38 @@ describe('SSR hydration', () => {
     expect(teleportContainer2.innerHTML).toBe('<span>Teleported</span>')
   })
 
+  test('hydrates unresolved tag fallback rendered as plain element', async () => {
+    const msg = ref('foo')
+    const App = {
+      setup() {
+        return { msg }
+      },
+      template: `
+        <center><span>{{ msg }}</span></center>
+        <span>after</span>
+      `,
+    }
+
+    const container = document.createElement('div')
+    container.innerHTML = await renderToString(h(App))
+    expect(container.innerHTML).toBe(
+      '<!--[--><center><span>foo</span></center><span>after</span><!--]-->',
+    )
+
+    createSSRApp(App).mount(container)
+    expect(container.innerHTML).toBe(
+      '<!--[--><center><span>foo</span></center><span>after</span><!--]-->',
+    )
+
+    msg.value = 'bar'
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      '<!--[--><center><span>bar</span></center><span>after</span><!--]-->',
+    )
+    expect(`Failed to resolve component: center`).toHaveBeenWarned()
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  })
+
   // compile SSR + client render fn from the same template & hydrate
   test('full compiler integration', async () => {
     const mounted: string[] = []

+ 36 - 1
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -143,7 +143,7 @@ async function testHydration(
   }
 
   app.mount(container)
-  return { data, container }
+  return { data, container, html }
 }
 
 const triggerEvent = (type: string, el: Element) => {
@@ -331,6 +331,41 @@ describe('Vapor Mode hydration', () => {
       expect(`mismatch in <div>`).not.toHaveBeenWarned()
     })
 
+    test('plain element fallback hydrates unresolved lowercase tag', async () => {
+      const code = `
+      <template>
+        <center><span>{{ data }}</span></center>
+        <span>after</span>
+      </template>
+    `
+      const { container, data, html } = await testHydration(code)
+      expect(formatHtml(html)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><center><span>foo</span></center><span>after</span><!--]-->
+        "
+      `,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><center><span>foo</span></center><span>after</span><!--]-->
+        "
+      `,
+      )
+      data.value = 'bar'
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><center><span>bar</span></center><span>after</span><!--]-->
+        "
+      `,
+      )
+      expect(`Failed to resolve component: center`).toHaveBeenWarned()
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
     test('element with binding and text children', async () => {
       const { container, data } = await testHydration(`
       <template><div :class="data">{{ data }}</div></template>

+ 1 - 1
packages/runtime-vapor/src/component.ts

@@ -967,7 +967,7 @@ export function createPlainElement(
       renderEffect(() => frag.update(getSlot(rawSlots as RawSlots, 'default')))
       if (!isHydrating) insert(frag, el)
     } else {
-      let slot = getSlot(rawSlots as RawSlots, 'default')
+      const slot = getSlot(rawSlots as RawSlots, 'default')
       if (slot) {
         const block = slot()
         if (!isHydrating) insert(block, el)

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

@@ -232,6 +232,17 @@ function testRender(type: string, render: typeof renderToString) {
         ).toBe(`<div>parent<div>hello</div></div>`)
       })
 
+      test('renders unresolved tag fallback as plain element', async () => {
+        const html = await render(
+          createApp({
+            template: `<center><span>foo</span></center>`,
+          }),
+        )
+
+        expect(html).toBe(`<center><span>foo</span></center>`)
+        expect(`Failed to resolve component: center`).toHaveBeenWarned()
+      })
+
       test('nested template components', async () => {
         const Child = {
           props: ['msg'],

+ 22 - 2
packages/server-renderer/src/helpers/ssrRenderComponent.ts

@@ -4,16 +4,36 @@ import {
   type Slots,
   createVNode,
 } from 'vue'
-import { type Props, type SSRBuffer, renderComponentVNode } from '../render'
+import { isString } from '@vue/shared'
+import {
+  type Props,
+  type SSRBuffer,
+  createBuffer,
+  renderComponentVNode,
+  renderVNode,
+} from '../render'
 import type { SSRSlots } from './ssrRenderSlot'
 
 export function ssrRenderComponent(
-  comp: Component,
+  comp: Component | string,
   props: Props | null = null,
   children: Slots | SSRSlots | null = null,
   parentComponent: ComponentInternalInstance | null = null,
   slotScopeId?: string,
 ): SSRBuffer | Promise<SSRBuffer> {
+  if (isString(comp)) {
+    // resolveComponent() can fall back to the original tag string; render it
+    // through the element path so SSR matches the client plain-element fallback.
+    const { getBuffer, push } = createBuffer()
+    renderVNode(
+      push,
+      createVNode(comp, props, children),
+      parentComponent as ComponentInternalInstance,
+      slotScopeId,
+    )
+    return getBuffer()
+  }
+
   return renderComponentVNode(
     createVNode(comp, props, children),
     parentComponent,