From f59914d1503645a9bb19663c94f5ed9722dba273 Mon Sep 17 00:00:00 2001 From: Valentin Yushkevich Date: Thu, 1 Oct 2026 12:06:27 +0300 Subject: [PATCH] fix(runtime-vapor): only merge fallthrough attrs into a root component's props when there are some --- .../__tests__/componentProps.spec.ts | 36 +++++++++++++++++++ packages/runtime-vapor/src/component.ts | 13 ++++--- packages/runtime-vapor/src/componentProps.ts | 19 ++++++++-- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index 9a0383fa21f..d4ce8fc47eb 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -3,6 +3,8 @@ import { createApp, currentInstance, + defineComponent, + h, inject, isShallow, markRaw, @@ -2606,4 +2608,38 @@ describe('component: props', () => { app.unmount() }) }) + + test('empty fallthrough attrs are not merged into the root props', async () => { + const child = `` + const VdomChild = defineComponent({ + props: ['style'], + setup: props => () => h('i', typeof props.style), + }) + const wrapper = (child: string, bind = '') => `` + const { vdom, vapor } = await renderParity( + { + Child: child, + A: wrapper('Child'), + B: wrapper('VdomChild'), + // an explicit v-bind still merges, like mergeProps + C: wrapper('Child', 'v-bind="data.rest"'), + App: ``, + }, + () => ref({ style: undefined, rest: {} }), + () => {}, + { VdomChild }, + ) + expect(vdom.after).toBe('undefinedundefinedobject') + expect(vapor.after).toBe(vdom.after) + }) }) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 809f790990f..c7e2f83f325 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -88,6 +88,7 @@ import { type DynamicPropsSource, INITIAL_RAW_VALUES, type RawProps, + fallthroughOnlyKey, getPropsProxyHandlers, getStaticBindingKeys, hasFallthroughAttrs, @@ -366,11 +367,15 @@ export function createComponent( // must see exactly one fallthrough source if (rawProps && rawProps !== EMPTY_OBJ) { const sources = (rawProps as RawProps).$ - rawProps = extend({}, rawProps, { - $: sources ? sources.concat(source) : [source], - }) as RawProps + rawProps = extend( + {}, + rawProps, + sources + ? { $: sources.concat(source) } + : { $: [source], [fallthroughOnlyKey]: true }, + ) as RawProps } else { - rawProps = { $: [source] } as RawProps + rawProps = { $: [source], [fallthroughOnlyKey]: true } as RawProps } } diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index c4ceada475d..1600ac75613 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -49,11 +49,17 @@ import { normalizeEmitsOptions } from './componentEmits' import { RenderEffect } from './renderEffect' import { interopKey, isInteropEnabled } from './vdomInteropState' +export const fallthroughOnlyKey: unique symbol = Symbol( + __DEV__ ? 'fallthrough only' : '', +) + export type RawProps = Record & { // generated by compiler for :[key]="x" or v-bind="x" $?: DynamicPropsSource[] // evaluated by vdom, which patches the values in later [interopKey]?: boolean + // the only dynamic source is the injected fallthrough attrs + [fallthroughOnlyKey]?: boolean } export type DynamicPropsSource = @@ -715,14 +721,21 @@ export function collectProps( // Raw attrs may contain prototype-named keys such as __proto__. const rawValues: Record = Object.create(null) const dynamicSources = rawProps.$ || EMPTY_ARR + // like vdom, which merges fallthrough attrs only when there are some + const attrs = rawProps[fallthroughOnlyKey] + ? (dynamicSources[0] as () => Record)() + : undefined + const isMerging = attrs + ? Object.keys(attrs).length > 0 + : dynamicSources.length > 0 for (let i = -1; i < dynamicSources.length; i++) { const source = i < 0 ? rawProps : dynamicSources[i] const isDynamic = isFunction(source) - const resolved = isDynamic ? source() : source + const resolved = attrs && i === 0 ? attrs : isDynamic ? source() : source for (const key in resolved) { if (i < 0 && key === '$') continue const value = isDynamic ? resolved[key] : readSource(resolved[key]) - if (!dynamicSources.length) { + if (!isMerging) { rawValues[key] = value } else if (key === 'class') { if (rawValues.class !== value) { @@ -749,7 +762,7 @@ export function collectProps( } } // merged sources are normalized above - if (dynamicSources.length) { + if (isMerging) { if (rawValues.style) { rawValues.style = stabilizeDynamicSourceValue(prevStyle, rawValues.style) }