Explorar el Código

fix(runtime-vapor): fix interop hydration anchor handling for Teleport/Suspense dynamic VNodes (#14517)

edison hace 1 mes
padre
commit
7d079d50ff

+ 564 - 14
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -23,6 +23,7 @@ const formatHtml = (raw: string) => {
   return raw
     .replace(/<!--\[/g, '\n<!--[')
     .replace(/]-->/g, ']-->\n')
+    .replace(/<!--teleport (start|end)-->/g, '\n<!--teleport $1-->\n')
     .replace(/\n{2,}/g, '\n')
 }
 
@@ -5518,21 +5519,24 @@ describe('VDOM interop', () => {
   })
 
   test('hydrate multi-root VNode component via createDynamicComponent and switch branch', async () => {
-    const showMulti = ref(true)
+    const data = ref({
+      showMulti: true,
+      tail: 'tail',
+    })
     const { container } = await testWithVaporApp(
       `<script setup>
         import { computed, h } from 'vue'
-        const showMulti = _data
+        const data = _data
         const components = _components
         const vnode = computed(() =>
-          showMulti.value
+          data.value.showMulti
             ? h(components.VdomMultiRoot)
             : h('p', null, 'fallback')
         )
       </script>
       <template>
         <component :is="vnode" />
-        <span>tail</span>
+        <span>{{ data.tail }}</span>
       </template>`,
       {
         VdomMultiRoot: {
@@ -5540,7 +5544,7 @@ describe('VDOM interop', () => {
           vapor: false,
         },
       },
-      showMulti,
+      data,
     )
 
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
@@ -5555,7 +5559,7 @@ describe('VDOM interop', () => {
 
     expect(`Hydration node mismatch`).not.toHaveBeenWarned()
 
-    showMulti.value = false
+    data.value.showMulti = false
     await nextTick()
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
@@ -5567,7 +5571,7 @@ describe('VDOM interop', () => {
     `,
     )
 
-    showMulti.value = true
+    data.value.showMulti = true
     await nextTick()
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
@@ -5578,6 +5582,16 @@ describe('VDOM interop', () => {
       "
     `,
     )
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[-->
+      <!--[--><div>first</div><div>second</div><!--dynamic-component--><!--]-->
+      <span>tail-updated</span><!--]-->
+      "
+    `)
   })
 
   test('hydrate vapor slot in vdom component with empty slot and sibling nodes', async () => {
@@ -5631,13 +5645,16 @@ describe('VDOM interop', () => {
   })
 
   test('hydrate static/fragment VNode via createDynamicComponent and switch type', async () => {
-    const useStatic = ref(true)
+    const data = ref({
+      useStatic: true,
+      tail: 'tail',
+    })
     const { container } = await testWithVaporApp(
       `<script setup>
         import { Fragment, computed, createStaticVNode, h } from 'vue'
-        const useStatic = _data
+        const data = _data
         const vnode = computed(() =>
-          useStatic.value
+          data.value.useStatic
             ? createStaticVNode(
                 '<div>first static</div><div>second static</div>',
                 2,
@@ -5650,10 +5667,10 @@ describe('VDOM interop', () => {
       </script>
       <template>
         <component :is="vnode" />
-        <span>tail</span>
+        <span>{{ data.tail }}</span>
       </template>`,
       undefined,
-      useStatic,
+      data,
     )
 
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
@@ -5666,7 +5683,7 @@ describe('VDOM interop', () => {
 
     expect(`Hydration node mismatch`).not.toHaveBeenWarned()
 
-    useStatic.value = false
+    data.value.useStatic = false
     await nextTick()
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
@@ -5676,7 +5693,7 @@ describe('VDOM interop', () => {
     `,
     )
 
-    useStatic.value = true
+    data.value.useStatic = true
     await nextTick()
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
@@ -5685,6 +5702,539 @@ describe('VDOM interop', () => {
       "
     `,
     )
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>first static</div><div>second static</div><!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate Teleport VNode via createDynamicComponent and switch branch', async () => {
+    const data = ref({
+      showTeleport: true,
+      tail: 'tail',
+    })
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        import { Teleport, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          data.value.showTeleport
+            ? h(Teleport, { to: '#target', disabled: true }, [
+                h('div', null, 'teleported'),
+              ])
+            : h('p', null, 'fallback')
+        )
+      </script>
+      <template>
+        <component :is="vnode" />
+        <span>{{ data.tail }}</span>
+      </template>`,
+      undefined,
+      data,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[-->
+      <!--teleport start-->
+      <div>teleported</div>
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.showTeleport = false
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><p>fallback</p><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.showTeleport = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[-->
+      <!--teleport start-->
+      <div>teleported</div>
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[-->
+      <!--teleport start-->
+      <div>teleported</div>
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate enabled Teleport VNode via createDynamicComponent and switch branch', async () => {
+    const data = ref({
+      showTeleport: true,
+      tail: 'tail',
+    })
+    const code = `<script setup>
+        import { Teleport, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          data.value.showTeleport
+            ? h(Teleport, { to: '#target' }, [h('div', null, 'teleported')])
+            : h('p', null, 'fallback')
+        )
+      </script>
+      <template>
+        <component :is="vnode" />
+        <span>{{ data.tail }}</span>
+      </template>`
+    const serverComp = compile(code, data, {}, { vapor: true, ssr: true })
+    const ssrCtx: Record<string, any> = {}
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(serverComp),
+      ssrCtx,
+    )
+
+    const target = document.createElement('div')
+    target.id = 'target'
+    target.innerHTML = ssrCtx.teleports['#target']
+    document.body.appendChild(target)
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+
+    const clientComp = compile(code, data, {}, { vapor: true, ssr: false })
+    const app = createVaporSSRApp(clientComp)
+    app.use(runtimeVapor.vaporInteropPlugin)
+    app.mount(container)
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[-->
+      <!--teleport start-->
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+    expect(formatHtml(target.innerHTML)).toMatchInlineSnapshot(
+      `"<!--teleport start anchor--><div>teleported</div><!--teleport anchor-->"`,
+    )
+
+    data.value.showTeleport = false
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><p>fallback</p><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+    expect(formatHtml(target.innerHTML)).toMatchInlineSnapshot(`""`)
+
+    data.value.showTeleport = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[-->
+      <!--teleport start-->
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+    expect(formatHtml(target.innerHTML)).toMatchInlineSnapshot(
+      `"<div>teleported</div>"`,
+    )
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[-->
+      <!--teleport start-->
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate Suspense VNode via createDynamicComponent and switch branch', async () => {
+    const data = ref({
+      showSuspense: true,
+      msg: 'foo',
+      tail: 'tail',
+    })
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        import { Suspense, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          data.value.showSuspense
+            ? h(Suspense, null, {
+                default: () => h('div', null, data.value.msg),
+                fallback: () => h('div', null, 'pending'),
+              })
+            : h('p', null, 'fallback')
+        )
+      </script>
+      <template>
+        <component :is="vnode" />
+        <span>{{ data.tail }}</span>
+      </template>`,
+      undefined,
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>foo</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value.msg = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>bar</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+
+    data.value.showSuspense = false
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><p>fallback</p><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+
+    data.value.showSuspense = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>bar</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `,
+    )
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>bar</div><!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate async Suspense VNode via createDynamicComponent and show fallback', async () => {
+    const data = ref({
+      showSuspense: true,
+      tail: 'tail',
+    })
+
+    const appCode = `<script setup>
+      import { Suspense, computed, h } from 'vue'
+      const data = _data
+      const components = _components
+      const vnode = computed(() =>
+        data.value.showSuspense
+          ? h(Suspense, { timeout: 0 }, {
+              default: () => h(components.AsyncComp),
+              fallback: () => h('div', null, 'pending'),
+            })
+          : h('p', null, 'fallback')
+      )
+    </script>
+    <template>
+      <component :is="vnode" />
+      <span>{{ data.tail }}</span>
+    </template>`
+
+    const AsyncResolvedComp = {
+      render: () => runtimeDom.h('div', null, 'async resolved'),
+    }
+
+    let serverResolve: (comp: any) => void
+    const ServerAsyncComp = defineAsyncComponent(
+      () =>
+        new Promise(r => {
+          serverResolve = r
+        }),
+    )
+
+    const SSRApp = compile(
+      appCode,
+      data,
+      { AsyncComp: ServerAsyncComp },
+      {
+        vapor: true,
+        ssr: true,
+      },
+    )
+
+    const htmlPromise = VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(SSRApp),
+    )
+    serverResolve!(AsyncResolvedComp)
+    const html = await htmlPromise
+
+    let clientResolve: (comp: any) => void
+    const ClientAsyncComp = defineAsyncComponent(
+      () =>
+        new Promise(r => {
+          clientResolve = r
+        }),
+    )
+    const App = compile(
+      appCode,
+      data,
+      { AsyncComp: ClientAsyncComp },
+      {
+        vapor: true,
+        ssr: false,
+      },
+    )
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+
+    const app = createVaporSSRApp(App)
+    app.use(runtimeVapor.vaporInteropPlugin)
+    app.mount(container)
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>async resolved</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.showSuspense = false
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>async resolved</div><p>fallback</p><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.showSuspense = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>async resolved</div><div>pending</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    clientResolve!(AsyncResolvedComp)
+    await new Promise(r => setTimeout(r))
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>async resolved</div><div>async resolved</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>async resolved</div><div>async resolved</div><!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate Suspense VNode via createDynamicComponent under KeepAlive', async () => {
+    const data = ref({
+      msg: 'foo',
+      tail: 'tail',
+    })
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        import { KeepAlive, Suspense, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          h(Suspense, null, {
+            default: () => h('div', null, data.value.msg),
+            fallback: () => h('div', null, 'pending'),
+          })
+        )
+      </script>
+      <template>
+        <KeepAlive>
+          <component :is="vnode" />
+        </KeepAlive>
+        <span>{{ data.tail }}</span>
+      </template>`,
+      undefined,
+      data,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>foo</div><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div>foo</div><!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate Teleport VNode via createDynamicComponent under Transition', async () => {
+    const data = ref({
+      showTeleport: true,
+      tail: 'tail',
+    })
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        import { Teleport, Transition, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          data.value.showTeleport
+            ? h(Teleport, { to: '#target', disabled: true }, [
+                h('div', null, 'teleported'),
+              ])
+            : h('p', null, 'fallback')
+        )
+      </script>
+      <template>
+        <Transition>
+          <component :is="vnode" />
+        </Transition>
+        <span>{{ data.tail }}</span>
+      </template>`,
+      undefined,
+      data,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[-->
+      <!--teleport start-->
+      <div>teleported</div>
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.showTeleport = false
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><p class="v-enter-from v-enter-active">fallback</p><!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.showTeleport = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><p class="v-enter-from v-leave-from v-leave-active">fallback</p>
+      <!--teleport start-->
+      <div>teleported</div>
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail</span><!--]-->
+      "
+    `)
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><p class="v-enter-from v-leave-from v-leave-active">fallback</p>
+      <!--teleport start-->
+      <div>teleported</div>
+      <!--teleport end-->
+      <!--dynamic-component--><span>tail-updated</span><!--]-->
+      "
+    `)
+  })
+
+  test('hydrate interop dynamic component under KeepAlive', async () => {
+    const data = ref({
+      show: true,
+      tail: 'tail',
+    })
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        import { KeepAlive, computed, h } from 'vue'
+        const data = _data
+        const components = _components
+        const vnode = computed(() => h(components.Counter))
+      </script>
+      <template>
+        <KeepAlive>
+          <component v-if="data.show" :is="vnode" />
+        </KeepAlive>
+        <span>{{ data.tail }}</span>
+      </template>`,
+      {
+        Counter: {
+          code: `<script setup>
+            import { ref } from 'vue'
+            const count = ref(0)
+          </script>
+          <template><button @click="count++">{{ count }}</button></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    const getButton = () =>
+      container.querySelector('button') as HTMLButtonElement
+
+    expect(getButton().textContent).toBe('0')
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    triggerEvent('click', getButton())
+    await nextTick()
+    expect(getButton().textContent).toBe('1')
+
+    data.value.show = false
+    await nextTick()
+    expect(container.querySelector('button')).toBeNull()
+
+    data.value.show = true
+    await nextTick()
+    expect(getButton().textContent).toBe('1')
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><button>1</button><!--dynamic-component--><!--if--><span>tail-updated</span><!--]-->
+      "
+    `)
   })
 
   test('hydrate VDOM slot content', async () => {

+ 12 - 4
packages/runtime-vapor/src/vdomInterop.ts

@@ -395,6 +395,10 @@ let vdomHydrateNode: HydrationRenderer['hydrateNode'] | undefined
 // is Static/Fragment (multi-root component case).
 function resolveVNodeRange(vnode: VNode): [Node, Node] | undefined {
   const { type, shapeFlag, el, anchor } = vnode
+  if (shapeFlag & ShapeFlags.TELEPORT && el && anchor && anchor !== el) {
+    return [el as Node, anchor as Node]
+  }
+
   if ((type === Static || type === Fragment) && el && anchor && anchor !== el) {
     return [el as Node, anchor as Node]
   }
@@ -472,10 +476,14 @@ function mountVNode(
     hydrateVNode(
       vnode,
       parentComponent as any,
-      // In the current hydration cursor, component and Static VNodes can be
-      // prefixed by an outer fragment start marker (`<!--[-->`). Skip it so
-      // runtime-core hydrateNode() starts from the first real node of this vnode.
-      vnode.type === Static || !!(vnode.shapeFlag & ShapeFlags.COMPONENT),
+      // In the current hydration cursor, component / Teleport / Suspense / Static
+      // VNodes can be prefixed by an outer fragment start marker (`<!--[-->`).
+      // Skip it so runtime-core hydrateNode() starts from this vnode's first real node.
+      vnode.type === Static ||
+        !!(
+          vnode.shapeFlag &
+          (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT | ShapeFlags.SUSPENSE)
+        ),
     )
     onScopeDispose(unmount, true)
     isMounted = true