diff --git a/packages/server-renderer/__tests__/renderToString.spec.ts b/packages/server-renderer/__tests__/renderToString.spec.ts index ed60dcaeb8d..2ac929ac1c4 100644 --- a/packages/server-renderer/__tests__/renderToString.spec.ts +++ b/packages/server-renderer/__tests__/renderToString.spec.ts @@ -5,11 +5,16 @@ import { withScopeId, resolveComponent, ComponentOptions, + Portal, ref, defineComponent } from 'vue' import { escapeHtml, mockWarn } from '@vue/shared' -import { renderToString, renderComponent } from '../src/renderToString' +import { + renderToString, + renderComponent, + SSRContext +} from '../src/renderToString' import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot' mockWarn() @@ -508,6 +513,21 @@ describe('ssr: renderToString', () => { }) }) + test('portal', async () => { + const ctx: SSRContext = {} + await renderToString( + h( + Portal, + { + target: `#target` + }, + h('span', 'hello') + ), + ctx + ) + expect(ctx.portals!['#target']).toBe('hello') + }) + describe('scopeId', () => { // note: here we are only testing scopeId handling for vdom serialization. // compiled srr render functions will include scopeId directly in strings. diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts index 3caaf4421e5..d8826a03e18 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts @@ -16,7 +16,7 @@ export function ssrRenderSlot( slotProps: Props, fallbackRenderFn: (() => void) | null, push: PushFn, - parentComponent: ComponentInternalInstance | null = null + parentComponent: ComponentInternalInstance ) { const slotFn = slots[slotName] // template-compiled slots are always rendered as fragments diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index 599f6b34510..b7bd38d3135 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -11,7 +11,8 @@ import { Portal, ssrUtils, Slots, - warn + warn, + createApp } from 'vue' import { ShapeFlags, @@ -47,9 +48,22 @@ const { type SSRBuffer = SSRBufferItem[] type SSRBufferItem = string | ResolvedSSRBuffer | Promise type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[] + export type PushFn = (item: SSRBufferItem) => void + export type Props = Record +const ssrContextKey = Symbol() + +export type SSRContext = { + [key: string]: any + portals?: Record + __portalBuffers?: Record< + string, + ResolvedSSRBuffer | Promise + > +} + function createBuffer() { let appendable = false let hasAsync = false @@ -88,17 +102,33 @@ function unrollBuffer(buffer: ResolvedSSRBuffer): string { return ret } -export async function renderToString(input: App | VNode): Promise { +export async function renderToString( + input: App | VNode, + context: SSRContext = {} +): Promise { let buffer: ResolvedSSRBuffer if (isVNode(input)) { - // raw vnode, wrap with component - buffer = await renderComponent({ render: () => input }) + // raw vnode, wrap with app (for context) + return renderToString(createApp({ render: () => input }), context) } else { // rendering an app const vnode = createVNode(input._component, input._props) vnode.appContext = input._context + // provide the ssr context to the tree + input.provide(ssrContextKey, context) buffer = await renderComponentVNode(vnode) } + + // resolve portals + if (context.__portalBuffers) { + context.portals = context.portals || {} + 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]) + } + } + return unrollBuffer(buffer) } @@ -132,7 +162,7 @@ function renderComponentVNode( } type SSRRenderFunction = ( - ctx: any, + context: any, push: (item: any) => void, parentInstance: ComponentInternalInstance ) => void @@ -206,7 +236,7 @@ function renderComponentSubTree( function renderVNode( push: PushFn, vnode: VNode, - parentComponent: ComponentInternalInstance | null = null + parentComponent: ComponentInternalInstance ) { const { type, shapeFlag, children } = vnode switch (type) { @@ -222,7 +252,7 @@ function renderVNode( push(``) break case Portal: - // TODO + renderPortal(vnode, parentComponent) break default: if (shapeFlag & ShapeFlags.ELEMENT) { @@ -244,7 +274,7 @@ function renderVNode( export function renderVNodeChildren( push: PushFn, children: VNodeArrayChildren, - parentComponent: ComponentInternalInstance | null = null + parentComponent: ComponentInternalInstance ) { for (let i = 0; i < children.length; i++) { renderVNode(push, normalizeVNode(children[i]), parentComponent) @@ -254,7 +284,7 @@ export function renderVNodeChildren( function renderElement( push: PushFn, vnode: VNode, - parentComponent: ComponentInternalInstance | null = null + parentComponent: ComponentInternalInstance ) { const tag = vnode.type as string const { props, children, shapeFlag, scopeId } = vnode @@ -305,3 +335,35 @@ function renderElement( push(``) } } + +function renderPortal( + vnode: VNode, + parentComponent: ComponentInternalInstance +) { + const target = vnode.props && vnode.props.target + if (!target) { + console.warn(`[@vue/server-renderer] Portal is missing target prop.`) + return [] + } + if (!isString(target)) { + console.warn( + `[@vue/server-renderer] Portal target must be a query selector string.` + ) + return [] + } + + const { buffer, push, hasAsync } = createBuffer() + renderVNodeChildren( + push, + vnode.children as VNodeArrayChildren, + parentComponent + ) + const context = parentComponent.appContext.provides[ + ssrContextKey as any + ] as SSRContext + const portalBuffers = + context.__portalBuffers || (context.__portalBuffers = {}) + portalBuffers[target] = hasAsync() + ? Promise.all(buffer) + : (buffer as ResolvedSSRBuffer) +}