From: edison Date: Tue, 11 Mar 2025 13:37:33 +0000 (+0800) Subject: feat(vapor): vapor TransitionGroup (#13019) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=2e71c9e9805de74a2e2617bb1206debc395ff563;p=thirdparty%2Fvuejs%2Fcore.git feat(vapor): vapor TransitionGroup (#13019) * wip: save * wip: save * wip: handle tag prop and attrs fallthrough * test: add e2e tests * [autofix.ci] apply automated fixes * wip: add more tests * [autofix.ci] apply automated fixes * wip: handle vdom interop * [autofix.ci] apply automated fixes * wip: vapor interop + filter out reserved props * [autofix.ci] apply automated fixes * fix: tests --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- 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 0000000000..ba050f0f26 --- /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 index 19770e7b9b..ebc9567b0c 100644 --- a/packages-private/vapor-e2e-test/__tests__/transition.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts @@ -130,7 +130,7 @@ describe('vapor transition', () => { expect(calls).toStrictEqual([ 'beforeAppear', - 'onEnter', + 'onAppear', 'afterAppear', 'beforeLeave', 'onLeave', diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index a6eb410fbb..e05f06e1ab 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -105,20 +105,18 @@ describe('vdom / vapor interop', () => { const btnSelector = '.trans-vapor > button' const containerSelector = '.trans-vapor > div' - expect(await html(containerSelector)).toBe( - `
vapor compA
`, - ) + expect(await html(containerSelector)).toBe(`
vapor compA
`) // comp leave expect( (await transitionStart(btnSelector, containerSelector)).innerHTML, ).toBe( - `
vapor compA
`, + `
vapor compA
`, ) await nextFrame() expect(await html(containerSelector)).toBe( - `
vapor compA
`, + `
vapor compA
`, ) await transitionFinish() @@ -127,18 +125,16 @@ describe('vdom / vapor interop', () => { // comp enter expect( (await transitionStart(btnSelector, containerSelector)).innerHTML, - ).toBe( - `
vapor compA
`, - ) + ).toBe(`
vapor compA
`) await nextFrame() expect(await html(containerSelector)).toBe( - `
vapor compA
`, + `
vapor compA
`, ) await transitionFinish() expect(await html(containerSelector)).toBe( - `
vapor compA
`, + `
vapor compA
`, ) }, E2E_TIMEOUT, @@ -214,4 +210,50 @@ describe('vdom / vapor interop', () => { 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 160e2125d3..09ea6aa607 100644 --- a/packages-private/vapor-e2e-test/index.html +++ b/packages-private/vapor-e2e-test/index.html @@ -1,3 +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 f29df3c80c..8cf42e4754 100644 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@ -3,6 +3,7 @@ import { ref, shallowRef } from 'vue' import VaporComp from './VaporComp.vue' import VaporCompA from '../transition/components/VaporCompA.vue' import VdomComp from '../transition/components/VdomComp.vue' +import VaporSlot from '../transition/components/VaporSlot.vue' const msg = ref('hello') const passSlot = ref(true) @@ -13,6 +14,9 @@ function toggleInteropComponent() { interopComponent.value = interopComponent.value === VaporCompA ? VdomComp : VaporCompA } + +const items = ref(['a', 'b', 'c']) +const enterClick = () => items.value.push('d', 'e') 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 0000000000..55775743c5 --- /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 0000000000..906795d22f --- /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 0000000000..afd7d55f2b --- /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 0000000000..79052a023b --- /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 0000000000..efa06a296c --- /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 index 057bb0a229..b8470c1074 100644 --- a/packages-private/vapor-e2e-test/transition/App.vue +++ b/packages-private/vapor-e2e-test/transition/App.vue @@ -26,78 +26,80 @@ function toggleInteropComponent() { @@ -106,3 +108,10 @@ function toggleInteropComponent() { height: 100px; } + 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 0000000000..f5eff0100f --- /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/main.ts b/packages-private/vapor-e2e-test/transition/main.ts index 88bfe0ee7e..e77d51d1c0 100644 --- a/packages-private/vapor-e2e-test/transition/main.ts +++ b/packages-private/vapor-e2e-test/transition/main.ts @@ -1,5 +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 index 98f19c8cd9..e6faf6cea5 100644 --- a/packages-private/vapor-e2e-test/transition/style.css +++ b/packages-private/vapor-e2e-test/transition/style.css @@ -17,3 +17,19 @@ .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 846620ad01..f50fccea3c 100644 --- a/packages-private/vapor-e2e-test/vite.config.ts +++ b/packages-private/vapor-e2e-test/vite.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ 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-vapor/src/transforms/transformElement.ts b/packages/compiler-vapor/src/transforms/transformElement.ts index 35fd596ee8..0ad4ef092f 100644 --- a/packages/compiler-vapor/src/transforms/transformElement.ts +++ b/packages/compiler-vapor/src/transforms/transformElement.ts @@ -413,7 +413,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/utils.ts b/packages/compiler-vapor/src/utils.ts index d390c69a21..2d5ba72b39 100644 --- a/packages/compiler-vapor/src/utils.ts +++ b/packages/compiler-vapor/src/utils.ts @@ -94,20 +94,35 @@ export function isInTransition( context: TransformContext, ): boolean { const parentNode = context.parent && context.parent.node - return !!(parentNode && isTransitionNode(parentNode as ElementNode)) + return !!( + parentNode && + (isTransitionNode(parentNode as ElementNode) || + isTransitionGroupNode(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-dom/src/components/TransitionGroup.ts b/packages/runtime-dom/src/components/TransitionGroup.ts index 8400e71d6c..4f4993b5ce 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 & { @@ -87,7 +87,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) @@ -96,20 +96,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) }) }) @@ -177,8 +164,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]() } @@ -192,19 +178,34 @@ 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 + } +} + +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( +export function hasCSSTransform( el: ElementWithTransition, root: Node, moveClass: string, @@ -231,3 +232,23 @@ function hasCSSTransform( container.removeChild(clone) return hasTransform } + +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 450ec74d15..521bb46498 100644 --- a/packages/runtime-dom/src/index.ts +++ b/packages/runtime-dom/src/index.ts @@ -271,10 +271,22 @@ export { useCssModule } from './helpers/useCssModule' export { useCssVars } from './helpers/useCssVars' // DOM-only components -export { Transition, type TransitionProps } from './components/Transition' +export { + Transition, + type TransitionProps, + forceReflow, + addTransitionClass, + removeTransitionClass, +} from './components/Transition' +export type { ElementWithTransition } from './components/Transition' export { TransitionGroup, type TransitionGroupProps, + hasCSSTransform, + callPendingCbs, + moveCbKey, + handleMovedChildren, + baseApplyTranslation, } from './components/TransitionGroup' // **Internal** DOM-only runtime directive helpers diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 19653cd5da..1e4be0b516 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -22,6 +22,7 @@ import { currentInstance, isVaporComponent } from './component' import type { DynamicSlot } from './componentSlots' import { renderEffect } from './renderEffect' import { VaporVForFlags } from '../../shared/src/vaporFlags' +import { applyTransitionEnterHooks } from './components/Transition' class ForBlock extends VaporFragment { scope: EffectScope | undefined @@ -315,6 +316,10 @@ export const createFor = ( getKey && getKey(item, key, index), )) + if (frag.$transition) { + applyTransitionEnterHooks(block.nodes, frag.$transition) + } + if (parent) insert(block.nodes, parent, anchor) return block @@ -415,8 +420,8 @@ function getItem( } } -function normalizeAnchor(node: Block): Node { - if (node instanceof Node) { +function normalizeAnchor(node: Block): Node | undefined { + if (node && node instanceof Node) { return node } else if (isArray(node)) { return normalizeAnchor(node[0]) @@ -439,3 +444,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/block.ts b/packages/runtime-vapor/src/block.ts index 6c904ab869..26c0d8ca37 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -28,6 +28,7 @@ export interface TransitionOptions { export interface VaporTransitionHooks extends TransitionHooks { state: TransitionState props: TransitionProps + disabledOnMoving?: boolean } export type TransitionBlock = @@ -157,7 +158,11 @@ export function insert( if (block instanceof Node) { if (!isHydrating) { // don't apply transition on text or comment nodes - if ((block as TransitionBlock).$transition && block instanceof Element) { + if ( + block instanceof Element && + (block as TransitionBlock).$transition && + !(block as TransitionBlock).$transition!.disabledOnMoving + ) { performTransitionEnter( block, (block as TransitionBlock).$transition as TransitionHooks, diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index d262c47238..fbba29f3ba 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -21,6 +21,7 @@ import { isFragment, } from '../block' import { type VaporComponentInstance, isVaporComponent } from '../component' +import { isArray } from '@vue/shared' const decorate = (t: typeof VaporTransition) => { t.displayName = 'VaporTransition' @@ -93,7 +94,7 @@ const getTransitionHooksContext = ( return context } -function resolveTransitionHooks( +export function resolveTransitionHooks( block: TransitionBlock, props: TransitionProps, state: TransitionState, @@ -118,10 +119,10 @@ function resolveTransitionHooks( return hooks } -function setTransitionHooks( +export function setTransitionHooks( block: TransitionBlock, hooks: VaporTransitionHooks, -) { +): void { block.$transition = hooks } @@ -144,7 +145,7 @@ export function applyTransitionEnterHooks( setTransitionHooks(child, enterHooks) if (isFragment(block)) { // also set transition hooks on fragment for reusing during it's updating - setTransitionHooks(block, enterHooks) + setTransitionHooksToFragment(block, enterHooks) } return enterHooks } @@ -211,7 +212,7 @@ export function findTransitionBlock(block: Block): TransitionBlock | undefined { } else if (isVaporComponent(block)) { child = findTransitionBlock(block.block) if (child && child.$key === undefined) child.$key = block.type.__name - } else if (Array.isArray(block)) { + } else if (isArray(block)) { child = block[0] as TransitionBlock let hasFound = false for (const c of block) { @@ -254,3 +255,16 @@ export function setTransitionToInstance( setTransitionHooks(child, hooks) } + +export function setTransitionHooksToFragment( + block: Block, + hooks: VaporTransitionHooks, +): void { + if (isFragment(block)) { + setTransitionHooks(block, hooks) + } else if (isArray(block)) { + for (let i = 0; i < block.length; i++) { + setTransitionHooksToFragment(block[i], hooks) + } + } +} diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts new file mode 100644 index 0000000000..35d39c6640 --- /dev/null +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -0,0 +1,209 @@ +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, + setTransitionHooksToFragment, +} from './Transition' +import { type ObjectVaporComponent, isVaporComponent } from '../component' +import { isForBlock } from '../apiCreateFor' +import { renderEffect, setDynamicProps } from '@vue/runtime-vapor' + +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 }: any) { + const instance = currentInstance + 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) + const hook = (child as TransitionBlock).$transition! + // disabled transition during moving, so the children will be + // inserted into the correct position immediately. this prevents + // `recordPosition` from getting incorrect positions in `onUpdated` + hook.disabledOnMoving = true + positionMap.set(child, getEl(child).getBoundingClientRect()) + } + } + } + }) + + onUpdated(() => { + if (!prevChildren.length) { + return + } + + const moveClass = props.moveClass || `${props.name || 'v'}-move` + + const firstChild = findFirstChild(prevChildren) + if ( + !firstChild || + !hasCSSTransform( + firstChild as ElementWithTransition, + firstChild.parentNode as Node, + moveClass, + ) + ) { + return + } + + prevChildren.forEach(callPendingCbs) + prevChildren.forEach(child => { + delete child.$transition!.disabledOnMoving + recordPosition(child) + }) + const movedChildren = prevChildren.filter(applyTranslation) + + // force reflow to put everything in position + forceReflow() + + movedChildren.forEach(c => + handleMovedChildren(getEl(c) as ElementWithTransition, moveClass), + ) + }) + + slottedBlock = slots.default && slots.default() + + // store props and state on fragment for reusing during insert new items + setTransitionHooksToFragment(slottedBlock, { + props: cssTransitionProps, + state, + } as VaporTransitionHooks) + + children = getTransitionBlocks(slottedBlock) + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (isValidTransitionBlock(child)) { + if ((child as TransitionBlock).$key != null) { + setTransitionHooks( + child, + resolveTransitionHooks(child, cssTransitionProps, state, instance!), + ) + } else if (__DEV__ && (child as TransitionBlock).$key == null) { + warn(` children must be keyed`) + } + } + } + + const tag = props.tag + if (tag) { + const el = document.createElement(tag) + insert(slottedBlock, el) + // fallthrough attrs + renderEffect(() => setDynamicProps(el, [instance!.attrs])) + return [el] + } 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 getEl(c: TransitionBlock): Element { + return (isFragment(c) ? c.nodes : c) as Element +} + +function recordPosition(c: TransitionBlock) { + newPositionMap.set(c, getEl(c).getBoundingClientRect()) +} + +function applyTranslation(c: TransitionBlock): TransitionBlock | undefined { + if ( + baseApplyTranslation( + positionMap.get(c)!, + newPositionMap.get(c)!, + getEl(c) as ElementWithTransition, + ) + ) { + return c + } +} + +function findFirstChild(children: TransitionBlock[]): Element | undefined { + for (let i = 0; i < children.length; i++) { + const child = children[i] + const el = getEl(child) + if (el.isConnected) return el + } +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 1a23a97a3c..df7810404b 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -43,3 +43,4 @@ export { } 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 0b17b47292..b9bce9d6f8 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -35,7 +35,7 @@ import { insert, remove, } from './block' -import { EMPTY_OBJ, extend, isFunction } from '@vue/shared' +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' @@ -54,7 +54,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 @@ -221,6 +229,7 @@ function createVDOMComponent( parentInstance as any, ) } + frag.nodes = vnode.el as Node simpleSetCurrentInstance(prev) } diff --git a/packages/vue/__tests__/e2e/style.css b/packages/vue/__tests__/e2e/style.css new file mode 100644 index 0000000000..ae6749b3af --- /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 ab404d67dc..7f5fce9e34 100644 --- a/packages/vue/__tests__/e2e/transition.html +++ b/packages/vue/__tests__/e2e/transition.html @@ -1,82 +1,4 @@
- +