From b74bab216c3be68ab046451cf5e5b5bec5f19483 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 30 Mar 2020 11:23:59 -0400 Subject: [PATCH] feat(portal): hydration support for portal disabled mode --- .../runtime-core/__tests__/hydration.spec.ts | 91 +++++++- .../runtime-core/src/components/Portal.ts | 212 +++++++++++++----- packages/runtime-core/src/hydration.ts | 58 +---- 3 files changed, 244 insertions(+), 117 deletions(-) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 1b944039836..510c14e02c6 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -161,20 +161,26 @@ describe('SSR hydration', () => { portalContainer.innerHTML = `foo` document.body.appendChild(portalContainer) - const { vnode, container } = mountWithHydration('', () => - h(Portal, { target: '#portal' }, [ - h('span', msg.value), - h('span', { class: msg.value, onClick: fn }) - ]) + const { vnode, container } = mountWithHydration( + '', + () => + 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( - `""` + `""` ) 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( + `"
foo
foo
bar
"` + ) + + 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( + `"
foo
bar
bar
"` + ) + }) + // compile SSR + client render fn from the same template & hydrate test('full compiler integration', async () => { const mounted: string[] = [] diff --git a/packages/runtime-core/src/components/Portal.ts b/packages/runtime-core/src/components/Portal.ts index 85ca6ef1f65..9a4241a3ce8 100644 --- a/packages/runtime-core/src/components/Portal.ts +++ b/packages/runtime-core/src/components/Portal.ts @@ -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 = ( + 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, + 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( + 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 diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 3cbe98758fb..55d6e558598 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -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, @@ -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,