]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(portal): hydration support for portal disabled mode
authorEvan You <yyx990803@gmail.com>
Mon, 30 Mar 2020 15:23:59 +0000 (11:23 -0400)
committerEvan You <yyx990803@gmail.com>
Mon, 30 Mar 2020 15:24:29 +0000 (11:24 -0400)
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/components/Portal.ts
packages/runtime-core/src/hydration.ts

index 1b9440398361f556f7ecdd56bae4505aa8108d23..510c14e02c6306e8a986d6a3cb28695cf7e65c2d 100644 (file)
@@ -161,20 +161,26 @@ describe('SSR hydration', () => {
     portalContainer.innerHTML = `<span>foo</span><span class="foo"></span><!---->`
     document.body.appendChild(portalContainer)
 
-    const { vnode, container } = mountWithHydration('<!--portal-->', () =>
-      h(Portal, { target: '#portal' }, [
-        h('span', msg.value),
-        h('span', { class: msg.value, onClick: fn })
-      ])
+    const { vnode, container } = mountWithHydration(
+      '<!--portal start--><!--portal end-->',
+      () =>
+        h(Portal, { target: '#portal' }, [
+          h('span', msg.value),
+          h('span', { class: msg.value, onClick: fn })
+        ])
     )
 
     expect(vnode.el).toBe(container.firstChild)
+    expect(vnode.anchor).toBe(container.lastChild)
+
+    expect(vnode.target).toBe(portalContainer)
     expect((vnode.children as VNode[])[0].el).toBe(
       portalContainer.childNodes[0]
     )
     expect((vnode.children as VNode[])[1].el).toBe(
       portalContainer.childNodes[1]
     )
+    expect(vnode.targetAnchor).toBe(portalContainer.childNodes[2])
 
     // event handler
     triggerEvent('click', portalContainer.querySelector('.foo')!)
@@ -208,7 +214,7 @@ describe('SSR hydration', () => {
     const ctx: SSRContext = {}
     const mainHtml = await renderToString(h(Comp), ctx)
     expect(mainHtml).toMatchInlineSnapshot(
-      `"<!--[--><!--portal--><!--portal--><!--]-->"`
+      `"<!--[--><!--portal start--><!--portal end--><!--portal start--><!--portal end--><!--]-->"`
     )
 
     const portalHtml = ctx.portals!['#portal2']
@@ -224,16 +230,21 @@ describe('SSR hydration', () => {
     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.anchor).toBe(container.childNodes[2])
+    expect(portalVnode2.el).toBe(container.childNodes[3])
+    expect(portalVnode2.anchor).toBe(container.childNodes[4])
 
+    expect(portalVnode1.target).toBe(portalContainer)
     expect((portalVnode1 as any).children[0].el).toBe(
       portalContainer.childNodes[0]
     )
-    expect(portalVnode1.anchor).toBe(portalContainer.childNodes[2])
+    expect(portalVnode1.targetAnchor).toBe(portalContainer.childNodes[2])
+
+    expect(portalVnode2.target).toBe(portalContainer)
     expect((portalVnode2 as any).children[0].el).toBe(
       portalContainer.childNodes[3]
     )
-    expect(portalVnode2.anchor).toBe(portalContainer.childNodes[5])
+    expect(portalVnode2.targetAnchor).toBe(portalContainer.childNodes[5])
 
     // // event handler
     triggerEvent('click', portalContainer.querySelector('.foo')!)
@@ -249,6 +260,68 @@ describe('SSR hydration', () => {
     )
   })
 
+  test('Portal (disabled)', async () => {
+    const msg = ref('foo')
+    const fn1 = jest.fn()
+    const fn2 = jest.fn()
+
+    const Comp = () => [
+      h('div', 'foo'),
+      h(Portal, { target: '#portal3', disabled: true }, [
+        h('span', msg.value),
+        h('span', { class: msg.value, onClick: fn1 })
+      ]),
+      h('div', { class: msg.value + '2', onClick: fn2 }, 'bar')
+    ]
+
+    const portalContainer = document.createElement('div')
+    portalContainer.id = 'portal3'
+    const ctx: SSRContext = {}
+    const mainHtml = await renderToString(h(Comp), ctx)
+    expect(mainHtml).toMatchInlineSnapshot(
+      `"<!--[--><div>foo</div><!--portal start--><span>foo</span><span class=\\"foo\\"></span><!--portal end--><div class=\\"foo2\\">bar</div><!--]-->"`
+    )
+
+    const portalHtml = ctx.portals!['#portal3']
+    expect(portalHtml).toMatchInlineSnapshot(`"<!---->"`)
+
+    portalContainer.innerHTML = portalHtml
+    document.body.appendChild(portalContainer)
+
+    const { vnode, container } = mountWithHydration(mainHtml, Comp)
+    expect(vnode.el).toBe(container.firstChild)
+    const children = vnode.children as VNode[]
+
+    expect(children[0].el).toBe(container.childNodes[1])
+
+    const portalVnode = children[1]
+    expect(portalVnode.el).toBe(container.childNodes[2])
+    expect((portalVnode.children as VNode[])[0].el).toBe(
+      container.childNodes[3]
+    )
+    expect((portalVnode.children as VNode[])[1].el).toBe(
+      container.childNodes[4]
+    )
+    expect(portalVnode.anchor).toBe(container.childNodes[5])
+    expect(children[2].el).toBe(container.childNodes[6])
+
+    expect(portalVnode.target).toBe(portalContainer)
+    expect(portalVnode.targetAnchor).toBe(portalContainer.childNodes[0])
+
+    // // event handler
+    triggerEvent('click', container.querySelector('.foo')!)
+    expect(fn1).toHaveBeenCalled()
+
+    triggerEvent('click', container.querySelector('.foo2')!)
+    expect(fn2).toHaveBeenCalled()
+
+    msg.value = 'bar'
+    await nextTick()
+    expect(container.innerHTML).toMatchInlineSnapshot(
+      `"<!--[--><div>foo</div><!--portal start--><span>bar</span><span class=\\"bar\\"></span><!--portal end--><div class=\\"bar2\\">bar</div><!--]-->"`
+    )
+  })
+
   // compile SSR + client render fn from the same template & hydrate
   test('full compiler integration', async () => {
     const mounted: string[] = []
index 85ca6ef1f65c7b155808865e9907111927c20665..9a4241a3ce87a58e7dd51f6ce25ab94e0e32eec8 100644 (file)
@@ -4,64 +4,51 @@ import {
   RendererInternals,
   MoveType,
   RendererElement,
-  RendererNode
+  RendererNode,
+  RendererOptions
 } from '../renderer'
 import { VNode, VNodeArrayChildren, VNodeProps } from '../vnode'
 import { isString, ShapeFlags } from '@vue/shared'
 import { warn } from '../warning'
 
-export const isPortal = (type: any): boolean => type.__isPortal
-
 export interface PortalProps {
-  target: string | object
+  target: string | RendererElement
   disabled?: boolean
 }
 
-export const enum PortalMoveTypes {
-  TARGET_CHANGE,
-  TOGGLE, // enable / disable
-  REORDER // moved in the main view
-}
+export const isPortal = (type: any): boolean => type.__isPortal
 
-const isDisabled = (props: VNode['props']): boolean =>
+const isPortalDisabled = (props: VNode['props']): boolean =>
   props && (props.disabled || props.disabled === '')
 
-const movePortal = (
-  vnode: VNode,
-  container: RendererElement,
-  parentAnchor: RendererNode | null,
-  { o: { insert }, m: move }: RendererInternals,
-  moveType: PortalMoveTypes = PortalMoveTypes.REORDER
-) => {
-  // move target anchor if this is a target change.
-  if (moveType === PortalMoveTypes.TARGET_CHANGE) {
-    insert(vnode.targetAnchor!, container, parentAnchor)
-  }
-  const { el, anchor, shapeFlag, children, props } = vnode
-  const isReorder = moveType === PortalMoveTypes.REORDER
-  // move main view anchor if this is a re-order.
-  if (isReorder) {
-    insert(el!, container, parentAnchor)
-  }
-  // if this is a re-order and portal is enabled (content is in target)
-  // do not move children. So the opposite is: only move children if this
-  // is not a reorder, or the portal is disabled
-  if (!isReorder || isDisabled(props)) {
-    // Portal has either Array children or no children.
-    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
-      for (let i = 0; i < (children as VNode[]).length; i++) {
-        move(
-          (children as VNode[])[i],
-          container,
-          parentAnchor,
-          MoveType.REORDER
+const resolveTarget = <T = RendererElement>(
+  props: PortalProps | null,
+  select: RendererOptions['querySelector']
+): T | null => {
+  const targetSelector = props && props.target
+  if (isString(targetSelector)) {
+    if (!select) {
+      __DEV__ &&
+        warn(
+          `Current renderer does not support string target for Portals. ` +
+            `(missing querySelector renderer option)`
         )
+      return null
+    } else {
+      const target = select(targetSelector)
+      if (!target) {
+        __DEV__ &&
+          warn(
+            `Failed to locate Portal target with selector "${targetSelector}".`
+          )
       }
+      return target as any
     }
-  }
-  // move main view anchor if this is a re-order.
-  if (isReorder) {
-    insert(anchor!, container, parentAnchor)
+  } else {
+    if (__DEV__ && !targetSelector) {
+      warn(`Invalid Portal target: ${targetSelector}`)
+    }
+    return targetSelector as any
   }
 }
 
@@ -85,16 +72,9 @@ export const PortalImpl = {
       o: { insert, querySelector, createText, createComment }
     } = internals
 
-    const targetSelector = n2.props && n2.props.target
-    const disabled = isDisabled(n2.props)
+    const disabled = isPortalDisabled(n2.props)
     const { shapeFlag, children } = n2
     if (n1 == null) {
-      if (__DEV__ && isString(targetSelector) && !querySelector) {
-        warn(
-          `Current renderer does not support string target for Portals. ` +
-            `(missing querySelector renderer option)`
-        )
-      }
       // insert anchors in the main view
       const placeholder = (n2.el = __DEV__
         ? createComment('portal start')
@@ -104,11 +84,11 @@ export const PortalImpl = {
         : createText(''))
       insert(placeholder, container, anchor)
       insert(mainAnchor, container, anchor)
-      // portal content needs an anchor to support patching multiple portals
-      // appending to the same target element.
-      const target = (n2.target = isString(targetSelector)
-        ? querySelector!(targetSelector)
-        : targetSelector)
+
+      const target = (n2.target = resolveTarget(
+        n2.props as PortalProps,
+        querySelector
+      ))
       const targetAnchor = (n2.targetAnchor = createText(''))
       if (target) {
         insert(targetAnchor, target)
@@ -143,7 +123,7 @@ export const PortalImpl = {
       const mainAnchor = (n2.anchor = n1.anchor)!
       const target = (n2.target = n1.target)!
       const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
-      const wasDisabled = isDisabled(n1.props)
+      const wasDisabled = isPortalDisabled(n1.props)
       const currentContainer = wasDisabled ? container : target
       const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
 
@@ -183,10 +163,11 @@ export const PortalImpl = {
         }
       } else {
         // target changed
-        if (targetSelector !== (n1.props && n1.props.target)) {
-          const nextTarget = (n2.target = isString(targetSelector)
-            ? querySelector!(targetSelector)
-            : targetSelector)
+        if ((n2.props && n2.props.target) !== (n1.props && n1.props.target)) {
+          const nextTarget = (n2.target = resolveTarget(
+            n2.props as PortalProps,
+            querySelector
+          ))
           if (nextTarget) {
             movePortal(
               n2,
@@ -230,7 +211,114 @@ export const PortalImpl = {
     }
   },
 
-  move: movePortal
+  move: movePortal,
+  hydrate: hydratePortal
+}
+
+export const enum PortalMoveTypes {
+  TARGET_CHANGE,
+  TOGGLE, // enable / disable
+  REORDER // moved in the main view
+}
+
+function movePortal(
+  vnode: VNode,
+  container: RendererElement,
+  parentAnchor: RendererNode | null,
+  { o: { insert }, m: move }: RendererInternals,
+  moveType: PortalMoveTypes = PortalMoveTypes.REORDER
+) {
+  // move target anchor if this is a target change.
+  if (moveType === PortalMoveTypes.TARGET_CHANGE) {
+    insert(vnode.targetAnchor!, container, parentAnchor)
+  }
+  const { el, anchor, shapeFlag, children, props } = vnode
+  const isReorder = moveType === PortalMoveTypes.REORDER
+  // move main view anchor if this is a re-order.
+  if (isReorder) {
+    insert(el!, container, parentAnchor)
+  }
+  // if this is a re-order and portal is enabled (content is in target)
+  // do not move children. So the opposite is: only move children if this
+  // is not a reorder, or the portal is disabled
+  if (!isReorder || isPortalDisabled(props)) {
+    // Portal has either Array children or no children.
+    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
+      for (let i = 0; i < (children as VNode[]).length; i++) {
+        move(
+          (children as VNode[])[i],
+          container,
+          parentAnchor,
+          MoveType.REORDER
+        )
+      }
+    }
+  }
+  // move main view anchor if this is a re-order.
+  if (isReorder) {
+    insert(anchor!, container, parentAnchor)
+  }
+}
+
+interface PortalTargetElement extends Element {
+  // last portal target
+  _lpa?: Node | null
+}
+
+function hydratePortal(
+  node: Node,
+  vnode: VNode,
+  parentComponent: ComponentInternalInstance | null,
+  parentSuspense: SuspenseBoundary | null,
+  optimized: boolean,
+  {
+    o: { nextSibling, parentNode, querySelector }
+  }: RendererInternals<Node, Element>,
+  hydrateChildren: (
+    node: Node | null,
+    vnode: VNode,
+    container: Element,
+    parentComponent: ComponentInternalInstance | null,
+    parentSuspense: SuspenseBoundary | null,
+    optimized: boolean
+  ) => Node | null
+): Node | null {
+  const target = (vnode.target = resolveTarget<Element>(
+    vnode.props as PortalProps,
+    querySelector
+  ))
+  if (target) {
+    // 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
+    const targetNode = (target as PortalTargetElement)._lpa || target.firstChild
+    if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
+      if (isPortalDisabled(vnode.props)) {
+        vnode.anchor = hydrateChildren(
+          nextSibling(node),
+          vnode,
+          parentNode(node)!,
+          parentComponent,
+          parentSuspense,
+          optimized
+        )
+        vnode.targetAnchor = targetNode
+      } else {
+        vnode.anchor = nextSibling(node)
+        vnode.targetAnchor = hydrateChildren(
+          targetNode,
+          vnode,
+          target,
+          parentComponent,
+          parentSuspense,
+          optimized
+        )
+      }
+      ;(target as PortalTargetElement)._lpa = nextSibling(
+        vnode.targetAnchor as Node
+      )
+    }
+  }
+  return vnode.anchor && nextSibling(vnode.anchor as Node)
 }
 
 // Force-casted public typing for h and TSX props inference
index 3cbe98758fba2d474d0d8f00113ef174eab7ee36..55d6e558598da056f8783446e7c8f2f3e2201d3a 100644 (file)
@@ -8,23 +8,17 @@ import {
   VNodeHook
 } from './vnode'
 import { flushPostFlushCbs } from './scheduler'
-import { ComponentInternalInstance } from './component'
+import { ComponentOptions, ComponentInternalInstance } from './component'
 import { invokeDirectiveHook } from './directives'
 import { warn } from './warning'
-import {
-  PatchFlags,
-  ShapeFlags,
-  isReservedProp,
-  isOn,
-  isString
-} from '@vue/shared'
+import { PatchFlags, ShapeFlags, isReservedProp, isOn } from '@vue/shared'
 import { RendererInternals, invokeVNodeHook } from './renderer'
 import {
   SuspenseImpl,
   SuspenseBoundary,
   queueEffectWithSuspense
 } from './components/Suspense'
-import { ComponentOptions } from './apiOptions'
+import { PortalImpl } from './components/Portal'
 
 export type RootHydrateFunction = (
   vnode: VNode<Node, Element>,
@@ -182,8 +176,15 @@ export function createHydrationFunctions(
           if (domType !== DOMNodeTypes.COMMENT) {
             return onMismatch()
           }
-          hydratePortal(vnode, parentComponent, parentSuspense, optimized)
-          return nextSibling(node)
+          return (vnode.type as typeof PortalImpl).hydrate(
+            node,
+            vnode,
+            parentComponent,
+            parentSuspense,
+            optimized,
+            rendererInternals,
+            hydrateChildren
+          )
         } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
           return (vnode.type as typeof SuspenseImpl).hydrate(
             node,
@@ -366,41 +367,6 @@ export function createHydrationFunctions(
     }
   }
 
-  interface PortalTargetElement extends Element {
-    // last portal target
-    _lpa?: Node | null
-  }
-
-  const hydratePortal = (
-    vnode: VNode,
-    parentComponent: ComponentInternalInstance | null,
-    parentSuspense: SuspenseBoundary | null,
-    optimized: boolean
-  ) => {
-    const targetSelector = vnode.props && vnode.props.target
-    const target = (vnode.target = isString(targetSelector)
-      ? document.querySelector(targetSelector)
-      : targetSelector)
-    if (target && vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
-      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 ` +
-          `exist in server-rendered markup.`
-      )
-    }
-  }
-
   const handleMismtach = (
     node: Node,
     vnode: VNode,