]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(runtime-core): resolve kebab-case slot names from in-DOM templates (#14302)
authoredison <daiwei521@126.com>
Mon, 19 Jan 2026 01:04:38 +0000 (09:04 +0800)
committerGitHub <noreply@github.com>
Mon, 19 Jan 2026 01:04:38 +0000 (09:04 +0800)
closes #14300

packages/runtime-core/__tests__/componentSlots.spec.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/KeepAlive.ts
packages/runtime-core/src/helpers/renderSlot.ts

index 458731dd150c9b3ac407b3868822b809d444cd51..9f0a8e570288a401977cb64e8ed311c79c7b6363 100644 (file)
@@ -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])
+      })
+    })
+  })
 })
index 4e1aa5e4d38dffef4351343401b7e6be9234f2fb..b24b57b4d480763f0735dccd8ead1cc2146b2389 100644 (file)
@@ -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<InternalSlots> => ({
+  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,
     }
index 55eaf86244330d0cdf80bb0c221ec285e0084220..32883d8f00f060a766b76084dfdd1ed457223be9 100644 (file)
@@ -189,7 +189,7 @@ const KeepAliveImpl: ComponentOptions = {
       }
 
       // for e2e test
-      if (__DEV__ && __BROWSER__) {
+      if (__DEV__ && __GLOBAL__) {
         ;(instance as any).__keepAliveStorageContainer = storageContainer
       }
     }
index 2f1296bb13e9310392a68e627e0f37d6715c43e4..7f2615523d1ef4840f7ca06abbc1548643cddaf6 100644 (file)
@@ -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(