Răsfoiți Sursa

fix(hydration): keep multi-root hydration cleanup inside component-owned close markers

daiwei 2 luni în urmă
părinte
comite
10325c0ef4

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

@@ -9693,6 +9693,139 @@ describe('VDOM interop', () => {
     )
   })
 
+  test('hydrate multi-root Vapor component should preserve close marker when client renders extra child', async () => {
+    const data = ref({
+      msg: 'Hello',
+      extra: '',
+    })
+
+    const childCode = `<script setup>
+        const data = _data
+      </script>
+      <template>
+        <span>{{ data.msg }}</span>{{ data.extra }}
+      </template>`
+
+    const appCode = `<script setup>
+        const components = _components
+      </script>
+      <template>
+        <div>Before</div>
+        <components.Child />
+        <div>After</div>
+      </template>`
+
+    const SSRChild = compileVaporComponent(childCode, data, undefined, true)
+    const SSRApp = compileVaporComponent(
+      appCode,
+      data,
+      { Child: SSRChild },
+      true,
+    )
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(SSRApp),
+    )
+
+    data.value.extra = 'Tail'
+
+    const ClientChild = compileVaporComponent(childCode, data)
+    const ClientApp = compileVaporComponent(appCode, data, {
+      Child: ClientChild,
+    })
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+    createVaporSSRApp(ClientApp).mount(container)
+
+    expect(`Hydration node mismatch`).toHaveBeenWarned()
+    expect(`Hydration text mismatch`).toHaveBeenWarned()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>Before</div>
+      <!--[--><span>Hello</span>Tail<!--]-->
+      <div>After</div><!--]-->
+      "
+    `,
+    )
+
+    data.value.extra = 'Tail updated'
+    await nextTick()
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>Before</div>
+      <!--[--><span>Hello</span>Tail updated<!--]-->
+      <div>After</div><!--]-->
+      "
+    `,
+    )
+  })
+
+  test('hydrate multi-root Vapor component should cleanup extra SSR text without crossing trailing sibling', async () => {
+    const data = ref({
+      msg: 'Hello',
+      extra: 'Tail',
+      after: 'After',
+    })
+
+    const childCode = `<script setup>
+        const data = _data
+      </script>
+      <template>
+        <span>{{ data.msg }}</span>{{ data.extra }}
+      </template>`
+
+    const appCode = `<script setup>
+        const components = _components
+        const data = _data
+      </script>
+      <template>
+        <div data-test="wrapper">
+          <components.Child />
+          <span>{{ data.after }}</span>
+        </div>
+      </template>`
+
+    const SSRChild = compileVaporComponent(childCode, data, undefined, true)
+    const SSRApp = compileVaporComponent(
+      appCode,
+      data,
+      { Child: SSRChild },
+      true,
+    )
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(SSRApp),
+    )
+
+    data.value.extra = ''
+
+    const ClientChild = compileVaporComponent(childCode, data)
+    const ClientApp = compileVaporComponent(appCode, data, {
+      Child: ClientChild,
+    })
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+    createVaporSSRApp(ClientApp).mount(container)
+
+    expect(`Hydration text mismatch`).toHaveBeenWarned()
+    expect(container.innerHTML).toBe(
+      '<div data-test="wrapper"><!--[--><span>Hello</span><!--]--><span>After</span></div>',
+    )
+
+    data.value.extra = 'Updated'
+    data.value.after = 'After updated'
+    await nextTick()
+
+    expect(container.innerHTML).toBe(
+      '<div data-test="wrapper"><!--[--><span>Hello</span>Updated<!--]--><span>After updated</span></div>',
+    )
+  })
+
   test('hydrate multi-root VDOM via mountVNode as non-first child', async () => {
     const MultiRootVDOM = {
       setup() {

+ 143 - 122
packages/runtime-vapor/src/component.ts

@@ -87,10 +87,13 @@ import {
   adoptTemplate,
   advanceHydrationNode,
   currentHydrationNode,
+  enterHydrationBoundary,
   isComment,
   isHydrating,
+  locateEndAnchor,
   locateHydrationNode,
   locateNextNode,
+  markHydrationAnchor,
   setCurrentHydrationNode,
 } from './dom/hydration'
 import { createComment, createElement, createTextNode } from './dom/node'
@@ -245,156 +248,174 @@ export function createComponent(
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
   const _isLastInsertion = isLastInsertion
+  let hydrationClose: Node | null = null
+  let exitHydrationBoundary: (() => void) | undefined
   if (isHydrating) {
-    locateHydrationNode(component.__multiRoot)
+    locateHydrationNode()
+    if (component.__multiRoot && isComment(currentHydrationNode!, '[')) {
+      hydrationClose = locateEndAnchor(currentHydrationNode!)
+      exitHydrationBoundary = enterHydrationBoundary(
+        hydrationClose && markHydrationAnchor(hydrationClose),
+      )
+      setCurrentHydrationNode(currentHydrationNode!.nextSibling)
+    }
   } else {
     resetInsertionState()
   }
 
-  let prevSuspense: SuspenseBoundary | null = null
-  if (__FEATURE_SUSPENSE__ && currentInstance && currentInstance.suspense) {
-    prevSuspense = setParentSuspense(currentInstance.suspense)
-  }
+  try {
+    let prevSuspense: SuspenseBoundary | null = null
+    if (__FEATURE_SUSPENSE__ && currentInstance && currentInstance.suspense) {
+      prevSuspense = setParentSuspense(currentInstance.suspense)
+    }
 
-  if (
-    (isSingleRoot ||
-      // transition has attrs fallthrough
-      (currentInstance && isVaporTransition(currentInstance!.type))) &&
-    component.inheritAttrs !== false &&
-    isVaporComponent(currentInstance) &&
-    currentInstance.hasFallthrough
-  ) {
-    // check if we are the single root of the parent
-    // if yes, inject parent attrs as dynamic props source
-    const attrs = currentInstance.attrs
-    if (rawProps && rawProps !== EMPTY_OBJ) {
-      ;((rawProps as RawProps).$ || ((rawProps as RawProps).$ = [])).push(
-        () => attrs,
-      )
-    } else {
-      rawProps = { $: [() => attrs] } as RawProps
+    if (
+      (isSingleRoot ||
+        // transition has attrs fallthrough
+        (currentInstance && isVaporTransition(currentInstance!.type))) &&
+      component.inheritAttrs !== false &&
+      isVaporComponent(currentInstance) &&
+      currentInstance.hasFallthrough
+    ) {
+      // check if we are the single root of the parent
+      // if yes, inject parent attrs as dynamic props source
+      const attrs = currentInstance.attrs
+      if (rawProps && rawProps !== EMPTY_OBJ) {
+        ;((rawProps as RawProps).$ || ((rawProps as RawProps).$ = [])).push(
+          () => attrs,
+        )
+      } else {
+        rawProps = { $: [() => attrs] } as RawProps
+      }
     }
-  }
 
-  // keep-alive
-  if (
-    currentInstance &&
-    currentInstance.vapor &&
-    isKeepAlive(currentInstance)
-  ) {
-    const cached = (
-      currentInstance as KeepAliveInstance
-    ).ctx.getCachedComponent(component)
-    // @ts-expect-error
-    if (cached) return cached
-  }
+    // keep-alive
+    if (
+      currentInstance &&
+      currentInstance.vapor &&
+      isKeepAlive(currentInstance)
+    ) {
+      const cached = (
+        currentInstance as KeepAliveInstance
+      ).ctx.getCachedComponent(component)
+      // @ts-expect-error
+      if (cached) return cached
+    }
 
-  // vdom interop enabled and component is not an explicit vapor component
-  if (isInteropEnabled && appContext.vapor && !component.__vapor) {
-    const frag = appContext.vapor.vdomMount(
-      component as any,
-      currentInstance as any,
-      rawProps,
-      rawSlots,
-    )
-    if (!isHydrating) {
-      if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
-    } else {
-      frag.hydrate()
-      if (_isLastInsertion) {
-        advanceHydrationNode(_insertionParent!)
+    // vdom interop enabled and component is not an explicit vapor component
+    if (isInteropEnabled && appContext.vapor && !component.__vapor) {
+      const frag = appContext.vapor.vdomMount(
+        component as any,
+        currentInstance as any,
+        rawProps,
+        rawSlots,
+      )
+      if (!isHydrating) {
+        if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
+      } else {
+        frag.hydrate()
+        if (_isLastInsertion) {
+          advanceHydrationNode(_insertionParent!)
+        }
       }
+      return frag
     }
-    return frag
-  }
 
-  // teleport
-  if (isVaporTeleport(component)) {
-    const frag = component.process(rawProps!, rawSlots!)
-    if (!isHydrating) {
-      if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
-    } else {
-      frag.hydrate()
-      if (_isLastInsertion) {
-        advanceHydrationNode(_insertionParent!)
+    // teleport
+    if (isVaporTeleport(component)) {
+      const frag = component.process(rawProps!, rawSlots!)
+      if (!isHydrating) {
+        if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
+      } else {
+        frag.hydrate()
+        if (_isLastInsertion) {
+          advanceHydrationNode(_insertionParent!)
+        }
       }
+
+      return frag as any
     }
 
-    return frag as any
-  }
+    const instance = new VaporComponentInstance(
+      component,
+      rawProps as RawProps,
+      rawSlots as RawSlots,
+      appContext,
+      once,
+    )
 
-  const instance = new VaporComponentInstance(
-    component,
-    rawProps as RawProps,
-    rawSlots as RawSlots,
-    appContext,
-    once,
-  )
+    // handle currentKeepAliveCtx for component boundary isolation
+    // AsyncWrapper should NOT clear currentKeepAliveCtx so its internal
+    // DynamicFragment can capture it
+    if (currentKeepAliveCtx && !isAsyncWrapper(instance)) {
+      currentKeepAliveCtx.processShapeFlag(instance)
+      // clear currentKeepAliveCtx so child components don't associate
+      // with parent's KeepAlive
+      setCurrentKeepAliveCtx(null)
+    }
 
-  // handle currentKeepAliveCtx for component boundary isolation
-  // AsyncWrapper should NOT clear currentKeepAliveCtx so its internal
-  // DynamicFragment can capture it
-  if (currentKeepAliveCtx && !isAsyncWrapper(instance)) {
-    currentKeepAliveCtx.processShapeFlag(instance)
-    // clear currentKeepAliveCtx so child components don't associate
-    // with parent's KeepAlive
-    setCurrentKeepAliveCtx(null)
-  }
+    // reset currentSlotOwner to null to avoid affecting the child components
+    const prevSlotOwner = setCurrentSlotOwner(null)
 
-  // reset currentSlotOwner to null to avoid affecting the child components
-  const prevSlotOwner = setCurrentSlotOwner(null)
+    // HMR
+    if (__DEV__) {
+      registerHMR(instance)
+      instance.isSingleRoot = isSingleRoot
+      instance.hmrRerender = hmrRerender.bind(null, instance)
+      instance.hmrReload = hmrReload.bind(null, instance)
 
-  // HMR
-  if (__DEV__) {
-    registerHMR(instance)
-    instance.isSingleRoot = isSingleRoot
-    instance.hmrRerender = hmrRerender.bind(null, instance)
-    instance.hmrReload = hmrReload.bind(null, instance)
+      pushWarningContext(instance)
+      startMeasure(instance, `init`)
 
-    pushWarningContext(instance)
-    startMeasure(instance, `init`)
+      // cache normalized options for dev only emit check
+      instance.propsOptions = normalizePropsOptions(component)
+      instance.emitsOptions = normalizeEmitsOptions(component)
+    }
 
-    // cache normalized options for dev only emit check
-    instance.propsOptions = normalizePropsOptions(component)
-    instance.emitsOptions = normalizeEmitsOptions(component)
-  }
+    // hydrating async component
+    if (
+      isHydrating &&
+      isAsyncWrapper(instance) &&
+      component.__asyncHydrate &&
+      !component.__asyncResolved
+    ) {
+      component.__asyncHydrate(currentHydrationNode as Element, instance, () =>
+        setupComponent(instance, component),
+      )
+    } else {
+      setupComponent(instance, component)
+    }
 
-  // hydrating async component
-  if (
-    isHydrating &&
-    isAsyncWrapper(instance) &&
-    component.__asyncHydrate &&
-    !component.__asyncResolved
-  ) {
-    component.__asyncHydrate(currentHydrationNode as Element, instance, () =>
-      setupComponent(instance, component),
-    )
-  } else {
-    setupComponent(instance, component)
-  }
+    if (__DEV__) {
+      popWarningContext()
+      endMeasure(instance, 'init')
+    }
 
-  if (__DEV__) {
-    popWarningContext()
-    endMeasure(instance, 'init')
-  }
+    if (__FEATURE_SUSPENSE__ && currentInstance && currentInstance.suspense) {
+      setParentSuspense(prevSuspense)
+    }
 
-  if (__FEATURE_SUSPENSE__ && currentInstance && currentInstance.suspense) {
-    setParentSuspense(prevSuspense)
-  }
+    // restore currentSlotOwner to previous value after setupFn is called
+    setCurrentSlotOwner(prevSlotOwner)
+    onScopeDispose(() => unmountComponent(instance), true)
 
-  // restore currentSlotOwner to previous value after setupFn is called
-  setCurrentSlotOwner(prevSlotOwner)
-  onScopeDispose(() => unmountComponent(instance), true)
+    if (!managedMount && (_insertionParent || isHydrating)) {
+      mountComponent(instance, _insertionParent!, _insertionAnchor)
+    }
 
-  if (!managedMount && (_insertionParent || isHydrating)) {
-    mountComponent(instance, _insertionParent!, _insertionAnchor)
-  }
+    if (isHydrating && _insertionAnchor !== undefined) {
+      advanceHydrationNode(_insertionParent!)
+    }
 
-  if (isHydrating && _insertionAnchor !== undefined) {
-    advanceHydrationNode(_insertionParent!)
+    return instance
+  } finally {
+    if (isHydrating) {
+      exitHydrationBoundary && exitHydrationBoundary()
+      if (hydrationClose && currentHydrationNode === hydrationClose) {
+        advanceHydrationNode(hydrationClose)
+      }
+    }
   }
-
-  return instance
 }
 
 export function setupComponent(