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( + `
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
`, + ) + }) +}) 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( + `
a
` + + `
b
` + + `
c
`, + ) + + // insert + 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, + ) + }) }) 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 @@ 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 @@ + + + + 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 @@ + + + + + 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 @@ + + 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 @@ + + 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 @@ + + 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 @@
- +