Browse Source

fix(transition-group): align transition-group dynamic tag updates with vdom

daiwei 1 month ago
parent
commit
53483917b9

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

@@ -409,6 +409,157 @@ describe('vapor transition-group', () => {
     )
   })
 
+  // Dynamic tag changes have no leave transition, only enter transition.
+  // This matches vdom transition-group behavior.
+  test('dynamic tag', async () => {
+    const btnSelector = '.dynamic-tag > button'
+    const containerSelector = '.dynamic-tag > div'
+
+    expect(await html(containerSelector)).toBe(
+      `<div>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `</div>`,
+    )
+
+    // div -> section
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `<section>` +
+        `<div class="test v-enter-from v-enter-active">a</div>` +
+        `<div class="test v-enter-from v-enter-active">b</div>` +
+        `<div class="test v-enter-from v-enter-active">c</div>` +
+        `</section>`,
+    )
+    await waitForInnerHTML(
+      containerSelector,
+      `<section>` +
+        `<div class="test v-enter-active v-enter-to">a</div>` +
+        `<div class="test v-enter-active v-enter-to">b</div>` +
+        `<div class="test v-enter-active v-enter-to">c</div>` +
+        `</section>`,
+    )
+    await waitForInnerHTML(
+      containerSelector,
+      `<section>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `</section>`,
+    )
+
+    // section -> fragment
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `<div class="test v-enter-from v-enter-active">a</div>` +
+        `<div class="test v-enter-from v-enter-active">b</div>` +
+        `<div class="test v-enter-from v-enter-active">c</div>`,
+    )
+    await waitForInnerHTML(
+      containerSelector,
+      `<div class="test v-enter-active v-enter-to">a</div>` +
+        `<div class="test v-enter-active v-enter-to">b</div>` +
+        `<div class="test v-enter-active v-enter-to">c</div>`,
+    )
+    await waitForInnerHTML(
+      containerSelector,
+      `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>`,
+    )
+
+    // fragment -> div
+    expect(
+      (await transitionStart(btnSelector, containerSelector)).innerHTML,
+    ).toBe(
+      `<div>` +
+        `<div class="test v-enter-from v-enter-active">a</div>` +
+        `<div class="test v-enter-from v-enter-active">b</div>` +
+        `<div class="test v-enter-from v-enter-active">c</div>` +
+        `</div>`,
+    )
+    await waitForInnerHTML(
+      containerSelector,
+      `<div>` +
+        `<div class="test v-enter-active v-enter-to">a</div>` +
+        `<div class="test v-enter-active v-enter-to">b</div>` +
+        `<div class="test v-enter-active v-enter-to">c</div>` +
+        `</div>`,
+    )
+    await waitForInnerHTML(
+      containerSelector,
+      `<div>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `</div>`,
+    )
+  })
+
+  test('dynamic tag render effect leak', async () => {
+    const cycleBtnSelector = '.dynamic-tag-render-effect-leak > button.cycle'
+    const addBtnSelector = '.dynamic-tag-render-effect-leak > button.add'
+    const containerSelector = '.dynamic-tag-render-effect-leak > div'
+
+    expect(await html(containerSelector)).toBe(
+      `<div>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `</div>`,
+    )
+
+    await page().evaluate(() => {
+      ;(window as any).clearRenderCalls()
+    })
+
+    await transitionStart(cycleBtnSelector, containerSelector)
+    await waitForInnerHTML(
+      containerSelector,
+      `<section>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `</section>`,
+    )
+
+    await transitionStart(cycleBtnSelector, containerSelector)
+    await waitForInnerHTML(
+      containerSelector,
+      `<div class="test">a</div>` + `<div class="test">b</div>`,
+    )
+
+    await transitionStart(cycleBtnSelector, containerSelector)
+    await waitForInnerHTML(
+      containerSelector,
+      `<div>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `</div>`,
+    )
+
+    await page().evaluate(() => {
+      ;(window as any).clearRenderCalls()
+    })
+
+    await transitionStart(addBtnSelector, containerSelector)
+    await waitForInnerHTML(
+      containerSelector,
+      `<div>` +
+        `<div class="test">a</div>` +
+        `<div class="test">b</div>` +
+        `<div class="test">c</div>` +
+        `</div>`,
+    )
+
+    expect(
+      await page().evaluate(() => {
+        return (window as any).getRenderCalls()
+      }),
+    ).toEqual(['c'])
+  })
+
   test('events', async () => {
     const btnSelector = '.events > button'
     const containerSelector = '.events > div'

+ 42 - 0
packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/dynamic-tag-render-effect-leak.vue

@@ -0,0 +1,42 @@
+<script setup vapor lang="ts">
+import { ref } from 'vue'
+
+const tags = ['div', 'section', undefined]
+const tagIndex = ref(0)
+const tag = ref(tags[tagIndex.value])
+const items = ref(['a', 'b'])
+
+const cycleTag = () => {
+  tagIndex.value = (tagIndex.value + 1) % tags.length
+  tag.value = tags[tagIndex.value]
+}
+
+const addItem = () => {
+  items.value = [...items.value, String.fromCharCode(97 + items.value.length)]
+}
+
+const renderCalls: string[] = []
+;(window as any).getRenderCalls = () => [...renderCalls]
+;(window as any).clearRenderCalls = () => {
+  renderCalls.length = 0
+}
+
+const trackRender = (item: string) => {
+  renderCalls.push(item)
+  return item
+}
+</script>
+
+<template>
+  <div class="dynamic-tag-render-effect-leak">
+    <button class="cycle" @click="cycleTag">cycle tag</button>
+    <button class="add" @click="addItem">add item</button>
+    <div>
+      <transition-group :tag="tag">
+        <div v-for="item in items" :key="item" class="test">
+          {{ trackRender(item) }}
+        </div>
+      </transition-group>
+    </div>
+  </div>
+</template>

+ 25 - 0
packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/dynamic-tag.vue

@@ -0,0 +1,25 @@
+<script setup vapor>
+import { ref } from 'vue'
+
+const tags = ['div', 'section', undefined]
+const tagIndex = ref(0)
+const tag = ref(tags[tagIndex.value])
+
+const cycleTag = () => {
+  tagIndex.value = (tagIndex.value + 1) % tags.length
+  tag.value = tags[tagIndex.value]
+}
+</script>
+
+<template>
+  <div class="dynamic-tag">
+    <button @click="cycleTag">cycle tag</button>
+    <div>
+      <transition-group :tag="tag">
+        <div v-for="item in ['a', 'b', 'c']" :key="item" class="test">
+          {{ item }}
+        </div>
+      </transition-group>
+    </div>
+  </div>
+</template>

+ 44 - 26
packages/runtime-vapor/src/components/TransitionGroup.ts

@@ -35,7 +35,7 @@ import {
 } from '../component'
 import { isForBlock } from '../apiCreateFor'
 import { createElement } from '../dom/node'
-import { isFragment } from '../fragment'
+import { DynamicFragment, isFragment } from '../fragment'
 import {
   type DefineVaporComponent,
   defineVaporComponent,
@@ -80,25 +80,23 @@ const VaporTransitionGroupImpl = defineVaporComponent({
     })
 
     let prevChildren: TransitionBlock[]
-    const slottedBlock = slots.default && slots.default()
+    let slottedBlock: Block = []
 
     onBeforeUpdate(() => {
       prevChildren = []
       const children = getTransitionBlocks(slottedBlock)
-      if (children) {
-        for (let i = 0; i < children.length; i++) {
-          const child = children[i]
-          if (isValidTransitionBlock(child)) {
-            prevChildren.push(child)
-            // disabled transition during enter, so the children will be
-            // inserted into the correct position immediately. this prevents
-            // `recordPosition` from getting incorrect positions in `onUpdated`
-            child.$transition!.disabled = true
-            positionMap.set(
-              child,
-              getTransitionElement(child).getBoundingClientRect(),
-            )
-          }
+      for (let i = 0; i < children.length; i++) {
+        const child = children[i]
+        if (isValidTransitionBlock(child)) {
+          prevChildren.push(child)
+          // disabled transition during enter, so the children will be
+          // inserted into the correct position immediately. this prevents
+          // `recordPosition` from getting incorrect positions in `onUpdated`
+          child.$transition!.disabled = true
+          positionMap.set(
+            child,
+            getTransitionElement(child).getBoundingClientRect(),
+          )
         }
       }
     })
@@ -140,21 +138,41 @@ const VaporTransitionGroupImpl = defineVaporComponent({
       prevChildren = []
     })
 
-    applyGroupTransitionHooks(slottedBlock, {
+    const transitionHooks = {
       props: propsProxy,
       state,
       instance,
       applyGroup: applyGroupTransitionHooks,
-    } as VaporTransitionHooks)
+    } as VaporTransitionHooks
 
-    const tag = props.tag
-    if (tag) {
-      const container = createElement(tag)
-      insert(slottedBlock, container)
-      return container
-    } else {
-      return slottedBlock
-    }
+    const frag = new DynamicFragment('transition-group')
+    let currentTag: string | undefined
+    let isMounted = false
+    renderEffect(() => {
+      const tag = props.tag
+      // tag is not changed, do nothing
+      if (isMounted && tag === currentTag) return
+
+      let block: Block = slottedBlock
+      frag.update(
+        () => {
+          block = (slots.default && slots.default()) || []
+          applyGroupTransitionHooks(block, transitionHooks)
+          if (tag) {
+            const container = createElement(tag)
+            insert(block, container)
+            return container
+          }
+          return block
+        },
+        // Avoid `undefined` falling back to the render function as the key.
+        tag ?? null,
+      )
+      slottedBlock = block
+      currentTag = tag
+      isMounted = true
+    })
+    return frag
   },
 })