]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor/hydration): handle component with anchor insertion
authordaiwei <daiwei521@126.com>
Mon, 21 Apr 2025 07:38:50 +0000 (15:38 +0800)
committerdaiwei <daiwei521@126.com>
Mon, 21 Apr 2025 08:16:12 +0000 (16:16 +0800)
packages/compiler-ssr/__tests__/ssrElement.spec.ts
packages/compiler-ssr/src/transforms/ssrTransformComponent.ts
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/hydration.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/dom/hydration.ts
packages/runtime-vapor/src/insertionState.ts

index f1d509acfb011a8b48acb2fd1191cca0a7c8cda1..97601ae9abcab71239fa0adfc312f3b3194414fe 100644 (file)
@@ -396,4 +396,46 @@ describe('ssr: element', () => {
       `)
     })
   })
+
+  describe('dynamic child anchor', () => {
+    test('component with element siblings', () => {
+      expect(
+        getCompiledString(`
+        <div>
+          <div/>
+          <Comp1/>
+          <div/>
+        </div>
+        `),
+      ).toMatchInlineSnapshot(`
+        "\`<div><div></div>\`)
+          _push("<!--[[-->")
+          _push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
+          _push("<!--]]-->")
+          _push(\`<div></div></div>\`"
+      `)
+    })
+
+    test('with consecutive components', () => {
+      expect(
+        getCompiledString(`
+        <div>
+          <div/>
+          <Comp1/>
+          <Comp2/>
+          <div/>
+        </div>
+        `),
+      ).toMatchInlineSnapshot(`
+        "\`<div><div></div>\`)
+          _push("<!--[[-->")
+          _push(_ssrRenderComponent(_component_Comp1, null, null, _parent))
+          _push("<!--]]-->")
+          _push("<!--[[-->")
+          _push(_ssrRenderComponent(_component_Comp2, null, null, _parent))
+          _push("<!--]]-->")
+          _push(\`<div></div></div>\`"
+      `)
+    })
+  })
 })
index cad1ee8102897beab4c8990b99b43366c5d42655..a130dc427ff61a12e7dc44f593de60d24d7f76ab 100644 (file)
@@ -255,6 +255,13 @@ export function ssrProcessComponent(
       node.ssrCodegenNode.arguments.push(`_scopeId`)
     }
 
+    // `<!--[[-->` marks the start of the dynamic children
+    // Only used in Vapor hydration, VDOM hydration
+    // skips this marker.
+    const needDynamicAnchor = shouldAddDynamicAnchor(parent, node)
+    if (needDynamicAnchor) {
+      context.pushStatement(createCallExpression(`_push`, [`"<!--[[-->"`]))
+    }
     if (typeof component === 'string') {
       // static component
       context.pushStatement(
@@ -265,6 +272,9 @@ export function ssrProcessComponent(
       // the codegen node is a `renderVNode` call
       context.pushStatement(node.ssrCodegenNode)
     }
+    if (needDynamicAnchor) {
+      context.pushStatement(createCallExpression(`_push`, [`"<!--]]-->"`]))
+    }
   }
 }
 
@@ -384,3 +394,39 @@ function clone(v: any): any {
     return v
   }
 }
+
+function shouldAddDynamicAnchor(
+  parent: { tag?: string; children: TemplateChildNode[] },
+  node: TemplateChildNode,
+): boolean {
+  if (!parent.tag) return false
+
+  const children = parent.children
+  const len = children.length
+  const index = children.indexOf(node)
+
+  const isStaticElement = (c: TemplateChildNode): boolean =>
+    c.type === NodeTypes.ELEMENT && c.tagType !== ElementTypes.COMPONENT
+
+  let hasStaticPreviousSibling = false
+  if (index > 0) {
+    for (let i = index - 1; i >= 0; i--) {
+      if (isStaticElement(children[i])) {
+        hasStaticPreviousSibling = true
+        break
+      }
+    }
+  }
+
+  let hasStaticNextSibling = false
+  if (hasStaticPreviousSibling && index > -1 && index < len - 1) {
+    for (let i = index + 1; i < len; i++) {
+      if (isStaticElement(children[i])) {
+        hasStaticNextSibling = true
+        break
+      }
+    }
+  }
+
+  return hasStaticPreviousSibling && hasStaticNextSibling
+}
index 56011d063599f0a92a29b56cc18813deaa89e51c..07a6504b504f2c4747463ea6b879ab4ecf17e18c 100644 (file)
@@ -1843,6 +1843,36 @@ describe('SSR hydration', () => {
     }
   })
 
+  describe('dynamic child anchor', () => {
+    test('component with element siblings', () => {
+      const Comp = {
+        render() {
+          return createTextVNode('foo')
+        },
+      }
+      const { vnode, container } = mountWithHydration(
+        `<div><span></span><!--[[-->foo<!--]]--><span></span></div>`,
+        () => h('div', null, [h('span'), h(Comp), h('span')]),
+      )
+      expect(vnode.el).toBe(container.firstChild)
+      expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('with consecutive components', () => {
+      const Comp = {
+        render() {
+          return createTextVNode('foo')
+        },
+      }
+      const { vnode, container } = mountWithHydration(
+        `<div><span></span><!--[[-->foo<!--]]--><!--[[-->foo<!--]]--><span></span></div>`,
+        () => h('div', null, [h('span'), h(Comp), h(Comp), h('span')]),
+      )
+      expect(vnode.el).toBe(container.firstChild)
+      expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+    })
+  })
+
   describe('mismatch handling', () => {
     test('text node', () => {
       const { container } = mountWithHydration(`foo`, () => 'bar')
index ef6f1918c31998208cf0ac36a3013e9a62fd898b..13f900a9482c5e0abaaa5005471e776ef24197c8 100644 (file)
@@ -111,7 +111,7 @@ export function createHydrationFunctions(
     o: {
       patchProp,
       createText,
-      nextSibling,
+      nextSibling: next,
       parentNode,
       remove,
       insert,
@@ -119,6 +119,19 @@ export function createHydrationFunctions(
     },
   } = rendererInternals
 
+  function isDynamicAnchor(node: Node): boolean {
+    return isComment(node) && (node.data === '[[' || node.data === ']]')
+  }
+
+  function nextSibling(node: Node) {
+    let n = next(node)
+    // skip dynamic child anchor
+    if (n && isDynamicAnchor(n)) {
+      n = next(n)
+    }
+    return n
+  }
+
   const hydrate: RootHydrateFunction = (vnode, container) => {
     if (!container.hasChildNodes()) {
       ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@@ -145,6 +158,7 @@ export function createHydrationFunctions(
     slotScopeIds: string[] | null,
     optimized = false,
   ): Node | null => {
+    if (isDynamicAnchor(node)) node = nextSibling(node)!
     optimized = optimized || !!vnode.dynamicChildren
     const isFragmentStart = isComment(node) && node.data === '['
     const onMismatch = () =>
@@ -451,7 +465,7 @@ export function createHydrationFunctions(
 
           // The SSRed DOM contains more nodes than it should. Remove them.
           const cur = next
-          next = next.nextSibling
+          next = nextSibling(next)
           remove(cur)
         }
       } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
@@ -553,7 +567,7 @@ export function createHydrationFunctions(
       }
     }
 
-    return el.nextSibling
+    return nextSibling(el)
   }
 
   const hydrateChildren = (
index 6ba2bf895fbb23ed7cfc6ba3da2d36d706330f18..def3c9d9248d85319566295bf52313f42bb7cdde 100644 (file)
@@ -239,8 +239,7 @@ describe('Vapor Mode hydration', () => {
     )
   })
 
-  // problem is the <!> placeholder does not exist in SSR output
-  test.todo('component with anchor insertion', async () => {
+  test('component with anchor insertion', async () => {
     const { container, data } = await testHydration(
       `
       <template>
@@ -255,14 +254,18 @@ describe('Vapor Mode hydration', () => {
         Child: `<template>{{ data }}</template>`,
       },
     )
-    expect(container.innerHTML).toMatchInlineSnapshot()
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<div><span></span><!--[[-->foo<!--]]--><span></span></div>"`,
+    )
 
     data.value = 'bar'
     await nextTick()
-    expect(container.innerHTML).toMatchInlineSnapshot()
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<div><span></span><!--[[-->bar<!--]]--><span></span></div>"`,
+    )
   })
 
-  test.todo('consecutive component with anchor insertion', async () => {
+  test('consecutive component with anchor insertion', async () => {
     const { container, data } = await testHydration(
       `<template>
         <div>
@@ -277,11 +280,15 @@ describe('Vapor Mode hydration', () => {
         Child: `<template>{{ data }}</template>`,
       },
     )
-    expect(container.innerHTML).toMatchInlineSnapshot()
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<div><span></span><!--[[-->foo<!--]]--><!--[[-->foo<!--]]--><span></span></div>"`,
+    )
 
     data.value = 'bar'
     await nextTick()
-    expect(container.innerHTML).toMatchInlineSnapshot()
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<div><span></span><!--[[-->bar<!--]]--><!--[[-->bar<!--]]--><span></span></div>"`,
+    )
   })
 
   test.todo('if')
index d34d9db7da58c4e2b2aaa9d5a417168ec316ab18..5c1cdde070b7ea7d9997c8ae773d42646061a0d3 100644 (file)
@@ -1,5 +1,6 @@
 import { warn } from '@vue/runtime-dom'
 import {
+  type Anchor,
   insertionAnchor,
   insertionParent,
   resetInsertionState,
@@ -36,12 +37,6 @@ export function withHydration(container: ParentNode, fn: () => void): void {
 export let adoptTemplate: (node: Node, template: string) => Node | null
 export let locateHydrationNode: () => void
 
-type Anchor = Comment & {
-  // cached matching fragment start to avoid repeated traversal
-  // on nested fragments
-  $fs?: Anchor
-}
-
 const isComment = (node: Node, data: string): node is Anchor =>
   node.nodeType === 8 && (node as Comment).data === data
 
@@ -77,41 +72,48 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
 
 function locateHydrationNodeImpl() {
   let node: Node | null
-
   // prepend / firstChild
   if (insertionAnchor === 0) {
     node = child(insertionParent!)
   } else {
-    node = insertionAnchor
-      ? insertionAnchor.previousSibling
-      : insertionParent
-        ? insertionParent.lastChild
-        : currentHydrationNode
-
-    if (node && isComment(node, ']')) {
-      // fragment backward search
-      if (node.$fs) {
-        // already cached matching fragment start
-        node = node.$fs
-      } else {
-        let cur: Node | null = node
-        let curFragEnd = node
-        let fragDepth = 0
-        node = null
-        while (cur) {
-          cur = cur.previousSibling
-          if (cur) {
-            if (isComment(cur, '[')) {
-              curFragEnd.$fs = cur
-              if (!fragDepth) {
-                node = cur
-                break
-              } else {
-                fragDepth--
+    // dynamic child anchor `<!--[[-->`
+    if (insertionAnchor && isDynamicStart(insertionAnchor)) {
+      const anchor = (insertionParent!.lds = insertionParent!.lds
+        ? // continuous dynamic children, the next dynamic start must exist
+          locateNextDynamicStart(insertionParent!.lds)!
+        : insertionAnchor)
+      node = anchor.nextSibling
+    } else {
+      node = insertionAnchor
+        ? insertionAnchor.previousSibling
+        : insertionParent
+          ? insertionParent.lastChild
+          : currentHydrationNode
+      if (node && isComment(node, ']')) {
+        // fragment backward search
+        if (node.$fs) {
+          // already cached matching fragment start
+          node = node.$fs
+        } else {
+          let cur: Node | null = node
+          let curFragEnd = node
+          let fragDepth = 0
+          node = null
+          while (cur) {
+            cur = cur.previousSibling
+            if (cur) {
+              if (isComment(cur, '[')) {
+                curFragEnd.$fs = cur
+                if (!fragDepth) {
+                  node = cur
+                  break
+                } else {
+                  fragDepth--
+                }
+              } else if (isComment(cur, ']')) {
+                curFragEnd = cur
+                fragDepth++
               }
-            } else if (isComment(cur, ']')) {
-              curFragEnd = cur
-              fragDepth++
             }
           }
         }
@@ -127,3 +129,32 @@ function locateHydrationNodeImpl() {
   resetInsertionState()
   currentHydrationNode = node
 }
+
+function isDynamicStart(node: Node): node is Anchor {
+  return isComment(node, '[[')
+}
+
+function locateNextDynamicStart(anchor: Anchor): Anchor | undefined {
+  let cur: Node | null = anchor
+  let end = null
+  let depth = 0
+  while (cur) {
+    cur = cur.nextSibling
+    if (cur) {
+      if (isComment(cur, '[[')) {
+        depth++
+      } else if (isComment(cur, ']]')) {
+        if (!depth) {
+          end = cur
+          break
+        } else {
+          depth--
+        }
+      }
+    }
+  }
+
+  if (end) {
+    return end!.nextSibling as Anchor
+  }
+}
index c8c7ffbcd1de3b1000cbc9fa62f50ee9c8c3a0b3..5004c4d97267747289a275d26f42c84baf2e81b1 100644 (file)
@@ -1,5 +1,10 @@
-export let insertionParent: ParentNode | undefined
-export let insertionAnchor: Node | 0 | undefined
+export let insertionParent:
+  | (ParentNode & {
+      // cached the last dynamic start anchor
+      lds?: Anchor
+    })
+  | undefined
+export let insertionAnchor: Node | 0 | undefined | null
 
 /**
  * This function is called before a block type that requires insertion
@@ -14,3 +19,13 @@ export function setInsertionState(parent: ParentNode, anchor?: Node | 0): void {
 export function resetInsertionState(): void {
   insertionParent = insertionAnchor = undefined
 }
+
+export function setInsertionAnchor(anchor: Node | null): void {
+  insertionAnchor = anchor
+}
+
+export type Anchor = Comment & {
+  // cached matching fragment start to avoid repeated traversal
+  // on nested fragments
+  $fs?: Anchor
+}