Skip to content

Commit

Permalink
feat(types/slots): support slot presence / props type checks via `def…
Browse files Browse the repository at this point in the history
…ineSlots` macro and `slots` option (#7982)
  • Loading branch information
sxzz committed Apr 3, 2023
1 parent 59e8284 commit 5a2f5d5
Show file tree
Hide file tree
Showing 16 changed files with 380 additions and 39 deletions.
Expand Up @@ -1785,6 +1785,51 @@ return { props, emit }
})"
`;

exports[`SFC compile <script setup> > with TypeScript > defineSlots() > basic usage 1`] = `
"import { useSlots as _useSlots, defineComponent as _defineComponent } from 'vue'

export default /*#__PURE__*/_defineComponent({
setup(__props, { expose: __expose }) {
__expose();

const slots = _useSlots()

return { slots }
}

})"
`;

exports[`SFC compile <script setup> > with TypeScript > defineSlots() > w/o generic params 1`] = `
"import { useSlots as _useSlots } from 'vue'

export default {
setup(__props, { expose: __expose }) {
__expose();

const slots = _useSlots()

return { slots }
}

}"
`;

exports[`SFC compile <script setup> > with TypeScript > defineSlots() > w/o return value 1`] = `
"import { defineComponent as _defineComponent } from 'vue'

export default /*#__PURE__*/_defineComponent({
setup(__props, { expose: __expose }) {
__expose();



return { }
}

})"
`;

exports[`SFC compile <script setup> > with TypeScript > hoist type declarations 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
export interface Foo {}
Expand Down
39 changes: 39 additions & 0 deletions packages/compiler-sfc/__tests__/compileScript.spec.ts
Expand Up @@ -1585,6 +1585,45 @@ const emit = defineEmits(['a', 'b'])
assertCode(content)
})

describe('defineSlots()', () => {
test('basic usage', () => {
const { content } = compile(`
<script setup lang="ts">
const slots = defineSlots<{
default: { msg: string }
}>()
</script>
`)
assertCode(content)
expect(content).toMatch(`const slots = _useSlots()`)
expect(content).not.toMatch('defineSlots')
})

test('w/o return value', () => {
const { content } = compile(`
<script setup lang="ts">
defineSlots<{
default: { msg: string }
}>()
</script>
`)
assertCode(content)
expect(content).not.toMatch('defineSlots')
expect(content).not.toMatch(`_useSlots`)
})

test('w/o generic params', () => {
const { content } = compile(`
<script setup>
const slots = defineSlots()
</script>
`)
assertCode(content)
expect(content).toMatch(`const slots = _useSlots()`)
expect(content).not.toMatch('defineSlots')
})
})

test('runtime Enum', () => {
const { content, bindings } = compile(
`<script setup lang="ts">
Expand Down
42 changes: 40 additions & 2 deletions packages/compiler-sfc/src/compileScript.ts
Expand Up @@ -67,6 +67,7 @@ const DEFINE_EMITS = 'defineEmits'
const DEFINE_EXPOSE = 'defineExpose'
const WITH_DEFAULTS = 'withDefaults'
const DEFINE_OPTIONS = 'defineOptions'
const DEFINE_SLOTS = 'defineSlots'

const isBuiltInDir = makeMap(
`once,memo,if,for,else,else-if,slot,text,html,on,bind,model,show,cloak,is`
Expand Down Expand Up @@ -312,6 +313,7 @@ export function compileScript(
let hasDefaultExportName = false
let hasDefaultExportRender = false
let hasDefineOptionsCall = false
let hasDefineSlotsCall = false
let propsRuntimeDecl: Node | undefined
let propsRuntimeDefaults: Node | undefined
let propsDestructureDecl: Node | undefined
Expand Down Expand Up @@ -590,6 +592,30 @@ export function compileScript(
return true
}

function processDefineSlots(node: Node, declId?: LVal): boolean {
if (!isCallOf(node, DEFINE_SLOTS)) {
return false
}
if (hasDefineSlotsCall) {
error(`duplicate ${DEFINE_SLOTS}() call`, node)
}
hasDefineSlotsCall = true

if (node.arguments.length > 0) {
error(`${DEFINE_SLOTS}() cannot accept arguments`, node)
}

if (declId) {
s.overwrite(
startOffset + node.start!,
startOffset + node.end!,
`${helper('useSlots')}()`
)
}

return true
}

function getAstBody(): Statement[] {
return scriptAst
? [...scriptSetupAst.body, ...scriptAst.body]
Expand Down Expand Up @@ -683,6 +709,7 @@ export function compileScript(
let propsOption = undefined
let emitsOption = undefined
let exposeOption = undefined
let slotsOption = undefined
if (optionsRuntimeDecl.type === 'ObjectExpression') {
for (const prop of optionsRuntimeDecl.properties) {
if (
Expand All @@ -692,6 +719,7 @@ export function compileScript(
if (prop.key.name === 'props') propsOption = prop
if (prop.key.name === 'emits') emitsOption = prop
if (prop.key.name === 'expose') exposeOption = prop
if (prop.key.name === 'slots') slotsOption = prop
}
}
}
Expand All @@ -714,6 +742,12 @@ export function compileScript(
exposeOption
)
}
if (slotsOption) {
error(
`${DEFINE_OPTIONS}() cannot be used to declare slots. Use ${DEFINE_SLOTS}() instead.`,
slotsOption
)
}

return true
}
Expand Down Expand Up @@ -1286,7 +1320,8 @@ export function compileScript(
processDefineProps(expr) ||
processDefineEmits(expr) ||
processDefineOptions(expr) ||
processWithDefaults(expr)
processWithDefaults(expr) ||
processDefineSlots(expr)
) {
s.remove(node.start! + startOffset, node.end! + startOffset)
} else if (processDefineExpose(expr)) {
Expand Down Expand Up @@ -1320,7 +1355,10 @@ export function compileScript(
const isDefineProps =
processDefineProps(init, decl.id) ||
processWithDefaults(init, decl.id)
const isDefineEmits = processDefineEmits(init, decl.id)
const isDefineEmits =
!isDefineProps && processDefineEmits(init, decl.id)
!isDefineEmits && processDefineSlots(init, decl.id)

if (isDefineProps || isDefineEmits) {
if (left === 1) {
s.remove(node.start! + startOffset, node.end! + startOffset)
Expand Down
69 changes: 68 additions & 1 deletion packages/dts-test/defineComponent.test-d.tsx
Expand Up @@ -8,7 +8,10 @@ import {
ComponentPublicInstance,
ComponentOptions,
SetupContext,
h
h,
SlotsType,
Slots,
VNode
} from 'vue'
import { describe, expectType, IsUnion } from './utils'

Expand Down Expand Up @@ -1406,6 +1409,69 @@ export default {
})
}

describe('slots', () => {
const comp1 = defineComponent({
slots: Object as SlotsType<{
default: { foo: string; bar: number }
optional?: { data: string }
undefinedScope: undefined | { data: string }
optionalUndefinedScope?: undefined | { data: string }
}>,
setup(props, { slots }) {
expectType<(scope: { foo: string; bar: number }) => VNode[]>(
slots.default
)
expectType<((scope: { data: string }) => VNode[]) | undefined>(
slots.optional
)

slots.default({ foo: 'foo', bar: 1 })

// @ts-expect-error it's optional
slots.optional({ data: 'foo' })
slots.optional?.({ data: 'foo' })

expectType<{
(): VNode[]
(scope: undefined | { data: string }): VNode[]
}>(slots.undefinedScope)

expectType<
| { (): VNode[]; (scope: undefined | { data: string }): VNode[] }
| undefined
>(slots.optionalUndefinedScope)

slots.default({ foo: 'foo', bar: 1 })
// @ts-expect-error it's optional
slots.optional({ data: 'foo' })
slots.optional?.({ data: 'foo' })
slots.undefinedScope()
slots.undefinedScope(undefined)
// @ts-expect-error
slots.undefinedScope('foo')

slots.optionalUndefinedScope?.()
slots.optionalUndefinedScope?.(undefined)
slots.optionalUndefinedScope?.({ data: 'foo' })
// @ts-expect-error
slots.optionalUndefinedScope()
// @ts-expect-error
slots.optionalUndefinedScope?.('foo')

expectType<typeof slots | undefined>(new comp1().$slots)
}
})

const comp2 = defineComponent({
setup(props, { slots }) {
// unknown slots
expectType<Slots>(slots)
expectType<((...args: any[]) => VNode[]) | undefined>(slots.default)
}
})
expectType<Slots | undefined>(new comp2().$slots)
})

import {
DefineComponent,
ComponentOptionsMixin,
Expand All @@ -1428,6 +1494,7 @@ declare const MyButton: DefineComponent<
ComponentOptionsMixin,
EmitsOptions,
string,
{},
VNodeProps & AllowedComponentProps & ComponentCustomProps,
Readonly<ExtractPropTypes<{}>>,
{}
Expand Down
28 changes: 27 additions & 1 deletion packages/dts-test/functionalComponent.test-d.tsx
@@ -1,4 +1,4 @@
import { h, Text, FunctionalComponent, Component } from 'vue'
import { h, Text, FunctionalComponent, Component, VNode } from 'vue'
import { expectType } from './utils'

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

expectType<Component>(Qux)

const Quux: FunctionalComponent<
{},
{},
{
default: { foo: number }
optional?: { foo: number }
}
> = (props, { emit, slots }) => {
expectType<{
default: (scope: { foo: number }) => VNode[]
optional?: (scope: { foo: number }) => VNode[]
}>(slots)

slots.default({ foo: 123 })
// @ts-expect-error
slots.default({ foo: 'fesf' })

slots.optional?.({ foo: 123 })
// @ts-expect-error
slots.optional?.({ foo: 'fesf' })
// @ts-expect-error
slots.optional({ foo: 123 })
}
expectType<Component>(Quux)
;<Quux />
25 changes: 24 additions & 1 deletion packages/dts-test/setupHelpers.test-d.ts
Expand Up @@ -4,7 +4,9 @@ import {
useAttrs,
useSlots,
withDefaults,
Slots
Slots,
defineSlots,
VNode
} from 'vue'
import { describe, expectType } from './utils'

Expand Down Expand Up @@ -179,6 +181,27 @@ describe('defineEmits w/ runtime declaration', () => {
emit2('baz')
})

describe('defineSlots', () => {
// short syntax
const slots = defineSlots<{
default: { foo: string; bar: number }
optional?: string
}>()
expectType<(scope: { foo: string; bar: number }) => VNode[]>(slots.default)
expectType<undefined | ((scope: string) => VNode[])>(slots.optional)

// literal fn syntax (allow for specifying return type)
const fnSlots = defineSlots<{
default(props: { foo: string; bar: number }): any
optional?(props: string): any
}>()
expectType<(scope: { foo: string; bar: number }) => VNode[]>(fnSlots.default)
expectType<undefined | ((scope: string) => VNode[])>(fnSlots.optional)

const slotsUntype = defineSlots()
expectType<Slots>(slotsUntype)
})

describe('useAttrs', () => {
const attrs = useAttrs()
expectType<Record<string, unknown>>(attrs)
Expand Down

0 comments on commit 5a2f5d5

Please sign in to comment.