Pārlūkot izejas kodu

fix(runtime-vapor): hydrate v-for in transition-group

daiwei 5 dienas atpakaļ
vecāks
revīzija
d3a584dfab

+ 93 - 33
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -2,7 +2,6 @@ import {
   VaporTeleport,
   child,
   createComponent,
-  createFor,
   createPlainElement,
   createVaporSSRApp,
   defineVaporAsyncComponent,
@@ -12,7 +11,6 @@ import {
   setStyle,
   setText,
   template,
-  txt,
   useVaporCssVars,
 } from '../src'
 import {
@@ -4059,45 +4057,107 @@ describe('Vapor Mode hydration', () => {
       ).not.toHaveBeenWarned()
     })
 
-    test('v-for should use transition-group container marker after cursor leaves container', async () => {
-      const host = document.createElement('div')
-      host.innerHTML = `<ul><li>1</li><li>2</li></ul><span>after</span>`
-      const ul = host.querySelector('ul')!
-      ;(ul as any).$tgt = 1
-      const items = ref([1, 2])
-      const itemTemplate = template(`<li> `)
-      const Child = defineVaporComponent({
-        setup() {
-          return createFor(
-            () => items.value,
-            item => {
-              const li = itemTemplate() as HTMLElement
-              const text = txt(li) as Text
-              renderEffect(() => setText(text, String(item.value)))
-              return li
-            },
-            item => item,
-          )
-        },
+    test('with tag should remove stale SSR v-for children when client list is shorter', async () => {
+      const ssrData = ref({
+        items: [1, 2, 3],
       })
+      const data = ref({
+        items: [1],
+      })
+      const code = `
+        <TransitionGroup :css="false" tag="ul">
+          <li v-for="item in data.items" :key="item">{{ item }}</li>
+        </TransitionGroup>
+      `
+      const SSRComp = compileVaporComponent(code, ssrData, undefined, true)
+      const html = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+      )
+      const { container } = await mountWithHydration(html, code, data)
+      const ul = container.querySelector('ul')!
 
-      setIsHydratingEnabled(true)
-      try {
-        hydrateNode(ul.firstChild!, () => {
-          createComponent(Child, null, null, false, false, undefined, true)
-        })
-      } finally {
-        setIsHydratingEnabled(false)
-      }
+      expect(formatHtml(ul.innerHTML)).toMatchInlineSnapshot(
+        `"<li>1</li><!--for-->"`,
+      )
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
+
+      data.value.items.push(4)
       await nextTick()
       expect(formatHtml(ul.innerHTML)).toMatchInlineSnapshot(
-        `"<li>1</li><li>2</li><!--for-->"`,
+        `"<li>1</li><li>4</li><!--for-->"`,
       )
+    })
 
-      items.value.splice(1, 0, 3)
+    test('with tag should preserve trailing sibling when removing stale SSR v-for children', async () => {
+      const ssrData = ref({
+        items: [1, 2, 3],
+        tail: 'tail',
+      })
+      const data = ref({
+        items: [1],
+        tail: 'tail',
+      })
+      const code = `
+        <TransitionGroup :css="false" tag="ul">
+          <li v-for="item in data.items" :key="item" class="item">{{ item }}</li>
+          <li key="tail" class="tail">{{ data.tail }}</li>
+        </TransitionGroup>
+      `
+      const SSRComp = compileVaporComponent(code, ssrData, undefined, true)
+      const html = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+      )
+      const { container } = await mountWithHydration(html, code, data)
+      const ul = container.querySelector('ul')!
+
+      expect(formatHtml(ul.innerHTML)).toMatchInlineSnapshot(
+        `"<li class="item">1</li><!--for--><li class="item">tail</li>"`,
+      )
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
+
+      data.value.items.push(4)
+      data.value.tail = 'tail updated'
       await nextTick()
       expect(formatHtml(ul.innerHTML)).toMatchInlineSnapshot(
-        `"<li>1</li><li>3</li><li>2</li><!--for-->"`,
+        `"<li class="item">1</li><li class="item">4</li><!--for--><li class="item">tail updated</li>"`,
+      )
+    })
+
+    test('with tag should keep v-for anchor before replaced trailing sibling', async () => {
+      const ssrData = ref({
+        items: [1, 2, 3],
+        tail: 'tail',
+      })
+      const data = ref({
+        items: [1],
+        tail: 'tail',
+      })
+      const code = `
+        <TransitionGroup :css="false" tag="div">
+          <span v-for="item in data.items" :key="item">{{ item }}</span>
+          <p key="tail">{{ data.tail }}</p>
+        </TransitionGroup>
+      `
+      const SSRComp = compileVaporComponent(code, ssrData, undefined, true)
+      const html = await VueServerRenderer.renderToString(
+        runtimeDom.createSSRApp(SSRComp),
+      )
+      const { container } = await mountWithHydration(html, code, data)
+      const div = container.querySelector('div')!
+
+      expect(formatHtml(div.innerHTML)).toMatchInlineSnapshot(
+        `"<span>1</span><!--for--><p>tail</p>"`,
+      )
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+      expect(`Hydration text mismatch`).toHaveBeenWarned()
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
+
+      data.value.items.push(4)
+      data.value.tail = 'tail updated'
+      await nextTick()
+      expect(formatHtml(div.innerHTML)).toMatchInlineSnapshot(
+        `"<span>1</span><span>4</span><!--for--><p>tail updated</p>"`,
       )
     })
 

+ 21 - 23
packages/runtime-vapor/src/apiCreateFor.ts

@@ -60,6 +60,19 @@ type ResolvedSource = {
   keys?: string[]
 }
 
+type ForHydrationAnchorResolver = (
+  hydrationStart: Node,
+  anchorNode: Node | null | undefined,
+) => Node | undefined
+
+let resolveForHydrationAnchor: ForHydrationAnchorResolver | undefined
+
+export function setForHydrationAnchorResolver(
+  resolver: ForHydrationAnchorResolver,
+): void {
+  resolveForHydrationAnchor = resolver
+}
+
 export const createFor = (
   src: () => Source,
   renderItem: (
@@ -151,31 +164,16 @@ export const createFor = (
               if (nextNode) setCurrentHydrationNode(nextNode)
             }
 
-            // transition-group + v-for, without <!--]--> marker
-            const container =
-              // empty list: hydrationStart is container
-              hydrationStart.nodeType === 1 && !!(hydrationStart as any).$tgt
-                ? (hydrationStart as ParentNode)
-                : // non-empty list: hydrationStart parent is container
-                  hydrationStart.parentNode &&
-                    !!(hydrationStart.parentNode as any).$tgt
-                  ? (hydrationStart.parentNode as ParentNode)
-                  : null
-            if (container) {
-              const anchorNode = newLength ? nextNode : currentHydrationNode
-              const anchor =
-                anchorNode &&
-                anchorNode !== container &&
-                anchorNode.parentNode === container
-                  ? anchorNode
-                  : null
-              parentAnchor = markHydrationAnchor(
-                __DEV__ ? createComment('for') : createTextNode(),
+            // special handling transition-group + v-for, without <!--]--> marker
+            const resolvedAnchor =
+              resolveForHydrationAnchor &&
+              resolveForHydrationAnchor(
+                hydrationStart,
+                newLength ? nextNode : currentHydrationNode,
               )
+            if (resolvedAnchor) {
+              parentAnchor = resolvedAnchor
               pendingHydrationAnchor = true
-              queuePostFlushCb(() =>
-                container.insertBefore(parentAnchor, anchor),
-              )
             } else if (slotFallbackRange && !isValidBlock(newBlocks)) {
               // Slot fallback can fall through an empty/invalid `v-for`. In that
               // case SSR only rendered the parent slot range, so this `v-for` has no

+ 60 - 7
packages/runtime-vapor/src/components/TransitionGroup.ts

@@ -36,8 +36,8 @@ import {
   type VaporComponentOptions,
   isVaporComponent,
 } from '../component'
-import { isForBlock } from '../apiCreateFor'
-import { createElement } from '../dom/node'
+import { isForBlock, setForHydrationAnchorResolver } from '../apiCreateFor'
+import { createComment, createElement, createTextNode } from '../dom/node'
 import { DynamicFragment, type VaporFragment, isFragment } from '../fragment'
 import {
   type DefineVaporComponent,
@@ -46,15 +46,47 @@ import {
 import { isInteropEnabled } from '../vdomInteropState'
 import {
   adoptTemplate,
+  cleanupHydrationTail,
   currentHydrationNode,
   isHydrating,
   locateNextNode,
+  markHydrationAnchor,
   setCurrentHydrationNode,
 } from '../dom/hydration'
 
 const positionMap = new WeakMap<TransitionBlock, DOMRect>()
 const newPositionMap = new WeakMap<TransitionBlock, DOMRect>()
 
+let isForHydrationAnchorResolverRegistered = false
+let currentForHydrationContainer: ParentNode | undefined
+
+function ensureForHydrationAnchorResolver(): void {
+  if (isForHydrationAnchorResolverRegistered) return
+  isForHydrationAnchorResolverRegistered = true
+  setForHydrationAnchorResolver((hydrationStart, anchorNode) => {
+    const container = currentForHydrationContainer
+    if (!container) return
+    if (
+      hydrationStart !== container &&
+      hydrationStart.parentNode !== container
+    ) {
+      return
+    }
+
+    const anchor =
+      anchorNode &&
+      anchorNode !== container &&
+      anchorNode.parentNode === container
+        ? anchorNode
+        : null
+    const parentAnchor = markHydrationAnchor(
+      __DEV__ ? createComment('for') : createTextNode(),
+    )
+    container.insertBefore(parentAnchor, anchor)
+    return parentAnchor
+  })
+}
+
 const decorate = <T extends VaporComponentOptions>(t: T): T => {
   delete (t.props! as any).mode
   return t
@@ -167,27 +199,47 @@ const VaporTransitionGroupImpl = defineVaporComponent({
           : createElement(tag)
         : undefined
       let nextNode: Node | null = null
+      let prevForHydrationContainer: ParentNode | undefined
       if (isHydrating && container) {
         // `transition-group + v-for` SSR output does not include `<!--]-->`.
-        // Mark the container so `v-for` hydration can create its own anchor.
-        ;(container as any).$tgt = 1
+        // Expose the container so `v-for` hydration can create its own anchor.
+        ensureForHydrationAnchorResolver()
+        prevForHydrationContainer = currentForHydrationContainer
+        currentForHydrationContainer = container
         nextNode = locateNextNode(container)
         setCurrentHydrationNode(container.firstChild || container)
       }
       let block: Block = slottedBlock
+      let transitionBlocks: ResolvedTransitionBlock[] = []
       try {
         frag.update(() => {
           block = (slot && slot()) || []
-          applyGroupTransitionHooks(block, propsProxy, state, instance)
+          transitionBlocks = applyGroupTransitionHooks(
+            block,
+            propsProxy,
+            state,
+            instance,
+          )
           if (container) {
             if (!isHydrating) insert(block, container)
             return container
           }
           return block
         })
+        if (
+          isHydrating &&
+          container &&
+          currentHydrationNode &&
+          currentHydrationNode.parentNode === container &&
+          !transitionBlocks.some(child => child === currentHydrationNode)
+        ) {
+          // Remove extra SSR nodes left after hydrating the current children,
+          // but keep a node that was claimed as a transition child.
+          cleanupHydrationTail(currentHydrationNode, container)
+        }
       } finally {
         if (isHydrating && container) {
-          delete (container as any).$tgt
+          currentForHydrationContainer = prevForHydrationContainer
           setCurrentHydrationNode(nextNode)
         }
       }
@@ -212,7 +264,7 @@ function applyGroupTransitionHooks(
   props: TransitionProps,
   state: TransitionState,
   instance: VaporComponentInstance,
-): void {
+): ResolvedTransitionBlock[] {
   const fragments: VaporFragment[] = []
   const children = getTransitionBlocks(block, frag => fragments.push(frag))
   for (let i = 0; i < children.length; i++) {
@@ -235,6 +287,7 @@ function applyGroupTransitionHooks(
     hooks.applyGroup = applyGroupTransitionHooks
     frag.$transition = hooks
   })
+  return children
 }
 
 function inheritKey(children: TransitionBlock[], key: any): void {

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

@@ -62,8 +62,6 @@ function performHydration<T>(
     ;(Node.prototype as any).$idx = undefined
     ;(Node.prototype as any).$llc = undefined
     ;(Node.prototype as any).$vha = undefined
-    // transition-group tag
-    ;(Node.prototype as any).$tgt = undefined
 
     isOptimized = true
   }
@@ -370,12 +368,22 @@ function removeHydrationNode(node: Node, close: Node | null = null): void {
   remove(node, parent)
 }
 
-export function cleanupHydrationTail(node: Node): void {
-  const container = node.parentElement
-  if (container) {
-    warnHydrationChildrenMismatch(container)
+export function cleanupHydrationTail(node: Node, container?: ParentNode): void {
+  const mismatchContainer = container || node.parentElement
+  if (mismatchContainer instanceof Element) {
+    warnHydrationChildrenMismatch(mismatchContainer)
+  }
+  if (!container) {
+    removeHydrationNode(node)
+    return
+  }
+
+  let current: Node | null = node
+  while (current && current.parentNode === container) {
+    const next = locateNextNode(current)
+    removeHydrationNode(current)
+    current = next
   }
-  removeHydrationNode(node)
 }
 
 export function markHydrationAnchor<T extends Node>(node: T): T {