From dbb715afdceb3a795d37f71fdc9655af0d4a7412 Mon Sep 17 00:00:00 2001 From: Joseph Nields Date: Mon, 30 Mar 2020 15:27:00 -0700 Subject: [PATCH] feat: add support of arbitrary mounting point via attachTo option This allows for users to specify where in the document their component should attach, either through a CSS selector string or a provided HTMLElement. This option is passed through directly to the vm.$mount method that is called as part of mount.js. This enables testing of SSR code with Vue test utils as well as rendering of applications via Vue test utiles in contexts that aren't 100% Vue fixes #1492 --- flow/options.flow.js | 2 + packages/shared/validate-options.js | 20 ++++++- packages/shared/validators.js | 8 +++ packages/test-utils/src/mount.js | 5 +- test/specs/mount.spec.js | 2 + test/specs/mounting-options/attachTo.spec.js | 62 ++++++++++++++++++++ 6 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 test/specs/mounting-options/attachTo.spec.js diff --git a/flow/options.flow.js b/flow/options.flow.js index 92f1a7231..992cf3d8b 100644 --- a/flow/options.flow.js +++ b/flow/options.flow.js @@ -1,6 +1,7 @@ declare type Options = { // eslint-disable-line no-undef attachToDocument?: boolean, + attachTo?: HTMLElement | string, propsData?: Object, mocks?: Object, methods?: { [key: string]: Function }, @@ -17,6 +18,7 @@ declare type Options = { } declare type NormalizedOptions = { + attachTo?: HTMLElement | string, attachToDocument?: boolean, propsData?: Object, mocks: Object, diff --git a/packages/shared/validate-options.js b/packages/shared/validate-options.js index 1ad9c90ac..452d64eb4 100644 --- a/packages/shared/validate-options.js +++ b/packages/shared/validate-options.js @@ -1,11 +1,13 @@ import { isPlainObject, isFunctionalComponent, - isConstructor + isConstructor, + isDomSelector, + isHTMLElement } from './validators' import { VUE_VERSION } from './consts' import { compileTemplateForSlots } from './compile-template' -import { throwError } from './util' +import { throwError, warn } from './util' import { validateSlots } from './validate-slots' function vueExtendUnsupportedOption(option) { @@ -22,6 +24,20 @@ function vueExtendUnsupportedOption(option) { const UNSUPPORTED_VERSION_OPTIONS = ['mocks', 'stubs', 'localVue'] export function validateOptions(options, component) { + if ( + options.attachTo && + !isHTMLElement(options.attachTo) && + !isDomSelector(options.attachTo) + ) { + throwError( + `options.attachTo should be a valid HTMLElement or CSS selector string` + ) + } + if ('attachToDocument' in options) { + warn( + `options.attachToDocument is deprecated in favor of options.attachTo and will be removed in a future release` + ) + } if (options.parentComponent && !isPlainObject(options.parentComponent)) { throwError( `options.parentComponent should be a valid Vue component options object` diff --git a/packages/shared/validators.js b/packages/shared/validators.js index 09fcb384b..55da960d6 100644 --- a/packages/shared/validators.js +++ b/packages/shared/validators.js @@ -112,6 +112,14 @@ export function isPlainObject(c: any): boolean { return Object.prototype.toString.call(c) === '[object Object]' } +export function isHTMLElement(c: any): boolean { + if (typeof HTMLElement === 'undefined') { + return false + } + // eslint-disable-next-line no-undef + return c instanceof HTMLElement +} + export function isRequiredComponent(name: string): boolean { return ( name === 'KeepAlive' || name === 'Transition' || name === 'TransitionGroup' diff --git a/packages/test-utils/src/mount.js b/packages/test-utils/src/mount.js index c6dab106d..0a0ef4128 100644 --- a/packages/test-utils/src/mount.js +++ b/packages/test-utils/src/mount.js @@ -28,7 +28,8 @@ export default function mount(component, options = {}) { const parentVm = createInstance(component, mergedOptions, _Vue) - const el = options.attachToDocument ? createElement() : undefined + const el = + options.attachTo || (options.attachToDocument ? createElement() : undefined) const vm = parentVm.$mount(el) component._Ctor = {} @@ -36,7 +37,7 @@ export default function mount(component, options = {}) { throwIfInstancesThrew(vm) const wrapperOptions = { - attachedToDocument: !!mergedOptions.attachToDocument + attachedToDocument: !!el } const root = parentVm.$options._isFunctionalContainer diff --git a/test/specs/mount.spec.js b/test/specs/mount.spec.js index 279f9224a..d9869c512 100644 --- a/test/specs/mount.spec.js +++ b/test/specs/mount.spec.js @@ -293,6 +293,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => { expect(wrapper.vm.$options.context).to.equal(undefined) expect(wrapper.vm.$options.attrs).to.equal(undefined) expect(wrapper.vm.$options.listeners).to.equal(undefined) + wrapper.destroy() }) itDoNotRunIf(vueVersion < 2.3, 'injects store correctly', () => { @@ -366,6 +367,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => { '

\n' + '' ) + wrapper.destroy() }) it('overwrites the component options with the instance options', () => { diff --git a/test/specs/mounting-options/attachTo.spec.js b/test/specs/mounting-options/attachTo.spec.js new file mode 100644 index 000000000..2fba92b14 --- /dev/null +++ b/test/specs/mounting-options/attachTo.spec.js @@ -0,0 +1,62 @@ +import { describeWithShallowAndMount } from '~resources/utils' + +const innerHTML = 'Hello world' +const outerHTML = `
${innerHTML}
` +const ssrHTML = `
${innerHTML}
` +const template = '
Hello world
' +const TestComponent = { template } + +describeWithShallowAndMount('options.attachTo', mountingMethod => { + it('attaches to a provided HTMLElement', () => { + const div = document.createElement('div') + div.id = 'root' + document.body.appendChild(div) + expect(document.getElementById('root')).to.not.be.null + expect(document.getElementById('attach-to')).to.be.null + const wrapper = mountingMethod(TestComponent, { + attachTo: div + }) + expect(document.getElementById('root')).to.be.null + expect(document.getElementById('attach-to')).to.not.be.null + expect(document.getElementById('attach-to').outerHTML).to.equal(outerHTML) + expect(wrapper.options.attachedToDocument).to.equal(true) + wrapper.destroy() + expect(document.getElementById('attach-to')).to.be.null + }) + it('attaches to a provided CSS selector string', () => { + const div = document.createElement('div') + div.id = 'root' + document.body.appendChild(div) + expect(document.getElementById('root')).to.not.be.null + expect(document.getElementById('attach-to')).to.be.null + const wrapper = mountingMethod(TestComponent, { + attachTo: '#root' + }) + expect(document.getElementById('root')).to.be.null + expect(document.getElementById('attach-to')).to.not.be.null + expect(document.getElementById('attach-to').outerHTML).to.equal(outerHTML) + expect(wrapper.options.attachedToDocument).to.equal(true) + wrapper.destroy() + expect(document.getElementById('attach-to')).to.be.null + }) + + it('correctly hydrates markup', () => { + expect(document.getElementById('attach-to')).to.be.null + + const div = document.createElement('div') + div.id = 'attach-to' + div.setAttribute('data-server-rendered', 'true') + div.innerHTML = innerHTML + document.body.appendChild(div) + expect(div.outerHTML).to.equal(ssrHTML) + const wrapper = mountingMethod(TestComponent, { + attachTo: '#attach-to' + }) + const rendered = document.getElementById('attach-to') + expect(rendered).to.not.be.null + expect(rendered.outerHTML).to.equal(outerHTML) + expect(wrapper.options.attachedToDocument).to.equal(true) + wrapper.destroy() + expect(document.getElementById('attach-to')).to.be.null + }) +})