diff --git a/packages/runtime-core/__tests__/apiSetupContext.spec.ts b/packages/runtime-core/__tests__/apiSetupContext.spec.ts index d0602fc99..759e78dd7 100644 --- a/packages/runtime-core/__tests__/apiSetupContext.spec.ts +++ b/packages/runtime-core/__tests__/apiSetupContext.spec.ts @@ -74,7 +74,7 @@ describe('api: setup context', () => { expect(dummy).toBe(1) }) - it('setup props should resolve the correct types from props object', async () => { + it.only('setup props should resolve the correct types from props object', async () => { const count = ref(0) let dummy diff --git a/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts b/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts index 8ddaa644d..dfbf5d595 100644 --- a/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts +++ b/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts @@ -8,8 +8,11 @@ import { onUpdated, createComponent } from '@vue/runtime-dom' +import { mockWarn } from '@vue/runtime-test' describe('attribute fallthrough', () => { + mockWarn() + it('everything should be in props when component has no declared props', async () => { const click = jest.fn() const childUpdated = jest.fn() @@ -75,7 +78,7 @@ describe('attribute fallthrough', () => { expect(node.style.fontWeight).toBe('bold') }) - it('should separate in attrs when component has declared props', async () => { + it('should implicitly fallthrough on single root nodes', async () => { const click = jest.fn() const childUpdated = jest.fn() @@ -103,18 +106,15 @@ describe('attribute fallthrough', () => { props: { foo: Number }, - setup(props, { attrs }) { + setup(props) { onUpdated(childUpdated) return () => h( 'div', - mergeProps( - { - class: 'c2', - style: { fontWeight: 'bold' } - }, - attrs - ), + { + class: 'c2', + style: { fontWeight: 'bold' } + }, props.foo ) } @@ -147,7 +147,7 @@ describe('attribute fallthrough', () => { expect(node.hasAttribute('foo')).toBe(false) }) - it('should fallthrough on multi-nested components', async () => { + it('should fallthrough for nested components', async () => { const click = jest.fn() const childUpdated = jest.fn() const grandChildUpdated = jest.fn() @@ -183,18 +183,15 @@ describe('attribute fallthrough', () => { props: { foo: Number }, - setup(props, { attrs }) { + setup(props) { onUpdated(grandChildUpdated) return () => h( 'div', - mergeProps( - { - class: 'c2', - style: { fontWeight: 'bold' } - }, - attrs - ), + { + class: 'c2', + style: { fontWeight: 'bold' } + }, props.foo ) } @@ -227,4 +224,104 @@ describe('attribute fallthrough', () => { 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 = createComponent({ + 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
`) + }) + + it('explicit spreading with inheritAttrs: false', () => { + const Parent = { + render() { + return h(Child, { foo: 1, class: 'parent' }) + } + } + + const Child = createComponent({ + 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' }) + } + } + + const Child = createComponent({ + 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() + }) + + it('should not warn when $attrs is used during render', () => { + const Parent = { + render() { + return h(Child, { foo: 1, class: 'parent' }) + } + } + + const Child = createComponent({ + 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(root.innerHTML).toBe( + `
` + ) + }) }) diff --git a/packages/runtime-core/src/apiOptions.ts b/packages/runtime-core/src/apiOptions.ts index b8df2fdea..9bf408b29 100644 --- a/packages/runtime-core/src/apiOptions.ts +++ b/packages/runtime-core/src/apiOptions.ts @@ -62,6 +62,7 @@ export interface ComponentOptionsBase< render?: Function components?: Record directives?: Record + inheritAttrs?: boolean } export type ComponentOptionsWithoutProps< @@ -80,7 +81,7 @@ export type ComponentOptionsWithArrayProps< D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, - Props = { [key in PropNames]?: unknown } + Props = { [key in PropNames]?: any } > = ComponentOptionsBase & { props: PropNames[] } & ThisType> diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index b0debfeca..adaf95834 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -37,6 +37,7 @@ export type Data = { [key: string]: unknown } export interface FunctionalComponent

{ (props: P, ctx: SetupContext): VNodeChild props?: ComponentPropsOptions

+ inheritAttrs?: boolean displayName?: string } diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index 146e0ff3c..4490d2019 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -202,7 +202,7 @@ export function resolveProps( instance.attrs = options ? __DEV__ && attrs != null ? readonly(attrs) - : attrs! + : attrs || EMPTY_OBJ : instance.props } diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index cf8448cbb..e39022c15 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -11,7 +11,10 @@ import { import { UnwrapRef, ReactiveEffect } from '@vue/reactivity' import { warn } from './warning' import { Slots } from './componentSlots' -import { currentRenderingInstance } from './componentRenderUtils' +import { + currentRenderingInstance, + markAttrsAccessed +} from './componentRenderUtils' // public properties exposed on the proxy, which is used as the render context // in templates (as `this` in the render option) @@ -109,6 +112,9 @@ export const PublicInstanceProxyHandlers: ProxyHandler = { } else if (key === '$el') { return target.vnode.el } else if (hasOwn(publicPropertiesMap, key)) { + if (__DEV__ && key === '$attrs') { + markAttrsAccessed() + } return target[publicPropertiesMap[key]] } // methods are only exposed when options are supported diff --git a/packages/runtime-core/src/componentRenderUtils.ts b/packages/runtime-core/src/componentRenderUtils.ts index b994f7065..cfe9e9a61 100644 --- a/packages/runtime-core/src/componentRenderUtils.ts +++ b/packages/runtime-core/src/componentRenderUtils.ts @@ -3,15 +3,31 @@ import { FunctionalComponent, Data } from './component' -import { VNode, normalizeVNode, createVNode, Comment } from './vnode' +import { + VNode, + normalizeVNode, + createVNode, + Comment, + cloneVNode +} from './vnode' import { ShapeFlags } from './shapeFlags' import { handleError, ErrorCodes } from './errorHandling' -import { PatchFlags } from '@vue/shared' +import { PatchFlags, EMPTY_OBJ } from '@vue/shared' +import { warn } from './warning' // mark the current rendering instance for asset resolution (e.g. // resolveComponent, resolveDirective) during render export let currentRenderingInstance: ComponentInternalInstance | null = null +// dev only flag to track whether $attrs was used during render. +// If $attrs was used during render then the warning for failed attrs +// fallthrough can be suppressed. +let accessedAttrs: boolean = false + +export function markAttrsAccessed() { + accessedAttrs = true +} + export function renderComponentRoot( instance: ComponentInternalInstance ): VNode { @@ -27,6 +43,9 @@ export function renderComponentRoot( let result currentRenderingInstance = instance + if (__DEV__) { + accessedAttrs = false + } try { if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { result = normalizeVNode(instance.render!.call(renderProxy)) @@ -43,6 +62,27 @@ export function renderComponentRoot( : render(props, null as any /* we know it doesn't need it */) ) } + + // attr merging + if ( + Component.props != null && + Component.inheritAttrs !== false && + attrs !== EMPTY_OBJ && + Object.keys(attrs).length + ) { + if ( + result.shapeFlag & ShapeFlags.ELEMENT || + result.shapeFlag & ShapeFlags.COMPONENT + ) { + result = cloneVNode(result, attrs) + } else if (__DEV__ && !accessedAttrs) { + warn( + `Extraneous non-props attributes (${Object.keys(attrs).join(',')}) ` + + `were passed to component but could not be automatically inhertied ` + + `because component renders fragment or text root nodes.` + ) + } + } } catch (err) { handleError(err, instance, ErrorCodes.RENDER_FUNCTION) result = createVNode(Comment) diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index ad1d3ad0c..fec19fe12 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -229,11 +229,15 @@ export function createVNode( return vnode } -export function cloneVNode(vnode: VNode): VNode { +export function cloneVNode(vnode: VNode, extraProps?: Data): VNode { return { _isVNode: true, type: vnode.type, - props: vnode.props, + props: extraProps + ? vnode.props + ? mergeProps(vnode.props, extraProps) + : extraProps + : vnode.props, key: vnode.key, ref: vnode.ref, children: vnode.children,