diff --git a/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts
new file mode 100644
index 00000000000..ba050f0f263
--- /dev/null
+++ b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts
@@ -0,0 +1,406 @@
+import path from 'node:path'
+import {
+ E2E_TIMEOUT,
+ setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+import { expect } from 'vitest'
+const { page, nextFrame, timeout, html, transitionStart } = setupPuppeteer()
+
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
+
+describe('vapor transition-group', () => {
+ let server: any
+ const port = '8196'
+ beforeAll(() => {
+ server = connect()
+ .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+ .listen(port)
+ process.on('SIGTERM', () => server && server.close())
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ beforeEach(async () => {
+ const baseUrl = `http://localhost:${port}/transition-group/`
+ await page().goto(baseUrl)
+ await page().waitForSelector('#app')
+ })
+
+ test(
+ 'enter',
+ async () => {
+ const btnSelector = '.enter > button'
+ const containerSelector = '.enter > div'
+
+ expect(await html(containerSelector)).toBe(
+ `
a
` +
+ `b
` +
+ `c
`,
+ )
+
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
` +
+ `e
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
` +
+ `e
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
` +
+ `e
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'leave',
+ async () => {
+ const btnSelector = '.leave > button'
+ const containerSelector = '.leave > div'
+
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(`b
`)
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'enter + leave',
+ async () => {
+ const btnSelector = '.enter-leave > button'
+ const containerSelector = '.enter-leave > div'
+
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
`,
+ )
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `b
` +
+ `c
` +
+ `d
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'appear',
+ async () => {
+ const btnSelector = '.appear > button'
+ const containerSelector = '.appear > div'
+
+ expect(await html('.appear')).toBe(``)
+
+ await page().evaluate(() => {
+ return (window as any).setAppear()
+ })
+
+ // appear
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
` +
+ `e
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
` +
+ `e
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
` +
+ `e
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'move',
+ async () => {
+ const btnSelector = '.move > button'
+ const containerSelector = '.move > div'
+
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `d
` +
+ `b
` +
+ `a
` +
+ `c
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `d
` +
+ `b
` +
+ `a
` +
+ `c
`,
+ )
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe(
+ `d
` +
+ `b
` +
+ `a
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test('dynamic name', async () => {
+ const btnSelector = '.dynamic-name button.toggleBtn'
+ const btnChangeName = '.dynamic-name button.changeNameBtn'
+ const containerSelector = '.dynamic-name > div'
+
+ expect(await html(containerSelector)).toBe(
+ `a
` + `b
` + `c
`,
+ )
+
+ // invalid name
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`b
` + `c
` + `a
`)
+
+ // change name
+ expect(
+ (await transitionStart(btnChangeName, containerSelector)).innerHTML,
+ ).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+ })
+
+ test('events', async () => {
+ const btnSelector = '.events > button'
+ const containerSelector = '.events > div'
+
+ expect(await html('.events')).toBe(``)
+
+ await page().evaluate(() => {
+ return (window as any).setAppear()
+ })
+
+ // appear
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls()
+ })
+ expect(calls).toContain('beforeAppear')
+ expect(calls).toContain('onAppear')
+ expect(calls).not.toContain('afterAppear')
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
`,
+ )
+
+ expect(
+ await page().evaluate(() => {
+ return (window as any).getCalls()
+ }),
+ ).toContain('afterAppear')
+
+ // enter + leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
`,
+ )
+
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls()
+ })
+ expect(calls).toContain('beforeLeave')
+ expect(calls).toContain('onLeave')
+ expect(calls).not.toContain('afterLeave')
+ expect(calls).toContain('beforeEnter')
+ expect(calls).toContain('onEnter')
+ expect(calls).not.toContain('afterEnter')
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `a
` +
+ `b
` +
+ `c
` +
+ `d
`,
+ )
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls()
+ })
+ expect(calls).not.toContain('afterLeave')
+ expect(calls).not.toContain('afterEnter')
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `b
` +
+ `c
` +
+ `d
`,
+ )
+
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls()
+ })
+ expect(calls).toContain('afterLeave')
+ expect(calls).toContain('afterEnter')
+ })
+
+ test('interop: render vdom component', async () => {
+ const btnSelector = '.interop > button'
+ const containerSelector = '.interop > div'
+
+ expect(await html(containerSelector)).toBe(
+ `` +
+ `` +
+ ``,
+ )
+
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `` +
+ `` +
+ `` +
+ ``,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `` +
+ `` +
+ `` +
+ ``,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `` +
+ `` +
+ ``,
+ )
+ })
+})
diff --git a/packages-private/vapor-e2e-test/__tests__/transition.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts
new file mode 100644
index 00000000000..0bfc30598cc
--- /dev/null
+++ b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts
@@ -0,0 +1,1660 @@
+import path from 'node:path'
+import {
+ E2E_TIMEOUT,
+ setupPuppeteer,
+} from '../../../packages/vue/__tests__/e2e/e2eUtils'
+import connect from 'connect'
+import sirv from 'sirv'
+import { nextTick } from 'vue'
+const {
+ page,
+ classList,
+ text,
+ nextFrame,
+ timeout,
+ isVisible,
+ count,
+ html,
+ transitionStart,
+ waitForElement,
+ click,
+} = setupPuppeteer()
+
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
+
+describe('vapor transition', () => {
+ let server: any
+ const port = '8195'
+ beforeAll(() => {
+ server = connect()
+ .use(sirv(path.resolve(import.meta.dirname, '../dist')))
+ .listen(port)
+ process.on('SIGTERM', () => server && server.close())
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ beforeEach(async () => {
+ const baseUrl = `http://localhost:${port}/transition/`
+ await page().goto(baseUrl)
+ await page().waitForSelector('#app')
+ })
+
+ describe('transition with v-if', () => {
+ test(
+ 'basic transition',
+ async () => {
+ const btnSelector = '.if-basic > button'
+ const containerSelector = '.if-basic > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ `content
`,
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'v-leave-from', 'v-leave-active'])
+
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'v-leave-active',
+ 'v-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'v-enter-from', 'v-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'v-enter-active',
+ 'v-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'named transition',
+ async () => {
+ const btnSelector = '.if-named > button'
+ const containerSelector = '.if-named > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'custom transition classes',
+ async () => {
+ const btnSelector = '.if-custom-classes > button'
+ const containerSelector = '.if-custom-classes > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'bye-from', 'bye-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'bye-active',
+ 'bye-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'hello-from', 'hello-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'hello-active',
+ 'hello-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition with dynamic name',
+ async () => {
+ const btnSelector = '.if-dynamic-name > button.toggle'
+ const btnChangeNameSelector = '.if-dynamic-name > button.change'
+ const containerSelector = '.if-dynamic-name > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ await click(btnChangeNameSelector)
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'changed-enter-from', 'changed-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'changed-enter-active',
+ 'changed-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition events without appear',
+ async () => {
+ const btnSelector = '.if-events-without-appear > button'
+ const containerSelector = '.if-events-without-appear > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('withoutAppear')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+
+ expect(
+ await page().evaluate(() => {
+ return (window as any).getCalls('withoutAppear')
+ }),
+ ).not.contain('afterLeave')
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+ expect(
+ await page().evaluate(() => {
+ return (window as any).getCalls('withoutAppear')
+ }),
+ ).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('withoutAppear')
+ })
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withoutAppear')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ expect(
+ await page().evaluate(() => {
+ return (window as any).getCalls('withoutAppear')
+ }),
+ ).not.contain('afterEnter')
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ expect(
+ await page().evaluate(() => {
+ return (window as any).getCalls('withoutAppear')
+ }),
+ ).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'events with arguments',
+ async () => {
+ const btnSelector = '.if-events-with-args > button'
+ const containerSelector = '.if-events-with-args > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ await click(btnSelector)
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('withArgs')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'before-leave',
+ 'leave',
+ ])
+
+ await timeout(200 + buffer)
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withArgs')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+ expect(await html(containerSelector)).toBe('')
+
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('withArgs')
+ })
+
+ // enter
+ await click(btnSelector)
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withArgs')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'before-enter',
+ 'enter',
+ ])
+
+ await timeout(200 + buffer)
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withArgs')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'onEnterCancelled',
+ async () => {
+ const btnSelector = '.if-enter-cancelled > button'
+ const containerSelector = '.if-enter-cancelled > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+
+ // cancel (leave)
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('enterCancel')
+ })
+ expect(calls).toStrictEqual(['enterCancelled'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition on appear',
+ async () => {
+ const btnSelector = '.if-appear > button'
+ const containerSelector = '.if-appear > div'
+ const childSelector = `${containerSelector} > div`
+
+ // appear
+ expect(await classList(childSelector)).contains('test-appear-active')
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition events with appear',
+ async () => {
+ const btnSelector = '.if-events-with-appear > button'
+ const containerSelector = '.if-events-with-appear > div'
+ const childSelector = `${containerSelector} > div`
+ // appear
+ expect(await classList(childSelector)).contains('test-appear-active')
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).toStrictEqual(['beforeAppear', 'onAppear'])
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).toStrictEqual(['beforeAppear', 'onAppear', 'afterAppear'])
+
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('withAppear')
+ })
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).not.contain('afterLeave')
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('withAppear')
+ })
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).not.contain('afterEnter')
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('withAppear')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+ },
+ E2E_TIMEOUT,
+ )
+ test(
+ 'css: false',
+ async () => {
+ const btnSelector = '.if-css-false > button'
+ const containerSelector = '.if-css-false > div'
+ const childSelector = `${containerSelector} > div`
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ await click(btnSelector)
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('cssFalse')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+ expect(await html(containerSelector)).toBe('')
+
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('cssFalse')
+ })
+
+ // enter
+ await transitionStart(btnSelector, childSelector)
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('cssFalse')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'no transition detected',
+ async () => {
+ const btnSelector = '.if-no-trans > button'
+ const containerSelector = '.if-no-trans > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe('content
')
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['noop-leave-from', 'noop-leave-active'])
+ await nextFrame()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['noop-enter-from', 'noop-enter-active'])
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'animations',
+ async () => {
+ const btnSelector = '.if-ani > button'
+ const containerSelector = '.if-ani > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe('content
')
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test-anim-leave-from', 'test-anim-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test-anim-leave-active',
+ 'test-anim-leave-to',
+ ])
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test-anim-enter-from', 'test-anim-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test-anim-enter-active',
+ 'test-anim-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'explicit transition type',
+ async () => {
+ const btnSelector = '.if-ani-explicit-type > button'
+ const containerSelector = '.if-ani-explicit-type > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe('content
')
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual([
+ 'test-anim-long-leave-from',
+ 'test-anim-long-leave-active',
+ ])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test-anim-long-leave-active',
+ 'test-anim-long-leave-to',
+ ])
+
+ if (!process.env.CI) {
+ await new Promise(r => {
+ setTimeout(r, duration - buffer)
+ })
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test-anim-long-leave-active',
+ 'test-anim-long-leave-to',
+ ])
+ }
+
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual([
+ 'test-anim-long-enter-from',
+ 'test-anim-long-enter-active',
+ ])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test-anim-long-enter-active',
+ 'test-anim-long-enter-to',
+ ])
+
+ if (!process.env.CI) {
+ await new Promise(r => {
+ setTimeout(r, duration - buffer)
+ })
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test-anim-long-enter-active',
+ 'test-anim-long-enter-to',
+ ])
+ }
+
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test.todo('transition on SVG elements', async () => {}, E2E_TIMEOUT)
+
+ test(
+ 'custom transition higher-order component',
+ async () => {
+ const btnSelector = '.if-high-order > button'
+ const containerSelector = '.if-high-order > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition on child components with empty root node',
+ async () => {
+ const btnSelector = '.if-empty-root > button.toggle'
+ const btnChangeSelector = '.if-empty-root > button.change'
+ const containerSelector = '.if-empty-root > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe('')
+
+ // change view -> 'two'
+ await click(btnChangeSelector)
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'two
',
+ )
+
+ // change view -> 'one'
+ await click(btnChangeSelector)
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition with v-if at component root-level',
+ async () => {
+ const btnSelector = '.if-at-component-root-level > button.toggle'
+ const btnChangeSelector = '.if-at-component-root-level > button.change'
+ const containerSelector = '.if-at-component-root-level > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe('')
+
+ // change view -> 'two'
+ await click(btnChangeSelector)
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'two
',
+ )
+
+ // change view -> 'one'
+ await click(btnChangeSelector)
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'wrapping transition + fallthrough attrs',
+ async () => {
+ const btnSelector = '.if-fallthrough-attr > button'
+ const containerSelector = '.if-fallthrough-attr > div'
+
+ expect(await html(containerSelector)).toBe('content
')
+
+ await click(btnSelector)
+ // toggle again before leave finishes
+ await nextTick()
+ await click(btnSelector)
+
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition + fallthrough attrs (in-out mode)',
+ async () => {
+ const btnSelector = '.if-fallthrough-attr-in-out > button'
+ const containerSelector = '.if-fallthrough-attr-in-out > div'
+
+ expect(await html(containerSelector)).toBe('one
')
+
+ // toggle
+ await click(btnSelector)
+ await nextTick()
+ await transitionFinish(duration * 3)
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('ifInOut')
+ })
+ expect(calls).toStrictEqual([
+ 'beforeEnter',
+ 'onEnter',
+ 'afterEnter',
+ 'beforeLeave',
+ 'onLeave',
+ 'afterLeave',
+ ])
+
+ expect(await html(containerSelector)).toBe(
+ 'two
',
+ )
+
+ // clear calls
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('ifInOut')
+ })
+
+ // toggle back
+ await click(btnSelector)
+ await nextTick()
+ await transitionFinish(duration * 3)
+
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('ifInOut')
+ })
+ expect(calls).toStrictEqual([
+ 'beforeEnter',
+ 'onEnter',
+ 'afterEnter',
+ 'beforeLeave',
+ 'onLeave',
+ 'afterLeave',
+ ])
+
+ expect(await html(containerSelector)).toBe(
+ 'one
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+ })
+
+ describe.todo('transition with KeepAlive', () => {})
+ describe.todo('transition with Suspense', () => {})
+ describe.todo('transition with Teleport', () => {})
+
+ describe('transition with v-show', () => {
+ test(
+ 'named transition with v-show',
+ async () => {
+ const btnSelector = '.show-named > button'
+ const containerSelector = '.show-named > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ expect(await isVisible(childSelector)).toBe(true)
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await isVisible(childSelector)).toBe(false)
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition events with v-show',
+ async () => {
+ const btnSelector = '.show-events > button'
+ const containerSelector = '.show-events > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('show')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('show')
+ })
+ expect(calls).not.contain('afterLeave')
+ await transitionFinish()
+ expect(await isVisible(childSelector)).toBe(false)
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('show')
+ })
+ expect(calls).toStrictEqual(['beforeLeave', 'onLeave', 'afterLeave'])
+
+ // clear calls
+ await page().evaluate(() => {
+ ;(window as any).resetCalls('show')
+ })
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('show')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('show')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'onLeaveCancelled (v-show only)',
+ async () => {
+ const btnSelector = '.show-leave-cancelled > button'
+ const containerSelector = '.show-leave-cancelled > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+
+ // cancel (enter)
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('showLeaveCancel')
+ })
+ expect(calls).toStrictEqual(['leaveCancelled'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await isVisible(childSelector)).toBe(true)
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition on appear with v-show',
+ async () => {
+ const btnSelector = '.show-appear > button'
+ const containerSelector = '.show-appear > div'
+ const childSelector = `${containerSelector} > div`
+
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('showAppear')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+
+ // appear
+ expect(await classList(childSelector)).contains('test-appear-active')
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('showAppear')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await isVisible(childSelector)).toBe(false)
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'transition events should not call onEnter with v-show false',
+ async () => {
+ const btnSelector = '.show-appear-not-enter > button'
+ const containerSelector = '.show-appear-not-enter > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await isVisible(childSelector)).toBe(false)
+ let calls = await page().evaluate(() => {
+ return (window as any).getCalls('notEnter')
+ })
+ expect(calls).toStrictEqual([])
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('notEnter')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('notEnter')
+ })
+ expect(calls).not.contain('afterEnter')
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ calls = await page().evaluate(() => {
+ return (window as any).getCalls('notEnter')
+ })
+ expect(calls).toStrictEqual(['beforeEnter', 'onEnter', 'afterEnter'])
+ },
+ E2E_TIMEOUT,
+ )
+ })
+
+ describe('explicit durations', () => {
+ test(
+ 'single value',
+ async () => {
+ const btnSelector = '.duration-single-value > button'
+ const containerSelector = '.duration-single-value > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'enter with explicit durations',
+ async () => {
+ const btnSelector = '.duration-enter > button'
+ const containerSelector = '.duration-enter > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'leave with explicit durations',
+ async () => {
+ const btnSelector = '.duration-leave > button'
+ const containerSelector = '.duration-leave > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'separate enter and leave',
+ async () => {
+ const btnSelector = '.duration-enter-leave > button'
+ const containerSelector = '.duration-enter-leave > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+
+ // leave
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-leave-from', 'test-leave-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-leave-active',
+ 'test-leave-to',
+ ])
+ await transitionFinish(duration * 2)
+ expect(await html(containerSelector)).toBe('')
+
+ // enter
+ expect(
+ (await transitionStart(btnSelector, childSelector)).classNames,
+ ).toStrictEqual(['test', 'test-enter-from', 'test-enter-active'])
+ await nextFrame()
+ expect(await classList(childSelector)).toStrictEqual([
+ 'test',
+ 'test-enter-active',
+ 'test-enter-to',
+ ])
+ await transitionFinish(duration * 4)
+ expect(await html(containerSelector)).toBe(
+ 'content
',
+ )
+ },
+ E2E_TIMEOUT,
+ )
+ })
+
+ test(
+ 'should work with keyed element',
+ async () => {
+ const btnSelector = '.keyed > button'
+ const containerSelector = '.keyed > h1'
+
+ expect(await text(containerSelector)).toContain('0')
+
+ // change key
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).classNames,
+ ).toStrictEqual(['v-leave-from', 'v-leave-active'])
+
+ await nextFrame()
+ expect(await classList(containerSelector)).toStrictEqual([
+ 'v-leave-active',
+ 'v-leave-to',
+ ])
+
+ await transitionFinish()
+ await nextFrame()
+ expect(await text(containerSelector)).toContain('1')
+
+ // change key again
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).classNames,
+ ).toStrictEqual(['v-leave-from', 'v-leave-active'])
+
+ await nextFrame()
+ expect(await classList(containerSelector)).toStrictEqual([
+ 'v-leave-active',
+ 'v-leave-to',
+ ])
+
+ await transitionFinish()
+ await nextFrame()
+ expect(await text(containerSelector)).toContain('2')
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'should work with out-in mode',
+ async () => {
+ const btnSelector = '.out-in > button'
+ const containerSelector = '.out-in > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(`vapor compB
`)
+
+ // compB -> compA
+ // compB leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`vapor compB
`)
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compB
`,
+ )
+
+ // compA enter
+ await waitForElement(childSelector, 'vapor compA', [
+ 'fade-enter-from',
+ 'fade-enter-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ await transitionFinish()
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ // compA -> compB
+ // compA leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`vapor compA
`)
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ // compB enter
+ await waitForElement(childSelector, 'vapor compB', [
+ 'fade-enter-from',
+ 'fade-enter-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compB
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vapor compB
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'should work with in-out mode',
+ async () => {
+ const btnSelector = '.in-out > button'
+ const containerSelector = '.in-out > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(`vapor compB
`)
+
+ // compA enter
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vapor compB
vapor compA
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compB
vapor compA
`,
+ )
+
+ // compB leave
+ await waitForElement(childSelector, 'vapor compB', [
+ 'fade-leave-from',
+ 'fade-leave-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compB
vapor compA
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ // tests for using vdom component in createVaporApp + vaporInteropPlugin
+ describe('interop', () => {
+ test(
+ 'render vdom component',
+ async () => {
+ const btnSelector = '.vdom > button'
+ const containerSelector = '.vdom > div'
+
+ expect(await html(containerSelector)).toBe(`vdom comp
`)
+
+ // comp leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`vdom comp
`)
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(``)
+
+ // comp enter
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`vdom comp
`)
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'switch between vdom/vapor component (out-in mode)',
+ async () => {
+ const btnSelector = '.vdom-vapor-out-in > button'
+ const containerSelector = '.vdom-vapor-out-in > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(`vdom comp
`)
+
+ // switch to vapor comp
+ // vdom comp leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`vdom comp
`)
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ // vapor comp enter
+ await waitForElement(childSelector, 'vapor compA', [
+ 'fade-enter-from',
+ 'fade-enter-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ // switch to vdom comp
+ // vapor comp leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vapor compA
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ // vdom comp enter
+ await waitForElement(childSelector, 'vdom comp', [
+ 'fade-enter-from',
+ 'fade-enter-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'switch between vdom/vapor component (in-out mode)',
+ async () => {
+ const btnSelector = '.vdom-vapor-in-out > button'
+ const containerSelector = '.vdom-vapor-in-out > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(`vapor compA
`)
+
+ // switch to vdom comp
+ // vdom comp enter
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vapor compA
vdom comp
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
vdom comp
`,
+ )
+
+ // vapor comp leave
+ await waitForElement(childSelector, 'vapor compA', [
+ 'fade-leave-from',
+ 'fade-leave-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
vdom comp
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ // switch to vapor comp
+ // vapor comp enter
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vdom comp
vapor compA
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
vapor compA
`,
+ )
+
+ // vdom comp leave
+ await waitForElement(childSelector, 'vdom comp', [
+ 'fade-leave-from',
+ 'fade-leave-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
vapor compA
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+ })
+})
diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
index 360f48085a1..e05f06e1abd 100644
--- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
+++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
@@ -5,10 +5,23 @@ import {
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'
+const {
+ page,
+ click,
+ text,
+ enterValue,
+ html,
+ transitionStart,
+ waitForElement,
+ nextFrame,
+ timeout,
+} = setupPuppeteer()
-describe('vdom / vapor interop', () => {
- const { page, click, text, enterValue } = setupPuppeteer()
+const duration = process.env.CI ? 200 : 50
+const buffer = process.env.CI ? 50 : 20
+const transitionFinish = (time = duration) => timeout(time + buffer)
+describe('vdom / vapor interop', () => {
let server: any
const port = '8193'
beforeAll(() => {
@@ -22,12 +35,15 @@ describe('vdom / vapor interop', () => {
server.close()
})
+ beforeEach(async () => {
+ const baseUrl = `http://localhost:${port}/interop/`
+ await page().goto(baseUrl)
+ await page().waitForSelector('#app')
+ })
+
test(
'should work',
async () => {
- const baseUrl = `http://localhost:${port}/interop/`
- await page().goto(baseUrl)
-
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
expect(await text('.vapor-prop')).toContain('hello')
@@ -81,4 +97,163 @@ describe('vdom / vapor interop', () => {
},
E2E_TIMEOUT,
)
+
+ describe('vdom transition', () => {
+ test(
+ 'render vapor component',
+ async () => {
+ const btnSelector = '.trans-vapor > button'
+ const containerSelector = '.trans-vapor > div'
+
+ expect(await html(containerSelector)).toBe(`vapor compA
`)
+
+ // comp leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vapor compA
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(``)
+
+ // comp enter
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(`vapor compA
`)
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+
+ test(
+ 'switch between vdom/vapor component (out-in mode)',
+ async () => {
+ const btnSelector = '.trans-vdom-vapor-out-in > button'
+ const containerSelector = '.trans-vdom-vapor-out-in > div'
+ const childSelector = `${containerSelector} > div`
+
+ expect(await html(containerSelector)).toBe(`vdom comp
`)
+
+ // switch to vapor comp
+ // vdom comp leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vdom comp
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ // vapor comp enter
+ await waitForElement(childSelector, 'vapor compA', [
+ 'fade-enter-from',
+ 'fade-enter-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ // switch to vdom comp
+ // vapor comp leave
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `vapor compA
`,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vapor compA
`,
+ )
+
+ // vdom comp enter
+ await waitForElement(childSelector, 'vdom comp', [
+ 'fade-enter-from',
+ 'fade-enter-active',
+ ])
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `vdom comp
`,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+ })
+
+ describe('vdom transition-group', () => {
+ test(
+ 'render vapor component',
+ async () => {
+ const btnSelector = '.trans-group-vapor > button'
+ const containerSelector = '.trans-group-vapor > div'
+
+ expect(await html(containerSelector)).toBe(
+ `` +
+ `` +
+ ``,
+ )
+
+ // insert
+ expect(
+ (await transitionStart(btnSelector, containerSelector)).innerHTML,
+ ).toBe(
+ `` +
+ `` +
+ `` +
+ `` +
+ ``,
+ )
+
+ await nextFrame()
+ expect(await html(containerSelector)).toBe(
+ `` +
+ `` +
+ `` +
+ `` +
+ ``,
+ )
+
+ await transitionFinish()
+ expect(await html(containerSelector)).toBe(
+ `` +
+ `` +
+ `` +
+ `` +
+ ``,
+ )
+ },
+ E2E_TIMEOUT,
+ )
+ })
})
diff --git a/packages-private/vapor-e2e-test/index.html b/packages-private/vapor-e2e-test/index.html
index 7dc205e5ab0..09ea6aa607a 100644
--- a/packages-private/vapor-e2e-test/index.html
+++ b/packages-private/vapor-e2e-test/index.html
@@ -1,2 +1,11 @@
VDOM / Vapor interop
Vapor TodoMVC
+Vapor Transition
+Vapor TransitionGroup
+
+
diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue
index 772a6989dd7..8cf42e47549 100644
--- a/packages-private/vapor-e2e-test/interop/App.vue
+++ b/packages-private/vapor-e2e-test/interop/App.vue
@@ -1,9 +1,22 @@
@@ -19,4 +32,41 @@ const passSlot = ref(true)
A test slot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/interop/main.ts b/packages-private/vapor-e2e-test/interop/main.ts
index d5d6d7dcf8c..41155dc5cb2 100644
--- a/packages-private/vapor-e2e-test/interop/main.ts
+++ b/packages-private/vapor-e2e-test/interop/main.ts
@@ -1,4 +1,5 @@
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
+import '../transition/style.css'
createApp(App).use(vaporInteropPlugin).mount('#app')
diff --git a/packages-private/vapor-e2e-test/transition-group/App.vue b/packages-private/vapor-e2e-test/transition-group/App.vue
new file mode 100644
index 00000000000..55775743c56
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/App.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
calls.push('beforeEnter')"
+ @enter="() => calls.push('onEnter')"
+ @afterEnter="() => calls.push('afterEnter')"
+ @beforeLeave="() => calls.push('beforeLeave')"
+ @leave="() => calls.push('onLeave')"
+ @afterLeave="() => calls.push('afterLeave')"
+ @beforeAppear="() => calls.push('beforeAppear')"
+ @appear="() => calls.push('onAppear')"
+ @afterAppear="() => calls.push('afterAppear')"
+ >
+ {{ item }}
+
+
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/transition-group/components/VaporComp.vue b/packages-private/vapor-e2e-test/transition-group/components/VaporComp.vue
new file mode 100644
index 00000000000..906795d22f2
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/components/VaporComp.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue b/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue
new file mode 100644
index 00000000000..afd7d55f2be
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/components/VdomComp.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/transition-group/index.html b/packages-private/vapor-e2e-test/transition-group/index.html
new file mode 100644
index 00000000000..79052a023ba
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/packages-private/vapor-e2e-test/transition-group/main.ts b/packages-private/vapor-e2e-test/transition-group/main.ts
new file mode 100644
index 00000000000..efa06a296cc
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition-group/main.ts
@@ -0,0 +1,5 @@
+import { createVaporApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+import '../../../packages/vue/__tests__/e2e/style.css'
+
+createVaporApp(App).use(vaporInteropPlugin).mount('#app')
diff --git a/packages-private/vapor-e2e-test/transition/App.vue b/packages-private/vapor-e2e-test/transition/App.vue
new file mode 100644
index 00000000000..4855098243b
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/App.vue
@@ -0,0 +1,528 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
calls.withoutAppear.push('beforeEnter')"
+ @enter="() => calls.withoutAppear.push('onEnter')"
+ @after-enter="() => calls.withoutAppear.push('afterEnter')"
+ @beforeLeave="() => calls.withoutAppear.push('beforeLeave')"
+ @leave="() => calls.withoutAppear.push('onLeave')"
+ @afterLeave="() => calls.withoutAppear.push('afterLeave')"
+ >
+ content
+
+
+
+
+
+
+
{
+ calls.withArgs.push('beforeEnter')
+ el.classList.add('before-enter')
+ }
+ "
+ @enter="
+ (el, done) => {
+ calls.withArgs.push('onEnter')
+ el.classList.add('enter')
+ timeout(done, 200)
+ }
+ "
+ @after-enter="
+ el => {
+ calls.withArgs.push('afterEnter')
+ el.classList.add('after-enter')
+ }
+ "
+ @before-leave="
+ el => {
+ calls.withArgs.push('beforeLeave')
+ el.classList.add('before-leave')
+ }
+ "
+ @leave="
+ (el, done) => {
+ calls.withArgs.push('onLeave')
+ el.classList.add('leave')
+ timeout(done, 200)
+ }
+ "
+ @after-leave="
+ () => {
+ calls.withArgs.push('afterLeave')
+ }
+ "
+ >
+ content
+
+
+
+
+
+
+
{
+ calls.enterCancel.push('enterCancelled')
+ }
+ "
+ >
+ content
+
+
+
+
+
+
+
+
calls.withAppear.push('beforeEnter')"
+ @enter="() => calls.withAppear.push('onEnter')"
+ @afterEnter="() => calls.withAppear.push('afterEnter')"
+ @beforeLeave="() => calls.withAppear.push('beforeLeave')"
+ @leave="() => calls.withAppear.push('onLeave')"
+ @afterLeave="() => calls.withAppear.push('afterLeave')"
+ @beforeAppear="() => calls.withAppear.push('beforeAppear')"
+ @appear="() => calls.withAppear.push('onAppear')"
+ @afterAppear="() => calls.withAppear.push('afterAppear')"
+ >
+ content
+
+
+
+
+
+
+
calls.cssFalse.push('beforeEnter')"
+ @enter="() => calls.cssFalse.push('onEnter')"
+ @afterEnter="() => calls.cssFalse.push('afterEnter')"
+ @beforeLeave="() => calls.cssFalse.push('beforeLeave')"
+ @leave="() => calls.cssFalse.push('onLeave')"
+ @afterLeave="() => calls.cssFalse.push('afterLeave')"
+ >
+ content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ calls.ifInOut.push('beforeEnter')"
+ @enter="() => calls.ifInOut.push('onEnter')"
+ @afterEnter="() => calls.ifInOut.push('afterEnter')"
+ @beforeLeave="() => calls.ifInOut.push('beforeLeave')"
+ @leave="() => calls.ifInOut.push('onLeave')"
+ @afterLeave="() => calls.ifInOut.push('afterLeave')"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
calls.show.push('beforeEnter')"
+ @enter="() => calls.show.push('onEnter')"
+ @afterEnter="() => calls.show.push('afterEnter')"
+ @beforeLeave="() => calls.show.push('beforeLeave')"
+ @leave="() => calls.show.push('onLeave')"
+ @afterLeave="() => calls.show.push('afterLeave')"
+ >
+ content
+
+
+
+
+
+
+
calls.showLeaveCancel.push('leaveCancelled')"
+ >
+ content
+
+
+
+
+
+
+
calls.showAppear.push('beforeEnter')"
+ @enter="() => calls.showAppear.push('onEnter')"
+ @afterEnter="() => calls.showAppear.push('afterEnter')"
+ >
+ content
+
+
+
+
+
+
+
calls.notEnter.push('beforeEnter')"
+ @enter="() => calls.notEnter.push('onEnter')"
+ @afterEnter="() => calls.notEnter.push('afterEnter')"
+ >
+ content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ count }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VaporCompA.vue b/packages-private/vapor-e2e-test/transition/components/VaporCompA.vue
new file mode 100644
index 00000000000..f6902d8cf78
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VaporCompA.vue
@@ -0,0 +1,6 @@
+
+
+ {{ msg }}
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VaporCompB.vue b/packages-private/vapor-e2e-test/transition/components/VaporCompB.vue
new file mode 100644
index 00000000000..db90f993f12
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VaporCompB.vue
@@ -0,0 +1,6 @@
+
+
+ {{ msg }}
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue b/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue
new file mode 100644
index 00000000000..f5eff0100f8
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VaporSlot.vue
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/transition/components/VdomComp.vue b/packages-private/vapor-e2e-test/transition/components/VdomComp.vue
new file mode 100644
index 00000000000..cb6ec7ccad1
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/components/VdomComp.vue
@@ -0,0 +1,6 @@
+
+
+ {{ msg }}
+
diff --git a/packages-private/vapor-e2e-test/transition/index.html b/packages-private/vapor-e2e-test/transition/index.html
new file mode 100644
index 00000000000..79052a023ba
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/packages-private/vapor-e2e-test/transition/main.ts b/packages-private/vapor-e2e-test/transition/main.ts
new file mode 100644
index 00000000000..e77d51d1c03
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/main.ts
@@ -0,0 +1,6 @@
+import { createVaporApp, vaporInteropPlugin } from 'vue'
+import App from './App.vue'
+import '../../../packages/vue/__tests__/e2e/style.css'
+import './style.css'
+
+createVaporApp(App).use(vaporInteropPlugin).mount('#app')
diff --git a/packages-private/vapor-e2e-test/transition/style.css b/packages-private/vapor-e2e-test/transition/style.css
new file mode 100644
index 00000000000..e6faf6cea53
--- /dev/null
+++ b/packages-private/vapor-e2e-test/transition/style.css
@@ -0,0 +1,35 @@
+.v-enter-active,
+.v-leave-active {
+ transition: opacity 50ms ease;
+}
+
+.v-enter-from,
+.v-leave-to {
+ opacity: 0;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 50ms ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.test-move,
+.test-enter-active,
+.test-leave-active {
+ transition: all 50ms cubic-bezier(0.55, 0, 0.1, 1);
+}
+
+.test-enter-from,
+.test-leave-to {
+ opacity: 0;
+ transform: scaleY(0.01) translate(30px, 0);
+}
+
+.test-leave-active {
+ position: absolute;
+}
diff --git a/packages-private/vapor-e2e-test/vite.config.ts b/packages-private/vapor-e2e-test/vite.config.ts
index 1e29a4dbd13..f50fccea3ce 100644
--- a/packages-private/vapor-e2e-test/vite.config.ts
+++ b/packages-private/vapor-e2e-test/vite.config.ts
@@ -14,6 +14,11 @@ export default defineConfig({
input: {
interop: resolve(import.meta.dirname, 'interop/index.html'),
todomvc: resolve(import.meta.dirname, 'todomvc/index.html'),
+ transition: resolve(import.meta.dirname, 'transition/index.html'),
+ transitionGroup: resolve(
+ import.meta.dirname,
+ 'transition-group/index.html',
+ ),
},
},
},
diff --git a/packages/compiler-dom/src/index.ts b/packages/compiler-dom/src/index.ts
index 950901e7bf9..446a917ad7c 100644
--- a/packages/compiler-dom/src/index.ts
+++ b/packages/compiler-dom/src/index.ts
@@ -76,4 +76,5 @@ export {
} from './errors'
export { resolveModifiers } from './transforms/vOn'
export { isValidHTMLNesting } from './htmlNesting'
+export { postTransformTransition } from './transforms/Transition'
export * from '@vue/compiler-core'
diff --git a/packages/compiler-dom/src/transforms/Transition.ts b/packages/compiler-dom/src/transforms/Transition.ts
index f6cf968e372..30ea083d8fc 100644
--- a/packages/compiler-dom/src/transforms/Transition.ts
+++ b/packages/compiler-dom/src/transforms/Transition.ts
@@ -1,4 +1,5 @@
import {
+ type CompilerError,
type ComponentNode,
ElementTypes,
type IfBranchNode,
@@ -15,47 +16,55 @@ export const transformTransition: NodeTransform = (node, context) => {
) {
const component = context.isBuiltInComponent(node.tag)
if (component === TRANSITION) {
- return () => {
- if (!node.children.length) {
- return
- }
+ return postTransformTransition(node, context.onError)
+ }
+ }
+}
- // warn multiple transition children
- if (hasMultipleChildren(node)) {
- context.onError(
- createDOMCompilerError(
- DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN,
- {
- start: node.children[0].loc.start,
- end: node.children[node.children.length - 1].loc.end,
- source: '',
- },
- ),
- )
- }
+export function postTransformTransition(
+ node: ComponentNode,
+ onError: (error: CompilerError) => void,
+ hasMultipleChildren: (
+ node: ComponentNode,
+ ) => boolean = defaultHasMultipleChildren,
+): () => void {
+ return () => {
+ if (!node.children.length) {
+ return
+ }
+
+ if (hasMultipleChildren(node)) {
+ onError(
+ createDOMCompilerError(DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN, {
+ start: node.children[0].loc.start,
+ end: node.children[node.children.length - 1].loc.end,
+ source: '',
+ }),
+ )
+ }
- // check if it's s single child w/ v-show
- // if yes, inject "persisted: true" to the transition props
- const child = node.children[0]
- if (child.type === NodeTypes.ELEMENT) {
- for (const p of child.props) {
- if (p.type === NodeTypes.DIRECTIVE && p.name === 'show') {
- node.props.push({
- type: NodeTypes.ATTRIBUTE,
- name: 'persisted',
- nameLoc: node.loc,
- value: undefined,
- loc: node.loc,
- })
- }
- }
+ // check if it's s single child w/ v-show
+ // if yes, inject "persisted: true" to the transition props
+ const child = node.children[0]
+ if (child.type === NodeTypes.ELEMENT) {
+ for (const p of child.props) {
+ if (p.type === NodeTypes.DIRECTIVE && p.name === 'show') {
+ node.props.push({
+ type: NodeTypes.ATTRIBUTE,
+ name: 'persisted',
+ nameLoc: node.loc,
+ value: undefined,
+ loc: node.loc,
+ })
}
}
}
}
}
-function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean {
+function defaultHasMultipleChildren(
+ node: ComponentNode | IfBranchNode,
+): boolean {
// #1352 filter out potential comment nodes.
const children = (node.children = node.children.filter(
c =>
@@ -66,6 +75,7 @@ function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean {
return (
children.length !== 1 ||
child.type === NodeTypes.FOR ||
- (child.type === NodeTypes.IF && child.branches.some(hasMultipleChildren))
+ (child.type === NodeTypes.IF &&
+ child.branches.some(defaultHasMultipleChildren))
)
}
diff --git a/packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts b/packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts
new file mode 100644
index 00000000000..bcb7b44ce5b
--- /dev/null
+++ b/packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts
@@ -0,0 +1,222 @@
+import { makeCompile } from './_utils'
+import {
+ transformChildren,
+ transformElement,
+ transformText,
+ transformVBind,
+ transformVIf,
+ transformVShow,
+ transformVSlot,
+} from '@vue/compiler-vapor'
+import { transformTransition } from '../../src/transforms/transformTransition'
+import { DOMErrorCodes } from '@vue/compiler-dom'
+
+const compileWithElementTransform = makeCompile({
+ nodeTransforms: [
+ transformText,
+ transformVIf,
+ transformElement,
+ transformVSlot,
+ transformChildren,
+ transformTransition,
+ ],
+ directiveTransforms: {
+ bind: transformVBind,
+ show: transformVShow,
+ },
+})
+
+describe('compiler: transition', () => {
+ test('basic', () => {
+ const { code } = compileWithElementTransform(
+ `foo
`,
+ )
+ expect(code).toMatchSnapshot()
+ })
+
+ test('v-show + appear', () => {
+ const { code } = compileWithElementTransform(
+ `foo
`,
+ )
+ expect(code).toMatchSnapshot()
+ })
+
+ test('work with v-if', () => {
+ const { code } = compileWithElementTransform(
+ `foo
`,
+ )
+
+ expect(code).toMatchSnapshot()
+ // n2 should have a key
+ expect(code).contains('n2.$key = 2')
+ })
+
+ test('work with dynamic keyed children', () => {
+ const { code } = compileWithElementTransform(
+ `
+ foo
+ `,
+ )
+
+ expect(code).toMatchSnapshot()
+ expect(code).contains('_createKeyedFragment(() => _ctx.key')
+ // should preserve key
+ expect(code).contains('n0.$key = _ctx.key')
+ })
+
+ function checkWarning(template: string, shouldWarn = true) {
+ const onError = vi.fn()
+ compileWithElementTransform(template, { onError })
+ if (shouldWarn) {
+ expect(onError).toHaveBeenCalled()
+ expect(onError.mock.calls).toMatchObject([
+ [{ code: DOMErrorCodes.X_TRANSITION_INVALID_CHILDREN }],
+ ])
+ } else {
+ expect(onError).not.toHaveBeenCalled()
+ }
+ }
+
+ test('warns if multiple children', () => {
+ checkWarning(
+ `
+ foo
+ bar
+ `,
+ true,
+ )
+ })
+
+ test('warns with v-for', () => {
+ checkWarning(
+ `
+
+ hey
+
+ `,
+ true,
+ )
+ })
+
+ test('warns with multiple v-if + v-for', () => {
+ checkWarning(
+ `
+
+ hey
+ hey
+
+ `,
+ true,
+ )
+ })
+
+ test('warns with template v-if', () => {
+ checkWarning(
+ `
+
+
+
+ `,
+ true,
+ )
+ })
+
+ test('warns with multiple templates', () => {
+ checkWarning(
+ `
+
+
+
+
+ `,
+ true,
+ )
+ })
+
+ test('warns if multiple children with v-if', () => {
+ checkWarning(
+ `
+
+ hey
+ hey
+
+ `,
+ true,
+ )
+ })
+
+ test('does not warn with regular element', () => {
+ checkWarning(
+ `
+
+ hey
+
+ `,
+ false,
+ )
+ })
+
+ test('does not warn with one single v-if', () => {
+ checkWarning(
+ `
+
+ hey
+
+ `,
+ false,
+ )
+ })
+
+ test('does not warn with v-if v-else-if v-else', () => {
+ checkWarning(
+ `
+
+ hey
+ hey
+ hey
+
+ `,
+ false,
+ )
+ })
+
+ test('does not warn with v-if v-else', () => {
+ checkWarning(
+ `
+
+ hey
+ hey
+
+ `,
+ false,
+ )
+ })
+
+ test('inject persisted when child has v-show', () => {
+ expect(
+ compileWithElementTransform(`
+
+
+
+ `).code,
+ ).toMatchSnapshot()
+ })
+
+ test('the v-if/else-if/else branches in Transition should ignore comments', () => {
+ expect(
+ compileWithElementTransform(`
+
+ hey
+
+ hey
+
+
+
+ `).code,
+ ).toMatchSnapshot()
+ })
+})
diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap
new file mode 100644
index 00000000000..8a83143b9b7
--- /dev/null
+++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/TransformTransition.spec.ts.snap
@@ -0,0 +1,128 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`compiler: transition > basic 1`] = `
+"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+ const n1 = _createComponent(_VaporTransition, { persisted: () => ("") }, {
+ "default": () => {
+ const n0 = t0()
+ _applyVShow(n0, () => (_ctx.show))
+ return n0
+ }
+ }, true)
+ return n1
+}"
+`;
+
+exports[`compiler: transition > inject persisted when child has v-show 1`] = `
+"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("")
+
+export function render(_ctx) {
+ const n1 = _createComponent(_VaporTransition, { persisted: () => ("") }, {
+ "default": () => {
+ const n0 = t0()
+ _applyVShow(n0, () => (_ctx.ok))
+ return n0
+ }
+ }, true)
+ return n1
+}"
+`;
+
+exports[`compiler: transition > the v-if/else-if/else branches in Transition should ignore comments 1`] = `
+"import { VaporTransition as _VaporTransition, setInsertionState as _setInsertionState, createIf as _createIf, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("hey
")
+const t1 = _template("")
+const t2 = _template("")
+
+export function render(_ctx) {
+ const n16 = _createComponent(_VaporTransition, null, {
+ "default": () => {
+ const n0 = _createIf(() => (_ctx.a), () => {
+ const n2 = t0()
+ n2.$key = 2
+ return n2
+ }, () => _createIf(() => (_ctx.b), () => {
+ const n5 = t0()
+ n5.$key = 5
+ return n5
+ }, () => {
+ const n14 = t2()
+ _setInsertionState(n14, 0)
+ const n9 = _createIf(() => (_ctx.c), () => {
+ const n11 = t1()
+ return n11
+ }, () => {
+ const n13 = t1()
+ return n13
+ })
+ n14.$key = 14
+ return n14
+ }))
+ return [n0, n3, n7]
+ }
+ }, true)
+ return n16
+}"
+`;
+
+exports[`compiler: transition > v-show + appear 1`] = `
+"import { VaporTransition as _VaporTransition, applyVShow as _applyVShow, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+ const deferredApplyVShows = []
+ const n1 = _createComponent(_VaporTransition, {
+ appear: () => (""),
+ persisted: () => ("")
+ }, {
+ "default": () => {
+ const n0 = t0()
+ deferredApplyVShows.push(() => _applyVShow(n0, () => (_ctx.show)))
+ return n0
+ }
+ }, true)
+ deferredApplyVShows.forEach(fn => fn())
+ return n1
+}"
+`;
+
+exports[`compiler: transition > work with dynamic keyed children 1`] = `
+"import { VaporTransition as _VaporTransition, createKeyedFragment as _createKeyedFragment, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+ const n1 = _createComponent(_VaporTransition, null, {
+ "default": () => {
+ return _createKeyedFragment(() => _ctx.key, () => {
+ const n0 = t0()
+ n0.$key = _ctx.key
+ return n0
+ })
+ }
+ }, true)
+ return n1
+}"
+`;
+
+exports[`compiler: transition > work with v-if 1`] = `
+"import { VaporTransition as _VaporTransition, createIf as _createIf, createComponent as _createComponent, template as _template } from 'vue';
+const t0 = _template("foo
")
+
+export function render(_ctx) {
+ const n3 = _createComponent(_VaporTransition, null, {
+ "default": () => {
+ const n0 = _createIf(() => (_ctx.show), () => {
+ const n2 = t0()
+ n2.$key = 2
+ return n2
+ })
+ return n0
+ }
+ }, true)
+ return n3
+}"
+`;
diff --git a/packages/compiler-vapor/src/compile.ts b/packages/compiler-vapor/src/compile.ts
index c39037a47d8..8fa2e793321 100644
--- a/packages/compiler-vapor/src/compile.ts
+++ b/packages/compiler-vapor/src/compile.ts
@@ -26,6 +26,7 @@ import { transformVFor } from './transforms/vFor'
import { transformComment } from './transforms/transformComment'
import { transformSlotOutlet } from './transforms/transformSlotOutlet'
import { transformVSlot } from './transforms/vSlot'
+import { transformTransition } from './transforms/transformTransition'
import type { HackOptions } from './ir'
export { wrapTemplate } from './transforms/utils'
@@ -54,6 +55,7 @@ export function compile(
extend({}, resolvedOptions, {
nodeTransforms: [
...nodeTransforms,
+ ...(__DEV__ ? [transformTransition] : []),
...(options.nodeTransforms || []), // user transforms
],
directiveTransforms: extend(
diff --git a/packages/compiler-vapor/src/generators/block.ts b/packages/compiler-vapor/src/generators/block.ts
index 30347394756..d101962ba4f 100644
--- a/packages/compiler-vapor/src/generators/block.ts
+++ b/packages/compiler-vapor/src/generators/block.ts
@@ -13,6 +13,7 @@ import type { CodegenContext } from '../generate'
import { genEffects, genOperations } from './operation'
import { genChildren, genSelf } from './template'
import { toValidAssetId } from '@vue/compiler-dom'
+import { genExpression } from './expression'
export function genBlock(
oper: BlockIRNode,
@@ -39,9 +40,13 @@ export function genBlockContent(
genEffectsExtraFrag?: () => CodeFragment[],
): CodeFragment[] {
const [frag, push] = buildCodeFragment()
- const { dynamic, effect, operation, returns } = block
+ const { dynamic, effect, operation, returns, key } = block
const resetBlock = context.enterBlock(block)
+ if (block.hasDeferredVShow) {
+ push(NEWLINE, `const deferredApplyVShows = []`)
+ }
+
if (root) {
for (let name of context.ir.component) {
const id = toValidAssetId(name, 'component')
@@ -71,6 +76,19 @@ export function genBlockContent(
push(...genOperations(operation, context))
push(...genEffects(effect, context, genEffectsExtraFrag))
+ if (block.hasDeferredVShow) {
+ push(NEWLINE, `deferredApplyVShows.forEach(fn => fn())`)
+ }
+
+ if (dynamic.needsKey) {
+ for (const child of dynamic.children) {
+ const keyValue = key
+ ? genExpression(key, context)
+ : JSON.stringify(child.id)
+ push(NEWLINE, `n${child.id}.$key = `, ...keyValue)
+ }
+ }
+
push(NEWLINE, `return `)
const returnNodes = returns.map(n => `n${n}`)
diff --git a/packages/compiler-vapor/src/generators/component.ts b/packages/compiler-vapor/src/generators/component.ts
index 10705a2c795..70fc0c10a63 100644
--- a/packages/compiler-vapor/src/generators/component.ts
+++ b/packages/compiler-vapor/src/generators/component.ts
@@ -40,6 +40,8 @@ import { genDirectiveModifiers, genDirectivesForElement } from './directive'
import { genBlock } from './block'
import { genModelHandler } from './vModel'
+import { isBuiltInComponent } from '../utils'
+
export function genCreateComponent(
operation: CreateComponentIRNode,
context: CodegenContext,
@@ -53,13 +55,12 @@ export function genCreateComponent(
const rawProps = context.withId(() => genRawProps(props, context), ids)
const inlineHandlers: CodeFragment[] = handlers.reduce(
- (acc, { name, value }) => {
+ (acc, { name, value }: InlineHandler) => {
const handler = genEventHandler(context, value, undefined, false)
return [...acc, `const ${name} = `, ...handler, NEWLINE]
},
[],
)
-
return [
NEWLINE,
...inlineHandlers,
@@ -92,8 +93,15 @@ export function genCreateComponent(
} else if (operation.asset) {
return toValidAssetId(operation.tag, 'component')
} else {
+ const { tag } = operation
+ const builtInTag = isBuiltInComponent(tag)
+ if (builtInTag) {
+ // @ts-expect-error
+ helper(builtInTag)
+ return `_${builtInTag}`
+ }
return genExpression(
- extend(createSimpleExpression(operation.tag, false), { ast: null }),
+ extend(createSimpleExpression(tag, false), { ast: null }),
context,
)
}
@@ -127,7 +135,10 @@ function processInlineHandlers(
const isMemberExp = isMemberExpression(value, context.options)
// cache inline handlers (fn expression or inline statement)
if (!isMemberExp) {
- const name = getUniqueHandlerName(context, `_on_${prop.key.content}`)
+ const name = getUniqueHandlerName(
+ context,
+ `_on_${prop.key.content.replace(/-/g, '_')}`,
+ )
handlers.push({ name, value })
ids[name] = null
// replace the original prop value with the handler name
@@ -396,7 +407,7 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
let propsName: string | undefined
let exitScope: (() => void) | undefined
let depth: number | undefined
- const { props } = oper
+ const { props, key } = oper
const idsOfProps = new Set()
if (props) {
@@ -424,11 +435,28 @@ function genSlotBlockWithProps(oper: SlotBlockIRNode, context: CodegenContext) {
? `${propsName}[${JSON.stringify(id)}]`
: null),
)
- const blockFn = context.withId(
+ let blockFn = context.withId(
() => genBlock(oper, context, [propsName]),
idMap,
)
exitScope && exitScope()
+ if (key) {
+ blockFn = [
+ `() => {`,
+ INDENT_START,
+ NEWLINE,
+ `return `,
+ ...genCall(
+ context.helper('createKeyedFragment'),
+ [`() => `, ...genExpression(key, context)],
+ blockFn,
+ ),
+ INDENT_END,
+ NEWLINE,
+ `}`,
+ ]
+ }
+
return blockFn
}
diff --git a/packages/compiler-vapor/src/generators/vShow.ts b/packages/compiler-vapor/src/generators/vShow.ts
index 9a6ccefcded..5ff6b257dc7 100644
--- a/packages/compiler-vapor/src/generators/vShow.ts
+++ b/packages/compiler-vapor/src/generators/vShow.ts
@@ -7,12 +7,15 @@ export function genVShow(
oper: DirectiveIRNode,
context: CodegenContext,
): CodeFragment[] {
+ const { deferred, element } = oper
return [
NEWLINE,
- ...genCall(context.helper('applyVShow'), `n${oper.element}`, [
+ deferred ? `deferredApplyVShows.push(() => ` : undefined,
+ ...genCall(context.helper('applyVShow'), `n${element}`, [
`() => (`,
...genExpression(oper.dir.exp!, context),
`)`,
]),
+ deferred ? `)` : undefined,
]
}
diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts
index 18f0139ab56..a8130be3890 100644
--- a/packages/compiler-vapor/src/ir/index.ts
+++ b/packages/compiler-vapor/src/ir/index.ts
@@ -39,6 +39,7 @@ export enum IRNodeTypes {
export interface BaseIRNode {
type: IRNodeTypes
+ key?: SimpleExpressionNode | undefined
}
export type CoreHelper = keyof typeof import('packages/runtime-dom/src')
@@ -53,6 +54,7 @@ export interface BlockIRNode extends BaseIRNode {
effect: IREffect[]
operation: OperationNode[]
returns: number[]
+ hasDeferredVShow: boolean
}
export interface RootIRNode {
@@ -181,6 +183,7 @@ export interface DirectiveIRNode extends BaseIRNode {
builtin?: boolean
asset?: boolean
modelType?: 'text' | 'dynamic' | 'radio' | 'checkbox' | 'select'
+ deferred?: boolean
}
export interface CreateComponentIRNode extends BaseIRNode {
@@ -259,6 +262,7 @@ export interface IRDynamicInfo {
children: IRDynamicInfo[]
template?: number
hasDynamicChild?: boolean
+ needsKey?: boolean
operation?: OperationNode
}
diff --git a/packages/compiler-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts
index 05153e729af..dcabe360938 100644
--- a/packages/compiler-vapor/src/transforms/transformElement.ts
+++ b/packages/compiler-vapor/src/transforms/transformElement.ts
@@ -1,4 +1,3 @@
-import { isValidHTMLNesting } from '@vue/compiler-dom'
import {
type AttributeNode,
type ComponentNode,
@@ -11,6 +10,7 @@ import {
createCompilerError,
createSimpleExpression,
isStaticArgOf,
+ isValidHTMLNesting,
} from '@vue/compiler-dom'
import {
camelize,
@@ -36,7 +36,7 @@ import {
type VaporDirectiveNode,
} from '../ir'
import { EMPTY_EXPRESSION } from './utils'
-import { findProp } from '../utils'
+import { findProp, isBuiltInComponent } from '../utils'
export const isReservedProp: (key: string) => boolean = /*#__PURE__*/ makeMap(
// the leading comma is intentional so empty string "" is also included
@@ -122,6 +122,12 @@ function transformComponentElement(
asset = false
}
+ const builtInTag = isBuiltInComponent(tag)
+ if (builtInTag) {
+ tag = builtInTag
+ asset = false
+ }
+
const dotIndex = tag.indexOf('.')
if (dotIndex > 0) {
const ns = resolveSetupReference(tag.slice(0, dotIndex), context)
@@ -437,7 +443,9 @@ function dedupeProperties(results: DirectiveTransformResult[]): IRProp[] {
}
const name = prop.key.content
const existing = knownProps.get(name)
- if (existing) {
+ // prop names and event handler names can be the same but serve different purposes
+ // e.g. `:appear="true"` is a prop while `@appear="handler"` is an event handler
+ if (existing && existing.handler === prop.handler) {
if (name === 'style' || name === 'class') {
mergePropValues(existing, prop)
}
diff --git a/packages/compiler-vapor/src/transforms/transformTransition.ts b/packages/compiler-vapor/src/transforms/transformTransition.ts
new file mode 100644
index 00000000000..60142350831
--- /dev/null
+++ b/packages/compiler-vapor/src/transforms/transformTransition.ts
@@ -0,0 +1,65 @@
+import type { NodeTransform } from '@vue/compiler-vapor'
+import { findDir, isTransitionTag } from '../utils'
+import {
+ type ElementNode,
+ ElementTypes,
+ NodeTypes,
+ isTemplateNode,
+ postTransformTransition,
+} from '@vue/compiler-dom'
+
+export const transformTransition: NodeTransform = (node, context) => {
+ if (
+ node.type === NodeTypes.ELEMENT &&
+ node.tagType === ElementTypes.COMPONENT
+ ) {
+ if (isTransitionTag(node.tag)) {
+ return postTransformTransition(
+ node,
+ context.options.onError,
+ hasMultipleChildren,
+ )
+ }
+ }
+}
+
+function hasMultipleChildren(node: ElementNode): boolean {
+ const children = (node.children = node.children.filter(
+ c =>
+ c.type !== NodeTypes.COMMENT &&
+ !(c.type === NodeTypes.TEXT && !c.content.trim()),
+ ))
+
+ const first = children[0]
+
+ // has v-for
+ if (
+ children.length === 1 &&
+ first.type === NodeTypes.ELEMENT &&
+ (findDir(first, 'for') || isTemplateNode(first))
+ ) {
+ return true
+ }
+
+ const hasElse = (node: ElementNode) =>
+ findDir(node, 'else-if') || findDir(node, 'else', true)
+
+ // has v-if/v-else-if/v-else
+ if (
+ children.every(
+ (c, index) =>
+ c.type === NodeTypes.ELEMENT &&
+ // not template
+ !isTemplateNode(c) &&
+ // not has v-for
+ !findDir(c, 'for') &&
+ // if the first child has v-if, the rest should also have v-else-if/v-else
+ (index === 0 ? findDir(c, 'if') : hasElse(c)) &&
+ !hasMultipleChildren(c),
+ )
+ ) {
+ return false
+ }
+
+ return children.length > 1
+}
diff --git a/packages/compiler-vapor/src/transforms/utils.ts b/packages/compiler-vapor/src/transforms/utils.ts
index f7d0594fe58..b746999a18a 100644
--- a/packages/compiler-vapor/src/transforms/utils.ts
+++ b/packages/compiler-vapor/src/transforms/utils.ts
@@ -30,6 +30,7 @@ export const newBlock = (node: BlockIRNode['node']): BlockIRNode => ({
operation: [],
returns: [],
tempId: 0,
+ hasDeferredVShow: false,
})
export function wrapTemplate(node: ElementNode, dirs: string[]): TemplateNode {
diff --git a/packages/compiler-vapor/src/transforms/vIf.ts b/packages/compiler-vapor/src/transforms/vIf.ts
index bae9f1aa23f..2426fa0215e 100644
--- a/packages/compiler-vapor/src/transforms/vIf.ts
+++ b/packages/compiler-vapor/src/transforms/vIf.ts
@@ -18,7 +18,7 @@ import {
import { extend } from '@vue/shared'
import { newBlock, wrapTemplate } from './utils'
import { getSiblingIf } from './transformComment'
-import { isStaticExpression } from '../utils'
+import { isInTransition, isStaticExpression } from '../utils'
export const transformVIf: NodeTransform = createStructuralDirectiveTransform(
['if', 'else', 'else-if'],
@@ -135,5 +135,8 @@ export function createIfBranch(
const branch: BlockIRNode = newBlock(node)
const exitBlock = context.enterBlock(branch)
context.reference()
+ // generate key for branch result when it's in transition
+ // the key will be used to track node leaving at runtime
+ branch.dynamic.needsKey = isInTransition(context)
return [branch, exitBlock]
}
diff --git a/packages/compiler-vapor/src/transforms/vShow.ts b/packages/compiler-vapor/src/transforms/vShow.ts
index f1135d6b0a5..a60b20a71fa 100644
--- a/packages/compiler-vapor/src/transforms/vShow.ts
+++ b/packages/compiler-vapor/src/transforms/vShow.ts
@@ -2,11 +2,13 @@ import {
DOMErrorCodes,
ElementTypes,
ErrorCodes,
+ NodeTypes,
createCompilerError,
createDOMCompilerError,
} from '@vue/compiler-dom'
import type { DirectiveTransform } from '../transform'
import { IRNodeTypes } from '../ir'
+import { findProp, isTransitionTag } from '../utils'
export const transformVShow: DirectiveTransform = (dir, node, context) => {
const { exp, loc } = dir
@@ -27,11 +29,26 @@ export const transformVShow: DirectiveTransform = (dir, node, context) => {
return
}
+ // lazy apply vshow if the node is inside a transition with appear
+ let shouldDeferred = false
+ const parentNode = context.parent && context.parent.node
+ if (parentNode && parentNode.type === NodeTypes.ELEMENT) {
+ shouldDeferred = !!(
+ isTransitionTag(parentNode.tag) &&
+ findProp(parentNode, 'appear', false, true)
+ )
+
+ if (shouldDeferred) {
+ context.parent!.parent!.block.hasDeferredVShow = true
+ }
+ }
+
context.registerOperation({
type: IRNodeTypes.DIRECTIVE,
element: context.reference(),
dir,
name: 'show',
builtin: true,
+ deferred: shouldDeferred,
})
}
diff --git a/packages/compiler-vapor/src/transforms/vSlot.ts b/packages/compiler-vapor/src/transforms/vSlot.ts
index 3e78913a23e..05aac4aee3c 100644
--- a/packages/compiler-vapor/src/transforms/vSlot.ts
+++ b/packages/compiler-vapor/src/transforms/vSlot.ts
@@ -23,7 +23,12 @@ import {
type SlotBlockIRNode,
type VaporDirectiveNode,
} from '../ir'
-import { findDir, resolveExpression } from '../utils'
+import {
+ findDir,
+ findProp,
+ isTransitionNode,
+ resolveExpression,
+} from '../utils'
import { markNonTemplate } from './transformText'
export const transformVSlot: NodeTransform = (node, context) => {
@@ -83,7 +88,18 @@ function transformComponentSlot(
})
}
- const [block, onExit] = createSlotBlock(node, dir, context)
+ let slotKey
+ if (isTransitionNode(node) && nonSlotTemplateChildren.length) {
+ const keyProp = findProp(
+ nonSlotTemplateChildren[0] as ElementNode,
+ 'key',
+ ) as VaporDirectiveNode
+ if (keyProp) {
+ slotKey = keyProp.exp
+ }
+ }
+
+ const [block, onExit] = createSlotBlock(node, dir, context, slotKey)
const { slots } = context
@@ -244,9 +260,14 @@ function createSlotBlock(
slotNode: ElementNode,
dir: VaporDirectiveNode | undefined,
context: TransformContext,
+ key: SimpleExpressionNode | undefined = undefined,
): [SlotBlockIRNode, () => void] {
const block: SlotBlockIRNode = newBlock(slotNode)
block.props = dir && dir.exp
+ if (key) {
+ block.key = key
+ block.dynamic.needsKey = true
+ }
const exitBlock = context.enterBlock(block)
return [block, exitBlock]
}
diff --git a/packages/compiler-vapor/src/utils.ts b/packages/compiler-vapor/src/utils.ts
index 728281914fd..d2c7eca3bb1 100644
--- a/packages/compiler-vapor/src/utils.ts
+++ b/packages/compiler-vapor/src/utils.ts
@@ -15,6 +15,7 @@ import {
} from '@vue/compiler-dom'
import type { VaporDirectiveNode } from './ir'
import { EMPTY_EXPRESSION } from './transforms/utils'
+import type { TransformContext } from './transform'
export const findProp = _findProp as (
node: ElementNode,
@@ -88,3 +89,36 @@ export function getLiteralExpressionValue(
}
return exp.isStatic ? exp.content : null
}
+
+export function isInTransition(
+ context: TransformContext,
+): boolean {
+ const parentNode = context.parent && context.parent.node
+ return !!(parentNode && isTransitionNode(parentNode as ElementNode))
+}
+
+export function isTransitionNode(node: ElementNode): boolean {
+ return node.type === NodeTypes.ELEMENT && isTransitionTag(node.tag)
+}
+
+export function isTransitionGroupNode(node: ElementNode): boolean {
+ return node.type === NodeTypes.ELEMENT && isTransitionGroupTag(node.tag)
+}
+
+export function isTransitionTag(tag: string): boolean {
+ tag = tag.toLowerCase()
+ return tag === 'transition' || tag === 'vaportransition'
+}
+
+export function isTransitionGroupTag(tag: string): boolean {
+ tag = tag.toLowerCase().replace(/-/g, '')
+ return tag === 'transitiongroup' || tag === 'vaportransitiongroup'
+}
+
+export function isBuiltInComponent(tag: string): string | undefined {
+ if (isTransitionTag(tag)) {
+ return 'VaporTransition'
+ } else if (isTransitionGroupTag(tag)) {
+ return 'VaporTransitionGroup'
+ }
+}
diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts
index a1409a7fe44..4c18a11f493 100644
--- a/packages/runtime-core/src/apiCreateApp.ts
+++ b/packages/runtime-core/src/apiCreateApp.ts
@@ -27,7 +27,7 @@ import { warn } from './warning'
import type { VNode } from './vnode'
import { devtoolsInitApp, devtoolsUnmountApp } from './devtools'
import { NO, extend, hasOwn, isFunction, isObject } from '@vue/shared'
-import { version } from '.'
+import { type TransitionHooks, version } from '.'
import { installAppCompatProperties } from './compat/global'
import type { NormalizedPropsOptions } from './componentProps'
import type { ObjectEmitsOptions } from './componentEmits'
@@ -175,7 +175,6 @@ export interface AppConfig extends GenericAppConfig {
/**
* The vapor in vdom implementation is in runtime-vapor/src/vdomInterop.ts
- * @internal
*/
export interface VaporInteropInterface {
mount(
@@ -188,6 +187,10 @@ export interface VaporInteropInterface {
unmount(vnode: VNode, doRemove?: boolean): void
move(vnode: VNode, container: any, anchor: any): void
slot(n1: VNode | null, n2: VNode, container: any, anchor: any): void
+ setTransitionHooks(
+ component: ComponentInternalInstance,
+ transition: TransitionHooks,
+ ): void
vdomMount: (component: ConcreteComponent, props?: any, slots?: any) => any
vdomUnmount: UnmountComponentFn
diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts
index fbb449ec8cb..dcfb81370a6 100644
--- a/packages/runtime-core/src/components/BaseTransition.ts
+++ b/packages/runtime-core/src/components/BaseTransition.ts
@@ -1,6 +1,8 @@
import {
type ComponentInternalInstance,
type ComponentOptions,
+ type ConcreteComponent,
+ type GenericComponentInstance,
type SetupContext,
getCurrentInstance,
} from '../component'
@@ -19,12 +21,12 @@ import { ErrorCodes, callWithAsyncErrorHandling } from '../errorHandling'
import { PatchFlags, ShapeFlags, isArray, isFunction } from '@vue/shared'
import { onBeforeUnmount, onMounted } from '../apiLifecycle'
import { isTeleport } from './Teleport'
-import type { RendererElement } from '../renderer'
+import { type RendererElement, getVaporInterface } from '../renderer'
import { SchedulerJobFlags } from '../scheduler'
type Hook void> = T | T[]
-const leaveCbKey: unique symbol = Symbol('_leaveCb')
+export const leaveCbKey: unique symbol = Symbol('_leaveCb')
const enterCbKey: unique symbol = Symbol('_enterCb')
export interface BaseTransitionProps {
@@ -87,7 +89,7 @@ export interface TransitionState {
isUnmounting: boolean
// Track pending leave callbacks for children of the same key.
// This is used to force remove leaving a child when a new copy is entering.
- leavingVNodes: Map>
+ leavingNodes: Map>
}
export interface TransitionElement {
@@ -103,7 +105,7 @@ export function useTransitionState(): TransitionState {
isMounted: false,
isLeaving: false,
isUnmounting: false,
- leavingVNodes: new Map(),
+ leavingNodes: new Map(),
}
onMounted(() => {
state.isMounted = true
@@ -138,7 +140,9 @@ export const BaseTransitionPropsValidators: Record = {
}
const recursiveGetSubtree = (instance: ComponentInternalInstance): VNode => {
- const subTree = instance.subTree
+ const subTree = instance.type.__vapor
+ ? (instance as any).block
+ : instance.subTree
return subTree.component ? recursiveGetSubtree(subTree.component) : subTree
}
@@ -164,15 +168,7 @@ const BaseTransitionImpl: ComponentOptions = {
const rawProps = toRaw(props)
const { mode } = rawProps
// check mode
- if (
- __DEV__ &&
- mode &&
- mode !== 'in-out' &&
- mode !== 'out-in' &&
- mode !== 'default'
- ) {
- warn(`invalid mode: ${mode}`)
- }
+ checkTransitionMode(mode)
if (state.isLeaving) {
return emptyPlaceholder(child)
@@ -309,24 +305,83 @@ function getLeavingNodesForType(
state: TransitionState,
vnode: VNode,
): Record {
- const { leavingVNodes } = state
- let leavingVNodesCache = leavingVNodes.get(vnode.type)!
+ const { leavingNodes } = state
+ let leavingVNodesCache = leavingNodes.get(vnode.type)!
if (!leavingVNodesCache) {
leavingVNodesCache = Object.create(null)
- leavingVNodes.set(vnode.type, leavingVNodesCache)
+ leavingNodes.set(vnode.type, leavingVNodesCache)
}
return leavingVNodesCache
}
+export interface TransitionHooksContext {
+ setLeavingNodeCache: (node: any) => void
+ unsetLeavingNodeCache: (node: any) => void
+ earlyRemove: () => void
+ cloneHooks: (node: any) => TransitionHooks
+}
+
// The transition hooks are attached to the vnode as vnode.transition
// and will be called at appropriate timing in the renderer.
export function resolveTransitionHooks(
vnode: VNode,
props: BaseTransitionProps,
state: TransitionState,
- instance: ComponentInternalInstance,
+ instance: GenericComponentInstance,
postClone?: (hooks: TransitionHooks) => void,
+): TransitionHooks {
+ const key = String(vnode.key)
+ const leavingVNodesCache = getLeavingNodesForType(state, vnode)
+ const context: TransitionHooksContext = {
+ setLeavingNodeCache: () => {
+ leavingVNodesCache[key] = vnode
+ },
+ unsetLeavingNodeCache: () => {
+ if (leavingVNodesCache[key] === vnode) {
+ delete leavingVNodesCache[key]
+ }
+ },
+ earlyRemove: () => {
+ const leavingVNode = leavingVNodesCache[key]
+ if (
+ leavingVNode &&
+ isSameVNodeType(vnode, leavingVNode) &&
+ (leavingVNode.el as TransitionElement)[leaveCbKey]
+ ) {
+ // force early removal (not cancelled)
+ ;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
+ }
+ },
+ cloneHooks: vnode => {
+ const hooks = resolveTransitionHooks(
+ vnode,
+ props,
+ state,
+ instance,
+ postClone,
+ )
+ if (postClone) postClone(hooks)
+ return hooks
+ },
+ }
+
+ return baseResolveTransitionHooks(context, props, state, instance)
+}
+
+// shared between vdom and vapor
+export function baseResolveTransitionHooks(
+ context: TransitionHooksContext,
+ props: BaseTransitionProps,
+ state: TransitionState,
+ instance: GenericComponentInstance,
): TransitionHooks {
+ const {
+ setLeavingNodeCache,
+ unsetLeavingNodeCache,
+ earlyRemove,
+ cloneHooks,
+ } = context
+
const {
appear,
mode,
@@ -344,8 +399,6 @@ export function resolveTransitionHooks(
onAfterAppear,
onAppearCancelled,
} = props
- const key = String(vnode.key)
- const leavingVNodesCache = getLeavingNodesForType(state, vnode)
const callHook: TransitionHookCaller = (hook, args) => {
hook &&
@@ -387,15 +440,7 @@ export function resolveTransitionHooks(
el[leaveCbKey](true /* cancelled */)
}
// for toggled element with same key (v-if)
- const leavingVNode = leavingVNodesCache[key]
- if (
- leavingVNode &&
- isSameVNodeType(vnode, leavingVNode) &&
- (leavingVNode.el as TransitionElement)[leaveCbKey]
- ) {
- // force early removal (not cancelled)
- ;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
- }
+ earlyRemove()
callHook(hook, [el])
},
@@ -434,7 +479,7 @@ export function resolveTransitionHooks(
},
leave(el, remove) {
- const key = String(vnode.key)
+ // const key = String(vnode.key)
if (el[enterCbKey]) {
el[enterCbKey](true /* cancelled */)
}
@@ -453,11 +498,9 @@ export function resolveTransitionHooks(
callHook(onAfterLeave, [el])
}
el[leaveCbKey] = undefined
- if (leavingVNodesCache[key] === vnode) {
- delete leavingVNodesCache[key]
- }
+ unsetLeavingNodeCache(el)
})
- leavingVNodesCache[key] = vnode
+ setLeavingNodeCache(el)
if (onLeave) {
callAsyncHook(onLeave, [el, done])
} else {
@@ -465,16 +508,8 @@ export function resolveTransitionHooks(
}
},
- clone(vnode) {
- const hooks = resolveTransitionHooks(
- vnode,
- props,
- state,
- instance,
- postClone,
- )
- if (postClone) postClone(hooks)
- return hooks
+ clone(node) {
+ return cloneHooks(node)
},
}
@@ -524,8 +559,15 @@ function getInnerChild(vnode: VNode): VNode | undefined {
export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks): void {
if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) {
- vnode.transition = hooks
- setTransitionHooks(vnode.component.subTree, hooks)
+ if ((vnode.type as ConcreteComponent).__vapor) {
+ getVaporInterface(vnode.component, vnode).setTransitionHooks(
+ vnode.component,
+ hooks,
+ )
+ } else {
+ vnode.transition = hooks
+ setTransitionHooks(vnode.component.subTree, hooks)
+ }
} else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
vnode.ssContent!.transition = hooks.clone(vnode.ssContent!)
vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
@@ -571,3 +613,18 @@ export function getTransitionRawChildren(
}
return ret
}
+
+/**
+ * dev-only
+ */
+export function checkTransitionMode(mode: string | undefined): void {
+ if (
+ __DEV__ &&
+ mode &&
+ mode !== 'in-out' &&
+ mode !== 'out-in' &&
+ mode !== 'default'
+ ) {
+ warn(`invalid mode: ${mode}`)
+ }
+}
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index 243bde548c5..c3ca9cbdddc 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -118,6 +118,7 @@ export { KeepAlive, type KeepAliveProps } from './components/KeepAlive'
export {
BaseTransition,
BaseTransitionPropsValidators,
+ checkTransitionMode,
type BaseTransitionProps,
} from './components/BaseTransition'
// For using custom directives
@@ -150,8 +151,10 @@ export { registerRuntimeCompiler, isRuntimeOnly } from './component'
export {
useTransitionState,
resolveTransitionHooks,
+ baseResolveTransitionHooks,
setTransitionHooks,
getTransitionRawChildren,
+ leaveCbKey,
} from './components/BaseTransition'
export { initCustomFormatter } from './customFormatter'
@@ -335,6 +338,8 @@ export type { SuspenseBoundary } from './components/Suspense'
export type {
TransitionState,
TransitionHooks,
+ TransitionHooksContext,
+ TransitionElement,
} from './components/BaseTransition'
export type {
AsyncComponentOptions,
@@ -558,6 +563,10 @@ export { startMeasure, endMeasure } from './profiling'
* @internal
*/
export { initFeatureFlags } from './featureFlags'
+/**
+ * @internal
+ */
+export { performTransitionEnter, performTransitionLeave } from './renderer'
/**
* @internal
*/
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index bad40f14393..37e50fb233d 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -738,20 +738,21 @@ function baseCreateRenderer(
}
// #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved
// #1689 For inside suspense + suspense resolved case, just call it
- const needCallTransitionHooks = needTransition(parentSuspense, transition)
- if (needCallTransitionHooks) {
- transition!.beforeEnter(el)
+ if (transition) {
+ performTransitionEnter(
+ el,
+ transition,
+ () => hostInsert(el, container, anchor),
+ parentSuspense,
+ )
+ } else {
+ hostInsert(el, container, anchor)
}
- hostInsert(el, container, anchor)
- if (
- (vnodeHook = props && props.onVnodeMounted) ||
- needCallTransitionHooks ||
- dirs
- ) {
+
+ if ((vnodeHook = props && props.onVnodeMounted) || dirs) {
queuePostRenderEffect(
() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
- needCallTransitionHooks && transition!.enter(el)
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
},
undefined,
@@ -2183,11 +2184,10 @@ function baseCreateRenderer(
transition
if (needTransition) {
if (moveType === MoveType.ENTER) {
- transition!.beforeEnter(el!)
- hostInsert(el!, container, anchor)
- queuePostRenderEffect(
- () => transition!.enter(el!),
- undefined,
+ performTransitionEnter(
+ el!,
+ transition,
+ () => hostInsert(el!, container, anchor),
parentSuspense,
)
} else {
@@ -2376,27 +2376,15 @@ function baseCreateRenderer(
return
}
- const performRemove = () => {
- hostRemove(el!)
- if (transition && !transition.persisted && transition.afterLeave) {
- transition.afterLeave()
- }
- }
-
- if (
- vnode.shapeFlag & ShapeFlags.ELEMENT &&
- transition &&
- !transition.persisted
- ) {
- const { leave, delayLeave } = transition
- const performLeave = () => leave(el!, performRemove)
- if (delayLeave) {
- delayLeave(vnode.el!, performRemove, performLeave)
- } else {
- performLeave()
- }
+ if (transition) {
+ performTransitionLeave(
+ el!,
+ transition,
+ () => hostRemove(el!),
+ !!(vnode.shapeFlag & ShapeFlags.ELEMENT),
+ )
} else {
- performRemove()
+ hostRemove(el!)
}
}
@@ -2523,7 +2511,7 @@ function baseCreateRenderer(
const getNextHostNode: NextFn = vnode => {
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
if ((vnode.type as ConcreteComponent).__vapor) {
- return hostNextSibling((vnode.component! as any).block)
+ return hostNextSibling(vnode.anchor!)
}
return getNextHostNode(vnode.component!.subTree)
}
@@ -2727,7 +2715,7 @@ export function traverseStaticChildren(
function locateNonHydratedAsyncRoot(
instance: ComponentInternalInstance,
): ComponentInternalInstance | undefined {
- const subComponent = instance.subTree.component
+ const subComponent = instance.subTree && instance.subTree.component
if (subComponent) {
if (subComponent.asyncDep && !subComponent.asyncResolved) {
return subComponent
@@ -2744,7 +2732,50 @@ export function invalidateMount(hooks: LifecycleHook | undefined): void {
}
}
-function getVaporInterface(
+// shared between vdom and vapor
+export function performTransitionEnter(
+ el: RendererElement,
+ transition: TransitionHooks,
+ insert: () => void,
+ parentSuspense: SuspenseBoundary | null,
+): void {
+ if (needTransition(parentSuspense, transition)) {
+ transition.beforeEnter(el)
+ insert()
+ queuePostRenderEffect(() => transition.enter(el), undefined, parentSuspense)
+ } else {
+ insert()
+ }
+}
+
+// shared between vdom and vapor
+export function performTransitionLeave(
+ el: RendererElement,
+ transition: TransitionHooks,
+ remove: () => void,
+ isElement: boolean = true,
+): void {
+ const performRemove = () => {
+ remove()
+ if (transition && !transition.persisted && transition.afterLeave) {
+ transition.afterLeave()
+ }
+ }
+
+ if (isElement && transition && !transition.persisted) {
+ const { leave, delayLeave } = transition
+ const performLeave = () => leave(el, performRemove)
+ if (delayLeave) {
+ delayLeave(el, performRemove, performLeave)
+ } else {
+ performLeave()
+ }
+ } else {
+ performRemove()
+ }
+}
+
+export function getVaporInterface(
instance: ComponentInternalInstance | null,
vnode: VNode,
): VaporInteropInterface {
diff --git a/packages/runtime-dom/src/components/TransitionGroup.ts b/packages/runtime-dom/src/components/TransitionGroup.ts
index 72af535d385..abf3e095424 100644
--- a/packages/runtime-dom/src/components/TransitionGroup.ts
+++ b/packages/runtime-dom/src/components/TransitionGroup.ts
@@ -32,7 +32,7 @@ import { extend } from '@vue/shared'
const positionMap = new WeakMap()
const newPositionMap = new WeakMap()
-const moveCbKey = Symbol('_moveCb')
+export const moveCbKey: symbol = Symbol('_moveCb')
const enterCbKey = Symbol('_enterCb')
export type TransitionGroupProps = Omit & {
@@ -88,7 +88,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
// we divide the work into three loops to avoid mixing DOM reads and writes
// in each iteration - which helps prevent layout thrashing.
- prevChildren.forEach(callPendingCbs)
+ prevChildren.forEach(vnode => callPendingCbs(vnode.el))
prevChildren.forEach(recordPosition)
const movedChildren = prevChildren.filter(applyTranslation)
@@ -97,20 +97,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
movedChildren.forEach(c => {
const el = c.el as ElementWithTransition
- const style = el.style
- addTransitionClass(el, moveClass)
- style.transform = style.webkitTransform = style.transitionDuration = ''
- const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
- if (e && e.target !== el) {
- return
- }
- if (!e || /transform$/.test(e.propertyName)) {
- el.removeEventListener('transitionend', cb)
- ;(el as any)[moveCbKey] = null
- removeTransitionClass(el, moveClass)
- }
- })
- el.addEventListener('transitionend', cb)
+ handleMovedChildren(el, moveClass)
})
prevChildren = []
})
@@ -179,8 +166,7 @@ export const TransitionGroup = TransitionGroupImpl as unknown as {
}
}
-function callPendingCbs(c: VNode) {
- const el = c.el as any
+export function callPendingCbs(el: any): void {
if (el[moveCbKey]) {
el[moveCbKey]()
}
@@ -194,19 +180,36 @@ function recordPosition(c: VNode) {
}
function applyTranslation(c: VNode): VNode | undefined {
- const oldPos = positionMap.get(c)!
- const newPos = newPositionMap.get(c)!
+ if (
+ baseApplyTranslation(
+ positionMap.get(c)!,
+ newPositionMap.get(c)!,
+ c.el as ElementWithTransition,
+ )
+ ) {
+ return c
+ }
+}
+
+// shared between vdom and vapor
+export function baseApplyTranslation(
+ oldPos: DOMRect,
+ newPos: DOMRect,
+ el: ElementWithTransition,
+): boolean {
const dx = oldPos.left - newPos.left
const dy = oldPos.top - newPos.top
if (dx || dy) {
- const s = (c.el as HTMLElement).style
+ const s = (el as HTMLElement).style
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
- return c
+ return true
}
+ return false
}
-function hasCSSTransform(
+// shared between vdom and vapor
+export function hasCSSTransform(
el: ElementWithTransition,
root: Node,
moveClass: string,
@@ -233,3 +236,24 @@ function hasCSSTransform(
container.removeChild(clone)
return hasTransform
}
+
+// shared between vdom and vapor
+export const handleMovedChildren = (
+ el: ElementWithTransition,
+ moveClass: string,
+): void => {
+ const style = el.style
+ addTransitionClass(el, moveClass)
+ style.transform = style.webkitTransform = style.transitionDuration = ''
+ const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
+ if (e && e.target !== el) {
+ return
+ }
+ if (!e || /transform$/.test(e.propertyName)) {
+ el.removeEventListener('transitionend', cb)
+ ;(el as any)[moveCbKey] = null
+ removeTransitionClass(el, moveClass)
+ }
+ })
+ el.addEventListener('transitionend', cb)
+}
diff --git a/packages/runtime-dom/src/index.ts b/packages/runtime-dom/src/index.ts
index b241458dba7..9f17fa9ecf5 100644
--- a/packages/runtime-dom/src/index.ts
+++ b/packages/runtime-dom/src/index.ts
@@ -348,3 +348,24 @@ export {
vModelSelectInit,
vModelSetSelected,
} from './directives/vModel'
+/**
+ * @internal
+ */
+export {
+ resolveTransitionProps,
+ TransitionPropsValidators,
+ forceReflow,
+ addTransitionClass,
+ removeTransitionClass,
+ type ElementWithTransition,
+} from './components/Transition'
+/**
+ * @internal
+ */
+export {
+ hasCSSTransform,
+ callPendingCbs,
+ moveCbKey,
+ handleMovedChildren,
+ baseApplyTranslation,
+} from './components/TransitionGroup'
diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts
index 945e0f38d87..8a127c2daf1 100644
--- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts
+++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts
@@ -1,6 +1,6 @@
-import { resolveDynamicComponent } from '@vue/runtime-dom'
+import { currentInstance, resolveDynamicComponent } from '@vue/runtime-dom'
import { DynamicFragment, type VaporFragment, insert } from './block'
-import { createComponentWithFallback } from './component'
+import { createComponentWithFallback, emptyContext } from './component'
import { renderEffect } from './renderEffect'
import type { RawProps } from './componentProps'
import type { RawSlots } from './componentSlots'
@@ -31,6 +31,8 @@ export function createDynamicComponent(
renderEffect(() => {
const value = getter()
+ const appContext =
+ (currentInstance && currentInstance.appContext) || emptyContext
frag.update(
() =>
createComponentWithFallback(
@@ -38,6 +40,7 @@ export function createDynamicComponent(
rawProps,
rawSlots,
isSingleRoot,
+ appContext,
),
value,
)
diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts
index 426a5c56b5b..8b03a59bfc5 100644
--- a/packages/runtime-vapor/src/apiCreateFor.ts
+++ b/packages/runtime-vapor/src/apiCreateFor.ts
@@ -24,6 +24,7 @@ import { currentInstance, isVaporComponent } from './component'
import type { DynamicSlot } from './componentSlots'
import { renderEffect } from './renderEffect'
import { VaporVForFlags } from '../../shared/src/vaporFlags'
+import { applyTransitionHooks } from './components/Transition'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import {
insertionAnchor,
@@ -366,6 +367,11 @@ export const createFor = (
key2,
))
+ // apply transition for new nodes
+ if (frag.$transition) {
+ applyTransitionHooks(block.nodes, frag.$transition, false)
+ }
+
if (parent) insert(block.nodes, parent, anchor)
return block
@@ -558,3 +564,7 @@ export function getRestElement(val: any, keys: string[]): any {
export function getDefaultValue(val: any, defaultVal: any): any {
return val === undefined ? defaultVal : val
}
+
+export function isForBlock(block: Block): block is ForBlock {
+ return block instanceof ForBlock
+}
diff --git a/packages/runtime-vapor/src/apiCreateFragment.ts b/packages/runtime-vapor/src/apiCreateFragment.ts
new file mode 100644
index 00000000000..50179b89ef9
--- /dev/null
+++ b/packages/runtime-vapor/src/apiCreateFragment.ts
@@ -0,0 +1,10 @@
+import { type Block, type BlockFn, DynamicFragment } from './block'
+import { renderEffect } from './renderEffect'
+
+export function createKeyedFragment(key: () => any, render: BlockFn): Block {
+ const frag = __DEV__ ? new DynamicFragment('keyed') : new DynamicFragment()
+ renderEffect(() => {
+ frag.update(render, key())
+ })
+ return frag
+}
diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts
index e021ce84b05..09a7ed2e70c 100644
--- a/packages/runtime-vapor/src/block.ts
+++ b/packages/runtime-vapor/src/block.ts
@@ -8,21 +8,52 @@ import {
import { createComment, createTextNode } from './dom/node'
import { EffectScope, setActiveSub } from '@vue/reactivity'
import { isHydrating } from './dom/hydration'
+import {
+ type TransitionHooks,
+ type TransitionProps,
+ type TransitionState,
+ performTransitionEnter,
+ performTransitionLeave,
+} from '@vue/runtime-dom'
+import {
+ applyTransitionHooks,
+ applyTransitionLeaveHooks,
+} from './components/Transition'
+
+export interface TransitionOptions {
+ $key?: any
+ $transition?: VaporTransitionHooks
+}
+
+export interface VaporTransitionHooks extends TransitionHooks {
+ state: TransitionState
+ props: TransitionProps
+ instance: VaporComponentInstance
+ // mark transition hooks as disabled so that it skips during
+ // inserting
+ disabled?: boolean
+}
+
+export type TransitionBlock =
+ | (Node & TransitionOptions)
+ | (VaporFragment & TransitionOptions)
+ | (DynamicFragment & TransitionOptions)
-export type Block =
- | Node
- | VaporFragment
- | DynamicFragment
- | VaporComponentInstance
- | Block[]
+export type Block = TransitionBlock | VaporComponentInstance | Block[]
export type BlockFn = (...args: any[]) => Block
-export class VaporFragment {
+export class VaporFragment implements TransitionOptions {
+ $key?: any
+ $transition?: VaporTransitionHooks | undefined
nodes: Block
anchor?: Node
- insert?: (parent: ParentNode, anchor: Node | null) => void
- remove?: (parent?: ParentNode) => void
+ insert?: (
+ parent: ParentNode,
+ anchor: Node | null,
+ transitionHooks?: TransitionHooks,
+ ) => void
+ remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void
constructor(nodes: Block) {
this.nodes = nodes
@@ -49,21 +80,38 @@ export class DynamicFragment extends VaporFragment {
const prevSub = setActiveSub()
const parent = this.anchor.parentNode
+ const transition = this.$transition
+ const renderBranch = () => {
+ if (render) {
+ this.scope = new EffectScope()
+ this.nodes = this.scope.run(render) || []
+ if (transition) {
+ this.$transition = applyTransitionHooks(this.nodes, transition)
+ }
+ if (parent) insert(this.nodes, parent, this.anchor)
+ } else {
+ this.scope = undefined
+ this.nodes = []
+ }
+ }
// teardown previous branch
if (this.scope) {
this.scope.stop()
- parent && remove(this.nodes, parent)
+ const mode = transition && transition.mode
+ if (mode) {
+ applyTransitionLeaveHooks(this.nodes, transition, renderBranch)
+ parent && remove(this.nodes, parent)
+ if (mode === 'out-in') {
+ setActiveSub(prevSub)
+ return
+ }
+ } else {
+ parent && remove(this.nodes, parent)
+ }
}
- if (render) {
- this.scope = new EffectScope()
- this.nodes = this.scope.run(render) || []
- if (parent) insert(this.nodes, parent, this.anchor)
- } else {
- this.scope = undefined
- this.nodes = []
- }
+ renderBranch()
if (this.fallback && !isValidBlock(this.nodes)) {
parent && remove(this.nodes, parent)
@@ -107,11 +155,26 @@ export function insert(
block: Block,
parent: ParentNode,
anchor: Node | null | 0 = null, // 0 means prepend
+ parentSuspense?: any, // TODO Suspense
): void {
anchor = anchor === 0 ? parent.firstChild : anchor
if (block instanceof Node) {
if (!isHydrating) {
- parent.insertBefore(block, anchor)
+ // only apply transition on Element nodes
+ if (
+ block instanceof Element &&
+ (block as TransitionBlock).$transition &&
+ !(block as TransitionBlock).$transition!.disabled
+ ) {
+ performTransitionEnter(
+ block,
+ (block as TransitionBlock).$transition as TransitionHooks,
+ () => parent.insertBefore(block, anchor),
+ parentSuspense,
+ )
+ } else {
+ parent.insertBefore(block, anchor)
+ }
}
} else if (isVaporComponent(block)) {
if (block.isMounted) {
@@ -127,9 +190,9 @@ export function insert(
// fragment
if (block.insert) {
// TODO handle hydration for vdom interop
- block.insert(parent, anchor)
+ block.insert(parent, anchor, (block as TransitionBlock).$transition)
} else {
- insert(block.nodes, parent, anchor)
+ insert(block.nodes, parent, anchor, parentSuspense)
}
if (block.anchor) insert(block.anchor, parent, anchor)
}
@@ -144,7 +207,15 @@ export function prepend(parent: ParentNode, ...blocks: Block[]): void {
export function remove(block: Block, parent?: ParentNode): void {
if (block instanceof Node) {
- parent && parent.removeChild(block)
+ if ((block as TransitionBlock).$transition && block instanceof Element) {
+ performTransitionLeave(
+ block,
+ (block as TransitionBlock).$transition as TransitionHooks,
+ () => parent && parent.removeChild(block),
+ )
+ } else {
+ parent && parent.removeChild(block)
+ }
} else if (isVaporComponent(block)) {
unmountComponent(block, parent)
} else if (isArray(block)) {
@@ -154,7 +225,7 @@ export function remove(block: Block, parent?: ParentNode): void {
} else {
// fragment
if (block.remove) {
- block.remove(parent)
+ block.remove(parent, (block as TransitionBlock).$transition)
} else {
remove(block.nodes, parent)
}
diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts
index da57882c49d..d3fb8453ae5 100644
--- a/packages/runtime-vapor/src/component.ts
+++ b/packages/runtime-vapor/src/component.ts
@@ -57,6 +57,7 @@ import {
getSlot,
} from './componentSlots'
import { hmrReload, hmrRerender } from './hmr'
+import { createElement } from './dom/node'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import {
insertionAnchor,
@@ -257,11 +258,7 @@ export function createComponent(
) {
const el = getRootElement(instance)
if (el) {
- renderEffect(() => {
- isApplyingFallthroughProps = true
- setDynamicProps(el, [instance.attrs])
- isApplyingFallthroughProps = false
- })
+ renderEffect(() => applyFallthroughProps(el, instance.attrs))
}
}
@@ -284,6 +281,15 @@ export function createComponent(
export let isApplyingFallthroughProps = false
+export function applyFallthroughProps(
+ block: Block,
+ attrs: Record,
+): void {
+ isApplyingFallthroughProps = true
+ setDynamicProps(block as Element, [attrs])
+ isApplyingFallthroughProps = false
+}
+
/**
* dev only
*/
@@ -318,7 +324,7 @@ export function devRender(instance: VaporComponentInstance): void {
)) || []
}
-const emptyContext: GenericAppContext = {
+export const emptyContext: GenericAppContext = {
app: null as any,
config: {},
provides: /*@__PURE__*/ Object.create(null),
@@ -486,11 +492,13 @@ export function createComponentWithFallback(
rawProps?: LooseRawProps | null,
rawSlots?: LooseRawSlots | null,
isSingleRoot?: boolean,
+ appContext?: GenericAppContext,
): HTMLElement | VaporComponentInstance {
if (!isString(comp)) {
- return createComponent(comp, rawProps, rawSlots, isSingleRoot)
+ return createComponent(comp, rawProps, rawSlots, isSingleRoot, appContext)
}
+ const el = createElement(comp)
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
if (isHydrating) {
@@ -499,7 +507,6 @@ export function createComponentWithFallback(
resetInsertionState()
}
- const el = document.createElement(comp)
// mark single root
;(el as any).$root = isSingleRoot
diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts
new file mode 100644
index 00000000000..017cb0fd5c8
--- /dev/null
+++ b/packages/runtime-vapor/src/components/Transition.ts
@@ -0,0 +1,326 @@
+import {
+ type GenericComponentInstance,
+ type TransitionElement,
+ type TransitionHooks,
+ type TransitionHooksContext,
+ type TransitionProps,
+ TransitionPropsValidators,
+ type TransitionState,
+ baseResolveTransitionHooks,
+ checkTransitionMode,
+ currentInstance,
+ leaveCbKey,
+ resolveTransitionProps,
+ useTransitionState,
+ warn,
+} from '@vue/runtime-dom'
+import {
+ type Block,
+ type TransitionBlock,
+ type VaporTransitionHooks,
+ isFragment,
+} from '../block'
+import {
+ type FunctionalVaporComponent,
+ type VaporComponentInstance,
+ applyFallthroughProps,
+ isVaporComponent,
+} from '../component'
+import { extend, isArray } from '@vue/shared'
+import { renderEffect } from '../renderEffect'
+
+const decorate = (t: typeof VaporTransition) => {
+ t.displayName = 'VaporTransition'
+ t.props = TransitionPropsValidators
+ t.__vapor = true
+ return t
+}
+
+export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
+ (props, { slots, attrs }) => {
+ const children = (slots.default && slots.default()) as any as Block
+ if (!children) return
+
+ const instance = currentInstance! as VaporComponentInstance
+ const { mode } = props
+ checkTransitionMode(mode)
+
+ let resolvedProps
+ let isMounted = false
+ renderEffect(() => {
+ resolvedProps = resolveTransitionProps(props)
+ if (isMounted) {
+ // only update props for Fragment block, for later reusing
+ if (isFragment(children)) {
+ children.$transition!.props = resolvedProps
+ } else {
+ const child = findTransitionBlock(children)
+ if (child) {
+ // replace existing transition hooks
+ child.$transition!.props = resolvedProps
+ applyTransitionHooks(child, child.$transition!)
+ }
+ }
+ } else {
+ isMounted = true
+ }
+ })
+
+ // fallthrough attrs
+ let fallthroughAttrs = true
+ if (instance.hasFallthrough) {
+ renderEffect(() => {
+ // attrs are accessed in advance
+ const resolvedAttrs = extend({}, attrs)
+ const child = findTransitionBlock(children)
+ if (child) {
+ // mark single root
+ ;(child as any).$root = true
+
+ applyFallthroughProps(child, resolvedAttrs)
+ // ensure fallthrough attrs are not happened again in
+ // applyTransitionHooks
+ fallthroughAttrs = false
+ }
+ })
+ }
+
+ applyTransitionHooks(
+ children,
+ {
+ state: useTransitionState(),
+ props: resolvedProps!,
+ instance: instance,
+ } as VaporTransitionHooks,
+ fallthroughAttrs,
+ )
+
+ return children
+ },
+)
+
+const getTransitionHooksContext = (
+ key: String,
+ props: TransitionProps,
+ state: TransitionState,
+ instance: GenericComponentInstance,
+ postClone: ((hooks: TransitionHooks) => void) | undefined,
+) => {
+ const { leavingNodes } = state
+ const context: TransitionHooksContext = {
+ setLeavingNodeCache: el => {
+ leavingNodes.set(key, el)
+ },
+ unsetLeavingNodeCache: el => {
+ const leavingNode = leavingNodes.get(key)
+ if (leavingNode === el) {
+ leavingNodes.delete(key)
+ }
+ },
+ earlyRemove: () => {
+ const leavingNode = leavingNodes.get(key)
+ if (leavingNode && (leavingNode as TransitionElement)[leaveCbKey]) {
+ // force early removal (not cancelled)
+ ;(leavingNode as TransitionElement)[leaveCbKey]!()
+ }
+ },
+ cloneHooks: block => {
+ const hooks = resolveTransitionHooks(
+ block,
+ props,
+ state,
+ instance,
+ postClone,
+ )
+ if (postClone) postClone(hooks)
+ return hooks
+ },
+ }
+ return context
+}
+
+export function resolveTransitionHooks(
+ block: TransitionBlock,
+ props: TransitionProps,
+ state: TransitionState,
+ instance: GenericComponentInstance,
+ postClone?: (hooks: TransitionHooks) => void,
+): VaporTransitionHooks {
+ const context = getTransitionHooksContext(
+ String(block.$key),
+ props,
+ state,
+ instance,
+ postClone,
+ )
+ const hooks = baseResolveTransitionHooks(
+ context,
+ props,
+ state,
+ instance,
+ ) as VaporTransitionHooks
+ hooks.state = state
+ hooks.props = props
+ hooks.instance = instance as VaporComponentInstance
+ return hooks
+}
+
+export function applyTransitionHooks(
+ block: Block,
+ hooks: VaporTransitionHooks,
+ fallthroughAttrs: boolean = true,
+): VaporTransitionHooks {
+ const isFrag = isFragment(block)
+ const child = findTransitionBlock(block)
+ if (!child) {
+ // set transition hooks on fragment for reusing during it's updating
+ if (isFrag) setTransitionHooksOnFragment(block, hooks)
+ return hooks
+ }
+
+ const { props, instance, state, delayedLeave } = hooks
+ let resolvedHooks = resolveTransitionHooks(
+ child,
+ props,
+ state,
+ instance,
+ hooks => (resolvedHooks = hooks as VaporTransitionHooks),
+ )
+ resolvedHooks.delayedLeave = delayedLeave
+ setTransitionHooks(child, resolvedHooks)
+ if (isFrag) setTransitionHooksOnFragment(block, resolvedHooks)
+
+ // fallthrough attrs
+ if (fallthroughAttrs && instance.hasFallthrough) {
+ // mark single root
+ ;(child as any).$root = true
+ applyFallthroughProps(child, instance.attrs)
+ }
+
+ return resolvedHooks
+}
+
+export function applyTransitionLeaveHooks(
+ block: Block,
+ enterHooks: VaporTransitionHooks,
+ afterLeaveCb: () => void,
+): void {
+ const leavingBlock = findTransitionBlock(block)
+ if (!leavingBlock) return undefined
+
+ const { props, state, instance } = enterHooks
+ const leavingHooks = resolveTransitionHooks(
+ leavingBlock,
+ props,
+ state,
+ instance,
+ )
+ setTransitionHooks(leavingBlock, leavingHooks)
+
+ const { mode } = props
+ if (mode === 'out-in') {
+ state.isLeaving = true
+ leavingHooks.afterLeave = () => {
+ state.isLeaving = false
+ afterLeaveCb()
+ leavingBlock.$transition = undefined
+ delete leavingHooks.afterLeave
+ }
+ } else if (mode === 'in-out') {
+ leavingHooks.delayLeave = (
+ block: TransitionElement,
+ earlyRemove,
+ delayedLeave,
+ ) => {
+ state.leavingNodes.set(String(leavingBlock.$key), leavingBlock)
+ // early removal callback
+ block[leaveCbKey] = () => {
+ earlyRemove()
+ block[leaveCbKey] = undefined
+ leavingBlock.$transition = undefined
+ delete enterHooks.delayedLeave
+ }
+ enterHooks.delayedLeave = () => {
+ delayedLeave()
+ leavingBlock.$transition = undefined
+ delete enterHooks.delayedLeave
+ }
+ }
+ }
+}
+
+const transitionBlockCache = new WeakMap()
+export function findTransitionBlock(
+ block: Block,
+ inFragment: boolean = false,
+): TransitionBlock | undefined {
+ if (transitionBlockCache.has(block)) {
+ return transitionBlockCache.get(block)
+ }
+
+ let isFrag = false
+ let child: TransitionBlock | undefined
+ if (block instanceof Node) {
+ // transition can only be applied on Element child
+ if (block instanceof Element) child = block
+ } else if (isVaporComponent(block)) {
+ child = findTransitionBlock(block.block)
+ // use component id as key
+ if (child && child.$key === undefined) child.$key = block.uid
+ } else if (isArray(block)) {
+ child = block[0] as TransitionBlock
+ let hasFound = false
+ for (const c of block) {
+ const item = findTransitionBlock(c)
+ if (item instanceof Element) {
+ if (__DEV__ && hasFound) {
+ // warn more than one non-comment child
+ warn(
+ ' can only be used on a single element or component. ' +
+ 'Use for lists.',
+ )
+ break
+ }
+ child = item
+ hasFound = true
+ if (!__DEV__) break
+ }
+ }
+ } else if ((isFrag = isFragment(block))) {
+ if (block.insert) {
+ child = block
+ } else {
+ child = findTransitionBlock(block.nodes, true)
+ }
+ }
+
+ if (__DEV__ && !child && !inFragment && !isFrag) {
+ warn('Transition component has no valid child element')
+ }
+
+ return child
+}
+
+export function setTransitionHooksOnFragment(
+ block: Block,
+ hooks: VaporTransitionHooks,
+): void {
+ if (isFragment(block)) {
+ setTransitionHooks(block, hooks)
+ } else if (isArray(block)) {
+ for (let i = 0; i < block.length; i++) {
+ setTransitionHooksOnFragment(block[i], hooks)
+ }
+ }
+}
+
+export function setTransitionHooks(
+ block: TransitionBlock | VaporComponentInstance,
+ hooks: VaporTransitionHooks,
+): void {
+ if (isVaporComponent(block)) {
+ block = findTransitionBlock(block.block) as TransitionBlock
+ if (!block) return
+ }
+ block.$transition = hooks
+}
diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts
new file mode 100644
index 00000000000..2eff0e91dad
--- /dev/null
+++ b/packages/runtime-vapor/src/components/TransitionGroup.ts
@@ -0,0 +1,227 @@
+import {
+ type ElementWithTransition,
+ type TransitionGroupProps,
+ TransitionPropsValidators,
+ baseApplyTranslation,
+ callPendingCbs,
+ currentInstance,
+ forceReflow,
+ handleMovedChildren,
+ hasCSSTransform,
+ onBeforeUpdate,
+ onUpdated,
+ resolveTransitionProps,
+ useTransitionState,
+ warn,
+} from '@vue/runtime-dom'
+import { extend, isArray } from '@vue/shared'
+import {
+ type Block,
+ DynamicFragment,
+ type TransitionBlock,
+ type VaporTransitionHooks,
+ insert,
+ isFragment,
+} from '../block'
+import {
+ resolveTransitionHooks,
+ setTransitionHooks,
+ setTransitionHooksOnFragment,
+} from './Transition'
+import {
+ type ObjectVaporComponent,
+ type VaporComponentInstance,
+ applyFallthroughProps,
+ isVaporComponent,
+} from '../component'
+import { isForBlock } from '../apiCreateFor'
+import { renderEffect } from '../renderEffect'
+import { createElement } from '../dom/node'
+
+const positionMap = new WeakMap()
+const newPositionMap = new WeakMap()
+
+const decorate = (t: typeof VaporTransitionGroup) => {
+ delete (t.props! as any).mode
+ t.__vapor = true
+ return t
+}
+
+export const VaporTransitionGroup: ObjectVaporComponent = decorate({
+ name: 'VaporTransitionGroup',
+
+ props: /*@__PURE__*/ extend({}, TransitionPropsValidators, {
+ tag: String,
+ moveClass: String,
+ }),
+
+ setup(props: TransitionGroupProps, { slots }) {
+ const instance = currentInstance as VaporComponentInstance
+ const state = useTransitionState()
+ const cssTransitionProps = resolveTransitionProps(props)
+
+ let prevChildren: TransitionBlock[]
+ let children: TransitionBlock[]
+ let slottedBlock: Block
+
+ onBeforeUpdate(() => {
+ prevChildren = []
+ 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(),
+ )
+ }
+ }
+ }
+ })
+
+ onUpdated(() => {
+ if (!prevChildren.length) {
+ return
+ }
+
+ const moveClass = props.moveClass || `${props.name || 'v'}-move`
+ const firstChild = getFirstConnectedChild(prevChildren)
+ if (
+ !firstChild ||
+ !hasCSSTransform(
+ firstChild as ElementWithTransition,
+ firstChild.parentNode as Node,
+ moveClass,
+ )
+ ) {
+ prevChildren = []
+ return
+ }
+
+ prevChildren.forEach(callPendingCbs)
+ prevChildren.forEach(child => {
+ child.$transition!.disabled = false
+ recordPosition(child)
+ })
+ const movedChildren = prevChildren.filter(applyTranslation)
+
+ // force reflow to put everything in position
+ forceReflow()
+
+ movedChildren.forEach(c =>
+ handleMovedChildren(
+ getTransitionElement(c) as ElementWithTransition,
+ moveClass,
+ ),
+ )
+ prevChildren = []
+ })
+
+ slottedBlock = slots.default && slots.default()
+
+ // store props and state on fragment for reusing during insert new items
+ setTransitionHooksOnFragment(slottedBlock, {
+ props: cssTransitionProps,
+ state,
+ instance,
+ } as VaporTransitionHooks)
+
+ children = getTransitionBlocks(slottedBlock)
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]
+ if (isValidTransitionBlock(child)) {
+ if (child.$key != null) {
+ setTransitionHooks(
+ child,
+ resolveTransitionHooks(child, cssTransitionProps, state, instance!),
+ )
+ } else if (__DEV__ && child.$key == null) {
+ warn(` children must be keyed`)
+ }
+ }
+ }
+
+ const tag = props.tag
+ if (tag) {
+ const container = createElement(tag)
+ insert(slottedBlock, container)
+ // fallthrough attrs
+ if (instance!.hasFallthrough) {
+ ;(container as any).$root = true
+ renderEffect(() => applyFallthroughProps(container, instance!.attrs))
+ }
+ return container
+ } else {
+ const frag = __DEV__
+ ? new DynamicFragment('transitionGroup')
+ : new DynamicFragment()
+ renderEffect(() => frag.update(() => slottedBlock))
+ return frag
+ }
+ },
+})
+
+function getTransitionBlocks(block: Block) {
+ let children: TransitionBlock[] = []
+ if (block instanceof Node) {
+ children.push(block)
+ } else if (isVaporComponent(block)) {
+ children.push(...getTransitionBlocks(block.block))
+ } else if (isArray(block)) {
+ for (let i = 0; i < block.length; i++) {
+ const b = block[i]
+ const blocks = getTransitionBlocks(b)
+ if (isForBlock(b)) blocks.forEach(block => (block.$key = b.key))
+ children.push(...blocks)
+ }
+ } else if (isFragment(block)) {
+ if (block.insert) {
+ // vdom component
+ children.push(block)
+ } else {
+ children.push(...getTransitionBlocks(block.nodes))
+ }
+ }
+
+ return children
+}
+
+function isValidTransitionBlock(block: Block): boolean {
+ return !!(block instanceof Element || (isFragment(block) && block.insert))
+}
+
+function getTransitionElement(c: TransitionBlock): Element {
+ return (isFragment(c) ? (c.nodes as Element[])[0] : c) as Element
+}
+
+function recordPosition(c: TransitionBlock) {
+ newPositionMap.set(c, getTransitionElement(c).getBoundingClientRect())
+}
+
+function applyTranslation(c: TransitionBlock): TransitionBlock | undefined {
+ if (
+ baseApplyTranslation(
+ positionMap.get(c)!,
+ newPositionMap.get(c)!,
+ getTransitionElement(c) as ElementWithTransition,
+ )
+ ) {
+ return c
+ }
+}
+
+function getFirstConnectedChild(
+ children: TransitionBlock[],
+): Element | undefined {
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i]
+ const el = getTransitionElement(child)
+ if (el.isConnected) return el
+ }
+}
diff --git a/packages/runtime-vapor/src/directives/vShow.ts b/packages/runtime-vapor/src/directives/vShow.ts
index ac4c066b71d..5cd9c66f294 100644
--- a/packages/runtime-vapor/src/directives/vShow.ts
+++ b/packages/runtime-vapor/src/directives/vShow.ts
@@ -6,7 +6,7 @@ import {
} from '@vue/runtime-dom'
import { renderEffect } from '../renderEffect'
import { isVaporComponent } from '../component'
-import { type Block, DynamicFragment } from '../block'
+import { type Block, DynamicFragment, type TransitionBlock } from '../block'
import { isArray } from '@vue/shared'
export function applyVShow(target: Block, source: () => any): void {
@@ -39,13 +39,32 @@ function setDisplay(target: Block, value: unknown): void {
if (target instanceof DynamicFragment) {
return setDisplay(target.nodes, value)
}
+ const { $transition } = target as TransitionBlock
if (target instanceof Element) {
const el = target as VShowElement
if (!(vShowOriginalDisplay in el)) {
el[vShowOriginalDisplay] =
el.style.display === 'none' ? '' : el.style.display
}
- el.style.display = value ? el[vShowOriginalDisplay]! : 'none'
+ if ($transition) {
+ if (value) {
+ $transition.beforeEnter(target)
+ el.style.display = el[vShowOriginalDisplay]!
+ $transition.enter(target)
+ } else {
+ // during initial render, the element is not yet inserted into the
+ // DOM, and it is hidden, no need to trigger transition
+ if (target.isConnected) {
+ $transition.leave(target, () => {
+ el.style.display = 'none'
+ })
+ } else {
+ el.style.display = 'none'
+ }
+ }
+ } else {
+ el.style.display = value ? el[vShowOriginalDisplay]! : 'none'
+ }
el[vShowHidden] = !value
} else if (__DEV__) {
warn(
diff --git a/packages/runtime-vapor/src/dom/node.ts b/packages/runtime-vapor/src/dom/node.ts
index 83bc32c57f0..26cb66c462c 100644
--- a/packages/runtime-vapor/src/dom/node.ts
+++ b/packages/runtime-vapor/src/dom/node.ts
@@ -1,3 +1,8 @@
+/*! #__NO_SIDE_EFFECTS__ */
+export function createElement(tagName: string): HTMLElement {
+ return document.createElement(tagName)
+}
+
/*! #__NO_SIDE_EFFECTS__ */
export function createTextNode(value = ''): Text {
return document.createTextNode(value)
diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts
index 8c42ad766a5..fb855ea8b6a 100644
--- a/packages/runtime-vapor/src/dom/prop.ts
+++ b/packages/runtime-vapor/src/dom/prop.ts
@@ -269,6 +269,8 @@ export function optimizePropertyLookup(): void {
if (isOptimized) return
isOptimized = true
const proto = Element.prototype as any
+ proto.$transition = undefined
+ proto.$key = undefined
proto.$evtclick = undefined
proto.$root = false
proto.$html =
diff --git a/packages/runtime-vapor/src/dom/template.ts b/packages/runtime-vapor/src/dom/template.ts
index b78ca4e52cf..7bfbca4e52b 100644
--- a/packages/runtime-vapor/src/dom/template.ts
+++ b/packages/runtime-vapor/src/dom/template.ts
@@ -1,5 +1,5 @@
+import { child, createElement, createTextNode } from './node'
import { adoptTemplate, currentHydrationNode, isHydrating } from './hydration'
-import { child, createTextNode } from './node'
let t: HTMLTemplateElement
@@ -19,7 +19,7 @@ export function template(html: string, root?: boolean) {
return createTextNode(html)
}
if (!node) {
- t = t || document.createElement('template')
+ t = t || createElement('template')
t.innerHTML = html
node = child(t.content)
}
diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts
index 7a8aea5a0d7..bad4b4f343a 100644
--- a/packages/runtime-vapor/src/index.ts
+++ b/packages/runtime-vapor/src/index.ts
@@ -29,6 +29,7 @@ export {
} from './dom/prop'
export { on, delegate, delegateEvents, setDynamicEvents } from './dom/event'
export { createIf } from './apiCreateIf'
+export { createKeyedFragment } from './apiCreateFragment'
export {
createFor,
createForSlots,
@@ -46,3 +47,5 @@ export {
applyDynamicModel,
} from './directives/vModel'
export { withVaporDirectives } from './directives/custom'
+export { VaporTransition } from './components/Transition'
+export { VaporTransitionGroup } from './components/TransitionGroup'
diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts
index 8c1dd2cee2b..b5bb141ad3f 100644
--- a/packages/runtime-vapor/src/vdomInterop.ts
+++ b/packages/runtime-vapor/src/vdomInterop.ts
@@ -7,6 +7,7 @@ import {
type RendererInternals,
type ShallowRef,
type Slots,
+ type TransitionHooks,
type VNode,
type VaporInteropInterface,
createInternalObject,
@@ -16,6 +17,7 @@ import {
isEmitListener,
onScopeDispose,
renderSlot,
+ setTransitionHooks as setVNodeTransitionHooks,
shallowReactive,
shallowRef,
simpleSetCurrentInstance,
@@ -29,13 +31,20 @@ import {
mountComponent,
unmountComponent,
} from './component'
-import { type Block, VaporFragment, insert, remove } from './block'
-import { EMPTY_OBJ, extend, isFunction } from '@vue/shared'
+import {
+ type Block,
+ VaporFragment,
+ type VaporTransitionHooks,
+ insert,
+ remove,
+} from './block'
+import { EMPTY_OBJ, extend, isFunction, isReservedProp } from '@vue/shared'
import { type RawProps, rawPropsProxyHandlers } from './componentProps'
import type { RawSlots, VaporSlot } from './componentSlots'
import { renderEffect } from './renderEffect'
import { createTextNode } from './dom/node'
import { optimizePropertyLookup } from './dom/prop'
+import { setTransitionHooks as setVaporTransitionHooks } from './components/Transition'
// mounting vapor components and slots in vdom
const vaporInteropImpl: Omit<
@@ -48,7 +57,15 @@ const vaporInteropImpl: Omit<
const prev = currentInstance
simpleSetCurrentInstance(parentComponent)
- const propsRef = shallowRef(vnode.props)
+ // filter out reserved props
+ const props: VNode['props'] = {}
+ for (const key in vnode.props) {
+ if (!isReservedProp(key)) {
+ props[key] = vnode.props[key]
+ }
+ }
+
+ const propsRef = shallowRef(props)
const slotsRef = shallowRef(vnode.children)
// @ts-expect-error
@@ -63,6 +80,12 @@ const vaporInteropImpl: Omit<
))
instance.rawPropsRef = propsRef
instance.rawSlotsRef = slotsRef
+ if (vnode.transition) {
+ setVaporTransitionHooks(
+ instance,
+ vnode.transition as VaporTransitionHooks,
+ )
+ }
mountComponent(instance, container, selfAnchor)
simpleSetCurrentInstance(prev)
return instance
@@ -116,6 +139,10 @@ const vaporInteropImpl: Omit<
insert(vnode.vb || (vnode.component as any), container, anchor)
insert(vnode.anchor as any, container, anchor)
},
+
+ setTransitionHooks(component, hooks) {
+ setVaporTransitionHooks(component as any, hooks as VaporTransitionHooks)
+ },
}
const vaporSlotPropsProxyHandler: ProxyHandler<
@@ -182,12 +209,16 @@ function createVDOMComponent(
let isMounted = false
const parentInstance = currentInstance as VaporComponentInstance
- const unmount = (parentNode?: ParentNode) => {
+ const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
+ if (transition) setVNodeTransitionHooks(vnode, transition)
internals.umt(vnode.component!, null, !!parentNode)
}
- frag.insert = (parentNode, anchor) => {
+ frag.insert = (parentNode, anchor, transition) => {
+ const prev = currentInstance
+ simpleSetCurrentInstance(parentInstance)
if (!isMounted) {
+ if (transition) setVNodeTransitionHooks(vnode, transition)
internals.mt(
vnode,
parentNode,
@@ -209,6 +240,9 @@ function createVDOMComponent(
parentInstance as any,
)
}
+
+ frag.nodes = [vnode.el as Node]
+ simpleSetCurrentInstance(prev)
}
frag.remove = unmount
diff --git a/packages/vue/__tests__/e2e/e2eUtils.ts b/packages/vue/__tests__/e2e/e2eUtils.ts
index 2ffebeb5950..ac05a47e7e0 100644
--- a/packages/vue/__tests__/e2e/e2eUtils.ts
+++ b/packages/vue/__tests__/e2e/e2eUtils.ts
@@ -50,6 +50,16 @@ interface PuppeteerUtils {
clearValue(selector: string): Promise
timeout(time: number): Promise
nextFrame(): Promise
+ transitionStart(
+ btnSelector: string,
+ containerSelector: string,
+ ): Promise<{ classNames: string[]; innerHTML: string }>
+ waitForElement(
+ selector: string,
+ text: string,
+ classNames: string[],
+ timeout?: number,
+ ): Promise
}
export function setupPuppeteer(args?: string[]): PuppeteerUtils {
@@ -200,6 +210,43 @@ export function setupPuppeteer(args?: string[]): PuppeteerUtils {
})
}
+ const transitionStart = (btnSelector: string, containerSelector: string) =>
+ page.evaluate(
+ ([btnSel, containerSel]) => {
+ ;(document.querySelector(btnSel) as HTMLElement)!.click()
+ return Promise.resolve().then(() => {
+ const container = document.querySelector(containerSel)!
+ return {
+ classNames: container.className.split(/\s+/g),
+ innerHTML: container.innerHTML,
+ }
+ })
+ },
+ [btnSelector, containerSelector],
+ )
+
+ const waitForElement = (
+ selector: string,
+ text: string,
+ classNames: string[], // if empty, check for no classes
+ timeout = 2000,
+ ) =>
+ page.waitForFunction(
+ (sel, expectedText, expectedClasses) => {
+ const el = document.querySelector(sel)
+ const hasClasses =
+ expectedClasses.length === 0
+ ? el?.classList.length === 0
+ : expectedClasses.every(c => el?.classList.contains(c))
+ const hasText = el?.textContent?.includes(expectedText)
+ return !!el && hasClasses && hasText
+ },
+ { timeout },
+ selector,
+ text,
+ classNames,
+ )
+
return {
page: () => page,
click,
@@ -219,5 +266,7 @@ export function setupPuppeteer(args?: string[]): PuppeteerUtils {
clearValue,
timeout,
nextFrame,
+ transitionStart,
+ waitForElement,
}
}
diff --git a/packages/vue/__tests__/e2e/style.css b/packages/vue/__tests__/e2e/style.css
new file mode 100644
index 00000000000..ae6749b3afb
--- /dev/null
+++ b/packages/vue/__tests__/e2e/style.css
@@ -0,0 +1,77 @@
+.test {
+ -webkit-transition: opacity 50ms ease;
+ transition: opacity 50ms ease;
+}
+.group-move {
+ -webkit-transition: -webkit-transform 50ms ease;
+ transition: transform 50ms ease;
+}
+.v-appear,
+.v-enter,
+.v-leave-active,
+.test-appear,
+.test-enter,
+.test-leave-active,
+.test-reflow-enter,
+.test-reflow-leave-to,
+.hello,
+.bye.active,
+.changed-enter {
+ opacity: 0;
+}
+.test-reflow-leave-active,
+.test-reflow-enter-active {
+ -webkit-transition: opacity 50ms ease;
+ transition: opacity 50ms ease;
+}
+.test-reflow-leave-from {
+ opacity: 0.9;
+}
+.test-anim-enter-active {
+ animation: test-enter 50ms;
+ -webkit-animation: test-enter 50ms;
+}
+.test-anim-leave-active {
+ animation: test-leave 50ms;
+ -webkit-animation: test-leave 50ms;
+}
+.test-anim-long-enter-active {
+ animation: test-enter 100ms;
+ -webkit-animation: test-enter 100ms;
+}
+.test-anim-long-leave-active {
+ animation: test-leave 100ms;
+ -webkit-animation: test-leave 100ms;
+}
+@keyframes test-enter {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@-webkit-keyframes test-enter {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@keyframes test-leave {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+@-webkit-keyframes test-leave {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
diff --git a/packages/vue/__tests__/e2e/transition.html b/packages/vue/__tests__/e2e/transition.html
index ab404d67dc7..7f5fce9e34a 100644
--- a/packages/vue/__tests__/e2e/transition.html
+++ b/packages/vue/__tests__/e2e/transition.html
@@ -1,82 +1,4 @@
-
+