}"
`;
+exports[`SFC compile <script setup> > defineModel() > basic usage 1`] = `
+"import { useModel as _useModel } from 'vue'
+
+export default {
+ props: {
+ \\"modelValue\\": { required: true },
+ \\"count\\": {},
+ },
+ emits: [\\"update:modelValue\\", \\"update:count\\"],
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+ const modelValue = _useModel(__props, \\"modelValue\\")
+ const c = _useModel(__props, \\"count\\")
+
+return { modelValue, c }
+}
+
+}"
+`;
+
+exports[`SFC compile <script setup> > defineModel() > w/ array props 1`] = `
+"import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
+
+export default {
+ props: _mergeModels(['foo', 'bar'], {
+ \\"count\\": {},
+ }),
+ emits: [\\"update:count\\"],
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+
+ const count = _useModel(__props, \\"count\\")
+
+return { count }
+}
+
+}"
+`;
+
+exports[`SFC compile <script setup> > defineModel() > w/ defineProps and defineEmits 1`] = `
+"import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
+
+export default {
+ props: _mergeModels({ foo: String }, {
+ \\"modelValue\\": { default: 0 },
+ }),
+ emits: _mergeModels(['change'], [\\"update:modelValue\\"]),
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+
+
+ const count = _useModel(__props, \\"modelValue\\")
+
+return { count }
+}
+
+}"
+`;
+
+exports[`SFC compile <script setup> > defineModel() > w/ local flag 1`] = `
+"import { useModel as _useModel } from 'vue'
+const local = true
+
+export default {
+ props: {
+ \\"modelValue\\": { local: true, default: 1 },
+ \\"bar\\": { [key]: true },
+ \\"baz\\": { ...x },
+ \\"qux\\": x,
+ \\"foo2\\": { local: true, ...x },
+ \\"hoist\\": { local },
+ },
+ emits: [\\"update:modelValue\\", \\"update:bar\\", \\"update:baz\\", \\"update:qux\\", \\"update:foo2\\", \\"update:hoist\\"],
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+ const foo = _useModel(__props, \\"modelValue\\", { local: true })
+ const bar = _useModel(__props, \\"bar\\", { [key]: true })
+ const baz = _useModel(__props, \\"baz\\", { ...x })
+ const qux = _useModel(__props, \\"qux\\", x)
+
+ const foo2 = _useModel(__props, \\"foo2\\", { local: true })
+
+ const hoist = _useModel(__props, \\"hoist\\", { local })
+
+return { foo, bar, baz, qux, foo2, local, hoist }
+}
+
+}"
+`;
+
exports[`SFC compile <script setup> > defineOptions() > basic usage 1`] = `
"export default /*#__PURE__*/Object.assign({ name: 'FooApp' }, {
setup(__props, { expose: __expose }) {
})"
`;
+exports[`SFC compile <script setup> > with TypeScript > defineModel() > basic usage 1`] = `
+"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
+
+export default /*#__PURE__*/_defineComponent({
+ props: {
+ \\"modelValue\\": { type: [Boolean, String] },
+ \\"count\\": { type: Number },
+ \\"disabled\\": { type: Number, ...{ required: false } },
+ \\"any\\": { type: Boolean, skipCheck: true },
+ },
+ emits: [\\"update:modelValue\\", \\"update:count\\", \\"update:disabled\\", \\"update:any\\"],
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+ const modelValue = _useModel(__props, \\"modelValue\\")
+ const count = _useModel(__props, \\"count\\")
+ const disabled = _useModel(__props, \\"disabled\\")
+ const any = _useModel(__props, \\"any\\")
+
+return { modelValue, count, disabled, any }
+}
+
+})"
+`;
+
+exports[`SFC compile <script setup> > with TypeScript > defineModel() > w/ production mode 1`] = `
+"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
+
+export default /*#__PURE__*/_defineComponent({
+ props: {
+ \\"modelValue\\": { type: Boolean },
+ \\"fn\\": {},
+ \\"fnWithDefault\\": { type: Function, ...{ default: () => null } },
+ \\"str\\": {},
+ \\"optional\\": { required: false },
+ },
+ emits: [\\"update:modelValue\\", \\"update:fn\\", \\"update:fnWithDefault\\", \\"update:str\\", \\"update:optional\\"],
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+ const modelValue = _useModel(__props, \\"modelValue\\")
+ const fn = _useModel(__props, \\"fn\\")
+ const fnWithDefault = _useModel(__props, \\"fnWithDefault\\")
+ const str = _useModel(__props, \\"str\\")
+ const optional = _useModel(__props, \\"optional\\")
+
+return { modelValue, fn, fnWithDefault, str, optional }
+}
+
+})"
+`;
+
exports[`SFC compile <script setup> > with TypeScript > defineProps w/ TS assertion 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
foo: { type: Function },
bar: { type: Boolean },
baz: { type: [Boolean, Function] },
- qux: null
+ qux: {}
}, { ...defaults }),
setup(__props: any, { expose: __expose }) {
__expose();
export default /*#__PURE__*/_defineComponent({
props: {
- foo: null,
+ foo: {},
bar: { type: Boolean },
baz: { type: [Boolean, Function], default: true },
qux: { default: 'hi' }
props: {
foo: { default: 1 },
bar: { default: () => ({}) },
- baz: null,
+ baz: {},
boola: { type: Boolean },
boolb: { type: [Boolean, Number] },
func: { type: Function, default: () => {} }
'[@vue/compiler-sfc] defineOptions() cannot be used to declare props. Use defineProps() instead.'
)
})
+
+ it('should emit an error with declaring props/emits/slots/expose', () => {
+ expect(() =>
+ compile(`
+ <script setup>
+ defineOptions({ props: ['foo'] })
+ </script>
+ `)
+ ).toThrowError(
+ '[@vue/compiler-sfc] defineOptions() cannot be used to declare props. Use defineProps() instead'
+ )
+
+ expect(() =>
+ compile(`
+ <script setup>
+ defineOptions({ emits: ['update'] })
+ </script>
+ `)
+ ).toThrowError(
+ '[@vue/compiler-sfc] defineOptions() cannot be used to declare emits. Use defineEmits() instead'
+ )
+
+ expect(() =>
+ compile(`
+ <script setup>
+ defineOptions({ expose: ['foo'] })
+ </script>
+ `)
+ ).toThrowError(
+ '[@vue/compiler-sfc] defineOptions() cannot be used to declare expose. Use defineExpose() instead'
+ )
+
+ expect(() =>
+ compile(`
+ <script setup lang="ts">
+ defineOptions({ slots: Object })
+ </script>
+ `)
+ ).toThrowError(
+ '[@vue/compiler-sfc] defineOptions() cannot be used to declare slots. Use defineSlots() instead'
+ )
+ })
})
test('defineExpose()', () => {
expect(content).toMatch(/\b__expose\(\{ foo: 123 \}\)/)
})
+ describe('defineModel()', () => {
+ test('basic usage', () => {
+ const { content, bindings } = compile(
+ `
+ <script setup>
+ const modelValue = defineModel({ required: true })
+ const c = defineModel('count')
+ </script>
+ `,
+ { defineModel: true }
+ )
+ assertCode(content)
+ expect(content).toMatch('props: {')
+ expect(content).toMatch('"modelValue": { required: true },')
+ expect(content).toMatch('"count": {},')
+ expect(content).toMatch('emits: ["update:modelValue", "update:count"],')
+ expect(content).toMatch(
+ `const modelValue = _useModel(__props, "modelValue")`
+ )
+ expect(content).toMatch(`const c = _useModel(__props, "count")`)
+ expect(content).toMatch(`return { modelValue, c }`)
+ expect(content).not.toMatch('defineModel')
+
+ expect(bindings).toStrictEqual({
+ modelValue: BindingTypes.SETUP_REF,
+ count: BindingTypes.PROPS,
+ c: BindingTypes.SETUP_REF
+ })
+ })
+
+ test('w/ defineProps and defineEmits', () => {
+ const { content, bindings } = compile(
+ `
+ <script setup>
+ defineProps({ foo: String })
+ defineEmits(['change'])
+ const count = defineModel({ default: 0 })
+ </script>
+ `,
+ { defineModel: true }
+ )
+ assertCode(content)
+ expect(content).toMatch(`props: _mergeModels({ foo: String }`)
+ expect(content).toMatch(`"modelValue": { default: 0 }`)
+ expect(content).toMatch(`const count = _useModel(__props, "modelValue")`)
+ expect(content).not.toMatch('defineModel')
+ expect(bindings).toStrictEqual({
+ count: BindingTypes.SETUP_REF,
+ foo: BindingTypes.PROPS,
+ modelValue: BindingTypes.PROPS
+ })
+ })
+
+ test('w/ array props', () => {
+ const { content, bindings } = compile(
+ `
+ <script setup>
+ defineProps(['foo', 'bar'])
+ const count = defineModel('count')
+ </script>
+ `,
+ { defineModel: true }
+ )
+ assertCode(content)
+ expect(content).toMatch(`props: _mergeModels(['foo', 'bar'], {
+ "count": {},
+ })`)
+ expect(content).toMatch(`const count = _useModel(__props, "count")`)
+ expect(content).not.toMatch('defineModel')
+ expect(bindings).toStrictEqual({
+ foo: BindingTypes.PROPS,
+ bar: BindingTypes.PROPS,
+ count: BindingTypes.SETUP_REF
+ })
+ })
+
+ test('w/ local flag', () => {
+ const { content } = compile(
+ `<script setup>
+ const foo = defineModel({ local: true, default: 1 })
+ const bar = defineModel('bar', { [key]: true })
+ const baz = defineModel('baz', { ...x })
+ const qux = defineModel('qux', x)
+
+ const foo2 = defineModel('foo2', { local: true, ...x })
+
+ const local = true
+ const hoist = defineModel('hoist', { local })
+ </script>`,
+ { defineModel: true }
+ )
+ assertCode(content)
+ expect(content).toMatch(
+ `_useModel(__props, "modelValue", { local: true })`
+ )
+ expect(content).toMatch(`_useModel(__props, "bar", { [key]: true })`)
+ expect(content).toMatch(`_useModel(__props, "baz", { ...x })`)
+ expect(content).toMatch(`_useModel(__props, "qux", x)`)
+ expect(content).toMatch(`_useModel(__props, "foo2", { local: true })`)
+ expect(content).toMatch(`_useModel(__props, "hoist", { local })`)
+ })
+ })
+
test('<script> after <script setup> the script content not end with `\\n`', () => {
const { content } = compile(`
<script setup>
expect(content).toMatch(`const props = __props`)
// foo has no default value, the Function can be dropped
- expect(content).toMatch(`foo: null`)
+ expect(content).toMatch(`foo: {}`)
expect(content).toMatch(`bar: { type: Boolean }`)
expect(content).toMatch(
`baz: { type: [Boolean, Function], default: true }`
foo: { type: Function },
bar: { type: Boolean },
baz: { type: [Boolean, Function] },
- qux: null
+ qux: {}
}, { ...defaults })`.trim()
)
})
})
})
+ describe('defineModel()', () => {
+ test('basic usage', () => {
+ const { content, bindings } = compile(
+ `
+ <script setup lang="ts">
+ const modelValue = defineModel<boolean | string>()
+ const count = defineModel<number>('count')
+ const disabled = defineModel<number>('disabled', { required: false })
+ const any = defineModel<any | boolean>('any')
+ </script>
+ `,
+ { defineModel: true }
+ )
+ assertCode(content)
+ expect(content).toMatch('"modelValue": { type: [Boolean, String] }')
+ expect(content).toMatch('"count": { type: Number }')
+ expect(content).toMatch(
+ '"disabled": { type: Number, ...{ required: false } }'
+ )
+ expect(content).toMatch('"any": { type: Boolean, skipCheck: true }')
+ expect(content).toMatch(
+ 'emits: ["update:modelValue", "update:count", "update:disabled", "update:any"]'
+ )
+
+ expect(content).toMatch(
+ `const modelValue = _useModel(__props, "modelValue")`
+ )
+ expect(content).toMatch(`const count = _useModel(__props, "count")`)
+ expect(content).toMatch(
+ `const disabled = _useModel(__props, "disabled")`
+ )
+ expect(content).toMatch(`const any = _useModel(__props, "any")`)
+
+ expect(bindings).toStrictEqual({
+ modelValue: BindingTypes.SETUP_REF,
+ count: BindingTypes.SETUP_REF,
+ disabled: BindingTypes.SETUP_REF,
+ any: BindingTypes.SETUP_REF
+ })
+ })
+
+ test('w/ production mode', () => {
+ const { content, bindings } = compile(
+ `
+ <script setup lang="ts">
+ const modelValue = defineModel<boolean>()
+ const fn = defineModel<() => void>('fn')
+ const fnWithDefault = defineModel<() => void>('fnWithDefault', { default: () => null })
+ const str = defineModel<string>('str')
+ const optional = defineModel<string>('optional', { required: false })
+ </script>
+ `,
+ { defineModel: true, isProd: true }
+ )
+ assertCode(content)
+ expect(content).toMatch('"modelValue": { type: Boolean }')
+ expect(content).toMatch('"fn": {}')
+ expect(content).toMatch(
+ '"fnWithDefault": { type: Function, ...{ default: () => null } },'
+ )
+ expect(content).toMatch('"str": {}')
+ expect(content).toMatch('"optional": { required: false }')
+ expect(content).toMatch(
+ 'emits: ["update:modelValue", "update:fn", "update:fnWithDefault", "update:str", "update:optional"]'
+ )
+ expect(content).toMatch(
+ `const modelValue = _useModel(__props, "modelValue")`
+ )
+ expect(content).toMatch(`const fn = _useModel(__props, "fn")`)
+ expect(content).toMatch(`const str = _useModel(__props, "str")`)
+ expect(bindings).toStrictEqual({
+ modelValue: BindingTypes.SETUP_REF,
+ fn: BindingTypes.SETUP_REF,
+ fnWithDefault: BindingTypes.SETUP_REF,
+ str: BindingTypes.SETUP_REF,
+ optional: BindingTypes.SETUP_REF
+ })
+ })
+ })
+
test('runtime Enum', () => {
const { content, bindings } = compile(
`<script setup lang="ts">
`,
{ isProd: true }
)
+ assertCode(content)
// literals can be used as-is, non-literals are always returned from a
// function
expect(content).toMatch(`props: {
foo: { default: 1 },
bar: { default: () => ({}) },
- baz: null,
+ baz: {},
boola: { type: Boolean },
boolb: { type: [Boolean, Number] },
func: { type: Function, default: () => {} }
}`)
- assertCode(content)
})
test('aliasing', () => {
const WITH_DEFAULTS = 'withDefaults'
const DEFINE_OPTIONS = 'defineOptions'
const DEFINE_SLOTS = 'defineSlots'
+const DEFINE_MODEL = 'defineModel'
const isBuiltInDir = makeMap(
`once,memo,if,for,else,else-if,slot,text,html,on,bind,model,show,cloak,is`
* options passed to `compiler-dom`.
*/
templateOptions?: Partial<SFCTemplateCompileOptions>
-
/**
* Hoist <script setup> static constants.
* - Only enables when one `<script setup>` exists.
* @default true
*/
hoistStatic?: boolean
+ /**
+ * (**Experimental**) Enable macro `defineModel`
+ */
+ defineModel?: boolean
}
export interface ImportBinding {
type EmitsDeclType = FromNormalScript<
TSFunctionType | TSTypeLiteral | TSInterfaceBody
>
+interface ModelDecl {
+ type: TSType | undefined
+ options: string | undefined
+ identifier: string | undefined
+}
/**
* Compile `<script setup>`
// feature flags
// TODO remove in 3.4
const enableReactivityTransform = !!options.reactivityTransform
+ const enableDefineModel = !!options.defineModel
const isProd = !!options.isProd
const genSourceMap = options.sourceMap !== false
const hoistStatic = options.hoistStatic !== false && !script
let hasDefaultExportRender = false
let hasDefineOptionsCall = false
let hasDefineSlotsCall = false
+ let hasDefineModelCall = false
let propsRuntimeDecl: Node | undefined
let propsRuntimeDefaults: Node | undefined
let propsDestructureDecl: Node | undefined
let emitsTypeDecl: EmitsDeclType | undefined
let emitIdentifier: string | undefined
let optionsRuntimeDecl: Node | undefined
+ let modelDecls: Record<string, ModelDecl> = {}
let hasAwait = false
let hasInlinedSsrRenderFn = false
// props/emits declared via types
return true
}
+ function processDefineModel(node: Node, declId?: LVal): boolean {
+ if (!enableDefineModel || !isCallOf(node, DEFINE_MODEL)) {
+ return false
+ }
+ hasDefineModelCall = true
+
+ const type =
+ (node.typeParameters && node.typeParameters.params[0]) || undefined
+ let modelName: string
+ let options: Node | undefined
+ const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
+ if (arg0 && arg0.type === 'StringLiteral') {
+ modelName = arg0.value
+ options = node.arguments[1]
+ } else {
+ modelName = 'modelValue'
+ options = arg0
+ }
+
+ if (modelDecls[modelName]) {
+ error(`duplicate model name ${JSON.stringify(modelName)}`, node)
+ }
+
+ const optionsString = options
+ ? s.slice(startOffset + options.start!, startOffset + options.end!)
+ : undefined
+
+ modelDecls[modelName] = {
+ type,
+ options: optionsString,
+ identifier:
+ declId && declId.type === 'Identifier' ? declId.name : undefined
+ }
+
+ let runtimeOptions = ''
+ if (options) {
+ if (options.type === 'ObjectExpression') {
+ const local = options.properties.find(
+ p =>
+ p.type === 'ObjectProperty' &&
+ ((p.key.type === 'Identifier' && p.key.name === 'local') ||
+ (p.key.type === 'StringLiteral' && p.key.value === 'local'))
+ ) as ObjectProperty
+
+ if (local) {
+ runtimeOptions = `{ ${s.slice(
+ startOffset + local.start!,
+ startOffset + local.end!
+ )} }`
+ } else {
+ for (const p of options.properties) {
+ if (p.type === 'SpreadElement' || p.computed) {
+ runtimeOptions = optionsString!
+ break
+ }
+ }
+ }
+ } else {
+ runtimeOptions = optionsString!
+ }
+ }
+
+ s.overwrite(
+ startOffset + node.start!,
+ startOffset + node.end!,
+ `${helper('useModel')}(__props, ${JSON.stringify(modelName)}${
+ runtimeOptions ? `, ${runtimeOptions}` : ``
+ })`
+ )
+
+ return true
+ }
+
function getAstBody(): Statement[] {
return scriptAst
? [...scriptSetupAst.body, ...scriptAst.body]
)
}
- function genRuntimeProps(props: Record<string, PropTypeData>) {
- const keys = Object.keys(props)
- if (!keys.length) {
- return ``
- }
- const hasStaticDefaults = hasStaticWithDefaults()
- const scriptSetupSource = scriptSetup!.content
- let propsDecls = `{
+ function concatStrings(strs: Array<string | null | undefined | false>) {
+ return strs.filter((s): s is string => !!s).join(', ')
+ }
+
+ function genRuntimeProps() {
+ function genPropsFromTS() {
+ const keys = Object.keys(typeDeclaredProps)
+ if (!keys.length) return
+
+ const hasStaticDefaults = hasStaticWithDefaults()
+ const scriptSetupSource = scriptSetup!.content
+ let propsDecls = `{
${keys
.map(key => {
let defaultString: string | undefined
- const destructured = genDestructuredDefaultValue(key, props[key].type)
+ const destructured = genDestructuredDefaultValue(
+ key,
+ typeDeclaredProps[key].type
+ )
if (destructured) {
defaultString = `default: ${destructured.valueString}${
destructured.needSkipFactory ? `, skipFactory: true` : ``
}
}
- const { type, required, skipCheck } = props[key]
+ const { type, required, skipCheck } = typeDeclaredProps[key]
if (!isProd) {
- return `${key}: { type: ${toRuntimeTypeString(
- type
- )}, required: ${required}${skipCheck ? ', skipCheck: true' : ''}${
- defaultString ? `, ${defaultString}` : ``
- } }`
+ return `${key}: { ${concatStrings([
+ `type: ${toRuntimeTypeString(type)}`,
+ `required: ${required}`,
+ skipCheck && 'skipCheck: true',
+ defaultString
+ ])} }`
} else if (
type.some(
el =>
// #4783 for boolean, should keep the type
// #7111 for function, if default value exists or it's not static, should keep it
// in production
- return `${key}: { type: ${toRuntimeTypeString(type)}${
- defaultString ? `, ${defaultString}` : ``
- } }`
+ return `${key}: { ${concatStrings([
+ `type: ${toRuntimeTypeString(type)}`,
+ defaultString
+ ])} }`
} else {
// production: checks are useless
- return `${key}: ${defaultString ? `{ ${defaultString} }` : 'null'}`
+ return `${key}: ${defaultString ? `{ ${defaultString} }` : `{}`}`
}
})
.join(',\n ')}\n }`
- if (propsRuntimeDefaults && !hasStaticDefaults) {
- propsDecls = `${helper('mergeDefaults')}(${propsDecls}, ${source.slice(
- propsRuntimeDefaults.start! + startOffset,
- propsRuntimeDefaults.end! + startOffset
- )})`
+ if (propsRuntimeDefaults && !hasStaticDefaults) {
+ propsDecls = `${helper('mergeDefaults')}(${propsDecls}, ${source.slice(
+ propsRuntimeDefaults.start! + startOffset,
+ propsRuntimeDefaults.end! + startOffset
+ )})`
+ }
+
+ return propsDecls
+ }
+
+ function genModels() {
+ if (!hasDefineModelCall) return
+
+ let modelPropsDecl = ''
+ for (const [name, { type, options }] of Object.entries(modelDecls)) {
+ let skipCheck = false
+
+ let runtimeTypes = type && inferRuntimeType(type, declaredTypes)
+ if (runtimeTypes) {
+ const hasUnknownType = runtimeTypes.includes(UNKNOWN_TYPE)
+
+ runtimeTypes = runtimeTypes.filter(el => {
+ if (el === UNKNOWN_TYPE) return false
+ return isProd
+ ? el === 'Boolean' || (el === 'Function' && options)
+ : true
+ })
+ skipCheck = !isProd && hasUnknownType && runtimeTypes.length > 0
+ }
+
+ let runtimeType =
+ (runtimeTypes &&
+ runtimeTypes.length > 0 &&
+ toRuntimeTypeString(runtimeTypes)) ||
+ undefined
+
+ const codegenOptions = concatStrings([
+ runtimeType && `type: ${runtimeType}`,
+ skipCheck && 'skipCheck: true'
+ ])
+
+ let decl: string
+ if (runtimeType && options) {
+ decl = isTS
+ ? `{ ${codegenOptions}, ...${options} }`
+ : `Object.assign({ ${codegenOptions} }, ${options})`
+ } else {
+ decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
+ }
+ modelPropsDecl += `\n ${JSON.stringify(name)}: ${decl},`
+ }
+ return `{${modelPropsDecl}\n }`
+ }
+
+ let propsDecls: undefined | string
+ if (propsRuntimeDecl) {
+ propsDecls = scriptSetup!.content
+ .slice(propsRuntimeDecl.start!, propsRuntimeDecl.end!)
+ .trim()
+ if (propsDestructureDecl) {
+ const defaults: string[] = []
+ for (const key in propsDestructuredBindings) {
+ const d = genDestructuredDefaultValue(key)
+ if (d)
+ defaults.push(
+ `${key}: ${d.valueString}${
+ d.needSkipFactory ? `, __skip_${key}: true` : ``
+ }`
+ )
+ }
+ if (defaults.length) {
+ propsDecls = `${helper(
+ `mergeDefaults`
+ )}(${propsDecls}, {\n ${defaults.join(',\n ')}\n})`
+ }
+ }
+ } else if (propsTypeDecl) {
+ propsDecls = genPropsFromTS()
}
- return `\n props: ${propsDecls},`
+ const modelsDecls = genModels()
+
+ if (propsDecls && modelsDecls) {
+ return `${helper('mergeModels')}(${propsDecls}, ${modelsDecls})`
+ } else {
+ return modelsDecls || propsDecls
+ }
}
function genDestructuredDefaultValue(
}
}
+ function genRuntimeEmits() {
+ function genEmitsFromTS() {
+ return typeDeclaredEmits.size
+ ? `[${Array.from(typeDeclaredEmits)
+ .map(k => JSON.stringify(k))
+ .join(', ')}]`
+ : ``
+ }
+
+ let emitsDecl = ''
+ if (emitsRuntimeDecl) {
+ emitsDecl = scriptSetup!.content
+ .slice(emitsRuntimeDecl.start!, emitsRuntimeDecl.end!)
+ .trim()
+ } else if (emitsTypeDecl) {
+ emitsDecl = genEmitsFromTS()
+ }
+ if (hasDefineModelCall) {
+ let modelEmitsDecl = `[${Object.keys(modelDecls)
+ .map(n => JSON.stringify(`update:${n}`))
+ .join(', ')}]`
+ emitsDecl = emitsDecl
+ ? `${helper('mergeModels')}(${emitsDecl}, ${modelEmitsDecl})`
+ : modelEmitsDecl
+ }
+ return emitsDecl
+ }
+
// 0. parse both <script> and <script setup> blocks
const scriptAst =
script &&
callee.end! + startOffset,
'__expose'
)
+ } else {
+ processDefineModel(expr)
}
}
processWithDefaults(init, decl.id)
const isDefineEmits =
!isDefineProps && processDefineEmits(init, decl.id)
- !isDefineEmits && processDefineSlots(init, decl.id)
+ !isDefineEmits &&
+ (processDefineSlots(init, decl.id) ||
+ processDefineModel(init, decl.id))
if (isDefineProps || isDefineEmits) {
if (left === 1) {
for (const key in typeDeclaredProps) {
bindingMetadata[key] = BindingTypes.PROPS
}
+ for (const key in modelDecls) {
+ bindingMetadata[key] = BindingTypes.PROPS
+ }
// props aliases
if (propsDestructureDecl) {
if (propsDestructureRestId) {
if (hasInlinedSsrRenderFn) {
runtimeOptions += `\n __ssrInlineRender: true,`
}
- if (propsRuntimeDecl) {
- let declCode = scriptSetup.content
- .slice(propsRuntimeDecl.start!, propsRuntimeDecl.end!)
- .trim()
- if (propsDestructureDecl) {
- const defaults: string[] = []
- for (const key in propsDestructuredBindings) {
- const d = genDestructuredDefaultValue(key)
- if (d)
- defaults.push(
- `${key}: ${d.valueString}${
- d.needSkipFactory ? `, __skip_${key}: true` : ``
- }`
- )
- }
- if (defaults.length) {
- declCode = `${helper(
- `mergeDefaults`
- )}(${declCode}, {\n ${defaults.join(',\n ')}\n})`
- }
- }
- runtimeOptions += `\n props: ${declCode},`
- } else if (propsTypeDecl) {
- runtimeOptions += genRuntimeProps(typeDeclaredProps)
- }
- if (emitsRuntimeDecl) {
- runtimeOptions += `\n emits: ${scriptSetup.content
- .slice(emitsRuntimeDecl.start!, emitsRuntimeDecl.end!)
- .trim()},`
- } else if (emitsTypeDecl) {
- runtimeOptions += genRuntimeEmits(typeDeclaredEmits)
- }
+
+ const propsDecl = genRuntimeProps()
+ if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`
+
+ const emitsDecl = genRuntimeEmits()
+ if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`
let definedOptions = ''
if (optionsRuntimeDecl) {
? BindingTypes.SETUP_REACTIVE_CONST
: BindingTypes.SETUP_CONST
} else if (isConst) {
- if (isCallOf(init, userImportAliases['ref'])) {
+ if (
+ isCallOf(init, userImportAliases['ref']) ||
+ isCallOf(init, DEFINE_MODEL)
+ ) {
bindingType = BindingTypes.SETUP_REF
} else {
bindingType = BindingTypes.SETUP_MAYBE_REF
}
}
-function genRuntimeEmits(emits: Set<string>) {
- return emits.size
- ? `\n emits: [${Array.from(emits)
- .map(p => JSON.stringify(p))
- .join(', ')}],`
- : ``
-}
-
function canNeverBeRef(node: Node, userReactiveImport?: string): boolean {
if (isCallOf(node, userReactiveImport)) {
return true
withDefaults,
Slots,
defineSlots,
- VNode
+ VNode,
+ Ref,
+ defineModel
} from 'vue'
import { describe, expectType } from './utils'
+import { defineComponent } from 'vue'
+import { useModel } from 'vue'
describe('defineProps w/ type declaration', () => {
// type declaration
expectType<Slots>(slotsUntype)
})
+describe('defineModel', () => {
+ // overload 1
+ const modelValueRequired = defineModel<boolean>({ required: true })
+ expectType<Ref<boolean>>(modelValueRequired)
+
+ // overload 2
+ const modelValue = defineModel<string>()
+ expectType<Ref<string | undefined>>(modelValue)
+ modelValue.value = 'new value'
+
+ const modelValueDefault = defineModel<boolean>({ default: true })
+ expectType<Ref<boolean>>(modelValueDefault)
+
+ // overload 3
+ const countRequired = defineModel<number>('count', { required: false })
+ expectType<Ref<number | undefined>>(countRequired)
+
+ // overload 4
+ const count = defineModel<number>('count')
+ expectType<Ref<number | undefined>>(count)
+
+ const countDefault = defineModel<number>('count', { default: 1 })
+ expectType<Ref<number>>(countDefault)
+
+ // infer type from default
+ const inferred = defineModel({ default: 123 })
+ expectType<Ref<number | undefined>>(inferred)
+ const inferredRequired = defineModel({ default: 123, required: true })
+ expectType<Ref<number>>(inferredRequired)
+
+ // @ts-expect-error type / default mismatch
+ defineModel<string>({ default: 123 })
+ // @ts-expect-error unknown props option
+ defineModel({ foo: 123 })
+
+ // accept defineModel-only options
+ defineModel({ local: true })
+ defineModel('foo', { local: true })
+})
+
+describe('useModel', () => {
+ defineComponent({
+ props: ['foo'],
+ setup(props) {
+ const r = useModel(props, 'foo')
+ expectType<Ref<any>>(r)
+
+ // @ts-expect-error
+ useModel(props, 'bar')
+ }
+ })
+
+ defineComponent({
+ props: {
+ foo: String,
+ bar: { type: Number, required: true },
+ baz: { type: Boolean }
+ },
+ setup(props) {
+ expectType<Ref<string | undefined>>(useModel(props, 'foo'))
+ expectType<Ref<number>>(useModel(props, 'bar'))
+ expectType<Ref<boolean>>(useModel(props, 'baz'))
+ }
+ })
+})
+
describe('useAttrs', () => {
const attrs = useAttrs()
expectType<Record<string, unknown>>(attrs)
Suspense,
computed,
ComputedRef,
- shallowReactive
+ shallowReactive,
+ nextTick,
+ ref
} from '@vue/runtime-test'
import {
defineEmits,
useSlots,
mergeDefaults,
withAsyncContext,
- createPropsRestProxy
+ createPropsRestProxy,
+ mergeModels,
+ useModel
} from '../src/apiSetupHelpers'
describe('SFC <script setup> helpers', () => {
})
})
+ describe('mergeModels', () => {
+ test('array syntax', () => {
+ expect(mergeModels(['foo', 'bar'], ['baz'])).toMatchObject([
+ 'foo',
+ 'bar',
+ 'baz'
+ ])
+ })
+
+ test('object syntax', () => {
+ expect(
+ mergeModels({ foo: null, bar: { required: true } }, ['baz'])
+ ).toMatchObject({
+ foo: null,
+ bar: { required: true },
+ baz: {}
+ })
+
+ expect(
+ mergeModels(['baz'], { foo: null, bar: { required: true } })
+ ).toMatchObject({
+ foo: null,
+ bar: { required: true },
+ baz: {}
+ })
+ })
+
+ test('overwrite', () => {
+ expect(
+ mergeModels(
+ { foo: null, bar: { required: true } },
+ { bar: {}, baz: {} }
+ )
+ ).toMatchObject({
+ foo: null,
+ bar: {},
+ baz: {}
+ })
+ })
+ })
+
+ describe('useModel', () => {
+ test('basic', async () => {
+ let foo: any
+ const update = () => {
+ foo.value = 'bar'
+ }
+
+ const Comp = defineComponent({
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ setup(props) {
+ foo = useModel(props, 'modelValue')
+ },
+ render() {}
+ })
+
+ const msg = ref('')
+ const setValue = vi.fn(v => (msg.value = v))
+ const root = nodeOps.createElement('div')
+ createApp(() =>
+ h(Comp, {
+ modelValue: msg.value,
+ 'onUpdate:modelValue': setValue
+ })
+ ).mount(root)
+
+ expect(foo.value).toBe('')
+ expect(msg.value).toBe('')
+ expect(setValue).not.toBeCalled()
+
+ // update from child
+ update()
+
+ await nextTick()
+ expect(msg.value).toBe('bar')
+ expect(foo.value).toBe('bar')
+ expect(setValue).toBeCalledTimes(1)
+
+ // update from parent
+ msg.value = 'qux'
+
+ await nextTick()
+ expect(msg.value).toBe('qux')
+ expect(foo.value).toBe('qux')
+ expect(setValue).toBeCalledTimes(1)
+ })
+
+ test('local', async () => {
+ let foo: any
+ const update = () => {
+ foo.value = 'bar'
+ }
+
+ const Comp = defineComponent({
+ props: ['foo'],
+ emits: ['update:foo'],
+ setup(props) {
+ foo = useModel(props, 'foo', { local: true })
+ },
+ render() {}
+ })
+
+ const root = nodeOps.createElement('div')
+ const updateFoo = vi.fn()
+ render(h(Comp, { 'onUpdate:foo': updateFoo }), root)
+
+ expect(foo.value).toBeUndefined()
+ update()
+
+ expect(foo.value).toBe('bar')
+
+ await nextTick()
+ expect(updateFoo).toBeCalledTimes(1)
+ })
+
+ test('default value', async () => {
+ let count: any
+ const inc = () => {
+ count.value++
+ }
+ const Comp = defineComponent({
+ props: { count: { default: 0 } },
+ emits: ['update:count'],
+ setup(props) {
+ count = useModel(props, 'count', { local: true })
+ },
+ render() {}
+ })
+
+ const root = nodeOps.createElement('div')
+ const updateCount = vi.fn()
+ render(h(Comp, { 'onUpdate:count': updateCount }), root)
+
+ expect(count.value).toBe(0)
+
+ inc()
+ expect(count.value).toBe(1)
+ await nextTick()
+ expect(updateCount).toBeCalledTimes(1)
+ })
+ })
+
test('createPropsRestProxy', () => {
const original = shallowReactive({
foo: 1,
isPromise,
isFunction,
Prettify,
- UnionToIntersection
+ UnionToIntersection,
+ extend
} from '@vue/shared'
import {
getCurrentInstance,
createSetupContext,
unsetCurrentInstance
} from './component'
-import { EmitFn, EmitsOptions } from './componentEmits'
+import { EmitFn, EmitsOptions, ObjectEmitsOptions } from './componentEmits'
import {
ComponentOptionsMixin,
ComponentOptionsWithoutProps,
import {
ComponentPropsOptions,
ComponentObjectPropsOptions,
- ExtractPropTypes
+ ExtractPropTypes,
+ NormalizedProps,
+ PropOptions
} from './componentProps'
import { warn } from './warning'
import { SlotsType, TypedSlots } from './componentSlots'
+import { Ref, ref } from '@vue/reactivity'
+import { watch } from './apiWatch'
// dev only
const warnRuntimeUsage = (method: string) =>
export function defineSlots<
S extends Record<string, any> = Record<string, any>
->(): // @ts-expect-error
-TypedSlots<SlotsType<S>> {
+>(): TypedSlots<SlotsType<S>> {
if (__DEV__) {
warnRuntimeUsage(`defineSlots`)
}
+ return null as any
+}
+
+/**
+ * (**Experimental**) Vue `<script setup>` compiler macro for declaring a
+ * two-way binding prop that can be consumed via `v-model` from the parent
+ * component. This will declare a prop with the same name and a corresponding
+ * `update:propName` event.
+ *
+ * If the first argument is a string, it will be used as the prop name;
+ * Otherwise the prop name will default to "modelValue". In both cases, you
+ * can also pass an additional object which will be used as the prop's options.
+ *
+ * The options object can also specify an additional option, `local`. When set
+ * to `true`, the ref can be locally mutated even if the parent did not pass
+ * the matching `v-model`.
+ *
+ * @example
+ * ```ts
+ * // default model (consumed via `v-model`)
+ * const modelValue = defineModel<string>()
+ * modelValue.value = "hello"
+ *
+ * // default model with options
+ * const modelValue = defineModel<stirng>({ required: true })
+ *
+ * // with specified name (consumed via `v-model:count`)
+ * const count = defineModel<number>('count')
+ * count.value++
+ *
+ * // with specified name and default value
+ * const count = defineModel<number>('count', { default: 0 })
+ *
+ * // local mutable model, can be mutated locally
+ * // even if the parent did not pass the matching `v-model`.
+ * const count = defineModel<number>('count', { local: true, default: 0 })
+ * ```
+ */
+export function defineModel<T>(
+ options: { required: true } & PropOptions<T> & DefineModelOptions
+): Ref<T>
+export function defineModel<T>(
+ options: { default: any } & PropOptions<T> & DefineModelOptions
+): Ref<T>
+export function defineModel<T>(
+ options?: PropOptions<T> & DefineModelOptions
+): Ref<T | undefined>
+export function defineModel<T>(
+ name: string,
+ options: { required: true } & PropOptions<T> & DefineModelOptions
+): Ref<T>
+export function defineModel<T>(
+ name: string,
+ options: { default: any } & PropOptions<T> & DefineModelOptions
+): Ref<T>
+export function defineModel<T>(
+ name: string,
+ options?: PropOptions<T> & DefineModelOptions
+): Ref<T | undefined>
+export function defineModel(): any {
+ if (__DEV__) {
+ warnRuntimeUsage('defineModel')
+ }
+}
+
+interface DefineModelOptions {
+ local?: boolean
}
type NotUndefined<T> = T extends undefined ? never : T
return getContext().attrs
}
+export function useModel<T extends Record<string, any>, K extends keyof T>(
+ props: T,
+ name: K,
+ options?: { local?: boolean }
+): Ref<T[K]>
+export function useModel(
+ props: Record<string, any>,
+ name: string,
+ options?: { local?: boolean }
+): Ref {
+ const i = getCurrentInstance()!
+ if (__DEV__ && !i) {
+ warn(`useModel() called without active instance.`)
+ return ref() as any
+ }
+
+ if (__DEV__ && !(i.propsOptions[0] as NormalizedProps)[name]) {
+ warn(`useModel() called with prop "${name}" which is not declared.`)
+ return ref() as any
+ }
+
+ if (options && options.local) {
+ const proxy = ref<any>(props[name])
+
+ watch(
+ () => props[name],
+ v => (proxy.value = v)
+ )
+
+ watch(proxy, value => {
+ if (value !== props[name]) {
+ i.emit(`update:${name}`, value)
+ }
+ })
+
+ return proxy
+ } else {
+ return {
+ __v_isRef: true,
+ get value() {
+ return props[name]
+ },
+ set value(value) {
+ i.emit(`update:${name}`, value)
+ }
+ } as any
+ }
+}
+
function getContext(): SetupContext {
const i = getCurrentInstance()!
if (__DEV__ && !i) {
return i.setupContext || (i.setupContext = createSetupContext(i))
}
+function normalizePropsOrEmits(props: ComponentPropsOptions | EmitsOptions) {
+ return isArray(props)
+ ? props.reduce(
+ (normalized, p) => ((normalized[p] = {}), normalized),
+ {} as ComponentObjectPropsOptions | ObjectEmitsOptions
+ )
+ : props
+}
+
/**
* Runtime helper for merging default declarations. Imported by compiled code
* only.
raw: ComponentPropsOptions,
defaults: Record<string, any>
): ComponentObjectPropsOptions {
- const props = isArray(raw)
- ? raw.reduce(
- (normalized, p) => ((normalized[p] = {}), normalized),
- {} as ComponentObjectPropsOptions
- )
- : raw
+ const props = normalizePropsOrEmits(raw)
for (const key in defaults) {
if (key.startsWith('__skip')) continue
let opt = props[key]
return props
}
+/**
+ * Runtime helper for merging model declarations.
+ * Imported by compiled code only.
+ * @internal
+ */
+export function mergeModels(
+ a: ComponentPropsOptions | EmitsOptions,
+ b: ComponentPropsOptions | EmitsOptions
+) {
+ if (!a || !b) return a || b
+ if (isArray(a) && isArray(b)) return a.concat(b)
+ return extend({}, normalizePropsOrEmits(a), normalizePropsOrEmits(b))
+}
+
/**
* Used to create a proxy for the rest element when destructuring props with
* defineProps().
return
}
// missing but optional
- if (value == null && !prop.required) {
+ if (value == null && !required) {
return
}
// type check
defineExpose,
defineOptions,
defineSlots,
+ defineModel,
withDefaults,
+ useModel,
// internal
mergeDefaults,
+ mergeModels,
createPropsRestProxy,
withAsyncContext
} from './apiSetupHelpers'
type _defineExpose = typeof defineExpose
type _defineOptions = typeof defineOptions
type _defineSlots = typeof defineSlots
+type _defineModel = typeof defineModel
type _withDefaults = typeof withDefaults
declare global {
const defineExpose: _defineExpose
const defineOptions: _defineOptions
const defineSlots: _defineSlots
+ const defineModel: _defineModel
const withDefaults: _withDefaults
}
script: {
inlineTemplate: !useDevMode.value,
isProd: !useDevMode.value,
- reactivityTransform: true
+ reactivityTransform: true,
+ defineModel: true
},
style: {
isProd: !useDevMode.value