diff --git a/packages/compiler-core/__tests__/transforms/transformElement.spec.ts b/packages/compiler-core/__tests__/transforms/transformElement.spec.ts index 078ea9bc..69cbce00 100644 --- a/packages/compiler-core/__tests__/transforms/transformElement.spec.ts +++ b/packages/compiler-core/__tests__/transforms/transformElement.spec.ts @@ -347,12 +347,8 @@ describe('compiler: element transform', () => { expect(node.arguments).toMatchObject([ KEEP_ALIVE, `null`, - createObjectMatcher({ - default: { - type: NodeTypes.JS_FUNCTION_EXPRESSION - }, - _compiled: `[true]` - }) + // keep-alive should not compile content to slots + [{ type: NodeTypes.ELEMENT, tag: 'span' }] ]) } diff --git a/packages/compiler-core/src/transforms/transformElement.ts b/packages/compiler-core/src/transforms/transformElement.ts index e440bb66..8e71a714 100644 --- a/packages/compiler-core/src/transforms/transformElement.ts +++ b/packages/compiler-core/src/transforms/transformElement.ts @@ -138,8 +138,11 @@ export const transformElement: NodeTransform = (node, context) => { if (!hasProps) { args.push(`null`) } - // Portal should have normal children instead of slots - if (isComponent && !isPortal) { + // Portal & KeepAlive should have normal children instead of slots + // Portal is not a real component has dedicated handling in the renderer + // KeepAlive should not track its own deps so that it can be used inside + // Transition + if (isComponent && !isPortal && !isKeepAlive) { const { slots, hasDynamicSlots } = buildSlots(node, context) args.push(slots) if (hasDynamicSlots) { diff --git a/packages/runtime-core/src/componentRenderUtils.ts b/packages/runtime-core/src/componentRenderUtils.ts index 7663677c..ed62cce2 100644 --- a/packages/runtime-core/src/componentRenderUtils.ts +++ b/packages/runtime-core/src/componentRenderUtils.ts @@ -91,7 +91,8 @@ export function renderComponentRoot( if ( __DEV__ && !(result.shapeFlag & ShapeFlags.COMPONENT) && - !(result.shapeFlag & ShapeFlags.ELEMENT) + !(result.shapeFlag & ShapeFlags.ELEMENT) && + result.type !== Comment ) { warn( `Component inside renders non-element root node ` + diff --git a/packages/runtime-core/src/componentSlots.ts b/packages/runtime-core/src/componentSlots.ts index 52dd6f4f..5821f38e 100644 --- a/packages/runtime-core/src/componentSlots.ts +++ b/packages/runtime-core/src/componentSlots.ts @@ -1,8 +1,9 @@ import { ComponentInternalInstance, currentInstance } from './component' import { VNode, NormalizedChildren, normalizeVNode, VNodeChild } from './vnode' -import { isArray, isFunction } from '@vue/shared' +import { isArray, isFunction, EMPTY_OBJ } from '@vue/shared' import { ShapeFlags } from './shapeFlags' import { warn } from './warning' +import { isKeepAlive } from './components/KeepAlive' export type Slot = (...args: any[]) => VNode[] @@ -65,7 +66,7 @@ export function resolveSlots( } } else if (children !== null) { // non slot object children (direct value) passed to a component - if (__DEV__) { + if (__DEV__ && !isKeepAlive(instance.vnode)) { warn( `Non-function value encountered for default slot. ` + `Prefer function slots for better performance.` @@ -74,7 +75,5 @@ export function resolveSlots( const normalized = normalizeSlotValue(children) slots = { default: () => normalized } } - if (slots !== void 0) { - instance.slots = slots - } + instance.slots = slots || EMPTY_OBJ } diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index 528e9e90..34403cfc 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -3,7 +3,13 @@ import { SetupContext, ComponentOptions } from '../component' -import { cloneVNode, Comment, isSameVNodeType, VNode } from '../vnode' +import { + cloneVNode, + Comment, + isSameVNodeType, + VNode, + VNodeChildren +} from '../vnode' import { warn } from '../warning' import { isKeepAlive } from './KeepAlive' import { toRaw } from '@vue/reactivity' @@ -36,17 +42,38 @@ export interface BaseTransitionProps { onLeaveCancelled?: (el: any) => void } +export interface TransitionHooks { + persisted: boolean + beforeEnter(el: object): void + enter(el: object): void + leave(el: object, remove: () => void): void + afterLeave?(): void + delayLeave?(delayedLeave: () => void): void + delayedLeave?(): void +} + type TransitionHookCaller = ( hook: ((el: any) => void) | undefined, args?: any[] ) => void +type PendingCallback = (cancelled?: boolean) => void + interface TransitionState { isMounted: boolean isLeaving: boolean isUnmounting: boolean - pendingEnter?: (cancelled?: boolean) => void - pendingLeave?: (cancelled?: boolean) => void + // Track pending leave callbacks for children of the same key. + // This is used to force remove leaving a child when a new copy is entering. + leavingVNodes: Record +} + +interface TransitionElement { + // in persisted mode (e.g. v-show), the same element is toggled, so the + // pending enter/leave callbacks may need to cancalled if the state is toggled + // before it finishes. + _enterCb?: PendingCallback + _leaveCb?: PendingCallback } const BaseTransitionImpl = { @@ -56,7 +83,8 @@ const BaseTransitionImpl = { const state: TransitionState = { isMounted: false, isLeaving: false, - isUnmounting: false + isUnmounting: false, + leavingVNodes: Object.create(null) } onMounted(() => { state.isMounted = true @@ -84,7 +112,7 @@ const BaseTransitionImpl = { // warn multiple elements if (__DEV__ && children.length > 1) { warn( - ' can only be used on a single element. Use ' + + ' can only be used on a single element or component. Use ' + ' for lists.' ) } @@ -101,45 +129,53 @@ const BaseTransitionImpl = { // at this point children has a guaranteed length of 1. const child = children[0] if (state.isLeaving) { - return placeholder(child) + return emptyPlaceholder(child) } - let delayedLeave: (() => void) | undefined - const performDelayedLeave = () => delayedLeave && delayedLeave() + // in the case of , we need to + // compare the type of the kept-alive children. + const innerChild = getKeepAliveChild(child) + if (!innerChild) { + return emptyPlaceholder(child) + } - const transitionHooks = (child.transition = resolveTransitionHooks( + const enterHooks = (innerChild.transition = resolveTransitionHooks( + innerChild, rawProps, state, - callTransitionHook, - performDelayedLeave + callTransitionHook )) - // clone old subTree because we need to modify it const oldChild = instance.subTree - ? (instance.subTree = cloneVNode(instance.subTree)) - : null - + const oldInnerChild = oldChild && getKeepAliveChild(oldChild) // handle mode if ( - oldChild && - !isSameVNodeType(child, oldChild) && - oldChild.type !== Comment + oldInnerChild && + oldInnerChild.type !== Comment && + !isSameVNodeType(innerChild, oldInnerChild) ) { + const prevHooks = oldInnerChild.transition! + const leavingHooks = resolveTransitionHooks( + oldInnerChild, + rawProps, + state, + callTransitionHook + ) // update old tree's hooks in case of dynamic transition - // need to do this recursively in case of HOCs - updateHOCTransitionData(oldChild, transitionHooks) + setTransitionHooks(oldInnerChild, leavingHooks) // switching between different views if (mode === 'out-in') { state.isLeaving = true // return placeholder node and queue update when leave finishes - transitionHooks.afterLeave = () => { + leavingHooks.afterLeave = () => { state.isLeaving = false instance.update() } - return placeholder(child) + return emptyPlaceholder(child) } else if (mode === 'in-out') { - transitionHooks.delayLeave = performLeave => { - delayedLeave = performLeave + delete prevHooks.delayedLeave + leavingHooks.delayLeave = delayedLeave => { + enterHooks.delayedLeave = delayedLeave } } } @@ -175,18 +211,10 @@ export const BaseTransition = (BaseTransitionImpl as any) as { } } -export interface TransitionHooks { - persisted: boolean - beforeEnter(el: object): void - enter(el: object): void - leave(el: object, remove: () => void): void - afterLeave?(): void - delayLeave?(performLeave: () => void): void -} - // The transition hooks are attached to the vnode as vnode.transition // and will be called at appropriate timing in the renderer. function resolveTransitionHooks( + vnode: VNode, { appear, persisted = false, @@ -200,36 +228,51 @@ function resolveTransitionHooks( onLeaveCancelled }: BaseTransitionProps, state: TransitionState, - callHook: TransitionHookCaller, - performDelayedLeave: () => void + callHook: TransitionHookCaller ): TransitionHooks { - return { + const { leavingVNodes } = state + const key = String(vnode.key) + + const hooks: TransitionHooks = { persisted, - beforeEnter(el) { - if (state.pendingLeave) { - state.pendingLeave(true /* cancelled */) - } + beforeEnter(el: TransitionElement) { if (!appear && !state.isMounted) { return } + // for same element (v-show) + if (el._leaveCb) { + el._leaveCb(true /* cancelled */) + } + // for toggled element with same key (v-if) + const leavingVNode = leavingVNodes[key] + if ( + leavingVNode && + isSameVNodeType(vnode, leavingVNode) && + leavingVNode.el._leaveCb + ) { + // force early removal (not cancelled) + leavingVNode.el._leaveCb() + } callHook(onBeforeEnter, [el]) }, - enter(el) { + enter(el: TransitionElement) { if (!appear && !state.isMounted) { return } let called = false - const afterEnter = (state.pendingEnter = (cancelled?) => { + const afterEnter = (el._enterCb = (cancelled?) => { if (called) return called = true if (cancelled) { callHook(onEnterCancelled, [el]) } else { callHook(onAfterEnter, [el]) - performDelayedLeave() } - state.pendingEnter = undefined + if (hooks.delayedLeave) { + hooks.delayedLeave() + } + el._enterCb = undefined }) if (onEnter) { onEnter(el, afterEnter) @@ -238,16 +281,17 @@ function resolveTransitionHooks( } }, - leave(el, remove) { - if (state.pendingEnter) { - state.pendingEnter(true /* cancelled */) + leave(el: TransitionElement, remove) { + const key = String(vnode.key) + if (el._enterCb) { + el._enterCb(true /* cancelled */) } if (state.isUnmounting) { return remove() } callHook(onBeforeLeave, [el]) let called = false - const afterLeave = (state.pendingLeave = (cancelled?) => { + const afterLeave = (el._leaveCb = (cancelled?) => { if (called) return called = true remove() @@ -256,8 +300,10 @@ function resolveTransitionHooks( } else { callHook(onAfterLeave, [el]) } - state.pendingLeave = undefined + el._leaveCb = undefined + delete leavingVNodes[key] }) + leavingVNodes[key] = vnode if (onLeave) { onLeave(el, afterLeave) } else { @@ -265,13 +311,15 @@ function resolveTransitionHooks( } } } + + return hooks } // the placeholder really only handles one special case: KeepAlive // in the case of a KeepAlive in a leave phase we need to return a KeepAlive // placeholder with empty content to avoid the KeepAlive instance from being // unmounted. -function placeholder(vnode: VNode): VNode | undefined { +function emptyPlaceholder(vnode: VNode): VNode | undefined { if (isKeepAlive(vnode)) { vnode = cloneVNode(vnode) vnode.children = null @@ -279,10 +327,18 @@ function placeholder(vnode: VNode): VNode | undefined { } } -function updateHOCTransitionData(vnode: VNode, data: TransitionHooks) { - if (vnode.shapeFlag & ShapeFlags.COMPONENT) { - updateHOCTransitionData(vnode.component!.subTree, data) +function getKeepAliveChild(vnode: VNode): VNode | undefined { + return isKeepAlive(vnode) + ? vnode.children + ? ((vnode.children as VNodeChildren)[0] as VNode) + : undefined + : vnode +} + +export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks) { + if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) { + setTransitionHooks(vnode.component.subTree, hooks) } else { - vnode.transition = data + vnode.transition = hooks } } diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 3040b453..10c63891 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -17,8 +17,10 @@ import { SuspenseBoundary } from './Suspense' import { RendererInternals, queuePostRenderEffect, - invokeHooks + invokeHooks, + MoveType } from '../renderer' +import { setTransitionHooks } from './BaseTransition' type MatchPattern = string | RegExp | string[] | RegExp[] @@ -80,7 +82,7 @@ const KeepAliveImpl = { const storageContainer = createElement('div') sink.activate = (vnode, container, anchor) => { - move(vnode, container, anchor) + move(vnode, container, anchor, MoveType.ENTER, parentSuspense) queuePostRenderEffect(() => { const component = vnode.component! component.isDeactivated = false @@ -91,7 +93,7 @@ const KeepAliveImpl = { } sink.deactivate = (vnode: VNode) => { - move(vnode, storageContainer, null) + move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense) queuePostRenderEffect(() => { const component = vnode.component! if (component.da !== null) { @@ -188,6 +190,10 @@ const KeepAliveImpl = { vnode.el = cached.el vnode.anchor = cached.anchor vnode.component = cached.component + if (vnode.transition) { + // recursively update transition hooks on subTree + setTransitionHooks(vnode, vnode.transition!) + } // avoid vnode being mounted as fresh vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE // make this key the freshest diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 101d86f7..d075ce12 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -3,7 +3,7 @@ import { ShapeFlags } from '../shapeFlags' import { isFunction, isArray } from '@vue/shared' import { ComponentInternalInstance, handleSetupResult } from '../component' import { Slots } from '../componentSlots' -import { RendererInternals } from '../renderer' +import { RendererInternals, MoveType } from '../renderer' import { queuePostFlushCb, queueJob } from '../scheduler' import { updateHOCHostEl } from '../componentRenderUtils' import { handleError, ErrorCodes } from '../errorHandling' @@ -213,7 +213,7 @@ export interface SuspenseBoundary< effects: Function[] resolve(): void recede(): void - move(container: HostElement, anchor: HostNode | null): void + move(container: HostElement, anchor: HostNode | null, type: MoveType): void next(): HostNode | null registerDep( instance: ComponentInternalInstance, @@ -299,7 +299,7 @@ function createSuspenseBoundary( unmount(fallbackTree as VNode, parentComponent, suspense, true) } // move content from off-dom container to actual container - move(subTree as VNode, container, anchor) + move(subTree as VNode, container, anchor, MoveType.ENTER) const el = (vnode.el = (subTree as VNode).el!) // suspense as the root node of a component... if (parentComponent && parentComponent.subTree === vnode) { @@ -346,7 +346,7 @@ function createSuspenseBoundary( // move content tree back to the off-dom container const anchor = next(subTree) - move(subTree as VNode, hiddenContainer, null) + move(subTree as VNode, hiddenContainer, null, MoveType.LEAVE) // remount the fallback tree patch( null, @@ -372,11 +372,12 @@ function createSuspenseBoundary( } }, - move(container, anchor) { + move(container, anchor, type) { move( suspense.isResolved ? suspense.subTree : suspense.fallbackTree, container, - anchor + anchor, + type ) suspense.container = container }, diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index ec79c1e3..6a91d7e6 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -109,12 +109,20 @@ export interface RendererInternals { move: ( vnode: VNode, container: HostElement, - anchor: HostNode | null + anchor: HostNode | null, + type: MoveType, + parentSuspense?: SuspenseBoundary | null ) => void next: (vnode: VNode) => HostNode | null options: RendererOptions } +export const enum MoveType { + ENTER, + LEAVE, + REORDER +} + const prodEffectOptions = { scheduler: queueJob } @@ -367,9 +375,6 @@ export function createRenderer< invokeDirectiveHook(props.onVnodeBeforeMount, parentComponent, vnode) } } - if (transition != null && !transition.persisted) { - transition.beforeEnter(el) - } if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { hostSetElementText(el, vnode.children as string) } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { @@ -383,6 +388,9 @@ export function createRenderer< optimized || vnode.dynamicChildren !== null ) } + if (transition != null && !transition.persisted) { + transition.beforeEnter(el) + } hostInsert(el, container, anchor) const vnodeMountedHook = props && props.onVnodeMounted if ( @@ -747,7 +755,12 @@ export function createRenderer< hostSetElementText(nextTarget, children as string) } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { for (let i = 0; i < (children as HostVNode[]).length; i++) { - move((children as HostVNode[])[i], nextTarget, null) + move( + (children as HostVNode[])[i], + nextTarget, + null, + MoveType.REORDER + ) } } } else if (__DEV__) { @@ -1372,7 +1385,7 @@ export function createRenderer< // There is no stable subsequence (e.g. a reverse) // OR current node is not among the stable sequence if (j < 0 || i !== increasingNewIndexSequence[j]) { - move(nextChild, container, anchor) + move(nextChild, container, anchor, MoveType.REORDER) } else { j-- } @@ -1384,25 +1397,54 @@ export function createRenderer< function move( vnode: HostVNode, container: HostElement, - anchor: HostNode | null + anchor: HostNode | null, + type: MoveType, + parentSuspense: HostSuspenseBoundary | null = null ) { if (vnode.shapeFlag & ShapeFlags.COMPONENT) { - move(vnode.component!.subTree, container, anchor) + move(vnode.component!.subTree, container, anchor, type) return } if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { - vnode.suspense!.move(container, anchor) + vnode.suspense!.move(container, anchor, type) return } if (vnode.type === Fragment) { hostInsert(vnode.el!, container, anchor) const children = vnode.children as HostVNode[] for (let i = 0; i < children.length; i++) { - move(children[i], container, anchor) + move(children[i], container, anchor, type) } hostInsert(vnode.anchor!, container, anchor) } else { - hostInsert(vnode.el!, container, anchor) + // Plain element + const { el, transition, shapeFlag } = vnode + const needTransition = + type !== MoveType.REORDER && + shapeFlag & ShapeFlags.ELEMENT && + transition != null + if (needTransition) { + if (type === MoveType.ENTER) { + transition!.beforeEnter(el!) + hostInsert(el!, container, anchor) + queuePostRenderEffect(() => transition!.enter(el!), parentSuspense) + } else { + const { leave, delayLeave, afterLeave } = transition! + const performLeave = () => { + leave(el!, () => { + hostInsert(el!, container, anchor) + afterLeave && afterLeave() + }) + } + if (delayLeave) { + delayLeave(performLeave) + } else { + performLeave() + } + } + } else { + hostInsert(el!, container, anchor) + } } }