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

fix(hydration): reuse attached SSR anchors for empty dynamic fragments

daiwei 2 недель назад
Родитель
Сommit
952bf53feb

+ 81 - 0
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -7231,6 +7231,87 @@ describe('VDOM interop', () => {
     `)
   })
 
+  test('hydrate empty createDynamicComponent should fill before trailing sibling', async () => {
+    const data = ref({
+      show: false,
+      msg: 'late',
+      tail: 'tail',
+    })
+    const { container } = await mountWithHydration(
+      '<!--[--><!--dynamic-component--><span>tail</span><!--]-->',
+      `<script setup>
+        const data = _data
+      </script>
+      <template>
+        <component :is="data.show ? 'div' : null">{{ data.msg }}</component>
+        <span>{{ data.tail }}</span>
+      </template>`,
+      data,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(container.innerHTML).toBe(
+      '<!--[--><!--dynamic-component--><span>tail</span><!--]-->',
+    )
+
+    data.value.show = true
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      '<!--[--><div>late</div><!--dynamic-component--><span>tail</span><!--]-->',
+    )
+
+    data.value.msg = 'late-updated'
+    data.value.tail = 'tail-updated'
+    await nextTick()
+
+    expect(container.innerHTML).toBe(
+      '<!--[--><div>late-updated</div><!--dynamic-component--><span>tail-updated</span><!--]-->',
+    )
+  })
+
+  test('hydrate empty createDynamicComponent under keyed Transition should fill before trailing sibling', async () => {
+    const data = ref({
+      show: false,
+      key: 'empty',
+      msg: 'late',
+      tail: 'tail',
+    })
+    const { container } = await mountWithHydration(
+      '<!--[--><!----><span>tail</span><!--]-->',
+      `<script setup>
+        const data = _data
+      </script>
+      <template>
+        <Transition :css="false">
+          <component :is="data.show ? 'div' : null" :key="data.key">
+            {{ data.msg }}
+          </component>
+        </Transition>
+        <span>{{ data.tail }}</span>
+      </template>`,
+      data,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(container.innerHTML).toBe(
+      '<!--[--><!----><!--keyed--><span>tail</span><!--]-->',
+    )
+
+    data.value.show = true
+    data.value.key = 'filled'
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      '<!--[--><div>late</div><!--dynamic-component--><!--keyed--><span>tail</span><!--]-->',
+    )
+
+    data.value.msg = 'late-updated'
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      '<!--[--><div>late-updated</div><!--dynamic-component--><!--keyed--><span>tail-updated</span><!--]-->',
+    )
+  })
+
   test('hydrate vapor slot in vdom component with empty slot and sibling nodes', async () => {
     const msg = ref('Hello World!')
     const { container } = await testWithVaporApp(

+ 29 - 0
packages/runtime-vapor/src/fragment.ts

@@ -374,6 +374,22 @@ export class DynamicFragment extends VaporFragment {
       }
     }
 
+    // Reuse an attached SSR comment anchor for empty dynamic-component /
+    // async-component / keyed-fragment branches. Otherwise hydration would
+    // fall back to creating a detached runtime anchor and lose the sibling
+    // position needed for later same-tick inserts.
+    if (
+      this.anchorLabel &&
+      !isValidBlock(this.nodes) &&
+      currentHydrationNode &&
+      isReusableDynamicFragmentAnchor(currentHydrationNode, this.anchorLabel)
+    ) {
+      this.anchor = markHydrationAnchor(currentHydrationNode)
+      this.nodes = []
+      advanceHydrationNode(this.anchor)
+      return
+    }
+
     // Slot fallback can fall through an inner `v-if`. When the `if` resolves
     // to an invalid block and the fallback is selected, the `if` still needs
     // its own runtime anchor instead of reusing the parent slot's end anchor.
@@ -457,6 +473,19 @@ function setCurrentSlotEndAnchor(end: Node | null): Node | null {
   }
 }
 
+function isReusableDynamicFragmentAnchor(
+  node: Node,
+  anchorLabel: string,
+): boolean {
+  return (
+    isComment(node, anchorLabel) ||
+    (isComment(node, '') &&
+      (anchorLabel === 'dynamic-component' ||
+        anchorLabel === 'async component' ||
+        anchorLabel === 'keyed'))
+  )
+}
+
 // Tracks slot fallback hydration that falls through an inner empty fragment,
 // e.g.
 // - `<slot><template v-if="false" /></slot>`