]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(defineModel): support modifiers and transformers
authorEvan You <yyx990803@gmail.com>
Tue, 26 Dec 2023 14:13:04 +0000 (22:13 +0800)
committerEvan You <yyx990803@gmail.com>
Tue, 26 Dec 2023 14:13:04 +0000 (22:13 +0800)
packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineModel.spec.ts.snap
packages/compiler-sfc/__tests__/compileScript/defineModel.spec.ts
packages/compiler-sfc/src/script/defineModel.ts
packages/dts-test/setupHelpers.test-d.ts
packages/runtime-core/__tests__/apiSetupHelpers.spec.ts
packages/runtime-core/src/apiSetupHelpers.ts
packages/runtime-core/src/index.ts

index 323fb8688d5841ba65dd8a758e76e38bfc2a0e9f..1163a4c02bae3c51d2f83dc1fbcfdd89998822e9 100644 (file)
@@ -6,8 +6,11 @@ exports[`defineModel() > basic usage 1`] = `
 export default {
   props: {
     "modelValue": { required: true },
+    "modelModifiers": {},
     "count": {},
+    "countModifiers": {},
     "toString": { type: Function },
+    "toStringModifiers": {},
   },
   emits: ["update:modelValue", "update:count", "update:toString"],
   setup(__props, { expose: __expose }) {
@@ -23,12 +26,58 @@ return { modelValue, c, toString }
 }"
 `;
 
+exports[`defineModel() > get / set transformers 1`] = `
+"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
+
+export default /*#__PURE__*/_defineComponent({
+  props: {
+    "modelValue": {
+        required: true
+      },
+    "modelModifiers": {},
+  },
+  emits: ["update:modelValue"],
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      const modelValue = _useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 },  })
+      
+return { modelValue }
+}
+
+})"
+`;
+
+exports[`defineModel() > get / set transformers 2`] = `
+"import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
+
+export default /*#__PURE__*/_defineComponent({
+  props: {
+    "modelValue": {
+        default: 0,
+        required: true,
+        },
+    "modelModifiers": {},
+  },
+  emits: ["update:modelValue"],
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      const modelValue = _useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 },  })
+      
+return { modelValue }
+}
+
+})"
+`;
+
 exports[`defineModel() > w/ array props 1`] = `
 "import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
 
 export default {
   props: /*#__PURE__*/_mergeModels(['foo', 'bar'], {
     "count": {},
+    "countModifiers": {},
   }),
   emits: ["update:count"],
   setup(__props, { expose: __expose }) {
@@ -49,6 +98,7 @@ exports[`defineModel() > w/ defineProps and defineEmits 1`] = `
 export default {
   props: /*#__PURE__*/_mergeModels({ foo: String }, {
     "modelValue": { default: 0 },
+    "modelModifiers": {},
   }),
   emits: /*#__PURE__*/_mergeModels(['change'], ["update:modelValue"]),
   setup(__props, { expose: __expose }) {
@@ -64,47 +114,19 @@ return { count }
 }"
 `;
 
-exports[`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[`defineModel() > w/ types, basic usage 1`] = `
 "import { useModel as _useModel, defineComponent as _defineComponent } from 'vue'
 
 export default /*#__PURE__*/_defineComponent({
   props: {
     "modelValue": { type: [Boolean, String] },
+    "modelModifiers": {},
     "count": { type: Number },
+    "countModifiers": {},
     "disabled": { type: Number, ...{ required: false } },
+    "disabledModifiers": {},
     "any": { type: Boolean, skipCheck: true },
+    "anyModifiers": {},
   },
   emits: ["update:modelValue", "update:count", "update:disabled", "update:any"],
   setup(__props, { expose: __expose }) {
@@ -127,10 +149,15 @@ exports[`defineModel() > w/ types, production mode 1`] = `
 export default /*#__PURE__*/_defineComponent({
   props: {
     "modelValue": { type: Boolean },
+    "modelModifiers": {},
     "fn": {},
+    "fnModifiers": {},
     "fnWithDefault": { type: Function, ...{ default: () => null } },
+    "fnWithDefaultModifiers": {},
     "str": {},
+    "strModifiers": {},
     "optional": { required: false },
+    "optionalModifiers": {},
   },
   emits: ["update:modelValue", "update:fn", "update:fnWithDefault", "update:str", "update:optional"],
   setup(__props, { expose: __expose }) {
index eea19fc9cbd47d7d9f6296620a8d17113938342d..6feff4500f14ee485c22fae5358c504c8affbe0e 100644 (file)
@@ -69,6 +69,7 @@ describe('defineModel()', () => {
     assertCode(content)
     expect(content).toMatch(`props: /*#__PURE__*/_mergeModels(['foo', 'bar'], {
     "count": {},
+    "countModifiers": {},
   })`)
     expect(content).toMatch(`const count = _useModel(__props, "count")`)
     expect(content).not.toMatch('defineModel')
@@ -79,29 +80,6 @@ describe('defineModel()', () => {
     })
   })
 
-  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>`,
-    )
-    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('w/ types, basic usage', () => {
     const { content, bindings } = compile(
       `
@@ -115,6 +93,7 @@ describe('defineModel()', () => {
     )
     assertCode(content)
     expect(content).toMatch('"modelValue": { type: [Boolean, String] }')
+    expect(content).toMatch('"modelModifiers": {}')
     expect(content).toMatch('"count": { type: Number }')
     expect(content).toMatch(
       '"disabled": { type: Number, ...{ required: false } }',
@@ -176,4 +155,43 @@ describe('defineModel()', () => {
       optional: BindingTypes.SETUP_REF,
     })
   })
+
+  test('get / set transformers', () => {
+    const { content } = compile(
+      `
+      <script setup lang="ts">
+      const modelValue = defineModel({
+        get(v) { return v - 1 },
+        set: (v) => { return v + 1 },
+        required: true
+      })
+      </script>
+      `,
+    )
+    assertCode(content)
+    expect(content).toMatch(/"modelValue": {\s+required: true,?\s+}/m)
+    expect(content).toMatch(
+      `_useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 },  })`,
+    )
+
+    const { content: content2 } = compile(
+      `
+      <script setup lang="ts">
+      const modelValue = defineModel({
+        default: 0,
+        get(v) { return v - 1 },
+        required: true,
+        set: (v) => { return v + 1 },
+      })
+      </script>
+      `,
+    )
+    assertCode(content2)
+    expect(content2).toMatch(
+      /"modelValue": {\s+default: 0,\s+required: true,?\s+}/m,
+    )
+    expect(content2).toMatch(
+      `_useModel(__props, "modelValue", { get(v) { return v - 1 }, set: (v) => { return v + 1 },  })`,
+    )
+  })
 })
index 183529d358544909359ac762969818d7666bcfb2..9411fa460b93275d35d7cd14d4f6aa20a2493204 100644 (file)
@@ -1,4 +1,4 @@
-import type { LVal, Node, ObjectProperty, TSType } from '@babel/types'
+import type { LVal, Node, TSType } from '@babel/types'
 import type { ScriptCompileContext } from './context'
 import { inferRuntimeType } from './resolveType'
 import {
@@ -45,42 +45,52 @@ export function processDefineModel(
     ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node)
   }
 
-  const optionsString = options && ctx.getString(options)
-
-  ctx.modelDecls[modelName] = {
-    type,
-    options: optionsString,
-    identifier:
-      declId && declId.type === 'Identifier' ? declId.name : undefined,
-  }
-  // register binding type
-  ctx.bindingMetadata[modelName] = BindingTypes.PROPS
-
+  let optionsString = options && ctx.getString(options)
   let runtimeOptions = ''
+  let transformOptions = ''
+
   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 = `{ ${ctx.getString(local)} }`
-      } else {
-        for (const p of options.properties) {
-          if (p.type === 'SpreadElement' || p.computed) {
-            runtimeOptions = optionsString!
-            break
-          }
+      for (let i = options.properties.length - 1; i >= 0; i--) {
+        const p = options.properties[i]
+        if (p.type === 'SpreadElement' || p.computed) {
+          runtimeOptions = optionsString!
+          break
+        }
+        if (
+          (p.type === 'ObjectProperty' || p.type === 'ObjectMethod') &&
+          ((p.key.type === 'Identifier' &&
+            (p.key.name === 'get' || p.key.name === 'set')) ||
+            (p.key.type === 'StringLiteral' &&
+              (p.key.value === 'get' || p.key.value === 'set')))
+        ) {
+          transformOptions = ctx.getString(p) + ', ' + transformOptions
+
+          // remove transform option from prop options to avoid duplicates
+          const offset = p.start! - options.start!
+          const next = options.properties[i + 1]
+          const end = (next ? next.start! : options.end! - 1) - options.start!
+          optionsString =
+            optionsString.slice(0, offset) + optionsString.slice(end)
         }
       }
+      if (!runtimeOptions && transformOptions) {
+        runtimeOptions = `{ ${transformOptions} }`
+      }
     } else {
       runtimeOptions = optionsString!
     }
   }
 
+  ctx.modelDecls[modelName] = {
+    type,
+    options: optionsString,
+    identifier:
+      declId && declId.type === 'Identifier' ? declId.name : undefined,
+  }
+  // register binding type
+  ctx.bindingMetadata[modelName] = BindingTypes.PROPS
+
   ctx.s.overwrite(
     ctx.startOffset! + node.start!,
     ctx.startOffset! + node.end!,
@@ -133,6 +143,12 @@ export function genModelProps(ctx: ScriptCompileContext) {
       decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
     }
     modelPropsDecl += `\n    ${JSON.stringify(name)}: ${decl},`
+
+    // also generate modifiers prop
+    const modifierPropName = JSON.stringify(
+      name === 'modelValue' ? `modelModifiers` : `${name}Modifiers`,
+    )
+    modelPropsDecl += `\n    ${modifierPropName}: {},`
   }
   return `{${modelPropsDecl}\n  }`
 }
index 7f67c62943d106b2ad44d381a213e3132665dfcb..9588cb9b209be4bc68f908a9493196bc1194342a 100644 (file)
@@ -314,6 +314,37 @@ describe('defineModel', () => {
   const inferredRequired = defineModel({ default: 123, required: true })
   expectType<Ref<number>>(inferredRequired)
 
+  // modifiers
+  const [_, modifiers] = defineModel<string>()
+  expectType<true | undefined>(modifiers.foo)
+
+  // limit supported modifiers
+  const [__, typedModifiers] = defineModel<string, 'trim' | 'capitalize'>()
+  expectType<true | undefined>(typedModifiers.trim)
+  expectType<true | undefined>(typedModifiers.capitalize)
+  // @ts-expect-error
+  typedModifiers.foo
+
+  // transformers with type
+  defineModel<string>({
+    get(val) {
+      return val.toLowerCase()
+    },
+    set(val) {
+      return val.toUpperCase()
+    },
+  })
+  // transformers with runtime type
+  defineModel({
+    type: String,
+    get(val) {
+      return val.toLowerCase()
+    },
+    set(val) {
+      return val.toUpperCase()
+    },
+  })
+
   // @ts-expect-error type / default mismatch
   defineModel<string>({ default: 123 })
   // @ts-expect-error unknown props option
index ef4fcd09e271c7fe8cf397204e96d07c76806f37..0528a14577a41ed1cfeff7a62e4ccd9408ce665d 100644 (file)
@@ -513,6 +513,73 @@ describe('SFC <script setup> helpers', () => {
       expect(slotRender).toBeCalledTimes(2)
       expect(serializeInner(root)).toBe('<div>bar</div>')
     })
+
+    test('with modifiers & transformers', async () => {
+      let childMsg: Ref<string>
+      let childModifiers: Record<string, true | undefined>
+
+      const compRender = vi.fn()
+      const Comp = defineComponent({
+        props: ['msg', 'msgModifiers'],
+        emits: ['update:msg'],
+        setup(props) {
+          ;[childMsg, childModifiers] = useModel(props, 'msg', {
+            get(val) {
+              return val.toLowerCase()
+            },
+            set(val) {
+              if (childModifiers.upper) {
+                return val.toUpperCase()
+              }
+            },
+          })
+          return () => {
+            compRender()
+            return childMsg.value
+          }
+        },
+      })
+
+      const msg = ref('HI')
+      const Parent = defineComponent({
+        setup() {
+          return () =>
+            h(Comp, {
+              msg: msg.value,
+              msgModifiers: { upper: true },
+              'onUpdate:msg': val => {
+                msg.value = val
+              },
+            })
+        },
+      })
+
+      const root = nodeOps.createElement('div')
+      render(h(Parent), root)
+
+      // should be lowered
+      expect(serializeInner(root)).toBe('hi')
+
+      // child update
+      childMsg!.value = 'Hmm'
+
+      await nextTick()
+      expect(childMsg!.value).toBe('hmm')
+      expect(serializeInner(root)).toBe('hmm')
+      // parent should get uppercase value
+      expect(msg.value).toBe('HMM')
+
+      // parent update
+      msg.value = 'Ughh'
+      await nextTick()
+      expect(serializeInner(root)).toBe('ughh')
+      expect(msg.value).toBe('Ughh')
+
+      // child update again
+      childMsg!.value = 'ughh'
+      await nextTick()
+      expect(msg.value).toBe('UGHH')
+    })
   })
 
   test('createPropsRestProxy', () => {
index 9440e8a62df0303a16df20bb2fc0104c760b49a4..244e30ac9a8fec2168be5087931db4b77e149788 100644 (file)
@@ -1,4 +1,5 @@
 import {
+  EMPTY_OBJ,
   type LooseRequired,
   type Prettify,
   type UnionToIntersection,
@@ -218,6 +219,9 @@ export function defineSlots<
   return null as any
 }
 
+export type ModelRef<T, M extends string | number | symbol = string> = Ref<T> &
+  [ModelRef<T, M>, Record<M, true | undefined>]
+
 /**
  * Vue `<script setup>` compiler macro for declaring a
  * two-way binding prop that can be consumed via `v-model` from the parent
@@ -251,25 +255,27 @@ export function defineSlots<
  * const count = defineModel<number>('count', { default: 0 })
  * ```
  */
-export function defineModel<T>(
-  options: { required: true } & PropOptions<T>,
-): Ref<T>
-export function defineModel<T>(
-  options: { default: any } & PropOptions<T>,
-): Ref<T>
-export function defineModel<T>(options?: PropOptions<T>): Ref<T | undefined>
-export function defineModel<T>(
+export function defineModel<T, M extends string | number | symbol = string>(
+  options: { required: true } & PropOptions<T> & UseModelOptions<T>,
+): ModelRef<T, M>
+export function defineModel<T, M extends string | number | symbol = string>(
+  options: { default: any } & PropOptions<T> & UseModelOptions<T>,
+): ModelRef<T, M>
+export function defineModel<T, M extends string | number | symbol = string>(
+  options?: PropOptions<T> & UseModelOptions<T>,
+): ModelRef<T | undefined, M>
+export function defineModel<T, M extends string | number | symbol = string>(
   name: string,
-  options: { required: true } & PropOptions<T>,
-): Ref<T>
-export function defineModel<T>(
+  options: { required: true } & PropOptions<T> & UseModelOptions<T>,
+): ModelRef<T, M>
+export function defineModel<T, M extends string | number | symbol = string>(
   name: string,
-  options: { default: any } & PropOptions<T>,
-): Ref<T>
-export function defineModel<T>(
+  options: { default: any } & PropOptions<T> & UseModelOptions<T>,
+): ModelRef<T, M>
+export function defineModel<T, M extends string | number | symbol = string>(
   name: string,
-  options?: PropOptions<T>,
-): Ref<T | undefined>
+  options?: PropOptions<T> & UseModelOptions<T>,
+): ModelRef<T | undefined, M>
 export function defineModel(): any {
   if (__DEV__) {
     warnRuntimeUsage('defineModel')
@@ -348,11 +354,21 @@ export function useAttrs(): SetupContext['attrs'] {
   return getContext().attrs
 }
 
-export function useModel<T extends Record<string, any>, K extends keyof T>(
-  props: T,
-  name: K,
-): Ref<T[K]>
-export function useModel(props: Record<string, any>, name: string): Ref {
+type UseModelOptions<T = any> = {
+  get?: (v: T) => any
+  set?: (v: T) => any
+}
+
+export function useModel<
+  M extends string | number | symbol,
+  T extends Record<string, any>,
+  K extends keyof T,
+>(props: T, name: K, options?: UseModelOptions<T[K]>): ModelRef<T[K], M>
+export function useModel(
+  props: Record<string, any>,
+  name: string,
+  options: UseModelOptions = EMPTY_OBJ,
+): Ref {
   const i = getCurrentInstance()!
   if (__DEV__ && !i) {
     warn(`useModel() called without active instance.`)
@@ -364,7 +380,7 @@ export function useModel(props: Record<string, any>, name: string): Ref {
     return ref() as any
   }
 
-  return customRef((track, trigger) => {
+  const res = customRef((track, trigger) => {
     let localValue: any
     watchSyncEffect(() => {
       const propValue = props[name]
@@ -376,7 +392,7 @@ export function useModel(props: Record<string, any>, name: string): Ref {
     return {
       get() {
         track()
-        return localValue
+        return options.get ? options.get(localValue) : localValue
       },
       set(value) {
         const rawProps = i.vnode!.props
@@ -384,10 +400,29 @@ export function useModel(props: Record<string, any>, name: string): Ref {
           localValue = value
           trigger()
         }
-        i.emit(`update:${name}`, value)
+        i.emit(`update:${name}`, options.set ? options.set(value) : value)
       },
     }
   })
+
+  const modifierKey =
+    name === 'modelValue' ? 'modelModifiers' : `${name}Modifiers`
+
+  // @ts-expect-error
+  res[Symbol.iterator] = () => {
+    let i = 0
+    return {
+      next() {
+        if (i < 2) {
+          return { value: i++ ? props[modifierKey] : res, done: false }
+        } else {
+          return { done: true }
+        }
+      },
+    }
+  }
+
+  return res
 }
 
 function getContext(): SetupContext {
index b69344d7ce7d27134e16c243fdf9ef4ed7fcecec..8e49771d3c1a1cc14d403b734d872d9bd14f129f 100644 (file)
@@ -60,7 +60,7 @@ export { provide, inject, hasInjectionContext } from './apiInject'
 export { nextTick } from './scheduler'
 export { defineComponent } from './apiDefineComponent'
 export { defineAsyncComponent } from './apiAsyncComponent'
-export { useAttrs, useSlots, type DefineProps } from './apiSetupHelpers'
+export { useAttrs, useSlots } from './apiSetupHelpers'
 
 // <script setup> API ----------------------------------------------------------
 
@@ -74,6 +74,8 @@ export {
   defineModel,
   withDefaults,
   useModel,
+  type DefineProps,
+  type ModelRef,
 } from './apiSetupHelpers'
 
 /**