Skip to content

Commit 5a2f5d5

Browse files
authored
feat(types/slots): support slot presence / props type checks via defineSlots macro and slots option (#7982)
1 parent 59e8284 commit 5a2f5d5

16 files changed

+380
-39
lines changed

packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap

+45
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,51 @@ return { props, emit }
17851785
})"
17861786
`;
17871787

1788+
exports[`SFC compile <script setup> > with TypeScript > defineSlots() > basic usage 1`] = `
1789+
"import { useSlots as _useSlots, defineComponent as _defineComponent } from 'vue'
1790+
1791+
export default /*#__PURE__*/_defineComponent({
1792+
setup(__props, { expose: __expose }) {
1793+
__expose();
1794+
1795+
const slots = _useSlots()
1796+
1797+
return { slots }
1798+
}
1799+
1800+
})"
1801+
`;
1802+
1803+
exports[`SFC compile <script setup> > with TypeScript > defineSlots() > w/o generic params 1`] = `
1804+
"import { useSlots as _useSlots } from 'vue'
1805+
1806+
export default {
1807+
setup(__props, { expose: __expose }) {
1808+
__expose();
1809+
1810+
const slots = _useSlots()
1811+
1812+
return { slots }
1813+
}
1814+
1815+
}"
1816+
`;
1817+
1818+
exports[`SFC compile <script setup> > with TypeScript > defineSlots() > w/o return value 1`] = `
1819+
"import { defineComponent as _defineComponent } from 'vue'
1820+
1821+
export default /*#__PURE__*/_defineComponent({
1822+
setup(__props, { expose: __expose }) {
1823+
__expose();
1824+
1825+
1826+
1827+
return { }
1828+
}
1829+
1830+
})"
1831+
`;
1832+
17881833
exports[`SFC compile <script setup> > with TypeScript > hoist type declarations 1`] = `
17891834
"import { defineComponent as _defineComponent } from 'vue'
17901835
export interface Foo {}

packages/compiler-sfc/__tests__/compileScript.spec.ts

+39
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,45 @@ const emit = defineEmits(['a', 'b'])
15851585
assertCode(content)
15861586
})
15871587

1588+
describe('defineSlots()', () => {
1589+
test('basic usage', () => {
1590+
const { content } = compile(`
1591+
<script setup lang="ts">
1592+
const slots = defineSlots<{
1593+
default: { msg: string }
1594+
}>()
1595+
</script>
1596+
`)
1597+
assertCode(content)
1598+
expect(content).toMatch(`const slots = _useSlots()`)
1599+
expect(content).not.toMatch('defineSlots')
1600+
})
1601+
1602+
test('w/o return value', () => {
1603+
const { content } = compile(`
1604+
<script setup lang="ts">
1605+
defineSlots<{
1606+
default: { msg: string }
1607+
}>()
1608+
</script>
1609+
`)
1610+
assertCode(content)
1611+
expect(content).not.toMatch('defineSlots')
1612+
expect(content).not.toMatch(`_useSlots`)
1613+
})
1614+
1615+
test('w/o generic params', () => {
1616+
const { content } = compile(`
1617+
<script setup>
1618+
const slots = defineSlots()
1619+
</script>
1620+
`)
1621+
assertCode(content)
1622+
expect(content).toMatch(`const slots = _useSlots()`)
1623+
expect(content).not.toMatch('defineSlots')
1624+
})
1625+
})
1626+
15881627
test('runtime Enum', () => {
15891628
const { content, bindings } = compile(
15901629
`<script setup lang="ts">

packages/compiler-sfc/src/compileScript.ts

+40-2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const DEFINE_EMITS = 'defineEmits'
6767
const DEFINE_EXPOSE = 'defineExpose'
6868
const WITH_DEFAULTS = 'withDefaults'
6969
const DEFINE_OPTIONS = 'defineOptions'
70+
const DEFINE_SLOTS = 'defineSlots'
7071

7172
const isBuiltInDir = makeMap(
7273
`once,memo,if,for,else,else-if,slot,text,html,on,bind,model,show,cloak,is`
@@ -312,6 +313,7 @@ export function compileScript(
312313
let hasDefaultExportName = false
313314
let hasDefaultExportRender = false
314315
let hasDefineOptionsCall = false
316+
let hasDefineSlotsCall = false
315317
let propsRuntimeDecl: Node | undefined
316318
let propsRuntimeDefaults: Node | undefined
317319
let propsDestructureDecl: Node | undefined
@@ -590,6 +592,30 @@ export function compileScript(
590592
return true
591593
}
592594

595+
function processDefineSlots(node: Node, declId?: LVal): boolean {
596+
if (!isCallOf(node, DEFINE_SLOTS)) {
597+
return false
598+
}
599+
if (hasDefineSlotsCall) {
600+
error(`duplicate ${DEFINE_SLOTS}() call`, node)
601+
}
602+
hasDefineSlotsCall = true
603+
604+
if (node.arguments.length > 0) {
605+
error(`${DEFINE_SLOTS}() cannot accept arguments`, node)
606+
}
607+
608+
if (declId) {
609+
s.overwrite(
610+
startOffset + node.start!,
611+
startOffset + node.end!,
612+
`${helper('useSlots')}()`
613+
)
614+
}
615+
616+
return true
617+
}
618+
593619
function getAstBody(): Statement[] {
594620
return scriptAst
595621
? [...scriptSetupAst.body, ...scriptAst.body]
@@ -683,6 +709,7 @@ export function compileScript(
683709
let propsOption = undefined
684710
let emitsOption = undefined
685711
let exposeOption = undefined
712+
let slotsOption = undefined
686713
if (optionsRuntimeDecl.type === 'ObjectExpression') {
687714
for (const prop of optionsRuntimeDecl.properties) {
688715
if (
@@ -692,6 +719,7 @@ export function compileScript(
692719
if (prop.key.name === 'props') propsOption = prop
693720
if (prop.key.name === 'emits') emitsOption = prop
694721
if (prop.key.name === 'expose') exposeOption = prop
722+
if (prop.key.name === 'slots') slotsOption = prop
695723
}
696724
}
697725
}
@@ -714,6 +742,12 @@ export function compileScript(
714742
exposeOption
715743
)
716744
}
745+
if (slotsOption) {
746+
error(
747+
`${DEFINE_OPTIONS}() cannot be used to declare slots. Use ${DEFINE_SLOTS}() instead.`,
748+
slotsOption
749+
)
750+
}
717751

718752
return true
719753
}
@@ -1286,7 +1320,8 @@ export function compileScript(
12861320
processDefineProps(expr) ||
12871321
processDefineEmits(expr) ||
12881322
processDefineOptions(expr) ||
1289-
processWithDefaults(expr)
1323+
processWithDefaults(expr) ||
1324+
processDefineSlots(expr)
12901325
) {
12911326
s.remove(node.start! + startOffset, node.end! + startOffset)
12921327
} else if (processDefineExpose(expr)) {
@@ -1320,7 +1355,10 @@ export function compileScript(
13201355
const isDefineProps =
13211356
processDefineProps(init, decl.id) ||
13221357
processWithDefaults(init, decl.id)
1323-
const isDefineEmits = processDefineEmits(init, decl.id)
1358+
const isDefineEmits =
1359+
!isDefineProps && processDefineEmits(init, decl.id)
1360+
!isDefineEmits && processDefineSlots(init, decl.id)
1361+
13241362
if (isDefineProps || isDefineEmits) {
13251363
if (left === 1) {
13261364
s.remove(node.start! + startOffset, node.end! + startOffset)

packages/dts-test/defineComponent.test-d.tsx

+68-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
ComponentPublicInstance,
99
ComponentOptions,
1010
SetupContext,
11-
h
11+
h,
12+
SlotsType,
13+
Slots,
14+
VNode
1215
} from 'vue'
1316
import { describe, expectType, IsUnion } from './utils'
1417

@@ -1406,6 +1409,69 @@ export default {
14061409
})
14071410
}
14081411

1412+
describe('slots', () => {
1413+
const comp1 = defineComponent({
1414+
slots: Object as SlotsType<{
1415+
default: { foo: string; bar: number }
1416+
optional?: { data: string }
1417+
undefinedScope: undefined | { data: string }
1418+
optionalUndefinedScope?: undefined | { data: string }
1419+
}>,
1420+
setup(props, { slots }) {
1421+
expectType<(scope: { foo: string; bar: number }) => VNode[]>(
1422+
slots.default
1423+
)
1424+
expectType<((scope: { data: string }) => VNode[]) | undefined>(
1425+
slots.optional
1426+
)
1427+
1428+
slots.default({ foo: 'foo', bar: 1 })
1429+
1430+
// @ts-expect-error it's optional
1431+
slots.optional({ data: 'foo' })
1432+
slots.optional?.({ data: 'foo' })
1433+
1434+
expectType<{
1435+
(): VNode[]
1436+
(scope: undefined | { data: string }): VNode[]
1437+
}>(slots.undefinedScope)
1438+
1439+
expectType<
1440+
| { (): VNode[]; (scope: undefined | { data: string }): VNode[] }
1441+
| undefined
1442+
>(slots.optionalUndefinedScope)
1443+
1444+
slots.default({ foo: 'foo', bar: 1 })
1445+
// @ts-expect-error it's optional
1446+
slots.optional({ data: 'foo' })
1447+
slots.optional?.({ data: 'foo' })
1448+
slots.undefinedScope()
1449+
slots.undefinedScope(undefined)
1450+
// @ts-expect-error
1451+
slots.undefinedScope('foo')
1452+
1453+
slots.optionalUndefinedScope?.()
1454+
slots.optionalUndefinedScope?.(undefined)
1455+
slots.optionalUndefinedScope?.({ data: 'foo' })
1456+
// @ts-expect-error
1457+
slots.optionalUndefinedScope()
1458+
// @ts-expect-error
1459+
slots.optionalUndefinedScope?.('foo')
1460+
1461+
expectType<typeof slots | undefined>(new comp1().$slots)
1462+
}
1463+
})
1464+
1465+
const comp2 = defineComponent({
1466+
setup(props, { slots }) {
1467+
// unknown slots
1468+
expectType<Slots>(slots)
1469+
expectType<((...args: any[]) => VNode[]) | undefined>(slots.default)
1470+
}
1471+
})
1472+
expectType<Slots | undefined>(new comp2().$slots)
1473+
})
1474+
14091475
import {
14101476
DefineComponent,
14111477
ComponentOptionsMixin,
@@ -1428,6 +1494,7 @@ declare const MyButton: DefineComponent<
14281494
ComponentOptionsMixin,
14291495
EmitsOptions,
14301496
string,
1497+
{},
14311498
VNodeProps & AllowedComponentProps & ComponentCustomProps,
14321499
Readonly<ExtractPropTypes<{}>>,
14331500
{}

packages/dts-test/functionalComponent.test-d.tsx

+27-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { h, Text, FunctionalComponent, Component } from 'vue'
1+
import { h, Text, FunctionalComponent, Component, VNode } from 'vue'
22
import { expectType } from './utils'
33

44
// simple function signature
@@ -68,3 +68,29 @@ const Qux: FunctionalComponent<{}, ['foo', 'bar']> = (props, { emit }) => {
6868
}
6969

7070
expectType<Component>(Qux)
71+
72+
const Quux: FunctionalComponent<
73+
{},
74+
{},
75+
{
76+
default: { foo: number }
77+
optional?: { foo: number }
78+
}
79+
> = (props, { emit, slots }) => {
80+
expectType<{
81+
default: (scope: { foo: number }) => VNode[]
82+
optional?: (scope: { foo: number }) => VNode[]
83+
}>(slots)
84+
85+
slots.default({ foo: 123 })
86+
// @ts-expect-error
87+
slots.default({ foo: 'fesf' })
88+
89+
slots.optional?.({ foo: 123 })
90+
// @ts-expect-error
91+
slots.optional?.({ foo: 'fesf' })
92+
// @ts-expect-error
93+
slots.optional({ foo: 123 })
94+
}
95+
expectType<Component>(Quux)
96+
;<Quux />

packages/dts-test/setupHelpers.test-d.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
useAttrs,
55
useSlots,
66
withDefaults,
7-
Slots
7+
Slots,
8+
defineSlots,
9+
VNode
810
} from 'vue'
911
import { describe, expectType } from './utils'
1012

@@ -179,6 +181,27 @@ describe('defineEmits w/ runtime declaration', () => {
179181
emit2('baz')
180182
})
181183

184+
describe('defineSlots', () => {
185+
// short syntax
186+
const slots = defineSlots<{
187+
default: { foo: string; bar: number }
188+
optional?: string
189+
}>()
190+
expectType<(scope: { foo: string; bar: number }) => VNode[]>(slots.default)
191+
expectType<undefined | ((scope: string) => VNode[])>(slots.optional)
192+
193+
// literal fn syntax (allow for specifying return type)
194+
const fnSlots = defineSlots<{
195+
default(props: { foo: string; bar: number }): any
196+
optional?(props: string): any
197+
}>()
198+
expectType<(scope: { foo: string; bar: number }) => VNode[]>(fnSlots.default)
199+
expectType<undefined | ((scope: string) => VNode[])>(fnSlots.optional)
200+
201+
const slotsUntype = defineSlots()
202+
expectType<Slots>(slotsUntype)
203+
})
204+
182205
describe('useAttrs', () => {
183206
const attrs = useAttrs()
184207
expectType<Record<string, unknown>>(attrs)

0 commit comments

Comments
 (0)