From 9440cfbf768b5449cec9202fd035d98e26c2e9b5 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 24 Apr 2019 10:52:16 +0200 Subject: [PATCH 1/2] fix: during SSR, don't register targets or cache content --- docs/.vuepress/config.js | 1 + docs/api/wormhole.md | 24 ++++++++++++++++++++---- docs/guide/SSR.md | 31 +++++++++++++++++++++++++++++++ docs/guide/caveats.md | 21 --------------------- src/components/wormhole.ts | 7 +++++-- src/utils/index.ts | 2 ++ types/lib/utils/index.d.ts | 1 + 7 files changed, 60 insertions(+), 27 deletions(-) create mode 100644 docs/guide/SSR.md diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 59a7e08..8a9fe3d 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -31,6 +31,7 @@ module.exports = { { text: 'Getting Started', link: '/guide/getting-started' }, { text: "What's new in 2.0", link: '/guide/migration' }, { text: 'Advanced Usage', link: '/guide/advanced' }, + { text: 'SSR', link: '/guide/SSR' }, { text: 'Caveats', link: '/guide/caveats' }, ], }, diff --git a/docs/api/wormhole.md b/docs/api/wormhole.md index 5be3f6c..7c3ece2 100644 --- a/docs/api/wormhole.md +++ b/docs/api/wormhole.md @@ -10,10 +10,6 @@ The Wormhole is not a component, it's an object that connects the ``s to Usually, you will never need to use this object, but you _can_ use this object to programmatically send content to a ``, check weither a target exists, and other stuff. -:::tip Hint -This feature was introduced with version `1.1.0` -::: - :::warning Public API The wormhole object exposes quite a few properties and methods. With regard to semver, only the properties and methods documented below are considered part of the public API. @@ -62,6 +58,11 @@ This is the programmatic equivalent of the following: ``` +::: tip Server-Side Rendering +For the reasons layed out in [the section about SSR](../guide/SSR.md), this method won't do anything during Server-Side Rendering. Portal'ing of the content will only happen on the client. +Make sure to Read the linked section for more information. +::: + ### close() As the name suggests, this is the counterpart to `open()`. It's used to remove content from a ``. @@ -122,6 +123,11 @@ Wormhole.hasSource('origin') // => true/false ``` +::: tip Server-Side Rendering +For the reasons layed out in [the section about SSR](../guide/SSR.md), this method will aleways return `false` during Server-Side Rendering. +Make sure to Read the linked section for more information. +::: + ### hasTarget() `Wormhole.hasTarget(to)` @@ -135,6 +141,11 @@ Wormhole.hasTarget('destination') // => true/false ``` +::: tip Server-Side Rendering +For the reasons layed out in [the section about SSR](../guide/SSR.md), this method will aleways return `false` during Server-Side Rendering. +Make sure to Read the linked section for more information. +::: + ### hasContentFor() `Wormhole.hasContentFor(to)` @@ -147,3 +158,8 @@ Example: Wormhole.hasContentFor('destination') // => true/false ``` + +::: tip Server-Side Rendering +For the reasons layed out in [the section about SSR](../guide/SSR.md), this method will aleways return `false` during Server-Side Rendering. +Make sure to Read the linked section for more information. +::: diff --git a/docs/guide/SSR.md b/docs/guide/SSR.md new file mode 100644 index 0000000..2f3e8bc --- /dev/null +++ b/docs/guide/SSR.md @@ -0,0 +1,31 @@ +# Server-Side Rendering (SSR) + +When using [Vue's SSR capabilities](https://ssr.vuejs.org), portal-vue can't work reliably for a couple of reasons: + +1. The internal Store (the [Wormhole](../api/wormhole.md)) that's caching vnodes and connecting `` components to their `` counterparts, is a singleton. As such, changes to the Wormhole persist between requests, leading to all sorts of problems. +2. In SSR, Vue renders the page directly to a string, there are not reactive updates applied. Consequently, a `` appearing before a `` will render an empty div on the server whereas it will render the sent content on the client, resulting in a hydration vdom mismatch error, while a `` _following_ a `` would technically work. + +## Solutions + +### Disabling the portal on the server + +For the aforementioned reasons, starting with , content won't be cached in the Wormhole anymore when on the server. Consequently, the HTML rendered by the server won't contain any DOM nodes in place of any `` + +### Handling on the client + +We want to display the `` content on the client, though. In order to prevent any hydration mismatches, we can use a _really_ tiny [component called ``](https://github.com/egoist/vue-no-ssr), written by [@egoist](https://github.com/egoist), which can solve this problem. + +We wrap oour `` elements in it, and it will prevent rendering on the server as well as on the client during hydration, preventing the error described above. Immediatly _after_ hyration, it will render the previously "hidden" content, so that the `` will render its content. Usually the user can hardly notice this as the update is near-immediate. + +Example: + +```html + + + + + + + + +``` diff --git a/docs/guide/caveats.md b/docs/guide/caveats.md index 1a031c3..543cb10 100644 --- a/docs/guide/caveats.md +++ b/docs/guide/caveats.md @@ -35,24 +35,3 @@ this.$nextTick().then( ``` the reason is that depending on the secnario, it _can_ take one tick for the content to be sent to the [Wormhole](../api/wormhole.md) (the middleman between `` and ``), and another one to be picked up by the ``. - -## Server-Side Rendering - -When you use [Vue's SSR capabilities](https://ssr.vuejs.org), portal-vue can't work reliably because Vue renders the page directly to a string, there are not reactive updates applied. That means that a `` appearing before a `` will render an empty div on the server whereas it will render the sent content on the client, resulting in a hydration vdom mismatch error. - -### Solution - -[@egoist](https://github.com/egoist) has written a _really_ tiny [component called ``](https://github.com/egoist/vue-no-ssr), which can solve this problem. You wrap your `` elements in it, and it will prevent rendering on the server as well as on the client during hydration, preventing the error described above. Immediatly after hyration, it will render the previously "hidden" content, so that the `` will render its content. Usually the user can hardly notice this as the update is near-immediate. - -Example: - -```html - - - - - - - - -``` diff --git a/src/components/wormhole.ts b/src/components/wormhole.ts index cb94ada..8cc79a1 100644 --- a/src/components/wormhole.ts +++ b/src/components/wormhole.ts @@ -1,5 +1,5 @@ import Vue from 'vue' -import { freeze, stableSort } from '../utils' +import { freeze, inBrowser, stableSort } from '../utils' import { Transports, Transport, @@ -17,10 +17,11 @@ export const Wormhole = Vue.extend({ transports, targets, sources, - trackInstances: true, + trackInstances: inBrowser, }), methods: { open(transport: TransportInput) { + if (!inBrowser) return const { to, from, passengers, order = Infinity } = transport if (!to || !from || !passengers) return @@ -71,6 +72,7 @@ export const Wormhole = Vue.extend({ } }, registerTarget(target: string, vm: Vue, force?: boolean): void { + if (!inBrowser) return if (this.trackInstances && !force && this.targets[target]) { console.warn(`[portal-vue]: Target ${target} already exists`) } @@ -80,6 +82,7 @@ export const Wormhole = Vue.extend({ this.$delete(this.targets, target) }, registerSource(source: string, vm: Vue, force?: boolean): void { + if (!inBrowser) return if (this.trackInstances && !force && this.sources[source]) { console.warn(`[portal-vue]: source ${source} already exists`) } diff --git a/src/utils/index.ts b/src/utils/index.ts index 6560a4f..c5116cc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,8 @@ import { VNode } from 'vue' import { Transport } from '../types' +export const inBrowser = typeof window !== 'undefined' + export function freeze(item: R[]): ReadonlyArray { if (Array.isArray(item) || typeof item === 'object') { return Object.freeze(item) diff --git a/types/lib/utils/index.d.ts b/types/lib/utils/index.d.ts index 98e6f15..2caeffa 100644 --- a/types/lib/utils/index.d.ts +++ b/types/lib/utils/index.d.ts @@ -1,5 +1,6 @@ import { VNode } from 'vue'; import { Transport } from '../types'; +export declare const inBrowser: boolean; export declare function freeze(item: R[]): ReadonlyArray; export declare function combinePassengers(transports: Transport[], slotProps?: {}): Array; export declare function stableSort(array: T[], compareFn: Function): T[]; From 9cc2dcae3fc11b4e4a907d901b800d2e33a12d3a Mon Sep 17 00:00:00 2001 From: Thorsten Date: Wed, 24 Apr 2019 10:55:04 +0200 Subject: [PATCH 2/2] build: 2.1.2 --- dist/portal-vue.common.js | 10 ++++++++-- dist/portal-vue.common.js.map | 2 +- dist/portal-vue.esm.js | 10 ++++++++-- dist/portal-vue.esm.js.map | 2 +- dist/portal-vue.umd.js | 10 ++++++++-- dist/portal-vue.umd.min.js | 4 ++-- package.json | 2 +- 7 files changed, 29 insertions(+), 11 deletions(-) diff --git a/dist/portal-vue.common.js b/dist/portal-vue.common.js index 2bafb39..b43e48b 100644 --- a/dist/portal-vue.common.js +++ b/dist/portal-vue.common.js @@ -2,7 +2,7 @@ /*! * portal-vue © Thorsten Lünborg, 2019 * - * Version: 2.1.1 + * Version: 2.1.2 * * LICENCE: MIT * @@ -52,6 +52,7 @@ function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance"); } +var inBrowser = typeof window !== 'undefined'; function freeze(item) { if (Array.isArray(item) || _typeof(item) === 'object') { return Object.freeze(item); @@ -95,11 +96,12 @@ var Wormhole = Vue.extend({ transports: transports, targets: targets, sources: sources, - trackInstances: true + trackInstances: inBrowser }; }, methods: { open: function open(transport) { + if (!inBrowser) return; var to = transport.to, from = transport.from, passengers = transport.passengers, @@ -156,6 +158,8 @@ var Wormhole = Vue.extend({ } }, registerTarget: function registerTarget(target, vm, force) { + if (!inBrowser) return; + if (this.trackInstances && !force && this.targets[target]) { console.warn("[portal-vue]: Target ".concat(target, " already exists")); } @@ -166,6 +170,8 @@ var Wormhole = Vue.extend({ this.$delete(this.targets, target); }, registerSource: function registerSource(source, vm, force) { + if (!inBrowser) return; + if (this.trackInstances && !force && this.sources[source]) { console.warn("[portal-vue]: source ".concat(source, " already exists")); } diff --git a/dist/portal-vue.common.js.map b/dist/portal-vue.common.js.map index 6de71d7..2bc384e 100644 --- a/dist/portal-vue.common.js.map +++ b/dist/portal-vue.common.js.map @@ -1 +1 @@ -{"version":3,"file":"portal-vue.common.js","sources":["../src/utils/index.ts","../src/components/wormhole.ts","../src/components/portal.tsx","../src/components/portal-target.tsx","../src/components/mounting-portal.tsx","../src/index.ts"],"sourcesContent":["import { VNode } from 'vue'\nimport { Transport } from '../types'\n\nexport function freeze(item: R[]): ReadonlyArray {\n if (Array.isArray(item) || typeof item === 'object') {\n return Object.freeze(item)\n }\n return item\n}\n\nexport function combinePassengers(\n transports: Transport[],\n slotProps = {}\n): Array {\n return transports.reduce(\n (passengers, transport) => {\n const temp = transport.passengers[0]\n const newPassengers =\n typeof temp === 'function'\n ? (temp(slotProps) as VNode[])\n : (transport.passengers as VNode[])\n return passengers.concat(newPassengers)\n },\n [] as Array\n )\n}\n\nexport function stableSort(array: T[], compareFn: Function) {\n return array\n .map((v: T, idx: number) => {\n return [idx, v] as [number, T]\n })\n .sort(function(a, b) {\n return compareFn(a[1], b[1]) || a[0] - b[0]\n })\n .map(c => c[1])\n}\n\nexport function pick(\n obj: T,\n keys: K[]\n): Pick {\n return keys.reduce(\n (acc, key) => {\n if (obj.hasOwnProperty(key)) {\n acc[key] = obj[key]\n }\n return acc\n },\n {} as Pick\n )\n}\n","import Vue from 'vue'\nimport { freeze, stableSort } from '../utils'\nimport {\n Transports,\n Transport,\n TransportInput,\n TransportVector,\n VMRegister,\n} from '../types'\n\nconst transports: Transports = {}\nconst targets: VMRegister = {}\nconst sources: VMRegister = {}\n\nexport const Wormhole = Vue.extend({\n data: () => ({\n transports,\n targets,\n sources,\n trackInstances: true,\n }),\n methods: {\n open(transport: TransportInput) {\n const { to, from, passengers, order = Infinity } = transport\n if (!to || !from || !passengers) return\n\n const newTransport = {\n to,\n from,\n passengers: freeze(passengers),\n order,\n } as Transport\n const keys = Object.keys(this.transports)\n\n if (keys.indexOf(to) === -1) {\n Vue.set(this.transports, to, [])\n }\n\n const currentIndex = this.$_getTransportIndex(newTransport)\n // Copying the array here so that the PortalTarget change event will actually contain two distinct arrays\n const newTransports = this.transports[to].slice(0)\n if (currentIndex === -1) {\n newTransports.push(newTransport)\n } else {\n newTransports[currentIndex] = newTransport\n }\n\n this.transports[to] = stableSort(\n newTransports,\n (a: Transport, b: Transport) => a.order - b.order\n )\n },\n\n close(transport: TransportVector, force = false) {\n const { to, from } = transport\n if (!to || !from) return\n if (!this.transports[to]) {\n return\n }\n\n if (force) {\n this.transports[to] = []\n } else {\n const index = this.$_getTransportIndex(transport)\n if (index >= 0) {\n // Copying the array here so that the PortalTarget change event will actually contain two distinct arrays\n const newTransports = this.transports[to].slice(0)\n newTransports.splice(index, 1)\n this.transports[to] = newTransports\n }\n }\n },\n registerTarget(target: string, vm: Vue, force?: boolean): void {\n if (this.trackInstances && !force && this.targets[target]) {\n console.warn(`[portal-vue]: Target ${target} already exists`)\n }\n this.$set(this.targets, target, Object.freeze([vm]))\n },\n unregisterTarget(target: string) {\n this.$delete(this.targets, target)\n },\n registerSource(source: string, vm: Vue, force?: boolean): void {\n if (this.trackInstances && !force && this.sources[source]) {\n console.warn(`[portal-vue]: source ${source} already exists`)\n }\n this.$set(this.sources, source, Object.freeze([vm]))\n },\n unregisterSource(source: string) {\n this.$delete(this.sources, source)\n },\n hasTarget(to: string) {\n return !!(this.targets[to] && this.targets[to][0])\n },\n hasSource(to: string) {\n return !!(this.sources[to] && this.sources[to][0])\n },\n hasContentFor(to: string) {\n return !!this.transports[to] && !!this.transports[to].length\n },\n // Internal\n $_getTransportIndex({ to, from }: TransportVector): number {\n for (const i in this.transports[to]) {\n if (this.transports[to][i].from === from) {\n return +i\n }\n }\n return -1\n },\n },\n})\n\nconst wormhole = new Wormhole(transports)\nexport { wormhole }\n","import Vue from 'vue'\nimport { VNode } from 'vue'\nimport { TransportInput, TransportVector } from '../types'\nimport { wormhole } from './wormhole'\n\nlet _id = 1\n\nexport default Vue.extend({\n name: 'portal',\n props: {\n disabled: { type: Boolean },\n name: { type: String, default: () => String(_id++) },\n order: { type: Number, default: 0 },\n slim: { type: Boolean },\n slotProps: { type: Object, default: () => ({}) },\n tag: { type: String, default: 'DIV' },\n to: {\n type: String,\n default: () => String(Math.round(Math.random() * 10000000)),\n },\n },\n created() {\n wormhole.registerSource(this.name, this)\n },\n mounted() {\n if (!this.disabled) {\n this.sendUpdate()\n }\n },\n\n updated() {\n if (this.disabled) {\n this.clear()\n } else {\n this.sendUpdate()\n }\n },\n\n beforeDestroy() {\n wormhole.unregisterSource(this.name)\n this.clear()\n },\n watch: {\n to(newValue: string, oldValue: string): void {\n oldValue && oldValue !== newValue && this.clear(oldValue)\n this.sendUpdate()\n },\n },\n\n methods: {\n clear(target?: string) {\n const closer: TransportVector = {\n from: this.name,\n to: target || this.to,\n }\n wormhole.close(closer)\n },\n normalizeSlots(): Function[] | VNode[] | undefined {\n return this.$scopedSlots.default\n ? [this.$scopedSlots.default]\n : this.$slots.default\n },\n normalizeOwnChildren(children: VNode[] | Function): VNode[] {\n return typeof children === 'function'\n ? children(this.slotProps)\n : children\n },\n sendUpdate() {\n const slotContent = this.normalizeSlots()\n if (slotContent) {\n const transport: TransportInput = {\n from: this.name,\n to: this.to,\n passengers: [...slotContent],\n order: this.order,\n }\n wormhole.open(transport)\n } else {\n this.clear()\n }\n },\n },\n\n render(h): VNode {\n const children: VNode[] | Function =\n this.$slots.default || this.$scopedSlots.default || []\n const Tag = this.tag\n if (children && this.disabled) {\n return children.length <= 1 && this.slim ? (\n this.normalizeOwnChildren(children)[0]\n ) : (\n {this.normalizeOwnChildren(children)}\n )\n } else {\n return this.slim\n ? h()\n : h(Tag, {\n class: { 'v-portal': true },\n style: { display: 'none' },\n key: 'v-portal-placeholder',\n })\n }\n },\n})\n","import Vue from 'vue'\nimport { VNode, PropOptions } from 'vue'\nimport { combinePassengers } from '@/utils'\nimport { Transport, PropWithComponent } from '../types'\n\nimport { wormhole } from '@/components/wormhole'\n\nexport default Vue.extend({\n name: 'portalTarget',\n props: {\n multiple: { type: Boolean, default: false },\n name: { type: String, required: true },\n slim: { type: Boolean, default: false },\n slotProps: { type: Object, default: () => ({}) },\n tag: { type: String, default: 'div' },\n transition: { type: [String, Object, Function] } as PropOptions<\n PropWithComponent\n >,\n },\n data() {\n return {\n transports: wormhole.transports,\n firstRender: true,\n }\n },\n created() {\n wormhole.registerTarget(this.name, this)\n },\n watch: {\n ownTransports() {\n this.$emit('change', this.children().length > 0)\n },\n name(newVal, oldVal) {\n /**\n * TODO\n * This should warn as well ...\n */\n wormhole.unregisterTarget(oldVal)\n wormhole.registerTarget(newVal, this)\n },\n },\n mounted() {\n if (this.transition) {\n this.$nextTick(() => {\n // only when we have a transition, because it causes a re-render\n this.firstRender = false\n })\n }\n },\n beforeDestroy() {\n wormhole.unregisterTarget(this.name)\n },\n\n computed: {\n ownTransports(): Transport[] {\n const transports: Transport[] = this.transports[this.name] || []\n if (this.multiple) {\n return transports\n }\n return transports.length === 0 ? [] : [transports[transports.length - 1]]\n },\n passengers(): VNode[] {\n return combinePassengers(this.ownTransports, this.slotProps)\n },\n },\n\n methods: {\n // can't be a computed prop because it has to \"react\" to $slot changes.\n children(): VNode[] {\n return this.passengers.length !== 0\n ? this.passengers\n : this.$scopedSlots.default\n ? (this.$scopedSlots.default(this.slotProps) as VNode[])\n : this.$slots.default || []\n },\n // can't be a computed prop because it has to \"react\" to this.children().\n noWrapper() {\n const noWrapper = this.slim && !this.transition\n if (noWrapper && this.children().length > 1) {\n console.warn(\n '[portal-vue]: PortalTarget with `slim` option received more than one child element.'\n )\n }\n return noWrapper\n },\n },\n render(h): VNode {\n const noWrapper = this.noWrapper()\n const children = this.children()\n const Tag = this.transition || this.tag\n\n return noWrapper\n ? children[0]\n : this.slim && !Tag\n ? h()\n : h(\n Tag,\n {\n props: {\n // if we have a transition component, pass the tag if it exists\n tag: this.transition && this.tag ? this.tag : undefined,\n },\n class: { 'vue-portal-target': true },\n },\n\n children\n )\n },\n})\n","import Vue from 'vue'\nimport { VNode, VueConstructor, PropOptions } from 'vue'\nimport Portal from './portal'\nimport PortalTarget from './portal-target'\nimport { wormhole } from './wormhole'\nimport { pick } from '@/utils'\n\nimport { PropWithComponent } from '../types'\n\nlet _id = 0\n\nexport type withPortalTarget = VueConstructor<\n Vue & {\n portalTarget: any\n }\n>\n\nconst portalProps = [\n 'disabled',\n 'name',\n 'order',\n 'slim',\n 'slotProps',\n 'tag',\n 'to',\n]\n\nconst targetProps = ['multiple', 'transition']\n\nexport default (Vue as withPortalTarget).extend({\n name: 'MountingPortal',\n inheritAttrs: false,\n props: {\n append: { type: [Boolean, String] },\n bail: {\n type: Boolean,\n },\n mountTo: { type: String, required: true },\n\n // Portal\n disabled: { type: Boolean },\n // name for the portal\n name: {\n type: String,\n default: () => 'mounted_' + String(_id++),\n },\n order: { type: Number, default: 0 },\n slim: { type: Boolean },\n slotProps: { type: Object, default: () => ({}) },\n tag: { type: String, default: 'DIV' },\n // name for the target\n to: {\n type: String,\n default: () => String(Math.round(Math.random() * 10000000)),\n },\n\n // Target\n multiple: { type: Boolean, default: false },\n targetSlotProps: { type: Object, default: () => ({}) },\n targetTag: { type: String, default: 'div' },\n transition: { type: [String, Object, Function] } as PropOptions<\n PropWithComponent\n >,\n transitionGroup: { type: Boolean },\n },\n created() {\n if (typeof document === 'undefined') return\n let el: HTMLElement | null = document.querySelector(this.mountTo)\n\n if (!el) {\n console.error(\n `[portal-vue]: Mount Point '${this.mountTo}' not found in document`\n )\n return\n }\n\n const props = this.$props\n\n // Target already exists\n if (wormhole.targets[props.name]) {\n if (props.bail) {\n console.warn(`[portal-vue]: Target ${props.name} is already mounted.\n Aborting because 'bail: true' is set`)\n } else {\n this.portalTarget = wormhole.targets[props.name]\n }\n return\n }\n\n const { append } = props\n if (append) {\n const type = typeof append === 'string' ? append : 'DIV'\n const mountEl = document.createElement(type)\n el.appendChild(mountEl)\n el = mountEl\n }\n\n // get props for target from $props\n // we have to rename a few of them\n const _props = pick(this.$props, targetProps)\n _props.tag = this.targetTag\n _props.slotSprop = this.targetSlotProps\n _props.name = this.to\n\n this.portalTarget = new PortalTarget({\n el,\n parent: this.$parent || this,\n propsData: _props,\n })\n },\n\n beforeDestroy() {\n const target = this.portalTarget\n if (this.append) {\n const el = target.$el\n el.parentNode.removeChild(el)\n }\n target.$destroy()\n },\n\n render(h): VNode {\n if (!this.portalTarget) {\n console.warn(\"[portal-vue] Target wasn't mounted\")\n return h()\n }\n\n // if there's no \"manual\" scoped slot, so we create a ourselves\n if (!this.$scopedSlots.manual) {\n const props = pick(this.$props, portalProps)\n return h(\n Portal,\n {\n props: props,\n attrs: this.$attrs,\n on: this.$listeners,\n scopedSlots: this.$scopedSlots,\n },\n this.$slots.default\n )\n }\n\n // else, we render the scoped slot\n let content: VNode = (this.$scopedSlots.manual({\n to: this.to,\n }) as unknown) as VNode\n\n // if user used