Prechádzať zdrojové kódy

refactor(runtime-vapor): improve anchor reuse logic during hydration (#14670)

- handle slot fallback hydration through empty and invalid inner v-if and v-for branches by creating dedicated runtime anchors instead of reusing the parent slot end anchor.
- delay slot anchor hydration until slot render and fallback resolution finish, and simplify forwarded slot anchor reuse by tracking the current slot end anchor directly.
- move ForBlock into fragment.ts so fallback traversal can continue through nested for blocks and add regression coverage for component slots, hydration, and vdom interop.
- force v-if multi root shape when in template v-for, for proper runtime `<!--[-->` consume.
edison 2 týždňov pred
rodič
commit
eb602084dd

+ 23 - 0
packages/compiler-vapor/__tests__/transforms/vIf.spec.ts

@@ -195,6 +195,29 @@ describe('compiler: v-if', () => {
     })
   })
 
+  test('v-if in template v-for forces MULTI_ROOT shape', () => {
+    const { ir, helpers } = compileWithVIf(
+      `<template v-for="item in list">
+        <span v-if="item.ok">
+          <span>{{ item.text }}</span>
+        </span>
+      </template>`,
+    )
+
+    expect(helpers).toContain('createIf')
+    expect(helpers).toContain('createFor')
+
+    const forOp = ir.block.dynamic.children[0].operation
+    expect(forOp).toMatchObject({
+      type: IRNodeTypes.FOR,
+    })
+
+    const ifOp = (forOp as any).render.dynamic.children[0].operation as IfIRNode
+    expect(ifOp.blockShape).toBe(
+      VaporBlockShape.MULTI_ROOT | (VaporBlockShape.MULTI_ROOT << 2),
+    )
+  })
+
   test('template v-if + normal v-else', () => {
     const { code, ir } = compileWithVIf(
       `<template v-if="foo"><div>hi</div><div>ho</div></template><div v-else/>`,

+ 24 - 1
packages/compiler-vapor/src/transforms/vIf.ts

@@ -1,6 +1,8 @@
 import {
   type ElementNode,
+  ElementTypes,
   ErrorCodes,
+  NodeTypes,
   createCompilerError,
   createSimpleExpression,
 } from '@vue/compiler-dom'
@@ -40,6 +42,7 @@ export function processIf(
   }
 
   context.dynamic.flags |= DynamicFlag.NON_TEMPLATE
+  const forceMultiRoot = shouldForceMultiRoot(context)
   if (dir.name === 'if') {
     const id = context.reference()
     context.dynamic.flags |= DynamicFlag.INSERT
@@ -50,7 +53,7 @@ export function processIf(
       context.dynamic.operation = {
         type: IRNodeTypes.IF,
         id,
-        blockShape: encodeIfBlockShape(branch),
+        blockShape: encodeIfBlockShape(branch, forceMultiRoot),
         condition: dir.exp!,
         positive: branch,
         index: context.root.nextIfIndex(),
@@ -134,10 +137,12 @@ export function processIf(
       if (lastIfNode.negative.type === IRNodeTypes.IF) {
         lastIfNode.negative.blockShape = encodeIfBlockShape(
           lastIfNode.negative.positive,
+          forceMultiRoot,
         )
       }
       lastIfNode.blockShape = encodeIfBlockShape(
         lastIfNode.positive,
+        forceMultiRoot,
         lastIfNode.negative,
       )
     }
@@ -158,10 +163,14 @@ export function createIfBranch(
 
 function encodeIfBlockShape(
   positive: BlockIRNode,
+  forceMultiRoot: boolean = false,
   negative?: BlockIRNode | IfIRNode,
 ): number {
   // Pack the true/false branch shapes into one integer so runtime `createIf()`
   // can decode the selected branch with a single bit-mask operation.
+  if (forceMultiRoot) {
+    return VaporBlockShape.MULTI_ROOT | (VaporBlockShape.MULTI_ROOT << 2)
+  }
   return getBlockShape(positive) | (getNegativeBlockShape(negative) << 2)
 }
 
@@ -171,3 +180,17 @@ function getNegativeBlockShape(negative?: BlockIRNode | IfIRNode) {
     ? VaporBlockShape.SINGLE_ROOT
     : getBlockShape(negative)
 }
+
+// SSR renders `v-if` inside `<template v-for>` always output <!--[-->...<!--]-->.
+// should mark the block as multi-root
+function shouldForceMultiRoot(context: TransformContext<ElementNode>): boolean {
+  const parent = context.parent && context.parent.node
+  return (
+    !!parent &&
+    parent.type === NodeTypes.ELEMENT &&
+    parent.tagType === ElementTypes.TEMPLATE &&
+    parent.props.some(
+      prop => prop.type === NodeTypes.DIRECTIVE && prop.name === 'for',
+    )
+  )
+}

+ 97 - 0
packages/runtime-vapor/__tests__/componentSlots.spec.ts

@@ -825,6 +825,103 @@ describe('component: slots', () => {
       expect(html()).toBe('<span>2</span><!--for--><!--slot-->')
     })
 
+    test('render fallback with invalid v-for branch', async () => {
+      const Child = {
+        setup() {
+          return createSlot('default', null, () =>
+            document.createTextNode('fallback'),
+          )
+        },
+      }
+
+      const items = ref([{ text: 'bar', show: false }])
+      const { html } = define({
+        setup() {
+          return createComponent(Child, null, {
+            default: () => {
+              return createFor(
+                () => items.value,
+                for_item0 => {
+                  return createIf(
+                    () => for_item0.value.show,
+                    () => {
+                      const n5 = template('<span> </span>')() as any
+                      const x5 = child(n5) as any
+                      renderEffect(() =>
+                        setText(x5, toDisplayString(for_item0.value.text)),
+                      )
+                      return n5
+                    },
+                  )
+                },
+                item => item.text,
+              )
+            },
+          })
+        },
+      }).render()
+
+      expect(html()).toBe('fallback<!--if--><!--for--><!--slot-->')
+
+      items.value[0].show = true
+      await nextTick()
+      expect(html()).toBe('<span>bar</span><!--if--><!--for--><!--slot-->')
+
+      items.value[0].show = false
+      await nextTick()
+      expect(html()).toBe('fallback<!--if--><!--for--><!--slot-->')
+    })
+
+    test('should not render fallback for a single empty item in v-for', async () => {
+      const Child = {
+        setup() {
+          return createSlot('default', null, () =>
+            document.createTextNode('fallback'),
+          )
+        },
+      }
+
+      const items = ref([
+        { text: 'bar', show: true },
+        { text: 'baz', show: true },
+      ])
+      const { html } = define({
+        setup() {
+          return createComponent(Child, null, {
+            default: () => {
+              return createFor(
+                () => items.value,
+                for_item0 => {
+                  return createIf(
+                    () => for_item0.value.show,
+                    () => {
+                      const n5 = template('<span> </span>')() as any
+                      const x5 = child(n5) as any
+                      renderEffect(() =>
+                        setText(x5, toDisplayString(for_item0.value.text)),
+                      )
+                      return n5
+                    },
+                  )
+                },
+                item => item.text,
+              )
+            },
+          })
+        },
+      }).render()
+
+      expect(html()).toBe(
+        '<span>bar</span><!--if--><span>baz</span><!--if--><!--for--><!--slot-->',
+      )
+
+      items.value[1].show = false
+      await nextTick()
+      expect(html()).toBe(
+        '<span>bar</span><!--if--><!--if--><!--for--><!--slot-->',
+      )
+    })
+
     test('work with v-once', async () => {
       const Child = defineVaporComponent({
         setup() {

+ 338 - 30
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -1146,14 +1146,12 @@ describe('Vapor Mode hydration', () => {
         undefined,
         data,
       )
-      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
-        `"<!--if-->"`,
-      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`"<!---->"`)
 
       data.value = true
       await nextTick()
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
-        `"<div>foo</div><!--if-->"`,
+        `"<div>foo</div><!---->"`,
       )
     })
 
@@ -2969,6 +2967,318 @@ describe('Vapor Mode hydration', () => {
       )
     })
 
+    test('slot fallback from empty v-if branch', async () => {
+      const data = reactive({
+        show: false,
+        fallback: 'foo',
+        slot: 'bar',
+      })
+      const { container } = await testHydration(
+        `<template>
+          <components.Child>
+            <template #default>
+              <template v-if="data.show">
+                <span>{{ data.slot }}</span>
+              </template>
+            </template>
+          </components.Child>
+        </template>`,
+        {
+          Child: `<template><slot><div>{{ data.fallback }}</div></slot></template>`,
+        },
+        data,
+      )
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>foo</div><!--if--><!--]-->
+        "
+      `,
+      )
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+      data.fallback = 'baz'
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>baz</div><!--if--><!--]-->
+        "
+      `,
+      )
+
+      data.show = true
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><span>bar</span><!--if--><!--]-->
+        "
+      `,
+      )
+
+      data.slot = 'qux'
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><span>qux</span><!--if--><!--]-->
+        "
+      `,
+      )
+    })
+
+    test('slot fallback from empty v-for branch', async () => {
+      const data = reactive({
+        items: [] as string[],
+        fallback: 'foo',
+      })
+      const { container } = await testHydration(
+        `<template>
+          <components.Child>
+            <template #default>
+              <span v-for="item in data.items" :key="item">{{ item }}</span>
+            </template>
+          </components.Child>
+        </template>`,
+        {
+          Child: `<template><slot><div>{{ data.fallback }}</div></slot></template>`,
+        },
+        data,
+      )
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>foo</div><!--for--><!--]-->
+        "
+      `,
+      )
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+      data.fallback = 'baz'
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>baz</div><!--for--><!--]-->
+        "
+      `,
+      )
+
+      data.items = ['bar', 'qux']
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><span>bar</span><span>qux</span><!--for--><!--]-->
+        "
+      `,
+      )
+    })
+
+    test('slot content from empty v-for branch with trailing sibling', async () => {
+      const data = reactive({
+        items: [] as string[],
+        tail: 'tail',
+        fallback: 'fallback',
+      })
+      const { container } = await testHydration(
+        `<template>
+          <components.Child>
+            <template #default>
+              <span v-for="item in data.items" :key="item">{{ item }}</span>
+              <i>{{ data.tail }}</i>
+            </template>
+          </components.Child>
+        </template>`,
+        {
+          Child: `<template>
+            <slot><div>{{ data.fallback }}</div></slot>
+          </template>`,
+        },
+        data,
+      )
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[-->
+        <!--[--><!--]-->
+        <!--for--><i>tail</i><!--]-->
+        "
+      `,
+      )
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+      data.items = ['foo', 'bar']
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[-->
+        <!--[--><!--]-->
+        <span>foo</span><span>bar</span><!--for--><i>tail</i><!--]-->
+        "
+      `,
+      )
+
+      data.tail = 'tail2'
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[-->
+        <!--[--><!--]-->
+        <span>foo</span><span>bar</span><!--for--><i>tail2</i><!--]-->
+        "
+      `,
+      )
+    })
+
+    test('slot fallback from invalid v-for branch', async () => {
+      const data = reactive({
+        items: [{ text: 'bar', show: false }],
+        fallback: 'foo',
+      })
+      const { container } = await testHydration(
+        `<template>
+          <components.Child>
+            <template #default>
+              <template v-for="item in data.items" :key="item.text">
+                <template v-if="item.show">
+                  <span>{{ item.text }}</span>
+                </template>
+              </template>
+            </template>
+          </components.Child>
+        </template>`,
+        {
+          Child: `<template><slot><div>{{ data.fallback }}</div></slot></template>`,
+        },
+        data,
+      )
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>foo</div><!--if--><!--for--><!--]-->
+        "
+      `,
+      )
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+      data.fallback = 'baz'
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>baz</div><!--if--><!--for--><!--]-->
+        "
+      `,
+      )
+
+      data.items[0].show = true
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><span>bar</span><!--if--><!--for--><!--]-->
+        "
+      `,
+      )
+
+      data.items[0].show = false
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><div>baz</div><!--if--><!--for--><!--]-->
+        "
+      `,
+      )
+    })
+
+    test('slot content from multi-item invalid v-for branch keeps list order', async () => {
+      const data = reactive({
+        items: [
+          { text: 'a', show: false },
+          { text: 'b', show: false },
+        ],
+        tail: 'tail',
+        fallback: 'fallback',
+      })
+      const { container } = await testHydration(
+        `<template>
+          <components.Child>
+            <template #default>
+              <template v-for="item in data.items" :key="item.text">
+                <template v-if="item.show">
+                  <span>{{ item.text }}</span>
+                </template>
+              </template>
+              <i>{{ data.tail }}</i>
+            </template>
+          </components.Child>
+        </template>`,
+        {
+          Child: `<template>
+            <slot><div>{{ data.fallback }}</div></slot>
+          </template>`,
+        },
+        data,
+      )
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      	"
+      	<!--[-->
+      	<!--[-->
+      	<!--[--><!----><!--]-->
+      	<!--[--><!----><!--]-->
+      	<!--for--><!--]-->
+      	<i>tail</i><!--]-->
+      	"
+      `)
+
+      data.items.push({ text: 'c', show: true })
+      await nextTick()
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+      	"
+      	<!--[-->
+      	<!--[-->
+      	<!--[--><!----><!--]-->
+      	<!--[--><!----><!--]-->
+      	<span>c</span><!--if--><!--for--><!--]-->
+      	<i>tail</i><!--]-->
+      	"
+      `,
+      )
+
+      data.items[1].show = true
+      await nextTick()
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[-->
+        <!--[-->
+        <!--[--><!----><!--]-->
+        <!--[--><span>b</span><!----><!--]-->
+        <span>c</span><!--if--><!--for--><!--]-->
+        <i>tail</i><!--]-->
+        "
+      `,
+      )
+    })
+
     test('forwarded slot', async () => {
       const data = reactive({
         foo: 'foo',
@@ -3243,7 +3553,7 @@ describe('Vapor Mode hydration', () => {
         data,
       )
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
-        `"<!--slot--><!--if-->"`,
+        `"<!----><!--if-->"`,
       )
       expect(`mismatch`).not.toHaveBeenWarned()
 
@@ -3265,9 +3575,7 @@ describe('Vapor Mode hydration', () => {
         undefined,
         data,
       )
-      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
-        `"<!--if-->"`,
-      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`"<!---->"`)
       expect(`mismatch`).not.toHaveBeenWarned()
     })
 
@@ -7088,7 +7396,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><div>foo</div><!--]-->
+      <!--[--><div>foo</div><!--if--><!--]-->
       "
     `,
     )
@@ -7100,7 +7408,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><div>baz</div><!--]-->
+      <!--[--><div>baz</div><!--if--><!--]-->
       "
     `,
     )
@@ -7110,7 +7418,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><span>bar</span><!--]-->
+      <!--[--><span>bar</span><!--if--><!--]-->
       "
     `,
     )
@@ -7120,7 +7428,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><span>qux</span><!--]-->
+      <!--[--><span>qux</span><!--if--><!--]-->
       "
     `,
     )
@@ -7234,7 +7542,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
         "
-        <!--[--><div>foo</div><p>bar</p><!--]-->
+        <!--[--><div>foo</div><p>bar</p><!--if--><!--]-->
         "
       `,
     )
@@ -7247,7 +7555,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
         "
-        <!--[--><div>qux</div><p>quux</p><!--]-->
+        <!--[--><div>qux</div><p>quux</p><!--if--><!--]-->
         "
       `,
     )
@@ -7257,7 +7565,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
         "
-        <!--[--><span>baz</span><!--]-->
+        <!--[--><span>baz</span><!--if--><!--]-->
         "
       `,
     )
@@ -7314,7 +7622,7 @@ describe('VDOM interop', () => {
       `
         "
         <!--[-->
-        <!--[--><div>foo</div><!----><!--]-->
+        <!--[--><div>foo</div><!----><!--if--><!--]-->
         <i>tail</i><!--]-->
         "
       `,
@@ -7326,7 +7634,7 @@ describe('VDOM interop', () => {
       `
         "
         <!--[-->
-        <!--[--><div>foo</div><p>bar</p><!--]-->
+        <!--[--><div>foo</div><!--if--><p>bar</p><!--]-->
         <i>tail</i><!--]-->
         "
       `,
@@ -7946,9 +8254,9 @@ describe('VDOM interop', () => {
     expect(`Hydration node mismatch`).not.toHaveBeenWarned()
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
-      "
-      <!--[--><div>Before</div><!--if--><span>Tail</span><!--]-->
-      "
+    	"
+    	<!--[--><div>Before</div><!----><span>Tail</span><!--]-->
+    	"
     `,
     )
 
@@ -7959,9 +8267,9 @@ describe('VDOM interop', () => {
 
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
-      "
-      <!--[--><div>Before</div><span>Updated</span><span>Updated</span><!--if--><span>Tail updated</span><!--]-->
-      "
+    	"
+    	<!--[--><div>Before</div><span>Updated</span><span>Updated</span><!----><span>Tail updated</span><!--]-->
+    	"
     `,
     )
 
@@ -7970,9 +8278,9 @@ describe('VDOM interop', () => {
 
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
-      "
-      <!--[--><div>Before</div><!--if--><span>Tail updated</span><!--]-->
-      "
+    	"
+    	<!--[--><div>Before</div><!----><span>Tail updated</span><!--]-->
+    	"
     `,
     )
   })
@@ -8168,7 +8476,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><div>foo</div><!--]-->
+      <!--[--><div>foo</div><!--if--><!--]-->
       "
     `,
     )
@@ -8180,7 +8488,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><div>baz</div><!--]-->
+      <!--[--><div>baz</div><!--if--><!--]-->
       "
     `,
     )
@@ -8190,7 +8498,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><span>bar</span><!--]-->
+      <!--[--><span>bar</span><!--if--><!--]-->
       "
     `,
     )
@@ -8200,7 +8508,7 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
       `
       "
-      <!--[--><span>qux</span><!--]-->
+      <!--[--><span>qux</span><!--if--><!--]-->
       "
     `,
     )

+ 48 - 42
packages/runtime-vapor/src/apiCreateFor.ts

@@ -13,7 +13,7 @@ import {
 } from '@vue/reactivity'
 import { isArray, isObject, isString } from '@vue/shared'
 import { createComment, createTextNode } from './dom/node'
-import { type Block, insert, remove } from './block'
+import { type Block, insert, isValidBlock, remove } from './block'
 import { queuePostFlushCb, warn } from '@vue/runtime-dom'
 import { currentInstance, isVaporComponent } from './component'
 import {
@@ -32,7 +32,12 @@ import {
   locateNextNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
-import { ForFragment, VaporFragment } from './fragment'
+import {
+  ForBlock,
+  ForFragment,
+  currentEmptyFragment,
+  currentSlotEndAnchor,
+} from './fragment'
 import {
   type ChildItem,
   insertionAnchor,
@@ -43,34 +48,6 @@ import {
 } from './insertionState'
 import { applyTransitionHooks } from './transition'
 
-class ForBlock extends VaporFragment {
-  scope: EffectScope | undefined
-  key: any
-  prev?: ForBlock
-  next?: ForBlock
-  prevAnchor?: ForBlock
-
-  itemRef: ShallowRef<any>
-  keyRef: ShallowRef<any> | undefined
-  indexRef: ShallowRef<number | undefined> | undefined
-
-  constructor(
-    nodes: Block,
-    scope: EffectScope | undefined,
-    item: ShallowRef<any>,
-    key: ShallowRef<any> | undefined,
-    index: ShallowRef<number | undefined> | undefined,
-    renderKey: any,
-  ) {
-    super(nodes)
-    this.scope = scope
-    this.itemRef = item
-    this.keyRef = key
-    this.indexRef = index
-    this.key = renderKey
-  }
-}
-
 type Source = any[] | Record<any, any> | number | Set<any> | Map<any, any>
 
 type ResolvedSource = {
@@ -110,6 +87,7 @@ export const createFor = (
   // createSelector only
   let currentKey: any
   let parentAnchor: Node
+  let pendingHydrationAnchor = false
   if (!isHydrating) {
     parentAnchor = __DEV__ ? createComment('for') : createTextNode()
   }
@@ -141,6 +119,7 @@ export const createFor = (
     if (!isMounted) {
       isMounted = true
       let nextNode
+      const hydrationStart = isHydrating ? currentHydrationNode : null
       for (let i = 0; i < newLength; i++) {
         if (isHydrating) nextNode = locateNextNode(currentHydrationNode!)
         mount(source, i)
@@ -148,21 +127,48 @@ export const createFor = (
       }
 
       if (isHydrating) {
-        parentAnchor = currentHydrationNode!
+        // 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 (
-          __DEV__ &&
-          (!parentAnchor || (parentAnchor && !isComment(parentAnchor, ']')))
+          currentEmptyFragment !== undefined &&
+          !isValidBlock(newBlocks) &&
+          currentSlotEndAnchor
         ) {
-          throw new Error(
-            `v-for fragment anchor node was not found. this is likely a Vue internal bug.`,
+          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),
           )
-        }
+        } 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.`,
+            )
+          }
 
-        // 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
+          // 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
+          }
         }
       }
     } else {
@@ -504,7 +510,7 @@ export const createFor = (
 
   if (!isHydrating) {
     if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
-  } else {
+  } else if (!pendingHydrationAnchor) {
     advanceHydrationNode(_isLastInsertion ? _insertionParent! : parentAnchor!)
   }
 

+ 171 - 116
packages/runtime-vapor/src/fragment.ts

@@ -1,4 +1,4 @@
-import { EffectScope, setActiveSub } from '@vue/reactivity'
+import { EffectScope, type ShallowRef, setActiveSub } from '@vue/reactivity'
 import { createComment, createTextNode } from './dom/node'
 import {
   type Block,
@@ -30,7 +30,9 @@ import {
   currentHydrationNode,
   isComment,
   isHydrating,
+  locateEndAnchor,
   locateHydrationNode,
+  setCurrentHydrationNode,
 } from './dom/hydration'
 import { isArray } from '@vue/shared'
 import { renderEffect } from './renderEffect'
@@ -97,17 +99,43 @@ export class ForFragment extends VaporFragment<Block[]> {
   }
 }
 
+export class ForBlock extends VaporFragment {
+  scope: EffectScope | undefined
+  key: any
+  prev?: ForBlock
+  next?: ForBlock
+  prevAnchor?: ForBlock
+
+  itemRef: ShallowRef<any>
+  keyRef: ShallowRef<any> | undefined
+  indexRef: ShallowRef<number | undefined> | undefined
+
+  constructor(
+    nodes: Block,
+    scope: EffectScope | undefined,
+    item: ShallowRef<any>,
+    key: ShallowRef<any> | undefined,
+    index: ShallowRef<number | undefined> | undefined,
+    renderKey: any,
+  ) {
+    super(nodes)
+    this.scope = scope
+    this.itemRef = item
+    this.keyRef = key
+    this.indexRef = index
+    this.key = renderKey
+  }
+}
+
 export class DynamicFragment extends VaporFragment {
   // @ts-expect-error - assigned in hydrate()
   anchor: Node
+  isAnchorPending?: boolean
   scope: EffectScope | undefined
   current?: BlockFn
   pending?: { render?: BlockFn; key: any }
   anchorLabel?: string
   keyed?: boolean
-  // When slot content hydrates as empty while the surrounding slot is already
-  // using fallback DOM, reuse the parent's closing fragment anchor.
-  deferredToFallback?: boolean
 
   // fallthrough attrs
   attrs?: Record<string, any>
@@ -132,7 +160,7 @@ export class DynamicFragment extends VaporFragment {
     if (key === this.current) {
       // On initial hydration, `key === current` means `render` is empty,
       // so this fragment hydrates as empty content.
-      if (isHydrating) this.hydrate(true)
+      if (isHydrating && this.anchorLabel !== 'slot') this.hydrate(true)
       return
     }
 
@@ -214,7 +242,9 @@ export class DynamicFragment extends VaporFragment {
     this.renderBranch(render, transition, parent, key)
     setActiveSub(prevSub)
 
-    if (isHydrating) this.hydrate(render == null)
+    if (isHydrating && this.anchorLabel !== 'slot') {
+      this.hydrate(render == null)
+    }
   }
 
   renderBranch(
@@ -303,12 +333,14 @@ export class DynamicFragment extends VaporFragment {
     }
   }
 
-  hydrate = (isEmpty = false): void => {
+  hydrate = (isEmpty = false, isSlot = false): void => {
     // early return allows tree-shaking of hydration logic when not used
     if (!isHydrating) return
 
-    // avoid repeated hydration
-    if (this.anchor) return
+    // Slot fallback can fall through to an inner empty `v-if` / `v-for`.
+    // When fallback runs during hydration, the same fragment can still
+    // re-enter `hydrate()` after its empty branch has already hydrated once.
+    if (this.isAnchorPending) return
 
     // reuse `<!---->` as anchor
     // `<div v-if="false"></div>` -> `<!---->`
@@ -316,63 +348,48 @@ export class DynamicFragment extends VaporFragment {
       if (isComment(currentHydrationNode!, '')) {
         this.anchor = currentHydrationNode
         advanceHydrationNode(currentHydrationNode)
-        if (__DEV__) {
-          ;(this.anchor as Comment).data = this.anchorLabel!
-        }
         return
       }
     }
 
-    const forwardedSlot = (this as any as SlotFragment).forwarded
-    const slotHasFallback = (this as any as SlotFragment).hasFallback
-    const slotContext = currentSlotContext
-    const hydratingSlotFallback =
-      slotContext !== null && slotContext.hydratingFallback && !forwardedSlot
-    const inSlotFallback =
-      slotContext !== null && slotContext.phase === 'fallback-render'
-    let isValidSlot = false
-    if ((forwardedSlot || hydratingSlotFallback) && !isEmpty) {
-      isValidSlot = isValidBlock(this.nodes)
-    }
-    // When the current slot hydrates against fallback DOM, defer anchor
-    // creation so renderSlotFallback → frag.update(fallback) can re-enter
-    // hydrate() after the fallback content is hydrated.
-    if (
-      ((forwardedSlot && slotHasFallback) || hydratingSlotFallback) &&
-      (isEmpty || !isValidSlot)
-    ) {
-      if (hydratingSlotFallback) {
-        this.deferredToFallback = true
+    // 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 = __DEV__
+              ? createComment(this.anchorLabel!)
+              : createTextNode()),
+            endAnchor,
+          ),
+        )
+        return
       }
-      return
-    }
-
-    if (this.deferredToFallback && isComment(currentHydrationNode!, ']')) {
-      this.anchor = currentHydrationNode
-      this.deferredToFallback = false
-      return
     }
 
+    const forwardedSlot = (this as any as SlotFragment).forwarded
+    const slotAnchor = isSlot ? currentSlotEndAnchor : null
     // Reuse SSR `<!--]-->` as anchor.
-    // SSR always wraps slot content with `<!--[-->...<!--]-->`, so any slot
-    // with content has a matching end anchor we can reuse.
-    //
-    // For forwarded slots, two additional conditions must hold:
-    //   1. isValidSlot — the forwarded slot rendered actual content
-    //   2. !inSlotFallback — the content came from the slot's own render,
-    //      not from a fallback re-entry. During fallback re-entry, the
-    //      `<!--]-->` at the cursor belongs to the outer (non-forwarded)
-    //      slot, not this forwarded one.
-    //
-    // Multi-root `v-if` also gets `<!--[-->...<!--]-->` from SSR.
+    // 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 (
-      (this.anchorLabel === 'slot' &&
-        (!forwardedSlot || (isValidSlot && !inSlotFallback))) ||
-      (this.anchorLabel === 'if' && isArray(this.nodes))
+      (isSlot && (!forwardedSlot || isValidBlock(this.nodes))) ||
+      (this.anchorLabel === 'if' &&
+        isArray(this.nodes) &&
+        this.nodes.length > 1)
     ) {
-      if (isComment(currentHydrationNode!, ']')) {
-        this.anchor = currentHydrationNode
-        advanceHydrationNode(currentHydrationNode)
+      const anchor = slotAnchor || currentHydrationNode
+      if (isComment(anchor!, ']')) {
+        this.anchor = anchor
+        advanceHydrationNode(anchor)
         return
       } else if (__DEV__) {
         throw new Error(
@@ -387,13 +404,17 @@ export class DynamicFragment extends VaporFragment {
     let parentNode: Node | null
     let nextNode: Node | null
     if (forwardedSlot) {
-      parentNode = currentHydrationNode!.parentNode
-      nextNode = currentHydrationNode!.nextSibling
+      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 = __DEV__
@@ -405,39 +426,32 @@ export class DynamicFragment extends VaporFragment {
   }
 }
 
-type SlotContextPhase = 'render' | 'fallback-render'
-
-type SlotContext = {
-  phase: SlotContextPhase
-  hydratingFallback: boolean
-}
-
-let currentSlotContext: SlotContext | null = null
-
-function runWithSlotContext<R>(
-  phase: SlotContextPhase,
-  hydratingFallback: boolean,
-  fn: () => R,
-): R {
-  const prev = currentSlotContext
-  currentSlotContext = {
-    phase,
-    hydratingFallback,
-  }
+export let currentSlotEndAnchor: Node | null = null
+function setCurrentSlotEndAnchor(end: Node | null): Node | null {
   try {
-    return fn()
+    return currentSlotEndAnchor
   } finally {
-    currentSlotContext = prev
+    currentSlotEndAnchor = end
   }
 }
 
+// Tracks slot fallback hydration that falls through an inner empty fragment,
+// e.g.
+// - `<slot><template v-if="false" /></slot>`
+// - `<slot><span v-for="item in items" /></slot>`.
+// We need this because the inner empty fragment can hydrate before slot render
+// finishes and before we know whether fallback will ultimately land on it.
+// - `undefined` means the current hydration is not resolving slot fallback.
+// - `null` means slot render is in progress and fallback may land on an empty
+//   fragment, but the target fragment is not known yet. Empty `v-for` only
+//   needs this phase marker so it can create its own runtime anchor instead of
+//   expecting one from SSR.
+// - A DynamicFragment value means fallback resolves through that fragment, so it
+//   must create its own anchor instead of reusing the slot end anchor.
+export let currentEmptyFragment: DynamicFragment | null | undefined
+
 export class SlotFragment extends DynamicFragment {
   forwarded = false
-  // Hydrating forwarded slots need to remember whether an outer slot can
-  // fall back so empty forwarded content defers anchor creation.
-  hasFallback = false
-  // Interop slots can hydrate directly against fallback DOM.
-  hydrateWithFallback = false
 
   constructor() {
     super(isHydrating || __DEV__ ? 'slot' : undefined, false, false)
@@ -448,31 +462,58 @@ export class SlotFragment extends DynamicFragment {
     fallback?: BlockFn,
     key: any = render || fallback,
   ): void {
+    let prevEndAnchor: Node | null = null
+    let pushedEndAnchor = false
     if (isHydrating) {
-      locateHydrationNode(true)
-      if (this.forwarded) {
-        this.hasFallback = currentSlotContext !== null
+      locateHydrationNode()
+      if (isComment(currentHydrationNode!, '[')) {
+        const endAnchor = locateEndAnchor(currentHydrationNode)
+        setCurrentHydrationNode(currentHydrationNode.nextSibling)
+        prevEndAnchor = setCurrentSlotEndAnchor(endAnchor)
+        pushedEndAnchor = true
       }
     }
 
-    if (!render || !fallback) {
-      this.update(render || fallback, key)
-      return
-    }
+    try {
+      if (!render || !fallback) {
+        this.update(render || fallback, key)
+      } else {
+        const wrapped = () => {
+          const prev = currentEmptyFragment
+          if (isHydrating) currentEmptyFragment = null
+          try {
+            let block = render()
+            const emptyFrag = attachSlotFallback(block, fallback)
+            if (!isValidBlock(block)) {
+              if (isHydrating && emptyFrag instanceof DynamicFragment) {
+                currentEmptyFragment = emptyFrag
+              }
+              block = renderSlotFallback(block, fallback, emptyFrag)
+            }
+            return block
+          } finally {
+            if (isHydrating) currentEmptyFragment = prev
+          }
+        }
 
-    const wrapped = () => {
-      const hydratingFallback =
-        this.hydrateWithFallback ||
-        (currentSlotContext !== null && currentSlotContext.hydratingFallback)
-      const block = runWithSlotContext('render', hydratingFallback, render)
-      const emptyFrag = attachSlotFallback(block, fallback)
-      if (!isValidBlock(block)) {
-        return renderSlotFallback(block, fallback, emptyFrag)
+        this.update(wrapped, key)
       }
-      return block
-    }
 
-    this.update(wrapped, key)
+      // Slot render and slot fallback can both trigger DynamicFragment
+      // hydrate that tries to reuse the current SSR end anchor. Hydrating
+      // the slot before render/fallback resolution finishes can make the
+      // slot and inner fallback carrier compete for the same `<!--]-->`, or
+      // place synthetic anchors like `<!--if-->` at the wrong position.
+      // Wait until render/fallback has fully resolved, then hydrate the slot
+      // once against the final block.
+      if (isHydrating) {
+        this.hydrate(render == null, true)
+      }
+    } finally {
+      if (isHydrating && pushedEndAnchor) {
+        setCurrentSlotEndAnchor(prevEndAnchor)
+      }
+    }
   }
 }
 
@@ -498,12 +539,7 @@ export function renderSlotFallback(
     if (frag instanceof ForFragment) {
       frag.nodes[0] = [fallback() || []] as Block[]
     } else if (frag instanceof DynamicFragment) {
-      const hydratingFallback =
-        currentSlotContext !== null && currentSlotContext.hydratingFallback
-      return runWithSlotContext('fallback-render', hydratingFallback, () => {
-        frag.update(fallback)
-        return block
-      })
+      frag.update(fallback)
     }
     return block
   }
@@ -525,7 +561,7 @@ export function attachSlotFallback(
 // 2) allow fallback to be chained/updated as slot fallback propagates through nested fragments.
 const slotFallbackState = new WeakMap<
   DynamicFragment,
-  { fallback: BlockFn; wrapped: boolean }
+  { fallback: BlockFn; wrapped: boolean; forOwner?: ForFragment }
 >()
 
 // Slot fallback needs to propagate into nested fragments created by v-if/v-for.
@@ -536,14 +572,16 @@ function traverseForFallback(
   block: Block,
   fallback: BlockFn,
   state: { emptyFrag: VaporFragment | null },
+  forOwner?: ForFragment,
 ): void {
   if (isVaporComponent(block)) {
-    if (block.block) traverseForFallback(block.block, fallback, state)
+    if (block.block) traverseForFallback(block.block, fallback, state, forOwner)
     return
   }
 
   if (isArray(block)) {
-    for (const item of block) traverseForFallback(item, fallback, state)
+    for (const item of block)
+      traverseForFallback(item, fallback, state, forOwner)
     return
   }
 
@@ -551,7 +589,16 @@ function traverseForFallback(
   if (block instanceof ForFragment) {
     block.fallback = chainFallback(block.fallback, fallback)
     if (!isValidBlock(block.nodes)) state.emptyFrag = block
-    traverseForFallback(block.nodes, fallback, state)
+    traverseForFallback(block.nodes, fallback, state, block)
+    return
+  }
+
+  // Recurse into per-item ForBlock so slot fallback can keep propagating to
+  // nested DynamicFragments inside each list item. Gate those updates on the
+  // owning `v-for` so a single empty item does not render slot fallback while
+  // the list still has valid content.
+  if (block instanceof ForBlock) {
+    traverseForFallback(block.nodes, fallback, state, forOwner)
     return
   }
 
@@ -559,7 +606,7 @@ function traverseForFallback(
   if (block instanceof VaporFragment && block.insert) {
     block.fallback = chainFallback(block.fallback, fallback)
     if (!isValidBlock(block.nodes)) state.emptyFrag = block
-    traverseForFallback(block.nodes, fallback, state)
+    traverseForFallback(block.nodes, fallback, state, forOwner)
     return
   }
 
@@ -569,8 +616,12 @@ function traverseForFallback(
     if (slotState) {
       slotState.fallback = chainFallback(slotState.fallback, fallback)
     } else {
-      slotFallbackState.set(block, (slotState = { fallback, wrapped: false }))
+      slotFallbackState.set(
+        block,
+        (slotState = { fallback, wrapped: false, forOwner }),
+      )
     }
+    slotState.forOwner = forOwner || slotState.forOwner
     if (!slotState.wrapped) {
       slotState.wrapped = true
       const original = block.update.bind(block)
@@ -578,13 +629,17 @@ function traverseForFallback(
         original(render, key)
         // attach to newly created nested fragments
         const emptyFrag = attachSlotFallback(block.nodes, slotState!.fallback)
-        if (render !== slotState!.fallback && !isValidBlock(block.nodes)) {
+        if (
+          render !== slotState!.fallback &&
+          !isValidBlock(block.nodes) &&
+          (!slotState!.forOwner || !isValidBlock(slotState!.forOwner.nodes))
+        ) {
           renderSlotFallback(block, slotState!.fallback, emptyFrag)
         }
       }
     }
     if (!isValidBlock(block.nodes)) state.emptyFrag = block
-    traverseForFallback(block.nodes, fallback, state)
+    traverseForFallback(block.nodes, fallback, state, forOwner)
   }
 }
 

+ 0 - 1
packages/runtime-vapor/src/vdomInterop.ts

@@ -1245,7 +1245,6 @@ function renderVaporSlot(
     const { fallback } = vnode.vs!
     if (isHydrating && fallback) {
       const frag = new SlotFragment()
-      frag.hydrateWithFallback = true
       frag.updateSlot(
         () => invokeVaporSlot(vnode),
         createVaporFallback(fallback, parentComponent),