diff --git a/packages/runtime-dom/__tests__/events.spec.ts b/packages/runtime-dom/__tests__/events.spec.ts new file mode 100644 index 000000000..12b0ae9d9 --- /dev/null +++ b/packages/runtime-dom/__tests__/events.spec.ts @@ -0,0 +1,101 @@ +import { patchEvent } from '../src/modules/events' + +describe(`events`, () => { + it('should assign event handler', () => { + const el = document.createElement('div') + const event = new Event('click') + const fn = jest.fn() + patchEvent(el, 'click', null, fn, null) + el.dispatchEvent(event) + el.dispatchEvent(event) + el.dispatchEvent(event) + expect(fn).toHaveBeenCalledTimes(3) + }) + + it('should update event handler', () => { + const el = document.createElement('div') + const event = new Event('click') + const prevFn = jest.fn() + const nextFn = jest.fn() + patchEvent(el, 'click', null, prevFn, null) + el.dispatchEvent(event) + patchEvent(el, 'click', prevFn, nextFn, null) + el.dispatchEvent(event) + el.dispatchEvent(event) + expect(prevFn).toHaveBeenCalledTimes(1) + expect(nextFn).toHaveBeenCalledTimes(2) + }) + + it('should support multiple event handlers', () => { + const el = document.createElement('div') + const event = new Event('click') + const fn1 = jest.fn() + const fn2 = jest.fn() + patchEvent(el, 'click', null, [fn1, fn2], null) + el.dispatchEvent(event) + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledTimes(1) + }) + + it('should unassign event handler', () => { + const el = document.createElement('div') + const event = new Event('click') + const fn = jest.fn() + patchEvent(el, 'click', null, fn, null) + patchEvent(el, 'click', fn, null, null) + el.dispatchEvent(event) + expect(fn).not.toHaveBeenCalled() + }) + + it('should support event options', () => { + const el = document.createElement('div') + const event = new Event('click') + const fn = jest.fn() + const nextValue = { + handler: fn, + options: { + once: true + } + } + patchEvent(el, 'click', null, nextValue, null) + el.dispatchEvent(event) + el.dispatchEvent(event) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should support varying event options', () => { + const el = document.createElement('div') + const event = new Event('click') + const prevFn = jest.fn() + const nextFn = jest.fn() + const nextValue = { + handler: nextFn, + options: { + once: true + } + } + patchEvent(el, 'click', null, prevFn, null) + patchEvent(el, 'click', prevFn, nextValue, null) + el.dispatchEvent(event) + el.dispatchEvent(event) + expect(prevFn).not.toHaveBeenCalled() + expect(nextFn).toHaveBeenCalledTimes(1) + }) + + it('should unassign event handler with options', () => { + const el = document.createElement('div') + const event = new Event('click') + const fn = jest.fn() + const nextValue = { + handler: fn, + options: { + once: true + } + } + patchEvent(el, 'click', null, nextValue, null) + patchEvent(el, 'click', nextValue, null, null) + el.dispatchEvent(event) + el.dispatchEvent(event) + expect(fn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/runtime-dom/src/modules/events.ts b/packages/runtime-dom/src/modules/events.ts index 7d572e491..d24ec3886 100644 --- a/packages/runtime-dom/src/modules/events.ts +++ b/packages/runtime-dom/src/modules/events.ts @@ -1,4 +1,4 @@ -import { isArray } from '@vue/shared' +import { isArray, EMPTY_OBJ } from '@vue/shared' import { ComponentInternalInstance, callWithAsyncErrorHandling @@ -14,6 +14,13 @@ type EventValue = (Function | Function[]) & { invoker?: Invoker | null } +type EventValueWithOptions = { + handler: EventValue + options: AddEventListenerOptions + persistent?: boolean + invoker?: Invoker | null +} + // Async edge case fix requires storing an event listener's attach timestamp. let _getNow: () => number = Date.now @@ -43,22 +50,53 @@ const getNow = () => cachedNow || (p.then(reset), (cachedNow = _getNow())) export function patchEvent( el: Element, name: string, - prevValue: EventValue | null, - nextValue: EventValue | null, + prevValue: EventValueWithOptions | EventValue | null, + nextValue: EventValueWithOptions | EventValue | null, instance: ComponentInternalInstance | null = null ) { + const prevOptions = prevValue && 'options' in prevValue && prevValue.options + const nextOptions = nextValue && 'options' in nextValue && nextValue.options const invoker = prevValue && prevValue.invoker - if (nextValue) { + const value = + nextValue && 'handler' in nextValue ? nextValue.handler : nextValue + const persistent = + nextValue && 'persistent' in nextValue && nextValue.persistent + + if (!persistent && (prevOptions || nextOptions)) { + const prev = prevOptions || EMPTY_OBJ + const next = nextOptions || EMPTY_OBJ + if ( + prev.capture !== next.capture || + prev.passive !== next.passive || + prev.once !== next.once + ) { + if (invoker) { + el.removeEventListener(name, invoker as any, prevOptions as any) + } + if (nextValue && value) { + const invoker = createInvoker(value, instance) + nextValue.invoker = invoker + el.addEventListener(name, invoker, nextOptions as any) + } + return + } + } + + if (nextValue && value) { if (invoker) { ;(prevValue as EventValue).invoker = null - invoker.value = nextValue + invoker.value = value nextValue.invoker = invoker invoker.lastUpdated = getNow() } else { - el.addEventListener(name, createInvoker(nextValue, instance)) + el.addEventListener( + name, + createInvoker(value, instance), + nextOptions as any + ) } } else if (invoker) { - el.removeEventListener(name, invoker) + el.removeEventListener(name, invoker, prevOptions as any) } } @@ -73,7 +111,7 @@ function createInvoker( // the solution is simple: we save the timestamp when a handler is attached, // and the handler would only fire if the event passed to it was fired // AFTER it was attached. - if (e.timeStamp >= invoker.lastUpdated) { + if (e.timeStamp >= invoker.lastUpdated - 1) { const args = [e] const value = invoker.value if (isArray(value)) {