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(
+ `
`,
+ )
+
+ click(btnSelector)
+ await nextTick()
+ await nextFrame()
+
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ `` +
+ `
` +
+ `
` +
+ `
` +
+ `
`,
+ )
+
+ await transitionFinish()
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ ``,
+ )
+ })
+
+ 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(
+ ``,
+ )
+
+ click(btnSelector)
+ await nextTick()
+ await nextFrame()
+
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ ``,
+ )
+
+ await transitionFinish(350)
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ ``,
+ )
+ })
+
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', () => {
``,
)
})
+
+ 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(
+ ``,
+ )
+
+ click(btnSelector)
+ await nextTick()
+ await nextFrame()
+
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ ``,
+ )
+
+ await transitionFinish(350)
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ ``,
+ )
+ })
})
})
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(
+ ``,
+ )
+
+ click(btnSelector)
+ await nextTick()
+ await nextFrame()
+
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ `` +
+ `
` +
+ `
` +
+ `
` +
+ `
`,
+ )
+
+ await transitionFinish()
+ await expect
+ .element(css(containerSelector))
+ .toContainHTML(
+ ``,
+ )
+ })
})
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)
}
/**