Skip to content

Commit

Permalink
fix(defineModel): force local update when setter results in same emit…
Browse files Browse the repository at this point in the history
…ted value

fix #10279
fix #10301
  • Loading branch information
yyx990803 committed Jul 11, 2024
1 parent 0ac0f2e commit de174e1
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 13 deletions.
86 changes: 86 additions & 0 deletions packages/runtime-core/__tests__/helpers/useModel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Fragment,
type Ref,
type TestElement,
createApp,
createBlock,
createElementBlock,
Expand Down Expand Up @@ -526,4 +527,89 @@ describe('useModel', () => {
await nextTick()
expect(msg.value).toBe('UGHH')
})

// #10279
test('force local update when setter formats value to the same value', async () => {
let childMsg: Ref<string>
let childModifiers: Record<string, true | undefined>

const compRender = vi.fn()
const parentRender = vi.fn()

const Comp = defineComponent({
props: ['msg', 'msgModifiers'],
emits: ['update:msg'],
setup(props) {
;[childMsg, childModifiers] = useModel(props, 'msg', {
set(val) {
if (childModifiers.number) {
return val.replace(/\D+/g, '')
}
},
})
return () => {
compRender()
return h('input', {
// simulate how v-model works
onVnodeBeforeMount(vnode) {
;(vnode.el as TestElement).props.value = childMsg.value
},
onVnodeBeforeUpdate(vnode) {
;(vnode.el as TestElement).props.value = childMsg.value
},
onInput(value: any) {
childMsg.value = value
},
})
}
},
})

const msg = ref(1)
const Parent = defineComponent({
setup() {
return () => {
parentRender()
return h(Comp, {
msg: msg.value,
msgModifiers: { number: true },
'onUpdate:msg': val => {
msg.value = val
},
})
}
},
})

const root = nodeOps.createElement('div')
render(h(Parent), root)

expect(parentRender).toHaveBeenCalledTimes(1)
expect(compRender).toHaveBeenCalledTimes(1)
expect(serializeInner(root)).toBe('<input value=1></input>')

const input = root.children[0] as TestElement

// simulate v-model update
input.props.onInput((input.props.value = '2'))
await nextTick()
expect(msg.value).toBe(2)
expect(parentRender).toHaveBeenCalledTimes(2)
expect(compRender).toHaveBeenCalledTimes(2)
expect(serializeInner(root)).toBe('<input value=2></input>')

input.props.onInput((input.props.value = '2a'))
await nextTick()
expect(msg.value).toBe(2)
expect(parentRender).toHaveBeenCalledTimes(2)
// should force local update
expect(compRender).toHaveBeenCalledTimes(3)
expect(serializeInner(root)).toBe('<input value=2></input>')

input.props.onInput((input.props.value = '2a'))
await nextTick()
expect(parentRender).toHaveBeenCalledTimes(2)
// should not force local update if set to the same value
expect(compRender).toHaveBeenCalledTimes(3)
})
})
13 changes: 5 additions & 8 deletions packages/runtime-core/src/componentEmits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
compatModelEmit,
compatModelEventPrefix,
} from './compat/componentVModel'
import { getModelModifiers } from './helpers/useModel'

export type ObjectEmitsOptions = Record<
string,
Expand Down Expand Up @@ -125,16 +126,12 @@ export function emit(
const isModelListener = event.startsWith('update:')

// for v-model update:xxx events, apply modifiers on args
const modelArg = isModelListener && event.slice(7)
if (modelArg && modelArg in props) {
const modifiersKey = `${
modelArg === 'modelValue' ? 'model' : modelArg
}Modifiers`
const { number, trim } = props[modifiersKey] || EMPTY_OBJ
if (trim) {
const modifiers = isModelListener && getModelModifiers(props, event.slice(7))
if (modifiers) {
if (modifiers.trim) {
args = rawArgs.map(a => (isString(a) ? a.trim() : a))
}
if (number) {
if (modifiers.number) {
args = rawArgs.map(looseToNumber)
}
}
Expand Down
34 changes: 29 additions & 5 deletions packages/runtime-core/src/helpers/useModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,27 @@ export function useModel(

const camelizedName = camelize(name)
const hyphenatedName = hyphenate(name)
const modifiers = getModelModifiers(props, name)

const res = customRef((track, trigger) => {
let localValue: any
let prevSetValue: any
let prevEmittedValue: 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 (
Expand All @@ -59,24 +65,36 @@ export function useModel(
) &&
hasChanged(value, localValue)
) {
// no v-model, local update
localValue = value
trigger()
}
i.emit(`update:${name}`, options.set ? options.set(value) : value)
const emittedValue = options.set ? options.set(value) : value
i.emit(`update:${name}`, emittedValue)
// #10279: if the local value is converted via a setter but the value
// emitted to parent was the same, the parent will not trigger any
// updates and there will be no prop sync. However the local input state
// may be out of sync, so we need to force an update here.
if (
value !== emittedValue &&
value !== prevSetValue &&
emittedValue === prevEmittedValue
) {
trigger()
}
prevSetValue = value
prevEmittedValue = emittedValue
},
}
})

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 }
return { value: i++ ? modifiers || EMPTY_OBJ : res, done: false }
} else {
return { done: true }
}
Expand All @@ -86,3 +104,9 @@ export function useModel(

return res
}

export const getModelModifiers = (
props: Record<string, any>,
modelName: string,
): Record<string, boolean> | undefined =>
props[`${modelName === 'modelValue' ? 'model' : modelName}Modifiers`]

0 comments on commit de174e1

Please sign in to comment.