Преглед изворни кода

fix(hydration): reuse next null-branch anchor during dynamic-component hydration

daiwei пре 2 недеља
родитељ
комит
df532ed447

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

@@ -7312,6 +7312,37 @@ describe('VDOM interop', () => {
     )
     )
   })
   })
 
 
+  test('hydrate createDynamicComponent to null branch should remove stale branch before trailing sibling', async () => {
+    const data = ref({
+      show: false,
+      msg: 'late',
+      tail: 'tail',
+    })
+    const { container } = await mountWithHydration(
+      '<!--[--><div>late</div><!--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(`Hydration children mismatch`).toHaveBeenWarned()
+    expect(container.innerHTML).toBe(
+      '<!--[--><!--dynamic-component--><span>tail</span><!--]-->',
+    )
+
+    data.value.tail = 'tail-updated'
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      '<!--[--><!--dynamic-component--><span>tail-updated</span><!--]-->',
+    )
+  })
+
   test('hydrate vapor slot in vdom component with empty slot and sibling nodes', async () => {
   test('hydrate vapor slot in vdom component with empty slot and sibling nodes', async () => {
     const msg = ref('Hello World!')
     const msg = ref('Hello World!')
     const { container } = await testWithVaporApp(
     const { container } = await testWithVaporApp(

+ 27 - 0
packages/runtime-vapor/src/component.ts

@@ -87,6 +87,7 @@ import {
   adoptTemplate,
   adoptTemplate,
   advanceHydrationNode,
   advanceHydrationNode,
   currentHydrationNode,
   currentHydrationNode,
+  isComment,
   isHydrating,
   isHydrating,
   locateHydrationNode,
   locateHydrationNode,
   locateNextNode,
   locateNextNode,
@@ -763,6 +764,23 @@ export function createComponentWithFallback(
   appContext?: GenericAppContext,
   appContext?: GenericAppContext,
 ): HTMLElement | VaporComponentInstance {
 ): HTMLElement | VaporComponentInstance {
   if (comp === NULL_DYNAMIC_COMPONENT) {
   if (comp === NULL_DYNAMIC_COMPONENT) {
+    if (isHydrating && currentHydrationNode) {
+      if (isReusableNullComponentAnchor(currentHydrationNode)) {
+        const node = currentHydrationNode
+        if (isComment(node, '')) {
+          advanceHydrationNode(node)
+        }
+        return node as any as HTMLElement
+      }
+
+      const nextAnchor = locateNextNode(currentHydrationNode)
+      if (nextAnchor && isReusableNullComponentAnchor(nextAnchor)) {
+        // Keep the cursor on the stale SSR node before `nextAnchor` so the
+        // owning DynamicFragment can trim that range on hydrate exit and then
+        // advance past the reused null-branch anchor in one place.
+        return nextAnchor as any as HTMLElement
+      }
+    }
     return (__DEV__
     return (__DEV__
       ? createComment('ndc')
       ? createComment('ndc')
       : createTextNode('')) as any as HTMLElement
       : createTextNode('')) as any as HTMLElement
@@ -782,6 +800,15 @@ export function createComponentWithFallback(
   return createPlainElement(comp, rawProps, rawSlots, isSingleRoot, once)
   return createPlainElement(comp, rawProps, rawSlots, isSingleRoot, once)
 }
 }
 
 
+function isReusableNullComponentAnchor(node: Node): boolean {
+  return (
+    isComment(node, '') ||
+    isComment(node, 'dynamic-component') ||
+    isComment(node, 'async component') ||
+    isComment(node, 'keyed')
+  )
+}
+
 export function createPlainElement(
 export function createPlainElement(
   comp: string,
   comp: string,
   rawProps?: LooseRawProps | null,
   rawProps?: LooseRawProps | null,

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

@@ -12,11 +12,14 @@ import {
 } from './block'
 } from './block'
 import {
 import {
   type GenericComponentInstance,
   type GenericComponentInstance,
+  MismatchTypes,
   type TransitionHooks,
   type TransitionHooks,
   type VNode,
   type VNode,
   currentInstance,
   currentInstance,
+  isMismatchAllowed,
   queuePostFlushCb,
   queuePostFlushCb,
   setCurrentInstance,
   setCurrentInstance,
+  warn,
   warnExtraneousAttributes,
   warnExtraneousAttributes,
 } from '@vue/runtime-dom'
 } from '@vue/runtime-dom'
 import {
 import {
@@ -32,6 +35,8 @@ import {
   isHydrating,
   isHydrating,
   locateEndAnchor,
   locateEndAnchor,
   locateHydrationNode,
   locateHydrationNode,
+  locateNextNode,
+  logMismatchError,
   markHydrationAnchor,
   markHydrationAnchor,
   setCurrentHydrationNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
 } from './dom/hydration'
@@ -374,6 +379,29 @@ export class DynamicFragment extends VaporFragment {
       }
       }
     }
     }
 
 
+    if (
+      this.anchorLabel &&
+      !isValidBlock(this.nodes) &&
+      this.nodes instanceof Comment &&
+      this.nodes.parentNode &&
+      isReusableDynamicFragmentAnchor(this.nodes, this.anchorLabel)
+    ) {
+      this.anchor = markHydrationAnchor(this.nodes)
+      this.nodes = []
+      if (
+        currentHydrationNode &&
+        shouldCleanupHydrationNodesBeforeAnchor(
+          currentHydrationNode,
+          this.anchor,
+        )
+      ) {
+        cleanupHydrationNodesBeforeAnchor(this.anchor)
+      } else {
+        advanceHydrationNode(this.anchor)
+      }
+      return
+    }
+
     // Reuse an attached SSR comment anchor for empty dynamic-component /
     // Reuse an attached SSR comment anchor for empty dynamic-component /
     // async-component / keyed-fragment branches. Otherwise hydration would
     // async-component / keyed-fragment branches. Otherwise hydration would
     // fall back to creating a detached runtime anchor and lose the sibling
     // fall back to creating a detached runtime anchor and lose the sibling
@@ -486,6 +514,43 @@ function isReusableDynamicFragmentAnchor(
   )
   )
 }
 }
 
 
+function cleanupHydrationNodesBeforeAnchor(anchor: Node): void {
+  let node = currentHydrationNode
+  const container = anchor.parentElement
+  if (container && !isMismatchAllowed(container, MismatchTypes.CHILDREN)) {
+    if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
+      warn(
+        `Hydration children mismatch on`,
+        container,
+        `\nServer rendered element contains more child nodes than client nodes.`,
+      )
+    }
+    logMismatchError()
+  }
+
+  while (node && node !== anchor) {
+    const next = locateNextNode(node)
+    const parent = node.parentNode
+    if (parent) {
+      remove(node, parent)
+    }
+    node = next
+  }
+
+  setCurrentHydrationNode(anchor)
+  advanceHydrationNode(anchor)
+}
+
+function shouldCleanupHydrationNodesBeforeAnchor(
+  node: Node,
+  anchor: Node,
+): boolean {
+  return !!(
+    node !== anchor &&
+    node.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_FOLLOWING
+  )
+}
+
 // Tracks slot fallback hydration that falls through an inner empty fragment,
 // Tracks slot fallback hydration that falls through an inner empty fragment,
 // e.g.
 // e.g.
 // - `<slot><template v-if="false" /></slot>`
 // - `<slot><template v-if="false" /></slot>`