From 07e02dcd84d8f502c6cb14736325c385b89dedb5 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 11:38:54 +0800 Subject: [PATCH 01/13] fix(transition): optimize prop handling in VaporTransition using Proxy --- .../src/components/Transition.ts | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index 00871ff2bf1..eefd9356c2a 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -80,29 +80,19 @@ export const VaporTransition: FunctionalVaporComponent = checkTransitionMode(mode) let resolvedProps: BaseTransitionProps - let isMounted = false - renderEffect(() => { - resolvedProps = resolveTransitionProps(props) - if (isMounted) { - // only update props for Fragment transition, 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!, true) - } - } - } else { - isMounted = true - } - }) + renderEffect(() => (resolvedProps = resolveTransitionProps(props))) const hooks = applyTransitionHooks(children, { state: useTransitionState(), - props: resolvedProps!, + // use proxy to keep props reference stable + props: new Proxy( + {}, + { + get(_, key) { + return resolvedProps[key as keyof BaseTransitionProps] + }, + }, + ), instance: instance, } as VaporTransitionHooks) From b6d633db459c5c3666a799fb571411c08de7d850 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 11:43:59 +0800 Subject: [PATCH 02/13] fix(runtime-vapor): optimize prop handling in VaporTransitionGroup using Proxy --- .../src/components/TransitionGroup.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index 76d5acd1c1a..7055b612333 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -21,6 +21,7 @@ import { type VaporTransitionHooks, insert, } from '../block' +import { renderEffect } from '../renderEffect' import { resolveTransitionHooks, setTransitionHooks, @@ -55,7 +56,18 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({ setup(props: TransitionGroupProps, { slots }) { const instance = currentInstance as VaporComponentInstance const state = useTransitionState() - const cssTransitionProps = resolveTransitionProps(props) + + // use proxy to keep props reference stable + let cssTransitionProps = resolveTransitionProps(props) + const propsProxy = new Proxy({} as typeof cssTransitionProps, { + get(_, key) { + return cssTransitionProps[key as keyof typeof cssTransitionProps] + }, + }) + + renderEffect(() => { + cssTransitionProps = resolveTransitionProps(props) + }) let prevChildren: TransitionBlock[] let children: TransitionBlock[] @@ -121,7 +133,7 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({ // store props and state on fragment for reusing during insert new items setTransitionHooksOnFragment(slottedBlock, { - props: cssTransitionProps, + props: propsProxy, state, instance, } as VaporTransitionHooks) @@ -133,7 +145,7 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({ if (child.$key != null) { const hooks = resolveTransitionHooks( child, - cssTransitionProps, + propsProxy, state, instance!, ) From 11202987acc386fc0c1f4e1d3c01b9f3bdedc2d9 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 11:48:34 +0800 Subject: [PATCH 03/13] fix(runtime-vapor): remove unused isResolved parameter from applyTransitionHooks --- packages/runtime-vapor/src/components/Transition.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index eefd9356c2a..51f59e477d2 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -175,7 +175,6 @@ export function resolveTransitionHooks( export function applyTransitionHooks( block: Block, hooks: VaporTransitionHooks, - isResolved: boolean = false, ): VaporTransitionHooks { // filter out comment nodes if (isArray(block)) { @@ -188,9 +187,7 @@ export function applyTransitionHooks( } const isFrag = isFragment(block) - const child = isResolved - ? (block as TransitionBlock) - : findTransitionBlock(block, isFrag) + const child = findTransitionBlock(block, isFrag) if (!child) { // set transition hooks on fragment for reusing during it's updating if (isFrag) setTransitionHooksOnFragment(block, hooks) From 17372a93ba4305b42ff49c03f8050fae0f392e9d Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 14:01:24 +0800 Subject: [PATCH 04/13] fix(runtime-vapor): refactor transition hooks application to use callback pattern --- .../src/components/Transition.ts | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index 51f59e477d2..73593d47d76 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -27,7 +27,7 @@ import { } from '../component' import { isArray } from '@vue/shared' import { renderEffect } from '../renderEffect' -import { isFragment } from '../fragment' +import { type VaporFragment, isFragment } from '../fragment' import { currentHydrationNode, isHydrating, @@ -85,14 +85,11 @@ export const VaporTransition: FunctionalVaporComponent = const hooks = applyTransitionHooks(children, { state: useTransitionState(), // use proxy to keep props reference stable - props: new Proxy( - {}, - { - get(_, key) { - return resolvedProps[key as keyof BaseTransitionProps] - }, + props: new Proxy({} as BaseTransitionProps, { + get(_, key) { + return resolvedProps[key as keyof BaseTransitionProps] }, - ), + }), instance: instance, } as VaporTransitionHooks) @@ -186,11 +183,15 @@ export function applyTransitionHooks( } } - const isFrag = isFragment(block) - const child = findTransitionBlock(block, isFrag) + const fragments: VaporFragment[] = [] + const child = findTransitionBlock(block, frag => fragments.push(frag)) if (!child) { - // set transition hooks on fragment for reusing during it's updating - if (isFrag) setTransitionHooksOnFragment(block, hooks) + // set transition hooks on fragments for later use + fragments.forEach(f => (f.$transition = hooks)) + // warn if no child and no fragments + if (__DEV__ && fragments.length === 0) { + warn('Transition component has no valid child element') + } return hooks } @@ -204,7 +205,7 @@ export function applyTransitionHooks( ) resolvedHooks.delayedLeave = delayedLeave child.$transition = resolvedHooks - if (isFrag) setTransitionHooksOnFragment(block, resolvedHooks) + fragments.forEach(f => (f.$transition = resolvedHooks)) return resolvedHooks } @@ -260,7 +261,7 @@ export function applyTransitionLeaveHooks( export function findTransitionBlock( block: Block, - inFragment: boolean = false, + onFragment?: (frag: VaporFragment) => void, ): TransitionBlock | undefined { let child: TransitionBlock | undefined if (block instanceof Node) { @@ -273,7 +274,7 @@ export function findTransitionBlock( } else { // stop searching if encountering nested Transition component if (getComponentName(block.type) === displayName) return undefined - child = findTransitionBlock(block.block, inFragment) + child = findTransitionBlock(block.block, onFragment) // use component id as key if (child && child.$key === undefined) child.$key = block.uid } @@ -281,9 +282,7 @@ export function findTransitionBlock( let hasFound = false for (const c of block) { if (c instanceof Comment) continue - // check if the child is a fragment to suppress warnings - if (isFragment(c)) inFragment = true - const item = findTransitionBlock(c, inFragment) + const item = findTransitionBlock(c, onFragment) if (__DEV__ && hasFound) { // warn more than one non-comment child warn( @@ -297,19 +296,15 @@ export function findTransitionBlock( if (!__DEV__) break } } else if (isFragment(block)) { - // mark as in fragment to suppress warnings - inFragment = true if (block.insert) { child = block } else { - child = findTransitionBlock(block.nodes, true) + // collect fragments for setting transition hooks + if (onFragment) onFragment(block) + child = findTransitionBlock(block.nodes, onFragment) } } - if (__DEV__ && !child && !inFragment) { - warn('Transition component has no valid child element') - } - return child } From 1f5b70c39eae5de81c69cda9286ac79115489772 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 14:04:30 +0800 Subject: [PATCH 05/13] fix(runtime-vapor): add move function for block repositioning with transition support --- packages/runtime-vapor/src/block.ts | 24 +++++++++++++++++++ .../runtime-vapor/src/components/KeepAlive.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 323ef7beefc..713f1b89519 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -158,6 +158,30 @@ export function remove(block: Block, parent?: ParentNode): void { } } +export function move(block: Block, parent: ParentNode): void { + if (block instanceof Node) { + if ((block as TransitionBlock).$transition && block instanceof Element) { + performTransitionLeave( + block, + (block as TransitionBlock).$transition as TransitionHooks, + () => insert(block, parent), + ) + } else { + insert(block, parent) + } + } else if (isVaporComponent(block)) { + move(block.block, parent) + } else if (isArray(block)) { + for (let i = 0; i < block.length; i++) { + move(block[i], parent) + } + } else { + // fragment + move(block.nodes, parent) + if (block.anchor) move(block.anchor, parent) + } +} + /** * dev / test only */ diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index 2c267b001ed..5c8a915feae 100644 --- a/packages/runtime-vapor/src/components/KeepAlive.ts +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -18,7 +18,7 @@ import { warn, watch, } from '@vue/runtime-dom' -import { type Block, insert, remove } from '../block' +import { type Block, insert, move, remove } from '../block' import { type ObjectVaporComponent, type VaporComponent, @@ -358,7 +358,7 @@ export function deactivate( instance: VaporComponentInstance, container: ParentNode, ): void { - insert(instance.block, container) + move(instance.block, container) queuePostFlushCb(() => { if (instance.da) invokeArrayFns(instance.da) From f7c9c6e81ffd376f0c18f867d4e6d21145f7cea6 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 15:09:45 +0800 Subject: [PATCH 06/13] fix(runtime-vapor): refactor move function into insert with MoveType parameter --- packages/runtime-vapor/__tests__/hmr.spec.ts | 8 +++-- packages/runtime-vapor/src/block.ts | 33 +++++-------------- .../runtime-vapor/src/components/KeepAlive.ts | 5 +-- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/packages/runtime-vapor/__tests__/hmr.spec.ts b/packages/runtime-vapor/__tests__/hmr.spec.ts index 3618ee10644..88be0bbabdc 100644 --- a/packages/runtime-vapor/__tests__/hmr.spec.ts +++ b/packages/runtime-vapor/__tests__/hmr.spec.ts @@ -276,11 +276,14 @@ describe('hot module replacement', () => { components: { Child }, setup() { const toggle = ref(true) - return { toggle } + function onLeave(_: any, done: Function) { + setTimeout(done, 0) + } + return { toggle, onLeave } }, render: compileToFunction( `
1
`) expect(unmountSpy).toHaveBeenCalledTimes(1) expect(mountSpy).toHaveBeenCalledTimes(1) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 713f1b89519..20bc01e94b8 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -8,6 +8,7 @@ import { import { _child } from './dom/node' import { isComment, isHydrating } from './dom/hydration' import { + MoveType, type TransitionHooks, type TransitionProps, type TransitionState, @@ -77,6 +78,7 @@ export function insert( block: Block, parent: ParentNode & { $fc?: Node | null }, anchor: Node | null | 0 = null, // 0 means prepend + moveType: MoveType = MoveType.ENTER, parentSuspense?: any, // TODO Suspense ): void { anchor = anchor === 0 ? parent.$fc || _child(parent) : anchor @@ -88,7 +90,12 @@ export function insert( (block as TransitionBlock).$transition && !(block as TransitionBlock).$transition!.disabled ) { - performTransitionEnter( + const action = + moveType === MoveType.LEAVE + ? performTransitionLeave + : performTransitionEnter + + action( block, (block as TransitionBlock).$transition as TransitionHooks, () => parent.insertBefore(block, anchor as Node), @@ -158,30 +165,6 @@ export function remove(block: Block, parent?: ParentNode): void { } } -export function move(block: Block, parent: ParentNode): void { - if (block instanceof Node) { - if ((block as TransitionBlock).$transition && block instanceof Element) { - performTransitionLeave( - block, - (block as TransitionBlock).$transition as TransitionHooks, - () => insert(block, parent), - ) - } else { - insert(block, parent) - } - } else if (isVaporComponent(block)) { - move(block.block, parent) - } else if (isArray(block)) { - for (let i = 0; i < block.length; i++) { - move(block[i], parent) - } - } else { - // fragment - move(block.nodes, parent) - if (block.anchor) move(block.anchor, parent) - } -} - /** * dev / test only */ diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index 5c8a915feae..616188b6f20 100644 --- a/packages/runtime-vapor/src/components/KeepAlive.ts +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -3,6 +3,7 @@ import { type GenericComponent, type GenericComponentInstance, type KeepAliveProps, + MoveType, type VNode, currentInstance, devtoolsComponentAdded, @@ -18,7 +19,7 @@ import { warn, watch, } from '@vue/runtime-dom' -import { type Block, insert, move, remove } from '../block' +import { type Block, insert, remove } from '../block' import { type ObjectVaporComponent, type VaporComponent, @@ -358,7 +359,7 @@ export function deactivate( instance: VaporComponentInstance, container: ParentNode, ): void { - move(instance.block, container) + insert(instance.block, container, null, MoveType.LEAVE) queuePostFlushCb(() => { if (instance.da) invokeArrayFns(instance.da) From e2ca9d1a7d3db093538602dd73ccff276f998b57 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 15:23:46 +0800 Subject: [PATCH 07/13] refactor(runtime-core): move MoveType export to public API --- packages/runtime-core/src/index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 9e589ef9156..068184e8265 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -133,6 +133,7 @@ export { export { withDirectives } from './directives' // SSR context export { useSSRContext, ssrContextKey } from './helpers/useSsrContext' +export { MoveType } from './renderer' // Custom Renderer API --------------------------------------------------------- @@ -519,11 +520,7 @@ export { type VaporInteropInterface } from './apiCreateApp' /** * @internal */ -export { - type RendererInternals, - MoveType, - getInheritedScopeIds, -} from './renderer' +export { type RendererInternals, getInheritedScopeIds } from './renderer' /** * @internal */ From 84be6558a0ff3f583301e7213d0dee3c7651a959 Mon Sep 17 00:00:00 2001 From: daiwei Date: Fri, 19 Dec 2025 16:28:15 +0800 Subject: [PATCH 08/13] test(vapor-e2e): add transition test for keep-alive include update --- .../__tests__/transition.spec.ts | 36 +++++++++++++ .../vapor-e2e-test/transition/App.vue | 50 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/packages-private/vapor-e2e-test/__tests__/transition.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts index 1dce93782e1..27fe3ce6281 100644 --- a/packages-private/vapor-e2e-test/__tests__/transition.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/transition.spec.ts @@ -922,6 +922,42 @@ describe('vapor transition', () => { }) expect(calls).toStrictEqual(['TrueBranch']) }) + + test( + 'switch child then update include (out-in mode)', + async () => { + const containerSelector = '.keep-alive-update-include > div' + const btnSwitchToB = '.keep-alive-update-include > #switchToB' + const btnSwitchToA = '.keep-alive-update-include > #switchToA' + const btnSwitchToC = '.keep-alive-update-include > #switchToC' + + await transitionFinish() + expect(await html(containerSelector)).toBe('
CompA
') + + await click(btnSwitchToB) + await nextTick() + await click(btnSwitchToC) + await transitionFinish(duration * 3) + expect(await html(containerSelector)).toBe('
CompC
') + + await click(btnSwitchToA) + await transitionFinish(duration * 3) + expect(await html(containerSelector)).toBe('
CompA
') + + let calls = await page().evaluate(() => { + return (window as any).getCalls('unmount') + }) + expect(calls).toStrictEqual(['CompC unmounted']) + + // Unlike vdom, CompA does not update because there are no state changes + // expect CompA only update once + // calls = await page().evaluate(() => { + // return (window as any).getCalls('updated') + // }) + // expect(calls).toStrictEqual(['CompA updated']) + }, + E2E_TIMEOUT, + ) }) describe.todo('transition with Suspense', () => {}) diff --git a/packages-private/vapor-e2e-test/transition/App.vue b/packages-private/vapor-e2e-test/transition/App.vue index e7227ded01e..7b802f768bc 100644 --- a/packages-private/vapor-e2e-test/transition/App.vue +++ b/packages-private/vapor-e2e-test/transition/App.vue @@ -9,6 +9,7 @@ import { template, defineVaporAsyncComponent, onUnmounted, + onUpdated, } from 'vue' const show = ref(true) const toggle = ref(true) @@ -31,6 +32,7 @@ let calls = { showAppear: [], notEnter: [], + updated: [], unmount: [], } window.getCalls = key => calls[key] @@ -117,6 +119,42 @@ const click = () => { includeRef.value = [] } } + +const CompA = defineVaporComponent({ + name: 'CompA', + setup() { + onUpdated(() => { + calls.updated.push('CompA updated') + }) + return template('
CompA
')() + }, +}) + +const CompB = defineVaporComponent({ + name: 'CompB', + setup() { + return template('
CompB
')() + }, +}) + +const CompC = defineVaporComponent({ + name: 'CompC', + setup() { + onUnmounted(() => { + calls.unmount.push('CompC unmounted') + }) + return template('
CompC
')() + }, +}) + +const includeToChange = ref(['CompA', 'CompB', 'CompC']) +const currentView = shallowRef(CompA) +const switchToB = () => (currentView.value = CompB) +const switchToC = () => (currentView.value = CompC) +const switchToA = () => { + currentView.value = CompA + includeToChange.value = ['CompA'] +}