]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(portal): support multiple portal appending to same target
authorEvan You <yyx990803@gmail.com>
Fri, 27 Mar 2020 22:42:57 +0000 (18:42 -0400)
committerEvan You <yyx990803@gmail.com>
Fri, 27 Mar 2020 22:42:57 +0000 (18:42 -0400)
packages/compiler-core/src/transforms/transformElement.ts
packages/runtime-core/__tests__/components/Portal.spec.ts
packages/runtime-core/__tests__/components/__snapshots__/Portal.spec.ts.snap [deleted file]
packages/runtime-core/src/components/Portal.ts
packages/runtime-core/src/renderer.ts
packages/runtime-core/src/vnode.ts

index 4ae461caf545a15e0a2bc48a09f78a0048e32dea..ad7a119c431d6619498fe087ebf392588c80ea0a 100644 (file)
@@ -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
index 6c5c36bfda4113b514af7a3d60d1a7fb5141cfbb..229732bd21d4d5581123b9b9eeced67c577e4e95 100644 (file)
@@ -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(
+      `"<!--portal--><div>root</div>"`
+    )
+    expect(serializeInner(target)).toMatchInlineSnapshot(
+      `"<div>teleported</div>"`
+    )
   })
 
   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(
+      `"<!--portal--><div>root</div>"`
+    )
+    expect(serializeInner(targetA)).toMatchInlineSnapshot(
+      `"<div>teleported</div>"`
+    )
+    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(
+      `"<!--portal--><div>root</div>"`
+    )
+    expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`)
+    expect(serializeInner(targetB)).toMatchInlineSnapshot(
+      `"<div>teleported</div>"`
+    )
   })
 
   test('should update children', async () => {
     const target = nodeOps.createElement('div')
     const root = nodeOps.createElement('div')
-    const children = ref<VNodeArrayChildren<TestNode, TestElement>>([
-      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(
+      `"<div>teleported</div>"`
     )
-    render(h(Comp), root)
-
-    expect(serializeInner(target)).toMatchSnapshot()
 
     children.value = []
     await nextTick()
 
-    expect(serializeInner(target)).toMatchSnapshot()
+    expect(serializeInner(target)).toMatchInlineSnapshot(
+      `"<div>teleported</div>"`
+    )
 
     children.value = [createVNode(Text, null, 'teleported')]
     await nextTick()
 
-    expect(serializeInner(target)).toMatchSnapshot()
+    expect(serializeInner(target)).toMatchInlineSnapshot(
+      `"<div>teleported</div>"`
+    )
   })
 
   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(
       `"<div>teleported</div>"`
     )
@@ -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(
+      `"<div><!--portal--><!--portal--></div>"`
+    )
+    expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>one</div>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(
+      `"<div>one</div><div>two</div>three"`
+    )
+
+    // toggling
+    render(h('div', [null, h(Portal, { target }, 'three')]), root)
+    expect(serializeInner(root)).toMatchInlineSnapshot(
+      `"<div><!----><!--portal--></div>"`
+    )
+    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(
+      `"<div><!--portal--><!--portal--></div>"`
+    )
+    // should append
+    expect(serializeInner(target)).toMatchInlineSnapshot(
+      `"three<div>one</div><div>two</div>"`
+    )
+
+    // toggle the other portal
+    render(
+      h('div', [
+        h(Portal, { target }, [h('div', 'one'), h('div', 'two')]),
+        null
+      ]),
+      root
+    )
+    expect(serializeInner(root)).toMatchInlineSnapshot(
+      `"<div><!--portal--><!----></div>"`
+    )
+    expect(serializeInner(target)).toMatchInlineSnapshot(
+      `"<div>one</div><div>two</div>"`
+    )
+  })
 })
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 (file)
index 4a47a58..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renderer: portal should update children 1`] = `"<div>teleported</div>"`;
-
-exports[`renderer: portal should update children 2`] = `""`;
-
-exports[`renderer: portal should update children 3`] = `"teleported"`;
-
-exports[`renderer: portal should update target 1`] = `"<!--portal--><div>root</div>"`;
-
-exports[`renderer: portal should update target 2`] = `"<div>teleported</div>"`;
-
-exports[`renderer: portal should update target 3`] = `""`;
-
-exports[`renderer: portal should update target 4`] = `"<!--portal--><div>root</div>"`;
-
-exports[`renderer: portal should update target 5`] = `""`;
-
-exports[`renderer: portal should update target 6`] = `"<div>teleported</div>"`;
-
-exports[`renderer: portal should work 1`] = `"<!--portal--><div>root</div>"`;
-
-exports[`renderer: portal should work 2`] = `"<div>teleported</div>"`;
index beac7e8ad06765eb33688adb749759b5fc892d6c..34afd38d4607c273df08ff604744fc46023eda8b 100644 (file)
@@ -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
index 1fc36b4d63d337584f98b10c8afa6c3021985f5a..58389db17ca5c73c934370c24a7968f698f85b56 100644 (file)
@@ -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 = (
index 5908e006f097f11a93d50f93af68cdc803459473..605aca51cfee425fbb4efadeaf2e561d2c9d4b7c 100644 (file)
@@ -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 <component :is="x"> 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