Skip to content

Commit ff35014

Browse files
authored
fix(transition): handle comment-wrapped component roots (#15853)
close #6080 close #5500
1 parent d084b90 commit ff35014

5 files changed

Lines changed: 489 additions & 36 deletions

File tree

‎packages/runtime-core/__tests__/components/BaseTransition.spec.ts‎

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
serialize,
1414
serializeInner,
1515
} from '@vue/runtime-test'
16+
import * as runtime from '@vue/runtime-test'
17+
import { compile } from '@vue/compiler-dom'
1618

1719
function mount(
1820
props: BaseTransitionProps<TestElement>,
@@ -1356,4 +1358,254 @@ describe('BaseTransition', () => {
13561358
expect(firstBeforeLeave).not.toHaveBeenCalled()
13571359
expect(nextBeforeLeave).toHaveBeenCalledTimes(1)
13581360
})
1361+
1362+
describe.each([true, false])('comment roots (__DEV__: %s)', dev => {
1363+
const roots = [
1364+
['element', '<!--start--><div>A</div><!--end-->', 5],
1365+
['component', '<!--start--><Leaf /><!--end-->', 5],
1366+
[
1367+
'nested fragment',
1368+
'<!--start--><div v-if="false">If</div><!--between--><div v-else>A</div><!--end-->',
1369+
dev ? 8 : 5,
1370+
],
1371+
] as const
1372+
1373+
test.each(
1374+
roots.flatMap(([name, template, nodes]) =>
1375+
(['out-in', 'in-out'] as const).flatMap(mode =>
1376+
[false, true].map(withKeepAlive => ({
1377+
name,
1378+
template,
1379+
nodes,
1380+
mode,
1381+
withKeepAlive,
1382+
})),
1383+
),
1384+
),
1385+
)('$name, $mode, KeepAlive: $withKeepAlive', async options => {
1386+
__DEV__ = dev
1387+
try {
1388+
const { template, nodes, mode, withKeepAlive } = options
1389+
const { code } = compile(template, {
1390+
comments: true,
1391+
hoistStatic: true,
1392+
})
1393+
const One = {
1394+
components: { Leaf: { render: () => h('div', 'A') } },
1395+
render: new Function('Vue', code)(runtime),
1396+
}
1397+
const Two = { render: () => h('span', 'B') }
1398+
const toggle = ref(true)
1399+
const { props, cbs } = mockProps({ mode }, withKeepAlive)
1400+
const { root } = mount(
1401+
props,
1402+
() => h(toggle.value ? One : Two),
1403+
withKeepAlive,
1404+
)
1405+
expect(root.children).toHaveLength(nodes)
1406+
1407+
for (const active of [false, true]) {
1408+
const leaving = active ? '<span>B</span>' : '<div>A</div>'
1409+
const entering = active ? '<div>A</div>' : '<span>B</span>'
1410+
const call = active ? 1 : 0
1411+
toggle.value = active
1412+
await nextTick()
1413+
1414+
if (mode === 'out-in') {
1415+
expect(props.onLeave).toHaveBeenCalledTimes(call + 1)
1416+
assertCalledWithEl(props.onLeave, leaving, call)
1417+
expect(props.onEnter).toHaveBeenCalledTimes(call)
1418+
cbs.doneLeave[leaving]()
1419+
await nextTick()
1420+
expect(props.onEnter).toHaveBeenCalledTimes(call + 1)
1421+
assertCalledWithEl(props.onEnter, entering, call)
1422+
cbs.doneEnter[entering]()
1423+
} else {
1424+
expect(props.onEnter).toHaveBeenCalledTimes(call + 1)
1425+
assertCalledWithEl(props.onEnter, entering, call)
1426+
expect(props.onLeave).toHaveBeenCalledTimes(call)
1427+
cbs.doneEnter[entering]()
1428+
expect(props.onLeave).toHaveBeenCalledTimes(call + 1)
1429+
assertCalledWithEl(props.onLeave, leaving, call)
1430+
cbs.doneLeave[leaving]()
1431+
await nextTick()
1432+
}
1433+
1434+
expect(props.onAfterLeave).toHaveBeenCalledTimes(call + 1)
1435+
expect(props.onAfterEnter).toHaveBeenCalledTimes(call + 1)
1436+
expect(root.children).toHaveLength(active ? nodes : 1)
1437+
expect(serializeInner(root)).toBe(
1438+
active
1439+
? `<!--start-->${dev && options.name === 'nested fragment' ? '<!--between-->' : ''}<div>A</div><!--end-->`
1440+
: '<span>B</span>',
1441+
)
1442+
}
1443+
} finally {
1444+
__DEV__ = true
1445+
}
1446+
})
1447+
})
1448+
1449+
test.each([false, true])(
1450+
'should update leave hooks through comment roots (KeepAlive: %s)',
1451+
async withKeepAlive => {
1452+
const root = nodeOps.createElement('div')
1453+
const toggle = ref(true)
1454+
const firstLeave = vi.fn((_el, done) => done())
1455+
const nextLeave = vi.fn((_el, done) => done())
1456+
const onLeave = ref(firstLeave)
1457+
const { code } = compile('<!--start--><div>A</div><!--end-->')
1458+
const One = { render: new Function('Vue', code)(runtime) }
1459+
const Two = { render: () => h('span', 'B') }
1460+
const slot = () => h(toggle.value ? One : Two)
1461+
const App = () =>
1462+
h(BaseTransition, { onLeave: onLeave.value }, () =>
1463+
withKeepAlive ? h(KeepAlive, null, slot()) : slot(),
1464+
)
1465+
render(h(App), root)
1466+
1467+
onLeave.value = nextLeave
1468+
toggle.value = false
1469+
await nextTick()
1470+
1471+
expect(firstLeave).not.toHaveBeenCalled()
1472+
expect(nextLeave).toHaveBeenCalledTimes(1)
1473+
assertCalledWithEl(nextLeave, '<div>A</div>')
1474+
expect(root.children).toHaveLength(1)
1475+
expect(serializeInner(root)).toBe('<span>B</span>')
1476+
render(null, root)
1477+
},
1478+
)
1479+
1480+
test.each([true, false])(
1481+
'should skip out-in leave for empty comment roots (__DEV__: %s)',
1482+
async dev => {
1483+
__DEV__ = dev
1484+
try {
1485+
const { code } = compile(
1486+
'<!--start--><div v-if="false">A</div><!--end-->',
1487+
{ comments: true },
1488+
)
1489+
const One = { render: new Function('Vue', code)(runtime) }
1490+
const toggle = ref(true)
1491+
const { props } = mockProps({ mode: 'out-in' })
1492+
const { root } = mount(props, () =>
1493+
toggle.value ? h(One) : h('span', 'B'),
1494+
)
1495+
1496+
toggle.value = false
1497+
await nextTick()
1498+
1499+
expect(props.onLeave).not.toHaveBeenCalled()
1500+
expect(props.onEnter).toHaveBeenCalledTimes(1)
1501+
expect(serializeInner(root)).toBe('<span>B</span>')
1502+
expect(root.children).toHaveLength(1)
1503+
} finally {
1504+
__DEV__ = true
1505+
}
1506+
},
1507+
)
1508+
1509+
test('should not animate a fragment with multiple element roots', async () => {
1510+
const { code } = compile(
1511+
'<!--start--><template v-if="true"><div>A</div><span>B</span></template><!--end-->',
1512+
)
1513+
const One = { render: new Function('Vue', code)(runtime) }
1514+
const toggle = ref(true)
1515+
const { props } = mockProps()
1516+
const { root } = mount(props, () => (toggle.value ? h(One) : h('p', 'C')))
1517+
expect(
1518+
'Component inside <Transition> renders non-element root node',
1519+
).toHaveBeenWarned()
1520+
1521+
toggle.value = false
1522+
await nextTick()
1523+
1524+
expect(props.onLeave).not.toHaveBeenCalled()
1525+
expect(serializeInner(root)).toBe('<p>C</p>')
1526+
expect(root.children).toHaveLength(1)
1527+
})
1528+
1529+
test.each(['default', 'in-out'] as const)(
1530+
'should finish pending leave when a comment root re-enters (mode: %s)',
1531+
async mode => {
1532+
const { code } = compile('<!--start--><div>A</div><!--end-->', {
1533+
comments: true,
1534+
hoistStatic: true,
1535+
})
1536+
const One = { render: new Function('Vue', code)(runtime) }
1537+
const Two = { render: () => h('span', 'B') }
1538+
const toggle = ref(true)
1539+
const { props, cbs } = mockProps({ mode })
1540+
const { root } = mount(props, () =>
1541+
h(toggle.value ? One : Two, { key: toggle.value ? 'one' : 'two' }),
1542+
)
1543+
const leavingNodes = root.children.slice()
1544+
1545+
toggle.value = false
1546+
await nextTick()
1547+
cbs.doneEnter['<span>B</span>']()
1548+
expect(props.onLeave).toHaveBeenCalledTimes(1)
1549+
expect(props.onAfterLeave).not.toHaveBeenCalled()
1550+
const finishFirstLeave = cbs.doneLeave['<div>A</div>']
1551+
1552+
toggle.value = true
1553+
await nextTick()
1554+
expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
1555+
assertCalledWithEl(props.onAfterLeave, '<div>A</div>')
1556+
expect(props.onLeaveCancelled).not.toHaveBeenCalled()
1557+
expect(leavingNodes.every(node => node.parentNode === null)).toBe(true)
1558+
expect(serializeInner(root)).toBe(
1559+
'<span>B</span><!--start--><div>A</div><!--end-->',
1560+
)
1561+
expect(root.children).toHaveLength(6)
1562+
1563+
cbs.doneEnter['<div>A</div>']()
1564+
expect(props.onLeave).toHaveBeenCalledTimes(2)
1565+
cbs.doneLeave['<span>B</span>']()
1566+
finishFirstLeave()
1567+
expect(props.onAfterLeave).toHaveBeenCalledTimes(2)
1568+
expect(serializeInner(root)).toBe('<!--start--><div>A</div><!--end-->')
1569+
expect(root.children).toHaveLength(5)
1570+
},
1571+
)
1572+
1573+
test.each([true, false])(
1574+
'should enter when a component updates its own root (__DEV__: %s)',
1575+
async dev => {
1576+
__DEV__ = dev
1577+
try {
1578+
const showA = ref(true)
1579+
const { code } = compile(
1580+
'<div v-if="showA">A</div><span v-else>B</span>',
1581+
{ prefixIdentifiers: true },
1582+
)
1583+
const Comp = {
1584+
setup: () => ({ showA }),
1585+
render: new Function('Vue', code)(runtime),
1586+
}
1587+
const { props, cbs } = mockProps()
1588+
const { root } = mount(props, () => h(Comp))
1589+
1590+
showA.value = false
1591+
await nextTick()
1592+
1593+
expect(props.onLeave).toHaveBeenCalledTimes(1)
1594+
expect(props.onBeforeEnter).toHaveBeenCalledTimes(1)
1595+
expect(props.onEnter).toHaveBeenCalledTimes(1)
1596+
assertCalledWithEl(props.onEnter, '<span>B</span>')
1597+
expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
1598+
assertCalledWithEl(props.onAfterLeave, '<div>A</div>')
1599+
expect(serializeInner(root)).toBe('<span>B</span>')
1600+
1601+
cbs.doneLeave['<div>A</div>']()
1602+
expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
1603+
expect(props.onLeaveCancelled).not.toHaveBeenCalled()
1604+
cbs.doneEnter['<span>B</span>']()
1605+
expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
1606+
} finally {
1607+
__DEV__ = true
1608+
}
1609+
},
1610+
)
13591611
})

‎packages/runtime-core/src/components/BaseTransition.ts‎

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { isTeleport } from './Teleport'
2323
import type { RendererElement } from '../renderer'
2424
import { SchedulerJobFlags } from '../scheduler'
2525
import { isHmrUpdating } from '../hmr'
26+
import { filterSingleRoot } from '../componentRenderUtils'
2627

2728
type Hook<T = () => void> = T | T[]
2829

@@ -139,11 +140,6 @@ export const BaseTransitionPropsValidators: Record<string, any> = {
139140
onAppearCancelled: TransitionHookValidator,
140141
}
141142

142-
const recursiveGetSubtree = (instance: ComponentInternalInstance): VNode => {
143-
const subTree = instance.subTree
144-
return subTree.component ? recursiveGetSubtree(subTree.component) : subTree
145-
}
146-
147143
const BaseTransitionImpl: ComponentOptions = {
148144
name: `BaseTransition`,
149145

@@ -214,7 +210,7 @@ const BaseTransitionImpl: ComponentOptions = {
214210
oldInnerChild &&
215211
oldInnerChild.type !== Comment &&
216212
!isSameVNodeType(oldInnerChild, innerChild) &&
217-
recursiveGetSubtree(instance).type !== Comment
213+
getTransitionRoot(instance.subTree)
218214
) {
219215
let leavingHooks = resolveTransitionHooks(
220216
oldInnerChild,
@@ -397,13 +393,18 @@ export function resolveTransitionHooks(
397393
}
398394
// for toggled element with same key (v-if)
399395
const leavingVNode = leavingVNodesCache[key]
400-
if (
401-
leavingVNode &&
402-
isSameVNodeType(vnode, leavingVNode) &&
403-
(leavingVNode.el as TransitionElement)[leaveCbKey]
404-
) {
405-
// force early removal (not cancelled)
406-
;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
396+
if (leavingVNode && isSameVNodeType(vnode, leavingVNode)) {
397+
// A component's el still points to its leaving root during patch.
398+
let leavingCb = (leavingVNode.el as TransitionElement)[leaveCbKey]
399+
if (!leavingCb) {
400+
const leavingRoot = getTransitionRoot(leavingVNode)
401+
leavingCb =
402+
leavingRoot && (leavingRoot.el as TransitionElement)[leaveCbKey]
403+
}
404+
if (leavingCb) {
405+
// force early removal (not cancelled)
406+
leavingCb()
407+
}
407408
}
408409
callHook(hook, [el])
409410
},
@@ -548,6 +549,12 @@ export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks): void {
548549
vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
549550
} else {
550551
vnode.transition = hooks
552+
if (vnode.type === Fragment && isArray(vnode.children)) {
553+
const child = filterSingleRoot(vnode.children, false)
554+
if (child) {
555+
setTransitionHooks(child, hooks)
556+
}
557+
}
551558
}
552559
}
553560

@@ -588,3 +595,17 @@ export function getTransitionRawChildren(
588595
}
589596
return ret
590597
}
598+
599+
export function getTransitionRoot(vnode: VNode): VNode | undefined {
600+
if (vnode.component) {
601+
return getTransitionRoot(vnode.component.subTree)
602+
}
603+
if (vnode.type === Fragment) {
604+
const child =
605+
isArray(vnode.children) && filterSingleRoot(vnode.children, false)
606+
return child ? getTransitionRoot(child) : undefined
607+
}
608+
if (vnode.type !== Comment) {
609+
return vnode
610+
}
611+
}

0 commit comments

Comments
 (0)