]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
wip: hydrate vapor teleport (#13864)
authoredison <daiwei521@126.com>
Sun, 28 Sep 2025 08:23:43 +0000 (16:23 +0800)
committerGitHub <noreply@github.com>
Sun, 28 Sep 2025 08:23:43 +0000 (16:23 +0800)
29 files changed:
packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap
packages/compiler-vapor/__tests__/compile.spec.ts
packages/compiler-vapor/__tests__/transforms/__snapshots__/expression.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformChildren.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/vOnce.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/transformChildren.spec.ts
packages/compiler-vapor/src/generators/block.ts
packages/compiler-vapor/src/generators/template.ts
packages/compiler-vapor/src/ir/index.ts
packages/compiler-vapor/src/transforms/transformText.ts
packages/compiler-vapor/src/transforms/vIf.ts
packages/runtime-core/src/components/Teleport.ts
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/components/Teleport.ts
packages/runtime-vapor/src/dom/hydration.ts
packages/runtime-vapor/src/dom/node.ts
packages/runtime-vapor/src/dom/prop.ts
packages/runtime-vapor/src/dom/template.ts
packages/runtime-vapor/src/fragment.ts
packages/runtime-vapor/src/hmr.ts
packages/runtime-vapor/src/insertionState.ts
packages/runtime-vapor/src/renderEffect.ts
packages/runtime-vapor/src/vdomInterop.ts

index 41e2e776bd9de106e2b278c5433869f87d08ae2f..d2cd54c9e1c94b0eeb0ffff7f3e65d77302964dd 100644 (file)
@@ -157,9 +157,9 @@ export function render(_ctx, $props, $emit, $attrs, $slots) {
   const _component_Comp = _resolveComponent("Comp")
   const n0 = t0()
   const n3 = t1()
+  const n2 = _child(n3, 1)
   _setInsertionState(n3, 0)
   const n1 = _createComponentWithFallback(_component_Comp)
-  const n2 = _child(n3)
   _renderEffect(() => {
     _setProp(n3, "id", _ctx.foo)
     _setText(n2, _toDisplayString(_ctx.bar))
@@ -212,6 +212,30 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compile > execution order > setInsertionState > next, child and nthChild should be above the setInsertionState 1`] = `
+"import { resolveComponent as _resolveComponent, child as _child, next as _next, setInsertionState as _setInsertionState, createComponentWithFallback as _createComponentWithFallback, nthChild as _nthChild, createIf as _createIf, setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div></div>")
+const t1 = _template("<div><div></div><!><div></div><!><div><button></button></div></div>", true)
+
+export function render(_ctx) {
+  const _component_Comp = _resolveComponent("Comp")
+  const n6 = t1()
+  const n5 = _next(_child(n6), 1)
+  const n7 = _nthChild(n6, 3, 3)
+  const p0 = _next(n7, 4)
+  const n4 = _child(p0, 0)
+  _setInsertionState(n6, n5)
+  const n0 = _createComponentWithFallback(_component_Comp)
+  _setInsertionState(n6, n7)
+  const n1 = _createIf(() => (true), () => {
+    const n3 = t0()
+    return n3
+  })
+  _renderEffect(() => _setProp(n4, "disabled", _ctx.foo))
+  return n6
+}"
+`;
+
 exports[`compile > execution order > with insertionState 1`] = `
 "import { resolveComponent as _resolveComponent, child as _child, setInsertionState as _setInsertionState, createSlot as _createSlot, createComponentWithFallback as _createComponentWithFallback, template as _template } from 'vue';
 const t0 = _template("<div><div></div></div>", true)
@@ -219,7 +243,7 @@ const t0 = _template("<div><div></div></div>", true)
 export function render(_ctx) {
   const _component_Comp = _resolveComponent("Comp")
   const n3 = t0()
-  const n1 = _child(n3)
+  const n1 = _child(n3, 0)
   _setInsertionState(n1, null)
   const n0 = _createSlot("default", null)
   _setInsertionState(n3, 1)
@@ -234,9 +258,9 @@ const t0 = _template("<div><span> </span> <br> </div>", true)
 
 export function render(_ctx) {
   const n3 = t0()
-  const n0 = _child(n3)
-  const n1 = _next(n0)
-  const n2 = _nthChild(n3, 3)
+  const n0 = _child(n3, 0)
+  const n1 = _next(n0, 1)
+  const n2 = _nthChild(n3, 3, 3)
   const x0 = _txt(n0)
   _setText(x0, _toDisplayString(_ctx.foo))
   _renderEffect(() => {
index ae59cc78e0c5ac1981b56f849a99a3d73675cbcb..6de93bd320c2c86a8d9dd4163b0b7246f0cc6e2a 100644 (file)
@@ -231,6 +231,23 @@ describe('compile', () => {
       )
     })
 
+    describe('setInsertionState', () => {
+      test('next, child and nthChild should be above the setInsertionState', () => {
+        const code = compile(`
+      <div>
+        <div />
+        <Comp />
+        <div />
+        <div v-if="true" />
+        <div>
+          <button :disabled="foo" />
+        </div>
+      </div>
+      `)
+        expect(code).toMatchSnapshot()
+      })
+    })
+
     test('with v-once', () => {
       const code = compile(
         `<div>
index 2d4da87c35e56bd4c16a9aec5853923a431024d1..9dc329f2120c6e896efcaf8853d279f9af280620 100644 (file)
@@ -47,7 +47,7 @@ const t0 = _template("<div> </div>", true)
 
 export function render(_ctx) {
   const n1 = t0()
-  const n0 = _child(n1)
+  const n0 = _child(n1, 0)
   const x1 = _txt(n1)
   _renderEffect(() => {
     const _foo = _ctx.foo
@@ -86,7 +86,7 @@ const t0 = _template("<div> </div>", true)
 
 export function render(_ctx) {
   const n1 = t0()
-  const n0 = _child(n1)
+  const n0 = _child(n1, 0)
   const x1 = _txt(n1)
   _renderEffect(() => {
     const _String = String
index c56b683323dc62c39d9ab1755630a34c0d7ad440..99f64c8cf23e31ccc8883cc9a9d181ecc5475175 100644 (file)
@@ -7,7 +7,7 @@ const t1 = _template("<div><div></div><!><div></div></div>", true)
 
 export function render(_ctx) {
   const n4 = t1()
-  const n3 = _next(_child(n4))
+  const n3 = _next(_child(n4), 1)
   _setInsertionState(n4, n3)
   const n0 = _createIf(() => (1), () => {
     const n2 = t0()
@@ -23,9 +23,9 @@ const t0 = _template("<div><p> </p> <p> </p></div>", true)
 
 export function render(_ctx) {
   const n3 = t0()
-  const n0 = _child(n3)
-  const n1 = _next(n0)
-  const n2 = _next(n1)
+  const n0 = _child(n3, 0)
+  const n1 = _next(n0, 1)
+  const n2 = _next(n1, 2)
   const x0 = _txt(n0)
   const x2 = _txt(n2)
   _renderEffect(() => {
@@ -43,7 +43,7 @@ const t0 = _template("<div><div>x</div><div>x</div><div> </div></div>", true)
 
 export function render(_ctx) {
   const n1 = t0()
-  const n0 = _nthChild(n1, 2)
+  const n0 = _nthChild(n1, 2, 2)
   const x0 = _txt(n0)
   _renderEffect(() => _setText(x0, _toDisplayString(_ctx.msg)))
   return n1
@@ -56,12 +56,12 @@ const t0 = _template("<div><div>x</div><div><span> </span></div><div><span> </sp
 
 export function render(_ctx) {
   const n3 = t0()
-  const p0 = _next(_child(n3))
-  const n0 = _child(p0)
-  const p1 = _next(p0)
-  const n1 = _child(p1)
-  const p2 = _next(p1)
-  const n2 = _child(p2)
+  const p0 = _next(_child(n3), 1)
+  const n0 = _child(p0, 0)
+  const p1 = _next(p0, 2)
+  const n1 = _child(p1, 0)
+  const p2 = _next(p1, 3)
+  const n2 = _child(p2, 0)
   const x0 = _txt(n0)
   const x1 = _txt(n1)
   const x2 = _txt(n2)
index 87d9833a9c537ea955a10b5f1517890329bab4eb..cccc9200f880c9c19e712775f657a431b569e4d8 100644 (file)
@@ -243,7 +243,7 @@ export function render(_ctx) {
   const _component_Comp = _resolveComponent("Comp")
   const n0 = _createFor(() => (_ctx.list), (_for_item0) => {
     const n3 = _createComponentWithFallback(_component_Comp)
-    const n2 = _child(n3)
+    const n2 = _child(n3, 0)
     _renderEffect(() => _setText(n2, _toDisplayString(_for_item0.value)))
     return [n2, n3]
   }, undefined, 2)
@@ -259,7 +259,7 @@ export function render(_ctx) {
   const _component_Comp = _resolveComponent("Comp")
   const n0 = _createFor(() => (_ctx.list), (_for_item0) => {
     const n3 = _createComponentWithFallback(_component_Comp)
-    const n2 = _child(n3)
+    const n2 = _child(n3, 0)
     _renderEffect(() => _setText(n2, _toDisplayString(_for_item0.value)))
     return [n2, n3]
   }, undefined, 2)
index 4ca745ef0f575bcfefe521764cfea16fb0ddea7b..2fcd18da1df452725cc6016c3aa63911b6d276ef 100644 (file)
@@ -17,8 +17,8 @@ const t0 = _template("<div> <span></span></div>", true)
 
 export function render(_ctx, $props, $emit, $attrs, $slots) {
   const n2 = t0()
-  const n0 = _child(n2)
-  const n1 = _next(n0)
+  const n0 = _child(n2, 0)
+  const n1 = _next(n0, 1)
   _setText(n0, _toDisplayString(_ctx.msg) + " ")
   _setClass(n1, _ctx.clz)
   return n2
@@ -54,7 +54,7 @@ const t0 = _template("<div><div></div></div>", true)
 
 export function render(_ctx) {
   const n1 = t0()
-  const n0 = _child(n1)
+  const n0 = _child(n1, 0)
   _setProp(n0, "id", _ctx.foo)
   return n1
 }"
index 2d8ae8c960d2d0bac4222ca2da60f339091bd3bb..d41ed2ec476e07c9f520eb8bb3ef00f9fd10fc4d 100644 (file)
@@ -56,7 +56,7 @@ describe('compiler: children transform', () => {
         <div>{{ msg }}</div>
       </div>`,
     )
-    expect(code).contains(`const n0 = _nthChild(n1, 2)`)
+    expect(code).contains(`const n0 = _nthChild(n1, 2, 2)`)
     expect(code).toMatchSnapshot()
   })
 
@@ -69,7 +69,7 @@ describe('compiler: children transform', () => {
       </div>`,
     )
     // ensure the insertion anchor is generated before the insertion statement
-    expect(code).toMatch(`const n3 = _next(_child(n4))`)
+    expect(code).toMatch(`const n3 = _next(_child(n4), 1)`)
     expect(code).toMatch(`_setInsertionState(n4, n3)`)
     expect(code).toMatchSnapshot()
   })
index 9ad5da12139e5041a85e26dccd3aaceb560c4ce7..40fa8da6322d9cb80ce6ffe13b72efe041161943 100644 (file)
@@ -71,7 +71,7 @@ export function genBlockContent(
   }
   for (const child of dynamic.children) {
     if (!child.hasDynamicChild) {
-      push(...genChildren(child, context, `n${child.id!}`))
+      push(...genChildren(child, context, push, `n${child.id!}`))
     }
   }
 
index c22d0bf987ec992984f1c83dec791363a2a15f83..96fbd92b0317f0d4dd0d42583c433413b82d8346 100644 (file)
@@ -1,5 +1,9 @@
 import type { CodegenContext } from '../generate'
-import { DynamicFlag, type IRDynamicInfo } from '../ir'
+import {
+  DynamicFlag,
+  type IRDynamicInfo,
+  type InsertionStateTypes,
+} from '../ir'
 import { genDirectivesForElement } from './directive'
 import { genOperationWithInsertionState } from './operation'
 import { type CodeFragment, NEWLINE, buildCodeFragment, genCall } from './utils'
@@ -36,7 +40,7 @@ export function genSelf(
   }
 
   if (hasDynamicChild) {
-    push(...genChildren(dynamic, context, `n${id}`))
+    push(...genChildren(dynamic, context, push, `n${id}`))
   }
 
   return frag
@@ -45,6 +49,7 @@ export function genSelf(
 export function genChildren(
   dynamic: IRDynamicInfo,
   context: CodegenContext,
+  pushBlock: (...items: CodeFragment[]) => number,
   from: string = `n${dynamic.id}`,
 ): CodeFragment[] {
   const { helper } = context
@@ -53,10 +58,20 @@ export function genChildren(
 
   let offset = 0
   let prev: [variable: string, elementIndex: number] | undefined
+  let ifBranchCount = 0
+  let prependCount = 0
 
   for (const [index, child] of children.entries()) {
+    if (
+      child.operation &&
+      (child.operation as InsertionStateTypes).anchor === -1
+    ) {
+      prependCount++
+    }
     if (child.flags & DynamicFlag.NON_TEMPLATE) {
       offset--
+    } else if (child.ifBranch) {
+      ifBranchCount++
     }
 
     const id =
@@ -72,29 +87,41 @@ export function genChildren(
     }
 
     const elementIndex = index + offset
+    const logicalIndex = elementIndex - ifBranchCount + prependCount
     // p for "placeholder" variables that are meant for possible reuse by
     // other access paths
     const variable = id === undefined ? `p${context.block.tempId++}` : `n${id}`
-    push(NEWLINE, `const ${variable} = `)
-
+    pushBlock(NEWLINE, `const ${variable} = `)
     if (prev) {
       if (elementIndex - prev[1] === 1) {
-        push(...genCall(helper('next'), prev[0]))
+        pushBlock(...genCall(helper('next'), prev[0], String(logicalIndex)))
       } else {
-        push(...genCall(helper('nthChild'), from, String(elementIndex)))
+        pushBlock(
+          ...genCall(
+            helper('nthChild'),
+            from,
+            String(elementIndex),
+            String(logicalIndex),
+          ),
+        )
       }
     } else {
       if (elementIndex === 0) {
-        push(...genCall(helper('child'), from))
+        pushBlock(...genCall(helper('child'), from, String(logicalIndex)))
       } else {
         // check if there's a node that we can reuse from
         let init = genCall(helper('child'), from)
         if (elementIndex === 1) {
-          init = genCall(helper('next'), init)
+          init = genCall(helper('next'), init, String(logicalIndex))
         } else if (elementIndex > 1) {
-          init = genCall(helper('nthChild'), from, String(elementIndex))
+          init = genCall(
+            helper('nthChild'),
+            from,
+            String(elementIndex),
+            String(logicalIndex),
+          )
         }
-        push(...init)
+        pushBlock(...init)
       }
     }
 
@@ -107,7 +134,7 @@ export function genChildren(
     }
 
     prev = [variable, elementIndex]
-    push(...genChildren(child, context, variable))
+    push(...genChildren(child, context, pushBlock, variable))
   }
 
   return frag
index bce00283e3d777c99ba83813ae222d163045e5c2..5bea27fc04338148f8b9ddea2b43153fd40d2163 100644 (file)
@@ -272,6 +272,7 @@ export interface IRDynamicInfo {
   hasDynamicChild?: boolean
   operation?: OperationNode
   needsKey?: boolean
+  ifBranch?: boolean
 }
 
 export interface IREffect {
index e9c273b85c73f583a3ed0261e4f2a1e19722255a..dd81bec1e80415d28621da677d815c941968f536 100644 (file)
@@ -16,6 +16,7 @@ import {
   isConstantExpression,
   isStaticExpression,
 } from '../utils'
+import { escapeHtml } from '@vue/shared'
 
 type TextLike = TextNode | InterpolationNode
 const seen = new WeakMap<
@@ -82,7 +83,7 @@ export const transformText: NodeTransform = (node, context) => {
   } else if (node.type === NodeTypes.INTERPOLATION) {
     processInterpolation(context as TransformContext<InterpolationNode>)
   } else if (node.type === NodeTypes.TEXT) {
-    context.template += node.content
+    context.template += escapeHtml(node.content)
   }
 }
 
@@ -143,7 +144,7 @@ function processTextContainer(
   const literals = values.map(getLiteralExpressionValue)
 
   if (literals.every(l => l != null)) {
-    context.childrenTemplate = literals.map(l => String(l))
+    context.childrenTemplate = literals.map(l => escapeHtml(String(l)))
   } else {
     context.childrenTemplate = [' ']
     context.registerOperation({
index 2426fa0215eeee6a0360b22f67c9562494016d5e..531d29b055cebbc449de0d40f037179c09426d37 100644 (file)
@@ -59,6 +59,7 @@ export function processIf(
   } else {
     // check the adjacent v-if
     const siblingIf = getSiblingIf(context, true)
+    context.dynamic.ifBranch = true
 
     const siblings = context.parent && context.parent.dynamic.children
     let lastIfNode
index 346d2f813eb6d396be2f3ab39b0c8d6b31de82d6..0dc70bcd83774b963d716aba5ed8180314aacfdc 100644 (file)
@@ -394,7 +394,7 @@ function moveTeleport(
   }
 }
 
-interface TeleportTargetElement extends Element {
+export interface TeleportTargetElement extends Element {
   // last teleport target
   _lpa?: Node | null
 }
index 34ae2180916fc53df003757f626ca14c065b30a1..1a5a3d2fdde38768a288be36338492812c67be82 100644 (file)
@@ -659,6 +659,17 @@ export function createHydrationFunctions(
         )
       }
     }
+
+    // the server output does not contain blank text nodes. It appears here that
+    // it is a dynamically inserted anchor, and needs to be skipped.
+    // e.g. vaporInteropImpl.mount() > selfAnchor
+    if (
+      node &&
+      node.nodeType === DOMNodeTypes.TEXT &&
+      !(node as Text).data.trim()
+    ) {
+      node = nextSibling(node)
+    }
     return node
   }
 
index bbbd3d183b8620dca393bf87e2a14e30492071a5..b7d811710b796f0a3b7e1b42136296b52719dafa 100644 (file)
@@ -584,6 +584,13 @@ export {
   isTeleportDisabled,
   isTeleportDeferred,
 } from './components/Teleport'
+/**
+ * @internal
+ */
+export type { TeleportTargetElement } from './components/Teleport'
+/**
+ * @internal
+ */
 export {
   createAsyncComponentContext,
   useAsyncComponentState,
index d8ca5a606ee4998cdfe435165722ec305691b8d9..ad26b4fd98418b5535770b9ccdde0895a23152b9 100644 (file)
@@ -5,6 +5,8 @@ import * as runtimeVapor from '../src'
 import * as runtimeDom from '@vue/runtime-dom'
 import * as VueServerRenderer from '@vue/server-renderer'
 import { isString } from '@vue/shared'
+import type { VaporComponentInstance } from '../src/component'
+import type { TeleportFragment } from '../src/components/Teleport'
 
 const formatHtml = (raw: string) => {
   return raw
@@ -77,22 +79,34 @@ async function testWithVDOMApp(
   })
 }
 
+function compileVaporComponent(
+  code: string,
+  data: runtimeDom.Ref<any> = ref({}),
+  components?: Record<string, any>,
+  ssr = false,
+) {
+  return compile(`<template>${code}</template>`, data, components, {
+    vapor: true,
+    ssr,
+  })
+}
+
 async function mountWithHydration(
   html: string,
   code: string,
-  data: runtimeDom.Ref<any>,
+  data: runtimeDom.Ref<any> = ref({}),
+  components?: Record<string, any>,
 ) {
   const container = document.createElement('div')
   container.innerHTML = html
+  document.body.appendChild(container)
 
-  const clientComp = compile(`<template>${code}</template>`, data, undefined, {
-    vapor: true,
-    ssr: false,
-  })
+  const clientComp = compileVaporComponent(code, data, components)
   const app = createVaporSSRApp(clientComp)
   app.mount(container)
 
   return {
+    block: (app._instance! as VaporComponentInstance).block,
     container,
   }
 }
@@ -298,7 +312,7 @@ describe('Vapor Mode hydration', () => {
       <template><!----></template>
     `)
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`"<!---->"`)
-      expect(`Hydration children mismatch in <div>`).not.toHaveBeenWarned()
+      expect(`mismatch in <div>`).not.toHaveBeenWarned()
     })
 
     test('root with mixed element and text', async () => {
@@ -331,7 +345,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `"<div></div>"`,
       )
-      expect(`Hydration children mismatch in <div>`).not.toHaveBeenWarned()
+      expect(`mismatch in <div>`).not.toHaveBeenWarned()
     })
 
     test('element with binding and text children', async () => {
@@ -1588,7 +1602,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "
-        <!--[--><span>a</span><span>b</span><span>c</span><!--for-->"
+        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
+        "
       `,
       )
 
@@ -1597,7 +1612,35 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for-->"
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
+        "
+      `,
+      )
+    })
+
+    test('empty v-for', async () => {
+      const { container, data } = await testHydration(
+        `<template>
+          <span v-for="item in data" :key="item">{{ item }}</span>
+        </template>`,
+        undefined,
+        ref([]),
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><!--]-->
+        "
+      `,
+      )
+
+      data.value.push('a')
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `
+        "
+        <!--[--><span>a</span><!--]-->
+        "
       `,
       )
     })
@@ -1619,7 +1662,8 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[--><div>
-        <!--[--><span>a</span><span>b</span><span>c</span><!--for--></div><div>3</div><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
+        </div><div>3</div><!--]-->
         "
       `,
       )
@@ -1630,7 +1674,8 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[--><div>
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for--></div><div>4</div><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
+        </div><div>4</div><!--]-->
         "
       `,
       )
@@ -1651,7 +1696,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><!--for--><span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
+        <span></span></div>"
       `,
       )
 
@@ -1660,7 +1706,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for--><span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
+        <span></span></div>"
       `,
       )
 
@@ -1669,7 +1716,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>b</span><span>c</span><span>d</span><!--for--><span></span></div>"
+        <!--[--><span>b</span><span>c</span><span>d</span><!--]-->
+        <span></span></div>"
       `,
       )
     })
@@ -1690,8 +1738,9 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><!--for-->
-        <!--[--><span>a</span><span>b</span><span>c</span><!--for--><span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
+        <span></span></div>"
       `,
       )
 
@@ -1700,8 +1749,9 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for-->
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for--><span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
+        <span></span></div>"
       `,
       )
 
@@ -1710,8 +1760,9 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>c</span><span>d</span><!--for-->
-        <!--[--><span>c</span><span>d</span><!--for--><span></span></div>"
+        <!--[--><span>c</span><span>d</span><!--]-->
+        <!--[--><span>c</span><span>d</span><!--]-->
+        <span></span></div>"
       `,
       )
     })
@@ -1732,7 +1783,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div>
-        <!--[--><div>comp</div><div>comp</div><div>comp</div><!--for--></div>"
+        <!--[--><div>comp</div><div>comp</div><div>comp</div><!--]-->
+        </div>"
       `,
       )
 
@@ -1741,7 +1793,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div>
-        <!--[--><div>comp</div><div>comp</div><div>comp</div><div>comp</div><!--for--></div>"
+        <!--[--><div>comp</div><div>comp</div><div>comp</div><div>comp</div><!--]-->
+        </div>"
       `,
       )
     })
@@ -1767,7 +1820,8 @@ describe('Vapor Mode hydration', () => {
         <!--[--><span>a</span><!--]-->
         <!--[--><span>b</span><!--]-->
         <!--[--><span>c</span><!--]-->
-        <!--for--></div>"
+        <!--]-->
+        </div>"
       `,
       )
 
@@ -1780,7 +1834,8 @@ describe('Vapor Mode hydration', () => {
         <!--[--><span>a</span><!--]-->
         <!--[--><span>b</span><!--]-->
         <!--[--><span>c</span><!--]-->
-        <span>d</span><!--slot--><!--for--></div>"
+        <span>d</span><!--slot--><!--]-->
+        </div>"
       `,
       )
     })
@@ -1803,7 +1858,8 @@ describe('Vapor Mode hydration', () => {
         <!--[-->
         <!--[--><div>foo</div>-bar-<!--]-->
         <!--[--><div>foo</div>-bar-<!--]-->
-        <!--[--><div>foo</div>-bar-<!--for--><!--]-->
+        <!--[--><div>foo</div>-bar-<!--]-->
+        <!--]-->
         </div>"
       `,
       )
@@ -1816,7 +1872,8 @@ describe('Vapor Mode hydration', () => {
         <!--[-->
         <!--[--><div>foo</div>-bar-<!--]-->
         <!--[--><div>foo</div>-bar-<!--]-->
-        <!--[--><div>foo</div>-bar-<div>foo</div>-bar-<!--for--><!--]-->
+        <!--[--><div>foo</div>-bar-<div>foo</div>-bar-<!--]-->
+        <!--]-->
         </div>"
       `,
       )
@@ -1845,7 +1902,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div>
-        <!--[--><div><div><div>foo</div><!--if--></div><span>non-hydration node</span></div><div><div><div>foo</div><!--if--></div><span>non-hydration node</span></div><!--for--></div>"
+        <!--[--><div><div><div>foo</div><!--if--></div><span>non-hydration node</span></div><div><div><div>foo</div><!--if--></div><span>non-hydration node</span></div><!--]-->
+        </div>"
       `,
       )
 
@@ -1854,7 +1912,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div>
-        <!--[--><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><!--for--></div>"
+        <!--[--><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><!--]-->
+        </div>"
       `,
       )
 
@@ -1862,14 +1921,16 @@ describe('Vapor Mode hydration', () => {
       await nextTick()
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
         "<div>
-        <!--[--><div><div><!--if--></div><span>non-hydration node</span></div><div><div><!--if--></div><span>non-hydration node</span></div><!--for--></div>"
+        <!--[--><div><div><!--if--></div><span>non-hydration node</span></div><div><div><!--if--></div><span>non-hydration node</span></div><!--]-->
+        </div>"
       `)
 
       data.value.show = true
       await nextTick()
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
         "<div>
-        <!--[--><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><!--for--></div>"
+        <!--[--><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><div><div><div>bar</div><!--if--></div><span>non-hydration node</span></div><!--]-->
+        </div>"
       `)
     })
   })
@@ -2005,7 +2066,8 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[-->
-        <!--[--><span>a</span><span>b</span><span>c</span><!--for--><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
+        <!--]-->
         "
       `,
       )
@@ -2437,9 +2499,10 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[-->
-        <!--[--><div>a</div><div>b</div><div>c</div><!--for-->
+        <!--[--><div>a</div><div>b</div><div>c</div><!--]-->
         <!--[--><span>foo</span><!--]-->
-        <!--[--><div>a</div><div>b</div><div>c</div><!--for--><!--]-->
+        <!--[--><div>a</div><div>b</div><div>c</div><!--]-->
+        <!--]-->
         "
       `,
       )
@@ -2450,9 +2513,10 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[-->
-        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--for-->
+        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--]-->
         <!--[--><span>foo</span><!--]-->
-        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--for--><!--]-->
+        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--]-->
+        <!--]-->
         "
       `,
       )
@@ -2920,7 +2984,587 @@ describe('Vapor Mode hydration', () => {
     test.todo('force hydrate custom element with dynamic props', () => {})
   })
 
-  describe.todo('Teleport')
+  describe('Teleport', () => {
+    test('basic', async () => {
+      const data = ref({
+        msg: ref('foo'),
+        disabled: ref(false),
+        fn: vi.fn(),
+      })
+
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = 'teleport'
+      teleportContainer.innerHTML =
+        `<!--teleport start anchor-->` +
+        `<span>foo</span>` +
+        `<span class="foo"></span>` +
+        `<!--teleport anchor-->`
+      document.body.appendChild(teleportContainer)
+
+      const { block, container } = await mountWithHydration(
+        '<!--teleport start--><!--teleport end-->',
+        `<teleport to="#teleport" :disabled="data.disabled">
+          <span>{{data.msg}}</span>
+          <span :class="data.msg" @click="data.fn"></span>
+        </teleport>`,
+        data,
+      )
+
+      const teleport = block as TeleportFragment
+      expect(teleport.anchor).toBe(container.lastChild)
+      expect(teleport.target).toBe(teleportContainer)
+      expect(teleport.targetStart).toBe(teleportContainer.childNodes[0])
+      expect((teleport.nodes as Node[])[0]).toBe(
+        teleportContainer.childNodes[1],
+      )
+      expect((teleport.nodes as Node[])[1]).toBe(
+        teleportContainer.childNodes[2],
+      )
+      expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[3])
+
+      expect(container.innerHTML).toMatchInlineSnapshot(
+        `"<!--teleport start--><!--teleport end-->"`,
+      )
+
+      // event handler
+      triggerEvent('click', teleportContainer.querySelector('.foo')!)
+      expect(data.value.fn).toHaveBeenCalled()
+
+      data.value.msg = 'bar'
+      await nextTick()
+      expect(formatHtml(teleportContainer.innerHTML)).toBe(
+        `<!--teleport start anchor-->` +
+          `<span>bar</span>` +
+          `<span class="bar"></span>` +
+          `<!--teleport anchor-->`,
+      )
+
+      data.value.disabled = true
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<!--teleport start-->` +
+          `<span>bar</span>` +
+          `<span class="bar"></span>` +
+          `<!--teleport end-->`,
+      )
+      expect(formatHtml(teleportContainer.innerHTML)).toMatchInlineSnapshot(
+        `"<!--teleport start anchor--><!--teleport anchor-->"`,
+      )
+
+      data.value.msg = 'baz'
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<!--teleport start-->` +
+          `<span>baz</span>` +
+          `<span class="baz"></span>` +
+          `<!--teleport end-->`,
+      )
+
+      data.value.disabled = false
+      await nextTick()
+      expect(container.innerHTML).toMatchInlineSnapshot(
+        `"<!--teleport start--><!--teleport end-->"`,
+      )
+      expect(formatHtml(teleportContainer.innerHTML)).toBe(
+        `<!--teleport start anchor-->` +
+          `<span>baz</span>` +
+          `<span class="baz"></span>` +
+          `<!--teleport anchor-->`,
+      )
+    })
+
+    test('multiple + integration', async () => {
+      const data = ref({
+        msg: ref('foo'),
+        fn1: vi.fn(),
+        fn2: vi.fn(),
+      })
+
+      const code = `
+          <teleport to="#teleport2">
+            <span>{{data.msg}}</span>
+            <span :class="data.msg" @click="data.fn1"></span>
+          </teleport>
+          <teleport to="#teleport2">
+            <span>{{data.msg}}2</span>
+            <span :class="data.msg + 2" @click="data.fn2"></span>
+          </teleport>`
+
+      const SSRComp = compileVaporComponent(code, data, undefined, true)
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = 'teleport2'
+      const ctx = {} as any
+      const mainHtml = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+        ctx,
+      )
+      expect(mainHtml).toBe(
+        `<!--[-->` +
+          `<!--teleport start--><!--teleport end-->` +
+          `<!--teleport start--><!--teleport end-->` +
+          `<!--]-->`,
+      )
+
+      const teleportHtml = ctx.teleports!['#teleport2']
+      expect(teleportHtml).toBe(
+        `<!--teleport start anchor-->` +
+          `<span>foo</span><span class="foo"></span>` +
+          `<!--teleport anchor-->` +
+          `<!--teleport start anchor-->` +
+          `<span>foo2</span><span class="foo2"></span>` +
+          `<!--teleport anchor-->`,
+      )
+
+      teleportContainer.innerHTML = teleportHtml
+      document.body.appendChild(teleportContainer)
+
+      const { block, container } = await mountWithHydration(
+        mainHtml,
+        code,
+        data,
+      )
+
+      const teleports = block as any as TeleportFragment[]
+      const teleport1 = teleports[0]
+      const teleport2 = teleports[1]
+      expect(teleport1.anchor).toBe(container.childNodes[2])
+      expect(teleport2.anchor).toBe(container.childNodes[4])
+
+      expect(teleport1.target).toBe(teleportContainer)
+      expect(teleport1.targetStart).toBe(teleportContainer.childNodes[0])
+      expect((teleport1.nodes as Node[])[0]).toBe(
+        teleportContainer.childNodes[1],
+      )
+      expect(teleport1.targetAnchor).toBe(teleportContainer.childNodes[3])
+
+      expect(teleport2.target).toBe(teleportContainer)
+      expect(teleport2.targetStart).toBe(teleportContainer.childNodes[4])
+      expect((teleport2.nodes as Node[])[0]).toBe(
+        teleportContainer.childNodes[5],
+      )
+      expect(teleport2.targetAnchor).toBe(teleportContainer.childNodes[7])
+
+      expect(container.innerHTML).toBe(
+        `<!--[-->` +
+          `<!--teleport start--><!--teleport end-->` +
+          `<!--teleport start--><!--teleport end-->` +
+          `<!--]-->`,
+      )
+
+      // event handler
+      triggerEvent('click', teleportContainer.querySelector('.foo')!)
+      expect(data.value.fn1).toHaveBeenCalled()
+
+      triggerEvent('click', teleportContainer.querySelector('.foo2')!)
+      expect(data.value.fn2).toHaveBeenCalled()
+
+      data.value.msg = 'bar'
+      await nextTick()
+      expect(teleportContainer.innerHTML).toBe(
+        `<!--teleport start anchor-->` +
+          `<span>bar</span>` +
+          `<span class="bar"></span>` +
+          `<!--teleport anchor-->` +
+          `<!--teleport start anchor-->` +
+          `<span>bar2</span>` +
+          `<span class="bar2"></span>` +
+          `<!--teleport anchor-->`,
+      )
+    })
+
+    test('disabled', async () => {
+      const data = ref({
+        msg: ref('foo'),
+        fn1: vi.fn(),
+        fn2: vi.fn(),
+      })
+
+      const code = `
+          <div>foo</div>
+          <teleport to="#teleport3" disabled="true">
+            <span>{{data.msg}}</span>
+            <span :class="data.msg" @click="data.fn1"></span>
+          </teleport>
+          <div :class="data.msg + 2" @click="data.fn2">bar</div>
+          `
+
+      const SSRComp = compileVaporComponent(code, data, undefined, true)
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = 'teleport3'
+      const ctx = {} as any
+      const mainHtml = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+        ctx,
+      )
+      expect(mainHtml).toBe(
+        `<!--[-->` +
+          `<div>foo</div>` +
+          `<!--teleport start-->` +
+          `<span>foo</span>` +
+          `<span class="foo"></span>` +
+          `<!--teleport end-->` +
+          `<div class="foo2">bar</div>` +
+          `<!--]-->`,
+      )
+
+      const teleportHtml = ctx.teleports!['#teleport3']
+      expect(teleportHtml).toMatchInlineSnapshot(
+        `"<!--teleport start anchor--><!--teleport anchor-->"`,
+      )
+
+      teleportContainer.innerHTML = teleportHtml
+      document.body.appendChild(teleportContainer)
+
+      const { block, container } = await mountWithHydration(
+        mainHtml,
+        code,
+        data,
+      )
+
+      const blocks = block as any[]
+      expect(blocks[0]).toBe(container.childNodes[1])
+
+      const teleport = blocks[1] as TeleportFragment
+      expect((teleport.nodes as Node[])[0]).toBe(container.childNodes[3])
+      expect((teleport.nodes as Node[])[1]).toBe(container.childNodes[4])
+      expect(teleport.anchor).toBe(container.childNodes[5])
+      expect(teleport.target).toBe(teleportContainer)
+      expect(teleport.targetStart).toBe(teleportContainer.childNodes[0])
+      expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[1])
+      expect(blocks[2]).toBe(container.childNodes[6])
+
+      expect(container.innerHTML).toBe(
+        `<!--[-->` +
+          `<div>foo</div>` +
+          `<!--teleport start-->` +
+          `<span>foo</span>` +
+          `<span class="foo"></span>` +
+          `<!--teleport end-->` +
+          `<div class="foo2">bar</div>` +
+          `<!--]-->`,
+      )
+
+      // event handler
+      triggerEvent('click', container.querySelector('.foo')!)
+      expect(data.value.fn1).toHaveBeenCalled()
+
+      triggerEvent('click', container.querySelector('.foo2')!)
+      expect(data.value.fn2).toHaveBeenCalled()
+
+      data.value.msg = 'bar'
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<!--[-->` +
+          `<div>foo</div>` +
+          `<!--teleport start-->` +
+          `<span>bar</span>` +
+          `<span class="bar"></span>` +
+          `<!--teleport end-->` +
+          `<div class="bar2">bar</div>` +
+          `<!--]-->`,
+      )
+    })
+
+    test('disabled + as component root', async () => {
+      const { container } = await mountWithHydration(
+        `<!--[-->` +
+          `<div>Parent fragment</div>` +
+          `<!--teleport start--><div>Teleport content</div><!--teleport end-->` +
+          `<!--]-->`,
+        `
+          <div>Parent fragment</div>
+          <teleport to="body" disabled>
+            <div>Teleport content</div>
+          </teleport>
+        `,
+      )
+      expect(container.innerHTML).toBe(
+        `<!--[-->` +
+          `<div>Parent fragment</div>` +
+          `<!--teleport start-->` +
+          `<div>Teleport content</div>` +
+          `<!--teleport end-->` +
+          `<!--]-->`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('as component root', async () => {
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = 'teleport4'
+      teleportContainer.innerHTML = `<!--teleport start anchor-->hello<!--teleport anchor-->`
+      document.body.appendChild(teleportContainer)
+
+      const { block, container } = await mountWithHydration(
+        '<!--teleport start--><!--teleport end-->',
+        `<components.Wrapper></components.Wrapper>`,
+        undefined,
+        {
+          Wrapper: compileVaporComponent(
+            `<teleport to="#teleport4">hello</teleport>`,
+          ),
+        },
+      )
+
+      const teleport = (block as VaporComponentInstance)
+        .block as TeleportFragment
+      expect(teleport.anchor).toBe(container.childNodes[1])
+      expect(teleport.target).toBe(teleportContainer)
+      expect(teleport.targetStart).toBe(teleportContainer.childNodes[0])
+      expect(teleport.nodes).toBe(teleportContainer.childNodes[1])
+      expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[2])
+    })
+
+    test('nested', async () => {
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = 'teleport5'
+      teleportContainer.innerHTML =
+        `<!--teleport start anchor-->` +
+        `<!--teleport start--><!--teleport end-->` +
+        `<!--teleport anchor-->` +
+        `<!--teleport start anchor-->` +
+        `<div>child</div>` +
+        `<!--teleport anchor-->`
+      document.body.appendChild(teleportContainer)
+
+      const { block, container } = await mountWithHydration(
+        '<!--teleport start--><!--teleport end-->',
+        `<teleport to="#teleport5">
+          <teleport to="#teleport5"><div>child</div></teleport>
+        </teleport>`,
+      )
+
+      const teleport = block as TeleportFragment
+      expect(teleport.anchor).toBe(container.childNodes[1])
+      expect(teleport.targetStart).toBe(teleportContainer.childNodes[0])
+      expect(teleport.targetAnchor).toBe(teleportContainer.childNodes[3])
+
+      const childTeleport = teleport.nodes as TeleportFragment
+      expect(childTeleport.anchor).toBe(teleportContainer.childNodes[2])
+      expect(childTeleport.targetStart).toBe(teleportContainer.childNodes[4])
+      expect(childTeleport.targetAnchor).toBe(teleportContainer.childNodes[6])
+      expect(childTeleport.nodes).toBe(teleportContainer.childNodes[5])
+    })
+
+    test('unmount (full integration)', async () => {
+      const targetId = 'teleport6'
+      const data = ref({
+        toggle: ref(true),
+      })
+
+      const template1 = `<Teleport to="#${targetId}"><span>Teleported Comp1</span></Teleport>`
+      const Comp1 = compileVaporComponent(template1)
+      const SSRComp1 = compileVaporComponent(
+        template1,
+        undefined,
+        undefined,
+        true,
+      )
+
+      const template2 = `<div>Comp2</div>`
+      const Comp2 = compileVaporComponent(template2)
+      const SSRComp2 = compileVaporComponent(
+        template2,
+        undefined,
+        undefined,
+        true,
+      )
+
+      const appCode = `
+        <div>
+          <components.Comp1 v-if="data.toggle"/>
+          <components.Comp2 v-else/>
+        </div>
+      `
+
+      const SSRApp = compileVaporComponent(
+        appCode,
+        data,
+        {
+          Comp1: SSRComp1,
+          Comp2: SSRComp2,
+        },
+        true,
+      )
+
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = targetId
+      document.body.appendChild(teleportContainer)
+
+      const ctx = {} as any
+      const mainHtml = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+        ctx,
+      )
+      expect(mainHtml).toBe(
+        '<div><!--teleport start--><!--teleport end--></div>',
+      )
+      teleportContainer.innerHTML = ctx.teleports![`#${targetId}`]
+
+      const { container } = await mountWithHydration(mainHtml, appCode, data, {
+        Comp1,
+        Comp2,
+      })
+
+      expect(container.innerHTML).toBe(
+        '<div><!--teleport start--><!--teleport end--><!--if--></div>',
+      )
+      expect(teleportContainer.innerHTML).toBe(
+        `<!--teleport start anchor-->` +
+          `<span>Teleported Comp1</span>` +
+          `<!--teleport anchor-->`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+
+      data.value.toggle = false
+      await nextTick()
+      expect(container.innerHTML).toBe('<div><div>Comp2</div><!--if--></div>')
+      expect(teleportContainer.innerHTML).toBe('')
+    })
+
+    test('unmount (mismatch + full integration)', async () => {
+      const targetId = 'teleport7'
+      const data = ref({
+        toggle: ref(true),
+      })
+
+      const template1 = `<Teleport to="#${targetId}"><span>Teleported Comp1</span></Teleport>`
+      const Comp1 = compileVaporComponent(template1)
+      const SSRComp1 = compileVaporComponent(
+        template1,
+        undefined,
+        undefined,
+        true,
+      )
+
+      const template2 = `<div>Comp2</div>`
+      const Comp2 = compileVaporComponent(template2)
+      const SSRComp2 = compileVaporComponent(
+        template2,
+        undefined,
+        undefined,
+        true,
+      )
+
+      const appCode = `
+        <div>
+          <components.Comp1 v-if="data.toggle"/>
+          <components.Comp2 v-else/>
+        </div>
+      `
+
+      const SSRApp = compileVaporComponent(
+        appCode,
+        data,
+        {
+          Comp1: SSRComp1,
+          Comp2: SSRComp2,
+        },
+        true,
+      )
+
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = targetId
+      document.body.appendChild(teleportContainer)
+
+      const mainHtml = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRApp),
+      )
+      expect(mainHtml).toBe(
+        '<div><!--teleport start--><!--teleport end--></div>',
+      )
+      expect(teleportContainer.innerHTML).toBe('')
+
+      const { container } = await mountWithHydration(mainHtml, appCode, data, {
+        Comp1,
+        Comp2,
+      })
+
+      expect(container.innerHTML).toBe(
+        '<div><!--teleport start--><!--teleport end--><!--if--></div>',
+      )
+      expect(teleportContainer.innerHTML).toBe(`<span>Teleported Comp1</span>`)
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
+
+      data.value.toggle = false
+      await nextTick()
+      expect(container.innerHTML).toBe('<div><div>Comp2</div><!--if--></div>')
+      expect(teleportContainer.innerHTML).toBe('')
+    })
+
+    test('target change (mismatch + full integration)', async () => {
+      const targetId1 = 'teleport8-1'
+      const targetId2 = 'teleport8-2'
+      const data = ref({
+        target: ref(targetId1),
+        msg: ref('foo'),
+      })
+
+      const template = `<Teleport :to="'#' + data.target"><span>{{data.msg}}</span></Teleport>`
+      const Comp = compileVaporComponent(template, data)
+      const SSRComp = compileVaporComponent(template, data, undefined, true)
+
+      const teleportContainer1 = document.createElement('div')
+      teleportContainer1.id = targetId1
+      const teleportContainer2 = document.createElement('div')
+      teleportContainer2.id = targetId2
+      document.body.appendChild(teleportContainer1)
+      document.body.appendChild(teleportContainer2)
+
+      // server render
+      const mainHtml = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+      )
+      expect(mainHtml).toBe(`<!--teleport start--><!--teleport end-->`)
+      expect(teleportContainer1.innerHTML).toBe('')
+      expect(teleportContainer2.innerHTML).toBe('')
+
+      // hydrate
+      const { container } = await mountWithHydration(mainHtml, template, data, {
+        Comp,
+      })
+
+      expect(container.innerHTML).toBe(
+        `<!--teleport start--><!--teleport end-->`,
+      )
+      expect(teleportContainer1.innerHTML).toBe(`<span>foo</span>`)
+      expect(teleportContainer2.innerHTML).toBe('')
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
+
+      data.value.target = targetId2
+      data.value.msg = 'bar'
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<!--teleport start--><!--teleport end-->`,
+      )
+      expect(teleportContainer1.innerHTML).toBe('')
+      expect(teleportContainer2.innerHTML).toBe(`<span>bar</span>`)
+    })
+
+    test('with disabled teleport + undefined target', async () => {
+      const data = ref({
+        msg: ref('foo'),
+      })
+
+      const { container } = await mountWithHydration(
+        '<!--teleport start--><span>foo</span><!--teleport end-->',
+        `<teleport :to="undefined" :disabled="true">
+          <span>{{data.msg}}</span>
+        </teleport>`,
+        data,
+      )
+
+      expect(container.innerHTML).toBe(
+        `<!--teleport start--><span>foo</span><!--teleport end-->`,
+      )
+
+      data.value.msg = 'bar'
+      await nextTick()
+      expect(container.innerHTML).toBe(
+        `<!--teleport start--><span>bar</span><!--teleport end-->`,
+      )
+    })
+  })
 
   describe.todo('Suspense')
 })
index 7fdc312146b408acac3baf996fff7df176199444..52a23e9fa805c31a86ceebad0a575c3ac71c7730 100644 (file)
@@ -27,8 +27,9 @@ import { renderEffect } from './renderEffect'
 import { VaporVForFlags } from '../../shared/src/vaporFlags'
 import {
   advanceHydrationNode,
+  currentHydrationNode,
+  isComment,
   isHydrating,
-  locateFragmentEndAnchor,
   locateHydrationNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
@@ -145,12 +146,12 @@ export const createFor = (
             findLastChild(newBlocks[newLength - 1].nodes)!.nextSibling,
           )
         }
-        parentAnchor = locateFragmentEndAnchor()!
-        if (__DEV__) {
-          if (!parentAnchor) {
-            throw new Error(`v-for fragment anchor node was not found.`)
-          }
-          ;(parentAnchor as Comment).data = 'for'
+        parentAnchor =
+          newLength === 0
+            ? currentHydrationNode!.nextSibling!
+            : currentHydrationNode!
+        if (!parentAnchor || (parentAnchor && !isComment(parentAnchor, ']'))) {
+          throw new Error(`v-for fragment anchor node was not found.`)
         }
       }
     } else {
index aeac078ae20c16158adeae0060aae8ec3e2ddcd7..070b5f98b13ede5e6ef72ee964e906979c1b3f16 100644 (file)
@@ -19,7 +19,8 @@ import {
   type VaporFragment,
   isFragment,
 } from './fragment'
-import { child } from './dom/node'
+import { _child } from './dom/node'
+import { TeleportFragment } from './components/Teleport'
 
 export interface TransitionOptions {
   $key?: any
@@ -68,11 +69,11 @@ export function isValidBlock(block: Block): boolean {
 
 export function insert(
   block: Block,
-  parent: ParentNode & { $anchor?: Node | null },
+  parent: ParentNode & { $prependAnchor?: Node | null },
   anchor: Node | null | 0 = null, // 0 means prepend
   parentSuspense?: any, // TODO Suspense
 ): void {
-  anchor = anchor === 0 ? child(parent) : anchor
+  anchor = anchor === 0 ? parent.$prependAnchor || _child(parent) : anchor
   if (block instanceof Node) {
     if (!isHydrating) {
       // only apply transition on Element nodes
@@ -182,12 +183,12 @@ export function normalizeBlock(block: Block): Node[] {
   } else if (isVaporComponent(block)) {
     nodes.push(...normalizeBlock(block.block!))
   } else {
-    if (block.getNodes) {
-      nodes.push(...normalizeBlock(block.getNodes()))
+    if (block instanceof TeleportFragment) {
+      nodes.push(block.placeholder!, block.anchor!)
     } else {
       nodes.push(...normalizeBlock(block.nodes))
+      block.anchor && nodes.push(block.anchor)
     }
-    block.anchor && nodes.push(block.anchor)
   }
   return nodes
 }
index 8d703220921952cc4616fbbe6a0a757f5b3a7594..d7e9fc5abeccff1d830e5d2b6b0a289daee37373 100644 (file)
@@ -52,7 +52,7 @@ import {
   resolveDynamicProps,
   setupPropsValidation,
 } from './componentProps'
-import { renderEffect } from './renderEffect'
+import { type RenderEffect, renderEffect } from './renderEffect'
 import { emit, normalizeEmitsOptions } from './componentEmits'
 import { setDynamicProps } from './dom/prop'
 import {
@@ -73,7 +73,7 @@ import {
   locateHydrationNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
-import { isVaporTeleport } from './components/Teleport'
+import { type TeleportFragment, isVaporTeleport } from './components/Teleport'
 import {
   insertionAnchor,
   insertionParent,
@@ -445,8 +445,9 @@ export class VaporComponentInstance implements GenericComponentInstance {
   setupState?: Record<string, any>
   devtoolsRawSetupState?: any
   hmrRerender?: () => void
-  hmrRerenderEffects?: (() => void)[]
   hmrReload?: (newComp: VaporComponent) => void
+  renderEffects?: RenderEffect[]
+  parentTeleport?: TeleportFragment | null
   propsOptions?: NormalizedPropsOptions
   emitsOptions?: ObjectEmitsOptions | null
   isSingleRoot?: boolean
index 637985ddca05a38dc636b5a03a0bccffb4543ac5..ef3d4598c9be7f956b11fc3094fbb716f14e11c6 100644 (file)
@@ -1,6 +1,8 @@
 import {
+  MismatchTypes,
   type TeleportProps,
-  currentInstance,
+  type TeleportTargetElement,
+  isMismatchAllowed,
   isTeleportDeferred,
   isTeleportDisabled,
   queuePostFlushCb,
@@ -12,16 +14,21 @@ import { createComment, createTextNode, querySelector } from '../dom/node'
 import {
   type LooseRawProps,
   type LooseRawSlots,
-  type VaporComponentInstance,
   isVaporComponent,
 } from '../component'
 import { rawPropsProxyHandlers } from '../componentProps'
 import { renderEffect } from '../renderEffect'
 import { extend, isArray } from '@vue/shared'
 import { VaporFragment } from '../fragment'
-
-const instanceToTeleportMap: WeakMap<VaporComponentInstance, TeleportFragment> =
-  __DEV__ ? new WeakMap() : (undefined as any)
+import {
+  advanceHydrationNode,
+  currentHydrationNode,
+  isComment,
+  isHydrating,
+  logMismatchError,
+  runWithoutHydration,
+  setCurrentHydrationNode,
+} from '../dom/hydration'
 
 export const VaporTeleportImpl = {
   name: 'VaporTeleport',
@@ -29,102 +36,90 @@ export const VaporTeleportImpl = {
   __vapor: true,
 
   process(props: LooseRawProps, slots: LooseRawSlots): TeleportFragment {
-    const frag = new TeleportFragment()
-    const updateChildrenEffect = renderEffect(() =>
-      frag.updateChildren(slots.default && (slots.default as BlockFn)()),
-    )
-
-    const updateEffect = renderEffect(() => {
-      // access the props to trigger tracking
-      frag.props = extend(
-        {},
-        new Proxy(props, rawPropsProxyHandlers) as any as TeleportProps,
-      )
-      frag.update()
-    })
-
-    if (__DEV__) {
-      // used in `normalizeBlock` to get nodes of TeleportFragment during
-      // HMR updates. returns empty array if content is mounted in target
-      // container to prevent incorrect parent node lookup.
-      frag.getNodes = () => {
-        return frag.parent !== frag.currentParent ? [] : frag.nodes
-      }
-
-      // for HMR rerender
-      const instance = currentInstance as VaporComponentInstance
-      ;(
-        instance!.hmrRerenderEffects || (instance!.hmrRerenderEffects = [])
-      ).push(() => {
-        // remove the teleport content
-        frag.remove()
-
-        // stop effects
-        updateChildrenEffect.stop()
-        updateEffect.stop()
-      })
-
-      // for HMR reload
-      const nodes = frag.nodes
-      if (isVaporComponent(nodes)) {
-        instanceToTeleportMap.set(nodes, frag)
-      } else if (isArray(nodes)) {
-        nodes.forEach(
-          node =>
-            isVaporComponent(node) && instanceToTeleportMap.set(node, frag),
-        )
-      }
-    }
-
-    return frag
+    return new TeleportFragment(props, slots)
   },
 }
 
 export class TeleportFragment extends VaporFragment {
+  anchor?: Node
+  private rawProps?: LooseRawProps
+  private resolvedProps?: TeleportProps
+  private rawSlots?: LooseRawSlots
+
   target?: ParentNode | null
   targetAnchor?: Node | null
-  anchor: Node
-  props?: TeleportProps
+  targetStart?: Node | null
 
-  private targetStart?: Node
-  private mainAnchor?: Node
-  private placeholder?: Node
-  private mountContainer?: ParentNode | null
-  private mountAnchor?: Node | null
+  placeholder?: Node
+  mountContainer?: ParentNode | null
+  mountAnchor?: Node | null
 
-  constructor() {
+  constructor(props: LooseRawProps, slots: LooseRawSlots) {
     super([])
-    this.anchor = createTextNode()
-  }
+    this.rawProps = props
+    this.rawSlots = slots
+    this.anchor = isHydrating
+      ? undefined
+      : __DEV__
+        ? createComment('teleport end')
+        : createTextNode()
 
-  get currentParent(): ParentNode {
-    return (this.mountContainer || this.parent)!
-  }
+    renderEffect(() => {
+      // access the props to trigger tracking
+      this.resolvedProps = extend(
+        {},
+        new Proxy(
+          this.rawProps!,
+          rawPropsProxyHandlers,
+        ) as any as TeleportProps,
+      )
+      this.handlePropsUpdate()
+    })
 
-  get currentAnchor(): Node | null {
-    return this.mountAnchor || this.anchor
+    if (!isHydrating) {
+      this.initChildren()
+    }
   }
 
   get parent(): ParentNode | null {
-    return this.anchor && this.anchor.parentNode
+    return this.anchor ? this.anchor.parentNode : null
   }
 
-  updateChildren(children: Block): void {
+  private initChildren(): void {
+    renderEffect(() => {
+      this.handleChildrenUpdate(
+        this.rawSlots!.default && (this.rawSlots!.default as BlockFn)(),
+      )
+    })
+
+    if (__DEV__) {
+      const nodes = this.nodes
+      if (isVaporComponent(nodes)) {
+        nodes.parentTeleport = this
+      } else if (isArray(nodes)) {
+        nodes.forEach(
+          node => isVaporComponent(node) && (node.parentTeleport = this),
+        )
+      }
+    }
+  }
+
+  private handleChildrenUpdate(children: Block): void {
     // not mounted yet
-    if (!this.parent) {
+    if (!this.parent || isHydrating) {
       this.nodes = children
       return
     }
 
     // teardown previous nodes
-    remove(this.nodes, this.currentParent)
+    remove(this.nodes, this.mountContainer!)
     // mount new nodes
-    insert((this.nodes = children), this.currentParent, this.currentAnchor)
+    insert((this.nodes = children), this.mountContainer!, this.mountAnchor!)
   }
 
-  update(): void {
+  private handlePropsUpdate(): void {
     // not mounted yet
-    if (!this.parent) return
+    if (!this.parent || isHydrating) return
 
     const mount = (parent: ParentNode, anchor: Node | null) => {
       insert(
@@ -136,7 +131,7 @@ export class TeleportFragment extends VaporFragment {
 
     const mountToTarget = () => {
       const target = (this.target = resolveTeleportTarget(
-        this.props!,
+        this.resolvedProps!,
         querySelector,
       ))
       if (target) {
@@ -161,12 +156,12 @@ export class TeleportFragment extends VaporFragment {
     }
 
     // mount into main container
-    if (isTeleportDisabled(this.props!)) {
-      mount(this.parent, this.mainAnchor!)
+    if (isTeleportDisabled(this.resolvedProps!)) {
+      mount(this.parent, this.anchor!)
     }
     // mount into target container
     else {
-      if (isTeleportDeferred(this.props!)) {
+      if (isTeleportDeferred(this.resolvedProps!)) {
         queuePostFlushCb(mountToTarget)
       } else {
         mountToTarget()
@@ -175,20 +170,21 @@ export class TeleportFragment extends VaporFragment {
   }
 
   insert = (container: ParentNode, anchor: Node | null): void => {
+    if (isHydrating) return
+
     // insert anchors in the main view
     this.placeholder = __DEV__
       ? createComment('teleport start')
       : createTextNode()
-    this.mainAnchor = __DEV__ ? createComment('teleport end') : createTextNode()
     insert(this.placeholder, container, anchor)
-    insert(this.mainAnchor, container, anchor)
-    this.update()
+    insert(this.anchor!, container, anchor)
+    this.handlePropsUpdate()
   }
 
   remove = (parent: ParentNode | undefined = this.parent!): void => {
     // remove nodes
     if (this.nodes) {
-      remove(this.nodes, this.currentParent)
+      remove(this.nodes, this.mountContainer!)
       this.nodes = []
     }
 
@@ -200,19 +196,99 @@ export class TeleportFragment extends VaporFragment {
       this.targetAnchor = undefined
     }
 
+    if (this.anchor) {
+      remove(this.anchor, this.anchor.parentNode!)
+      this.anchor = undefined
+    }
+
     if (this.placeholder) {
       remove(this.placeholder!, parent)
       this.placeholder = undefined
-      remove(this.mainAnchor!, parent)
-      this.mainAnchor = undefined
     }
 
     this.mountContainer = undefined
     this.mountAnchor = undefined
   }
 
+  private hydrateDisabledTeleport(targetNode: Node | null): void {
+    let nextNode = this.placeholder!.nextSibling!
+    setCurrentHydrationNode(nextNode)
+    this.mountAnchor = this.anchor = locateTeleportEndAnchor(nextNode)!
+    this.mountContainer = this.anchor.parentNode
+    this.targetStart = targetNode
+    this.targetAnchor = targetNode && targetNode.nextSibling
+    this.initChildren()
+  }
+
+  private mount(target: Node): void {
+    target.appendChild((this.targetStart = createTextNode('')))
+    target.appendChild(
+      (this.mountAnchor = this.targetAnchor = createTextNode('')),
+    )
+
+    if (!isMismatchAllowed(target as Element, MismatchTypes.CHILDREN)) {
+      if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
+        warn(
+          `Hydration children mismatch on`,
+          target,
+          `\nServer rendered element contains fewer child nodes than client nodes.`,
+        )
+      }
+      logMismatchError()
+    }
+
+    runWithoutHydration(this.initChildren.bind(this))
+  }
+
   hydrate = (): void => {
-    // TODO
+    const target = (this.target = resolveTeleportTarget(
+      this.resolvedProps!,
+      querySelector,
+    ))
+    const disabled = isTeleportDisabled(this.resolvedProps!)
+    this.placeholder = currentHydrationNode!
+    if (target) {
+      const targetNode =
+        (target as TeleportTargetElement)._lpa || target.firstChild
+      if (disabled) {
+        this.hydrateDisabledTeleport(targetNode)
+      } else {
+        this.anchor = locateTeleportEndAnchor()!
+        this.mountContainer = target
+        let targetAnchor = targetNode
+        while (targetAnchor) {
+          if (targetAnchor && targetAnchor.nodeType === 8) {
+            if ((targetAnchor as Comment).data === 'teleport start anchor') {
+              this.targetStart = targetAnchor
+            } else if ((targetAnchor as Comment).data === 'teleport anchor') {
+              this.mountAnchor = this.targetAnchor = targetAnchor
+              ;(target as TeleportTargetElement)._lpa =
+                this.targetAnchor && this.targetAnchor.nextSibling
+              break
+            }
+          }
+          targetAnchor = targetAnchor.nextSibling
+        }
+
+        if (targetNode) {
+          setCurrentHydrationNode(targetNode.nextSibling)
+        }
+
+        // if the HTML corresponding to Teleport is not embedded in the
+        // correct position on the final page during SSR. the targetAnchor will
+        // always be null, we need to manually add targetAnchor to ensure
+        // Teleport it can properly unmount or move
+        if (!this.targetAnchor) {
+          this.mount(target)
+        } else {
+          this.initChildren()
+        }
+      }
+    } else if (disabled) {
+      this.hydrateDisabledTeleport(currentHydrationNode!)
+    }
+
+    advanceHydrationNode(this.anchor!)
   }
 }
 
@@ -222,24 +298,14 @@ export function isVaporTeleport(
   return value === VaporTeleportImpl
 }
 
-/**
- * dev only
- * during root component HMR reload, since the old component will be unmounted
- * and a new one will be mounted, we need to update the teleport's nodes
- * to ensure they are up to date.
- */
-export function handleTeleportRootComponentHmrReload(
-  instance: VaporComponentInstance,
-  newInstance: VaporComponentInstance,
-): void {
-  const teleport = instanceToTeleportMap.get(instance)
-  if (teleport) {
-    instanceToTeleportMap.set(newInstance, teleport)
-    if (teleport.nodes === instance) {
-      teleport.nodes = newInstance
-    } else if (isArray(teleport.nodes)) {
-      const i = teleport.nodes.indexOf(instance)
-      if (i !== -1) teleport.nodes[i] = newInstance
+function locateTeleportEndAnchor(
+  node: Node = currentHydrationNode!,
+): Node | null {
+  while (node) {
+    if (isComment(node, 'teleport end')) {
+      return node
     }
+    node = node.nextSibling as Node
   }
+  return null
 }
index ef1fb1375ae68f9a7cc4388d014085d224ab6a34..f6785f348c3e9a6b2b9b822e18e64504ed7c24e5 100644 (file)
@@ -1,19 +1,19 @@
 import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom'
 import {
   type ChildItem,
-  incrementIndexOffset,
   insertionAnchor,
   insertionParent,
   resetInsertionState,
   setInsertionState,
 } from '../insertionState'
 import {
+  _child,
   _next,
-  child,
   createElement,
   createTextNode,
   disableHydrationNodeLookup,
   enableHydrationNodeLookup,
+  locateChildByLogicalIndex,
   parentNode,
 } from './node'
 import { remove } from '../block'
@@ -22,6 +22,15 @@ const isHydratingStack = [] as boolean[]
 export let isHydrating = false
 export let currentHydrationNode: Node | null = null
 
+export function runWithoutHydration(fn: () => any): any {
+  try {
+    isHydrating = false
+    return fn()
+  } finally {
+    isHydrating = true
+  }
+}
+
 let isOptimized = false
 
 function performHydration<T>(
@@ -37,12 +46,9 @@ function performHydration<T>(
     ;(Node.prototype as any).$pns = undefined
     ;(Node.prototype as any).$uc = undefined
     ;(Node.prototype as any).$idx = undefined
-    ;(Node.prototype as any).$children = undefined
-    ;(Node.prototype as any).$idxMap = undefined
     ;(Node.prototype as any).$prevDynamicCount = undefined
     ;(Node.prototype as any).$anchorCount = undefined
     ;(Node.prototype as any).$appendIndex = undefined
-    ;(Node.prototype as any).$indexOffset = undefined
 
     isOptimized = true
   }
@@ -120,7 +126,6 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
       ) {
         const parent = parentNode(node)!
         node = parent.insertBefore(createTextNode(), node)
-        incrementIndexOffset(parent)
         break
       }
     }
@@ -143,19 +148,16 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
 
 function locateHydrationNodeImpl(): void {
   let node: Node | null
-  let idxMap: number[] | undefined
-  if (insertionAnchor !== undefined && (idxMap = insertionParent!.$idxMap)) {
+  if (insertionAnchor !== undefined) {
     const {
       $prevDynamicCount: prevDynamicCount = 0,
       $appendIndex: appendIndex,
-      $indexOffset: indexOffset = 0,
       $anchorCount: anchorCount = 0,
     } = insertionParent!
     // prepend
     if (insertionAnchor === 0) {
       // use prevDynamicCount as logical index to locate the hydration node
-      const realIndex = idxMap![prevDynamicCount] + indexOffset
-      node = insertionParent!.childNodes[realIndex]
+      node = locateChildByLogicalIndex(insertionParent!, prevDynamicCount)!
     }
     // insert
     else if (insertionAnchor instanceof Node) {
@@ -166,11 +168,13 @@ function locateHydrationNodeImpl(): void {
       // consecutive insert operations locate the correct hydration node.
       let { $idx, $uc: usedCount } = insertionAnchor as ChildItem
       if (usedCount !== undefined) {
-        const realIndex = idxMap![$idx + usedCount + 1] + indexOffset
-        node = insertionParent!.childNodes[realIndex]
+        node = locateChildByLogicalIndex(
+          insertionParent!,
+          ($idx || 0) + usedCount + 1,
+        )!
         usedCount++
       } else {
-        node = insertionAnchor
+        insertionParent!.$lastLogicalChild = node = insertionAnchor
         // first use of this anchor: it doesn't consume the next child
         // so we track unique anchor appearances for later offset correction
         insertionParent!.$anchorCount = anchorCount + 1
@@ -180,22 +184,16 @@ function locateHydrationNodeImpl(): void {
     }
     // append
     else {
-      let realIndex: number
       if (appendIndex !== null && appendIndex !== undefined) {
-        realIndex = idxMap![appendIndex + 1] + indexOffset
-        node = insertionParent!.childNodes[realIndex]
+        node = locateChildByLogicalIndex(insertionParent!, appendIndex + 1)!
       } else {
         if (insertionAnchor === null) {
-          // insertionAnchor is null, indicates no previous static nodes
-          // use the first child as hydration node
-          realIndex = idxMap![0] + indexOffset
-          node = insertionParent!.childNodes[realIndex]
+          node = locateChildByLogicalIndex(insertionParent!, 0)!
         } else {
-          // insertionAnchor is a number > 0
-          // indicates how many static nodes precede the node to append
-          // use it as index to locate the hydration node
-          realIndex = idxMap![prevDynamicCount + insertionAnchor] + indexOffset
-          node = insertionParent!.childNodes[realIndex]
+          node = locateChildByLogicalIndex(
+            insertionParent!,
+            prevDynamicCount + insertionAnchor,
+          )!
         }
       }
       insertionParent!.$appendIndex = (node as ChildItem).$idx
@@ -246,15 +244,6 @@ export function locateEndAnchor(
   return null
 }
 
-export function locateFragmentEndAnchor(label: string = ']'): Comment | null {
-  let node = currentHydrationNode!
-  while (node) {
-    if (isComment(node, label)) return node
-    node = node.nextSibling!
-  }
-  return null
-}
-
 function handleMismatch(node: Node, template: string): Node {
   if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
     ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
@@ -297,7 +286,7 @@ function handleMismatch(node: Node, template: string): Node {
   // element node
   const t = createElement('template') as HTMLTemplateElement
   t.innerHTML = template
-  const newNode = child(t.content).cloneNode(true) as Element
+  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)
index 945c55fe02e81806ee098e5f8149dad7ebce1f75..5ec574e855710b101057034d814edb44b7b760dc 100644 (file)
@@ -1,6 +1,7 @@
 /* @__NO_SIDE_EFFECTS__ */
 
 import type { ChildItem, InsertionParent } from '../insertionState'
+import { isComment, locateEndAnchor } from './hydration'
 
 export function createElement(tagName: string): HTMLElement {
   return document.createElement(tagName)
@@ -30,14 +31,14 @@ export function parentNode(node: Node): ParentNode | null {
 const _txt: typeof _child = _child
 
 /**
- * Hydration-specific version of `child`.
+ * Hydration-specific version of `txt`.
  */
 /* @__NO_SIDE_EFFECTS__ */
-const __txt: typeof __child = (node: ParentNode): Node => {
+const __txt = (node: ParentNode): Node => {
   let n = node.firstChild!
 
-  // since SSR doesn't generate whitespace placeholder text nodes, if firstChild
-  // is null, manually insert a text node as the first child
+  // since SSR doesn't generate blank text nodes,
+  // manually insert a text node as the first child
   if (!n) {
     return node.appendChild(createTextNode())
   }
@@ -47,74 +48,44 @@ const __txt: typeof __child = (node: ParentNode): Node => {
 
 /* @__NO_SIDE_EFFECTS__ */
 export function _child(node: InsertionParent): Node {
-  const children = node.$children
-  return children ? children[0] : node.firstChild!
+  return node.firstChild!
 }
 
 /**
  * Hydration-specific version of `child`.
  */
 /* @__NO_SIDE_EFFECTS__ */
-export function __child(node: ParentNode): Node {
-  return __nthChild(node, 0)!
+export function __child(node: ParentNode, logicalIndex: number = 0): Node {
+  return locateChildByLogicalIndex(node as InsertionParent, logicalIndex)!
 }
 
 /* @__NO_SIDE_EFFECTS__ */
 export function _nthChild(node: InsertionParent, i: number): Node {
-  const children = node.$children
-  return children ? children[i] : node.childNodes[i]
+  return node.childNodes[i]
 }
 
 /**
  * Hydration-specific version of `nthChild`.
  */
 /* @__NO_SIDE_EFFECTS__ */
-export function __nthChild(node: Node, i: number): Node {
-  const parent = node as InsertionParent
-  if (parent.$idxMap) {
-    const {
-      $prevDynamicCount: prevDynamicCount = 0,
-      $anchorCount: anchorCount = 0,
-      $idxMap: idxMap,
-      $indexOffset: indexOffset = 0,
-    } = parent
-    // prevDynamicCount tracks how many dynamic nodes have been processed
-    // so far (prepend/insert/append).
-    // For anchor-based insert, the first time an anchor is used we adopt the
-    // anchor node itself and do NOT consume the next child in `idxMap`,
-    // yet prevDynamicCount is still incremented. This overcounts the base
-    // offset by 1 per unique anchor that has appeared.
-    // anchorCount equals the number of unique anchors seen, so we
-    // subtract it to neutralize those "first-use doesn't consume" cases:
-    //   base = prevDynamicCount - anchorCount
-    // Then index from this base: idxMap[base + i] + indexOffset.
-    const logicalIndex = prevDynamicCount - anchorCount + i
-    const realIndex = idxMap[logicalIndex] + indexOffset
-    return node.childNodes[realIndex]
-  }
-  return node.childNodes[i]
+export function __nthChild(node: Node, logicalIndex: number): Node {
+  return locateChildByLogicalIndex(node as InsertionParent, logicalIndex)!
 }
 
 /* @__NO_SIDE_EFFECTS__ */
 export function _next(node: Node): Node {
-  const children = (node.parentNode! as InsertionParent).$children
-  return children ? children[(node as ChildItem).$idx + 1] : node.nextSibling!
+  return node.nextSibling!
 }
 
 /**
  * Hydration-specific version of `next`.
  */
 /* @__NO_SIDE_EFFECTS__ */
-export function __next(node: Node): Node {
-  const parent = node.parentNode! as InsertionParent
-  if (parent.$idxMap) {
-    const { $idxMap: idxMap, $indexOffset: indexOffset = 0 } = parent
-    const { $idx, $uc: usedCount = 0 } = node as ChildItem
-    const logicalIndex = $idx + usedCount + 1
-    const realIndex = idxMap[logicalIndex] + indexOffset
-    return node.parentNode!.childNodes[realIndex]
-  }
-  return node.nextSibling!
+export function __next(node: Node, logicalIndex: number): Node {
+  return locateChildByLogicalIndex(
+    node.parentNode! as InsertionParent,
+    logicalIndex,
+  )!
 }
 
 type DelegatedFunction<T extends (...args: any[]) => any> = T & {
@@ -122,26 +93,26 @@ type DelegatedFunction<T extends (...args: any[]) => any> = T & {
 }
 
 /* @__NO_SIDE_EFFECTS__ */
-export const txt: DelegatedFunction<typeof _txt> = node => {
-  return txt.impl(node)
+export const txt: DelegatedFunction<typeof _txt> = (...args) => {
+  return txt.impl(...args)
 }
-txt.impl = _child
+txt.impl = _txt
 
 /* @__NO_SIDE_EFFECTS__ */
-export const child: DelegatedFunction<typeof _child> = node => {
-  return child.impl(node)
+export const child: DelegatedFunction<typeof _child> = (...args) => {
+  return child.impl(...args)
 }
 child.impl = _child
 
 /* @__NO_SIDE_EFFECTS__ */
-export const next: DelegatedFunction<typeof _next> = node => {
-  return next.impl(node)
+export const next: DelegatedFunction<typeof _next> = (...args) => {
+  return next.impl(...args)
 }
 next.impl = _next
 
 /* @__NO_SIDE_EFFECTS__ */
-export const nthChild: DelegatedFunction<typeof _nthChild> = (node, i) => {
-  return nthChild.impl(node, i)
+export const nthChild: DelegatedFunction<typeof _nthChild> = (...args) => {
+  return nthChild.impl(...args)
 }
 nthChild.impl = _nthChild
 
@@ -155,9 +126,9 @@ nthChild.impl = _nthChild
  */
 export function enableHydrationNodeLookup(): void {
   txt.impl = __txt
-  child.impl = __child
-  next.impl = __next
-  nthChild.impl = __nthChild
+  child.impl = __child as typeof _child
+  next.impl = __next as typeof _next
+  nthChild.impl = __nthChild as any as typeof _nthChild
 }
 
 export function disableHydrationNodeLookup(): void {
@@ -166,3 +137,29 @@ export function disableHydrationNodeLookup(): void {
   next.impl = _next
   nthChild.impl = _nthChild
 }
+
+export function locateChildByLogicalIndex(
+  node: InsertionParent,
+  logicalIndex: number,
+): Node | null {
+  let child = (node.$lastLogicalChild || node.firstChild) as ChildItem
+  let fromIndex = child.$idx || 0
+
+  while (child) {
+    if (fromIndex === logicalIndex) {
+      child.$idx = logicalIndex
+      return (node.$lastLogicalChild = child)
+    }
+
+    child = (
+      isComment(child, '[')
+        ? // fragment start: jump to the node after the matching end anchor
+          locateEndAnchor(child)!.nextSibling
+        : child.nextSibling
+    ) as ChildItem
+
+    fromIndex++
+  }
+
+  return null
+}
index 4646572ea7c154e81e137283217dbaf41552a750..b9a607b3419f45d426956a4f5297a0b23ba0093b 100644 (file)
@@ -386,8 +386,7 @@ export function optimizePropertyLookup(): void {
   const proto = Element.prototype as any
   proto.$transition = undefined
   proto.$key = undefined
-  proto.$evtclick = undefined
-  proto.$children = undefined
+  proto.$prependAnchor = proto.$evtclick = undefined
   proto.$idx = undefined
   proto.$root = false
   proto.$html =
index 2022cf339d498742af9d56e9abdfc2c4eac3e14f..7349113011a12140dcac581d87586331199fb952 100644 (file)
@@ -1,15 +1,8 @@
 import { adoptTemplate, currentHydrationNode, isHydrating } from './hydration'
-import { child, createElement, createTextNode } from './node'
+import { _child, createElement, createTextNode } from './node'
 
 let t: HTMLTemplateElement
 
-export let currentTemplateFn: (Function & { $idxMap?: number[] }) | undefined =
-  undefined
-
-export function resetTemplateFn(): void {
-  currentTemplateFn = undefined
-}
-
 /*! #__NO_SIDE_EFFECTS__ */
 export function template(
   html: string,
@@ -18,8 +11,6 @@ export function template(
   let node: Node
   const fn = () => {
     if (isHydrating) {
-      currentTemplateFn = fn
-
       // do not cache the adopted node in node because it contains child nodes
       // this avoids duplicate rendering of children
       const adopted = adoptTemplate(currentHydrationNode!, html)!
@@ -34,7 +25,7 @@ export function template(
     if (!node) {
       t = t || createElement('template')
       t.innerHTML = html
-      node = child(t.content)
+      node = _child(t.content)
     }
     const ret = node.cloneNode(true)
     if (root) (ret as any).$root = true
index b13458cfdf6dc0bd41fa82f0eb184a7fa99501fa..38e074001930828cf53378c12b77d49ebe78fc50 100644 (file)
@@ -9,13 +9,11 @@ import {
   isValidBlock,
   remove,
 } from './block'
-import type { TransitionHooks } from '@vue/runtime-dom'
+import { type TransitionHooks, queuePostFlushCb } from '@vue/runtime-dom'
 import {
-  advanceHydrationNode,
   currentHydrationNode,
   isComment,
   isHydrating,
-  locateFragmentEndAnchor,
   locateHydrationNode,
 } from './dom/hydration'
 import {
@@ -24,7 +22,6 @@ import {
 } from './components/Transition'
 import { type VaporComponentInstance, isVaporComponent } from './component'
 import { isArray } from '@vue/shared'
-import { incrementIndexOffset } from './insertionState'
 
 export class VaporFragment<T extends Block = Block>
   implements TransitionOptions
@@ -42,7 +39,6 @@ export class VaporFragment<T extends Block = Block>
   remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void
   fallback?: BlockFn
 
-  getNodes?: () => Block
   setRef?: (comp: VaporComponentInstance) => void
 
   constructor(nodes: T) {
@@ -149,7 +145,7 @@ export class DynamicFragment extends VaporFragment {
 
     // reuse the empty comment node as the anchor for empty if
     if (this.anchorLabel === 'if' && isEmpty) {
-      this.anchor = locateFragmentEndAnchor('')!
+      this.anchor = currentHydrationNode!
       if (!this.anchor) {
         throw new Error('Failed to locate if anchor')
       } else {
@@ -172,7 +168,7 @@ export class DynamicFragment extends VaporFragment {
       }
 
       // reuse the vdom fragment end anchor for slots
-      this.anchor = locateFragmentEndAnchor()!
+      this.anchor = currentHydrationNode!
       if (!this.anchor) {
         throw new Error('Failed to locate slot anchor')
       } else {
@@ -182,13 +178,14 @@ export class DynamicFragment extends VaporFragment {
 
     // create an anchor
     const { parentNode, nextSibling } = findLastChild(this)!
-    parentNode!.insertBefore(
-      (this.anchor = createComment(this.anchorLabel!)),
-      nextSibling,
-    )
-    // increment index offset since we dynamically inserted a comment node
-    incrementIndexOffset(parentNode!)
-    advanceHydrationNode(this.anchor)
+    queuePostFlushCb(() => {
+      parentNode!.insertBefore(
+        (this.anchor = __DEV__
+          ? createComment(this.anchorLabel!)
+          : createTextNode()),
+        nextSibling,
+      )
+    })
   }
 }
 
@@ -241,7 +238,7 @@ export function findLastChild(node: Block): Node | undefined | null {
   } else if (isVaporComponent(node)) {
     return findLastChild(node.block!)
   } else {
-    if (node instanceof DynamicFragment && node.anchor) return node.anchor
+    if (node.anchor) return node.anchor
     return findLastChild(node.nodes!)
   }
 }
index 17b1bd0f23794f99968b2a1881bf349620be147e..ceb15ec0af01118da01c8d3276d5560354285020 100644 (file)
@@ -12,19 +12,19 @@ import {
   mountComponent,
   unmountComponent,
 } from './component'
-import { handleTeleportRootComponentHmrReload } from './components/Teleport'
+import { isArray } from '@vue/shared'
 
 export function hmrRerender(instance: VaporComponentInstance): void {
   const normalized = normalizeBlock(instance.block)
   const parent = normalized[0].parentNode!
   const anchor = normalized[normalized.length - 1].nextSibling
   remove(instance.block, parent)
-  if (instance.hmrRerenderEffects) {
-    instance.hmrRerenderEffects.forEach(e => e())
-    instance.hmrRerenderEffects.length = 0
-  }
   const prev = setCurrentInstance(instance)
   pushWarningContext(instance)
+  if (instance.renderEffects) {
+    instance.renderEffects.forEach(e => e.stop())
+    instance.renderEffects = []
+  }
   devRender(instance)
   popWarningContext()
   setCurrentInstance(...prev)
@@ -39,7 +39,8 @@ export function hmrReload(
   const parent = normalized[0].parentNode!
   const anchor = normalized[normalized.length - 1].nextSibling
   unmountComponent(instance, parent)
-  const prev = setCurrentInstance(instance.parent)
+  const parentInstance = instance.parent as VaporComponentInstance | null
+  const prev = setCurrentInstance(parentInstance)
   const newInstance = createComponent(
     newComp,
     instance.rawProps,
@@ -48,5 +49,59 @@ export function hmrReload(
   )
   setCurrentInstance(...prev)
   mountComponent(newInstance, parent, anchor)
-  handleTeleportRootComponentHmrReload(instance, newInstance)
+
+  updateParentBlockOnHmrReload(parentInstance, instance, newInstance)
+  updateParentTeleportOnHmrReload(instance, newInstance)
+}
+
+/**
+ * dev only
+ * update parentInstance.block to ensure that the correct parent and
+ * anchor are found during parentInstance HMR rerender/reload, as
+ * `normalizeBlock` relies on the current instance.block
+ */
+function updateParentBlockOnHmrReload(
+  parentInstance: VaporComponentInstance | null,
+  instance: VaporComponentInstance,
+  newInstance: VaporComponentInstance,
+): void {
+  if (parentInstance) {
+    if (parentInstance.block === instance) {
+      parentInstance.block = newInstance
+    } else if (isArray(parentInstance.block)) {
+      for (let i = 0; i < parentInstance.block.length; i++) {
+        if (parentInstance.block[i] === instance) {
+          parentInstance.block[i] = newInstance
+          break
+        }
+      }
+    }
+  }
+}
+
+/**
+ * dev only
+ * during root component HMR reload, since the old component will be unmounted
+ * and a new one will be mounted, we need to update the teleport's nodes
+ * to ensure that the correct parent and anchor are found during parentInstance
+ * HMR rerender/reload, as `normalizeBlock` relies on the current instance.block
+ */
+export function updateParentTeleportOnHmrReload(
+  instance: VaporComponentInstance,
+  newInstance: VaporComponentInstance,
+): void {
+  const teleport = instance.parentTeleport
+  if (teleport) {
+    newInstance.parentTeleport = teleport
+    if (teleport.nodes === instance) {
+      teleport.nodes = newInstance
+    } else if (isArray(teleport.nodes)) {
+      for (let i = 0; i < teleport.nodes.length; i++) {
+        if (teleport.nodes[i] === instance) {
+          teleport.nodes[i] = newInstance
+          break
+        }
+      }
+    }
+  }
 }
index fc015c219fdfba651472b49a22093fc9ea9794bc..10262bb20f381bb367ac13c7cf9beb79df22120b 100644 (file)
@@ -1,5 +1,4 @@
-import { isComment, isHydrating } from './dom/hydration'
-import { currentTemplateFn, resetTemplateFn } from './dom/template'
+import { isHydrating } from './dom/hydration'
 export type ChildItem = ChildNode & {
   $idx: number
   // used count as an anchor
@@ -7,20 +6,19 @@ export type ChildItem = ChildNode & {
 }
 
 export type InsertionParent = ParentNode & {
-  $children?: ChildItem[]
+  $prependAnchor?: Node | null
+
   /**
    * hydration-specific properties
    */
-  // mapping from logical index to real index in childNodes
-  $idxMap?: number[]
   // hydrated dynamic children count so far
   $prevDynamicCount?: number
   // number of unique insertion anchors that have appeared
   $anchorCount?: number
   // last append index
   $appendIndex?: number | null
-  // number of dynamically inserted nodes (e.g., comment anchors)
-  $indexOffset?: number
+  // last located logical child
+  $lastLogicalChild?: Node | null
 }
 export let insertionParent: InsertionParent | undefined
 export let insertionAnchor: Node | 0 | undefined | null
@@ -31,7 +29,7 @@ export let insertionAnchor: Node | 0 | undefined | null
  * insertion on client-side render, and used for node adoption during hydration.
  */
 export function setInsertionState(
-  parent: ParentNode,
+  parent: ParentNode & { $prependAnchor?: Node | null },
   anchor?: Node | 0 | null | number,
 ): void {
   insertionParent = parent
@@ -39,119 +37,26 @@ export function setInsertionState(
   if (anchor !== undefined) {
     if (isHydrating) {
       insertionAnchor = anchor as Node
-      initializeHydrationState(parent)
-      resetTemplateFn()
+      // when the setInsertionState is called for the first time, reset $lastLogicalChild,
+      // in order to reuse it in locateChildByLogicalIndex
+      if (insertionParent.$prevDynamicCount === undefined) {
+        insertionParent!.$lastLogicalChild = null
+      }
     } else {
       // special handling append anchor value to null
       insertionAnchor =
         typeof anchor === 'number' && anchor > 0 ? null : (anchor as Node)
-      cacheTemplateChildren(parent)
-    }
-  } else {
-    insertionAnchor = undefined
-  }
-}
-
-function initializeHydrationState(parent: InsertionParent) {
-  if (!parent.$idxMap) {
-    const childNodes = parent.childNodes
-    const len = childNodes.length
-
-    // fast path for single child case. use first child as hydration node
-    // no need to build logical index map
-    if (
-      len === 1 ||
-      (len === 3 &&
-        isComment(childNodes[0], '[') &&
-        isComment(childNodes[2], ']'))
-    ) {
-      insertionAnchor = undefined
-      return
-    }
-
-    if (currentTemplateFn) {
-      if (currentTemplateFn.$idxMap) {
-        const idxMap = (parent.$idxMap = currentTemplateFn.$idxMap)
-        // set $idx to childNodes
-        for (let i = 0; i < idxMap.length; i++) {
-          ;(childNodes[idxMap[i]] as ChildItem).$idx = i
-        }
-      } else {
-        parent.$idxMap = currentTemplateFn.$idxMap = buildLogicalIndexMap(
-          len,
-          childNodes,
-        )
-      }
-    } else {
-      parent.$idxMap = buildLogicalIndexMap(len, childNodes)
-    }
-    parent.$prevDynamicCount = 0
-    parent.$anchorCount = 0
-    parent.$appendIndex = null
-    parent.$indexOffset = 0
-  }
-}
 
-function buildLogicalIndexMap(len: number, childNodes: NodeListOf<ChildNode>) {
-  const idxMap = new Array() as number[]
-  // Build logical index map:
-  // - static node: map logical index to real index
-  // - fragment: map logical index to start anchor's real index
-  let logicalIndex = 0
-  for (let i = 0; i < len; i++) {
-    const n = childNodes[i] as ChildItem
-    n.$idx = logicalIndex
-    if (n.nodeType === 8) {
-      const data = (n as any as Comment).data
-      // vdom fragment
-      if (data === '[') {
-        idxMap[logicalIndex++] = i
-        // find matching end anchor, accounting for nested fragments
-        let depth = 1
-        let j = i + 1
-        for (; j < len; j++) {
-          const c = childNodes[j] as Comment
-          if (c.nodeType === 8) {
-            const d = c.data
-            if (d === '[') depth++
-            else if (d === ']') {
-              depth--
-              if (depth === 0) break
-            }
-          }
-        }
-        // jump i to the end anchor
-        i = j
-        continue
+      // track the first child for potential future use
+      if (anchor === 0 && !parent.$prependAnchor) {
+        parent.$prependAnchor = parent.firstChild
       }
     }
-    idxMap[logicalIndex++] = i
-  }
-  return idxMap
-}
-
-function cacheTemplateChildren(parent: InsertionParent) {
-  if (!parent.$children) {
-    const nodes = parent.childNodes
-    const len = nodes.length
-    if (len === 0) return
-
-    const children = new Array(len) as ChildItem[]
-    for (let i = 0; i < len; i++) {
-      const node = nodes[i] as ChildItem
-      node.$idx = i
-      children[i] = node
-    }
-    parent.$children = children
+  } else {
+    insertionAnchor = undefined
   }
 }
 
 export function resetInsertionState(): void {
   insertionParent = insertionAnchor = undefined
 }
-
-export function incrementIndexOffset(parent: InsertionParent): void {
-  if (parent.$indexOffset !== undefined) {
-    parent.$indexOffset++
-  }
-}
index 8317c2130c3428f61f081aa318c2188edbd9162e..3c937c0ed5866aabec275462a70d93dab5040d4e 100644 (file)
@@ -11,7 +11,7 @@ import {
 import { type VaporComponentInstance, isVaporComponent } from './component'
 import { invokeArrayFns } from '@vue/shared'
 
-class RenderEffect extends ReactiveEffect {
+export class RenderEffect extends ReactiveEffect {
   i: VaporComponentInstance | null
   job: SchedulerJob
   updateJob: SchedulerJob
@@ -41,6 +41,9 @@ class RenderEffect extends ReactiveEffect {
         this.onTrigger = instance.rtg
           ? e => invokeArrayFns(instance.rtg!, e)
           : void 0
+
+        // register effect for stopping them during HMR rerender
+        ;(instance.renderEffects || (instance.renderEffects = [])).push(this)
       }
       job.i = instance
     }
@@ -82,14 +85,10 @@ class RenderEffect extends ReactiveEffect {
   }
 }
 
-export function renderEffect(
-  fn: () => void,
-  noLifecycle = false,
-): RenderEffect {
+export function renderEffect(fn: () => void, noLifecycle = false): void {
   const effect = new RenderEffect(fn)
   if (noLifecycle) {
     effect.fn = fn
   }
   effect.run()
-  return effect
 }
index 9a979c4e4c7129cef2ef6545ff08bdd7592f88d6..d007788827b01e202630695d899b0ee53b19dde0 100644 (file)
@@ -59,7 +59,6 @@ import {
   currentHydrationNode,
   isComment,
   isHydrating,
-  locateFragmentEndAnchor,
   locateHydrationNode,
   setCurrentHydrationNode,
   hydrateNode as vaporHydrateNode,
@@ -75,9 +74,7 @@ const vaporInteropImpl: Omit<
 > = {
   mount(vnode, container, anchor, parentComponent) {
     let selfAnchor = (vnode.el = vnode.anchor = createTextNode())
-    if (!isHydrating) {
-      container.insertBefore(selfAnchor, anchor)
-    }
+    container.insertBefore(selfAnchor, anchor)
     const prev = currentInstance
     simpleSetCurrentInstance(parentComponent)
 
@@ -119,12 +116,6 @@ const vaporInteropImpl: Omit<
         vnode.transition as VaporTransitionHooks,
       )
     }
-    if (isHydrating) {
-      // insert self anchor after hydration completed to avoid mismatching
-      ;(instance.m || (instance.m = [])).push(() => {
-        container.insertBefore(selfAnchor, anchor)
-      })
-    }
     mountComponent(instance, container, selfAnchor)
     simpleSetCurrentInstance(prev)
     return instance
@@ -198,8 +189,7 @@ const vaporInteropImpl: Omit<
     const propsRef = (vnode.vs!.ref = shallowRef(vnode.props))
     vaporHydrateNode(node, () => {
       vnode.vb = slot(new Proxy(propsRef, vaporSlotPropsProxyHandler))
-      vnode.el = currentHydrationNode!
-      vnode.anchor = locateFragmentEndAnchor()
+      vnode.anchor = vnode.el = currentHydrationNode!
 
       if (__DEV__ && !vnode.anchor) {
         throw new Error(`Failed to locate slot anchor`)