"const { ssrRenderPortal: _ssrRenderPortal } = require(\\"@vue/server-renderer\\")
return function ssrRender(_ctx, _push, _parent) {
- _ssrRenderPortal((_push) => {
+ _ssrRenderPortal(_push, (_push) => {
_push(\`<div></div>\`)
}, _ctx.target, _parent)
}"
contentRenderFn.body = processChildrenAsStatement(node.children, context)
context.pushStatement(
createCallExpression(context.helper(SSR_RENDER_PORTAL), [
+ `_push`,
contentRenderFn,
target,
`_parent`
} from '@vue/runtime-dom'
import { renderToString } from '@vue/server-renderer'
import { mockWarn } from '@vue/shared'
+import { SSRContext } from 'packages/server-renderer/src/renderToString'
function mountWithHydration(html: string, render: () => any) {
const container = document.createElement('div')
const fn = jest.fn()
const portalContainer = document.createElement('div')
portalContainer.id = 'portal'
- portalContainer.innerHTML = `<span>foo</span><span class="foo"></span>`
+ portalContainer.innerHTML = `<span>foo</span><span class="foo"></span><!---->`
document.body.appendChild(portalContainer)
const { vnode, container } = mountWithHydration('<!--portal-->', () =>
msg.value = 'bar'
await nextTick()
expect(portalContainer.innerHTML).toBe(
- `<span>bar</span><span class="bar"></span>`
+ `<span>bar</span><span class="bar"></span><!---->`
+ )
+ })
+
+ test('Portal (multiple + integration)', async () => {
+ const msg = ref('foo')
+ const fn1 = jest.fn()
+ const fn2 = jest.fn()
+
+ const Comp = () => [
+ h(Portal, { target: '#portal2' }, [
+ h('span', msg.value),
+ h('span', { class: msg.value, onClick: fn1 })
+ ]),
+ h(Portal, { target: '#portal2' }, [
+ h('span', msg.value + '2'),
+ h('span', { class: msg.value + '2', onClick: fn2 })
+ ])
+ ]
+
+ const portalContainer = document.createElement('div')
+ portalContainer.id = 'portal2'
+ const ctx: SSRContext = {}
+ const mainHtml = await renderToString(h(Comp), ctx)
+ expect(mainHtml).toMatchInlineSnapshot(
+ `"<!--[--><!--portal--><!--portal--><!--]-->"`
+ )
+
+ const portalHtml = ctx.portals!['#portal2']
+ expect(portalHtml).toMatchInlineSnapshot(
+ `"<span>foo</span><span class=\\"foo\\"></span><!----><span>foo2</span><span class=\\"foo2\\"></span><!---->"`
+ )
+
+ portalContainer.innerHTML = portalHtml
+ document.body.appendChild(portalContainer)
+
+ const { vnode, container } = mountWithHydration(mainHtml, Comp)
+ expect(vnode.el).toBe(container.firstChild)
+ const portalVnode1 = (vnode.children as VNode[])[0]
+ const portalVnode2 = (vnode.children as VNode[])[1]
+ expect(portalVnode1.el).toBe(container.childNodes[1])
+ expect(portalVnode2.el).toBe(container.childNodes[2])
+
+ expect((portalVnode1 as any).children[0].el).toBe(
+ portalContainer.childNodes[0]
+ )
+ expect(portalVnode1.anchor).toBe(portalContainer.childNodes[2])
+ expect((portalVnode2 as any).children[0].el).toBe(
+ portalContainer.childNodes[3]
+ )
+ expect(portalVnode2.anchor).toBe(portalContainer.childNodes[5])
+
+ // // event handler
+ triggerEvent('click', portalContainer.querySelector('.foo')!)
+ expect(fn1).toHaveBeenCalled()
+
+ triggerEvent('click', portalContainer.querySelector('.foo2')!)
+ expect(fn2).toHaveBeenCalled()
+
+ msg.value = 'bar'
+ await nextTick()
+ expect(portalContainer.innerHTML).toMatchInlineSnapshot(
+ `"<span>bar</span><span class=\\"bar\\"></span><!----><span>bar2</span><span class=\\"bar2\\"></span><!---->"`
)
})
}
}
+ interface PortalTargetElement extends Element {
+ // last portal target
+ _lpa?: Node | null
+ }
+
const hydratePortal = (
vnode: VNode,
parentComponent: ComponentInternalInstance | null,
? document.querySelector(targetSelector)
: targetSelector)
if (target && vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
- hydrateChildren(
- target.firstChild,
+ vnode.anchor = hydrateChildren(
+ // if multiple portals rendered to the same target element, we need to
+ // pick up from where the last portal finished instead of the first node
+ (target as PortalTargetElement)._lpa || target.firstChild,
vnode,
target,
parentComponent,
parentSuspense,
optimized
)
+ ;(target as PortalTargetElement)._lpa = nextSibling(vnode.anchor as Node)
} else if (__DEV__) {
warn(
`Attempting to hydrate portal but target ${targetSelector} does not ` +
describe('ssrRenderPortal', () => {
test('portal rendering (compiled)', async () => {
- const ctx = {
- portals: {}
- } as SSRContext
- await renderToString(
+ const ctx: SSRContext = {}
+ const html = await renderToString(
createApp({
data() {
return { msg: 'hello' }
},
ssrRender(_ctx, _push, _parent) {
ssrRenderPortal(
+ _push,
_push => {
_push(`<div>content</div>`)
},
}),
ctx
)
- expect(ctx.portals!['#target']).toBe(`<div>content</div>`)
+ expect(html).toBe('<!--portal-->')
+ expect(ctx.portals!['#target']).toBe(`<div>content</div><!---->`)
})
test('portal rendering (vnode)', async () => {
const ctx: SSRContext = {}
- await renderToString(
+ const html = await renderToString(
h(
Portal,
{
),
ctx
)
- expect(ctx.portals!['#target']).toBe('<span>hello</span>')
+ expect(html).toBe('<!--portal-->')
+ expect(ctx.portals!['#target']).toBe('<span>hello</span><!---->')
+ })
+
+ test('multiple portals with same target', async () => {
+ const ctx: SSRContext = {}
+ const html = await renderToString(
+ h('div', [
+ h(
+ Portal,
+ {
+ target: `#target`
+ },
+ h('span', 'hello')
+ ),
+ h(Portal, { target: `#target` }, 'world')
+ ]),
+ ctx
+ )
+ expect(html).toBe('<div><!--portal--><!--portal--></div>')
+ expect(ctx.portals!['#target']).toBe(
+ '<span>hello</span><!---->world<!---->'
+ )
})
})
import { SSRContext, createBuffer, PushFn } from '../renderToString'
export function ssrRenderPortal(
+ parentPush: PushFn,
contentRenderFn: (push: PushFn) => void,
target: string,
parentComponent: ComponentInternalInstance
) {
+ parentPush('<!--portal-->')
const { getBuffer, push } = createBuffer()
-
contentRenderFn(push)
+ push(`<!---->`) // portal end anchor
const context = parentComponent.appContext.provides[
ssrContextKey as any
] as SSRContext
const portalBuffers =
context.__portalBuffers || (context.__portalBuffers = {})
-
- portalBuffers[target] = getBuffer()
+ if (portalBuffers[target]) {
+ portalBuffers[target].push(getBuffer())
+ } else {
+ portalBuffers[target] = [getBuffer()]
+ }
}
import { ssrRenderAttrs } from './helpers/ssrRenderAttrs'
import { SSRSlots } from './helpers/ssrRenderSlot'
import { CompilerError } from '@vue/compiler-dom'
+import { ssrRenderPortal } from './helpers/ssrRenderPortal'
const {
isVNode,
export type SSRContext = {
[key: string]: any
portals?: Record<string, string>
- __portalBuffers?: Record<
- string,
- ResolvedSSRBuffer | Promise<ResolvedSSRBuffer>
- >
+ __portalBuffers?: Record<string, SSRBuffer>
}
export function createBuffer() {
} else if (shapeFlag & ShapeFlags.COMPONENT) {
push(renderComponentVNode(vnode, parentComponent))
} else if (shapeFlag & ShapeFlags.PORTAL) {
- renderPortalVNode(vnode, parentComponent)
+ renderPortalVNode(push, vnode, parentComponent)
} else if (shapeFlag & ShapeFlags.SUSPENSE) {
renderVNode(
push,
}
function renderPortalVNode(
+ push: PushFn,
vnode: VNode,
parentComponent: ComponentInternalInstance
) {
)
return []
}
-
- const { getBuffer, push } = createBuffer()
- renderVNodeChildren(
+ ssrRenderPortal(
push,
- vnode.children as VNodeArrayChildren,
+ push => {
+ renderVNodeChildren(
+ push,
+ vnode.children as VNodeArrayChildren,
+ parentComponent
+ )
+ },
+ target,
parentComponent
)
- const context = parentComponent.appContext.provides[
- ssrContextKey as any
- ] as SSRContext
- const portalBuffers =
- context.__portalBuffers || (context.__portalBuffers = {})
-
- portalBuffers[target] = getBuffer()
}
async function resolvePortals(context: SSRContext) {
for (const key in context.__portalBuffers) {
// note: it's OK to await sequentially here because the Promises were
// created eagerly in parallel.
- context.portals[key] = unrollBuffer(await context.__portalBuffers[key])
+ context.portals[key] = unrollBuffer(
+ await Promise.all(context.__portalBuffers[key])
+ )
}
}
}