ソースを参照

fix(hydration): keep late fragment inserts before their own hydration anchor

daiwei 2 週間 前
コミット
59a27ef1f6

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

@@ -4841,7 +4841,7 @@ describe('Vapor Mode hydration', () => {
       `)
     })
 
-    test.todo('update async component slot structure after parent mount before async component resolve', async () => {
+    test('update async component slot structure after parent mount before async component resolve', async () => {
       const data = ref({
         show: false,
         msg: 'bar',
@@ -4905,6 +4905,71 @@ describe('Vapor Mode hydration', () => {
       `)
     })
 
+    test('update async component slot single-root if with trailing sibling after parent mount before async component resolve', async () => {
+      const data = ref({
+        show: false,
+        msg: 'bar',
+        tail: 'tail',
+      })
+      const compCode = `<div><slot/></div>`
+      const SSRComp = compileVaporComponent(
+        compCode,
+        undefined,
+        undefined,
+        true,
+      )
+      let serverResolve: any
+      let AsyncComp = defineAsyncComponent(
+        () =>
+          new Promise(r => {
+            serverResolve = r
+          }),
+      )
+      const appCode = `<components.AsyncComp><span v-if="data.show">{{data.msg}}</span><i>{{data.tail}}</i></components.AsyncComp>`
+      const SSRApp = compileVaporComponent(appCode, data, { AsyncComp }, true)
+
+      const htmlPromise = VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      serverResolve(SSRComp)
+      const html = await htmlPromise
+      expect(formatHtml(html)).toMatchInlineSnapshot(`
+      	"<div>
+      	<!--[--><!----><i>tail</i><!--]-->
+      	</div>"
+      `)
+
+      let clientResolve: any
+      AsyncComp = defineVaporAsyncComponent(
+        () =>
+          new Promise(r => {
+            clientResolve = r
+          }),
+      ) as any
+
+      const Comp = compileVaporComponent(compCode)
+      const App = compileVaporComponent(appCode, data, { AsyncComp })
+
+      const container = document.createElement('div')
+      container.innerHTML = html
+      document.body.appendChild(container)
+      createVaporSSRApp(App).mount(container)
+
+      data.value.show = true
+      await nextTick()
+
+      clientResolve(Comp)
+      await new Promise(r => setTimeout(r))
+
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      	"<div>
+      	<!--[--><span>bar</span><!--if--><i>tail</i><!--]-->
+      	</div><!--async component-->"
+      `)
+    })
+
     describe('suspense', () => {
       describe('VDOM suspense', () => {
         test('hydrate VDOM Suspense vapor async setup should not enter mount hooks twice', async () => {

+ 33 - 9
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)
+    node = resolveHydrationTarget(node, template)
   }
 
   const type = node.nodeType
@@ -307,25 +307,34 @@ export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
 }
 
 export function markHydrationAnchor<T extends Node>(node: T): T {
-  ;(node as T & { $vha?: 1 }).$vha = 1
+  ;(node as any).$vha = 1
   return node
 }
 
-function isHydrationAnchor(node: Node | null | undefined): boolean {
-  return !!node && (node as Node & { $vha?: 1 }).$vha === 1
+export function isHydrationAnchor(node: Node | null | undefined): boolean {
+  return !!node && (node as Anchor).$vha === 1
 }
 
-function resolveHydrationTarget(node: Node): Node {
+function resolveHydrationTarget(node: Node, template: string): Node {
   while (true) {
     if (isHydrationAnchor(node)) {
+      const next = node.nextSibling
+      if (next && canUseAsHydrationTarget(next, template)) {
+        node = next
+        continue
+      }
       return node
     }
 
-    if (node.nodeType === 8) {
+    if (
+      node.nodeType === 8 &&
+      ((node as Comment).data === '[' ||
+        (node as Comment).data === ']' ||
+        (node as Comment).data === 'teleport start' ||
+        (node as Comment).data === 'teleport end')
+    ) {
       const next = node.nextSibling
-      if (!next) {
-        return node
-      }
+      if (!next) return node
       node = next
       continue
     }
@@ -333,3 +342,18 @@ function resolveHydrationTarget(node: Node): Node {
     return node
   }
 }
+
+function canUseAsHydrationTarget(node: Node, template: string): boolean {
+  if (template[0] !== '<') {
+    return node.nodeType === 3
+  }
+
+  if (template.startsWith('<!')) {
+    return node.nodeType === 8
+  }
+
+  return (
+    node.nodeType === 1 &&
+    template.startsWith(`<${(node as Element).tagName.toLowerCase()}`)
+  )
+}

+ 30 - 8
packages/runtime-vapor/src/fragment.ts

@@ -32,6 +32,7 @@ import {
   isHydrating,
   locateEndAnchor,
   locateHydrationNode,
+  markHydrationAnchor,
   setCurrentHydrationNode,
 } from './dom/hydration'
 import { isArray } from '@vue/shared'
@@ -239,6 +240,27 @@ export class DynamicFragment extends VaporFragment {
       }
     }
 
+    // A non-slot fragment can render empty first during hydration, then flip
+    // to a real branch before hydration exits (for example inside an async
+    // component slot). Re-point the cursor at the fragment-owned insertion
+    // anchor so the late branch inserts before that anchor instead of
+    // consuming trailing hydrated siblings or the enclosing slot boundary.
+    if (
+      isHydrating &&
+      render &&
+      this.anchorLabel !== 'slot' &&
+      !isValidBlock(this.nodes)
+    ) {
+      const anchor =
+        this.anchor ||
+        (currentHydrationNode === currentSlotEndAnchor
+          ? currentSlotEndAnchor
+          : null)
+      if (anchor) {
+        setCurrentHydrationNode(markHydrationAnchor(anchor))
+      }
+    }
+
     this.renderBranch(render, transition, parent, key)
     setActiveSub(prevSub)
 
@@ -346,7 +368,7 @@ export class DynamicFragment extends VaporFragment {
     // `<div v-if="false"></div>` -> `<!---->`
     if (isEmpty) {
       if (isComment(currentHydrationNode!, '')) {
-        this.anchor = currentHydrationNode
+        this.anchor = markHydrationAnchor(currentHydrationNode!)
         advanceHydrationNode(currentHydrationNode)
         return
       }
@@ -364,9 +386,9 @@ export class DynamicFragment extends VaporFragment {
         this.isAnchorPending = true
         queuePostFlushCb(() =>
           endAnchor.parentNode!.insertBefore(
-            (this.anchor = __DEV__
-              ? createComment(this.anchorLabel!)
-              : createTextNode()),
+            (this.anchor = markHydrationAnchor(
+              __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
+            )),
             endAnchor,
           ),
         )
@@ -388,7 +410,7 @@ export class DynamicFragment extends VaporFragment {
     ) {
       const anchor = slotAnchor || currentHydrationNode
       if (isComment(anchor!, ']')) {
-        this.anchor = anchor
+        this.anchor = markHydrationAnchor(anchor)
         advanceHydrationNode(anchor)
         return
       } else if (__DEV__) {
@@ -417,9 +439,9 @@ export class DynamicFragment extends VaporFragment {
     // logic such as `findLastChild()`.
     queuePostFlushCb(() => {
       parentNode!.insertBefore(
-        (this.anchor = __DEV__
-          ? createComment(this.anchorLabel!)
-          : createTextNode()),
+        (this.anchor = markHydrationAnchor(
+          __DEV__ ? createComment(this.anchorLabel!) : createTextNode(),
+        )),
         nextNode,
       )
     })