From aafb880a0a9e023b62cf8fb3ae269b31f22ac84e Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 27 Mar 2020 18:42:57 -0400 Subject: [PATCH] feat(portal): support multiple portal appending to same target --- .../src/transforms/transformElement.ts | 4 +- .../__tests__/components/Portal.spec.ts | 159 +++++++++++++----- .../__snapshots__/Portal.spec.ts.snap | 23 --- .../runtime-core/src/components/Portal.ts | 68 +++++--- packages/runtime-core/src/renderer.ts | 2 +- packages/runtime-core/src/vnode.ts | 17 +- 6 files changed, 178 insertions(+), 95 deletions(-) delete mode 100644 packages/runtime-core/__tests__/components/__snapshots__/Portal.spec.ts.snap diff --git a/packages/compiler-core/src/transforms/transformElement.ts b/packages/compiler-core/src/transforms/transformElement.ts index 4ae461caf54..ad7a119c431 100644 --- a/packages/compiler-core/src/transforms/transformElement.ts +++ b/packages/compiler-core/src/transforms/transformElement.ts @@ -124,7 +124,7 @@ export const transformElement: NodeTransform = (node, context) => { const shouldBuildAsSlots = isComponent && - // Portal is not a real component has dedicated handling in the renderer + // Portal is not a real component and has dedicated runtime handling vnodeTag !== PORTAL && // explained above. vnodeTag !== KEEP_ALIVE @@ -135,7 +135,7 @@ export const transformElement: NodeTransform = (node, context) => { if (hasDynamicSlots) { patchFlag |= PatchFlags.DYNAMIC_SLOTS } - } else if (node.children.length === 1) { + } else if (node.children.length === 1 && vnodeTag !== PORTAL) { const child = node.children[0] const type = child.type // check for dynamic text children diff --git a/packages/runtime-core/__tests__/components/Portal.spec.ts b/packages/runtime-core/__tests__/components/Portal.spec.ts index 6c5c36bfda4..229732bd21d 100644 --- a/packages/runtime-core/__tests__/components/Portal.spec.ts +++ b/packages/runtime-core/__tests__/components/Portal.spec.ts @@ -3,29 +3,32 @@ import { serializeInner, render, h, - defineComponent, Portal, Text, ref, - nextTick, - TestElement, - TestNode + nextTick } from '@vue/runtime-test' -import { VNodeArrayChildren, createVNode } from '../../src/vnode' +import { createVNode } from '../../src/vnode' describe('renderer: portal', () => { test('should work', () => { const target = nodeOps.createElement('div') const root = nodeOps.createElement('div') - const Comp = defineComponent(() => () => [ - h(Portal, { target }, h('div', 'teleported')), - h('div', 'root') - ]) - render(h(Comp), root) + render( + h(() => [ + h(Portal, { target }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) - expect(serializeInner(root)).toMatchSnapshot() - expect(serializeInner(target)).toMatchSnapshot() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) }) test('should update target', async () => { @@ -34,58 +37,70 @@ describe('renderer: portal', () => { const target = ref(targetA) const root = nodeOps.createElement('div') - const Comp = defineComponent(() => () => [ - h(Portal, { target: target.value }, h('div', 'teleported')), - h('div', 'root') - ]) - render(h(Comp), root) + render( + h(() => [ + h(Portal, { target: target.value }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) - expect(serializeInner(root)).toMatchSnapshot() - expect(serializeInner(targetA)).toMatchSnapshot() - expect(serializeInner(targetB)).toMatchSnapshot() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(targetA)).toMatchInlineSnapshot( + `"
teleported
"` + ) + expect(serializeInner(targetB)).toMatchInlineSnapshot(`""`) target.value = targetB await nextTick() - expect(serializeInner(root)).toMatchSnapshot() - expect(serializeInner(targetA)).toMatchSnapshot() - expect(serializeInner(targetB)).toMatchSnapshot() + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
root
"` + ) + expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`) + expect(serializeInner(targetB)).toMatchInlineSnapshot( + `"
teleported
"` + ) }) test('should update children', async () => { const target = nodeOps.createElement('div') const root = nodeOps.createElement('div') - const children = ref>([ - h('div', 'teleported') - ]) + const children = ref([h('div', 'teleported')]) - const Comp = defineComponent(() => () => - h(Portal, { target }, children.value) + render(h(Portal, { target }, children.value), root) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` ) - render(h(Comp), root) - - expect(serializeInner(target)).toMatchSnapshot() children.value = [] await nextTick() - expect(serializeInner(target)).toMatchSnapshot() + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) children.value = [createVNode(Text, null, 'teleported')] await nextTick() - expect(serializeInner(target)).toMatchSnapshot() + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
teleported
"` + ) }) test('should remove children when unmounted', () => { const target = nodeOps.createElement('div') const root = nodeOps.createElement('div') - const Comp = defineComponent(() => () => [ - h(Portal, { target }, h('div', 'teleported')), - h('div', 'root') - ]) - render(h(Comp), root) + render( + h(() => [ + h(Portal, { target }, h('div', 'teleported')), + h('div', 'root') + ]), + root + ) expect(serializeInner(target)).toMatchInlineSnapshot( `"
teleported
"` ) @@ -93,4 +108,72 @@ describe('renderer: portal', () => { render(null, root) expect(serializeInner(target)).toBe('') }) + + test('multiple portal with same target', () => { + const target = nodeOps.createElement('div') + const root = nodeOps.createElement('div') + + render( + h('div', [ + h(Portal, { target }, h('div', 'one')), + h(Portal, { target }, 'two') + ]), + root + ) + + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot(`"
one
two"`) + + // update existing content + render( + h('div', [ + h(Portal, { target }, [h('div', 'one'), h('div', 'two')]), + h(Portal, { target }, 'three') + ]), + root + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
one
two
three"` + ) + + // toggling + render(h('div', [null, h(Portal, { target }, 'three')]), root) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot(`"three"`) + + // toggle back + render( + h('div', [ + h(Portal, { target }, [h('div', 'one'), h('div', 'two')]), + h(Portal, { target }, 'three') + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"` + ) + // should append + expect(serializeInner(target)).toMatchInlineSnapshot( + `"three
one
two
"` + ) + + // toggle the other portal + render( + h('div', [ + h(Portal, { target }, [h('div', 'one'), h('div', 'two')]), + null + ]), + root + ) + expect(serializeInner(root)).toMatchInlineSnapshot( + `"
"` + ) + expect(serializeInner(target)).toMatchInlineSnapshot( + `"
one
two
"` + ) + }) }) diff --git a/packages/runtime-core/__tests__/components/__snapshots__/Portal.spec.ts.snap b/packages/runtime-core/__tests__/components/__snapshots__/Portal.spec.ts.snap deleted file mode 100644 index 4a47a585826..00000000000 --- a/packages/runtime-core/__tests__/components/__snapshots__/Portal.spec.ts.snap +++ /dev/null @@ -1,23 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renderer: portal should update children 1`] = `"
teleported
"`; - -exports[`renderer: portal should update children 2`] = `""`; - -exports[`renderer: portal should update children 3`] = `"teleported"`; - -exports[`renderer: portal should update target 1`] = `"
root
"`; - -exports[`renderer: portal should update target 2`] = `"
teleported
"`; - -exports[`renderer: portal should update target 3`] = `""`; - -exports[`renderer: portal should update target 4`] = `"
root
"`; - -exports[`renderer: portal should update target 5`] = `""`; - -exports[`renderer: portal should update target 6`] = `"
teleported
"`; - -exports[`renderer: portal should work 1`] = `"
root
"`; - -exports[`renderer: portal should work 2`] = `"
teleported
"`; diff --git a/packages/runtime-core/src/components/Portal.ts b/packages/runtime-core/src/components/Portal.ts index beac7e8ad06..34afd38d460 100644 --- a/packages/runtime-core/src/components/Portal.ts +++ b/packages/runtime-core/src/components/Portal.ts @@ -4,10 +4,11 @@ import { RendererInternals, MoveType, RendererElement, - RendererNode + RendererNode, + RendererOptions } from '../renderer' import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode' -import { isString, ShapeFlags, PatchFlags } from '@vue/shared' +import { isString, ShapeFlags } from '@vue/shared' import { warn } from '../warning' export const isPortal = (type: any): boolean => type.__isPortal @@ -32,11 +33,11 @@ export const PortalImpl = { pc: patchChildren, pbc: patchBlockChildren, m: move, - o: { insert, querySelector, setElementText, createComment } + o: { insert, querySelector, createText, createComment } }: RendererInternals ) { const targetSelector = n2.props && n2.props.target - const { patchFlag, shapeFlag, children } = n2 + const { shapeFlag, children } = n2 if (n1 == null) { // insert an empty node as the placeholder for the portal insert((n2.el = createComment(`portal`)), container, anchor) @@ -49,14 +50,18 @@ export const PortalImpl = { 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('')) if (target) { - if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { - setElementText(target, children as string) - } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { + insert(portalAnchor, target) + // 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, - null, + portalAnchor, parentComponent, parentSuspense, isSVG, @@ -67,12 +72,11 @@ export const PortalImpl = { warn('Invalid Portal target on mount:', target, `(${typeof target})`) } } else { - n2.el = n1.el // update content + n2.el = n1.el const target = (n2.target = n1.target)! - if (patchFlag === PatchFlags.TEXT) { - setElementText(target, children as string) - } else if (n2.dynamicChildren) { + const portalAnchor = (n2.anchor = n1.anchor)! + if (n2.dynamicChildren) { // fast path when the portal happens to be a block root patchBlockChildren( n1.dynamicChildren!, @@ -87,27 +91,20 @@ export const PortalImpl = { n1, n2, target, - null, + portalAnchor, parentComponent, parentSuspense, isSVG ) } + // target changed if (targetSelector !== (n1.props && n1.props.target)) { const nextTarget = (n2.target = isString(targetSelector) ? querySelector!(targetSelector) : targetSelector) if (nextTarget) { - // move content - if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { - setElementText(target, '') - setElementText(nextTarget, children as string) - } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { - for (let i = 0; i < (children as VNode[]).length; i++) { - move((children as VNode[])[i], nextTarget, null, MoveType.REORDER) - } - } + movePortal(n2, nextTarget, null, insert, move) } else if (__DEV__) { warn('Invalid Portal target on update:', target, `(${typeof target})`) } @@ -117,12 +114,11 @@ export const PortalImpl = { remove( vnode: VNode, - { r: remove, o: { setElementText } }: RendererInternals + { r: remove, o: { remove: hostRemove } }: RendererInternals ) { - const { target, shapeFlag, children } = vnode - if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { - setElementText(target!, '') - } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { + const { shapeFlag, children, anchor } = vnode + hostRemove(anchor!) + if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { for (let i = 0; i < (children as VNode[]).length; i++) { remove((children as VNode[])[i]) } @@ -130,6 +126,24 @@ export const PortalImpl = { } } +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) + } + } +} + // Force-casted public typing for h and TSX props inference export const Portal = (PortalImpl as any) as { __isPortal: true diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 1fc36b4d63d..58389db17ca 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1839,7 +1839,7 @@ function baseCreateRenderer( if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) { return vnode.suspense!.next() } - return hostNextSibling((vnode.anchor || vnode.el)!) + return hostNextSibling((vnode.type === Fragment ? vnode.anchor : vnode.el)!) } const setRef = ( diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 5908e006f09..605aca51cfe 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -419,14 +419,17 @@ export function cloneIfMounted(child: VNode): VNode { export function normalizeChildren(vnode: VNode, children: unknown) { let type = 0 + const { shapeFlag } = vnode if (children == null) { children = null } else if (isArray(children)) { type = ShapeFlags.ARRAY_CHILDREN } else if (typeof children === 'object') { - // in case resolves to native element, the vnode call - // will receive slots object. - if (vnode.shapeFlag & ShapeFlags.ELEMENT && (children as any).default) { + // Normalize slot to plain children + if ( + (shapeFlag & ShapeFlags.ELEMENT || shapeFlag & ShapeFlags.PORTAL) && + (children as any).default + ) { normalizeChildren(vnode, (children as any).default()) return } else { @@ -440,7 +443,13 @@ export function normalizeChildren(vnode: VNode, children: unknown) { type = ShapeFlags.SLOTS_CHILDREN } else { children = String(children) - type = ShapeFlags.TEXT_CHILDREN + // force portal children to array so it can be moved around + if (shapeFlag & ShapeFlags.PORTAL) { + type = ShapeFlags.ARRAY_CHILDREN + children = [createTextVNode(children as string)] + } else { + type = ShapeFlags.TEXT_CHILDREN + } } vnode.children = children as VNodeNormalizedChildren vnode.shapeFlag |= type