diff --git a/packages/runtime-core/__tests__/components/BaseTransition.spec.ts b/packages/runtime-core/__tests__/components/BaseTransition.spec.ts
index c78f0b6d8..ac9455009 100644
--- a/packages/runtime-core/__tests__/components/BaseTransition.spec.ts
+++ b/packages/runtime-core/__tests__/components/BaseTransition.spec.ts
@@ -7,7 +7,8 @@ import {
ref,
nextTick,
serializeInner,
- serialize
+ serialize,
+ VNodeProps
} from '@vue/runtime-test'
function mount(props: BaseTransitionProps, slot: () => any) {
@@ -26,7 +27,9 @@ function mockProps(extra: BaseTransitionProps = {}) {
}
const props: BaseTransitionProps = {
onBeforeEnter: jest.fn(el => {
- expect(el.parentNode).toBeNull()
+ if (!extra.persisted) {
+ expect(el.parentNode).toBeNull()
+ }
}),
onEnter: jest.fn((el, done) => {
cbs.doneEnter[serialize(el)] = done
@@ -67,8 +70,8 @@ interface ToggleOptions {
falseSerialized: string
}
-async function runTestWithElements(tester: (o: ToggleOptions) => void) {
- await tester({
+function runTestWithElements(tester: (o: ToggleOptions) => void) {
+ return tester({
trueBranch: () => h('div'),
falseBranch: () => h('span'),
trueSerialized: `
`,
@@ -76,12 +79,12 @@ async function runTestWithElements(tester: (o: ToggleOptions) => void) {
})
}
-async function runTestWithComponents(tester: (o: ToggleOptions) => void) {
+function runTestWithComponents(tester: (o: ToggleOptions) => void) {
const CompA = ({ msg }: { msg: string }) => h('div', msg)
// test HOC
const CompB = ({ msg }: { msg: string }) => h(CompC, { msg })
const CompC = ({ msg }: { msg: string }) => h('span', msg)
- await tester({
+ return tester({
trueBranch: () => h(CompA, { msg: 'foo' }),
falseBranch: () => h(CompB, { msg: 'bar' }),
trueSerialized: `foo
`,
@@ -90,6 +93,89 @@ async function runTestWithComponents(tester: (o: ToggleOptions) => void) {
}
describe('BaseTransition', () => {
+ test('appear: true', () => {
+ const { props, cbs } = mockProps({ appear: true })
+ mount(props, () => h('div'))
+ expect(props.onBeforeEnter).toHaveBeenCalledTimes(1)
+ expect(props.onEnter).toHaveBeenCalledTimes(1)
+ expect(props.onAfterEnter).not.toHaveBeenCalled()
+ cbs.doneEnter[``]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
+ })
+
+ describe('persisted: true', () => {
+ // this is pretty much how v-show is implemented
+ // (but using the directive API instead)
+ function mockPersistedHooks() {
+ const state = { show: true }
+ const toggle = ref(true)
+ const hooks: VNodeProps = {
+ onVnodeBeforeMount(vnode) {
+ vnode.transition!.beforeEnter(vnode.el)
+ },
+ onVnodeMounted(vnode) {
+ vnode.transition!.enter(vnode.el)
+ },
+ onVnodeUpdated(vnode, oldVnode) {
+ if (oldVnode.props!.id !== vnode.props!.id) {
+ if (vnode.props!.id) {
+ vnode.transition!.beforeEnter(vnode.el)
+ state.show = true
+ vnode.transition!.enter(vnode.el)
+ } else {
+ vnode.transition!.leave(vnode.el, () => {
+ state.show = false
+ })
+ }
+ }
+ }
+ }
+ return { state, toggle, hooks }
+ }
+
+ test('w/ appear: false', async () => {
+ const { props, cbs } = mockProps({ persisted: true })
+ const { toggle, state, hooks } = mockPersistedHooks()
+
+ mount(props, () => h('div', { id: toggle.value, ...hooks }))
+ // without appear: true, enter hooks should not be called on mount
+ expect(props.onBeforeEnter).not.toHaveBeenCalled()
+ expect(props.onEnter).not.toHaveBeenCalled()
+ expect(props.onAfterEnter).not.toHaveBeenCalled()
+
+ toggle.value = false
+ await nextTick()
+ expect(props.onBeforeLeave).toHaveBeenCalledTimes(1)
+ expect(props.onLeave).toHaveBeenCalledTimes(1)
+ expect(props.onAfterLeave).not.toHaveBeenCalled()
+ expect(state.show).toBe(true) // should still be shown
+ cbs.doneLeave[``]()
+ expect(state.show).toBe(false) // should be hidden now
+ expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
+
+ toggle.value = true
+ await nextTick()
+ expect(props.onBeforeEnter).toHaveBeenCalledTimes(1)
+ expect(props.onEnter).toHaveBeenCalledTimes(1)
+ expect(props.onAfterEnter).not.toHaveBeenCalled()
+ expect(state.show).toBe(true) // should be shown now
+ cbs.doneEnter[``]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
+ })
+
+ test('w/ appear: true', () => {
+ const { props, cbs } = mockProps({ persisted: true, appear: true })
+ const { hooks } = mockPersistedHooks()
+ mount(props, () => h('div', hooks))
+
+ expect(props.onBeforeEnter).toHaveBeenCalledTimes(1)
+ expect(props.onEnter).toHaveBeenCalledTimes(1)
+ expect(props.onAfterEnter).not.toHaveBeenCalled()
+ cbs.doneEnter[``]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
+ })
+ })
+
describe('toggle on-off', () => {
async function testToggleOnOff({
trueBranch,
@@ -694,15 +780,181 @@ describe('BaseTransition', () => {
})
})
- describe('mode: "in-out"', () => {})
+ describe('mode: "in-out"', () => {
+ async function testInOut({
+ trueBranch,
+ falseBranch,
+ trueSerialized,
+ falseSerialized
+ }: ToggleOptions) {
+ const toggle = ref(true)
+ const { props, cbs } = mockProps({ mode: 'in-out' })
+ const root = mount(
+ props,
+ () => (toggle.value ? trueBranch() : falseBranch())
+ )
- describe('mode: "in-out" toggle before finish', () => {})
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe(`${trueSerialized}${falseSerialized}`)
+ // enter should start
+ expect(props.onBeforeEnter).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onBeforeEnter, falseSerialized)
+ expect(props.onEnter).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onEnter, falseSerialized)
+ expect(props.onAfterEnter).not.toHaveBeenCalled()
+ // leave should not start
+ expect(props.onBeforeLeave).not.toHaveBeenCalled()
+ expect(props.onLeave).not.toHaveBeenCalled()
+ expect(props.onAfterLeave).not.toHaveBeenCalled()
- test('persisted: true', () => {
- // test onLeaveCancelled
+ // finish enter
+ cbs.doneEnter[falseSerialized]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onAfterEnter, falseSerialized)
+
+ // leave should start now
+ expect(props.onBeforeLeave).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onBeforeLeave, trueSerialized)
+ expect(props.onLeave).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onLeave, trueSerialized)
+ expect(props.onAfterLeave).not.toHaveBeenCalled()
+ // finish leave
+ cbs.doneLeave[trueSerialized]()
+ expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onAfterLeave, trueSerialized)
+
+ // toggle again
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe(`${falseSerialized}${trueSerialized}`)
+ // enter should start
+ expect(props.onBeforeEnter).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onBeforeEnter, trueSerialized, 1)
+ expect(props.onEnter).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onEnter, trueSerialized, 1)
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
+ // leave should not start
+ expect(props.onBeforeLeave).toHaveBeenCalledTimes(1)
+ expect(props.onLeave).toHaveBeenCalledTimes(1)
+ expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
+
+ // finish enter
+ cbs.doneEnter[trueSerialized]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onAfterEnter, trueSerialized, 1)
+
+ // leave should start now
+ expect(props.onBeforeLeave).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onBeforeLeave, falseSerialized, 1)
+ expect(props.onLeave).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onLeave, falseSerialized, 1)
+ expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
+ // finish leave
+ cbs.doneLeave[falseSerialized]()
+ expect(props.onAfterLeave).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onAfterLeave, falseSerialized, 1)
+
+ assertCalls(props, {
+ onBeforeEnter: 2,
+ onEnter: 2,
+ onAfterEnter: 2,
+ onEnterCancelled: 0,
+ onBeforeLeave: 2,
+ onLeave: 2,
+ onAfterLeave: 2,
+ onLeaveCancelled: 0
+ })
+ }
+
+ test('w/ elements', async () => {
+ await runTestWithElements(testInOut)
+ })
+
+ test('w/ components', async () => {
+ await runTestWithComponents(testInOut)
+ })
})
- test('appear: true', () => {})
+ describe('mode: "in-out" toggle before finish', () => {
+ async function testInOutBeforeFinish({
+ trueBranch,
+ falseBranch,
+ trueSerialized,
+ falseSerialized
+ }: ToggleOptions) {
+ const toggle = ref(true)
+ const { props, cbs } = mockProps({ mode: 'in-out' })
+ const root = mount(
+ props,
+ () => (toggle.value ? trueBranch() : falseBranch())
+ )
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe(`${trueSerialized}${falseSerialized}`)
+
+ // toggle back before enter finishes
+ toggle.value = true
+ await nextTick()
+ // should force remove stale true branch
+ expect(serializeInner(root)).toBe(`${falseSerialized}${trueSerialized}`)
+ expect(props.onBeforeEnter).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onBeforeEnter, falseSerialized)
+ assertCalledWithEl(props.onBeforeEnter, trueSerialized, 1)
+ expect(props.onEnter).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onEnter, falseSerialized)
+ assertCalledWithEl(props.onEnter, trueSerialized, 1)
+ expect(props.onAfterEnter).not.toHaveBeenCalled()
+ expect(props.onEnterCancelled).not.toHaveBeenCalled()
+
+ // calling the enter done for false branch does fire the afterEnter
+ // hook, but should have no other effects since stale branch has already
+ // left
+ cbs.doneEnter[falseSerialized]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onAfterEnter, falseSerialized)
+
+ // leave should not start for either branch
+ expect(props.onBeforeLeave).not.toHaveBeenCalled()
+ expect(props.onLeave).not.toHaveBeenCalled()
+ expect(props.onAfterLeave).not.toHaveBeenCalled()
+
+ cbs.doneEnter[trueSerialized]()
+ expect(props.onAfterEnter).toHaveBeenCalledTimes(2)
+ assertCalledWithEl(props.onAfterEnter, trueSerialized, 1)
+ // should start leave for false branch
+ expect(props.onBeforeLeave).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onBeforeLeave, falseSerialized)
+ expect(props.onLeave).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onLeave, falseSerialized)
+ expect(props.onAfterLeave).not.toHaveBeenCalled()
+ // finish leave
+ cbs.doneLeave[falseSerialized]()
+ expect(serializeInner(root)).toBe(trueSerialized)
+ expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
+ assertCalledWithEl(props.onAfterLeave, falseSerialized)
+
+ assertCalls(props, {
+ onBeforeEnter: 2,
+ onEnter: 2,
+ onAfterEnter: 2,
+ onEnterCancelled: 0,
+ onBeforeLeave: 1,
+ onLeave: 1,
+ onAfterLeave: 1,
+ onLeaveCancelled: 0
+ })
+ }
+
+ test('w/ elements', async () => {
+ await runTestWithElements(testInOutBeforeFinish)
+ })
+
+ test('w/ components', async () => {
+ await runTestWithComponents(testInOutBeforeFinish)
+ })
+ })
describe('with KeepAlive', () => {
// TODO
diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts
index 46739ded4..0513cdb1a 100644
--- a/packages/runtime-core/src/components/BaseTransition.ts
+++ b/packages/runtime-core/src/components/BaseTransition.ts
@@ -48,7 +48,11 @@ export interface TransitionHooks {
enter(el: object): void
leave(el: object, remove: () => void): void
afterLeave?(): void
- delayLeave?(delayedLeave: () => void): void
+ delayLeave?(
+ el: object,
+ earlyRemove: () => void,
+ delayedLeave: () => void
+ ): void
delayedLeave?(): void
}
@@ -174,7 +178,22 @@ const BaseTransitionImpl = {
return emptyPlaceholder(child)
} else if (mode === 'in-out') {
delete prevHooks.delayedLeave
- leavingHooks.delayLeave = delayedLeave => {
+ leavingHooks.delayLeave = (
+ el: TransitionElement,
+ earlyRemove,
+ delayedLeave
+ ) => {
+ const leavingVNodesCache = getLeavingNodesForType(
+ state,
+ oldInnerChild
+ )
+ leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild
+ // early removal callback
+ el._leaveCb = () => {
+ earlyRemove()
+ el._leaveCb = undefined
+ delete enterHooks.delayedLeave
+ }
enterHooks.delayedLeave = delayedLeave
}
}
@@ -211,6 +230,19 @@ export const BaseTransition = (BaseTransitionImpl as any) as {
}
}
+function getLeavingNodesForType(
+ state: TransitionState,
+ vnode: VNode
+): Record {
+ const { leavingVNodes } = state
+ let leavingVNodesCache = leavingVNodes.get(vnode.type)!
+ if (!leavingVNodesCache) {
+ leavingVNodesCache = Object.create(null)
+ leavingVNodes.set(vnode.type, leavingVNodesCache)
+ }
+ return leavingVNodesCache
+}
+
// The transition hooks are attached to the vnode as vnode.transition
// and will be called at appropriate timing in the renderer.
function resolveTransitionHooks(
@@ -231,12 +263,7 @@ function resolveTransitionHooks(
callHook: TransitionHookCaller
): TransitionHooks {
const key = String(vnode.key)
- const { leavingVNodes } = state
- let leavingVNodesCache = leavingVNodes.get(vnode.type)!
- if (!leavingVNodesCache) {
- leavingVNodesCache = Object.create(null)
- leavingVNodes.set(vnode.type, leavingVNodesCache)
- }
+ const leavingVNodesCache = getLeavingNodesForType(state, vnode)
const hooks: TransitionHooks = {
persisted,
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index 6a91d7e60..63a660562 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -1430,14 +1430,15 @@ export function createRenderer<
queuePostRenderEffect(() => transition!.enter(el!), parentSuspense)
} else {
const { leave, delayLeave, afterLeave } = transition!
+ const remove = () => hostInsert(el!, container, anchor)
const performLeave = () => {
leave(el!, () => {
- hostInsert(el!, container, anchor)
+ remove()
afterLeave && afterLeave()
})
}
if (delayLeave) {
- delayLeave(performLeave)
+ delayLeave(el!, remove, performLeave)
} else {
performLeave()
}
@@ -1526,7 +1527,7 @@ export function createRenderer<
const { leave, delayLeave } = transition
const performLeave = () => leave(el!, remove)
if (delayLeave) {
- delayLeave(performLeave)
+ delayLeave(vnode.el!, remove, performLeave)
} else {
performLeave()
}