]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(transition-group): support reusable transition group (#14077)
authoredison <daiwei521@126.com>
Wed, 12 Nov 2025 03:27:31 +0000 (11:27 +0800)
committerGitHub <noreply@github.com>
Wed, 12 Nov 2025 03:27:31 +0000 (11:27 +0800)
packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts
packages-private/vapor-e2e-test/transition-group/App.vue
packages-private/vapor-e2e-test/transition-group/components/MyTransitionGroup.vue [new file with mode: 0644]
packages/compiler-vapor/src/generators/component.ts
packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/components/Transition.ts
packages/runtime-vapor/src/components/TransitionGroup.ts

index ba050f0f26380bdd6be9ff7d8df617b0034af5be..80563244733c3fbfbe0fa23636086b972bc65791 100644 (file)
@@ -369,6 +369,44 @@ describe('vapor transition-group', () => {
     expect(calls).toContain('afterEnter')
   })
 
+  test(
+    'reusable transition group',
+    async () => {
+      const btnSelector = '.reusable-transition-group > button'
+      const containerSelector = '.reusable-transition-group > div'
+
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">a</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test">c</div>`,
+      )
+
+      expect(
+        (await transitionStart(btnSelector, containerSelector)).innerHTML,
+      ).toBe(
+        `<div class="test group-enter-from group-enter-active">d</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test group-move" style="">a</div>` +
+          `<div class="test group-leave-from group-leave-active group-move" style="">c</div>`,
+      )
+
+      await nextFrame()
+      expect(await html(containerSelector)).toBe(
+        `<div class="test group-enter-active group-enter-to">d</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test group-move" style="">a</div>` +
+          `<div class="test group-leave-active group-move group-leave-to" style="">c</div>`,
+      )
+      await transitionFinish(duration * 2)
+      expect(await html(containerSelector)).toBe(
+        `<div class="test">d</div>` +
+          `<div class="test">b</div>` +
+          `<div class="test" style="">a</div>`,
+      )
+    },
+    E2E_TIMEOUT,
+  )
+
   test('interop: render vdom component', async () => {
     const btnSelector = '.interop > button'
     const containerSelector = '.interop > div'
index 55775743c561a87024e7648ed12e07619711b3d8..5cc6903a985f388abcdec7f14a674627221aa69e 100644 (file)
@@ -1,6 +1,7 @@
 <script setup vapor>
 import { ref } from 'vue'
 import VdomComp from './components/VdomComp.vue'
+import MyTransitionGroup from './components/MyTransitionGroup.vue'
 
 const items = ref(['a', 'b', 'c'])
 const enterClick = () => items.value.push('d', 'e')
@@ -108,6 +109,14 @@ const interopClick = () => (items.value = ['b', 'c', 'd'])
         </transition-group>
       </div>
     </div>
+    <div class="reusable-transition-group">
+      <button @click="moveClick">reusable button</button>
+      <div>
+        <MyTransitionGroup name="group">
+          <div v-for="item in items" :key="item" class="test">{{ item }}</div>
+        </MyTransitionGroup>
+      </div>
+    </div>
     <div class="interop">
       <button @click="interopClick">interop button</button>
       <div>
diff --git a/packages-private/vapor-e2e-test/transition-group/components/MyTransitionGroup.vue b/packages-private/vapor-e2e-test/transition-group/components/MyTransitionGroup.vue
new file mode 100644 (file)
index 0000000..a9305b8
--- /dev/null
@@ -0,0 +1,7 @@
+<script setup vapor></script>
+
+<template>
+  <TransitionGroup>
+    <slot />
+  </TransitionGroup>
+</template>
index c2da7a3f22e657a74f1cafc333abdf09e079fdcf..cd842a91887ee2d52f2f09d315c830cde6306759 100644 (file)
@@ -40,12 +40,7 @@ import { genEventHandler } from './event'
 import { genDirectiveModifiers, genDirectivesForElement } from './directive'
 import { genBlock } from './block'
 import { genModelHandler } from './vModel'
-import {
-  isBuiltInComponent,
-  isKeepAliveTag,
-  isTeleportTag,
-  isTransitionGroupTag,
-} from '../utils'
+import { isBuiltInComponent } from '../utils'
 
 export function genCreateComponent(
   operation: CreateComponentIRNode,
@@ -465,15 +460,7 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
     ]
   }
 
-  if (
-    node.type === NodeTypes.ELEMENT &&
-    // Not a real component
-    !isTeleportTag(node.tag) &&
-    // Needs to determine whether to activate/deactivate based on instance.parent being KeepAlive
-    !isKeepAliveTag(node.tag) &&
-    // Slot updates need to trigger TransitionGroup's onBeforeUpdate/onUpdated hook
-    !isTransitionGroupTag(node.tag)
-  ) {
+  if (node.type === NodeTypes.ELEMENT) {
     // wrap with withVaporCtx to ensure correct currentInstance inside slot
     blockFn = [`${context.helper('withVaporCtx')}(`, ...blockFn, `)`]
   }
index 5c4e598b9109801f7826718fa8cee4a3adb282c8..5bc4473997cb95ef5a0c6f5280329d77f2d664f3 100644 (file)
@@ -39,6 +39,7 @@ import {
   isLastInsertion,
   resetInsertionState,
 } from './insertionState'
+import { triggerTransitionGroupUpdate } from './components/TransitionGroup'
 
 class ForBlock extends VaporFragment {
   scope: EffectScope | undefined
@@ -130,6 +131,12 @@ export const createFor = (
     newBlocks = new Array(newLength)
     let isFallback = false
 
+    // trigger TransitionGroup update hooks
+    const transitionHooks = frag.$transition
+    if (transitionHooks && transitionHooks.group) {
+      triggerTransitionGroupUpdate(transitionHooks)
+    }
+
     const prevSub = setActiveSub()
 
     if (!isMounted) {
index 628e6b61c7bd9d950107dc3e00f179916f44613a..76a5edb2f69bc3c4c1826e632050b2e2f232cf30 100644 (file)
@@ -29,6 +29,9 @@ export interface VaporTransitionHooks extends TransitionHooks {
   // mark transition hooks as disabled so that it skips during
   // inserting
   disabled?: boolean
+  // mark transition hooks as group so that it triggers TransitionGroup update hooks
+  // in vFor renderList function
+  group?: boolean
 }
 
 export interface TransitionOptions {
index 131154e2b6cda1bdd46ae91fbd206de96549c263..3f945e838f5b1f5b0eac04cc91e6ba9302a47d01 100644 (file)
@@ -233,7 +233,7 @@ export function applyTransitionHooks(
     return hooks
   }
 
-  const { props, instance, state, delayedLeave } = hooks
+  const { props, instance, state, delayedLeave, group } = hooks
   let resolvedHooks = resolveTransitionHooks(
     child,
     props,
@@ -242,6 +242,7 @@ export function applyTransitionHooks(
     hooks => (resolvedHooks = hooks as VaporTransitionHooks),
   )
   resolvedHooks.delayedLeave = delayedLeave
+  resolvedHooks.group = group
   child.$transition = resolvedHooks
   if (isFrag) setTransitionHooksOnFragment(block, resolvedHooks)
 
@@ -365,6 +366,9 @@ export function setTransitionHooksOnFragment(
 ): void {
   if (isFragment(block)) {
     block.$transition = hooks
+    if (block.nodes && isFragment(block.nodes)) {
+      setTransitionHooksOnFragment(block.nodes, hooks)
+    }
   } else if (isArray(block)) {
     for (let i = 0; i < block.length; i++) {
       setTransitionHooksOnFragment(block[i], hooks)
index b1e86c87b240a4bc69607a725cca66258aaf0134..48dab603211ae74cbf9385ec0c8d9dbfd55dde8c 100644 (file)
@@ -10,11 +10,12 @@ import {
   hasCSSTransform,
   onBeforeUpdate,
   onUpdated,
+  queuePostFlushCb,
   resolveTransitionProps,
   useTransitionState,
   warn,
 } from '@vue/runtime-dom'
-import { extend, isArray } from '@vue/shared'
+import { extend, invokeArrayFns, isArray } from '@vue/shared'
 import {
   type Block,
   type TransitionBlock,
@@ -126,6 +127,7 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({
       props: cssTransitionProps,
       state,
       instance,
+      group: true,
     } as VaporTransitionHooks)
 
     children = getTransitionBlocks(slottedBlock)
@@ -133,10 +135,14 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({
       const child = children[i]
       if (isValidTransitionBlock(child)) {
         if (child.$key != null) {
-          setTransitionHooks(
+          const hooks = resolveTransitionHooks(
             child,
-            resolveTransitionHooks(child, cssTransitionProps, state, instance!),
+            cssTransitionProps,
+            state,
+            instance!,
           )
+          hooks.group = true
+          setTransitionHooks(child, hooks)
         } else if (__DEV__ && child.$key == null) {
           warn(`<transition-group> children must be keyed`)
         }
@@ -221,3 +227,23 @@ function getFirstConnectedChild(
     if (el.isConnected) return el
   }
 }
+
+/**
+ * The implementation of TransitionGroup relies on the onBeforeUpdate and onUpdated hooks.
+ * However, when the slot content of TransitionGroup updates, it does not trigger the
+ * onBeforeUpdate and onUpdated hooks. Therefore, it is necessary to manually trigger
+ * the TransitionGroup update hooks to ensure its proper work.
+ */
+export function triggerTransitionGroupUpdate(
+  transition: VaporTransitionHooks,
+): void {
+  const { instance } = transition
+  if (!instance.isUpdating) {
+    instance.isUpdating = true
+    if (instance.bu) invokeArrayFns(instance.bu)
+    queuePostFlushCb(() => {
+      instance.isUpdating = false
+      if (instance.u) invokeArrayFns(instance.u)
+    })
+  }
+}