From e866434f0c54498dd0fc47d48287a1d0ada36388 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 27 Mar 2020 20:49:01 -0400 Subject: [PATCH] feat(portal): SSR support for multi portal shared target --- .../compiler-ssr/__tests__/ssrPortal.spec.ts | 2 +- .../src/transforms/ssrTransformPortal.ts | 1 + .../runtime-core/__tests__/hydration.spec.ts | 67 ++++++++++++++++++- packages/runtime-core/src/hydration.ts | 12 +++- .../__tests__/ssrPortal.spec.ts | 36 ++++++++-- .../src/helpers/ssrRenderPortal.ts | 11 ++- .../server-renderer/src/renderToString.ts | 33 +++++---- 7 files changed, 130 insertions(+), 32 deletions(-) diff --git a/packages/compiler-ssr/__tests__/ssrPortal.spec.ts b/packages/compiler-ssr/__tests__/ssrPortal.spec.ts index 5490649d57f..7f608f91442 100644 --- a/packages/compiler-ssr/__tests__/ssrPortal.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrPortal.spec.ts @@ -7,7 +7,7 @@ describe('ssr compile: portal', () => { "const { ssrRenderPortal: _ssrRenderPortal } = require(\\"@vue/server-renderer\\") return function ssrRender(_ctx, _push, _parent) { - _ssrRenderPortal((_push) => { + _ssrRenderPortal(_push, (_push) => { _push(\`
\`) }, _ctx.target, _parent) }" diff --git a/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts b/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts index c380e672aba..8c7fa063b6c 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts @@ -52,6 +52,7 @@ export function ssrProcessPortal( contentRenderFn.body = processChildrenAsStatement(node.children, context) context.pushStatement( createCallExpression(context.helper(SSR_RENDER_PORTAL), [ + `_push`, contentRenderFn, target, `_parent` diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 84a95b41e49..1b944039836 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -12,6 +12,7 @@ import { } 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') @@ -157,7 +158,7 @@ describe('SSR hydration', () => { const fn = jest.fn() const portalContainer = document.createElement('div') portalContainer.id = 'portal' - portalContainer.innerHTML = `foo` + portalContainer.innerHTML = `foo` document.body.appendChild(portalContainer) const { vnode, container } = mountWithHydration('', () => @@ -182,7 +183,69 @@ describe('SSR hydration', () => { msg.value = 'bar' await nextTick() expect(portalContainer.innerHTML).toBe( - `bar` + `bar` + ) + }) + + 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( + `""` + ) + + const portalHtml = ctx.portals!['#portal2'] + expect(portalHtml).toMatchInlineSnapshot( + `"foofoo2"` + ) + + 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( + `"barbar2"` ) }) diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 37933eca5a4..3cbe98758fb 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -366,6 +366,11 @@ export function createHydrationFunctions( } } + interface PortalTargetElement extends Element { + // last portal target + _lpa?: Node | null + } + const hydratePortal = ( vnode: VNode, parentComponent: ComponentInternalInstance | null, @@ -377,14 +382,17 @@ export function createHydrationFunctions( ? 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 ` + diff --git a/packages/server-renderer/__tests__/ssrPortal.spec.ts b/packages/server-renderer/__tests__/ssrPortal.spec.ts index c26d60cc962..45314c2b464 100644 --- a/packages/server-renderer/__tests__/ssrPortal.spec.ts +++ b/packages/server-renderer/__tests__/ssrPortal.spec.ts @@ -4,16 +4,15 @@ import { ssrRenderPortal } from '../src/helpers/ssrRenderPortal' 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(`
content
`) }, @@ -24,12 +23,13 @@ describe('ssrRenderPortal', () => { }), ctx ) - expect(ctx.portals!['#target']).toBe(`
content
`) + expect(html).toBe('') + expect(ctx.portals!['#target']).toBe(`
content
`) }) test('portal rendering (vnode)', async () => { const ctx: SSRContext = {} - await renderToString( + const html = await renderToString( h( Portal, { @@ -39,6 +39,28 @@ describe('ssrRenderPortal', () => { ), ctx ) - expect(ctx.portals!['#target']).toBe('hello') + expect(html).toBe('') + expect(ctx.portals!['#target']).toBe('hello') + }) + + 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('
') + expect(ctx.portals!['#target']).toBe( + 'helloworld' + ) }) }) diff --git a/packages/server-renderer/src/helpers/ssrRenderPortal.ts b/packages/server-renderer/src/helpers/ssrRenderPortal.ts index 12c2282334a..3e54d999ac1 100644 --- a/packages/server-renderer/src/helpers/ssrRenderPortal.ts +++ b/packages/server-renderer/src/helpers/ssrRenderPortal.ts @@ -2,19 +2,24 @@ import { ComponentInternalInstance, ssrContextKey } from 'vue' import { SSRContext, createBuffer, PushFn } from '../renderToString' export function ssrRenderPortal( + parentPush: PushFn, contentRenderFn: (push: PushFn) => void, target: string, parentComponent: ComponentInternalInstance ) { + parentPush('') 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()] + } } diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index 1253bbc0bfb..14666678da3 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -32,6 +32,7 @@ import { compile } from '@vue/compiler-ssr' import { ssrRenderAttrs } from './helpers/ssrRenderAttrs' import { SSRSlots } from './helpers/ssrRenderSlot' import { CompilerError } from '@vue/compiler-dom' +import { ssrRenderPortal } from './helpers/ssrRenderPortal' const { isVNode, @@ -63,10 +64,7 @@ export type Props = Record export type SSRContext = { [key: string]: any portals?: Record - __portalBuffers?: Record< - string, - ResolvedSSRBuffer | Promise - > + __portalBuffers?: Record } export function createBuffer() { @@ -259,7 +257,7 @@ function renderVNode( } 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, @@ -363,6 +361,7 @@ function applySSRDirectives( } function renderPortalVNode( + push: PushFn, vnode: VNode, parentComponent: ComponentInternalInstance ) { @@ -377,20 +376,18 @@ function renderPortalVNode( ) 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) { @@ -399,7 +396,9 @@ 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]) + ) } } }