From: daiwei Date: Mon, 23 Jun 2025 07:22:11 +0000 (+0800) Subject: chore: Merge branch 'edison/feat/vaporTeleport' into edison/testVapor X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=fd2e46bf64d6e13e51da9b44a75a4e3ff83a9ce1;p=thirdparty%2Fvuejs%2Fcore.git chore: Merge branch 'edison/feat/vaporTeleport' into edison/testVapor --- fd2e46bf64d6e13e51da9b44a75a4e3ff83a9ce1 diff --cc packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts index ba050f0f26,0000000000..13e1f1df3f mode 100644,000000..100644 --- a/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts @@@ -1,406 -1,0 +1,407 @@@ +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() ++import { ports } from '../utils' + +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' ++ const port = ports.transitionGroup + 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 --cc packages-private/vapor-e2e-test/__tests__/transition.spec.ts index 0bfc30598c,0000000000..ccb0475f34 mode 100644,000000..100644 --- a/packages-private/vapor-e2e-test/__tests__/transition.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts @@@ -1,1660 -1,0 +1,1661 @@@ +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() ++import { ports } from '../utils' + +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' ++ const port = ports.transition + 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 --cc packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index e05f06e1ab,734c9fde19..e4959121ca --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@@ -5,25 -5,13 +5,28 @@@ 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() + +const duration = process.env.CI ? 200 : 50 +const buffer = process.env.CI ? 50 : 20 +const transitionFinish = (time = duration) => timeout(time + buffer) + + import { ports } from '../utils' + import { nextTick } from 'vue' -const { page, click, text, enterValue, html } = setupPuppeteer() + describe('vdom / vapor interop', () => { let server: any - const port = '8193' + const port = ports.vdomInterop beforeAll(() => { server = connect() .use(sirv(path.resolve(import.meta.dirname, '../dist'))) @@@ -98,162 -86,32 +107,190 @@@ E2E_TIMEOUT, ) - describe('teleport', () => { - const testSelector = '.teleport' - test('render vapor component', async () => { - const targetSelector = `${testSelector} .teleport-target` - const containerSelector = `${testSelector} .render-vapor-comp` - const buttonSelector = `${containerSelector} button` + describe('vdom transition', () => { + test( + 'render vapor component', + async () => { + const btnSelector = '.trans-vapor > button' + const containerSelector = '.trans-vapor > div' - // teleport is disabled by default - expect(await html(containerSelector)).toBe( - `
vapor comp
`, - ) - expect(await html(targetSelector)).toBe('') - - // disabled -> enabled - await click(buttonSelector) - await nextTick() - expect(await html(containerSelector)).toBe(``) - expect(await html(targetSelector)).toBe('
vapor comp
') - - // enabled -> disabled - await click(buttonSelector) - await nextTick() - expect(await html(containerSelector)).toBe( - `
vapor comp
`, - ) - expect(await html(targetSelector)).toBe('') + 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, + ) ++ describe('teleport', () => { ++ const testSelector = '.teleport' ++ test('render vapor component', async () => { ++ const targetSelector = `${testSelector} .teleport-target` ++ const containerSelector = `${testSelector} .render-vapor-comp` ++ const buttonSelector = `${containerSelector} button` ++ ++ // teleport is disabled by default ++ expect(await html(containerSelector)).toBe( ++ `
vapor comp
`, ++ ) ++ expect(await html(targetSelector)).toBe('') ++ ++ // disabled -> enabled ++ await click(buttonSelector) ++ await nextTick() ++ expect(await html(containerSelector)).toBe(``) ++ expect(await html(targetSelector)).toBe('
vapor comp
') ++ ++ // enabled -> disabled ++ await click(buttonSelector) ++ await nextTick() ++ expect(await html(containerSelector)).toBe( ++ `
vapor comp
`, ++ ) ++ expect(await html(targetSelector)).toBe('') ++ }) + }) }) }) diff --cc packages-private/vapor-e2e-test/index.html index 09ea6aa607,bb1234e8e1..85a18d79ea --- a/packages-private/vapor-e2e-test/index.html +++ b/packages-private/vapor-e2e-test/index.html @@@ -1,11 -1,3 +1,12 @@@ VDOM / Vapor interop Vapor TodoMVC +Vapor Transition +Vapor TransitionGroup + Vapor Teleport + + diff --cc packages-private/vapor-e2e-test/interop/App.vue index 8cf42e4754,dcdd5f99ac..7bfdd6abf0 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@@ -1,22 -1,11 +1,25 @@@ diff --cc packages-private/vapor-e2e-test/utils.ts index 0000000000,a42064b705..88461f8dba mode 000000,100644..100644 --- a/packages-private/vapor-e2e-test/utils.ts +++ b/packages-private/vapor-e2e-test/utils.ts @@@ -1,0 -1,6 +1,8 @@@ + // make sure these ports are unique + export const ports = { + vdomInterop: 8193, + todomvc: 8194, - teleport: 8195, ++ transition: 8195, ++ transitionGroup: 8196, ++ teleport: 8197, + } diff --cc packages-private/vapor-e2e-test/vite.config.ts index f50fccea3c,a2816f4b6d..2cfb660dbf --- a/packages-private/vapor-e2e-test/vite.config.ts +++ b/packages-private/vapor-e2e-test/vite.config.ts @@@ -14,11 -14,7 +14,12 @@@ export default defineConfig( input: { interop: resolve(import.meta.dirname, 'interop/index.html'), todomvc: resolve(import.meta.dirname, 'todomvc/index.html'), + teleport: resolve(import.meta.dirname, 'teleport/index.html'), + transition: resolve(import.meta.dirname, 'transition/index.html'), + transitionGroup: resolve( + import.meta.dirname, + 'transition-group/index.html', + ), }, }, }, diff --cc packages/compiler-vapor/src/utils.ts index d2c7eca3bb,9b99ef869c..b509b9ba04 --- a/packages/compiler-vapor/src/utils.ts +++ b/packages/compiler-vapor/src/utils.ts @@@ -90,35 -89,13 +90,42 @@@ export function getLiteralExpressionVal 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 isTeleportTag(tag: string): boolean { + tag = tag.toLowerCase() + return tag === 'teleport' || tag === 'vaporteleport' + } + export function isBuiltInComponent(tag: string): string | undefined { - if (isTeleportTag(tag)) { + if (isTransitionTag(tag)) { + return 'VaporTransition' + } else if (isTransitionGroupTag(tag)) { + return 'VaporTransitionGroup' ++ } else if (isTeleportTag(tag)) { + return 'VaporTeleport' } } diff --cc packages/runtime-core/src/index.ts index 4fe35c8b49,1ac5cb9bac..a4063a0666 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@@ -566,14 -557,14 +566,22 @@@ export { startMeasure, endMeasure } fro * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { performTransitionEnter, performTransitionLeave } from './renderer' +/** + * @internal + */ +export { ensureVaporSlotFallback } from './helpers/renderSlot' + /** + * @internal + */ + export { + resolveTarget as resolveTeleportTarget, + isTeleportDisabled, + isTeleportDeferred, + } from './components/Teleport' /** * @internal */ diff --cc packages/runtime-vapor/src/apiCreateDynamicComponent.ts index 7942b76569,409d73d7df..e0341a54e9 --- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts +++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts @@@ -1,6 -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 { 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' @@@ -9,8 -9,8 +9,9 @@@ import insertionParent, resetInsertionState, } from './insertionState' +import { DYNAMIC_COMPONENT_ANCHOR_LABEL } from '@vue/shared' import { isHydrating, locateHydrationNode } from './dom/hydration' + import { DynamicFragment, type VaporFragment } from './fragment' export function createDynamicComponent( getter: () => any, diff --cc packages/runtime-vapor/src/apiCreateFor.ts index 19ee718271,a0f780406c..059f2176ad --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@@ -11,32 -11,16 +11,28 @@@ import toReactive, toReadonly, } from '@vue/reactivity' -import { getSequence, isArray, isObject, isString } from '@vue/shared' +import { + FOR_ANCHOR_LABEL, + getSequence, + isArray, + isObject, + isString, +} from '@vue/shared' import { createComment, createTextNode } from './dom/node' - import { - type Block, - VaporFragment, - insert, - remove as removeBlock, - } from './block' + import { type Block, insert, remove as removeBlock } from './block' import { warn } from '@vue/runtime-dom' import { currentInstance, isVaporComponent } from './component' import type { DynamicSlot } from './componentSlots' import { renderEffect } from './renderEffect' import { VaporVForFlags } from '../../shared/src/vaporFlags' -import { isHydrating, locateHydrationNode } from './dom/hydration' +import { applyTransitionHooks } from './components/Transition' +import { + currentHydrationNode, + isHydrating, + locateHydrationNode, + locateVaporFragmentAnchor, +} from './dom/hydration' + import { VaporFragment } from './fragment' import { insertionAnchor, insertionParent, diff --cc packages/runtime-vapor/src/apiCreateFragment.ts index 50179b89ef,0000000000..d4bb876368 mode 100644,000000..100644 --- a/packages/runtime-vapor/src/apiCreateFragment.ts +++ b/packages/runtime-vapor/src/apiCreateFragment.ts @@@ -1,10 -1,0 +1,11 @@@ - import { type Block, type BlockFn, DynamicFragment } from './block' ++import type { Block, BlockFn } from './block' ++import { DynamicFragment } from './fragment' +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 --cc packages/runtime-vapor/src/apiCreateIf.ts index ba13df83d7,37f6077b0f..f7c70f7582 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@@ -1,5 -1,4 +1,5 @@@ +import { IF_ANCHOR_LABEL } from '@vue/shared' - import { type Block, type BlockFn, DynamicFragment, insert } from './block' + import { type Block, type BlockFn, insert } from './block' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, diff --cc packages/runtime-vapor/src/block.ts index 6a8dc3310d,f1791904ce..fdf25cc583 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@@ -5,48 -5,19 +5,41 @@@ import mountComponent, unmountComponent, } from './component' - import { createComment, createTextNode } from './dom/node' - import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' - import { - currentHydrationNode, - isComment, - locateHydrationNode, - locateVaporFragmentAnchor, - } from './dom/hydration' +import { + type TransitionHooks, + type TransitionProps, + type TransitionState, + performTransitionEnter, + performTransitionLeave, +} from '@vue/runtime-dom' + import { isHydrating } from './dom/hydration' ++import { getInheritedScopeIds } from '@vue/runtime-dom' import { - applyTransitionHooks, - applyTransitionLeaveHooks, - } from './components/Transition' + type DynamicFragment, + type VaporFragment, + isFragment, + } from './fragment' -export type Block = - | Node - | VaporFragment - | DynamicFragment - | VaporComponentInstance - | Block[] +export interface TransitionOptions { + $key?: any + $transition?: VaporTransitionHooks +} - import { isHydrating } from './dom/hydration' - import { getInheritedScopeIds } from '@vue/runtime-dom' + +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 = TransitionBlock | VaporComponentInstance | Block[] export type BlockFn = (...args: any[]) => Block @@@ -221,16 -64,14 +101,21 @@@ export function insert insert(b, parent, anchor) } } else { + if (block.anchor) { + insert(block.anchor, parent, anchor) + anchor = block.anchor + } // 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, parentSuspense) - insert(block.nodes, block.target || parent, block.targetAnchor || anchor) ++ insert( ++ block.nodes, ++ block.target || parent, ++ block.targetAnchor || anchor, ++ parentSuspense, ++ ) } - if (block.anchor) insert(block.anchor, parent, anchor) } } diff --cc packages/runtime-vapor/src/component.ts index 6d63920a9f,38054e710c..681586a5b5 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@@ -25,15 -25,7 +25,14 @@@ import unregisterHMR, warn, } from '@vue/runtime-dom' -import { type Block, insert, isBlock, remove } from './block' +import { + type Block, - DynamicFragment, + insert, + isBlock, + remove, + setComponentScopeId, + setScopeId, +} from './block' import { type ShallowRef, markRaw, @@@ -66,8 -58,8 +65,9 @@@ import getSlot, } from './componentSlots' import { hmrReload, hmrRerender } from './hmr' +import { createElement } from './dom/node' import { isHydrating, locateHydrationNode } from './dom/hydration' + import { isVaporTeleport } from './components/Teleport' import { insertionAnchor, insertionParent, diff --cc packages/runtime-vapor/src/componentSlots.ts index b3a931deee,2f8c3dd3ce..d3ef5b0147 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@@ -1,20 -1,5 +1,12 @@@ -import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared' -import { type Block, type BlockFn, insert } from './block' +import { + EMPTY_OBJ, + NO, + SLOT_ANCHOR_LABEL, + hasOwn, + isArray, + isFunction, +} from '@vue/shared' - import { - type Block, - type BlockFn, - DynamicFragment, - type VaporFragment, - insert, - isFragment, - setScopeId, - } from './block' ++import { type Block, type BlockFn, insert, setScopeId } from './block' import { rawPropsProxyHandlers } from './componentProps' import { currentInstance, isRef } from '@vue/runtime-dom' import type { LooseRawProps, VaporComponentInstance } from './component' @@@ -25,6 -10,7 +17,7 @@@ import resetInsertionState, } from './insertionState' import { isHydrating, locateHydrationNode } from './dom/hydration' -import { DynamicFragment } from './fragment' ++import { DynamicFragment, type VaporFragment, isFragment } from './fragment' export type RawSlots = Record & { $?: DynamicSlotSource[] diff --cc packages/runtime-vapor/src/components/Transition.ts index 5422c39ba9,0000000000..560b277cbb mode 100644,000000..100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@@ -1,326 -1,0 +1,322 @@@ +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 { Block, TransitionBlock, VaporTransitionHooks } from '../block' +import { + type FunctionalVaporComponent, + type VaporComponentInstance, + applyFallthroughProps, + isVaporComponent, +} from '../component' +import { extend, isArray } from '@vue/shared' +import { renderEffect } from '../renderEffect' ++import { isFragment } from '../fragment' + +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 + renderEffect(() => 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 --cc packages/runtime-vapor/src/components/TransitionGroup.ts index 2eff0e91da,0000000000..e3b4fb4fb0 mode 100644,000000..100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@@ -1,227 -1,0 +1,226 @@@ +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' ++import { DynamicFragment, isFragment } from '../fragment' + +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 --cc packages/runtime-vapor/src/directives/vShow.ts index 5cd9c66f29,b0fc22c14c..406b372c20 --- a/packages/runtime-vapor/src/directives/vShow.ts +++ b/packages/runtime-vapor/src/directives/vShow.ts @@@ -6,8 -6,9 +6,9 @@@ import } from '@vue/runtime-dom' import { renderEffect } from '../renderEffect' import { isVaporComponent } from '../component' - import { type Block, DynamicFragment, type TransitionBlock } from '../block' -import type { Block } from '../block' ++import type { Block, TransitionBlock } from '../block' import { isArray } from '@vue/shared' + import { DynamicFragment } from '../fragment' export function applyVShow(target: Block, source: () => any): void { if (isVaporComponent(target)) { diff --cc packages/runtime-vapor/src/fragment.ts index 0000000000,3e4fcb221c..258e848b04 mode 000000,100644..100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@@ -1,0 -1,69 +1,140 @@@ + import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity' + import { createComment, createTextNode } from './dom/node' -import { type Block, type BlockFn, insert, isValidBlock, remove } from './block' ++import { ++ type Block, ++ type BlockFn, ++ type TransitionOptions, ++ type VaporTransitionHooks, ++ insert, ++ isValidBlock, ++ remove, ++} from './block' ++import type { TransitionHooks } from '@vue/runtime-dom' ++import { ++ currentHydrationNode, ++ isComment, ++ isHydrating, ++ locateHydrationNode, ++ locateVaporFragmentAnchor, ++} from './dom/hydration' ++import { ++ applyTransitionHooks, ++ applyTransitionLeaveHooks, ++} from './components/Transition' + -export class VaporFragment { ++export class VaporFragment implements TransitionOptions { ++ $key?: any ++ $transition?: VaporTransitionHooks | undefined + nodes: Block ++ anchor?: Node ++ insert?: ( ++ parent: ParentNode, ++ anchor: Node | null, ++ transitionHooks?: TransitionHooks, ++ ) => void ++ remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void ++ fallback?: BlockFn ++ + target?: ParentNode | null + targetAnchor?: Node | null - anchor?: Node - insert?: (parent: ParentNode, anchor: Node | null) => void - remove?: (parent?: ParentNode) => void + getNodes?: () => Block + + constructor(nodes: Block) { + this.nodes = nodes + } + } + + export class DynamicFragment extends VaporFragment { - anchor: Node ++ anchor!: Node + scope: EffectScope | undefined + current?: BlockFn + fallback?: BlockFn ++ /** ++ * slot only ++ * indicates forwarded slot ++ */ ++ forwarded?: boolean + + constructor(anchorLabel?: string) { + super([]) - this.anchor = - __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() ++ if (isHydrating) { ++ locateHydrationNode(true) ++ this.hydrate(anchorLabel!) ++ } else { ++ this.anchor = ++ __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode() ++ } + } + + update(render?: BlockFn, key: any = render): void { + if (key === this.current) { + return + } + this.current = key + + pauseTracking() + 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') { ++ resetTracking() ++ 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) + this.nodes = + (this.scope || (this.scope = new EffectScope())).run(this.fallback) || + [] + parent && insert(this.nodes, parent, this.anchor) + } + + resetTracking() + } ++ ++ hydrate(label: string): void { ++ // for `v-if="false"` the node will be an empty comment, use it as the anchor. ++ // otherwise, find next sibling vapor fragment anchor ++ if (isComment(currentHydrationNode!, '')) { ++ this.anchor = currentHydrationNode ++ } else { ++ const anchor = locateVaporFragmentAnchor(currentHydrationNode!, label)! ++ if (anchor) { ++ this.anchor = anchor ++ } else if (__DEV__) { ++ // this should not happen ++ throw new Error(`${label} fragment anchor node was not found.`) ++ } ++ } ++ } + } + + export function isFragment(val: NonNullable): val is VaporFragment { + return val instanceof VaporFragment + } diff --cc packages/runtime-vapor/src/index.ts index c801a84c69,051944443a..ef2b6188b7 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@@ -44,5 -43,5 +45,6 @@@ export applyDynamicModel, } from './directives/vModel' export { withVaporDirectives } from './directives/custom' -export { isFragment } from './fragment' -export { VaporFragment } from './fragment' +export { VaporTransition } from './components/Transition' +export { VaporTransitionGroup } from './components/TransitionGroup' ++export { isFragment, VaporFragment } from './fragment' diff --cc packages/runtime-vapor/src/vdomInterop.ts index dc6efb40af,d5bbc71466..8bf3ebd1d9 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@@ -37,34 -28,14 +37,27 @@@ import mountComponent, unmountComponent, } from './component' - import { - type Block, - DynamicFragment, - VaporFragment, - type VaporTransitionHooks, - insert, - isFragment, - remove, - } from './block' -import { type Block, insert, remove } from './block' -import { EMPTY_OBJ, extend, isFunction } from '@vue/shared' ++import { type Block, type VaporTransitionHooks, insert, remove } from './block' +import { + EMPTY_OBJ, + extend, + isArray, + 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 { VaporFragment } from './fragment' +import { setTransitionHooks as setVaporTransitionHooks } from './components/Transition' +import { + currentHydrationNode, + isHydrating, + locateHydrationNode, + hydrateNode as vaporHydrateNode, +} from './dom/hydration' ++import { DynamicFragment, VaporFragment, isFragment } from './fragment' // mounting vapor components and slots in vdom const vaporInteropImpl: Omit<