Преглед изворни кода

fix(hydration): preserve teleport hydration anchors across empty ranges

daiwei пре 1 недеља
родитељ
комит
42ee3ebc65

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

@@ -4398,6 +4398,63 @@ describe('Vapor Mode hydration', () => {
       expect(teleport.targetAnchor).toBeNull()
     })
 
+    test('enabled teleport hydration should preserve existing target end anchor when target is empty', async () => {
+      const data = ref({ msg: 'foo' })
+
+      const teleportContainer = document.createElement('div')
+      teleportContainer.id = 'teleport-empty-anchors'
+      teleportContainer.innerHTML =
+        `<!--teleport start anchor-->` + `<!--teleport anchor-->`
+      document.body.appendChild(teleportContainer)
+
+      const { container } = await mountWithHydration(
+        '<!--teleport start--><!--teleport end-->',
+        `<teleport to="#teleport-empty-anchors">
+          <span>{{data.msg}}</span>
+        </teleport>`,
+        data,
+      )
+
+      expect(container.innerHTML).toBe(
+        `<!--teleport start--><!--teleport end-->`,
+      )
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(teleportContainer.innerHTML).toBe(
+        `<!--teleport start anchor--><span>foo</span><!--teleport anchor-->`,
+      )
+
+      data.value.msg = 'bar'
+      await nextTick()
+      expect(teleportContainer.innerHTML).toBe(
+        `<!--teleport start anchor--><span>bar</span><!--teleport anchor-->`,
+      )
+    })
+
+    test('disabled teleport hydration over empty main-view range should preserve teleport end anchor', async () => {
+      const data = ref({ msg: 'foo' })
+
+      const { container } = await mountWithHydration(
+        '<!--teleport start--><!--teleport end-->',
+        `<teleport :to="undefined" :disabled="true">
+          <span>{{data.msg}}</span>
+        </teleport>`,
+        data,
+      )
+
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      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-->`,
+      )
+    })
+
     test('enabled teleport with null target', async () => {
       const { container } = await mountWithHydration(
         '<!--teleport start--><!--teleport end-->',

+ 13 - 7
packages/runtime-vapor/src/components/Teleport.ts

@@ -39,6 +39,7 @@ import {
   isComment,
   isHydrating,
   logMismatchError,
+  markHydrationAnchor,
   runWithoutHydration,
   setCurrentHydrationNode,
 } from '../dom/hydration'
@@ -386,7 +387,7 @@ export class TeleportFragment extends VaporFragment {
         if ((targetAnchor as Comment).data === 'teleport start anchor') {
           this.targetStart = targetAnchor
         } else if ((targetAnchor as Comment).data === 'teleport anchor') {
-          this.targetAnchor = targetAnchor
+          this.targetAnchor = markHydrationAnchor(targetAnchor)
           target._lpa = this.targetAnchor.nextSibling
           break
         }
@@ -402,7 +403,9 @@ export class TeleportFragment extends VaporFragment {
     if (!isHydrating) return
     let nextNode = this.placeholder!.nextSibling!
     setCurrentHydrationNode(nextNode)
-    this.mountAnchor = this.anchor = locateTeleportEndAnchor(nextNode)!
+    this.mountAnchor = this.anchor = markHydrationAnchor(
+      locateTeleportEndAnchor(nextNode)!,
+    )
     this.mountContainer = parentNode(this.anchor)
     if (target) {
       this.hydrateTargetAnchors(target, targetNode)
@@ -417,7 +420,8 @@ export class TeleportFragment extends VaporFragment {
     if (!isHydrating) return
     target.appendChild((this.targetStart = createTextNode('')))
     target.appendChild(
-      (this.mountAnchor = this.targetAnchor = createTextNode('')),
+      (this.mountAnchor = this.targetAnchor =
+        markHydrationAnchor(createTextNode(''))),
     )
 
     if (!isMismatchAllowed(target as Element, MismatchTypes.CHILDREN)) {
@@ -451,9 +455,9 @@ export class TeleportFragment extends VaporFragment {
           targetNode,
         )
       } else {
-        this.anchor = locateTeleportEndAnchor(
-          currentHydrationNode!.nextSibling!,
-        )!
+        this.anchor = markHydrationAnchor(
+          locateTeleportEndAnchor(currentHydrationNode!.nextSibling!)!,
+        )
         this.mountContainer = target
         this.hydrateTargetAnchors(target as TeleportTargetElement, targetNode)
         this.mountAnchor = this.targetAnchor
@@ -479,7 +483,9 @@ export class TeleportFragment extends VaporFragment {
       // Align with VDOM Teleport hydration: keep main-view markers only and
       // avoid mounting children inline or eagerly initializing them when the
       // target is missing.
-      this.anchor = locateTeleportEndAnchor(currentHydrationNode!.nextSibling!)!
+      this.anchor = markHydrationAnchor(
+        locateTeleportEndAnchor(currentHydrationNode!.nextSibling!)!,
+      )
     }
 
     if (target || disabled) {

+ 19 - 11
packages/runtime-vapor/src/dom/hydration.ts

@@ -112,16 +112,19 @@ export function enterHydration(node: Node): () => void {
 export let adoptTemplate: (node: Node, template: string) => Node | null
 export let locateHydrationNode: (consumeFragmentStart?: boolean) => void
 
-type Anchor = Comment & {
-  // reused hydration anchors must stay in place during mismatch recovery
+type Anchor = Node & {
+  // Runtime-created or reused hydration anchor that mismatch recovery and
+  // boundary cleanup must keep in place.
   $vha?: 1
 
-  // cached matching fragment end to avoid repeated traversal
-  // on nested fragments
+  // cached matching fragment end to avoid repeated traversal on nested
+  // comment fragments.
   $fe?: Anchor
 }
 
-export const isComment = (node: Node, data: string): node is Anchor =>
+type CommentAnchor = Comment & Anchor
+
+export const isComment = (node: Node, data: string): node is CommentAnchor =>
   node.nodeType === 8 && (node as Comment).data === data
 
 export function setCurrentHydrationNode(node: Node | null): void {
@@ -211,7 +214,7 @@ function locateHydrationNodeImpl(consumeFragmentStart = false) {
 }
 
 export function locateEndAnchor(
-  node: Anchor,
+  node: CommentAnchor,
   open = '[',
   close = ']',
 ): Node | null {
@@ -220,8 +223,8 @@ export function locateEndAnchor(
     return node.$fe
   }
 
-  const stack: Anchor[] = [node]
-  while ((node = _next(node) as Anchor) && stack.length > 0) {
+  const stack: CommentAnchor[] = [node]
+  while ((node = _next(node) as CommentAnchor) && stack.length > 0) {
     if (node.nodeType === 8) {
       if (node.data === open) {
         stack.push(node)
@@ -283,8 +286,11 @@ function handleMismatch(node: Node, template: string): Node {
     removeFragmentNodes(node)
   }
 
-  const container = parentNode(node)!
+  // Reused hydration anchors are structural boundaries, not replaceable
+  // content. Mismatch recovery inserts the new node before the anchor and
+  // keeps the anchor in place.
   const shouldPreserveAnchor = isHydrationAnchor(node)
+  const container = parentNode(node)!
   const next = shouldPreserveAnchor ? node : _next(node)
   if (!shouldPreserveAnchor) {
     remove(node, container)
@@ -299,6 +305,8 @@ function handleMismatch(node: Node, template: string): Node {
   const t = createElement('template') as HTMLTemplateElement
   t.innerHTML = template
   const newNode = _child(t.content).cloneNode(true) as Element
+  // only carry over existing children/attrs when the original node is itself
+  // an element (the legacy element-vs-element mismatch case).
   if (node.nodeType === 1) {
     newNode.innerHTML = (node as Element).innerHTML
     Array.from((node as Element).attributes).forEach(attr => {
@@ -324,7 +332,7 @@ export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
   if (!parent) {
     return
   }
-  const end = endAnchor || locateEndAnchor(node as Anchor)
+  const end = endAnchor || locateEndAnchor(node as CommentAnchor)
   while (true) {
     const next = _next(node)
     if (next && next !== end) {
@@ -369,7 +377,7 @@ export function cleanupHydrationTail(node: Node): void {
 }
 
 export function markHydrationAnchor<T extends Node>(node: T): T {
-  ;(node as any).$vha = 1
+  ;(node as Anchor).$vha = 1
   return node
 }