feat(defineModel): support local mutation by default, remove local option

ref https://github.com/vuejs/rfcs/discussions/503#discussioncomment-7566278
This commit is contained in:
Evan You 2023-12-12 16:47:34 +08:00
parent 7e60d1058f
commit f74785bc4a
3 changed files with 194 additions and 70 deletions

View 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', () => {

View 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')
})
})

View 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]
})
let localValue: any
watchSyncEffect(() => {
localValue = props[name]
})
watch(
proxy,
value => {
if (value !== props[name]) {
i.emit(`update:${name}`, value)
}
},
{ flush: 'sync' }
)
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 {