]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: hydrate VaporTransition with appear (#13863)
authoredison <daiwei521@126.com>
Wed, 10 Sep 2025 03:11:51 +0000 (11:11 +0800)
committerGitHub <noreply@github.com>
Wed, 10 Sep 2025 03:11:51 +0000 (11:11 +0800)
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/components/Transition.ts
packages/runtime-vapor/src/components/TransitionGroup.ts
packages/runtime-vapor/src/fragment.ts

index ae660abf1546e268df440aa164d743da6d63f028..e41a4fdc9c9e78c08ae9d933de9966b2392e97bb 100644 (file)
@@ -805,16 +805,16 @@ export function createHydrationFunctions(
     }
   }
 
-  const isTemplateNode = (node: Node): node is HTMLTemplateElement => {
-    return (
-      node.nodeType === DOMNodeTypes.ELEMENT &&
-      (node as Element).tagName === 'TEMPLATE'
-    )
-  }
-
   return [hydrate, hydrateNode]
 }
 
+export const isTemplateNode = (node: Node): node is HTMLTemplateElement => {
+  return (
+    node.nodeType === DOMNodeTypes.ELEMENT &&
+    (node as Element).tagName === 'TEMPLATE'
+  )
+}
+
 /**
  * Dev only
  */
index 533000ee85cb22f30acb760b7462faf2bf742a92..1c7fa5d78bebf4f03f81e87bc30edeb32716a7de 100644 (file)
@@ -601,3 +601,7 @@ export { createInternalObject } from './internalObject'
  * @internal
  */
 export { createCanSetSetupRefChecker } from './rendererTemplateRef'
+/**
+ * @internal
+ */
+export { isTemplateNode } from './hydration'
index 72b7e07873b5d2c1b3a76792b1e5288f02a93179..999caf97c8a30abaa1d1946a86374fbbd3a688cc 100644 (file)
@@ -1568,8 +1568,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "
-        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
-        "
+        <!--[--><span>a</span><span>b</span><span>c</span><!--for-->"
       `,
       )
 
@@ -1578,8 +1577,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
-        "
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for-->"
       `,
       )
     })
@@ -1601,8 +1599,7 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[--><div>
-        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
-        </div><div>3</div><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><!--for--></div><div>3</div><!--]-->
         "
       `,
       )
@@ -1613,8 +1610,7 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[--><div>
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
-        </div><div>4</div><!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for--></div><div>4</div><!--]-->
         "
       `,
       )
@@ -1635,8 +1631,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
-        <span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><!--for--><span></span></div>"
       `,
       )
 
@@ -1645,8 +1640,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
-        <span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--for--><span></span></div>"
       `,
       )
 
@@ -1655,8 +1649,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>b</span><span>c</span><span>d</span><!--]-->
-        <span></span></div>"
+        <!--[--><span>b</span><span>c</span><span>d</span><!--for--><span></span></div>"
       `,
       )
     })
@@ -1677,9 +1670,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
-        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
-        <span></span></div>"
+        <!--[--><span>a</span><span>b</span><span>c</span><!--for-->
+        <!--[--><span>a</span><span>b</span><span>c</span><!--for--><span></span></div>"
       `,
       )
 
@@ -1688,9 +1680,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><!--]-->
-        <!--[--><span>a</span><span>b</span><span>c</span><span>d</span><!--]-->
-        <span></span></div>"
+        <!--[--><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>"
       `,
       )
 
@@ -1699,9 +1690,8 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div><span></span>
-        <!--[--><span>c</span><span>d</span><!--]-->
-        <!--[--><span>c</span><span>d</span><!--]-->
-        <span></span></div>"
+        <!--[--><span>c</span><span>d</span><!--for-->
+        <!--[--><span>c</span><span>d</span><!--for--><span></span></div>"
       `,
       )
     })
@@ -1722,8 +1712,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div>
-        <!--[--><div>comp</div><div>comp</div><div>comp</div><!--]-->
-        </div>"
+        <!--[--><div>comp</div><div>comp</div><div>comp</div><!--for--></div>"
       `,
       )
 
@@ -1732,8 +1721,7 @@ describe('Vapor Mode hydration', () => {
       expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
         `
         "<div>
-        <!--[--><div>comp</div><div>comp</div><div>comp</div><div>comp</div><!--]-->
-        </div>"
+        <!--[--><div>comp</div><div>comp</div><div>comp</div><div>comp</div><!--for--></div>"
       `,
       )
     })
@@ -1758,8 +1746,7 @@ describe('Vapor Mode hydration', () => {
         <!--[-->
         <!--[--><span>a</span><!--]-->
         <!--[--><span>b</span><!--]-->
-        <!--[--><span>c</span><!--]-->
-        <!--]-->
+        <!--[--><span>c</span><!--for--><!--]-->
         </div>"
       `,
       )
@@ -1772,8 +1759,7 @@ describe('Vapor Mode hydration', () => {
         <!--[-->
         <!--[--><span>a</span><!--]-->
         <!--[--><span>b</span><!--]-->
-        <!--[--><span>c</span><span>d</span><!--slot--><!--]-->
-        <!--]-->
+        <!--[--><span>c</span><span>d</span><!--slot--><!--for--><!--]-->
         </div>"
       `,
       )
@@ -1797,8 +1783,7 @@ describe('Vapor Mode hydration', () => {
         <!--[-->
         <!--[--><div>foo</div>-bar-<!--]-->
         <!--[--><div>foo</div>-bar-<!--]-->
-        <!--[--><div>foo</div>-bar-<!--]-->
-        <!--]-->
+        <!--[--><div>foo</div>-bar-<!--for--><!--]-->
         </div>"
       `,
       )
@@ -1811,8 +1796,7 @@ describe('Vapor Mode hydration', () => {
         <!--[-->
         <!--[--><div>foo</div>-bar-<!--]-->
         <!--[--><div>foo</div>-bar-<!--]-->
-        <!--[--><div>foo</div>-bar-<div>foo</div>-bar-<!--]-->
-        <!--]-->
+        <!--[--><div>foo</div>-bar-<div>foo</div>-bar-<!--for--><!--]-->
         </div>"
       `,
       )
@@ -1950,8 +1934,7 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[-->
-        <!--[--><span>a</span><span>b</span><span>c</span><!--]-->
-        <!--]-->
+        <!--[--><span>a</span><span>b</span><span>c</span><!--for--><!--]-->
         "
       `,
       )
@@ -2383,10 +2366,9 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[-->
-        <!--[--><div>a</div><div>b</div><div>c</div><!--]-->
+        <!--[--><div>a</div><div>b</div><div>c</div><!--for-->
         <!--[--><span>foo</span><!--]-->
-        <!--[--><div>a</div><div>b</div><div>c</div><!--]-->
-        <!--]-->
+        <!--[--><div>a</div><div>b</div><div>c</div><!--for--><!--]-->
         "
       `,
       )
@@ -2397,10 +2379,9 @@ describe('Vapor Mode hydration', () => {
         `
         "
         <!--[-->
-        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--]-->
+        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--for-->
         <!--[--><span>foo</span><!--]-->
-        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--]-->
-        <!--]-->
+        <!--[--><div>a</div><div>b</div><div>c</div><div>d</div><!--for--><!--]-->
         "
       `,
       )
@@ -2725,14 +2706,115 @@ describe('Vapor Mode hydration', () => {
     })
   })
 
-  describe.todo('transition', async () => {
-    test('transition appear', async () => {})
+  describe('transition', async () => {
+    test('transition appear', async () => {
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <div>foo</div>
+          </transition>
+        </template>`,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<div style="" class="v-enter-from v-enter-active">foo</div>"`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
 
-    test('transition appear with v-if', async () => {})
+    test('transition appear work with pre-existing class', async () => {
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <div class="foo">foo</div>
+          </transition>
+        </template>`,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<div class="foo v-enter-from v-enter-active" style="">foo</div>"`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
 
-    test('transition appear with v-show', async () => {})
+    test('transition appear work with empty content', async () => {
+      const data = ref(true)
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <slot v-if="data"></slot>
+            <span v-else>foo</span>
+          </transition>
+        </template>`,
+        undefined,
+        data,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<!--slot--><!--if-->"`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+
+      data.value = false
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<span class="v-enter-from v-enter-active">foo</span><!--if-->"`,
+      )
+    })
+
+    test('transition appear with v-if', async () => {
+      const data = ref(false)
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <div v-if="data">foo</div>
+          </transition>
+        </template>`,
+        undefined,
+        data,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<!--if-->"`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('transition appear with v-show', async () => {
+      const data = ref(false)
+      const { container } = await testHydration(
+        `<template>
+          <transition appear>
+            <div v-show="data">foo</div>
+          </transition>
+        </template>`,
+        undefined,
+        data,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<div style="display:none;" class="v-enter-from v-enter-active v-leave-from v-leave-active">foo</div>"`,
+      )
+      expect(`mismatch`).not.toHaveBeenWarned()
+    })
 
-    test('transition appear w/ event listener', async () => {})
+    test('transition appear w/ event listener', async () => {
+      const { container } = await testHydration(
+        `<script setup>
+          import { ref } from 'vue'
+          const count = ref(0)
+        </script>
+        <template>
+          <transition appear>
+            <button @click="count++">{{ count }}</button>
+          </transition>
+        </template>`,
+      )
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<button style="" class="v-enter-from v-enter-active">0</button>"`,
+      )
+
+      triggerEvent('click', container.querySelector('button')!)
+      await nextTick()
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+        `"<button style="" class="v-enter-from v-enter-active">1</button>"`,
+      )
+    })
   })
 
   describe.todo('async component', async () => {
index 7954dcb05609b0ef6104ab262f81cf41c5715237..4e88afe8f4f3d8accd67b396accf0abf55e0aff5 100644 (file)
@@ -135,9 +135,11 @@ export const createFor = (
 
       if (isHydrating) {
         parentAnchor = locateFragmentEndAnchor()!
-        // TODO: special handling vFor not render as a fragment. (inside Transition/TransitionGroup)
-        if (__DEV__ && !parentAnchor) {
-          throw new Error(`v-for fragment anchor node was not found.`)
+        if (__DEV__) {
+          if (!parentAnchor) {
+            throw new Error(`v-for fragment anchor node was not found.`)
+          }
+          ;(parentAnchor as Comment).data = 'for'
         }
       }
     } else {
index 560b277cbb91aea2ed368bca0bbd84dd288766ac..b3deaf0b68fc91daf5a06a7063f4fdc60e74983b 100644 (file)
@@ -1,4 +1,5 @@
 import {
+  type BaseTransitionProps,
   type GenericComponentInstance,
   type TransitionElement,
   type TransitionHooks,
@@ -9,7 +10,9 @@ import {
   baseResolveTransitionHooks,
   checkTransitionMode,
   currentInstance,
+  isTemplateNode,
   leaveCbKey,
+  queuePostFlushCb,
   resolveTransitionProps,
   useTransitionState,
   warn,
@@ -24,6 +27,11 @@ import {
 import { extend, isArray } from '@vue/shared'
 import { renderEffect } from '../renderEffect'
 import { isFragment } from '../fragment'
+import {
+  currentHydrationNode,
+  isHydrating,
+  setCurrentHydrationNode,
+} from '../dom/hydration'
 
 const decorate = (t: typeof VaporTransition) => {
   t.displayName = 'VaporTransition'
@@ -34,6 +42,33 @@ const decorate = (t: typeof VaporTransition) => {
 
 export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
   (props, { slots, attrs }) => {
+    // wrapped <transition appear>
+    let resetDisplay: Function | undefined
+    if (
+      isHydrating &&
+      currentHydrationNode &&
+      isTemplateNode(currentHydrationNode)
+    ) {
+      // replace <template> node with inner child
+      const {
+        content: { firstChild },
+        parentNode,
+      } = currentHydrationNode
+      if (firstChild) {
+        if (
+          firstChild instanceof HTMLElement ||
+          firstChild instanceof SVGElement
+        ) {
+          const originalDisplay = firstChild.style.display
+          firstChild.style.display = 'none'
+          resetDisplay = () => (firstChild.style.display = originalDisplay)
+        }
+
+        parentNode!.replaceChild(firstChild, currentHydrationNode)
+        setCurrentHydrationNode(firstChild)
+      }
+    }
+
     const children = (slots.default && slots.default()) as any as Block
     if (!children) return
 
@@ -41,7 +76,7 @@ export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
     const { mode } = props
     checkTransitionMode(mode)
 
-    let resolvedProps
+    let resolvedProps: BaseTransitionProps<Element>
     let isMounted = false
     renderEffect(() => {
       resolvedProps = resolveTransitionProps(props)
@@ -81,7 +116,7 @@ export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
       })
     }
 
-    applyTransitionHooks(
+    const hooks = applyTransitionHooks(
       children,
       {
         state: useTransitionState(),
@@ -91,6 +126,13 @@ export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
       fallthroughAttrs,
     )
 
+    if (resetDisplay && resolvedProps!.appear) {
+      const child = findTransitionBlock(children)!
+      hooks.beforeEnter(child)
+      resetDisplay()
+      queuePostFlushCb(() => hooks.enter(child))
+    }
+
     return children
   },
 )
index e3b4fb4fb0acaf950d5b1a01fc049bec0eae3421..81f384595753662042894f36adc3c9fe0311dd96 100644 (file)
@@ -158,7 +158,7 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({
       return container
     } else {
       const frag = __DEV__
-        ? new DynamicFragment('transitionGroup')
+        ? new DynamicFragment('transition-group')
         : new DynamicFragment()
       renderEffect(() => frag.update(() => slottedBlock))
       return frag
index da92f6b4eca260cc16d0a7a0a206ee90d6fbc24b..f5b70b08b3670afe75c06d8717b1c6fd357ddf8f 100644 (file)
@@ -12,6 +12,8 @@ import {
 import type { TransitionHooks } from '@vue/runtime-dom'
 import {
   advanceHydrationNode,
+  currentHydrationNode,
+  isComment,
   isHydrating,
   locateFragmentEndAnchor,
   locateHydrationNode,
@@ -152,13 +154,25 @@ export class DynamicFragment extends VaporFragment {
       if (!this.anchor) {
         throw new Error('Failed to locate if anchor')
       } else {
-        ;(this.anchor as Comment).data = this.anchorLabel
+        if (__DEV__) {
+          ;(this.anchor as Comment).data = this.anchorLabel
+        }
         return
       }
     }
 
-    // reuse the vdom fragment end anchor for slots
     if (this.anchorLabel === 'slot') {
+      // reuse the empty comment node for empty slot
+      // e.g. `<slot v-if="false"></slot>`
+      if (isEmpty && isComment(currentHydrationNode!, '')) {
+        this.anchor = currentHydrationNode!
+        if (__DEV__) {
+          ;(this.anchor as Comment).data = this.anchorLabel!
+        }
+        return
+      }
+
+      // reuse the vdom fragment end anchor for slots
       this.anchor = locateFragmentEndAnchor()!
       if (!this.anchor) {
         throw new Error('Failed to locate slot anchor')