Skip to content

Commit 7e554bf

Browse files
authored
fix(runtime-core): resolve kebab-case slot names from in-DOM templates (#14302)
closes #14300
1 parent 0596a5f commit 7e554bf

4 files changed

Lines changed: 144 additions & 15 deletions

File tree

packages/runtime-core/__tests__/componentSlots.spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
} from '@vue/runtime-test'
1212
import { createBlock, normalizeVNode } from '../src/vnode'
1313
import { createSlots } from '../src/helpers/createSlots'
14+
import { renderSlot } from '../src/helpers/renderSlot'
15+
import { setCurrentRenderingInstance } from '../src/componentRenderContext'
1416

1517
describe('component: slots', () => {
1618
function renderWithSlots(slots: any): any {
@@ -461,4 +463,118 @@ describe('component: slots', () => {
461463
createApp(App).mount(root)
462464
expect(serializeInner(root)).toBe('foo')
463465
})
466+
467+
// in-DOM templates use kebab-case slot names
468+
describe('in-DOM template kebab-case slot name resolution', () => {
469+
beforeEach(() => {
470+
__BROWSER__ = true
471+
})
472+
473+
afterEach(() => {
474+
__BROWSER__ = false
475+
})
476+
477+
test('should resolve camelCase slot access to kebab-case via slots', () => {
478+
const Comp = {
479+
setup(_: any, { slots }: any) {
480+
// Access with camelCase, but slot is passed with kebab-case
481+
return () => slots.dropdownRender()
482+
},
483+
}
484+
485+
const App = {
486+
setup() {
487+
// Parent passes slot with kebab-case name (simulating in-DOM template)
488+
return () =>
489+
h(Comp, null, { 'dropdown-render': () => 'dropdown content' })
490+
},
491+
}
492+
493+
const root = nodeOps.createElement('div')
494+
createApp(App).mount(root)
495+
expect(serializeInner(root)).toBe('dropdown content')
496+
})
497+
498+
test('should resolve camelCase slot access to kebab-case via slots (PROD)', () => {
499+
__DEV__ = false
500+
try {
501+
const Comp = {
502+
setup(_: any, { slots }: any) {
503+
// Access with camelCase, but slot is passed with kebab-case
504+
return () => slots.dropdownRender()
505+
},
506+
}
507+
508+
const App = {
509+
setup() {
510+
// Parent passes slot with kebab-case name (simulating in-DOM template)
511+
return () =>
512+
h(Comp, null, { 'dropdown-render': () => 'dropdown content' })
513+
},
514+
}
515+
516+
const root = nodeOps.createElement('div')
517+
createApp(App).mount(root)
518+
expect(serializeInner(root)).toBe('dropdown content')
519+
} finally {
520+
__DEV__ = true
521+
}
522+
})
523+
524+
test('should prefer exact match over kebab-case conversion via slots', () => {
525+
const Comp = {
526+
setup(_: any, { slots }: any) {
527+
return () => slots.dropdownRender()
528+
},
529+
}
530+
531+
const App = {
532+
setup() {
533+
// Both exact match and kebab-case exist
534+
return () =>
535+
h(Comp, null, {
536+
'dropdown-render': () => 'kebab',
537+
dropdownRender: () => 'exact',
538+
})
539+
},
540+
}
541+
542+
const root = nodeOps.createElement('div')
543+
createApp(App).mount(root)
544+
// exact match should take priority
545+
expect(serializeInner(root)).toBe('exact')
546+
})
547+
548+
// renderSlot tests
549+
describe('renderSlot', () => {
550+
beforeEach(() => {
551+
setCurrentRenderingInstance({ type: {} } as any)
552+
})
553+
554+
afterEach(() => {
555+
setCurrentRenderingInstance(null)
556+
})
557+
558+
test('should resolve camelCase slot name to kebab-case via renderSlot', () => {
559+
let child: any
560+
const vnode = renderSlot(
561+
{ 'dropdown-render': () => [(child = h('child'))] },
562+
'dropdownRender',
563+
)
564+
expect(vnode.children).toEqual([child])
565+
})
566+
567+
test('should prefer exact match over kebab-case conversion via renderSlot', () => {
568+
let exactChild: any
569+
const vnode = renderSlot(
570+
{
571+
'dropdown-render': () => [h('kebab')],
572+
dropdownRender: () => [(exactChild = h('exact'))],
573+
},
574+
'dropdownRender',
575+
)
576+
expect(vnode.children).toEqual([exactChild])
577+
})
578+
})
579+
})
464580
})

packages/runtime-core/src/component.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
ShapeFlags,
6767
extend,
6868
getGlobalThis,
69+
hyphenate,
6970
isArray,
7071
isFunction,
7172
isObject,
@@ -1110,17 +1111,20 @@ const attrsProxyHandlers = __DEV__
11101111
},
11111112
}
11121113

1113-
/**
1114-
* Dev-only
1115-
*/
1116-
function getSlotsProxy(instance: ComponentInternalInstance): Slots {
1117-
return new Proxy(instance.slots, {
1118-
get(target, key: string) {
1114+
const createSlotsProxyHandlers = (
1115+
instance: ComponentInternalInstance,
1116+
): ProxyHandler<InternalSlots> => ({
1117+
get(target, key: string | symbol) {
1118+
if (__DEV__) {
11191119
track(instance, TrackOpTypes.GET, '$slots')
1120-
return target[key]
1121-
},
1122-
})
1123-
}
1120+
}
1121+
// in-DOM templates use kebab-case slot names, only relevant in browser
1122+
return (
1123+
target[key as string] ||
1124+
(__BROWSER__ && typeof key === 'string' && target[hyphenate(key)])
1125+
)
1126+
},
1127+
})
11241128

11251129
export function createSetupContext(
11261130
instance: ComponentInternalInstance,
@@ -1162,7 +1166,13 @@ export function createSetupContext(
11621166
)
11631167
},
11641168
get slots() {
1165-
return slotsProxy || (slotsProxy = getSlotsProxy(instance))
1169+
return (
1170+
slotsProxy ||
1171+
(slotsProxy = new Proxy(
1172+
instance.slots,
1173+
createSlotsProxyHandlers(instance),
1174+
))
1175+
)
11661176
},
11671177
get emit() {
11681178
return (event: string, ...args: any[]) => instance.emit(event, ...args)
@@ -1172,7 +1182,9 @@ export function createSetupContext(
11721182
} else {
11731183
return {
11741184
attrs: new Proxy(instance.attrs, attrsProxyHandlers),
1175-
slots: instance.slots,
1185+
slots: __BROWSER__
1186+
? new Proxy(instance.slots, createSlotsProxyHandlers(instance))
1187+
: instance.slots,
11761188
emit: instance.emit,
11771189
expose,
11781190
}

packages/runtime-core/src/components/KeepAlive.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ const KeepAliveImpl: ComponentOptions = {
189189
}
190190

191191
// for e2e test
192-
if (__DEV__ && __BROWSER__) {
192+
if (__DEV__ && __GLOBAL__) {
193193
;(instance as any).__keepAliveStorageContainer = storageContainer
194194
}
195195
}

packages/runtime-core/src/helpers/renderSlot.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
isVNode,
1515
openBlock,
1616
} from '../vnode'
17-
import { PatchFlags, SlotFlags, isSymbol } from '@vue/shared'
17+
import { PatchFlags, SlotFlags, hyphenate, isSymbol } from '@vue/shared'
1818
import { warn } from '../warning'
1919
import { isAsyncWrapper } from '../apiAsyncComponent'
2020

@@ -53,7 +53,8 @@ export function renderSlot(
5353
)
5454
}
5555

56-
let slot = slots[name]
56+
// in-DOM templates use kebab-case slot names, only relevant in browser
57+
let slot = slots[name] || (__BROWSER__ && slots[hyphenate(name)])
5758

5859
if (__DEV__ && slot && slot.length > 1) {
5960
warn(

0 commit comments

Comments
 (0)