Kaynağa Gözat

fix(runtime-vapor): hydrate interop slot fallbacks with correct slot context

daiwei 3 hafta önce
ebeveyn
işleme
62001b8117

+ 6 - 1
packages/runtime-core/src/apiCreateApp.ts

@@ -219,7 +219,12 @@ export interface VaporInteropInterface {
     onBeforeMount?: () => void,
     onVnodeBeforeMount?: () => void,
   ): Node
-  hydrateSlot(vnode: VNode, node: any): Node
+  hydrateSlot(
+    vnode: VNode,
+    node: any,
+    parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
+  ): Node
   activate(
     vnode: VNode,
     container: any,

+ 2 - 0
packages/runtime-core/src/hydration.ts

@@ -285,6 +285,8 @@ export function createHydrationFunctions(
         nextNode = getVaporInterface(parentComponent, vnode).hydrateSlot(
           vnode,
           node,
+          parentComponent,
+          parentSuspense,
         )
         break
       default:

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

@@ -6921,6 +6921,397 @@ describe('VDOM interop', () => {
     )
   })
 
+  test('hydrate interop vapor slot fallback', async () => {
+    const data = reactive({
+      text: 'foo',
+    })
+    const { container } = await testWithVDOMApp(
+      `<script setup>
+        const components = _components
+      </script>
+      <template>
+        <components.VaporChild />
+      </template>`,
+      {
+        VaporChild: {
+          code: `<template><components.VdomChild /></template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<script setup>const data = _data</script>
+          <template><slot><span>{{ data.text }}</span></slot></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><span>foo</span><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.text = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><span>bar</span><!--]-->
+      "
+    `,
+    )
+  })
+
+  test('hydrate interop vapor slot fallback from empty slot branch', async () => {
+    const data = reactive({
+      show: false,
+      fallback: 'foo',
+      slot: 'bar',
+    })
+    const { container } = await testWithVDOMApp(
+      `<script setup>
+        const components = _components
+      </script>
+      <template>
+        <components.VaporChild />
+      </template>`,
+      {
+        VaporChild: {
+          code: `<script setup>
+            const data = _data
+            const components = _components
+          </script>
+          <template>
+            <components.VdomChild>
+              <template #default>
+                <template v-if="data.show">
+                  <span>{{ data.slot }}</span>
+                </template>
+              </template>
+            </components.VdomChild>
+          </template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<script setup>const data = _data</script>
+          <template><slot><div>{{ data.fallback }}</div></slot></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>foo</div><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.fallback = 'baz'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>baz</div><!--]-->
+      "
+    `,
+    )
+
+    data.show = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><span>bar</span><!--]-->
+      "
+    `,
+    )
+
+    data.slot = 'qux'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><span>qux</span><!--]-->
+      "
+    `,
+    )
+  })
+
+  test('hydrate interop vapor slot with fallback should preserve valid slot branches', async () => {
+    const data = reactive({
+      slot: 'bar',
+    })
+    const { container } = await testWithVDOMApp(
+      `<script setup>
+          const components = _components
+        </script>
+        <template>
+          <components.VaporChild />
+        </template>`,
+      {
+        VaporChild: {
+          code: `<script setup>
+              const data = _data
+              const components = _components
+            </script>
+            <template>
+              <components.VdomChild>
+                <template #default>
+                  <div>
+                    <template v-if="false">
+                      <i>unused</i>
+                    </template>
+                    <span>{{ data.slot }}</span>
+                  </div>
+                </template>
+              </components.VdomChild>
+            </template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<template><slot><p>fallback</p></slot></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><div><!----><span>bar</span></div><!--]-->
+        "
+      `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.slot = 'baz'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><div><!----><span>baz</span></div><!--]-->
+        "
+      `,
+    )
+  })
+
+  test('hydrate interop vapor multi-root slot fallback from empty slot branch', async () => {
+    const data = reactive({
+      show: false,
+      fallbackA: 'foo',
+      fallbackB: 'bar',
+      slot: 'baz',
+    })
+    const { container } = await testWithVDOMApp(
+      `<script setup>
+          const components = _components
+        </script>
+        <template>
+          <components.VaporChild />
+        </template>`,
+      {
+        VaporChild: {
+          code: `<script setup>
+              const data = _data
+              const components = _components
+            </script>
+            <template>
+              <components.VdomChild>
+                <template #default>
+                  <template v-if="data.show">
+                    <span>{{ data.slot }}</span>
+                  </template>
+                </template>
+              </components.VdomChild>
+            </template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<script setup>const data = _data</script>
+            <template>
+              <slot>
+                <div>{{ data.fallbackA }}</div>
+                <p>{{ data.fallbackB }}</p>
+              </slot>
+            </template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><div>foo</div><p>bar</p><!--]-->
+        "
+      `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.fallbackA = 'qux'
+    data.fallbackB = 'quux'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><div>qux</div><p>quux</p><!--]-->
+        "
+      `,
+    )
+
+    data.show = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><span>baz</span><!--]-->
+        "
+      `,
+    )
+  })
+
+  test('hydrate interop vapor multi-root slot fallback should preserve slot anchor on updates', async () => {
+    const data = reactive({
+      show: false,
+      extra: false,
+      fallbackA: 'foo',
+      fallbackB: 'bar',
+      tail: 'tail',
+    })
+    const { container } = await testWithVDOMApp(
+      `<script setup>
+          const components = _components
+        </script>
+        <template>
+          <components.VaporChild />
+        </template>`,
+      {
+        VaporChild: {
+          code: `<script setup>
+              const data = _data
+              const components = _components
+            </script>
+            <template>
+              <components.VdomChild>
+                <template #default>
+                  <template v-if="data.show">
+                    <span>slot</span>
+                  </template>
+                </template>
+              </components.VdomChild>
+            </template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<script setup>const data = _data</script>
+            <template>
+              <slot>
+                <div>{{ data.fallbackA }}</div>
+                <p v-if="data.extra">{{ data.fallbackB }}</p>
+              </slot>
+              <i>{{ data.tail }}</i>
+            </template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[-->
+        <!--[--><div>foo</div><!----><!--]-->
+        <i>tail</i><!--]-->
+        "
+      `,
+    )
+
+    data.extra = true
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[-->
+        <!--[--><div>foo</div><p>bar</p><!--]-->
+        <i>tail</i><!--]-->
+        "
+      `,
+    )
+  })
+
+  test('hydrate interop vapor slot fallback should preserve nested forwarded slots', async () => {
+    const data = reactive({
+      fallback: 'foo',
+    })
+    const { container } = await testWithVDOMApp(
+      `<script setup>
+          const components = _components
+        </script>
+        <template>
+          <components.VaporChild />
+        </template>`,
+      {
+        VaporChild: {
+          code: `<template><components.VdomChild /></template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<template><slot><components.Forwarder /></slot></template>`,
+          vapor: false,
+        },
+        Forwarder: {
+          code: `<template><components.Receiver><slot /></components.Receiver></template>`,
+          vapor: true,
+        },
+        Receiver: {
+          code: `<script setup>const data = _data</script>
+            <template><div><slot>{{ data.fallback }}</slot></div></template>`,
+          vapor: true,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><div>
+        <!--[-->foo<!--]-->
+        <!--slot--></div><!--]-->
+        "
+      `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.fallback = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+        "
+        <!--[--><div>
+        <!--[-->bar<!--]-->
+        <!--slot--></div><!--]-->
+        "
+      `,
+    )
+  })
+
   test('hydrate VDOM component returning Fragment', async () => {
     const data = ref('foo')
     const { container } = await testWithVaporApp(

+ 67 - 25
packages/runtime-vapor/src/fragment.ts

@@ -105,6 +105,9 @@ export class DynamicFragment extends VaporFragment {
   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>
@@ -321,15 +324,32 @@ export class DynamicFragment extends VaporFragment {
     }
 
     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
-    // Empty forwarded slot with a fallback: defer anchor creation —
-    // renderSlotFallback → frag.update(fallback) will re-enter hydrate()
-    // after the fallback content is hydrated.
+    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 &&
-      (isEmpty || !(isValidSlot = isValidBlock(this.nodes))) &&
-      (this as any as SlotFragment).hasFallback
+      ((forwardedSlot && slotHasFallback) || hydratingSlotFallback) &&
+      (isEmpty || !isValidSlot)
     ) {
+      if (hydratingSlotFallback) {
+        this.deferredToFallback = true
+      }
+      return
+    }
+
+    if (this.deferredToFallback && isComment(currentHydrationNode!, ']')) {
+      this.anchor = currentHydrationNode
+      this.deferredToFallback = false
       return
     }
 
@@ -339,7 +359,7 @@ export class DynamicFragment extends VaporFragment {
     //
     // For forwarded slots, two additional conditions must hold:
     //   1. isValidSlot — the forwarded slot rendered actual content
-    //   2. !isInSlotFallback — the content came from the slot's own render,
+    //   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.
@@ -347,7 +367,7 @@ export class DynamicFragment extends VaporFragment {
     // Multi-root `v-if` also gets `<!--[-->...<!--]-->` from SSR.
     if (
       (this.anchorLabel === 'slot' &&
-        (!forwardedSlot || (isValidSlot && !isInSlotFallback))) ||
+        (!forwardedSlot || (isValidSlot && !inSlotFallback))) ||
       (this.anchorLabel === 'if' && isArray(this.nodes))
     ) {
       if (isComment(currentHydrationNode!, ']')) {
@@ -385,12 +405,39 @@ export class DynamicFragment extends VaporFragment {
   }
 }
 
-let currentSlotHasFallback = false
-let isInSlotFallback = false
+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,
+  }
+  try {
+    return fn()
+  } finally {
+    currentSlotContext = prev
+  }
+}
 
 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)
@@ -404,7 +451,7 @@ export class SlotFragment extends DynamicFragment {
     if (isHydrating) {
       locateHydrationNode(true)
       if (this.forwarded) {
-        this.hasFallback = currentSlotHasFallback
+        this.hasFallback = currentSlotContext !== null
       }
     }
 
@@ -414,14 +461,10 @@ export class SlotFragment extends DynamicFragment {
     }
 
     const wrapped = () => {
-      const prev = currentSlotHasFallback
-      currentSlotHasFallback = true
-      let block: Block
-      try {
-        block = render()
-      } finally {
-        currentSlotHasFallback = prev
-      }
+      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)
@@ -455,13 +498,12 @@ export function renderSlotFallback(
     if (frag instanceof ForFragment) {
       frag.nodes[0] = [fallback() || []] as Block[]
     } else if (frag instanceof DynamicFragment) {
-      const prev = isInSlotFallback
-      isInSlotFallback = true
-      try {
+      const hydratingFallback =
+        currentSlotContext !== null && currentSlotContext.hydratingFallback
+      return runWithSlotContext('fallback-render', hydratingFallback, () => {
         frag.update(fallback)
-      } finally {
-        isInSlotFallback = prev
-      }
+        return block
+      })
     }
     return block
   }

+ 146 - 18
packages/runtime-vapor/src/vdomInterop.ts

@@ -96,6 +96,7 @@ import {
   hydrateNode as vaporHydrateNode,
 } from './dom/hydration'
 import {
+  SlotFragment,
   VaporFragment,
   attachSlotFallback,
   isFragment,
@@ -368,11 +369,14 @@ const vaporInteropImpl: Omit<
     return _next(node)
   },
 
-  hydrateSlot(vnode, node) {
+  hydrateSlot(vnode, node, parentComponent, parentSuspense) {
     if (!isHydrating && !isVdomHydrating) return node
     vaporHydrateNode(node, () => {
-      vnode.vb = invokeVaporSlot(vnode)
-      vnode.anchor = vnode.el = currentHydrationNode!
+      vnode.vb = renderVaporSlot(vnode, parentComponent, parentSuspense)
+      vnode.anchor = vnode.el =
+        isFragment(vnode.vb) && vnode.vb.anchor
+          ? vnode.vb.anchor
+          : currentHydrationNode!
 
       if (__DEV__ && !vnode.anchor) {
         throw new Error(
@@ -1193,7 +1197,13 @@ function createVaporFallback(
   parentComponent: ComponentInternalInstance | null,
 ): BlockFn {
   const internals = ensureRenderer().internals
-  return () => createFallback(fallback)(internals, parentComponent)
+  return () => {
+    const block = createFallback(fallback)(internals, parentComponent)
+    if (isHydrating && isFragment(block) && block.hydrate) {
+      block.hydrate()
+    }
+    return block
+  }
 }
 
 const createFallback =
@@ -1206,18 +1216,11 @@ const createFallback =
 
     // vnode content, wrap it as a VaporFragment
     if (isArray(fallbackNodes) && fallbackNodes.every(isVNode)) {
-      const frag = new VaporFragment([])
-      frag.insert = (parentNode, anchor) => {
-        fallbackNodes.forEach(vnode => {
-          internals.p(null, vnode, parentNode, anchor, parentComponent)
-        })
-      }
-      frag.remove = parentNode => {
-        fallbackNodes.forEach(vnode => {
-          internals.um(vnode, parentComponent, null, true)
-        })
-      }
-      return frag
+      return createVNodeChildrenFragment(
+        internals,
+        () => fallback() as VNode[],
+        parentComponent,
+      )
     }
 
     // vapor block
@@ -1237,12 +1240,25 @@ function renderVaporSlot(
   }
   try {
     const { fallback } = vnode.vs!
-    let slotBlock = invokeVaporSlot(vnode)
+    if (isHydrating && fallback) {
+      const frag = new SlotFragment()
+      frag.hydrateWithFallback = true
+      frag.updateSlot(
+        () => invokeVaporSlot(vnode),
+        createVaporFallback(fallback, parentComponent),
+      )
+      return frag
+    }
+
     if (!fallback) {
-      return slotBlock
+      return invokeVaporSlot(vnode)
     }
 
+    let slotBlock = invokeVaporSlot(vnode)
     const vaporFallback = createVaporFallback(fallback, parentComponent)
+    if (!slotBlock) {
+      return vaporFallback()
+    }
     const emptyFrag = attachSlotFallback(slotBlock, vaporFallback)
     if (!isValidBlock(slotBlock)) {
       slotBlock = renderSlotFallback(slotBlock, vaporFallback, emptyFrag)
@@ -1350,3 +1366,115 @@ function ensureVNodeHookState(
   }
   return state
 }
+
+function createVNodeChildrenFragment(
+  internals: RendererInternals,
+  render: () => VNode[],
+  parentComponent: ComponentInternalInstance | null,
+): VaporFragment {
+  const suspense =
+    currentParentSuspense || (parentComponent && parentComponent.suspense)
+  const frag = new VaporFragment<Block>([])
+  let currentVNode: VNode | null = null
+  let currentChildren: VNode[] = []
+  let currentParentNode: ParentNode | null = null
+  let currentAnchor: Node | null = null
+  let isMounted = false
+  const scope = effectScope()
+
+  const renderContent = () => {
+    const prev = currentInstance
+    simpleSetCurrentInstance(parentComponent)
+    try {
+      renderEffect(() => {
+        const nextChildren = render()
+        if (isHydrating) {
+          nextChildren.forEach(vnode => hydrateVNode(vnode, parentComponent))
+          currentChildren = nextChildren
+          currentVNode = createVNode(Fragment, null, nextChildren)
+          currentParentNode = currentHydrationNode!.parentNode as ParentNode
+          currentAnchor = currentHydrationNode
+        } else if (!currentVNode) {
+          currentChildren = nextChildren
+          currentVNode = createVNode(Fragment, null, nextChildren)
+          if (nextChildren.length) {
+            internals.mc(
+              nextChildren,
+              currentParentNode!,
+              currentAnchor,
+              parentComponent,
+              suspense,
+              undefined,
+              null,
+              false,
+            )
+          }
+        } else {
+          const nextVNode = createVNode(Fragment, null, nextChildren)
+          internals.pc(
+            currentVNode,
+            nextVNode,
+            currentParentNode!,
+            currentAnchor,
+            parentComponent,
+            suspense,
+            undefined,
+            null,
+            false,
+          )
+          currentChildren = nextChildren
+          currentVNode = nextVNode
+        }
+
+        if (currentChildren.length === 0) {
+          frag.nodes = []
+        } else if (currentChildren.length === 1) {
+          frag.nodes = resolveVNodeNodes(currentChildren[0])
+        } else {
+          frag.nodes = currentChildren.map(resolveVNodeNodes) as Block[]
+        }
+
+        if (isMounted && frag.onUpdated) {
+          frag.onUpdated.forEach(hook => hook())
+        }
+      })
+    } finally {
+      simpleSetCurrentInstance(prev)
+    }
+  }
+
+  frag.insert = (parentNode, anchor) => {
+    if (isHydrating) return
+    currentParentNode = parentNode
+    currentAnchor = anchor
+    if (!isMounted) {
+      scope.run(renderContent)
+      isMounted = true
+    } else {
+      currentChildren.forEach(vnode => {
+        internals.m(
+          vnode,
+          parentNode,
+          anchor,
+          MoveType.REORDER,
+          parentComponent as any,
+        )
+      })
+    }
+  }
+
+  frag.remove = parentNode => {
+    scope.stop()
+    currentChildren.forEach(vnode => {
+      internals.um(vnode, parentComponent, null, !!parentNode)
+    })
+  }
+
+  frag.hydrate = () => {
+    if (!isHydrating) return
+    scope.run(renderContent)
+    isMounted = true
+  }
+
+  return frag
+}