Skip to content

Commit 2506700

Browse files
fix(slots): handle nullish v-bind slot props (#15177)
1 parent 77061fe commit 2506700

5 files changed

Lines changed: 121 additions & 4 deletions

File tree

packages/runtime-core/__tests__/helpers/renderSlot.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,50 @@ describe('renderSlot', () => {
115115
expect(slot.key).toBe('user')
116116
})
117117

118+
it('should handle nullish props', () => {
119+
for (const props of [null, undefined]) {
120+
let receivedProps: any
121+
const vnode = renderSlot(
122+
{
123+
default: props => {
124+
receivedProps = props
125+
return [h('div')]
126+
},
127+
},
128+
'default',
129+
props,
130+
)
131+
expect(receivedProps).toEqual({})
132+
expect(vnode.key).toBe('_default')
133+
}
134+
})
135+
136+
it('should handle nullish props with a compiler-injected slot key', () => {
137+
for (const props of [null, undefined]) {
138+
const vnode = renderSlot(
139+
{ default: () => [h('div')] },
140+
'default',
141+
props,
142+
undefined,
143+
undefined,
144+
'branch',
145+
)
146+
expect(vnode.key).toBe('branch')
147+
}
148+
})
149+
150+
it('should handle nullish props in custom element mode', () => {
151+
setCurrentRenderingInstance({ type: {}, ce: {} } as any)
152+
153+
for (const props of [null, undefined]) {
154+
const vnode = renderSlot({}, 'foo', props, undefined, undefined, 'branch')
155+
const slot = (vnode.children as any[])[0]
156+
expect(slot.type).toBe('slot')
157+
expect(slot.key).toBe('branch')
158+
expect(slot.props.name).toBe('foo')
159+
}
160+
})
161+
118162
it('should render slot fallback', () => {
119163
const vnode = renderSlot({}, 'default', { key: 'foo' }, () => ['fallback'])
120164
expect(vnode.children).toEqual(['fallback'])

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,20 @@ import { isAsyncWrapper } from '../apiAsyncComponent'
2727
export function renderSlot(
2828
slots: Slots,
2929
name: string,
30-
props: Data = {},
30+
// can be nullish: `v-bind` on a `<slot>` compiles to
31+
// `normalizeProps(guardReactiveProps(exp))`, and both helpers preserve
32+
// nullish values by design
33+
props?: Data | null,
3134
// this is not a user-facing function, so the fallback is always generated by
3235
// the compiler and guaranteed to be a function returning an array
3336
fallback?: () => VNodeArrayChildren,
3437
noSlotted?: boolean,
3538
branchKey?: PropertyKey,
3639
): VNode {
40+
// normalize once so that the key lookups below and the slot function itself
41+
// always see an object. fresh object rather than EMPTY_OBJ because the
42+
// custom element branch mutates it
43+
if (props == null) props = {}
3744
if (
3845
currentRenderingInstance!.ce ||
3946
(currentRenderingInstance!.parent &&

packages/server-renderer/__tests__/ssrSlot.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,33 @@ describe('ssr: slot', () => {
110110
)
111111
})
112112

113+
test('nullish slot props', async () => {
114+
const nullishBind = {
115+
one: {
116+
props: ['value'],
117+
template: `<div><slot v-bind="value" name="foo">fallback</slot></div>`,
118+
},
119+
}
120+
121+
expect(
122+
await renderToString(
123+
createApp({
124+
components: nullishBind,
125+
template: `<one :value="null"/>`,
126+
}),
127+
),
128+
).toBe(`<div><!--[-->fallback<!--]--></div>`)
129+
130+
expect(
131+
await renderToString(
132+
createApp({
133+
components: nullishBind,
134+
template: `<one :value="null"><template #foo="{ label }">{{ label || 'none' }}</template></one>`,
135+
}),
136+
),
137+
).toBe(`<div><!--[-->none<!--]--></div>`)
138+
})
139+
113140
test('transition slot', async () => {
114141
const ReusableTransition = {
115142
template: `<transition><slot/></transition>`,

packages/server-renderer/src/helpers/ssrRenderSlot.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export type SSRSlot = (
2424
export function ssrRenderSlot(
2525
slots: Slots | SSRSlots,
2626
slotName: string,
27-
slotProps: Props,
27+
// can be nullish when `v-bind` on the `<slot>` evaluates to nullish
28+
slotProps: Props | null | undefined,
2829
fallbackRenderFn: (() => void) | null,
2930
push: PushFn,
3031
parentComponent: ComponentInternalInstance,
@@ -47,7 +48,7 @@ export function ssrRenderSlot(
4748
export function ssrRenderSlotInner(
4849
slots: Slots | SSRSlots,
4950
slotName: string,
50-
slotProps: Props,
51+
slotProps: Props | null | undefined,
5152
fallbackRenderFn: (() => void) | null,
5253
push: PushFn,
5354
parentComponent: ComponentInternalInstance,
@@ -61,7 +62,9 @@ export function ssrRenderSlotInner(
6162
slotBuffer.push(item)
6263
}
6364
const ret = slotFn(
64-
slotProps,
65+
// keep the slot function's contract in sync with `renderSlot`, which also
66+
// normalizes nullish props before invoking the slot
67+
slotProps || {},
6568
bufferedPush,
6669
parentComponent,
6770
slotScopeId ? ' ' + slotScopeId : '',

packages/vue/__tests__/index.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,42 @@ describe('compiler + runtime integration', () => {
293293
expect(container.innerHTML).toBe(`<div>2<div>1</div></div>`)
294294
})
295295

296+
test('nullish v-bind on <slot>', async () => {
297+
const Child = {
298+
props: ['error', 'value'],
299+
template:
300+
`<div>` +
301+
`<template v-if="error">{{ error }}</template>` +
302+
`<template v-else><slot v-bind="value" name="scoped">fallback</slot></template>` +
303+
`</div>`,
304+
}
305+
306+
const fallbackContainer = document.createElement('div')
307+
createApp({
308+
components: { Child },
309+
template: `<Child :error="null" :value="null"/>`,
310+
}).mount(fallbackContainer)
311+
expect(fallbackContainer.innerHTML).toBe(`<div>fallback</div>`)
312+
313+
const value = ref<{ label: string } | null>(null)
314+
const container = document.createElement('div')
315+
createApp({
316+
components: { Child },
317+
setup() {
318+
return { value }
319+
},
320+
template:
321+
`<Child :error="null" :value="value">` +
322+
`<template #scoped="{ label }">{{ label || 'none' }}</template>` +
323+
`</Child>`,
324+
}).mount(container)
325+
expect(container.innerHTML).toBe(`<div>none</div>`)
326+
327+
value.value = { label: 'foo' }
328+
await nextTick()
329+
expect(container.innerHTML).toBe(`<div>foo</div>`)
330+
})
331+
296332
// #2413
297333
it('EMPTY_ARR should not change', () => {
298334
const App = {

0 commit comments

Comments
 (0)