Skip to content

Commit aafb880

Browse files
committed
feat(portal): support multiple portal appending to same target
1 parent b8ffbff commit aafb880

File tree

6 files changed

+178
-95
lines changed

6 files changed

+178
-95
lines changed

packages/compiler-core/src/transforms/transformElement.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const transformElement: NodeTransform = (node, context) => {
124124

125125
const shouldBuildAsSlots =
126126
isComponent &&
127-
// Portal is not a real component has dedicated handling in the renderer
127+
// Portal is not a real component and has dedicated runtime handling
128128
vnodeTag !== PORTAL &&
129129
// explained above.
130130
vnodeTag !== KEEP_ALIVE
@@ -135,7 +135,7 @@ export const transformElement: NodeTransform = (node, context) => {
135135
if (hasDynamicSlots) {
136136
patchFlag |= PatchFlags.DYNAMIC_SLOTS
137137
}
138-
} else if (node.children.length === 1) {
138+
} else if (node.children.length === 1 && vnodeTag !== PORTAL) {
139139
const child = node.children[0]
140140
const type = child.type
141141
// check for dynamic text children

packages/runtime-core/__tests__/components/Portal.spec.ts

+121-38
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,32 @@ import {
33
serializeInner,
44
render,
55
h,
6-
defineComponent,
76
Portal,
87
Text,
98
ref,
10-
nextTick,
11-
TestElement,
12-
TestNode
9+
nextTick
1310
} from '@vue/runtime-test'
14-
import { VNodeArrayChildren, createVNode } from '../../src/vnode'
11+
import { createVNode } from '../../src/vnode'
1512

1613
describe('renderer: portal', () => {
1714
test('should work', () => {
1815
const target = nodeOps.createElement('div')
1916
const root = nodeOps.createElement('div')
2017

21-
const Comp = defineComponent(() => () => [
22-
h(Portal, { target }, h('div', 'teleported')),
23-
h('div', 'root')
24-
])
25-
render(h(Comp), root)
18+
render(
19+
h(() => [
20+
h(Portal, { target }, h('div', 'teleported')),
21+
h('div', 'root')
22+
]),
23+
root
24+
)
2625

27-
expect(serializeInner(root)).toMatchSnapshot()
28-
expect(serializeInner(target)).toMatchSnapshot()
26+
expect(serializeInner(root)).toMatchInlineSnapshot(
27+
`"<!--portal--><div>root</div>"`
28+
)
29+
expect(serializeInner(target)).toMatchInlineSnapshot(
30+
`"<div>teleported</div>"`
31+
)
2932
})
3033

3134
test('should update target', async () => {
@@ -34,63 +37,143 @@ describe('renderer: portal', () => {
3437
const target = ref(targetA)
3538
const root = nodeOps.createElement('div')
3639

37-
const Comp = defineComponent(() => () => [
38-
h(Portal, { target: target.value }, h('div', 'teleported')),
39-
h('div', 'root')
40-
])
41-
render(h(Comp), root)
40+
render(
41+
h(() => [
42+
h(Portal, { target: target.value }, h('div', 'teleported')),
43+
h('div', 'root')
44+
]),
45+
root
46+
)
4247

43-
expect(serializeInner(root)).toMatchSnapshot()
44-
expect(serializeInner(targetA)).toMatchSnapshot()
45-
expect(serializeInner(targetB)).toMatchSnapshot()
48+
expect(serializeInner(root)).toMatchInlineSnapshot(
49+
`"<!--portal--><div>root</div>"`
50+
)
51+
expect(serializeInner(targetA)).toMatchInlineSnapshot(
52+
`"<div>teleported</div>"`
53+
)
54+
expect(serializeInner(targetB)).toMatchInlineSnapshot(`""`)
4655

4756
target.value = targetB
4857
await nextTick()
4958

50-
expect(serializeInner(root)).toMatchSnapshot()
51-
expect(serializeInner(targetA)).toMatchSnapshot()
52-
expect(serializeInner(targetB)).toMatchSnapshot()
59+
expect(serializeInner(root)).toMatchInlineSnapshot(
60+
`"<!--portal--><div>root</div>"`
61+
)
62+
expect(serializeInner(targetA)).toMatchInlineSnapshot(`""`)
63+
expect(serializeInner(targetB)).toMatchInlineSnapshot(
64+
`"<div>teleported</div>"`
65+
)
5366
})
5467

5568
test('should update children', async () => {
5669
const target = nodeOps.createElement('div')
5770
const root = nodeOps.createElement('div')
58-
const children = ref<VNodeArrayChildren<TestNode, TestElement>>([
59-
h('div', 'teleported')
60-
])
71+
const children = ref([h('div', 'teleported')])
6172

62-
const Comp = defineComponent(() => () =>
63-
h(Portal, { target }, children.value)
73+
render(h(Portal, { target }, children.value), root)
74+
expect(serializeInner(target)).toMatchInlineSnapshot(
75+
`"<div>teleported</div>"`
6476
)
65-
render(h(Comp), root)
66-
67-
expect(serializeInner(target)).toMatchSnapshot()
6877

6978
children.value = []
7079
await nextTick()
7180

72-
expect(serializeInner(target)).toMatchSnapshot()
81+
expect(serializeInner(target)).toMatchInlineSnapshot(
82+
`"<div>teleported</div>"`
83+
)
7384

7485
children.value = [createVNode(Text, null, 'teleported')]
7586
await nextTick()
7687

77-
expect(serializeInner(target)).toMatchSnapshot()
88+
expect(serializeInner(target)).toMatchInlineSnapshot(
89+
`"<div>teleported</div>"`
90+
)
7891
})
7992

8093
test('should remove children when unmounted', () => {
8194
const target = nodeOps.createElement('div')
8295
const root = nodeOps.createElement('div')
8396

84-
const Comp = defineComponent(() => () => [
85-
h(Portal, { target }, h('div', 'teleported')),
86-
h('div', 'root')
87-
])
88-
render(h(Comp), root)
97+
render(
98+
h(() => [
99+
h(Portal, { target }, h('div', 'teleported')),
100+
h('div', 'root')
101+
]),
102+
root
103+
)
89104
expect(serializeInner(target)).toMatchInlineSnapshot(
90105
`"<div>teleported</div>"`
91106
)
92107

93108
render(null, root)
94109
expect(serializeInner(target)).toBe('')
95110
})
111+
112+
test('multiple portal with same target', () => {
113+
const target = nodeOps.createElement('div')
114+
const root = nodeOps.createElement('div')
115+
116+
render(
117+
h('div', [
118+
h(Portal, { target }, h('div', 'one')),
119+
h(Portal, { target }, 'two')
120+
]),
121+
root
122+
)
123+
124+
expect(serializeInner(root)).toMatchInlineSnapshot(
125+
`"<div><!--portal--><!--portal--></div>"`
126+
)
127+
expect(serializeInner(target)).toMatchInlineSnapshot(`"<div>one</div>two"`)
128+
129+
// update existing content
130+
render(
131+
h('div', [
132+
h(Portal, { target }, [h('div', 'one'), h('div', 'two')]),
133+
h(Portal, { target }, 'three')
134+
]),
135+
root
136+
)
137+
expect(serializeInner(target)).toMatchInlineSnapshot(
138+
`"<div>one</div><div>two</div>three"`
139+
)
140+
141+
// toggling
142+
render(h('div', [null, h(Portal, { target }, 'three')]), root)
143+
expect(serializeInner(root)).toMatchInlineSnapshot(
144+
`"<div><!----><!--portal--></div>"`
145+
)
146+
expect(serializeInner(target)).toMatchInlineSnapshot(`"three"`)
147+
148+
// toggle back
149+
render(
150+
h('div', [
151+
h(Portal, { target }, [h('div', 'one'), h('div', 'two')]),
152+
h(Portal, { target }, 'three')
153+
]),
154+
root
155+
)
156+
expect(serializeInner(root)).toMatchInlineSnapshot(
157+
`"<div><!--portal--><!--portal--></div>"`
158+
)
159+
// should append
160+
expect(serializeInner(target)).toMatchInlineSnapshot(
161+
`"three<div>one</div><div>two</div>"`
162+
)
163+
164+
// toggle the other portal
165+
render(
166+
h('div', [
167+
h(Portal, { target }, [h('div', 'one'), h('div', 'two')]),
168+
null
169+
]),
170+
root
171+
)
172+
expect(serializeInner(root)).toMatchInlineSnapshot(
173+
`"<div><!--portal--><!----></div>"`
174+
)
175+
expect(serializeInner(target)).toMatchInlineSnapshot(
176+
`"<div>one</div><div>two</div>"`
177+
)
178+
})
96179
})

packages/runtime-core/__tests__/components/__snapshots__/Portal.spec.ts.snap

-23
This file was deleted.

packages/runtime-core/src/components/Portal.ts

+41-27
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import {
44
RendererInternals,
55
MoveType,
66
RendererElement,
7-
RendererNode
7+
RendererNode,
8+
RendererOptions
89
} from '../renderer'
910
import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode'
10-
import { isString, ShapeFlags, PatchFlags } from '@vue/shared'
11+
import { isString, ShapeFlags } from '@vue/shared'
1112
import { warn } from '../warning'
1213

1314
export const isPortal = (type: any): boolean => type.__isPortal
@@ -32,11 +33,11 @@ export const PortalImpl = {
3233
pc: patchChildren,
3334
pbc: patchBlockChildren,
3435
m: move,
35-
o: { insert, querySelector, setElementText, createComment }
36+
o: { insert, querySelector, createText, createComment }
3637
}: RendererInternals
3738
) {
3839
const targetSelector = n2.props && n2.props.target
39-
const { patchFlag, shapeFlag, children } = n2
40+
const { shapeFlag, children } = n2
4041
if (n1 == null) {
4142
// insert an empty node as the placeholder for the portal
4243
insert((n2.el = createComment(`portal`)), container, anchor)
@@ -49,14 +50,18 @@ export const PortalImpl = {
4950
const target = (n2.target = isString(targetSelector)
5051
? querySelector!(targetSelector)
5152
: targetSelector)
53+
// portal content needs an anchor to support patching multiple portals
54+
// appending to the same target element.
55+
const portalAnchor = (n2.anchor = createText(''))
5256
if (target) {
53-
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
54-
setElementText(target, children as string)
55-
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
57+
insert(portalAnchor, target)
58+
// Portal *always* has Array children. This is enforced in both the
59+
// compiler and vnode children normalization.
60+
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
5661
mountChildren(
5762
children as VNodeArrayChildren,
5863
target,
59-
null,
64+
portalAnchor,
6065
parentComponent,
6166
parentSuspense,
6267
isSVG,
@@ -67,12 +72,11 @@ export const PortalImpl = {
6772
warn('Invalid Portal target on mount:', target, `(${typeof target})`)
6873
}
6974
} else {
70-
n2.el = n1.el
7175
// update content
76+
n2.el = n1.el
7277
const target = (n2.target = n1.target)!
73-
if (patchFlag === PatchFlags.TEXT) {
74-
setElementText(target, children as string)
75-
} else if (n2.dynamicChildren) {
78+
const portalAnchor = (n2.anchor = n1.anchor)!
79+
if (n2.dynamicChildren) {
7680
// fast path when the portal happens to be a block root
7781
patchBlockChildren(
7882
n1.dynamicChildren!,
@@ -87,27 +91,20 @@ export const PortalImpl = {
8791
n1,
8892
n2,
8993
target,
90-
null,
94+
portalAnchor,
9195
parentComponent,
9296
parentSuspense,
9397
isSVG
9498
)
9599
}
100+
96101
// target changed
97102
if (targetSelector !== (n1.props && n1.props.target)) {
98103
const nextTarget = (n2.target = isString(targetSelector)
99104
? querySelector!(targetSelector)
100105
: targetSelector)
101106
if (nextTarget) {
102-
// move content
103-
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
104-
setElementText(target, '')
105-
setElementText(nextTarget, children as string)
106-
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
107-
for (let i = 0; i < (children as VNode[]).length; i++) {
108-
move((children as VNode[])[i], nextTarget, null, MoveType.REORDER)
109-
}
110-
}
107+
movePortal(n2, nextTarget, null, insert, move)
111108
} else if (__DEV__) {
112109
warn('Invalid Portal target on update:', target, `(${typeof target})`)
113110
}
@@ -117,19 +114,36 @@ export const PortalImpl = {
117114

118115
remove(
119116
vnode: VNode,
120-
{ r: remove, o: { setElementText } }: RendererInternals
117+
{ r: remove, o: { remove: hostRemove } }: RendererInternals
121118
) {
122-
const { target, shapeFlag, children } = vnode
123-
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
124-
setElementText(target!, '')
125-
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
119+
const { shapeFlag, children, anchor } = vnode
120+
hostRemove(anchor!)
121+
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
126122
for (let i = 0; i < (children as VNode[]).length; i++) {
127123
remove((children as VNode[])[i])
128124
}
129125
}
130126
}
131127
}
132128

129+
const movePortal = (
130+
vnode: VNode,
131+
nextTarget: RendererElement,
132+
anchor: RendererNode | null,
133+
insert: RendererOptions['insert'],
134+
move: RendererInternals['m']
135+
) => {
136+
const { anchor: portalAnchor, shapeFlag, children } = vnode
137+
// move content.
138+
// Portal has either Array children or no children.
139+
insert(portalAnchor!, nextTarget, anchor)
140+
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
141+
for (let i = 0; i < (children as VNode[]).length; i++) {
142+
move((children as VNode[])[i], nextTarget, portalAnchor, MoveType.REORDER)
143+
}
144+
}
145+
}
146+
133147
// Force-casted public typing for h and TSX props inference
134148
export const Portal = (PortalImpl as any) as {
135149
__isPortal: true

0 commit comments

Comments
 (0)