]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(portal): SSR support for multi portal shared target
authorEvan You <yyx990803@gmail.com>
Sat, 28 Mar 2020 00:49:01 +0000 (20:49 -0400)
committerEvan You <yyx990803@gmail.com>
Sat, 28 Mar 2020 00:49:01 +0000 (20:49 -0400)
packages/compiler-ssr/__tests__/ssrPortal.spec.ts
packages/compiler-ssr/src/transforms/ssrTransformPortal.ts
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/hydration.ts
packages/server-renderer/__tests__/ssrPortal.spec.ts
packages/server-renderer/src/helpers/ssrRenderPortal.ts
packages/server-renderer/src/renderToString.ts

index 5490649d57f5565d5bb2d937d408ef396ca2e29c..7f608f91442c98d100381b8383e0f97d9b20d731 100644 (file)
@@ -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(\`<div></div>\`)
         }, _ctx.target, _parent)
       }"
index c380e672aba502d9ca829f081a3e1e24c95adecc..8c7fa063b6cc6153dd405286b78c8a6f80ab7741 100644 (file)
@@ -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`
index 84a95b41e492c31b5365882232aba435f7904a24..1b9440398361f556f7ecdd56bae4505aa8108d23 100644 (file)
@@ -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 = `<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-->', () =>
@@ -182,7 +183,69 @@ describe('SSR hydration', () => {
     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><!---->"`
     )
   })
 
index 37933eca5a4a6d3515ab99956d99a48705218728..3cbe98758fba2d474d0d8f00113ef174eab7ee36 100644 (file)
@@ -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 ` +
index c26d60cc962d863f260c935b17f11a89fa92534e..45314c2b464d2d4aac37499085d0fa5e93c7bee3 100644 (file)
@@ -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(`<div>content</div>`)
             },
@@ -24,12 +23,13 @@ describe('ssrRenderPortal', () => {
       }),
       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,
         {
@@ -39,6 +39,28 @@ describe('ssrRenderPortal', () => {
       ),
       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<!---->'
+    )
   })
 })
index 12c2282334aa89eaba90868a6c007649c785accc..3e54d999ac1eacbc1d5eb70ebda22c28fd2417f8 100644 (file)
@@ -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('<!--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()]
+  }
 }
index 1253bbc0bfb6b9691ac5a1d92dab4cf3d0966953..14666678da3b78ca59b64432017bb3bae94c59ce 100644 (file)
@@ -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<string, unknown>
 export type SSRContext = {
   [key: string]: any
   portals?: Record<string, string>
-  __portalBuffers?: Record<
-    string,
-    ResolvedSSRBuffer | Promise<ResolvedSSRBuffer>
-  >
+  __portalBuffers?: Record<string, SSRBuffer>
 }
 
 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])
+      )
     }
   }
 }