Skip to content

Commit 239caf8

Browse files
authored
fix: return abitility to use findComponent with DOM selector (#994)
* fix: return ability to use findComponent with DOM selector * fix: ensure findAllComponents return at most one vnode for same el
1 parent fa0c8a8 commit 239caf8

File tree

9 files changed

+139
-27
lines changed

9 files changed

+139
-27
lines changed

docs/api/index.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,7 @@ findComponent<T extends ComponentPublicInstance>(selector: FindComponentSelector
11031103
11041104
| syntax | example | details |
11051105
| -------------- | ----------------------------- | ------------------------------------------------------------ |
1106+
| querySelector | `findComponent('.component')` | Matches standard query selector. |
11061107
| Component name | `findComponent({name: 'a'})` | matches PascalCase, snake-case, camelCase |
11071108
| Component ref | `findComponent({ref: 'ref'})` | Can be used only on direct ref children of mounted component |
11081109
| SFC | `findComponent(Component)` | Pass an imported component directly |
@@ -1165,6 +1166,7 @@ test('findComponent', () => {
11651166
If `ref` in component points to HTML element, `findComponent` will return empty wrapper. This is intended behaviour
11661167
:::
11671168
1169+
11681170
**NOTE** `getComponent` and `findComponent` will not work on functional components, because they do not have an internal Vue instance (this is what makes functional components more performant). That means the following will **not** work:
11691171
11701172
```js
@@ -1179,6 +1181,31 @@ wrapper.findComponent(Foo)
11791181
11801182
For tests using functional component, consider using `get` or `find` and treating them like standard DOM nodes.
11811183
1184+
:::warning Usage with CSS selectors
1185+
Using `findComponent` with CSS selector might have confusing behavior
1186+
1187+
Consider this example:
1188+
1189+
```js
1190+
const ChildComponent = {
1191+
name: 'Child',
1192+
template: '<div class="child"></div>'
1193+
}
1194+
const RootComponent = {
1195+
name: 'Root',
1196+
components: { ChildComponent },
1197+
template: '<child-component class="root" />'
1198+
}
1199+
const wrapper = mount(RootComponent)
1200+
const rootByCss = wrapper.findComponent('.root') // => finds Root
1201+
expect(rootByCss.vm.$options.name).toBe('Root')
1202+
const childByCss = wrapper.findComponent('.child')
1203+
expect(childByCss.vm.$options.name).toBe('Root') // => still Root
1204+
```
1205+
1206+
The reason for such behavior is that `RootComponent` and `ChildComponent` are sharing same DOM node and only first matching component is included for each unique DOM node
1207+
:::
1208+
11821209
### findAllComponents
11831210
11841211
**Signature:**
@@ -1219,6 +1246,10 @@ test('findAllComponents', () => {
12191246
})
12201247
```
12211248
1249+
:::warning Usage with CSS selectors
1250+
`findAllComponents` has same behavior when used with CSS selector as [findComponent](#findcomponent)
1251+
:::
1252+
12221253
### get
12231254
12241255
Gets an element and returns a `DOMWrapper` if found. Otherwise it throws an error.

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ interface NameSelector {
2626
name: string
2727
}
2828

29-
export type FindComponentSelector = RefSelector | NameSelector
30-
export type FindAllComponentsSelector = NameSelector
29+
export type FindComponentSelector = RefSelector | NameSelector | string
30+
export type FindAllComponentsSelector = NameSelector | string
3131

3232
export type Slot = VNode | string | { render: Function } | Function | Component
3333

src/utils/find.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,14 @@ export function find(
148148
root: VNode,
149149
selector: FindAllComponentsSelector
150150
): ComponentPublicInstance[] {
151-
return findAllVNodes(root, selector).map(
152-
// @ts-ignore
153-
(vnode: VNode) => vnode.component!.proxy!
154-
)
151+
let matchingVNodes = findAllVNodes(root, selector)
152+
153+
if (typeof selector === 'string') {
154+
// When searching by CSS selector we want only one (topmost) vnode for each el`
155+
matchingVNodes = matchingVNodes.filter(
156+
(vnode: VNode) => vnode.component!.parent?.vnode.el !== vnode.el
157+
)
158+
}
159+
160+
return matchingVNodes.map((vnode: VNode) => vnode.component!.proxy!)
155161
}

src/vueWrapper.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,6 @@ export class VueWrapper<T extends ComponentPublicInstance>
140140
findComponent<T extends ComponentPublicInstance>(
141141
selector: FindComponentSelector | (new () => T)
142142
): VueWrapper<T> {
143-
if (typeof selector === 'string') {
144-
throw Error(
145-
'findComponent requires a Vue constructor or valid find object. If you are searching for DOM nodes, use `find` instead'
146-
)
147-
}
148-
149143
if (typeof selector === 'object' && 'ref' in selector) {
150144
const result = this.vm.$refs[selector.ref]
151145
if (result && !(result instanceof HTMLElement)) {
@@ -171,13 +165,31 @@ export class VueWrapper<T extends ComponentPublicInstance>
171165
return createWrapperError('VueWrapper')
172166
}
173167

174-
findAllComponents(selector: FindAllComponentsSelector): VueWrapper<any>[] {
168+
getComponent<T extends ComponentPublicInstance>(
169+
selector: FindComponentSelector | (new () => T)
170+
): Omit<VueWrapper<T>, 'exists'> {
171+
const result = this.findComponent(selector)
172+
173+
if (result instanceof VueWrapper) {
174+
return result as VueWrapper<T>
175+
}
176+
177+
let message = 'Unable to get '
175178
if (typeof selector === 'string') {
176-
throw Error(
177-
'findAllComponents requires a Vue constructor or valid find object. If you are searching for DOM nodes, use `find` instead'
178-
)
179+
message += `component with selector ${selector}`
180+
} else if ('name' in selector) {
181+
message += `component with name ${selector.name}`
182+
} else if ('ref' in selector) {
183+
message += `component with ref ${selector.ref}`
184+
} else {
185+
message += 'specified component'
179186
}
187+
message += ` within: ${this.html()}`
188+
throw new Error(message)
189+
}
180190

191+
findAllComponents(selector: FindAllComponentsSelector): VueWrapper<T>[] {
192+
const results = find(this.vm.$.subTree, selector)
181193
return find(this.vm.$.subTree, selector).map((c) => createWrapper(null, c))
182194
}
183195

test-dts/getComponent.d-test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ const componentByName = wrapper.getComponent({ name: 'ComponentToFind' })
2828
// returns a wrapper with a generic vm (any)
2929
expectType<ComponentPublicInstance>(componentByName.vm)
3030

31+
// get by string
32+
const componentByString = wrapper.getComponent('other')
33+
// returns a wrapper with a generic vm (any)
34+
expectType<ComponentPublicInstance>(componentByString.vm)
35+
3136
// get by ref
3237
const componentByRef = wrapper.getComponent({ ref: 'ref' })
3338
// returns a wrapper with a generic vm (any)

tests/attributes.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('attributes', () => {
5858
}
5959
})
6060

61-
expect(wrapper.findComponent({ name: 'Hello' }).attributes()).toEqual({
61+
expect(wrapper.findComponent('.hello-outside').attributes()).toEqual({
6262
class: 'hello-outside',
6363
'data-testid': 'hello',
6464
disabled: ''

tests/findAllComponents.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ const compA = defineComponent({
1818
describe('findAllComponents', () => {
1919
it('finds all deeply nested vue components', () => {
2020
const wrapper = mount(compA)
21-
expect(wrapper.findAllComponents(compC)).toHaveLength(2)
21+
// find by DOM selector
22+
expect(wrapper.findAllComponents('.C')).toHaveLength(2)
2223
expect(wrapper.findAllComponents({ name: 'Hello' })[0].text()).toBe(
2324
'Hello world'
2425
)
@@ -35,4 +36,46 @@ describe('findAllComponents', () => {
3536
expect(wrapper.findAllComponents(Hello)).toHaveLength(3)
3637
expect(wrapper.find('.nested').findAllComponents(Hello)).toHaveLength(2)
3738
})
39+
40+
it('ignores DOM nodes matching css selector', () => {
41+
const Component = defineComponent({
42+
components: { Hello },
43+
template:
44+
'<div class="foo"><Hello class="foo" /><div class="nested foo"></div></div>'
45+
})
46+
const wrapper = mount(Component)
47+
expect(wrapper.findAllComponents('.foo')).toHaveLength(1)
48+
})
49+
50+
it('findAllComponents returns top-level components when components are nested', () => {
51+
const DeepNestedChild = {
52+
name: 'DeepNestedChild',
53+
template: '<div>I am deeply nested</div>'
54+
}
55+
const NestedChild = {
56+
name: 'NestedChild',
57+
components: { DeepNestedChild },
58+
template: '<deep-nested-child class="in-child" />'
59+
}
60+
const RootComponent = {
61+
name: 'RootComponent',
62+
components: { NestedChild },
63+
template: '<div><nested-child class="in-root"></nested-child></div>'
64+
}
65+
66+
const wrapper = mount(RootComponent)
67+
68+
expect(wrapper.findAllComponents('.in-root')).toHaveLength(1)
69+
expect(wrapper.findAllComponents('.in-root')[0].vm.$options.name).toEqual(
70+
'NestedChild'
71+
)
72+
73+
expect(wrapper.findAllComponents('.in-child')).toHaveLength(1)
74+
75+
// someone might expect DeepNestedChild here, but
76+
// we always return TOP component matching DOM element
77+
expect(wrapper.findAllComponents('.in-child')[0].vm.$options.name).toEqual(
78+
'NestedChild'
79+
)
80+
})
3881
})

tests/findComponent.spec.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@ const compA = defineComponent({
4141
describe('findComponent', () => {
4242
it('does not find plain dom elements', () => {
4343
const wrapper = mount(compA)
44-
// @ts-expect-error
45-
expect(() => wrapper.findComponent('.domElement')).toThrowError()
44+
expect(wrapper.findComponent('.domElement').exists()).toBeFalsy()
4645
})
4746

4847
it('finds component by ref', () => {
@@ -60,6 +59,23 @@ describe('findComponent', () => {
6059
expect(wrapper.findComponent({ ref: 'hello' }).exists()).toBe(false)
6160
})
6261

62+
it('finds component by dom selector', () => {
63+
const wrapper = mount(compA)
64+
// find by DOM selector
65+
expect(wrapper.findComponent('.C').vm).toHaveProperty(
66+
'$options.name',
67+
'ComponentC'
68+
)
69+
})
70+
71+
it('does allows using complicated DOM selector query', () => {
72+
const wrapper = mount(compA)
73+
expect(wrapper.findComponent('.B > .C').vm).toHaveProperty(
74+
'$options.name',
75+
'ComponentC'
76+
)
77+
})
78+
6379
it('finds component by name', () => {
6480
const wrapper = mount(compA)
6581
expect(wrapper.findComponent({ name: 'Hello' }).text()).toBe('Hello world')

tests/getComponent.spec.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineComponent } from 'vue'
2-
import { mount, RouterLinkStub, shallowMount } from '../src'
2+
import { mount, MountingOptions, RouterLinkStub, shallowMount } from '../src'
33
import Issue425 from './components/Issue425.vue'
44

55
const compA = defineComponent({
@@ -15,15 +15,14 @@ describe('getComponent', () => {
1515
it('should delegate to findComponent', () => {
1616
const wrapper = mount(compA)
1717
jest.spyOn(wrapper, 'findComponent').mockReturnThis()
18-
wrapper.getComponent(compA)
19-
expect(wrapper.findComponent).toHaveBeenCalledWith(compA)
18+
wrapper.getComponent('.domElement')
19+
expect(wrapper.findComponent).toHaveBeenCalledWith('.domElement')
2020
})
2121

2222
it('should throw if not found with a string selector', () => {
2323
const wrapper = mount(compA)
24-
// @ts-expect-error
2524
expect(() => wrapper.getComponent('.domElement')).toThrowError(
26-
'findComponent requires a Vue constructor or valid find object. If you are searching for DOM nodes, use `find` instead'
25+
'Unable to get component with selector .domElement within: <div class="A"></div>'
2726
)
2827
})
2928

@@ -70,12 +69,12 @@ describe('getComponent', () => {
7069
// https://github.com/vuejs/vue-test-utils-next/issues/425
7170
it('works with router-link and mount', () => {
7271
const wrapper = mount(Issue425, options)
73-
expect(wrapper.getComponent(RouterLinkStub).props('to')).toEqual({ name })
72+
expect(wrapper.getComponent('.link').props('to')).toEqual({ name })
7473
})
7574

7675
// https://github.com/vuejs/vue-test-utils-next/issues/425
7776
it('works with router-link and shallowMount', () => {
7877
const wrapper = shallowMount(Issue425, options)
79-
expect(wrapper.getComponent(RouterLinkStub).props('to')).toEqual({ name })
78+
expect(wrapper.getComponent('.link').props('to')).toEqual({ name })
8079
})
8180
})

0 commit comments

Comments
 (0)