From ccd3f3923f213084d86c2df24a92e8273cdb0ed0 Mon Sep 17 00:00:00 2001 From: FireBushtree <770104798@qq.com> Date: Fri, 1 Mar 2024 18:23:49 +0800 Subject: [PATCH] fix(runtime-vapor): trigger event after `v-model` (#137) --- .../__tests__/directives/vModel.spec.ts | 125 ++++++++++++++++++ .../__tests__/renderWatch.spec.ts | 6 +- .../runtime-vapor/src/directives/vModel.ts | 4 +- packages/runtime-vapor/src/dom/event.ts | 27 ++-- packages/runtime-vapor/src/render.ts | 9 +- packages/runtime-vapor/src/scheduler.ts | 2 +- 6 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 packages/runtime-vapor/__tests__/directives/vModel.spec.ts diff --git a/packages/runtime-vapor/__tests__/directives/vModel.spec.ts b/packages/runtime-vapor/__tests__/directives/vModel.spec.ts new file mode 100644 index 000000000..023501240 --- /dev/null +++ b/packages/runtime-vapor/__tests__/directives/vModel.spec.ts @@ -0,0 +1,125 @@ +import { ref } from '@vue/reactivity' +import { + on, + setDOMProp, + template, + vModelDynamic, + vModelSelect, + withDirectives, +} from '../../src' +import { makeRender } from '../_utils' +import { nextTick } from '@vue/runtime-dom' + +const define = makeRender() + +const triggerEvent = (type: string, el: Element) => { + const event = new Event(type) + el.dispatchEvent(event) +} + +describe('directive: v-model', () => { + test('should work with text input', async () => { + const spy = vi.fn() + + const data = ref('') + const { host } = define(() => { + const t0 = template('') + const n0 = t0() as HTMLInputElement + withDirectives(n0, [[vModelDynamic, () => data.value]]) + on(n0, 'update:modelValue', () => val => (data.value = val)) + on(n0, 'input', () => () => spy(data.value)) + return n0 + }).render() + + const input = host.querySelector('input')! + expect(input.value).toEqual('') + + input.value = 'foo' + triggerEvent('input', input) + await nextTick() + expect(data.value).toEqual('foo') + expect(spy).toHaveBeenCalledWith('foo') + + data.value = 'bar' + await nextTick() + expect(input.value).toEqual('bar') + + data.value = undefined + await nextTick() + expect(input.value).toEqual('') + }) + + test('should work with select', async () => { + const spy = vi.fn() + const data = ref('') + const { host } = define(() => { + const t0 = template( + '', + ) + const n0 = t0() as HTMLInputElement + withDirectives(n0, [[vModelSelect, () => data.value]]) + on(n0, 'update:modelValue', () => val => (data.value = val)) + on(n0, 'change', () => () => spy(data.value)) + return n0 + }).render() + + const select = host.querySelector('select')! + expect(select.value).toEqual('') + + select.value = 'red' + triggerEvent('change', select) + await nextTick() + expect(data.value).toEqual('red') + expect(spy).toHaveBeenCalledWith('red') + + data.value = 'blue' + await nextTick() + expect(select.value).toEqual('blue') + }) + + test('should work with number input', async () => { + const data = ref(null) + const { host } = define(() => { + const t0 = template('') + const n0 = t0() as HTMLInputElement + + setDOMProp(n0, 'type', 'number') + withDirectives(n0, [[vModelDynamic, () => data.value]]) + on(n0, 'update:modelValue', () => val => (data.value = val)) + return n0 + }).render() + + const input = host.querySelector('input')! + expect(input.value).toEqual('') + expect(input.type).toEqual('number') + + // @ts-expect-error + input.value = 1 + triggerEvent('input', input) + await nextTick() + expect(typeof data.value).toEqual('number') + expect(data.value).toEqual(1) + }) + + test('should work with multiple listeners', async () => { + const spy = vi.fn() + + const data = ref('') + const { host } = define(() => { + const t0 = template('') + const n0 = t0() as HTMLInputElement + withDirectives(n0, [[vModelDynamic, () => data.value]]) + on(n0, 'update:modelValue', () => val => (data.value = val)) + on(n0, 'update:modelValue', () => spy) + return n0 + }).render() + + const input = host.querySelector('input')! + + input.value = 'foo' + triggerEvent('input', input) + await nextTick() + expect(data.value).toEqual('foo') + expect(spy).toHaveBeenCalledWith('foo') + }) +}) diff --git a/packages/runtime-vapor/__tests__/renderWatch.spec.ts b/packages/runtime-vapor/__tests__/renderWatch.spec.ts index bd4a5a039..d425dade0 100644 --- a/packages/runtime-vapor/__tests__/renderWatch.spec.ts +++ b/packages/runtime-vapor/__tests__/renderWatch.spec.ts @@ -114,11 +114,7 @@ describe('renderWatch', () => { ).render() const { change, changeRender } = instance.setupState as any - expect(calls).toEqual(['pre 0', 'sync 0', 'renderEffect 0']) - calls.length = 0 - - await nextTick() - expect(calls).toEqual(['post 0']) + expect(calls).toEqual(['pre 0', 'sync 0', 'renderEffect 0', 'post 0']) calls.length = 0 // Update diff --git a/packages/runtime-vapor/src/directives/vModel.ts b/packages/runtime-vapor/src/directives/vModel.ts index 26020c9f5..e08439887 100644 --- a/packages/runtime-vapor/src/directives/vModel.ts +++ b/packages/runtime-vapor/src/directives/vModel.ts @@ -50,7 +50,9 @@ export const vModelText: ObjectDirective< const assigner = getModelAssigner(el) assignFnMap.set(el, assigner) - const castToNumber = number // || (vnode.props && vnode.props.type === 'number') + const metadata = getMetadata(el) + const castToNumber = number || metadata[MetadataKind.prop].type === 'number' + addEventListener(el, lazy ? 'change' : 'input', e => { if ((e.target as any).composing) return let domValue: string | number = el.value diff --git a/packages/runtime-vapor/src/dom/event.ts b/packages/runtime-vapor/src/dom/event.ts index 2370147ca..4ac5e4379 100644 --- a/packages/runtime-vapor/src/dom/event.ts +++ b/packages/runtime-vapor/src/dom/event.ts @@ -6,6 +6,7 @@ import { } from '@vue/reactivity' import { MetadataKind, getMetadata, recordEventMetadata } from '../metadata' import { withKeys, withModifiers } from '@vue/runtime-dom' +import { queuePostRenderEffect } from '../scheduler' export function addEventListener( el: HTMLElement, @@ -30,20 +31,22 @@ export function on( ) { const handler: DelegatedHandler = eventHandler(handlerGetter, options) const cleanupMetadata = recordEventMetadata(el, event, handler) - const cleanupEvent = addEventListener(el, event, handler, options) + queuePostRenderEffect(() => { + const cleanupEvent = addEventListener(el, event, handler, options) - function cleanup() { - cleanupMetadata() - cleanupEvent() - } + function cleanup() { + cleanupMetadata() + cleanupEvent() + } - const scope = getCurrentScope() - const effect = getCurrentEffect() - if (effect && effect.scope === scope) { - onEffectCleanup(cleanup) - } else if (scope) { - onScopeDispose(cleanup) - } + const scope = getCurrentScope() + const effect = getCurrentEffect() + if (effect && effect.scope === scope) { + onEffectCleanup(cleanup) + } else if (scope) { + onScopeDispose(cleanup) + } + }) } export type DelegatedHandler = { diff --git a/packages/runtime-vapor/src/render.ts b/packages/runtime-vapor/src/render.ts index 9ef66a62e..e7425a4c3 100644 --- a/packages/runtime-vapor/src/render.ts +++ b/packages/runtime-vapor/src/render.ts @@ -16,7 +16,7 @@ import { import { initProps } from './componentProps' import { invokeDirectiveHook } from './directives' import { insert, querySelector, remove } from './dom/element' -import { queuePostRenderEffect } from './scheduler' +import { flushPostFlushCbs, queuePostRenderEffect } from './scheduler' export const fragmentKey = Symbol(__DEV__ ? `fragmentKey` : ``) @@ -34,7 +34,12 @@ export function render( ): ComponentInternalInstance { const instance = createComponentInstance(comp, props) initProps(instance, props, !isFunction(instance.component)) - return mountComponent(instance, (container = normalizeContainer(container))) + const component = mountComponent( + instance, + (container = normalizeContainer(container)), + ) + flushPostFlushCbs() + return component } function normalizeContainer(container: string | ParentNode): ParentNode { diff --git a/packages/runtime-vapor/src/scheduler.ts b/packages/runtime-vapor/src/scheduler.ts index c0fea362b..0bc3c38b9 100644 --- a/packages/runtime-vapor/src/scheduler.ts +++ b/packages/runtime-vapor/src/scheduler.ts @@ -70,7 +70,7 @@ function queueFlush() { } } -function flushPostFlushCbs() { +export function flushPostFlushCbs() { if (!pendingPostFlushCbs.length) return const deduped = [...new Set(pendingPostFlushCbs)]