]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
refactor: split out useModel implementation and tests
authorEvan You <yyx990803@gmail.com>
Thu, 11 Jan 2024 09:57:47 +0000 (17:57 +0800)
committerEvan You <yyx990803@gmail.com>
Thu, 11 Jan 2024 10:04:47 +0000 (18:04 +0800)
packages/dts-test/setupHelpers.test-d.ts
packages/runtime-core/__tests__/apiSetupHelpers.spec.ts
packages/runtime-core/__tests__/helpers/useModel.spec.ts [new file with mode: 0644]
packages/runtime-core/src/apiSetupHelpers.ts
packages/runtime-core/src/helpers/useModel.ts [new file with mode: 0644]
packages/runtime-core/src/index.ts

index 9588cb9b209be4bc68f908a9493196bc1194342a..c749e80a5c7164c19b8f28acd34ea95304f30c32 100644 (file)
@@ -2,18 +2,18 @@ import {
   type Ref,
   type Slots,
   type VNode,
+  defineComponent,
   defineEmits,
   defineModel,
   defineProps,
   defineSlots,
   toRefs,
   useAttrs,
+  useModel,
   useSlots,
   withDefaults,
 } from 'vue'
 import { describe, expectType } from './utils'
-import { defineComponent } from 'vue'
-import { useModel } from 'vue'
 
 describe('defineProps w/ type declaration', () => {
   // type declaration
index 04df0ae593ab5aa1ad4e8c9563845e22d5a7d8d9..04e9c1c86dbba0b9b3ea641569d2de921c5e2053 100644 (file)
@@ -1,28 +1,18 @@
 import {
   type ComponentInternalInstance,
   type ComputedRef,
-  Fragment,
-  type Ref,
   type SetupContext,
   Suspense,
   computed,
   createApp,
-  createBlock,
-  createElementBlock,
-  createElementVNode,
-  createVNode,
   defineComponent,
   getCurrentInstance,
   h,
-  nextTick,
   nodeOps,
   onMounted,
-  openBlock,
-  ref,
   render,
   serializeInner,
   shallowReactive,
-  watch,
 } from '@vue/runtime-test'
 import {
   createPropsRestProxy,
@@ -32,7 +22,6 @@ import {
   mergeDefaults,
   mergeModels,
   useAttrs,
-  useModel,
   useSlots,
   withAsyncContext,
   withDefaults,
@@ -185,516 +174,6 @@ describe('SFC <script setup> helpers', () => {
     })
   })
 
-  describe('useModel', () => {
-    test('basic', async () => {
-      let foo: any
-      const update = () => {
-        foo.value = 'bar'
-      }
-
-      const compRender = vi.fn()
-      const Comp = defineComponent({
-        props: ['modelValue'],
-        emits: ['update:modelValue'],
-        setup(props) {
-          foo = useModel(props, 'modelValue')
-          return () => {
-            compRender()
-            return foo.value
-          }
-        },
-      })
-
-      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()
-      expect(compRender).toBeCalledTimes(1)
-      expect(serializeInner(root)).toBe('')
-
-      // update from child
-      update()
-
-      await nextTick()
-      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('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')
-          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('without parent listener (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')
-          return () => {
-            compRender()
-            return foo.value
-          }
-        },
-      })
-
-      const root = nodeOps.createElement('div')
-      // provide initial value
-      render(h(Comp, { foo: 'initial' }), root)
-      expect(compRender).toBeCalledTimes(1)
-      expect(serializeInner(root)).toBe('initial')
-
-      expect(foo.value).toBe('initial')
-      update()
-      // when parent didn't provide value, local mutation is enabled
-      expect(foo.value).toBe('bar')
-
-      await nextTick()
-      expect(compRender).toBeCalledTimes(2)
-      expect(serializeInner(root)).toBe('bar')
-    })
-
-    test('kebab-case v-model (should not be local)', async () => {
-      let foo: any
-
-      const compRender = vi.fn()
-      const Comp = defineComponent({
-        props: ['fooBar'],
-        emits: ['update:fooBar'],
-        setup(props) {
-          foo = useModel(props, 'fooBar')
-          return () => {
-            compRender()
-            return foo.value
-          }
-        },
-      })
-
-      const updateFooBar = vi.fn()
-      const root = nodeOps.createElement('div')
-      // v-model:foo-bar compiles to foo-bar and onUpdate:fooBar
-      render(
-        h(Comp, { 'foo-bar': 'initial', 'onUpdate:fooBar': updateFooBar }),
-        root,
-      )
-      expect(compRender).toBeCalledTimes(1)
-      expect(serializeInner(root)).toBe('initial')
-
-      expect(foo.value).toBe('initial')
-      foo.value = 'bar'
-      // should not be using local mode, so nothing should actually change
-      expect(foo.value).toBe('initial')
-
-      await nextTick()
-      expect(compRender).toBeCalledTimes(1)
-      expect(updateFooBar).toBeCalledTimes(1)
-      expect(updateFooBar).toHaveBeenCalledWith('bar')
-      expect(foo.value).toBe('initial')
-      expect(serializeInner(root)).toBe('initial')
-    })
-
-    test('kebab-case update listener (should not be local)', async () => {
-      let foo: any
-
-      const compRender = vi.fn()
-      const Comp = defineComponent({
-        props: ['fooBar'],
-        emits: ['update:fooBar'],
-        setup(props) {
-          foo = useModel(props, 'fooBar')
-          return () => {
-            compRender()
-            return foo.value
-          }
-        },
-      })
-
-      const updateFooBar = vi.fn()
-      const root = nodeOps.createElement('div')
-      // The template compiler won't create hyphenated listeners, but it could have been passed manually
-      render(
-        h(Comp, { 'foo-bar': 'initial', 'onUpdate:foo-bar': updateFooBar }),
-        root,
-      )
-      expect(compRender).toBeCalledTimes(1)
-      expect(serializeInner(root)).toBe('initial')
-
-      expect(foo.value).toBe('initial')
-      foo.value = 'bar'
-      // should not be using local mode, so nothing should actually change
-      expect(foo.value).toBe('initial')
-
-      await nextTick()
-      expect(compRender).toBeCalledTimes(1)
-      expect(updateFooBar).toBeCalledTimes(1)
-      expect(updateFooBar).toHaveBeenCalledWith('bar')
-      expect(foo.value).toBe('initial')
-      expect(serializeInner(root)).toBe('initial')
-    })
-
-    test('default value', async () => {
-      let count: any
-      const inc = () => {
-        count.value++
-      }
-
-      const compRender = vi.fn()
-      const Comp = defineComponent({
-        props: { count: { default: 0 } },
-        emits: ['update:count'],
-        setup(props) {
-          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')
-    })
-
-    // #9838
-    test('pass modelValue to slot (optimized mode) ', async () => {
-      let foo: any
-      const update = () => {
-        foo.value = 'bar'
-      }
-
-      const Comp = {
-        render(this: any) {
-          return this.$slots.default()
-        },
-      }
-
-      const childRender = vi.fn()
-      const slotRender = vi.fn()
-      const Child = defineComponent({
-        props: ['modelValue'],
-        emits: ['update:modelValue'],
-        setup(props) {
-          foo = useModel(props, 'modelValue')
-          return () => {
-            childRender()
-            return (
-              openBlock(),
-              createElementBlock(Fragment, null, [
-                createVNode(Comp, null, {
-                  default: () => {
-                    slotRender()
-                    return createElementVNode('div', null, foo.value)
-                  },
-                  _: 1 /* STABLE */,
-                }),
-              ])
-            )
-          }
-        },
-      })
-
-      const msg = ref('')
-      const setValue = vi.fn(v => (msg.value = v))
-      const root = nodeOps.createElement('div')
-      createApp({
-        render() {
-          return (
-            openBlock(),
-            createBlock(
-              Child,
-              {
-                modelValue: msg.value,
-                'onUpdate:modelValue': setValue,
-              },
-              null,
-              8 /* PROPS */,
-              ['modelValue'],
-            )
-          )
-        },
-      }).mount(root)
-
-      expect(foo.value).toBe('')
-      expect(msg.value).toBe('')
-      expect(setValue).not.toBeCalled()
-      expect(childRender).toBeCalledTimes(1)
-      expect(slotRender).toBeCalledTimes(1)
-      expect(serializeInner(root)).toBe('<div></div>')
-
-      // update from child
-      update()
-
-      await nextTick()
-      expect(msg.value).toBe('bar')
-      expect(foo.value).toBe('bar')
-      expect(setValue).toBeCalledTimes(1)
-      expect(childRender).toBeCalledTimes(2)
-      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', () => {
     const original = shallowReactive({
       foo: 1,
diff --git a/packages/runtime-core/__tests__/helpers/useModel.spec.ts b/packages/runtime-core/__tests__/helpers/useModel.spec.ts
new file mode 100644 (file)
index 0000000..c02af33
--- /dev/null
@@ -0,0 +1,529 @@
+import {
+  Fragment,
+  type Ref,
+  createApp,
+  createBlock,
+  createElementBlock,
+  createElementVNode,
+  createVNode,
+  defineComponent,
+  h,
+  nextTick,
+  nodeOps,
+  openBlock,
+  ref,
+  render,
+  serializeInner,
+  watch,
+} from '@vue/runtime-test'
+import { useModel } from '../../src/helpers/useModel'
+
+describe('useModel', () => {
+  test('basic', async () => {
+    let foo: any
+    const update = () => {
+      foo.value = 'bar'
+    }
+
+    const compRender = vi.fn()
+    const Comp = defineComponent({
+      props: ['modelValue'],
+      emits: ['update:modelValue'],
+      setup(props) {
+        foo = useModel(props, 'modelValue')
+        return () => {
+          compRender()
+          return foo.value
+        }
+      },
+    })
+
+    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()
+    expect(compRender).toBeCalledTimes(1)
+    expect(serializeInner(root)).toBe('')
+
+    // update from child
+    update()
+
+    await nextTick()
+    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('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')
+        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('without parent listener (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')
+        return () => {
+          compRender()
+          return foo.value
+        }
+      },
+    })
+
+    const root = nodeOps.createElement('div')
+    // provide initial value
+    render(h(Comp, { foo: 'initial' }), root)
+    expect(compRender).toBeCalledTimes(1)
+    expect(serializeInner(root)).toBe('initial')
+
+    expect(foo.value).toBe('initial')
+    update()
+    // when parent didn't provide value, local mutation is enabled
+    expect(foo.value).toBe('bar')
+
+    await nextTick()
+    expect(compRender).toBeCalledTimes(2)
+    expect(serializeInner(root)).toBe('bar')
+  })
+
+  test('kebab-case v-model (should not be local)', async () => {
+    let foo: any
+
+    const compRender = vi.fn()
+    const Comp = defineComponent({
+      props: ['fooBar'],
+      emits: ['update:fooBar'],
+      setup(props) {
+        foo = useModel(props, 'fooBar')
+        return () => {
+          compRender()
+          return foo.value
+        }
+      },
+    })
+
+    const updateFooBar = vi.fn()
+    const root = nodeOps.createElement('div')
+    // v-model:foo-bar compiles to foo-bar and onUpdate:fooBar
+    render(
+      h(Comp, { 'foo-bar': 'initial', 'onUpdate:fooBar': updateFooBar }),
+      root,
+    )
+    expect(compRender).toBeCalledTimes(1)
+    expect(serializeInner(root)).toBe('initial')
+
+    expect(foo.value).toBe('initial')
+    foo.value = 'bar'
+    // should not be using local mode, so nothing should actually change
+    expect(foo.value).toBe('initial')
+
+    await nextTick()
+    expect(compRender).toBeCalledTimes(1)
+    expect(updateFooBar).toBeCalledTimes(1)
+    expect(updateFooBar).toHaveBeenCalledWith('bar')
+    expect(foo.value).toBe('initial')
+    expect(serializeInner(root)).toBe('initial')
+  })
+
+  test('kebab-case update listener (should not be local)', async () => {
+    let foo: any
+
+    const compRender = vi.fn()
+    const Comp = defineComponent({
+      props: ['fooBar'],
+      emits: ['update:fooBar'],
+      setup(props) {
+        foo = useModel(props, 'fooBar')
+        return () => {
+          compRender()
+          return foo.value
+        }
+      },
+    })
+
+    const updateFooBar = vi.fn()
+    const root = nodeOps.createElement('div')
+    // The template compiler won't create hyphenated listeners, but it could have been passed manually
+    render(
+      h(Comp, { 'foo-bar': 'initial', 'onUpdate:foo-bar': updateFooBar }),
+      root,
+    )
+    expect(compRender).toBeCalledTimes(1)
+    expect(serializeInner(root)).toBe('initial')
+
+    expect(foo.value).toBe('initial')
+    foo.value = 'bar'
+    // should not be using local mode, so nothing should actually change
+    expect(foo.value).toBe('initial')
+
+    await nextTick()
+    expect(compRender).toBeCalledTimes(1)
+    expect(updateFooBar).toBeCalledTimes(1)
+    expect(updateFooBar).toHaveBeenCalledWith('bar')
+    expect(foo.value).toBe('initial')
+    expect(serializeInner(root)).toBe('initial')
+  })
+
+  test('default value', async () => {
+    let count: any
+    const inc = () => {
+      count.value++
+    }
+
+    const compRender = vi.fn()
+    const Comp = defineComponent({
+      props: { count: { default: 0 } },
+      emits: ['update:count'],
+      setup(props) {
+        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')
+  })
+
+  // #9838
+  test('pass modelValue to slot (optimized mode) ', async () => {
+    let foo: any
+    const update = () => {
+      foo.value = 'bar'
+    }
+
+    const Comp = {
+      render(this: any) {
+        return this.$slots.default()
+      },
+    }
+
+    const childRender = vi.fn()
+    const slotRender = vi.fn()
+    const Child = defineComponent({
+      props: ['modelValue'],
+      emits: ['update:modelValue'],
+      setup(props) {
+        foo = useModel(props, 'modelValue')
+        return () => {
+          childRender()
+          return (
+            openBlock(),
+            createElementBlock(Fragment, null, [
+              createVNode(Comp, null, {
+                default: () => {
+                  slotRender()
+                  return createElementVNode('div', null, foo.value)
+                },
+                _: 1 /* STABLE */,
+              }),
+            ])
+          )
+        }
+      },
+    })
+
+    const msg = ref('')
+    const setValue = vi.fn(v => (msg.value = v))
+    const root = nodeOps.createElement('div')
+    createApp({
+      render() {
+        return (
+          openBlock(),
+          createBlock(
+            Child,
+            {
+              modelValue: msg.value,
+              'onUpdate:modelValue': setValue,
+            },
+            null,
+            8 /* PROPS */,
+            ['modelValue'],
+          )
+        )
+      },
+    }).mount(root)
+
+    expect(foo.value).toBe('')
+    expect(msg.value).toBe('')
+    expect(setValue).not.toBeCalled()
+    expect(childRender).toBeCalledTimes(1)
+    expect(slotRender).toBeCalledTimes(1)
+    expect(serializeInner(root)).toBe('<div></div>')
+
+    // update from child
+    update()
+
+    await nextTick()
+    expect(msg.value).toBe('bar')
+    expect(foo.value).toBe('bar')
+    expect(setValue).toBeCalledTimes(1)
+    expect(childRender).toBeCalledTimes(2)
+    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')
+  })
+})
index 3815c7b143e4fc52339f43ad862b06f0cd710540..e5f79444da04ec8b3ae345f8e8b969706f93060d 100644 (file)
@@ -1,12 +1,8 @@
 import {
-  EMPTY_OBJ,
   type LooseRequired,
   type Prettify,
   type UnionToIntersection,
-  camelize,
   extend,
-  hasChanged,
-  hyphenate,
   isArray,
   isFunction,
   isPromise,
@@ -29,13 +25,11 @@ import type {
   ComponentObjectPropsOptions,
   ComponentPropsOptions,
   ExtractPropTypes,
-  NormalizedProps,
   PropOptions,
 } from './componentProps'
 import { warn } from './warning'
 import type { SlotsType, StrictUnwrapSlotsType } from './componentSlots'
-import { type Ref, customRef, ref } from '@vue/reactivity'
-import { watchSyncEffect } from '.'
+import type { Ref } from '@vue/reactivity'
 
 // dev only
 const warnRuntimeUsage = (method: string) =>
@@ -224,6 +218,11 @@ export function defineSlots<
 export type ModelRef<T, M extends string | number | symbol = string> = Ref<T> &
   [ModelRef<T, M>, Record<M, true | undefined>]
 
+export type DefineModelOptions<T = any> = {
+  get?: (v: T) => any
+  set?: (v: T) => any
+}
+
 /**
  * Vue `<script setup>` compiler macro for declaring a
  * two-way binding prop that can be consumed via `v-model` from the parent
@@ -258,25 +257,25 @@ export type ModelRef<T, M extends string | number | symbol = string> = Ref<T> &
  * ```
  */
 export function defineModel<T, M extends string | number | symbol = string>(
-  options: { required: true } & PropOptions<T> & UseModelOptions<T>,
+  options: { required: true } & PropOptions<T> & DefineModelOptions<T>,
 ): ModelRef<T, M>
 export function defineModel<T, M extends string | number | symbol = string>(
-  options: { default: any } & PropOptions<T> & UseModelOptions<T>,
+  options: { default: any } & PropOptions<T> & DefineModelOptions<T>,
 ): ModelRef<T, M>
 export function defineModel<T, M extends string | number | symbol = string>(
-  options?: PropOptions<T> & UseModelOptions<T>,
+  options?: PropOptions<T> & DefineModelOptions<T>,
 ): ModelRef<T | undefined, M>
 export function defineModel<T, M extends string | number | symbol = string>(
   name: string,
-  options: { required: true } & PropOptions<T> & UseModelOptions<T>,
+  options: { required: true } & PropOptions<T> & DefineModelOptions<T>,
 ): ModelRef<T, M>
 export function defineModel<T, M extends string | number | symbol = string>(
   name: string,
-  options: { default: any } & PropOptions<T> & UseModelOptions<T>,
+  options: { default: any } & PropOptions<T> & DefineModelOptions<T>,
 ): ModelRef<T, M>
 export function defineModel<T, M extends string | number | symbol = string>(
   name: string,
-  options?: PropOptions<T> & UseModelOptions<T>,
+  options?: PropOptions<T> & DefineModelOptions<T>,
 ): ModelRef<T | undefined, M>
 export function defineModel(): any {
   if (__DEV__) {
@@ -356,92 +355,6 @@ export function useAttrs(): SetupContext['attrs'] {
   return getContext().attrs
 }
 
-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.`)
-    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
-  }
-
-  const camelizedName = camelize(name)
-  const hyphenatedName = hyphenate(name)
-
-  const res = customRef((track, trigger) => {
-    let localValue: any
-    watchSyncEffect(() => {
-      const propValue = props[name]
-      if (hasChanged(localValue, propValue)) {
-        localValue = propValue
-        trigger()
-      }
-    })
-    return {
-      get() {
-        track()
-        return options.get ? options.get(localValue) : localValue
-      },
-      set(value) {
-        const rawProps = i.vnode!.props
-        if (
-          !(
-            rawProps &&
-            // check if parent has passed v-model
-            (name in rawProps ||
-              camelizedName in rawProps ||
-              hyphenatedName in rawProps) &&
-            (`onUpdate:${name}` in rawProps ||
-              `onUpdate:${camelizedName}` in rawProps ||
-              `onUpdate:${hyphenatedName}` in rawProps)
-          ) &&
-          hasChanged(value, localValue)
-        ) {
-          localValue = value
-          trigger()
-        }
-        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 {
   const i = getCurrentInstance()!
   if (__DEV__ && !i) {
diff --git a/packages/runtime-core/src/helpers/useModel.ts b/packages/runtime-core/src/helpers/useModel.ts
new file mode 100644 (file)
index 0000000..b2670e5
--- /dev/null
@@ -0,0 +1,88 @@
+import { type Ref, customRef, ref } from '@vue/reactivity'
+import { EMPTY_OBJ, camelize, hasChanged, hyphenate } from '@vue/shared'
+import type { DefineModelOptions, ModelRef } from '../apiSetupHelpers'
+import { getCurrentInstance } from '../component'
+import { warn } from '../warning'
+import type { NormalizedProps } from '../componentProps'
+import { watchSyncEffect } from '../apiWatch'
+
+export function useModel<
+  M extends string | number | symbol,
+  T extends Record<string, any>,
+  K extends keyof T,
+>(props: T, name: K, options?: DefineModelOptions<T[K]>): ModelRef<T[K], M>
+export function useModel(
+  props: Record<string, any>,
+  name: string,
+  options: DefineModelOptions = EMPTY_OBJ,
+): 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
+  }
+
+  const camelizedName = camelize(name)
+  const hyphenatedName = hyphenate(name)
+
+  const res = customRef((track, trigger) => {
+    let localValue: any
+    watchSyncEffect(() => {
+      const propValue = props[name]
+      if (hasChanged(localValue, propValue)) {
+        localValue = propValue
+        trigger()
+      }
+    })
+    return {
+      get() {
+        track()
+        return options.get ? options.get(localValue) : localValue
+      },
+      set(value) {
+        const rawProps = i.vnode!.props
+        if (
+          !(
+            rawProps &&
+            // check if parent has passed v-model
+            (name in rawProps ||
+              camelizedName in rawProps ||
+              hyphenatedName in rawProps) &&
+            (`onUpdate:${name}` in rawProps ||
+              `onUpdate:${camelizedName}` in rawProps ||
+              `onUpdate:${hyphenatedName}` in rawProps)
+          ) &&
+          hasChanged(value, localValue)
+        ) {
+          localValue = value
+          trigger()
+        }
+        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
+}
index 8e49771d3c1a1cc14d403b734d872d9bd14f129f..13345b78a456d6f0eb2b50c960369de1a17e9a46 100644 (file)
@@ -61,6 +61,7 @@ export { nextTick } from './scheduler'
 export { defineComponent } from './apiDefineComponent'
 export { defineAsyncComponent } from './apiAsyncComponent'
 export { useAttrs, useSlots } from './apiSetupHelpers'
+export { useModel } from './helpers/useModel'
 
 // <script setup> API ----------------------------------------------------------
 
@@ -73,7 +74,6 @@ export {
   defineSlots,
   defineModel,
   withDefaults,
-  useModel,
   type DefineProps,
   type ModelRef,
 } from './apiSetupHelpers'