diff --git a/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts index 9edf0a7e05d..d37b7d82a67 100644 --- a/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/transition-group.spec.ts @@ -603,6 +603,81 @@ describe('vapor transition-group', () => { E2E_TIMEOUT, ) + test('keyed component move after key change', async () => { + const btnSelector = '.keyed-component-move-after-key-change > button' + const containerSelector = '.keyed-component-move-after-key-change > div' + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + click(btnSelector) + await nextTick() + await nextFrame() + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + await transitionFinish() + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + }) + + test('same-key component move after prop change', async () => { + const btnSelector = '.same-key-component-move-after-prop-change > button' + const containerSelector = '.same-key-component-move-after-prop-change > div' + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + click(btnSelector) + await nextTick() + await nextFrame() + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + await transitionFinish(350) + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + }) + test('dynamic name', async () => { const btnSelector = '.dynamic-name button.toggleBtn' const btnChangeName = '.dynamic-name button.changeNameBtn' @@ -946,6 +1021,88 @@ describe('vapor transition-group', () => { E2E_TIMEOUT, ) + test( + 'root slot component move', + async () => { + const btnSelector = '.root-slot-component-move > button' + const containerSelector = '.root-slot-component-move > div' + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
a
` + + `
b
` + + `
c
` + + ``, + ) + + click(btnSelector) + await nextTick() + await nextFrame() + expect(html(containerSelector)).toContain( + `
d
` + + `
b
` + + `
a
` + + `
c
` + + ``, + ) + + await transitionFinish() + await expect + .element(css(containerSelector)) + .toContainHTML( + `
d
` + + `
b
` + + `
a
`, + ) + }, + E2E_TIMEOUT, + ) + + test( + 'async root slot component move', + async () => { + const btnSelector = '.async-root-slot-component-move > button' + const containerSelector = '.async-root-slot-component-move > div' + + await waitForInnerHTML( + containerSelector, + `
a
` + + `
b
` + + `
c
`, + ) + await expect + .element(css(containerSelector)) + .toContainHTML( + `
a
` + + `
b
` + + `
c
` + + ``, + ) + + click(btnSelector) + await nextTick() + await nextFrame() + expect(html(containerSelector)).toContain( + `
d
` + + `
b
` + + `
a
` + + `
c
` + + ``, + ) + + await transitionFinish() + await expect + .element(css(containerSelector)) + .toContainHTML( + `
d
` + + `
b
` + + `
a
`, + ) + }, + E2E_TIMEOUT, + ) + describe('interop', () => { test( 'avoid set transition hooks for comment node', @@ -1096,5 +1253,43 @@ describe('vapor transition-group', () => { `
d
`, ) }) + + test('keyed vdom component move after key change', async () => { + const btnSelector = '.keyed-vdom-component-move-after-key-change > button' + const containerSelector = + '.keyed-vdom-component-move-after-key-change > div' + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + click(btnSelector) + await nextTick() + await nextFrame() + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + await transitionFinish(350) + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + }) }) }) diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index 41a53d09d83..e0cdfd77846 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -196,6 +196,44 @@ describe('vdom transition', () => { }, E2E_TIMEOUT, ) + + test('keyed vapor component move after key change', async () => { + const btnSelector = '.trans-group-vapor-component-move > button' + const containerSelector = '.trans-group-vapor-component-move > div' + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + click(btnSelector) + await nextTick() + await nextFrame() + + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + + await transitionFinish() + await expect + .element(css(containerSelector)) + .toContainHTML( + `
` + + `
item 1
` + + `
item 2
` + + `
`, + ) + }) }) describe('vdom transition-group', () => { diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue index b0c9b778a7e..e9934f15439 100644 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@ -4,6 +4,7 @@ import VaporComp from './components/VaporComp.vue' import VaporCompA from '../transition/components/VaporCompA.vue' import VdomComp from '../transition/components/VdomComp.vue' import VaporSlot from '../transition/components/VaporSlot.vue' +import VdomTransitionGroup from './components/VdomTransitionGroup.vue' const msg = ref('hello') const passSlot = ref(true) @@ -67,5 +68,6 @@ const enterClick = () => items.value.push('d', 'e') + diff --git a/packages-private/vapor-e2e-test/interop/components/VdomTransitionGroup.vue b/packages-private/vapor-e2e-test/interop/components/VdomTransitionGroup.vue new file mode 100644 index 00000000000..cc3800f6910 --- /dev/null +++ b/packages-private/vapor-e2e-test/interop/components/VdomTransitionGroup.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages-private/vapor-e2e-test/transition-group/cases/interop/keyed-vdom-component-move-after-key-change.vue b/packages-private/vapor-e2e-test/transition-group/cases/interop/keyed-vdom-component-move-after-key-change.vue new file mode 100644 index 00000000000..2c503332c0f --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/cases/interop/keyed-vdom-component-move-after-key-change.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/async-root-slot-component-move.vue b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/async-root-slot-component-move.vue new file mode 100644 index 00000000000..3662a90aefc --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/async-root-slot-component-move.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/keyed-component-move-after-key-change.vue b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/keyed-component-move-after-key-change.vue new file mode 100644 index 00000000000..3ece8d150b7 --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/keyed-component-move-after-key-change.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/root-slot-component-move.vue b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/root-slot-component-move.vue new file mode 100644 index 00000000000..ef8bdfb0782 --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/root-slot-component-move.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/same-key-component-move-after-prop-change.vue b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/same-key-component-move-after-prop-change.vue new file mode 100644 index 00000000000..1e88974361e --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/cases/vapor-transition-group/same-key-component-move-after-prop-change.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages-private/vapor-e2e-test/transition-group/components/RootSlot.vue b/packages-private/vapor-e2e-test/transition-group/components/RootSlot.vue new file mode 100644 index 00000000000..c6be1f26939 --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/components/RootSlot.vue @@ -0,0 +1,5 @@ + + + diff --git a/packages-private/vapor-e2e-test/transition-group/components/VaporExpandingItem.vue b/packages-private/vapor-e2e-test/transition-group/components/VaporExpandingItem.vue new file mode 100644 index 00000000000..406fecf2057 --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/components/VaporExpandingItem.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages-private/vapor-e2e-test/transition-group/components/VdomExpandingItem.vue b/packages-private/vapor-e2e-test/transition-group/components/VdomExpandingItem.vue new file mode 100644 index 00000000000..bf72aa6e391 --- /dev/null +++ b/packages-private/vapor-e2e-test/transition-group/components/VdomExpandingItem.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/runtime-vapor/__tests__/for.spec.ts b/packages/runtime-vapor/__tests__/for.spec.ts index 1cef61653b6..82e38d47e8d 100644 --- a/packages/runtime-vapor/__tests__/for.spec.ts +++ b/packages/runtime-vapor/__tests__/for.spec.ts @@ -14,6 +14,7 @@ import { import { type Ref, nextTick, + onScopeDispose, reactive, readonly, ref, @@ -827,6 +828,36 @@ describe('createFor', () => { expect(host.innerHTML).toBe('') }) + test('should track key dependencies for keyed diff', async () => { + const list = ref([{ id: 1, opened: false }]) + const calls: string[] = [] + + const { host } = define(() => { + return createFor( + () => list.value, + item => { + const label = `${item.value.id}-${item.value.opened}` + calls.push(`mount ${label}`) + onScopeDispose(() => calls.push(`unmount ${label}`)) + + const span = document.createElement('span') + span.textContent = label + return span + }, + item => `${item.id}-${item.opened}`, + ) + }).render() + + expect(host.innerHTML).toBe('1-false') + expect(calls).toEqual(['mount 1-false']) + + list.value[0].opened = true + await nextTick() + + expect(host.innerHTML).toBe('1-true') + expect(calls).toEqual(['mount 1-false', 'unmount 1-false', 'mount 1-true']) + }) + describe('readonly source', () => { test('should not allow mutation', () => { const arr = readonly(reactive([{ foo: 1 }])) diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 372e85bc081..7ff554992d7 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -98,6 +98,7 @@ export const createFor = ( let isMounted = false let oldBlocks: ForBlock[] = [] let newBlocks: ForBlock[] + let newKeys: any[] | undefined let parent: ParentNode | undefined | null let parentAnchor: Node let pendingHydrationAnchor = false @@ -132,9 +133,24 @@ export const createFor = ( const newLength = source.values.length const oldLength = oldBlocks.length newBlocks = new Array(newLength) + // Key expressions can depend on item fields, not just list shape. Evaluate + // them while the render effect is still the active subscriber so those deps + // can trigger keyed diff, then reuse the same keys below after + // setActiveSub() clears the active subscriber during patching. + newKeys = undefined + if (getKey) { + newKeys = new Array(newLength) + for (let i = 0; i < newLength; i++) { + newKeys[i] = getKey(...getItem(source, i)) + } + } const prevSub = setActiveSub() - + if (isMounted && frag.onBeforeUpdate) { + for (let i = 0; i < frag.onBeforeUpdate.length; i++) { + frag.onBeforeUpdate[i]() + } + } if (!isMounted) { isMounted = true if (isHydrating) { @@ -184,8 +200,7 @@ export const createFor = ( if (__DEV__) { const keyToIndexMap: Map = new Map() for (let i = 0; i < newLength; i++) { - const item = getItem(source, i) - const key = getKey(...item) + const key = newKeys![i] if (key != null) { if (keyToIndexMap.has(key)) { warn( @@ -214,7 +229,7 @@ export const createFor = ( while (endOffset < commonLength) { const index = newLength - endOffset - 1 const item = getItem(source, index) - const key = getKey(...item) + const key = newKeys![index] const existingBlock = oldBlocks[oldLength - endOffset - 1] if (existingBlock.key !== key) break update(existingBlock, ...item) @@ -228,7 +243,7 @@ export const createFor = ( for (let i = 0; i < e1; i++) { const currentItem = getItem(source, i) - const currentKey = getKey(...currentItem) + const currentKey = newKeys![i] const oldBlock = oldBlocks[i] const oldKey = oldBlock.key if (oldKey === currentKey) { @@ -245,7 +260,7 @@ export const createFor = ( for (let i = e1; i < e3; i++) { const blockItem = getItem(source, i) - const blockKey = getKey(...blockItem) + const blockKey = newKeys![i] queuedBlocks[queuedBlocksLength++] = [i, blockItem, blockKey] } @@ -381,7 +396,7 @@ export const createFor = ( idx: number, anchor: Node | undefined = parentAnchor, [item, key, index] = getItem(source, idx), - key2 = getKey && getKey(item, key, index), + key2 = newKeys ? newKeys[idx] : getKey && getKey(item, key, index), ): ForBlock => { const itemRef = shallowRef(item) // avoid creating refs if the render fn doesn't need it diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index 6ddc9b4d0ef..d6f257a977e 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -40,6 +40,7 @@ import { renderEffect } from '../renderEffect' import { DynamicFragment, ForFragment, + SlotFragment, type VaporFragment, isFragment, } from '../fragment' @@ -326,8 +327,15 @@ function applyResolvedTransitionHooks( } } - // delegate to TransitionGroup's apply logic for list children - if (hooks.applyGroup && block instanceof ForFragment) { + // Delegate list/root-slot wrappers back to TransitionGroup's apply logic. + // Other fragment shapes, such as keyed v-if branches, still need normal + // enter/leave hooks for their resolved single child. + if ( + hooks.applyGroup && + (block instanceof ForFragment || + block instanceof SlotFragment || + (isVaporComponent(block) && block.block instanceof SlotFragment)) + ) { hooks.applyGroup(block, hooks.props, hooks.state, hooks.instance) return { hooks } } diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index 5508f057cad..92d6a8f8503 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -13,7 +13,9 @@ import { hasCSSTransform, onBeforeUpdate, onUpdated, + queuePostFlushCb, resolveTransitionProps, + setCurrentInstance, useTransitionState, warn, } from '@vue/runtime-dom' @@ -37,13 +39,20 @@ import { type VaporComponentOptions, isVaporComponent, } from '../component' +import { resolveDynamicProps } from '../componentProps' import { isForBlock, setForHydrationAnchorResolver } from '../apiCreateFor' import { createComment, createElement, createTextNode } from '../dom/node' -import { DynamicFragment, type VaporFragment, isFragment } from '../fragment' +import { + DynamicFragment, + SlotFragment, + type VaporFragment, + isFragment, +} from '../fragment' import { type DefineVaporComponent, defineVaporComponent, } from '../apiDefineComponent' +import { watch } from '@vue/reactivity' import { isInteropEnabled } from '../vdomInteropState' import { adoptTemplate, @@ -58,6 +67,20 @@ import { const positionMap = new WeakMap() const newPositionMap = new WeakMap() +type TransitionGroupUpdateOwner = VaporFragment | VaporComponentInstance + +type TransitionGroupUpdateHookRef = { + beforeUpdate: () => void + updated: () => void +} + +// Each owner installs its update callback once. The stored hook object lets +// that callback keep pointing at the latest TransitionGroup update hooks. +const transitionGroupUpdateOwnerMap = new WeakMap< + TransitionGroupUpdateOwner, + TransitionGroupUpdateHookRef +>() + let isForHydrationAnchorResolverRegistered = false let currentForHydrationContainer: ParentNode | undefined @@ -124,10 +147,17 @@ const VaporTransitionGroupImpl = defineVaporComponent({ true, ) - let prevChildren: ResolvedTransitionBlock[] + let prevChildren: ResolvedTransitionBlock[] = [] + // Multiple child owners can update in the same flush (e.g. a VDOM child + // props update plus the surrounding v-for keyed diff). Keep the first old + // position snapshot, then apply moves after child render jobs have flushed. + let isUpdatePending = false + let isUpdatedPending = false let slottedBlock: Block = [] - onBeforeUpdate(() => { + const beforeUpdate = () => { + if (isUpdatePending) return + isUpdatePending = true prevChildren = [] const children = getTransitionBlocks(slottedBlock) for (let i = 0; i < children.length; i++) { @@ -145,9 +175,12 @@ const VaporTransitionGroupImpl = defineVaporComponent({ positionMap.set(child, el.getBoundingClientRect()) } } - }) + } - onUpdated(() => { + const flushUpdated = () => { + isUpdatedPending = false + if (!isUpdatePending) return + isUpdatePending = false if (!prevChildren.length) { return } @@ -182,7 +215,16 @@ const VaporTransitionGroupImpl = defineVaporComponent({ ), ) prevChildren = [] - }) + } + + const updated = () => { + if (!isUpdatePending || isUpdatedPending) return + isUpdatedPending = true + queuePostFlushCb(flushUpdated) + } + + onBeforeUpdate(beforeUpdate) + onUpdated(updated) const frag = new DynamicFragment('transition-group') let currentTag: string | undefined @@ -221,6 +263,7 @@ const VaporTransitionGroupImpl = defineVaporComponent({ propsProxy, state, instance, + { beforeUpdate, updated }, ) if (container) { if (!isHydrating) insert(block, container) @@ -266,9 +309,14 @@ function applyGroupTransitionHooks( props: TransitionProps, state: TransitionState, instance: VaporComponentInstance, + updateHooks: TransitionGroupUpdateHookRef, ): ResolvedTransitionBlock[] { const fragments: VaporFragment[] = [] - const children = getTransitionBlocks(block, frag => fragments.push(frag)) + const children = getTransitionBlocks( + block, + frag => fragments.push(frag), + owner => trackTransitionGroupUpdate(owner, updateHooks), + ) for (let i = 0; i < children.length; i++) { const child = children[i] if (isValidTransitionBlock(child)) { @@ -286,12 +334,62 @@ function applyGroupTransitionHooks( // propagate hooks to inner fragments for reusing during insert new items fragments.forEach(frag => { const hooks = resolveTransitionHooks(frag, props, state, instance) - hooks.applyGroup = applyGroupTransitionHooks + hooks.applyGroup = (block, props, state, instance) => + applyGroupTransitionHooks(block, props, state, instance, updateHooks) frag.$transition = hooks }) return children } +function trackTransitionGroupUpdate( + owner: TransitionGroupUpdateOwner, + updateHooks: TransitionGroupUpdateHookRef, +): void { + const registeredHooks = transitionGroupUpdateOwnerMap.get(owner) + if (registeredHooks) { + registeredHooks.beforeUpdate = updateHooks.beforeUpdate + registeredHooks.updated = updateHooks.updated + return + } + + transitionGroupUpdateOwnerMap.set(owner, updateHooks) + if (isFragment(owner)) { + ;(owner.onBeforeUpdate ||= []).push(() => updateHooks.beforeUpdate()) + ;(owner.onUpdated ||= []).push(() => updateHooks.updated()) + } else { + // A component child can update from parent-driven props without re-running + // the surrounding v-for fragment. Watch raw props directly instead of + // using component updated hooks, because child-local state updates should + // not trigger TransitionGroup move bookkeeping. This matches VDOM behavior. + let isPending = false + const flushUpdated = () => { + isPending = false + updateHooks.updated() + } + owner.scope.run(() => { + watch( + () => { + // Dynamic prop sources are resolved as child props, so the getter + // must run with the child instance while the watcher itself remains + // owned by the child scope for teardown. + const prev = setCurrentInstance(owner, owner.scope) + try { + return resolveDynamicProps(owner.rawProps) + } finally { + setCurrentInstance(...prev) + } + }, + () => { + if (isPending) return + isPending = true + updateHooks.beforeUpdate() + queuePostFlushCb(flushUpdated) + }, + ) + }) + } +} + function inheritKey(children: TransitionBlock[], key: any): void { if (key === undefined || children.length === 0) return for (let i = 0; i < children.length; i++) { @@ -303,28 +401,42 @@ function inheritKey(children: TransitionBlock[], key: any): void { function getTransitionBlocks( block: Block, onFragment?: (frag: VaporFragment) => void, + onUpdateOwner?: (owner: TransitionGroupUpdateOwner) => void, ): ResolvedTransitionBlock[] { let children: ResolvedTransitionBlock[] = [] if (block instanceof Element) { children.push(block) } else if (isVaporComponent(block)) { - const blocks = getTransitionBlocks(block.block, onFragment) + // A normal component child can move when parent-driven props update its + // root layout without re-running the surrounding v-for fragment. + // When the component root is a slot, the TransitionGroup children are the + // slotted blocks, so track the SlotFragment instead of the component. + const isRootSlot = block.block instanceof SlotFragment + if (onUpdateOwner && !isRootSlot) onUpdateOwner(block) + const blocks = getTransitionBlocks( + block.block, + onFragment, + // Only a root slot exposes nested blocks as TransitionGroup children. + // Other component internals should not trigger group move bookkeeping. + isRootSlot ? onUpdateOwner : undefined, + ) inheritKey(blocks, block.$key) children.push(...blocks) } else if (isArray(block)) { for (let i = 0; i < block.length; i++) { const b = block[i] - const blocks = getTransitionBlocks(b, onFragment) + const blocks = getTransitionBlocks(b, onFragment, onUpdateOwner) if (isForBlock(b)) blocks.forEach(block => (block.$key = b.key)) children.push(...blocks) } } else if (isFragment(block)) { + if (onFragment) onFragment(block) + if (onUpdateOwner) onUpdateOwner(block) if (isInteropEnabled && block.vnode) { // vdom component children.push(block) } else { - if (onFragment) onFragment(block) - const blocks = getTransitionBlocks(block.nodes, onFragment) + const blocks = getTransitionBlocks(block.nodes, onFragment, onUpdateOwner) inheritKey(blocks, block.$key) children.push(...blocks) } diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index de5320e2340..d491dbcef8e 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -92,6 +92,7 @@ export class VaporFragment< ) => void // hooks + onBeforeUpdate?: (() => void)[] onUpdated?: ((nodes?: Block) => void)[] // render context diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index f0fce9a0d53..157fb155745 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -820,13 +820,31 @@ function appendVnodeUpdatedHook(vnode: VNode, hook: () => void): void { : hook } +function appendVnodeBeforeUpdateHook(vnode: VNode, hook: () => void): void { + const props = (vnode.props ||= {}) + const existing = props.onVnodeBeforeUpdate + props.onVnodeBeforeUpdate = existing + ? isArray(existing) + ? [...existing, hook] + : [existing, hook] + : hook +} + function trackFragmentVNodeUpdates(frag: VaporFragment, vnode: VNode): void { - const refresh = () => { + const beforeUpdate = () => { + if (frag.onBeforeUpdate) { + for (let i = 0; i < frag.onBeforeUpdate.length; i++) { + frag.onBeforeUpdate[i]() + } + } + } + const updated = () => { frag.nodes = resolveVNodeNodes(vnode) frag.validityPending = false if (frag.onUpdated) frag.onUpdated.forEach(m => m()) } - appendVnodeUpdatedHook(vnode, refresh) + appendVnodeBeforeUpdateHook(vnode, beforeUpdate) + appendVnodeUpdatedHook(vnode, updated) } /**