diff --git a/packages/runtime-core/__tests__/components/Portal.spec.ts b/packages/runtime-core/__tests__/components/Portal.spec.ts index 229732bd21d..651c87396be 100644 --- a/packages/runtime-core/__tests__/components/Portal.spec.ts +++ b/packages/runtime-core/__tests__/components/Portal.spec.ts @@ -8,7 +8,7 @@ import { ref, nextTick } from '@vue/runtime-test' -import { createVNode } from '../../src/vnode' +import { createVNode, Fragment } from '../../src/vnode' describe('renderer: portal', () => { test('should work', () => { @@ -24,7 +24,7 @@ describe('renderer: portal', () => { ) expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"` + `"
root
"` ) expect(serializeInner(target)).toMatchInlineSnapshot( `"
teleported
"` @@ -46,7 +46,7 @@ describe('renderer: portal', () => { ) expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"` + `"
root
"` ) expect(serializeInner(targetA)).toMatchInlineSnapshot( `"
teleported
"` @@ -57,7 +57,7 @@ describe('renderer: portal', () => { await nextTick() expect(serializeInner(root)).toMatchInlineSnapshot( - `"
root
"` + `"
root
"` ) expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`) expect(serializeInner(targetB)).toMatchInlineSnapshot( @@ -122,7 +122,7 @@ describe('renderer: portal', () => { ) expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"` + `"
"` ) expect(serializeInner(target)).toMatchInlineSnapshot(`"
one
two"`) @@ -141,7 +141,7 @@ describe('renderer: portal', () => { // toggling render(h('div', [null, h(Portal, { target }, 'three')]), root) expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"` + `"
"` ) expect(serializeInner(target)).toMatchInlineSnapshot(`"three"`) @@ -154,7 +154,7 @@ describe('renderer: portal', () => { root ) expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"` + `"
"` ) // should append expect(serializeInner(target)).toMatchInlineSnapshot( @@ -170,10 +170,133 @@ describe('renderer: portal', () => { root ) expect(serializeInner(root)).toMatchInlineSnapshot( - `"
"` + `"
"` ) expect(serializeInner(target)).toMatchInlineSnapshot( `"
one
two
"` ) }) + + test('disabled', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + + const renderWithDisabled = (disabled: boolean) => { + return h(Fragment, [ + h(Portal, { target, disabled }, h('div', 'teleported')), + h('div', 'root') + ]) + } + + render(renderWithDisabled(false), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) + + render(renderWithDisabled(true), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
root
"` + ) + expect(serializeInner(target)).toBe(``) + + // toggle back + render(renderWithDisabled(false), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) + }) + + test('moving portal while enabled', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + + render( + h(Fragment, [ + h(Portal, { target }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) + + render( + h(Fragment, [ + h('div', 'root'), + h(Portal, { target }, h('div', 'teleported')) + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) + + render( + h(Fragment, [ + h(Portal, { target }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) + }) + + test('moving portal while disabled', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + + render( + h(Fragment, [ + h(Portal, { target, disabled: true }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
root
"` + ) + expect(serializeInner(target)).toBe('') + + render( + h(Fragment, [ + h('div', 'root'), + h(Portal, { target, disabled: true }, h('div', 'teleported')) + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
teleported
"` + ) + expect(serializeInner(target)).toBe('') + + render( + h(Fragment, [ + h(Portal, { target, disabled: true }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
teleported
root
"` + ) + expect(serializeInner(target)).toBe('') + }) }) diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index c2506309340..1e62746ebd9 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -206,7 +206,6 @@ const KeepAliveImpl = { if (cachedVNode) { // copy over mounted state vnode.el = cachedVNode.el - vnode.anchor = cachedVNode.anchor vnode.component = cachedVNode.component if (vnode.transition) { // recursively update transition hooks on subTree diff --git a/packages/runtime-core/src/components/Portal.ts b/packages/runtime-core/src/components/Portal.ts index 34afd38d460..370b43bb0e6 100644 --- a/packages/runtime-core/src/components/Portal.ts +++ b/packages/runtime-core/src/components/Portal.ts @@ -4,8 +4,7 @@ import { RendererInternals, MoveType, RendererElement, - RendererNode, - RendererOptions + RendererNode } from '../renderer' import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode' import { isString, ShapeFlags } from '@vue/shared' @@ -15,6 +14,52 @@ export const isPortal = (type: any): boolean => type.__isPortal export interface PortalProps { target: string | object + disabled?: boolean +} + +export const enum PortalMoveTypes { + TARGET_CHANGE, + TOGGLE, // enable / disable + REORDER // moved in the main view +} + +const movePortal = ( + vnode: VNode, + container: RendererElement, + parentAnchor: RendererNode | null, + { o: { insert }, m: move }: RendererInternals, + moveType: PortalMoveTypes = PortalMoveTypes.REORDER +) => { + // move target anchor if this is a target change. + if (moveType === PortalMoveTypes.TARGET_CHANGE) { + insert(vnode.targetAnchor!, container, parentAnchor) + } + const { el, anchor, shapeFlag, children, props } = vnode + const isReorder = moveType === PortalMoveTypes.REORDER + // move main view anchor if this is a re-order. + if (isReorder) { + insert(el!, container, parentAnchor) + } + // if this is a re-order and portal is enabled (content is in target) + // do not move children. So the opposite is: only move children if this + // is not a reorder, or the portal is disabled + if (!isReorder || (props && props.disabled)) { + // Portal has either Array children or no children. + if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { + for (let i = 0; i < (children as VNode[]).length; i++) { + move( + (children as VNode[])[i], + container, + parentAnchor, + MoveType.REORDER + ) + } + } + } + // move main view anchor if this is a re-order. + if (isReorder) { + insert(anchor!, container, parentAnchor) + } } export const PortalImpl = { @@ -28,60 +73,83 @@ export const PortalImpl = { parentSuspense: SuspenseBoundary | null, isSVG: boolean, optimized: boolean, - { + internals: RendererInternals + ) { + const { mc: mountChildren, pc: patchChildren, pbc: patchBlockChildren, - m: move, o: { insert, querySelector, createText, createComment } - }: RendererInternals - ) { + } = internals + const targetSelector = n2.props && n2.props.target + const disabled = n2.props && n2.props.disabled const { shapeFlag, children } = n2 if (n1 == null) { - // insert an empty node as the placeholder for the portal - insert((n2.el = createComment(`portal`)), container, anchor) if (__DEV__ && isString(targetSelector) && !querySelector) { warn( `Current renderer does not support string target for Portals. ` + `(missing querySelector renderer option)` ) } + // insert anchors in the main view + const placeholder = (n2.el = __DEV__ + ? createComment('portal start') + : createText('')) + const mainAnchor = (n2.anchor = __DEV__ + ? createComment('portal end') + : createText('')) + insert(placeholder, container, anchor) + insert(mainAnchor, container, anchor) + // portal content needs an anchor to support patching multiple portals + // appending to the same target element. const target = (n2.target = isString(targetSelector) ? querySelector!(targetSelector) : targetSelector) - // portal content needs an anchor to support patching multiple portals - // appending to the same target element. - const portalAnchor = (n2.anchor = createText('')) + const targetAnchor = (n2.targetAnchor = createText('')) if (target) { - insert(portalAnchor, target) + insert(targetAnchor, target) + } else if (__DEV__) { + warn('Invalid Portal target on mount:', target, `(${typeof target})`) + } + + const mount = (container: RendererElement, anchor: RendererNode) => { // Portal *always* has Array children. This is enforced in both the // compiler and vnode children normalization. if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { mountChildren( children as VNodeArrayChildren, - target, - portalAnchor, + container, + anchor, parentComponent, parentSuspense, isSVG, optimized ) } - } else if (__DEV__) { - warn('Invalid Portal target on mount:', target, `(${typeof target})`) + } + + if (disabled) { + mount(container, mainAnchor) + } else if (target) { + mount(target, targetAnchor) } } else { // update content n2.el = n1.el + const mainAnchor = (n2.anchor = n1.anchor)! const target = (n2.target = n1.target)! - const portalAnchor = (n2.anchor = n1.anchor)! + const targetAnchor = (n2.targetAnchor = n1.targetAnchor)! + const wasDisabled = n1.props && n1.props.disabled + const currentContainer = wasDisabled ? container : target + const currentAnchor = wasDisabled ? mainAnchor : targetAnchor + if (n2.dynamicChildren) { // fast path when the portal happens to be a block root patchBlockChildren( n1.dynamicChildren!, n2.dynamicChildren, - container, + currentContainer, parentComponent, parentSuspense, isSVG @@ -90,23 +158,57 @@ export const PortalImpl = { patchChildren( n1, n2, - target, - portalAnchor, + currentContainer, + currentAnchor, parentComponent, parentSuspense, isSVG ) } - // target changed - if (targetSelector !== (n1.props && n1.props.target)) { - const nextTarget = (n2.target = isString(targetSelector) - ? querySelector!(targetSelector) - : targetSelector) - if (nextTarget) { - movePortal(n2, nextTarget, null, insert, move) - } else if (__DEV__) { - warn('Invalid Portal target on update:', target, `(${typeof target})`) + if (disabled) { + if (!wasDisabled) { + // enabled -> disabled + // move into main container + movePortal( + n2, + container, + mainAnchor, + internals, + PortalMoveTypes.TOGGLE + ) + } + } else { + // target changed + if (targetSelector !== (n1.props && n1.props.target)) { + const nextTarget = (n2.target = isString(targetSelector) + ? querySelector!(targetSelector) + : targetSelector) + if (nextTarget) { + movePortal( + n2, + nextTarget, + null, + internals, + PortalMoveTypes.TARGET_CHANGE + ) + } else if (__DEV__) { + warn( + 'Invalid Portal target on update:', + target, + `(${typeof target})` + ) + } + } else if (wasDisabled) { + // disabled -> enabled + // move into portal target + movePortal( + n2, + target, + targetAnchor, + internals, + PortalMoveTypes.TOGGLE + ) } } } @@ -123,25 +225,9 @@ export const PortalImpl = { remove((children as VNode[])[i]) } } - } -} + }, -const movePortal = ( - vnode: VNode, - nextTarget: RendererElement, - anchor: RendererNode | null, - insert: RendererOptions['insert'], - move: RendererInternals['m'] -) => { - const { anchor: portalAnchor, shapeFlag, children } = vnode - // move content. - // Portal has either Array children or no children. - insert(portalAnchor!, nextTarget, anchor) - if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { - for (let i = 0; i < (children as VNode[]).length; i++) { - move((children as VNode[])[i], nextTarget, portalAnchor, MoveType.REORDER) - } - } + move: movePortal } // Force-casted public typing for h and TSX props inference diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 58389db17ca..976022fcfe5 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1606,54 +1606,61 @@ function baseCreateRenderer( vnode, container, anchor, - type, + moveType, parentSuspense = null ) => { - if (vnode.shapeFlag & ShapeFlags.COMPONENT) { - move(vnode.component!.subTree, container, anchor, type) + const { el, type, transition, children, shapeFlag } = vnode + if (shapeFlag & ShapeFlags.COMPONENT) { + move(vnode.component!.subTree, container, anchor, moveType) return } - if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { - vnode.suspense!.move(container, anchor, type) + + if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { + vnode.suspense!.move(container, anchor, moveType) return } - if (vnode.type === Fragment) { - hostInsert(vnode.el!, container, anchor) - const children = vnode.children as VNode[] - for (let i = 0; i < children.length; i++) { - move(children[i], container, anchor, type) + + if (shapeFlag & ShapeFlags.PORTAL) { + ;(type as typeof PortalImpl).move(vnode, container, anchor, internals) + return + } + + if (type === Fragment) { + hostInsert(el!, container, anchor) + for (let i = 0; i < (children as VNode[]).length; i++) { + move((children as VNode[])[i], container, anchor, moveType) } hostInsert(vnode.anchor!, container, anchor) - } else { - // Plain element - const { el, transition, shapeFlag } = vnode - const needTransition = - type !== MoveType.REORDER && - shapeFlag & ShapeFlags.ELEMENT && - transition - if (needTransition) { - if (type === MoveType.ENTER) { - transition!.beforeEnter(el!) - hostInsert(el!, container, anchor) - queuePostRenderEffect(() => transition!.enter(el!), parentSuspense) + return + } + + // single nodes + const needTransition = + moveType !== MoveType.REORDER && + shapeFlag & ShapeFlags.ELEMENT && + transition + if (needTransition) { + if (moveType === MoveType.ENTER) { + transition!.beforeEnter(el!) + hostInsert(el!, container, anchor) + queuePostRenderEffect(() => transition!.enter(el!), parentSuspense) + } else { + const { leave, delayLeave, afterLeave } = transition! + const remove = () => hostInsert(el!, container, anchor) + const performLeave = () => { + leave(el!, () => { + remove() + afterLeave && afterLeave() + }) + } + if (delayLeave) { + delayLeave(el!, remove, performLeave) } else { - const { leave, delayLeave, afterLeave } = transition! - const remove = () => hostInsert(el!, container, anchor) - const performLeave = () => { - leave(el!, () => { - remove() - afterLeave && afterLeave() - }) - } - if (delayLeave) { - delayLeave(el!, remove, performLeave) - } else { - performLeave() - } + performLeave() } - } else { - hostInsert(el!, container, anchor) } + } else { + hostInsert(el!, container, anchor) } } @@ -1839,7 +1846,7 @@ function baseCreateRenderer( if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { return vnode.suspense!.next() } - return hostNextSibling((vnode.type === Fragment ? vnode.anchor : vnode.el)!) + return hostNextSibling((vnode.anchor || vnode.el)!) } const setRef = ( diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 605aca51cfe..0396323c5bd 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -114,6 +114,7 @@ export interface VNode { el: HostNode | null anchor: HostNode | null // fragment anchor target: HostElement | null // portal target + targetAnchor: HostNode | null // portal target anchor // optimization only shapeFlag: number @@ -308,6 +309,7 @@ function _createVNode( el: null, anchor: null, target: null, + targetAnchor: null, shapeFlag, patchFlag, dynamicProps, @@ -357,6 +359,7 @@ export function cloneVNode( scopeId: vnode.scopeId, children: vnode.children, target: vnode.target, + targetAnchor: vnode.targetAnchor, shapeFlag: vnode.shapeFlag, patchFlag: vnode.patchFlag, dynamicProps: vnode.dynamicProps,