2
0
Эх сурвалжийг харах

fix(hydration): cleanup detached null branches at the end of owner ranges

daiwei 2 сар өмнө
parent
commit
b0db001f70

+ 258 - 66
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -5806,38 +5806,51 @@ describe('mismatch handling', () => {
     expect(container.innerHTML).toBe('<span>foo</span><!--dynamic-component-->')
     expect(`Hydration node mismatch`).toHaveBeenWarned()
   })
-  // test('fragment mismatch removal', () => {
-  //   const { container } = mountWithHydration(
-  //     `<div><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
-  //     () => h('div', [h('span', 'replaced')]),
-  //   )
-  //   expect(container.innerHTML).toBe('<div><span>replaced</span></div>')
-  //   expect(`Hydration node mismatch`).toHaveBeenWarned()
-  // })
-  // test('fragment not enough children', () => {
-  //   const { container } = mountWithHydration(
-  //     `<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
-  //     () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
-  //   )
-  //   expect(container.innerHTML).toBe(
-  //     '<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
-  //   )
-  //   expect(`Hydration node mismatch`).toHaveBeenWarned()
-  // })
-  // test('fragment too many children', () => {
-  //   const { container } = mountWithHydration(
-  //     `<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
-  //     () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
-  //   )
-  //   expect(container.innerHTML).toBe(
-  //     '<div><!--[--><div>foo</div><!--]--><div>baz</div></div>',
-  //   )
-  //   // fragment ends early and attempts to hydrate the extra <div>bar</div>
-  //   // as 2nd fragment child.
-  //   expect(`Hydration text content mismatch`).toHaveBeenWarned()
-  //   // excessive children removal
-  //   expect(`Hydration children mismatch`).toHaveBeenWarned()
-  // })
+  test('fragment mismatch removal', async () => {
+    const data = ref({ items: [] as string[] })
+    const { container } = await mountWithHydration(
+      `<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
+      `<div>
+        <div v-for="item in data.items" :key="item">foo</div>
+        <div>baz</div>
+      </div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div><!--[--><!--]--><div>baz</div></div>',
+    )
+    expect(`Hydration children mismatch`).toHaveBeenWarned()
+  })
+  test('fragment not enough children', async () => {
+    const data = ref({ items: ['a', 'b'] })
+    const { container } = await mountWithHydration(
+      `<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
+      `<div>
+        <div v-for="item in data.items" :key="item">foo</div>
+        <div>baz</div>
+      </div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div><!--[--><div>foo</div><div>foo</div><!--]--><div>baz</div></div>',
+    )
+    expect(`Hydration node mismatch`).toHaveBeenWarned()
+  })
+  test('fragment too many children', async () => {
+    const data = ref({ items: ['a'] })
+    const { container } = await mountWithHydration(
+      `<div><!--[--><div>foo</div><div>foo</div><!--]--><div>baz</div></div>`,
+      `<div>
+        <div v-for="item in data.items" :key="item">foo</div>
+        <div>baz</div>
+      </div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div><!--[--><div>foo</div><!--]--><div>baz</div></div>',
+    )
+    expect(`Hydration children mismatch`).toHaveBeenWarned()
+  })
   // test('Teleport target has empty children', () => {
   //   const teleportContainer = document.createElement('div')
   //   teleportContainer.id = 'teleport'
@@ -6296,40 +6309,51 @@ describe('data-allow-mismatch', () => {
     )
     expect(`Hydration node mismatch`).not.toHaveBeenWarned()
   })
-  // test('fragment mismatch removal', () => {
-  //   const { container } = mountWithHydration(
-  //     `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
-  //     () => h('div', [h('span', 'replaced')]),
-  //   )
-  //   expect(container.innerHTML).toBe(
-  //     '<div data-allow-mismatch="children"><span>replaced</span></div>',
-  //   )
-  //   expect(`Hydration node mismatch`).not.toHaveBeenWarned()
-  // })
-  // test('fragment not enough children', () => {
-  //   const { container } = mountWithHydration(
-  //     `<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
-  //     () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
-  //   )
-  //   expect(container.innerHTML).toBe(
-  //     '<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
-  //   )
-  //   expect(`Hydration node mismatch`).not.toHaveBeenWarned()
-  // })
-  // test('fragment too many children', () => {
-  //   const { container } = mountWithHydration(
-  //     `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
-  //     () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
-  //   )
-  //   expect(container.innerHTML).toBe(
-  //     '<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
-  //   )
-  //   // fragment ends early and attempts to hydrate the extra <div>bar</div>
-  //   // as 2nd fragment child.
-  //   expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
-  //   // excessive children removal
-  //   expect(`Hydration children mismatch`).not.toHaveBeenWarned()
-  // })
+  test('fragment mismatch removal', async () => {
+    const data = ref({ items: [] as string[] })
+    const { container } = await mountWithHydration(
+      `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
+      `<div data-allow-mismatch="children">
+        <div v-for="item in data.items" :key="item">foo</div>
+        <div>baz</div>
+      </div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="children"><!--[--><!--]--><div>baz</div></div>',
+    )
+    expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+  })
+  test('fragment not enough children', async () => {
+    const data = ref({ items: ['a', 'b'] })
+    const { container } = await mountWithHydration(
+      `<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
+      `<div data-allow-mismatch="children">
+        <div v-for="item in data.items" :key="item">foo</div>
+        <div>baz</div>
+      </div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="children"><!--[--><div>foo</div><div>foo</div><!--]--><div>baz</div></div>',
+    )
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+  })
+  test('fragment too many children', async () => {
+    const data = ref({ items: ['a'] })
+    const { container } = await mountWithHydration(
+      `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>foo</div><!--]--><div>baz</div></div>`,
+      `<div data-allow-mismatch="children">
+        <div v-for="item in data.items" :key="item">foo</div>
+        <div>baz</div>
+      </div>`,
+      data,
+    )
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
+    )
+    expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+  })
   // test('comment mismatch (element)', () => {
   //   const { container } = mountWithHydration(
   //     `<div data-allow-mismatch="children"><span></span></div>`,
@@ -7343,6 +7367,174 @@ describe('VDOM interop', () => {
     )
   })
 
+  test('hydrate createDynamicComponent to null branch at end of container', async () => {
+    const data = ref({
+      show: true,
+      msg: 'late',
+    })
+    const code = `<script setup>
+        const data = _data
+      </script>
+      <template>
+        <component :is="data.show ? 'div' : null">{{ data.msg }}</component>
+      </template>`
+
+    const serverComp = compile(code, data, {}, { vapor: true, ssr: true })
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(serverComp),
+    )
+
+    data.value.show = false
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+
+    const clientComp = compile(code, data, {}, { vapor: true, ssr: false })
+    createVaporSSRApp(clientComp).mount(container)
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(`Hydration children mismatch`).toHaveBeenWarned()
+    expect(container.innerHTML).toBe('<!--dynamic-component-->')
+
+    data.value.show = true
+    await nextTick()
+    expect(container.innerHTML).toBe('<div>late</div><!--dynamic-component-->')
+
+    data.value.msg = 'late-updated'
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      '<div>late-updated</div><!--dynamic-component-->',
+    )
+  })
+
+  test('hydrate createDynamicComponent to null branch at end of allowed-mismatch container', async () => {
+    const data = ref({
+      show: true,
+      msg: 'late',
+    })
+    const code = `<script setup>
+        const data = _data
+      </script>
+      <template>
+        <div data-allow-mismatch="children">
+          <component :is="data.show ? 'div' : null">{{ data.msg }}</component>
+        </div>
+      </template>`
+
+    const serverComp = compile(code, data, {}, { vapor: true, ssr: true })
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(serverComp),
+    )
+
+    data.value.show = false
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+
+    const clientComp = compile(code, data, {}, { vapor: true, ssr: false })
+    createVaporSSRApp(clientComp).mount(container)
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="children"><!--dynamic-component--></div>',
+    )
+  })
+
+  test('hydrate Fragment dynamic component to null branch at end of container', async () => {
+    const data = ref({
+      showMulti: true,
+    })
+    const code = `<script setup>
+        import { Fragment, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          data.value.showMulti
+            ? h(Fragment, null, [
+                h('div', null, 'first fragment'),
+                h('div', null, 'second fragment'),
+              ])
+            : null
+        )
+      </script>
+      <template>
+        <component :is="vnode" />
+      </template>`
+
+    const serverComp = compile(code, data, {}, { vapor: true, ssr: true })
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(serverComp),
+    )
+
+    data.value.showMulti = false
+
+    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(`Hydration children mismatch`).toHaveBeenWarned()
+    expect(container.innerHTML).toBe('<!--dynamic-component-->')
+
+    data.value.showMulti = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "<div>first fragment</div><div>second fragment</div><!--dynamic-component-->"
+    `)
+  })
+
+  test('hydrate Fragment dynamic component to null branch at end of allowed-mismatch container', async () => {
+    const data = ref({
+      showMulti: true,
+    })
+    const code = `<script setup>
+        import { Fragment, computed, h } from 'vue'
+        const data = _data
+        const vnode = computed(() =>
+          data.value.showMulti
+            ? h(Fragment, null, [
+                h('div', null, 'first fragment'),
+                h('div', null, 'second fragment'),
+              ])
+            : null
+        )
+      </script>
+      <template>
+        <div data-allow-mismatch="children">
+          <component :is="vnode" />
+        </div>
+      </template>`
+
+    const serverComp = compile(code, data, {}, { vapor: true, ssr: true })
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(serverComp),
+    )
+
+    data.value.showMulti = false
+
+    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(`Hydration children mismatch`).not.toHaveBeenWarned()
+    expect(container.innerHTML).toBe(
+      '<div data-allow-mismatch="children"><!--dynamic-component--></div>',
+    )
+  })
+
   test('hydrate vapor slot in vdom component with empty slot and sibling nodes', async () => {
     const msg = ref('Hello World!')
     const { container } = await testWithVaporApp(

+ 69 - 52
packages/runtime-vapor/src/apiCreateFor.ts

@@ -26,8 +26,10 @@ import { VaporVForFlags } from '@vue/shared'
 import {
   advanceHydrationNode,
   currentHydrationNode,
+  enterHydrationBoundary,
   isComment,
   isHydrating,
+  locateHydrationBoundaryClose,
   locateHydrationNode,
   locateNextNode,
   markHydrationAnchor,
@@ -121,67 +123,82 @@ export const createFor = (
       isMounted = true
       if (isHydrating) {
         const hydrationStart = currentHydrationNode!
+        let exitHydrationBoundary: (() => void) | undefined
         let nextNode
         const emptyLocalRange =
           isComment(hydrationStart, ']') &&
           isComment(hydrationStart.previousSibling!, '[')
-
-        if (emptyLocalRange && newLength) {
-          parentAnchor = markHydrationAnchor(hydrationStart)
-          for (let i = 0; i < newLength; i++) {
-            mount(source, i)
-          }
-          setCurrentHydrationNode(parentAnchor)
-        } else {
-          for (let i = 0; i < newLength; i++) {
-            nextNode = locateNextNode(currentHydrationNode!)
-            mount(source, i)
-            if (nextNode) setCurrentHydrationNode(nextNode)
-          }
-
-          // Slot fallback can fall through an empty/invalid `v-for`. In that
-          // case SSR only rendered the parent slot range, so this `v-for` has no
-          // own `<!--]-->` to reuse. If `hydrationStart` is not the parent slot
-          // end anchor, use `hydrationStart.nextSibling` as the insertion anchor
-          // so the runtime `<!--for-->` lands immediately after that local SSR
-          // range. Otherwise insert it before the parent slot end anchor.
-          if (
-            currentEmptyFragment !== undefined &&
-            !isValidBlock(newBlocks) &&
-            currentSlotEndAnchor
-          ) {
-            const anchor =
-              // The invalid list still consumed local SSR item ranges.
-              currentHydrationNode !== hydrationStart
-                ? currentHydrationNode!
-                : // Empty source with trailing slot siblings.
-                  hydrationStart !== currentSlotEndAnchor
-                  ? hydrationStart.nextSibling!
-                  : currentSlotEndAnchor
-            parentAnchor = __DEV__ ? createComment('for') : createTextNode()
-            pendingHydrationAnchor = true
-            setCurrentHydrationNode(hydrationStart)
-            queuePostFlushCb(() =>
-              anchor.parentNode!.insertBefore(parentAnchor, anchor),
-            )
+        const slotFallbackRange =
+          currentEmptyFragment !== undefined && currentSlotEndAnchor
+
+        try {
+          if (emptyLocalRange && newLength) {
+            parentAnchor = markHydrationAnchor(hydrationStart)
+            exitHydrationBoundary = enterHydrationBoundary(parentAnchor)
+            for (let i = 0; i < newLength; i++) {
+              mount(source, i)
+            }
+            setCurrentHydrationNode(parentAnchor)
           } else {
-            parentAnchor = currentHydrationNode!
-            if (
-              __DEV__ &&
-              (!parentAnchor || (parentAnchor && !isComment(parentAnchor, ']')))
-            ) {
-              throw new Error(
-                `v-for fragment anchor node was not found. this is likely a Vue internal bug.`,
-              )
+            for (let i = 0; i < newLength; i++) {
+              if (isComment(currentHydrationNode!, ']')) {
+                nextNode = markHydrationAnchor(currentHydrationNode!)
+                setCurrentHydrationNode(nextNode)
+              } else {
+                nextNode = locateNextNode(currentHydrationNode!)
+              }
+              mount(source, i)
+              if (nextNode) setCurrentHydrationNode(nextNode)
             }
 
-            // optimization: cache the fragment end anchor as $llc (last logical child)
-            // so that locateChildByLogicalIndex can skip the entire fragment
-            if (_insertionParent && isComment(parentAnchor, ']')) {
-              ;(parentAnchor as any as ChildItem).$idx = _insertionIndex || 0
-              _insertionParent.$llc = parentAnchor
+            // Slot fallback can fall through an empty/invalid `v-for`. In that
+            // case SSR only rendered the parent slot range, so this `v-for` has no
+            // own `<!--]-->` to reuse. If `hydrationStart` is not the parent slot
+            // end anchor, use `hydrationStart.nextSibling` as the insertion point
+            // so the runtime `<!--for-->` lands immediately after that local SSR
+            // range. Otherwise insert it before the parent slot end anchor.
+            if (slotFallbackRange && !isValidBlock(newBlocks)) {
+              const anchor =
+                // The invalid list still consumed local SSR item ranges.
+                currentHydrationNode !== hydrationStart
+                  ? currentHydrationNode!
+                  : // Empty source with trailing slot siblings.
+                    hydrationStart !== currentSlotEndAnchor
+                    ? hydrationStart.nextSibling!
+                    : currentSlotEndAnchor!
+              parentAnchor = markHydrationAnchor(
+                __DEV__ ? createComment('for') : createTextNode(),
+              )
+              pendingHydrationAnchor = true
+              if (
+                currentHydrationNode === hydrationStart ||
+                currentHydrationNode === currentSlotEndAnchor
+              ) {
+                setCurrentHydrationNode(hydrationStart)
+              }
+              queuePostFlushCb(() =>
+                anchor.parentNode!.insertBefore(parentAnchor, anchor),
+              )
+            } else {
+              const close = locateHydrationBoundaryClose(currentHydrationNode!)
+              parentAnchor = markHydrationAnchor(close)
+              exitHydrationBoundary = enterHydrationBoundary(parentAnchor)
+              if (__DEV__ && !isComment(parentAnchor, ']')) {
+                throw new Error(
+                  `v-for fragment anchor node was not found. this is likely a Vue internal bug.`,
+                )
+              }
+
+              // optimization: cache the fragment end anchor as $llc (last logical child)
+              // so that locateChildByLogicalIndex can skip the entire fragment
+              if (_insertionParent && isComment(parentAnchor, ']')) {
+                ;(parentAnchor as any as ChildItem).$idx = _insertionIndex || 0
+                _insertionParent.$llc = parentAnchor
+              }
             }
           }
+        } finally {
+          exitHydrationBoundary && exitHydrationBoundary()
         }
       } else {
         for (let i = 0; i < newLength; i++) {

+ 117 - 17
packages/runtime-vapor/src/dom/hydration.ts

@@ -155,7 +155,7 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
       node.before((node = createTextNode()))
     }
 
-    node = resolveHydrationTarget(node, template)
+    node = resolveHydrationTarget(node)
   }
 
   const type = node.nodeType
@@ -236,6 +236,31 @@ export function locateEndAnchor(
   return null
 }
 
+// Find the SSR close marker for the current owner.
+export function locateHydrationBoundaryClose(
+  node: Node,
+  closeHint: Node | null = null,
+): Node {
+  let close = closeHint
+  if (!close || !isComment(close, ']')) {
+    if (isComment(node, ']')) {
+      close = node
+    } else {
+      let candidate = locateNextNode(node)
+      while (candidate && !isComment(candidate, ']')) {
+        candidate = locateNextNode(candidate)
+      }
+      close = candidate
+    }
+  }
+
+  if (!close) {
+    return node
+  }
+
+  return close
+}
+
 function handleMismatch(node: Node, template: string): Node {
   if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
     ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@@ -295,17 +320,54 @@ export const logMismatchError = (): void => {
 }
 
 export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
+  const parent = parentNode(node)
+  if (!parent) {
+    return
+  }
   const end = endAnchor || locateEndAnchor(node as Anchor)
   while (true) {
     const next = _next(node)
     if (next && next !== end) {
-      remove(next, parentNode(node)!)
+      remove(next, parent)
     } else {
       break
     }
   }
 }
 
+function removeHydrationNode(node: Node, close: Node | null = null): void {
+  const parent = parentNode(node)
+  if (!parent) {
+    return
+  }
+
+  if (isComment(node, '[')) {
+    const end = locateEndAnchor(node)
+    removeFragmentNodes(node, end || undefined)
+    const endParent = end && parentNode(end)
+    if (end && end !== close && endParent) {
+      remove(end, endParent)
+    }
+  } else if (isComment(node, 'teleport start')) {
+    const end = locateEndAnchor(node, 'teleport start', 'teleport end')
+    removeFragmentNodes(node, end || undefined)
+    const endParent = end && parentNode(end)
+    if (end && end !== close && endParent) {
+      remove(end, endParent)
+    }
+  }
+
+  remove(node, parent)
+}
+
+export function cleanupHydrationTail(node: Node): void {
+  const container = node.parentElement
+  if (container) {
+    warnHydrationChildrenMismatch(container)
+  }
+  removeHydrationNode(node)
+}
+
 export function markHydrationAnchor<T extends Node>(node: T): T {
   ;(node as any).$vha = 1
   return node
@@ -315,14 +377,9 @@ export function isHydrationAnchor(node: Node | null | undefined): boolean {
   return !!node && (node as Anchor).$vha === 1
 }
 
-function resolveHydrationTarget(node: Node, template: string): Node {
+function resolveHydrationTarget(node: Node): Node {
   while (true) {
     if (isHydrationAnchor(node)) {
-      const next = node.nextSibling
-      if (next && canUseAsHydrationTarget(next, template)) {
-        node = next
-        continue
-      }
       return node
     }
 
@@ -343,17 +400,60 @@ function resolveHydrationTarget(node: Node, template: string): Node {
   }
 }
 
-function canUseAsHydrationTarget(node: Node, template: string): boolean {
-  if (template[0] !== '<') {
-    return node.nodeType === 3
+function finalizeHydrationBoundary(close: Node | null): void {
+  let node = currentHydrationNode
+
+  // Once the hydration cursor has already reached `close`, this scope has no
+  // unclaimed SSR nodes left to trim. Single-root paths commonly end up here,
+  // so there is no children-count mismatch to report for this boundary.
+  if (!close || !node || node === close) {
+    return
+  }
+
+  // This boundary only owns cleanup while the current cursor is still inside
+  // its SSR range. If nested hydration has already advanced past `close`, stop
+  // here so we don't delete sibling or parent-owned SSR nodes by mistake.
+  let cur: Node | null = node
+  let hasRemovableNode = false
+  while (cur && cur !== close) {
+    if (!isHydrationAnchor(cur)) {
+      hasRemovableNode = true
+    }
+    cur = locateNextNode(cur)
+  }
+  if (!cur) return
+  if (!hasRemovableNode) {
+    setCurrentHydrationNode(close)
+    return
+  }
+
+  warnHydrationChildrenMismatch((close as Node).parentElement)
+
+  while (node && node !== close) {
+    const next = locateNextNode(node)
+    if (!isHydrationAnchor(node)) {
+      removeHydrationNode(node, close)
+    }
+    node = next!
   }
 
-  if (template.startsWith('<!')) {
-    return node.nodeType === 8
+  setCurrentHydrationNode(close)
+}
+
+function warnHydrationChildrenMismatch(container: Element | null): void {
+  if (container && !isMismatchAllowed(container, MismatchTypes.CHILDREN)) {
+    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+      warn(
+        `Hydration children mismatch on`,
+        container,
+        `\nServer rendered element contains more child nodes than client nodes.`,
+      )
+    logMismatchError()
   }
+}
 
-  return (
-    node.nodeType === 1 &&
-    template.startsWith(`<${(node as Element).tagName.toLowerCase()}`)
-  )
+export function enterHydrationBoundary(close: Node | null): () => void {
+  return () => {
+    finalizeHydrationBoundary(close)
+  }
 }

+ 145 - 147
packages/runtime-vapor/src/fragment.ts

@@ -1,5 +1,9 @@
 import { EffectScope, type ShallowRef, setActiveSub } from '@vue/reactivity'
-import { createComment, createTextNode } from './dom/node'
+import {
+  createComment,
+  createTextNode,
+  parentNode as getParentNode,
+} from './dom/node'
 import {
   type Block,
   type BlockFn,
@@ -12,14 +16,11 @@ import {
 } from './block'
 import {
   type GenericComponentInstance,
-  MismatchTypes,
   type TransitionHooks,
   type VNode,
   currentInstance,
-  isMismatchAllowed,
   queuePostFlushCb,
   setCurrentInstance,
-  warn,
   warnExtraneousAttributes,
 } from '@vue/runtime-dom'
 import {
@@ -30,13 +31,15 @@ import {
 import type { NodeRef } from './apiTemplateRef'
 import {
   advanceHydrationNode,
+  cleanupHydrationTail,
   currentHydrationNode,
+  enterHydrationBoundary,
   isComment,
   isHydrating,
   locateEndAnchor,
+  locateHydrationBoundaryClose,
   locateHydrationNode,
   locateNextNode,
-  logMismatchError,
   markHydrationAnchor,
   setCurrentHydrationNode,
 } from './dom/hydration'
@@ -369,126 +372,158 @@ export class DynamicFragment extends VaporFragment {
     // re-enter `hydrate()` after its empty branch has already hydrated once.
     if (this.isAnchorPending) return
 
-    // reuse `<!---->` as anchor
-    // `<div v-if="false"></div>` -> `<!---->`
-    if (isEmpty) {
-      if (isComment(currentHydrationNode!, '')) {
-        this.anchor = markHydrationAnchor(currentHydrationNode!)
-        advanceHydrationNode(currentHydrationNode)
+    let advanceAfterRestore: Node | null = null
+    let exitHydrationBoundary: (() => void) | undefined
+
+    try {
+      // reuse `<!---->` as anchor
+      // `<div v-if="false"></div>` -> `<!---->`
+      if (isEmpty) {
+        if (isComment(currentHydrationNode!, '')) {
+          this.anchor = markHydrationAnchor(currentHydrationNode!)
+          advanceHydrationNode(currentHydrationNode)
+          return
+        }
+      }
+
+      // Reuse an existing SSR comment anchor for empty dynamic-component /
+      // async-component / keyed-fragment branches. Without this, hydration can
+      // end up creating a detached runtime anchor and lose the parent/sibling
+      // position needed for same-hydration branch flips.
+      if (
+        this.anchorLabel &&
+        !isValidBlock(this.nodes) &&
+        this.nodes instanceof Comment &&
+        isReusableDynamicFragmentAnchor(this.nodes, this.anchorLabel) &&
+        getParentNode(this.nodes)
+      ) {
+        this.anchor = markHydrationAnchor(this.nodes)
+        this.nodes = []
+        const needsCleanup = currentHydrationNode !== this.anchor
+        if (needsCleanup) {
+          exitHydrationBoundary = enterHydrationBoundary(this.anchor)
+          advanceAfterRestore = this.anchor
+        } else {
+          advanceHydrationNode(this.anchor)
+        }
         return
       }
-    }
 
-    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 = []
+      // Empty dynamic fragments can also start from a detached runtime comment
+      // (for example client null against non-empty SSR content). In that case
+      // derive the insertion point from the current hydration cursor rather
+      // than from the detached block node, and let boundary cleanup trim the
+      // SSR range before the next logical sibling.
       if (
-        currentHydrationNode &&
-        shouldCleanupHydrationNodesBeforeAnchor(
-          currentHydrationNode,
-          this.anchor,
-        )
+        this.anchorLabel &&
+        !isValidBlock(this.nodes) &&
+        this.nodes instanceof Comment &&
+        !getParentNode(this.nodes) &&
+        currentHydrationNode
       ) {
-        cleanupHydrationNodesBeforeAnchor(this.anchor)
-      } else {
-        advanceHydrationNode(this.anchor)
+        const parentNode = getParentNode(currentHydrationNode)
+        const nextNode = locateNextNode(currentHydrationNode)
+        if (parentNode) {
+          this.nodes = []
+          if (nextNode) {
+            exitHydrationBoundary = enterHydrationBoundary(nextNode)
+          } else {
+            cleanupHydrationTail(currentHydrationNode)
+            setCurrentHydrationNode(null)
+          }
+          queuePostFlushCb(() => {
+            parentNode.insertBefore(
+              (this.anchor = markHydrationAnchor(
+                __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
+              )),
+              nextNode,
+            )
+          })
+          return
+        }
       }
-      return
-    }
 
-    // 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.
+      if (this.anchorLabel === 'if' && currentSlotEndAnchor) {
+        if (
+          currentEmptyFragment !== undefined &&
+          (!isValidBlock(this.nodes) || currentEmptyFragment === this)
+        ) {
+          const endAnchor = currentSlotEndAnchor
+          this.isAnchorPending = true
+          queuePostFlushCb(() =>
+            endAnchor.parentNode!.insertBefore(
+              (this.anchor = markHydrationAnchor(
+                __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
+              )),
+              endAnchor,
+            ),
+          )
+          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.
-    if (this.anchorLabel === 'if' && currentSlotEndAnchor) {
+      const forwardedSlot = (this as any as SlotFragment).forwarded
+      const slotAnchor = isSlot ? currentSlotEndAnchor : null
+      // Reuse SSR `<!--]-->` as anchor.
+      // SSR wraps slots and multi-root `v-if` branches with `<!--[-->...<!--]-->`.
+      // Non-forwarded slots always own the closing `<!--]-->`, even when empty.
+      // Forwarded slots only own it when they rendered valid content.
       if (
-        currentEmptyFragment !== undefined &&
-        (!isValidBlock(this.nodes) || currentEmptyFragment === this)
+        (isSlot && (!forwardedSlot || isValidBlock(this.nodes))) ||
+        (this.anchorLabel === 'if' &&
+          isArray(this.nodes) &&
+          this.nodes.length > 1)
       ) {
-        const endAnchor = currentSlotEndAnchor
-        this.isAnchorPending = true
-        queuePostFlushCb(() =>
-          endAnchor.parentNode!.insertBefore(
-            (this.anchor = markHydrationAnchor(
-              __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
-            )),
-            endAnchor,
-          ),
+        const anchor = locateHydrationBoundaryClose(
+          slotAnchor || currentHydrationNode!,
+          slotAnchor || null,
         )
-        return
+        if (isComment(anchor!, ']')) {
+          this.anchor = markHydrationAnchor(anchor)
+          exitHydrationBoundary = enterHydrationBoundary(anchor)
+          advanceHydrationNode(anchor)
+          return
+        } else if (__DEV__) {
+          throw new Error(
+            `Failed to locate ${this.anchorLabel} fragment anchor. this is likely a Vue internal bug.`,
+          )
+        }
       }
-    }
 
-    const forwardedSlot = (this as any as SlotFragment).forwarded
-    const slotAnchor = isSlot ? currentSlotEndAnchor : null
-    // Reuse SSR `<!--]-->` as anchor.
-    // SSR wraps slots and multi-root `v-if` branches with `<!--[-->...<!--]-->`.
-    // Non-forwarded slots always own the closing `<!--]-->`, even when empty.
-    // Forwarded slots only own it when they rendered valid content.
-    if (
-      (isSlot && (!forwardedSlot || isValidBlock(this.nodes))) ||
-      (this.anchorLabel === 'if' &&
-        isArray(this.nodes) &&
-        this.nodes.length > 1)
-    ) {
-      const anchor = slotAnchor || currentHydrationNode
-      if (isComment(anchor!, ']')) {
-        this.anchor = markHydrationAnchor(anchor)
-        advanceHydrationNode(anchor)
-        return
-      } else if (__DEV__) {
-        throw new Error(
-          `Failed to locate ${this.anchorLabel} fragment anchor. this is likely a Vue internal bug.`,
-        )
+      // Otherwise, create a new anchor.
+      // This covers: empty forwarded slots, dynamic-component,
+      // async component, keyed fragment.
+      let parentNode: Node | null
+      let nextNode: Node | null
+      if (forwardedSlot) {
+        parentNode = slotAnchor!.parentNode
+        nextNode = slotAnchor!.nextSibling
+      } else {
+        const node = findBlockNode(this.nodes)
+        parentNode = node.parentNode
+        nextNode = node.nextNode
       }
-    }
 
-    // Otherwise, create a new anchor.
-    // This covers: empty forwarded slots, dynamic-component,
-    // async component, keyed fragment.
-    let parentNode: Node | null
-    let nextNode: Node | null
-    if (forwardedSlot) {
-      parentNode = slotAnchor!.parentNode
-      nextNode = slotAnchor!.nextSibling
-    } else {
-      const node = findBlockNode(this.nodes)
-      parentNode = node.parentNode
-      nextNode = node.nextNode
+      // Assign `this.anchor` only after the anchor is inserted.
+      // Otherwise detached anchors could be observed too early by traversal
+      // logic such as `findLastChild()`.
+      queuePostFlushCb(() => {
+        parentNode!.insertBefore(
+          (this.anchor = markHydrationAnchor(
+            __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
+          )),
+          nextNode,
+        )
+      })
+    } finally {
+      exitHydrationBoundary && exitHydrationBoundary()
+      if (advanceAfterRestore && currentHydrationNode === advanceAfterRestore) {
+        advanceHydrationNode(advanceAfterRestore)
+      }
     }
-
-    // Assign `this.anchor` only after the anchor is inserted.
-    // Otherwise detached anchors could be observed too early by traversal
-    // logic such as `findLastChild()`.
-    queuePostFlushCb(() => {
-      parentNode!.insertBefore(
-        (this.anchor = markHydrationAnchor(
-          __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
-        )),
-        nextNode,
-      )
-    })
   }
 }
 
@@ -502,7 +537,7 @@ function setCurrentSlotEndAnchor(end: Node | null): Node | null {
 }
 
 function isReusableDynamicFragmentAnchor(
-  node: Node,
+  node: Comment,
   anchorLabel: string,
 ): boolean {
   return (
@@ -514,43 +549,6 @@ 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,
 // e.g.
 // - `<slot><template v-if="false" /></slot>`