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

fix(runtime-vapor): preserve anchors for deferred fragment hydration (#14822)

edison 1 месяц назад
Родитель
Сommit
94767aa482

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

@@ -27,7 +27,12 @@ import { isString } from '@vue/shared'
 import type { VaporComponentInstance } from '../src/component'
 import type { TeleportFragment } from '../src/components/Teleport'
 import { VueServerRenderer, compile, runtimeDom, runtimeVapor } from './_utils'
-import { hydrateNode, setIsHydratingEnabled } from '../src/dom/hydration'
+import {
+  hydrateNode,
+  setIsHydratingEnabled,
+  withDeferredHydrationBoundary,
+} from '../src/dom/hydration'
+import { DynamicFragment } from '../src/fragment'
 
 const formatHtml = (raw: string) => {
   return raw
@@ -5478,6 +5483,36 @@ describe('Vapor Mode hydration', () => {
       expect(container.innerHTML).toMatchInlineSnapshot(
         `"<h1>Updated async component</h1><!--async component-->"`,
       )
+
+      data.value.toggle = true
+      await nextTick()
+      expect(container.innerHTML).toMatchInlineSnapshot(
+        `"<h1>Async component</h1><!--async component-->"`,
+      )
+    })
+
+    test('deferred dynamic fragment reuses existing empty anchor when branch revives', async () => {
+      const container = document.createElement('div')
+      const anchor = document.createComment('if')
+      container.append(anchor)
+
+      setIsHydratingEnabled(true)
+      try {
+        hydrateNode(anchor, () => {
+          const fragment = new DynamicFragment('if', false, false)
+          fragment.anchor = anchor
+          withDeferredHydrationBoundary(() => {
+            fragment.update(() => template('<span>foo</span>')())
+          })
+        })
+      } finally {
+        setIsHydratingEnabled(false)
+      }
+      await nextTick()
+
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(container.innerHTML).toBe('<span>foo</span><!--if-->')
+      expect(container.lastChild).toBe(anchor)
     })
 
     test('update async component (fragment root) after parent mount before async component resolve', async () => {

+ 2 - 1
packages/runtime-vapor/src/apiDefineAsyncComponent.ts

@@ -24,6 +24,7 @@ import {
   isHydrating,
   locateEndAnchor,
   setCurrentHydrationNode,
+  withDeferredHydrationBoundary,
 } from './dom/hydration'
 import type { TransitionOptions } from './block'
 import { _next } from './dom/node'
@@ -93,7 +94,7 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
       performAsyncHydrate(
         el,
         instance,
-        () => hydrateNode(el, hydrate),
+        () => hydrateNode(el, () => withDeferredHydrationBoundary(hydrate)),
         getResolvedComp,
         load,
         hydrateStrategy,

+ 11 - 4
packages/runtime-vapor/src/component.ts

@@ -99,6 +99,7 @@ import {
   locateNextNode,
   markHydrationAnchor,
   setCurrentHydrationNode,
+  withDeferredHydrationBoundary,
 } from './dom/hydration'
 import { createComment, createElement, createTextNode } from './dom/node'
 import type { TeleportFragment } from './components/Teleport'
@@ -1018,11 +1019,17 @@ export function mountComponent(
       const reset =
         instance.restoreAsyncContext && instance.restoreAsyncContext()
       try {
-        handleSetupResult(setupResult, component, instance)
-        mountComponent(instance, parent, anchor)
         if (isHydrating) {
-          instance.deferredHydrationBoundary &&
-            instance.deferredHydrationBoundary()
+          withDeferredHydrationBoundary(() => {
+            handleSetupResult(setupResult, component, instance)
+            mountComponent(instance, parent, anchor)
+            if (instance.deferredHydrationBoundary) {
+              instance.deferredHydrationBoundary()
+            }
+          })
+        } else {
+          handleSetupResult(setupResult, component, instance)
+          mountComponent(instance, parent, anchor)
         }
       } finally {
         if (isHydrating) {

+ 15 - 0
packages/runtime-vapor/src/dom/hydration.ts

@@ -38,6 +38,21 @@ function setIsHydrating(value: boolean) {
   }
 }
 
+let deferredHydrationBoundaryDepth = 0
+
+export function isInDeferredHydrationBoundary(): boolean {
+  return deferredHydrationBoundaryDepth > 0
+}
+
+export function withDeferredHydrationBoundary<T>(fn: () => T): T {
+  deferredHydrationBoundaryDepth++
+  try {
+    return fn()
+  } finally {
+    deferredHydrationBoundaryDepth--
+  }
+}
+
 export function runWithoutHydration(fn: () => any): any {
   const prev = setIsHydrating(false)
   try {

+ 15 - 9
packages/runtime-vapor/src/fragment.ts

@@ -36,6 +36,7 @@ import {
   enterHydrationBoundary,
   isComment,
   isHydrating,
+  isInDeferredHydrationBoundary,
   locateEndAnchor,
   locateHydrationBoundaryClose,
   locateHydrationNode,
@@ -303,17 +304,22 @@ export class DynamicFragment extends VaporFragment {
       }
     }
 
-    // A non-slot fragment can render empty first during hydration, then flip
-    // to a real branch before hydration exits (for example inside an async
-    // component slot). Re-point the cursor at the fragment-owned insertion
-    // anchor so the late branch inserts before that anchor instead of
-    // consuming trailing hydrated siblings or the enclosing slot boundary.
-    if (
+    const isRevivingDeferredBranch =
       isHydrating &&
-      render &&
+      isInDeferredHydrationBoundary() &&
+      !!render &&
       this.anchorLabel !== 'slot' &&
       !isValidBlock(this.nodes)
-    ) {
+
+    const reusingDeferredAnchor =
+      isRevivingDeferredBranch && !!this.anchor && !!this.anchor.parentNode
+
+    // Deferred hydration can keep an empty wrapper fragment alive, then resolve
+    // it to a real branch before hydration exits. Re-point the cursor at the
+    // fragment-owned insertion anchor so the late branch inserts before that
+    // anchor instead of consuming trailing hydrated siblings or the enclosing
+    // slot boundary.
+    if (isRevivingDeferredBranch) {
       let slotEndAnchor: Node | null = null
       const anchor =
         this.anchor ||
@@ -328,7 +334,7 @@ export class DynamicFragment extends VaporFragment {
     this.renderBranch(render, transition, parent, key)
     setActiveSub(prevSub)
 
-    if (isHydrating && this.anchorLabel !== 'slot') {
+    if (isHydrating && this.anchorLabel !== 'slot' && !reusingDeferredAnchor) {
       this.hydrate(render == null)
     }
   }