Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support of arbitrary mounting point via attachTo option #1492

Merged
merged 1 commit into from Apr 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/api/mount.md
Expand Up @@ -55,10 +55,13 @@ import Foo from './Foo.vue'

describe('Foo', () => {
it('renders a div', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const wrapper = mount(Foo, {
attachToDocument: true
attachTo: div
})
expect(wrapper.contains('div')).toBe(true)
wrapper.destroy()
})
})
```
Expand Down
32 changes: 31 additions & 1 deletion docs/api/options.md
Expand Up @@ -16,6 +16,7 @@ These options will be merged with the component's existing options when mounted
- [`stubs`](#stubs)
- [`mocks`](#mocks)
- [`localVue`](#localvue)
- [`attachTo`](#attachto)
- [`attachToDocument`](#attachtodocument)
- [`propsData`](#propsdata)
- [`attrs`](#attrs)
Expand Down Expand Up @@ -288,12 +289,41 @@ const wrapper = mount(Component, {
expect(wrapper.vm.$route).toBeInstanceOf(Object)
```

## attachTo

- type: `HTMLElement | string`
- default: `null`

This either specifies a specific HTMLElement or CSS selector string targeting an
HTMLElement, to which your component will be fully mounted in the document.

When attaching to the DOM, you should call `wrapper.destroy()` at the end of your test to
remove the rendered elements from the document and destroy the component instance.

```js
const Component = {
template: '<div>ABC</div>',
props: ['msg']
}
let wrapper = mount(Component, {
attachTo: '#root'
})
expect(wrapper.vm.$el.parentNode).to.not.be.null
wrapper.destroy()

wrapper = mount(Component, {
attachTo: document.getElementById('root')
})
expect(wrapper.vm.$el.parentNode).to.not.be.null
wrapper.destroy()
```

## attachToDocument

- type: `boolean`
- default: `false`

Component will be attached to DOM when rendered if set to `true`.
Like [`attachTo`](#attachto), but automatically creates a new `div` element for you and inserts it into the body. This is deprecated in favor of [`attachTo`](#attachto).

When attaching to the DOM, you should call `wrapper.destroy()` at the end of your test to
remove the rendered elements from the document and destroy the component instance.
Expand Down
6 changes: 5 additions & 1 deletion docs/api/shallowMount.md
Expand Up @@ -4,6 +4,7 @@

- `{Component} component`
- `{Object} options`
- `{HTMLElement|string} string`
- `{boolean} attachToDocument`
- `{Object} context`
- `{Array<Component|Object>|Component} children`
Expand Down Expand Up @@ -64,10 +65,13 @@ import Foo from './Foo.vue'

describe('Foo', () => {
it('renders a div', () => {
const div = document.createElement('div')
document.body.appendChild(div)
const wrapper = shallowMount(Foo, {
attachToDocument: true
attachTo: div
})
expect(wrapper.contains('div')).toBe(true)
wrapper.destroy()
})
})
```
Expand Down
2 changes: 1 addition & 1 deletion docs/api/wrapper/destroy.md
Expand Up @@ -18,7 +18,7 @@ mount({
expect(spy.calledOnce).toBe(true)
```

if `attachToDocument` was set to `true` when mounted, the component DOM elements will
if either the `attachTo` or `attachToDocument` option caused the component to mount to the document, the component DOM elements will
also be removed from the document.

For functional components, `destroy` only removes the rendered DOM elements from the document.
2 changes: 2 additions & 0 deletions 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 },
Expand All @@ -17,6 +18,7 @@ declare type Options = {
}

declare type NormalizedOptions = {
attachTo?: HTMLElement | string,
attachToDocument?: boolean,
propsData?: Object,
mocks: Object,
Expand Down
20 changes: 18 additions & 2 deletions 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) {
Expand All @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/validators.js
Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions packages/test-utils/src/mount.js
Expand Up @@ -28,15 +28,16 @@ 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 = {}

throwIfInstancesThrew(vm)

const wrapperOptions = {
attachedToDocument: !!mergedOptions.attachToDocument
attachedToDocument: !!el
}

const root = parentVm.$options._isFunctionalContainer
Expand Down
2 changes: 2 additions & 0 deletions test/specs/mount.spec.js
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -366,6 +367,7 @@ describeRunIf(process.env.TEST_ENV !== 'node', 'mount', () => {
' <p class="prop-2"></p>\n' +
'</div>'
)
wrapper.destroy()
})

it('overwrites the component options with the instance options', () => {
Expand Down
77 changes: 77 additions & 0 deletions test/specs/mounting-options/attachTo.spec.js
@@ -0,0 +1,77 @@
import { describeWithShallowAndMount } from '~resources/utils'

const innerHTML = '<input><span>Hello world</span>'
const outerHTML = `<div id="attach-to">${innerHTML}</div>`
const ssrHTML = `<div id="attach-to" data-server-rendered="true">${innerHTML}</div>`
const template = '<div id="attach-to"><input /><span>Hello world</span></div>'
const TestComponent = { template }

describeWithShallowAndMount('options.attachTo', mountingMethod => {
it('should not mount to document when null', () => {
const wrapper = mountingMethod(TestComponent, {})
expect(wrapper.vm.$el.parentNode).to.be.null
wrapper.destroy()
})
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
})

const root = document.getElementById('root')
const rendered = document.getElementById('attach-to')
expect(wrapper.vm.$el.parentNode).to.not.be.null
expect(root).to.be.null
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
})
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'
})

const root = document.getElementById('root')
const rendered = document.getElementById('attach-to')
expect(wrapper.vm.$el.parentNode).to.not.be.null
expect(root).to.be.null
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
})

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(wrapper.vm.$el.parentNode).to.not.be.null
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
})
})