]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(defineModel): support local mutation by default, remove local option
authorEvan You <yyx990803@gmail.com>
Tue, 12 Dec 2023 08:47:34 +0000 (16:47 +0800)
committerEvan You <yyx990803@gmail.com>
Tue, 12 Dec 2023 08:47:34 +0000 (16:47 +0800)
ref https://github.com/vuejs/rfcs/discussions/503#discussioncomment-7566278

packages/dts-test/setupHelpers.test-d.ts
packages/runtime-core/__tests__/apiSetupHelpers.spec.ts
packages/runtime-core/src/apiSetupHelpers.ts

index 53c4d85978812a826ade91d5634dc06c844506f3..0e06e849b22199a82d94430b1de59fe27e12c8b6 100644 (file)
@@ -318,10 +318,6 @@ describe('defineModel', () => {
   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', () => {
index e5bca1d92721704059919105cf260a6b6fac189e..aceab13650e9cb069644e461e09fbe867478b635 100644 (file)
@@ -14,7 +14,9 @@ import {
   ComputedRef,
   shallowReactive,
   nextTick,
-  ref
+  ref,
+  Ref,
+  watch
 } from '@vue/runtime-test'
 import {
   defineEmits,
@@ -184,13 +186,17 @@ describe('SFC <script setup> helpers', () => {
         foo.value = 'bar'
       }
 
+      const compRender = vi.fn()
       const Comp = defineComponent({
         props: ['modelValue'],
         emits: ['update:modelValue'],
         setup(props) {
           foo = useModel(props, 'modelValue')
-        },
-        render() {}
+          return () => {
+            compRender()
+            return foo.value
+          }
+        }
       })
 
       const msg = ref('')
@@ -206,6 +212,8 @@ describe('SFC <script setup> helpers', () => {
       expect(foo.value).toBe('')
       expect(msg.value).toBe('')
       expect(setValue).not.toBeCalled()
+      expect(compRender).toBeCalledTimes(1)
+      expect(serializeInner(root)).toBe('')
 
       // update from child
       update()
@@ -214,42 +222,55 @@ describe('SFC <script setup> helpers', () => {
       expect(msg.value).toBe('bar')
       expect(foo.value).toBe('bar')
       expect(setValue).toBeCalledTimes(1)
+      expect(compRender).toBeCalledTimes(2)
+      expect(serializeInner(root)).toBe('bar')
 
       // update from parent
       msg.value = 'qux'
+      expect(msg.value).toBe('qux')
 
       await nextTick()
       expect(msg.value).toBe('qux')
       expect(foo.value).toBe('qux')
       expect(setValue).toBeCalledTimes(1)
+      expect(compRender).toBeCalledTimes(3)
+      expect(serializeInner(root)).toBe('qux')
     })
 
-    test('local', async () => {
+    test('without parent value (local mutation)', async () => {
       let foo: any
       const update = () => {
         foo.value = 'bar'
       }
 
+      const compRender = vi.fn()
       const Comp = defineComponent({
         props: ['foo'],
         emits: ['update:foo'],
         setup(props) {
-          foo = useModel(props, 'foo', { local: true })
-        },
-        render() {}
+          foo = useModel(props, 'foo')
+          return () => {
+            compRender()
+            return foo.value
+          }
+        }
       })
 
       const root = nodeOps.createElement('div')
       const updateFoo = vi.fn()
       render(h(Comp, { 'onUpdate:foo': updateFoo }), root)
+      expect(compRender).toBeCalledTimes(1)
+      expect(serializeInner(root)).toBe('<!---->')
 
       expect(foo.value).toBeUndefined()
       update()
-
+      // when parent didn't provide value, local mutation is enabled
       expect(foo.value).toBe('bar')
 
       await nextTick()
       expect(updateFoo).toBeCalledTimes(1)
+      expect(compRender).toBeCalledTimes(2)
+      expect(serializeInner(root)).toBe('bar')
     })
 
     test('default value', async () => {
@@ -257,25 +278,156 @@ describe('SFC <script setup> helpers', () => {
       const inc = () => {
         count.value++
       }
+
+      const compRender = vi.fn()
       const Comp = defineComponent({
         props: { count: { default: 0 } },
         emits: ['update:count'],
         setup(props) {
-          count = useModel(props, 'count', { local: true })
-        },
-        render() {}
+          count = useModel(props, 'count')
+          return () => {
+            compRender()
+            return count.value
+          }
+        }
       })
 
       const root = nodeOps.createElement('div')
       const updateCount = vi.fn()
       render(h(Comp, { 'onUpdate:count': updateCount }), root)
+      expect(compRender).toBeCalledTimes(1)
+      expect(serializeInner(root)).toBe('0')
 
       expect(count.value).toBe(0)
 
       inc()
+      // when parent didn't provide value, local mutation is enabled
       expect(count.value).toBe(1)
+
       await nextTick()
+
       expect(updateCount).toBeCalledTimes(1)
+      expect(compRender).toBeCalledTimes(2)
+      expect(serializeInner(root)).toBe('1')
+    })
+
+    test('parent limiting child value', async () => {
+      let childCount: Ref<number>
+
+      const compRender = vi.fn()
+      const Comp = defineComponent({
+        props: ['count'],
+        emits: ['update:count'],
+        setup(props) {
+          childCount = useModel(props, 'count')
+          return () => {
+            compRender()
+            return childCount.value
+          }
+        }
+      })
+
+      const Parent = defineComponent({
+        setup() {
+          const count = ref(0)
+          watch(count, () => {
+            if (count.value < 0) {
+              count.value = 0
+            }
+          })
+          return () =>
+            h(Comp, {
+              count: count.value,
+              'onUpdate:count': val => {
+                count.value = val
+              }
+            })
+        }
+      })
+
+      const root = nodeOps.createElement('div')
+      render(h(Parent), root)
+      expect(serializeInner(root)).toBe('0')
+
+      // child update
+      childCount!.value = 1
+      // not yet updated
+      expect(childCount!.value).toBe(0)
+
+      await nextTick()
+      expect(childCount!.value).toBe(1)
+      expect(serializeInner(root)).toBe('1')
+
+      // child update to invalid value
+      childCount!.value = -1
+      // not yet updated
+      expect(childCount!.value).toBe(1)
+
+      await nextTick()
+      // limited to 0 by parent
+      expect(childCount!.value).toBe(0)
+      expect(serializeInner(root)).toBe('0')
+    })
+
+    test('has parent value -> no parent value', async () => {
+      let childCount: Ref<number>
+
+      const compRender = vi.fn()
+      const Comp = defineComponent({
+        props: ['count'],
+        emits: ['update:count'],
+        setup(props) {
+          childCount = useModel(props, 'count')
+          return () => {
+            compRender()
+            return childCount.value
+          }
+        }
+      })
+
+      const toggle = ref(true)
+      const Parent = defineComponent({
+        setup() {
+          const count = ref(0)
+          return () =>
+            toggle.value
+              ? h(Comp, {
+                  count: count.value,
+                  'onUpdate:count': val => {
+                    count.value = val
+                  }
+                })
+              : h(Comp)
+        }
+      })
+
+      const root = nodeOps.createElement('div')
+      render(h(Parent), root)
+      expect(serializeInner(root)).toBe('0')
+
+      // child update
+      childCount!.value = 1
+      // not yet updated
+      expect(childCount!.value).toBe(0)
+
+      await nextTick()
+      expect(childCount!.value).toBe(1)
+      expect(serializeInner(root)).toBe('1')
+
+      // parent change
+      toggle.value = false
+
+      await nextTick()
+      // localValue should be reset
+      expect(childCount!.value).toBeUndefined()
+      expect(serializeInner(root)).toBe('<!---->')
+
+      // child local mutation should continue to work
+      childCount!.value = 2
+      expect(childCount!.value).toBe(2)
+
+      await nextTick()
+      expect(serializeInner(root)).toBe('2')
     })
   })
 
index 76c5aef4eafc6f617e731451edb98eb0bf7a04fa..c11464071f5048bd9e87905762934a1d5b2fc34c 100644 (file)
@@ -5,7 +5,8 @@ import {
   Prettify,
   UnionToIntersection,
   extend,
-  LooseRequired
+  LooseRequired,
+  hasChanged
 } from '@vue/shared'
 import {
   getCurrentInstance,
@@ -30,8 +31,8 @@ import {
 } from './componentProps'
 import { warn } from './warning'
 import { SlotsType, StrictUnwrapSlotsType } from './componentSlots'
-import { Ref, ref } from '@vue/reactivity'
-import { watch, watchSyncEffect } from './apiWatch'
+import { Ref, customRef, ref } from '@vue/reactivity'
+import { watchSyncEffect } from '.'
 
 // dev only
 const warnRuntimeUsage = (method: string) =>
@@ -227,9 +228,8 @@ export function defineSlots<
  * 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`.
+ * If the parent did not provide the corresponding v-model props, the returned
+ * ref can still be used and will behave like a normal local ref.
  *
  * @example
  * ```ts
@@ -246,32 +246,26 @@ export function defineSlots<
  *
  * // 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
+  options: { required: true } & PropOptions<T>
 ): Ref<T>
 export function defineModel<T>(
-  options: { default: any } & PropOptions<T> & DefineModelOptions
+  options: { default: any } & PropOptions<T>
 ): Ref<T>
-export function defineModel<T>(
-  options?: PropOptions<T> & DefineModelOptions
-): Ref<T | undefined>
+export function defineModel<T>(options?: PropOptions<T>): Ref<T | undefined>
 export function defineModel<T>(
   name: string,
-  options: { required: true } & PropOptions<T> & DefineModelOptions
+  options: { required: true } & PropOptions<T>
 ): Ref<T>
 export function defineModel<T>(
   name: string,
-  options: { default: any } & PropOptions<T> & DefineModelOptions
+  options: { default: any } & PropOptions<T>
 ): Ref<T>
 export function defineModel<T>(
   name: string,
-  options?: PropOptions<T> & DefineModelOptions
+  options?: PropOptions<T>
 ): Ref<T | undefined>
 export function defineModel(): any {
   if (__DEV__) {
@@ -279,10 +273,6 @@ export function defineModel(): any {
   }
 }
 
-interface DefineModelOptions {
-  local?: boolean
-}
-
 type NotUndefined<T> = T extends undefined ? never : T
 
 type InferDefaults<T> = {
@@ -357,14 +347,9 @@ export function useAttrs(): SetupContext['attrs'] {
 
 export function useModel<T extends Record<string, any>, K extends keyof T>(
   props: T,
-  name: K,
-  options?: { local?: boolean }
+  name: K
 ): Ref<T[K]>
-export function useModel(
-  props: Record<string, any>,
-  name: string,
-  options?: { local?: boolean }
-): Ref {
+export function useModel(props: Record<string, any>, name: string): Ref {
   const i = getCurrentInstance()!
   if (__DEV__ && !i) {
     warn(`useModel() called without active instance.`)
@@ -376,34 +361,25 @@ export function useModel(
     return ref() as any
   }
 
-  if (options && options.local) {
-    const proxy = ref<any>(props[name])
-    watchSyncEffect(() => {
-      proxy.value = props[name]
-    })
-
-    watch(
-      proxy,
-      value => {
-        if (value !== props[name]) {
-          i.emit(`update:${name}`, value)
-        }
-      },
-      { flush: 'sync' }
-    )
+  let localValue: any
+  watchSyncEffect(() => {
+    localValue = props[name]
+  })
 
-    return proxy
-  } else {
-    return {
-      __v_isRef: true,
-      get value() {
-        return props[name]
-      },
-      set value(value) {
-        i.emit(`update:${name}`, value)
+  return customRef((track, trigger) => ({
+    get() {
+      track()
+      return localValue
+    },
+    set(value) {
+      const rawProps = i.vnode!.props
+      if (!(rawProps && name in rawProps) && hasChanged(value, localValue)) {
+        localValue = value
+        trigger()
       }
-    } as any
-  }
+      i.emit(`update:${name}`, value)
+    }
+  }))
 }
 
 function getContext(): SetupContext {