Kaynağa Gözat

fix(runtime-vapor): animate vapor component moves in TransitionGroup

daiwei 3 hafta önce
ebeveyn
işleme
cb584e8f99

+ 38 - 0
packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts

@@ -603,6 +603,44 @@ describe('vapor transition-group', () => {
     E2E_TIMEOUT,
   )
 
+  test('keyed component move after key change', async () => {
+    const btnSelector = '.keyed-component-move-after-key-change > button'
+    const containerSelector = '.keyed-component-move-after-key-change > div'
+
+    await expect
+      .element(css(containerSelector))
+      .toContainHTML(
+        `<div class="item-wrapper">` +
+          `<div class="item closed" id="item-1"><div class="item-inner">item 1</div></div>` +
+          `<div class="item closed" id="item-2"><div class="item-inner">item 2</div></div>` +
+          `<!--for--></div><!--transition-group-->`,
+      )
+
+    click(btnSelector)
+    await nextTick()
+    await nextFrame()
+
+    await expect
+      .element(css(containerSelector))
+      .toContainHTML(
+        `<div class="item-wrapper">` +
+          `<div class="item closed group-leave-from group-leave-active" id="item-1"><div class="item-inner">item 1</div></div>` +
+          `<div class="item opened group-enter-from group-enter-active" id="item-1"><div class="item-inner">item 1</div></div>` +
+          `<div class="item closed group-move" id="item-2" style=""><div class="item-inner">item 2</div></div>` +
+          `<!--for--></div><!--transition-group-->`,
+      )
+
+    await transitionFinish()
+    await expect
+      .element(css(containerSelector))
+      .toContainHTML(
+        `<div class="item-wrapper">` +
+          `<div class="item opened" id="item-1"><div class="item-inner">item 1</div></div>` +
+          `<div class="item closed" id="item-2" style=""><div class="item-inner">item 2</div></div>` +
+          `<!--for--></div><!--transition-group-->`,
+      )
+  })
+
   test('dynamic name', async () => {
     const btnSelector = '.dynamic-name button.toggleBtn'
     const btnChangeName = '.dynamic-name button.changeNameBtn'

+ 40 - 0
packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/keyed-component-move-after-key-change.vue

@@ -0,0 +1,40 @@
+<script setup vapor>
+import { ref } from 'vue'
+import VaporExpandingItem from '../../components/VaporExpandingItem.vue'
+
+const items = ref(
+  [...Array(2)].map((_, i) => ({
+    id: i + 1,
+    isOpened: false,
+  })),
+)
+
+function toggleExpansion() {
+  items.value[0].isOpened = !items.value[0].isOpened
+}
+</script>
+
+<template>
+  <div class="keyed-component-move-after-key-change">
+    <button @click="toggleExpansion">toggle expansion of first element</button>
+    <div>
+      <transition-group name="group" tag="div" class="item-wrapper">
+        <VaporExpandingItem
+          v-for="i in items"
+          :key="`${i.id}-${i.isOpened ? 'true' : 'false'}`"
+          :id="i.id"
+          :is-opened="i.isOpened"
+        />
+      </transition-group>
+    </div>
+  </div>
+</template>
+
+<style>
+.item-wrapper {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+  width: 430px;
+}
+</style>

+ 27 - 0
packages-private/vapor-e2e-test/transition-group/components/VaporExpandingItem.vue

@@ -0,0 +1,27 @@
+<script setup vapor lang="ts">
+defineProps<{
+  id: number
+  isOpened: boolean
+}>()
+</script>
+
+<template>
+  <div class="item" :class="isOpened ? 'opened' : 'closed'" :id="`item-${id}`">
+    <div class="item-inner">item {{ id }}</div>
+  </div>
+</template>
+
+<style>
+.item {
+  border: 1px solid black;
+  width: 100px;
+  height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.item.opened {
+  width: 420px;
+}
+</style>

+ 31 - 0
packages/runtime-vapor/__tests__/for.spec.ts

@@ -19,6 +19,7 @@ import {
 import {
   type Ref,
   nextTick,
+  onScopeDispose,
   reactive,
   readonly,
   ref,
@@ -833,6 +834,36 @@ describe('createFor', () => {
     expect(host.innerHTML).toBe('<!--for-->')
   })
 
+  test('should track key dependencies for keyed diff', async () => {
+    const list = ref([{ id: 1, opened: false }])
+    const calls: string[] = []
+
+    const { host } = define(() => {
+      return createFor(
+        () => list.value,
+        item => {
+          const label = `${item.value.id}-${item.value.opened}`
+          calls.push(`mount ${label}`)
+          onScopeDispose(() => calls.push(`unmount ${label}`))
+
+          const span = document.createElement('span')
+          span.textContent = label
+          return span
+        },
+        item => `${item.id}-${item.opened}`,
+      )
+    }).render()
+
+    expect(host.innerHTML).toBe('<span>1-false</span><!--for-->')
+    expect(calls).toEqual(['mount 1-false'])
+
+    list.value[0].opened = true
+    await nextTick()
+
+    expect(host.innerHTML).toBe('<span>1-true</span><!--for-->')
+    expect(calls).toEqual(['mount 1-false', 'unmount 1-false', 'mount 1-true'])
+  })
+
   describe('readonly source', () => {
     test('should not allow mutation', () => {
       const arr = readonly(reactive([{ foo: 1 }]))

+ 22 - 7
packages/runtime-vapor/src/apiCreateFor.ts

@@ -108,6 +108,7 @@ export const createFor = (
   let isMounted = false
   let oldBlocks: ForBlock[] = []
   let newBlocks: ForBlock[]
+  let newKeys: any[] | undefined
   let parent: ParentNode | undefined | null
   let parentAnchor: Node
   let pendingHydrationAnchor = false
@@ -144,9 +145,24 @@ export const createFor = (
     const newLength = source.values.length
     const oldLength = oldBlocks.length
     newBlocks = new Array(newLength)
+    // Key expressions can depend on item fields, not just list shape. Evaluate
+    // them while the render effect is still the active subscriber so those deps
+    // can trigger keyed diff, then reuse the same keys below after
+    // setActiveSub() clears the active subscriber during patching.
+    newKeys = undefined
+    if (getKey) {
+      newKeys = new Array(newLength)
+      for (let i = 0; i < newLength; i++) {
+        newKeys[i] = getKey(...getItem(source, i))
+      }
+    }
 
     const prevSub = setActiveSub()
-
+    if (isMounted && frag.onBeforeUpdate) {
+      for (let i = 0; i < frag.onBeforeUpdate.length; i++) {
+        frag.onBeforeUpdate[i]()
+      }
+    }
     if (!isMounted) {
       isMounted = true
       if (isHydrating) {
@@ -196,8 +212,7 @@ export const createFor = (
         if (__DEV__) {
           const keyToIndexMap: Map<any, number> = new Map()
           for (let i = 0; i < newLength; i++) {
-            const item = getItem(source, i)
-            const key = getKey(...item)
+            const key = newKeys![i]
             if (key != null) {
               if (keyToIndexMap.has(key)) {
                 warn(
@@ -226,7 +241,7 @@ export const createFor = (
         while (endOffset < commonLength) {
           const index = newLength - endOffset - 1
           const item = getItem(source, index)
-          const key = getKey(...item)
+          const key = newKeys![index]
           const existingBlock = oldBlocks[oldLength - endOffset - 1]
           if (existingBlock.key !== key) break
           update(existingBlock, ...item)
@@ -240,7 +255,7 @@ export const createFor = (
 
         for (let i = 0; i < e1; i++) {
           const currentItem = getItem(source, i)
-          const currentKey = getKey(...currentItem)
+          const currentKey = newKeys![i]
           const oldBlock = oldBlocks[i]
           const oldKey = oldBlock.key
           if (oldKey === currentKey) {
@@ -257,7 +272,7 @@ export const createFor = (
 
         for (let i = e1; i < e3; i++) {
           const blockItem = getItem(source, i)
-          const blockKey = getKey(...blockItem)
+          const blockKey = newKeys![i]
           queuedBlocks[queuedBlocksLength++] = [i, blockItem, blockKey]
         }
 
@@ -411,7 +426,7 @@ export const createFor = (
     idx: number,
     anchor: Node | undefined = parentAnchor,
     [item, key, index] = getItem(source, idx),
-    key2 = getKey && getKey(item, key, index),
+    key2 = newKeys ? newKeys[idx] : getKey && getKey(item, key, index),
   ): ForBlock => {
     const itemRef = shallowRef(item)
     // avoid creating refs if the render fn doesn't need it

+ 98 - 10
packages/runtime-vapor/src/components/TransitionGroup.ts

@@ -13,7 +13,9 @@ import {
   hasCSSTransform,
   onBeforeUpdate,
   onUpdated,
+  queuePostFlushCb,
   resolveTransitionProps,
+  setCurrentInstance,
   useTransitionState,
   warn,
 } from '@vue/runtime-dom'
@@ -37,6 +39,7 @@ import {
   type VaporComponentOptions,
   isVaporComponent,
 } from '../component'
+import { resolveDynamicProps } from '../componentProps'
 import { isForBlock, setForHydrationAnchorResolver } from '../apiCreateFor'
 import { createComment, createElement, createTextNode } from '../dom/node'
 import { DynamicFragment, type VaporFragment, isFragment } from '../fragment'
@@ -44,6 +47,7 @@ import {
   type DefineVaporComponent,
   defineVaporComponent,
 } from '../apiDefineComponent'
+import { watch } from '@vue/reactivity'
 import { isInteropEnabled } from '../vdomInteropState'
 import {
   adoptTemplate,
@@ -58,6 +62,20 @@ import {
 const positionMap = new WeakMap<TransitionBlock, DOMRect>()
 const newPositionMap = new WeakMap<TransitionBlock, DOMRect>()
 
+type TransitionGroupUpdateOwner = VaporFragment | VaporComponentInstance
+
+type TransitionGroupUpdateHookRef = {
+  beforeUpdate: () => void
+  updated: () => void
+}
+
+// Each owner installs its update callback once. The stored hook object lets
+// that callback keep pointing at the latest TransitionGroup update hooks.
+const transitionGroupUpdateOwnerMap = new WeakMap<
+  TransitionGroupUpdateOwner,
+  TransitionGroupUpdateHookRef
+>()
+
 let isForHydrationAnchorResolverRegistered = false
 let currentForHydrationContainer: ParentNode | undefined
 
@@ -124,10 +142,16 @@ const VaporTransitionGroupImpl = defineVaporComponent({
       true,
     )
 
-    let prevChildren: ResolvedTransitionBlock[]
+    let prevChildren: ResolvedTransitionBlock[] = []
+    // Multiple child owners can update in the same flush (e.g. a VDOM child
+    // props update plus the surrounding v-for keyed diff). Keep the first
+    // position snapshot until the matching updated hook applies the move.
+    let isUpdatePending = false
     let slottedBlock: Block = []
 
-    onBeforeUpdate(() => {
+    const beforeUpdate = () => {
+      if (isUpdatePending) return
+      isUpdatePending = true
       prevChildren = []
       const children = getTransitionBlocks(slottedBlock)
       for (let i = 0; i < children.length; i++) {
@@ -145,9 +169,11 @@ const VaporTransitionGroupImpl = defineVaporComponent({
           positionMap.set(child, el.getBoundingClientRect())
         }
       }
-    })
+    }
 
-    onUpdated(() => {
+    const updated = () => {
+      if (!isUpdatePending) return
+      isUpdatePending = false
       if (!prevChildren.length) {
         return
       }
@@ -182,7 +208,10 @@ const VaporTransitionGroupImpl = defineVaporComponent({
         ),
       )
       prevChildren = []
-    })
+    }
+
+    onBeforeUpdate(beforeUpdate)
+    onUpdated(updated)
 
     const frag = new DynamicFragment('transition-group')
     let currentTag: string | undefined
@@ -221,6 +250,7 @@ const VaporTransitionGroupImpl = defineVaporComponent({
             propsProxy,
             state,
             instance,
+            { beforeUpdate, updated },
           )
           if (container) {
             if (!isHydrating) insert(block, container)
@@ -266,9 +296,14 @@ function applyGroupTransitionHooks(
   props: TransitionProps,
   state: TransitionState,
   instance: VaporComponentInstance,
+  updateHooks: TransitionGroupUpdateHookRef,
 ): ResolvedTransitionBlock[] {
   const fragments: VaporFragment[] = []
-  const children = getTransitionBlocks(block, frag => fragments.push(frag))
+  const children = getTransitionBlocks(
+    block,
+    frag => fragments.push(frag),
+    owner => trackTransitionGroupUpdate(owner, updateHooks),
+  )
   for (let i = 0; i < children.length; i++) {
     const child = children[i]
     if (isValidTransitionBlock(child)) {
@@ -286,12 +321,62 @@ function applyGroupTransitionHooks(
   // propagate hooks to inner fragments for reusing during insert new items
   fragments.forEach(frag => {
     const hooks = resolveTransitionHooks(frag, props, state, instance)
-    hooks.applyGroup = applyGroupTransitionHooks
+    hooks.applyGroup = (block, props, state, instance) =>
+      applyGroupTransitionHooks(block, props, state, instance, updateHooks)
     frag.$transition = hooks
   })
   return children
 }
 
+function trackTransitionGroupUpdate(
+  owner: TransitionGroupUpdateOwner,
+  updateHooks: TransitionGroupUpdateHookRef,
+): void {
+  const registeredHooks = transitionGroupUpdateOwnerMap.get(owner)
+  if (registeredHooks) {
+    registeredHooks.beforeUpdate = updateHooks.beforeUpdate
+    registeredHooks.updated = updateHooks.updated
+    return
+  }
+
+  transitionGroupUpdateOwnerMap.set(owner, updateHooks)
+  if (isFragment(owner)) {
+    ;(owner.onBeforeUpdate ||= []).push(() => updateHooks.beforeUpdate())
+    ;(owner.onUpdated ||= []).push(() => updateHooks.updated())
+  } else {
+    // A component child can update from parent-driven props without re-running
+    // the surrounding v-for fragment. Watch raw props directly instead of
+    // using component updated hooks, because child-local state updates should
+    // not trigger TransitionGroup move bookkeeping. This matches VDOM behavior.
+    let isPending = false
+    const flushUpdated = () => {
+      isPending = false
+      updateHooks.updated()
+    }
+    owner.scope.run(() => {
+      watch(
+        () => {
+          // Dynamic prop sources are resolved as child props, so the getter
+          // must run with the child instance while the watcher itself remains
+          // owned by the child scope for teardown.
+          const prev = setCurrentInstance(owner, owner.scope)
+          try {
+            return resolveDynamicProps(owner.rawProps)
+          } finally {
+            setCurrentInstance(...prev)
+          }
+        },
+        () => {
+          if (isPending) return
+          isPending = true
+          updateHooks.beforeUpdate()
+          queuePostFlushCb(flushUpdated)
+        },
+      )
+    })
+  }
+}
+
 function inheritKey(children: TransitionBlock[], key: any): void {
   if (key === undefined || children.length === 0) return
   for (let i = 0; i < children.length; i++) {
@@ -303,18 +388,20 @@ function inheritKey(children: TransitionBlock[], key: any): void {
 function getTransitionBlocks(
   block: Block,
   onFragment?: (frag: VaporFragment) => void,
+  onUpdateOwner?: (owner: TransitionGroupUpdateOwner) => void,
 ): ResolvedTransitionBlock[] {
   let children: ResolvedTransitionBlock[] = []
   if (block instanceof Element) {
     children.push(block)
   } else if (isVaporComponent(block)) {
-    const blocks = getTransitionBlocks(block.block, onFragment)
+    if (onUpdateOwner) onUpdateOwner(block)
+    const blocks = getTransitionBlocks(block.block, onFragment, onUpdateOwner)
     inheritKey(blocks, block.$key)
     children.push(...blocks)
   } else if (isArray(block)) {
     for (let i = 0; i < block.length; i++) {
       const b = block[i]
-      const blocks = getTransitionBlocks(b, onFragment)
+      const blocks = getTransitionBlocks(b, onFragment, onUpdateOwner)
       if (isForBlock(b)) blocks.forEach(block => (block.$key = b.key))
       children.push(...blocks)
     }
@@ -324,7 +411,8 @@ function getTransitionBlocks(
       children.push(block)
     } else {
       if (onFragment) onFragment(block)
-      const blocks = getTransitionBlocks(block.nodes, onFragment)
+      if (onUpdateOwner) onUpdateOwner(block)
+      const blocks = getTransitionBlocks(block.nodes, onFragment, onUpdateOwner)
       inheritKey(blocks, block.$key)
       children.push(...blocks)
     }

+ 1 - 0
packages/runtime-vapor/src/fragment.ts

@@ -92,6 +92,7 @@ export class VaporFragment<
   ) => void
 
   // hooks
+  onBeforeUpdate?: (() => void)[]
   onUpdated?: ((nodes?: Block) => void)[]
 
   // render context