From: edison Date: Mon, 19 Jan 2026 01:04:38 +0000 (+0800) Subject: fix(runtime-core): resolve kebab-case slot names from in-DOM templates (#14302) X-Git-Tag: v3.5.27~2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7e554bf8975a6522cde00c261e8c6f1bffff1c24;p=thirdparty%2Fvuejs%2Fcore.git fix(runtime-core): resolve kebab-case slot names from in-DOM templates (#14302) closes #14300 --- diff --git a/packages/runtime-core/__tests__/componentSlots.spec.ts b/packages/runtime-core/__tests__/componentSlots.spec.ts index 458731dd15..9f0a8e5702 100644 --- a/packages/runtime-core/__tests__/componentSlots.spec.ts +++ b/packages/runtime-core/__tests__/componentSlots.spec.ts @@ -11,6 +11,8 @@ import { } from '@vue/runtime-test' import { createBlock, normalizeVNode } from '../src/vnode' import { createSlots } from '../src/helpers/createSlots' +import { renderSlot } from '../src/helpers/renderSlot' +import { setCurrentRenderingInstance } from '../src/componentRenderContext' describe('component: slots', () => { function renderWithSlots(slots: any): any { @@ -461,4 +463,118 @@ describe('component: slots', () => { createApp(App).mount(root) expect(serializeInner(root)).toBe('foo') }) + + // in-DOM templates use kebab-case slot names + describe('in-DOM template kebab-case slot name resolution', () => { + beforeEach(() => { + __BROWSER__ = true + }) + + afterEach(() => { + __BROWSER__ = false + }) + + test('should resolve camelCase slot access to kebab-case via slots', () => { + const Comp = { + setup(_: any, { slots }: any) { + // Access with camelCase, but slot is passed with kebab-case + return () => slots.dropdownRender() + }, + } + + const App = { + setup() { + // Parent passes slot with kebab-case name (simulating in-DOM template) + return () => + h(Comp, null, { 'dropdown-render': () => 'dropdown content' }) + }, + } + + const root = nodeOps.createElement('div') + createApp(App).mount(root) + expect(serializeInner(root)).toBe('dropdown content') + }) + + test('should resolve camelCase slot access to kebab-case via slots (PROD)', () => { + __DEV__ = false + try { + const Comp = { + setup(_: any, { slots }: any) { + // Access with camelCase, but slot is passed with kebab-case + return () => slots.dropdownRender() + }, + } + + const App = { + setup() { + // Parent passes slot with kebab-case name (simulating in-DOM template) + return () => + h(Comp, null, { 'dropdown-render': () => 'dropdown content' }) + }, + } + + const root = nodeOps.createElement('div') + createApp(App).mount(root) + expect(serializeInner(root)).toBe('dropdown content') + } finally { + __DEV__ = true + } + }) + + test('should prefer exact match over kebab-case conversion via slots', () => { + const Comp = { + setup(_: any, { slots }: any) { + return () => slots.dropdownRender() + }, + } + + const App = { + setup() { + // Both exact match and kebab-case exist + return () => + h(Comp, null, { + 'dropdown-render': () => 'kebab', + dropdownRender: () => 'exact', + }) + }, + } + + const root = nodeOps.createElement('div') + createApp(App).mount(root) + // exact match should take priority + expect(serializeInner(root)).toBe('exact') + }) + + // renderSlot tests + describe('renderSlot', () => { + beforeEach(() => { + setCurrentRenderingInstance({ type: {} } as any) + }) + + afterEach(() => { + setCurrentRenderingInstance(null) + }) + + test('should resolve camelCase slot name to kebab-case via renderSlot', () => { + let child: any + const vnode = renderSlot( + { 'dropdown-render': () => [(child = h('child'))] }, + 'dropdownRender', + ) + expect(vnode.children).toEqual([child]) + }) + + test('should prefer exact match over kebab-case conversion via renderSlot', () => { + let exactChild: any + const vnode = renderSlot( + { + 'dropdown-render': () => [h('kebab')], + dropdownRender: () => [(exactChild = h('exact'))], + }, + 'dropdownRender', + ) + expect(vnode.children).toEqual([exactChild]) + }) + }) + }) }) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 4e1aa5e4d3..b24b57b4d4 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -66,6 +66,7 @@ import { ShapeFlags, extend, getGlobalThis, + hyphenate, isArray, isFunction, isObject, @@ -1110,17 +1111,20 @@ const attrsProxyHandlers = __DEV__ }, } -/** - * Dev-only - */ -function getSlotsProxy(instance: ComponentInternalInstance): Slots { - return new Proxy(instance.slots, { - get(target, key: string) { +const createSlotsProxyHandlers = ( + instance: ComponentInternalInstance, +): ProxyHandler => ({ + get(target, key: string | symbol) { + if (__DEV__) { track(instance, TrackOpTypes.GET, '$slots') - return target[key] - }, - }) -} + } + // in-DOM templates use kebab-case slot names, only relevant in browser + return ( + target[key as string] || + (__BROWSER__ && typeof key === 'string' && target[hyphenate(key)]) + ) + }, +}) export function createSetupContext( instance: ComponentInternalInstance, @@ -1162,7 +1166,13 @@ export function createSetupContext( ) }, get slots() { - return slotsProxy || (slotsProxy = getSlotsProxy(instance)) + return ( + slotsProxy || + (slotsProxy = new Proxy( + instance.slots, + createSlotsProxyHandlers(instance), + )) + ) }, get emit() { return (event: string, ...args: any[]) => instance.emit(event, ...args) @@ -1172,7 +1182,9 @@ export function createSetupContext( } else { return { attrs: new Proxy(instance.attrs, attrsProxyHandlers), - slots: instance.slots, + slots: __BROWSER__ + ? new Proxy(instance.slots, createSlotsProxyHandlers(instance)) + : instance.slots, emit: instance.emit, expose, } diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 55eaf86244..32883d8f00 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -189,7 +189,7 @@ const KeepAliveImpl: ComponentOptions = { } // for e2e test - if (__DEV__ && __BROWSER__) { + if (__DEV__ && __GLOBAL__) { ;(instance as any).__keepAliveStorageContainer = storageContainer } } diff --git a/packages/runtime-core/src/helpers/renderSlot.ts b/packages/runtime-core/src/helpers/renderSlot.ts index 2f1296bb13..7f2615523d 100644 --- a/packages/runtime-core/src/helpers/renderSlot.ts +++ b/packages/runtime-core/src/helpers/renderSlot.ts @@ -14,7 +14,7 @@ import { isVNode, openBlock, } from '../vnode' -import { PatchFlags, SlotFlags, isSymbol } from '@vue/shared' +import { PatchFlags, SlotFlags, hyphenate, isSymbol } from '@vue/shared' import { warn } from '../warning' import { isAsyncWrapper } from '../apiAsyncComponent' @@ -53,7 +53,8 @@ export function renderSlot( ) } - let slot = slots[name] + // in-DOM templates use kebab-case slot names, only relevant in browser + let slot = slots[name] || (__BROWSER__ && slots[hyphenate(name)]) if (__DEV__ && slot && slot.length > 1) { warn(