Просмотр исходного кода

fix(hydration): handle non-empty v-for over empty SSR ranges before trailing siblings

daiwei 2 недель назад
Родитель
Сommit
108809a34d

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

@@ -3157,6 +3157,35 @@ describe('Vapor Mode hydration', () => {
       )
     })
 
+    test('hydrate non-empty v-for over empty SSR range with trailing sibling', async () => {
+      const ssrData = ref({
+        items: [] as string[],
+        tail: 'tail',
+      })
+      const clientData = ref({
+        items: ['foo', 'bar'],
+        tail: 'tail',
+      })
+      const code = `
+        <div>
+          <span v-for="item in data.items" :key="item">{{ item }}</span>
+          <i>{{ data.tail }}</i>
+        </div>
+      `
+      const SSRComp = compileVaporComponent(code, ssrData, undefined, true)
+      const html = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+      )
+
+      const { container } = await mountWithHydration(html, code, clientData)
+
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<div>\n<!--[--><span>foo</span><span>bar</span><!--]-->\n<i>tail</i></div>"`,
+      )
+    })
+
     test('slot fallback from invalid v-for branch', async () => {
       const data = reactive({
         items: [{ text: 'bar', show: false }],

+ 61 - 44
packages/runtime-vapor/src/apiCreateFor.ts

@@ -30,6 +30,7 @@ import {
   isHydrating,
   locateHydrationNode,
   locateNextNode,
+  markHydrationAnchor,
   setCurrentHydrationNode,
 } from './dom/hydration'
 import {
@@ -118,58 +119,74 @@ 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)
-        if (isHydrating && nextNode) setCurrentHydrationNode(nextNode)
-      }
-
       if (isHydrating) {
-        // 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 hydrationStart = currentHydrationNode!
+        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 {
-          parentAnchor = currentHydrationNode!
+          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 (
-            __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 {
+        for (let i = 0; i < newLength; i++) {
+          mount(source, i)
+        }
       }
     } else {
       parent = parent || parentAnchor!.parentNode

+ 44 - 9
packages/runtime-vapor/src/dom/hydration.ts

@@ -61,6 +61,7 @@ function performHydration<T>(
     ;(Comment.prototype as any).$fe = undefined
     ;(Node.prototype as any).$idx = undefined
     ;(Node.prototype as any).$llc = undefined
+    ;(Node.prototype as any).$vha = undefined
 
     isOptimized = true
   }
@@ -112,6 +113,9 @@ export let adoptTemplate: (node: Node, template: string) => Node | null
 export let locateHydrationNode: (consumeFragmentStart?: boolean) => void
 
 type Anchor = Comment & {
+  // reused hydration anchors must stay in place during mismatch recovery
+  $vha?: 1
+
   // cached matching fragment end to avoid repeated traversal
   // on nested fragments
   $fe?: Anchor
@@ -151,9 +155,7 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
       node.before((node = createTextNode()))
     }
 
-    while (node.nodeType === 8) {
-      node = node.nextSibling!
-    }
+    node = resolveHydrationTarget(node)
   }
 
   const type = node.nodeType
@@ -256,9 +258,12 @@ function handleMismatch(node: Node, template: string): Node {
     removeFragmentNodes(node)
   }
 
-  const next = _next(node)
   const container = parentNode(node)!
-  remove(node, container)
+  const shouldPreserveAnchor = isHydrationAnchor(node)
+  const next = shouldPreserveAnchor ? node : _next(node)
+  if (!shouldPreserveAnchor) {
+    remove(node, container)
+  }
 
   // fast path for text nodes
   if (template[0] !== '<') {
@@ -269,10 +274,12 @@ function handleMismatch(node: Node, template: string): Node {
   const t = createElement('template') as HTMLTemplateElement
   t.innerHTML = template
   const newNode = _child(t.content).cloneNode(true) as Element
-  newNode.innerHTML = (node as Element).innerHTML
-  Array.from((node as Element).attributes).forEach(attr => {
-    newNode.setAttribute(attr.name, attr.value)
-  })
+  if (node.nodeType === 1) {
+    newNode.innerHTML = (node as Element).innerHTML
+    Array.from((node as Element).attributes).forEach(attr => {
+      newNode.setAttribute(attr.name, attr.value)
+    })
+  }
   container.insertBefore(newNode, next)
   return newNode
 }
@@ -298,3 +305,31 @@ export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
     }
   }
 }
+
+export function markHydrationAnchor<T extends Node>(node: T): T {
+  ;(node as T & { $vha?: 1 }).$vha = 1
+  return node
+}
+
+function isHydrationAnchor(node: Node | null | undefined): boolean {
+  return !!node && (node as Node & { $vha?: 1 }).$vha === 1
+}
+
+function resolveHydrationTarget(node: Node): Node {
+  while (true) {
+    if (isHydrationAnchor(node)) {
+      return node
+    }
+
+    if (node.nodeType === 8) {
+      const next = node.nextSibling
+      if (!next) {
+        return node
+      }
+      node = next
+      continue
+    }
+
+    return node
+  }
+}