diff --git a/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts b/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts index e2031eb66bb..07348016fed 100644 --- a/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts +++ b/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts @@ -594,4 +594,61 @@ describe('attribute fallthrough', () => { button.dispatchEvent(new CustomEvent('click')) expect(click).toHaveBeenCalled() }) + + // #1989 + it('should not fallthrough v-model listeners with corresponding declared prop', () => { + let textFoo = '' + let textBar = '' + const click = jest.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') + }) }) diff --git a/packages/runtime-core/src/componentRenderUtils.ts b/packages/runtime-core/src/componentRenderUtils.ts index 9c7ff21a67a..9fa72c9dd51 100644 --- a/packages/runtime-core/src/componentRenderUtils.ts +++ b/packages/runtime-core/src/componentRenderUtils.ts @@ -17,6 +17,7 @@ import { handleError, ErrorCodes } from './errorHandling' import { PatchFlags, ShapeFlags, isOn, isModelListener } from '@vue/shared' import { warn } from './warning' import { isHmrUpdating } from './hmr' +import { NormalizedProps } from './componentProps' // mark the current rendering instance for asset resolution (e.g. // resolveComponent, resolveDirective) during render @@ -46,6 +47,7 @@ export function renderComponentRoot( proxy, withProxy, props, + propsOptions: [propsOptions], slots, attrs, emit, @@ -125,11 +127,15 @@ export function renderComponentRoot( shapeFlag & ShapeFlags.ELEMENT || shapeFlag & ShapeFlags.COMPONENT ) { - if (shapeFlag & ShapeFlags.ELEMENT && keys.some(isModelListener)) { - // #1643, #1543 - // component v-model listeners should only fallthrough for component - // HOCs - fallthroughAttrs = filterModelListeners(fallthroughAttrs) + if (propsOptions && keys.some(isModelListener)) { + // If a v-model listener (onUpdate:xxx) has a corresponding declared + // prop, it indicates this component expects to handle v-model and + // it should not fallthrough. + // related: #1543, #1643, #1989 + fallthroughAttrs = filterModelListeners( + fallthroughAttrs, + propsOptions + ) } root = cloneVNode(root, fallthroughAttrs) } else if (__DEV__ && !accessedAttrs && root.type !== Comment) { @@ -251,10 +257,10 @@ const getFunctionalFallthrough = (attrs: Data): Data | undefined => { return res } -const filterModelListeners = (attrs: Data): Data => { +const filterModelListeners = (attrs: Data, props: NormalizedProps): Data => { const res: Data = {} for (const key in attrs) { - if (!isModelListener(key)) { + if (!isModelListener(key) || !(key.slice(9) in props)) { res[key] = attrs[key] } }