/** * @vitest-environment jsdom */ // using DOM renderer because this case is mostly DOM-specific import { Fragment, type FunctionalComponent, Teleport, createBlock, createCommentVNode, createElementBlock, createElementVNode, defineComponent, h, mergeProps, nextTick, onUpdated, openBlock, ref, render, withModifiers, } from '@vue/runtime-dom' import { createApp } from 'vue' import { PatchFlags } from '@vue/shared' describe('attribute fallthrough', () => { it('should allow attrs to fallthrough', async () => { const click = vi.fn() const childUpdated = vi.fn() const Hello = { setup() { const count = ref(0) function inc() { count.value++ click() } return () => h(Child, { foo: count.value + 1, id: 'test', class: 'c' + count.value, style: { color: count.value ? 'red' : 'green' }, onClick: inc, 'data-id': count.value + 1, }) }, } const Child = { setup(props: any) { onUpdated(childUpdated) return () => h( 'div', { class: 'c2', style: { fontWeight: 'bold' }, }, props.foo, ) }, } const root = document.createElement('div') document.body.appendChild(root) render(h(Hello), root) const node = root.children[0] as HTMLElement expect(node.getAttribute('id')).toBe('test') expect(node.getAttribute('foo')).toBe('1') expect(node.getAttribute('class')).toBe('c2 c0') expect(node.style.color).toBe('green') expect(node.style.fontWeight).toBe('bold') expect(node.dataset.id).toBe('1') node.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() await nextTick() expect(childUpdated).toHaveBeenCalled() expect(node.getAttribute('id')).toBe('test') expect(node.getAttribute('foo')).toBe('2') expect(node.getAttribute('class')).toBe('c2 c1') expect(node.style.color).toBe('red') expect(node.style.fontWeight).toBe('bold') expect(node.dataset.id).toBe('2') }) it('should only allow whitelisted fallthrough on functional component with optional props', async () => { const click = vi.fn() const childUpdated = vi.fn() const count = ref(0) function inc() { count.value++ click() } const Hello = () => h(Child, { foo: count.value + 1, id: 'test', class: 'c' + count.value, style: { color: count.value ? 'red' : 'green' }, onClick: inc, }) const Child = (props: any) => { childUpdated() return h( 'div', { class: 'c2', style: { fontWeight: 'bold' }, }, props.foo, ) } const root = document.createElement('div') document.body.appendChild(root) render(h(Hello), root) const node = root.children[0] as HTMLElement // not whitelisted expect(node.getAttribute('id')).toBe(null) expect(node.getAttribute('foo')).toBe(null) // whitelisted: style, class, event listeners expect(node.getAttribute('class')).toBe('c2 c0') expect(node.style.color).toBe('green') expect(node.style.fontWeight).toBe('bold') node.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() await nextTick() expect(childUpdated).toHaveBeenCalled() expect(node.getAttribute('id')).toBe(null) expect(node.getAttribute('foo')).toBe(null) expect(node.getAttribute('class')).toBe('c2 c1') expect(node.style.color).toBe('red') expect(node.style.fontWeight).toBe('bold') }) it('should allow all attrs on functional component with declared props', async () => { const click = vi.fn() const childUpdated = vi.fn() const count = ref(0) function inc() { count.value++ click() } const Hello = () => h(Child, { foo: count.value + 1, id: 'test', class: 'c' + count.value, style: { color: count.value ? 'red' : 'green' }, onClick: inc, }) const Child = (props: { foo: number }) => { childUpdated() return h( 'div', { class: 'c2', style: { fontWeight: 'bold' }, }, props.foo, ) } Child.props = ['foo'] const root = document.createElement('div') document.body.appendChild(root) render(h(Hello), root) const node = root.children[0] as HTMLElement expect(node.getAttribute('id')).toBe('test') expect(node.getAttribute('foo')).toBe(null) // declared as prop expect(node.getAttribute('class')).toBe('c2 c0') expect(node.style.color).toBe('green') expect(node.style.fontWeight).toBe('bold') node.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() await nextTick() expect(childUpdated).toHaveBeenCalled() expect(node.getAttribute('id')).toBe('test') expect(node.getAttribute('foo')).toBe(null) expect(node.getAttribute('class')).toBe('c2 c1') expect(node.style.color).toBe('red') expect(node.style.fontWeight).toBe('bold') }) it('should fallthrough for nested components', async () => { const click = vi.fn() const childUpdated = vi.fn() const grandChildUpdated = vi.fn() const Hello = { setup() { const count = ref(0) function inc() { count.value++ click() } return () => h(Child, { foo: 1, id: 'test', class: 'c' + count.value, style: { color: count.value ? 'red' : 'green' }, onClick: inc, }) }, } const Child = { setup(props: any) { onUpdated(childUpdated) // HOC simply passing props down. // this will result in merging the same attrs, but should be deduped by // `mergeProps`. return () => h(GrandChild, props) }, } const GrandChild = defineComponent({ props: { id: String, foo: Number, }, setup(props) { onUpdated(grandChildUpdated) return () => h( 'div', { id: props.id, class: 'c2', style: { fontWeight: 'bold' }, }, props.foo, ) }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Hello), root) const node = root.children[0] as HTMLElement // with declared props, any parent attr that isn't a prop falls through expect(node.getAttribute('id')).toBe('test') expect(node.getAttribute('class')).toBe('c2 c0') expect(node.style.color).toBe('green') expect(node.style.fontWeight).toBe('bold') node.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() // ...while declared ones remain props expect(node.hasAttribute('foo')).toBe(false) await nextTick() expect(childUpdated).toHaveBeenCalled() expect(grandChildUpdated).toHaveBeenCalled() expect(node.getAttribute('id')).toBe('test') expect(node.getAttribute('class')).toBe('c2 c1') expect(node.style.color).toBe('red') expect(node.style.fontWeight).toBe('bold') expect(node.hasAttribute('foo')).toBe(false) }) it('should not fallthrough with inheritAttrs: false', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent' }) }, } const Child = defineComponent({ props: ['foo'], inheritAttrs: false, render() { return h('div', this.foo) }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) // should not contain class expect(root.innerHTML).toMatch(`
1
`) }) // #3741 it('should not fallthrough with inheritAttrs: false from mixins', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent' }) }, } const mixin = { inheritAttrs: false, } const Child = defineComponent({ mixins: [mixin], props: ['foo'], render() { return h('div', this.foo) }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) // should not contain class expect(root.innerHTML).toMatch(`
1
`) }) it('explicit spreading with inheritAttrs: false', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent' }) }, } const Child = defineComponent({ props: ['foo'], inheritAttrs: false, render() { return h( 'div', mergeProps( { class: 'child', }, this.$attrs, ), this.foo, ) }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) // should merge parent/child classes expect(root.innerHTML).toMatch(`
1
`) }) it('should warn when fallthrough fails on non-single-root', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent', onBar: () => {} }) }, } const Child = defineComponent({ props: ['foo'], render() { return [h('div'), h('div')] }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned() expect(`Extraneous non-emits event listeners`).toHaveBeenWarned() }) it('should warn when fallthrough fails on teleport root node', () => { const Parent = { render() { return h(Child, { class: 'parent' }) }, } const root = document.createElement('div') const Child = defineComponent({ render() { return h(Teleport, { to: root }, h('div')) }, }) document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned() }) it('should dedupe same listeners when $attrs is used during render', () => { const click = vi.fn() const count = ref(0) function inc() { count.value++ click() } const Parent = { render() { return h(Child, { onClick: inc }) }, } const Child = defineComponent({ render() { return h( 'div', mergeProps( { onClick: withModifiers(() => {}, ['prevent', 'stop']), }, this.$attrs, ), ) }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) const node = root.children[0] as HTMLElement node.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalledTimes(1) expect(count.value).toBe(1) }) it('should not warn when $attrs is used during render', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent', onBar: () => {} }) }, } const Child = defineComponent({ props: ['foo'], render() { return [h('div'), h('div', this.$attrs)] }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes`).not.toHaveBeenWarned() expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned() expect(root.innerHTML).toBe(`
`) }) it('should not warn when context.attrs is used during render', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent', onBar: () => {} }) }, } const Child = defineComponent({ props: ['foo'], setup(_props, { attrs }) { return () => [h('div'), h('div', attrs)] }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes`).not.toHaveBeenWarned() expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned() expect(root.innerHTML).toBe(`
`) }) it('should not warn when context.attrs is used during render (functional)', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent', onBar: () => {} }) }, } const Child: FunctionalComponent = (_, { attrs }) => [ h('div'), h('div', attrs), ] Child.props = ['foo'] const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes`).not.toHaveBeenWarned() expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned() expect(root.innerHTML).toBe(`
`) }) it('should not warn when functional component has optional props', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent', onBar: () => {} }) }, } const Child = (props: any) => [h('div'), h('div', { class: props.class })] const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes`).not.toHaveBeenWarned() expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned() expect(root.innerHTML).toBe(`
`) }) it('should warn when functional component has props and does not use attrs', () => { const Parent = { render() { return h(Child, { foo: 1, class: 'parent', onBar: () => {} }) }, } const Child: FunctionalComponent = () => [h('div'), h('div')] Child.props = ['foo'] const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(`Extraneous non-props attributes`).toHaveBeenWarned() expect(`Extraneous non-emits event listeners`).toHaveBeenWarned() expect(root.innerHTML).toBe(`
`) }) // #677 it('should update merged dynamic attrs on optimized child root', async () => { const aria = ref('true') const cls = ref('bar') const Parent = { render() { return h(Child, { 'aria-hidden': aria.value, class: cls.value }) }, } const Child = { props: [], render() { return (openBlock(), createBlock('div')) }, } const root = document.createElement('div') document.body.appendChild(root) render(h(Parent), root) expect(root.innerHTML).toBe(``) aria.value = 'false' await nextTick() expect(root.innerHTML).toBe(`
`) cls.value = 'barr' await nextTick() expect(root.innerHTML).toBe(`
`) }) it('should not let listener fallthrough when declared in emits (stateful)', () => { const Child = defineComponent({ emits: ['click'], render() { return h( 'button', { onClick: () => { this.$emit('click', 'custom') }, }, 'hello', ) }, }) const onClick = vi.fn() const App = { render() { return h(Child, { onClick, }) }, } const root = document.createElement('div') document.body.appendChild(root) render(h(App), root) const node = root.children[0] as HTMLElement node.dispatchEvent(new CustomEvent('click')) expect(onClick).toHaveBeenCalledTimes(1) expect(onClick).toHaveBeenCalledWith('custom') }) it('should not let listener fallthrough when declared in emits (functional)', () => { const Child: FunctionalComponent<{}, { click: any }> = (_, { emit }) => { // should not be in props expect((_ as any).onClick).toBeUndefined() return h('button', { onClick: () => { emit('click', 'custom') }, }) } Child.emits = ['click'] const onClick = vi.fn() const App = { render() { return h(Child, { onClick, }) }, } const root = document.createElement('div') document.body.appendChild(root) render(h(App), root) const node = root.children[0] as HTMLElement node.dispatchEvent(new CustomEvent('click')) expect(onClick).toHaveBeenCalledTimes(1) expect(onClick).toHaveBeenCalledWith('custom') }) it('should support fallthrough for fragments with single element + comments', () => { const click = vi.fn() const Hello = { setup() { return () => h(Child, { class: 'foo', onClick: click }) }, } const Child = { setup() { return () => ( openBlock(), createBlock( Fragment, null, [ createCommentVNode('hello'), h('button'), createCommentVNode('world'), ], PatchFlags.STABLE_FRAGMENT | PatchFlags.DEV_ROOT_FRAGMENT, ) ) }, } const root = document.createElement('div') document.body.appendChild(root) render(h(Hello), root) expect(root.innerHTML).toBe( ``, ) const button = root.children[0] as HTMLElement button.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() }) it('should support fallthrough for nested dev root fragments', async () => { const toggle = ref(false) const Child = { setup() { return () => ( openBlock(), createElementBlock( Fragment, null, [ createCommentVNode(' comment A '), toggle.value ? (openBlock(), createElementBlock('span', { key: 0 }, 'Foo')) : (openBlock(), createElementBlock( Fragment, { key: 1 }, [ createCommentVNode(' comment B '), createElementVNode('div', null, 'Bar'), ], PatchFlags.STABLE_FRAGMENT | PatchFlags.DEV_ROOT_FRAGMENT, )), ], PatchFlags.STABLE_FRAGMENT | PatchFlags.DEV_ROOT_FRAGMENT, ) ) }, } const Root = { setup() { return () => (openBlock(), createBlock(Child, { class: 'red' })) }, } const root = document.createElement('div') document.body.appendChild(root) render(h(Root), root) expect(root.innerHTML).toBe( `
Bar
`, ) toggle.value = true await nextTick() expect(root.innerHTML).toBe( `Foo`, ) }) // #1989 it('should not fallthrough v-model listeners with corresponding declared prop', () => { let textFoo = '' let textBar = '' const click = vi.fn() const App = defineComponent({ setup() { return () => h(Child, { modelValue: textFoo, 'onUpdate:modelValue': (val: string) => { textFoo = val }, }) }, }) const Child = defineComponent({ props: ['modelValue'], setup(_props, { emit }) { return () => h(GrandChild, { modelValue: textBar, 'onUpdate:modelValue': (val: string) => { textBar = val emit('update:modelValue', 'from Child') }, }) }, }) const GrandChild = defineComponent({ props: ['modelValue'], setup(_props, { emit }) { return () => h('button', { onClick() { click() emit('update:modelValue', 'from GrandChild') }, }) }, }) const root = document.createElement('div') document.body.appendChild(root) render(h(App), root) const node = root.children[0] as HTMLElement node.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() expect(textBar).toBe('from GrandChild') expect(textFoo).toBe('from Child') }) // covers uncaught regression #10710 it('should track this.$attrs access in slots', async () => { const GrandChild = { template: ``, } const Child = { components: { GrandChild }, template: `
{{ $attrs.foo }}
`, } const obj = ref(1) const App = { render() { return h(Child, { foo: obj.value }) }, } const root = document.createElement('div') createApp(App).mount(root) expect(root.innerHTML).toBe('
1
') obj.value = 2 await nextTick() expect(root.innerHTML).toBe('
2
') }) })