From 5ee40532a63e0b792e0c1eccf3cf68546a4e23e9 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 14 Oct 2022 16:00:03 +0800 Subject: [PATCH] fix(runtime-dom): fix event timestamp check in iframes fix #2513 fix #3933 close #5474 --- .../runtime-dom/__tests__/patchEvents.spec.ts | 73 +++++++++++-------- packages/runtime-dom/src/modules/events.ts | 72 +++++++----------- 2 files changed, 70 insertions(+), 75 deletions(-) diff --git a/packages/runtime-dom/__tests__/patchEvents.spec.ts b/packages/runtime-dom/__tests__/patchEvents.spec.ts index 9c30616a24a..32466f29a7b 100644 --- a/packages/runtime-dom/__tests__/patchEvents.spec.ts +++ b/packages/runtime-dom/__tests__/patchEvents.spec.ts @@ -5,30 +5,28 @@ const timeout = () => new Promise(r => setTimeout(r)) describe(`runtime-dom: events patching`, () => { it('should assign event handler', async () => { const el = document.createElement('div') - const event = new Event('click') const fn = jest.fn() patchProp(el, 'onClick', null, fn) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn).toHaveBeenCalledTimes(3) }) it('should update event handler', async () => { const el = document.createElement('div') - const event = new Event('click') const prevFn = jest.fn() const nextFn = jest.fn() patchProp(el, 'onClick', null, prevFn) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) patchProp(el, 'onClick', prevFn, nextFn) await timeout() - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(prevFn).toHaveBeenCalledTimes(1) expect(nextFn).toHaveBeenCalledTimes(2) @@ -36,11 +34,10 @@ describe(`runtime-dom: events patching`, () => { it('should support multiple event handlers', async () => { const el = document.createElement('div') - const event = new Event('click') const fn1 = jest.fn() const fn2 = jest.fn() patchProp(el, 'onClick', null, [fn1, fn2]) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn1).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledTimes(1) @@ -48,58 +45,55 @@ describe(`runtime-dom: events patching`, () => { it('should unassign event handler', async () => { const el = document.createElement('div') - const event = new Event('click') const fn = jest.fn() patchProp(el, 'onClick', null, fn) patchProp(el, 'onClick', fn, null) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn).not.toHaveBeenCalled() }) it('should support event option modifiers', async () => { const el = document.createElement('div') - const event = new Event('click') const fn = jest.fn() patchProp(el, 'onClickOnceCapture', null, fn) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn).toHaveBeenCalledTimes(1) }) it('should unassign event handler with options', async () => { const el = document.createElement('div') - const event = new Event('click') const fn = jest.fn() patchProp(el, 'onClickCapture', null, fn) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn).toHaveBeenCalledTimes(1) patchProp(el, 'onClickCapture', fn, null) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn).toHaveBeenCalledTimes(1) }) it('should support native onclick', async () => { const el = document.createElement('div') - const event = new Event('click') // string should be set as attribute const fn = ((window as any).__globalSpy = jest.fn()) patchProp(el, 'onclick', null, '__globalSpy(1)') - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() delete (window as any).__globalSpy expect(fn).toHaveBeenCalledWith(1) const fn2 = jest.fn() patchProp(el, 'onclick', '__globalSpy(1)', fn2) + const event = new Event('click') el.dispatchEvent(event) await timeout() expect(fn).toHaveBeenCalledTimes(1) @@ -108,13 +102,12 @@ describe(`runtime-dom: events patching`, () => { it('should support stopImmediatePropagation on multiple listeners', async () => { const el = document.createElement('div') - const event = new Event('click') const fn1 = jest.fn((e: Event) => { e.stopImmediatePropagation() }) const fn2 = jest.fn() patchProp(el, 'onClick', null, [fn1, fn2]) - el.dispatchEvent(event) + el.dispatchEvent(new Event('click')) await timeout() expect(fn1).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledTimes(0) @@ -125,15 +118,15 @@ describe(`runtime-dom: events patching`, () => { const el1 = document.createElement('div') const el2 = document.createElement('div') - const event = new Event('click') + // const event = new Event('click') const prevFn = jest.fn() const nextFn = jest.fn() patchProp(el1, 'onClick', null, prevFn) patchProp(el2, 'onClick', null, prevFn) - el1.dispatchEvent(event) - el2.dispatchEvent(event) + el1.dispatchEvent(new Event('click')) + el2.dispatchEvent(new Event('click')) await timeout() expect(prevFn).toHaveBeenCalledTimes(2) expect(nextFn).toHaveBeenCalledTimes(0) @@ -141,19 +134,39 @@ describe(`runtime-dom: events patching`, () => { patchProp(el1, 'onClick', prevFn, nextFn) patchProp(el2, 'onClick', prevFn, nextFn) - el1.dispatchEvent(event) - el2.dispatchEvent(event) + el1.dispatchEvent(new Event('click')) + el2.dispatchEvent(new Event('click')) await timeout() expect(prevFn).toHaveBeenCalledTimes(2) expect(nextFn).toHaveBeenCalledTimes(2) - el1.dispatchEvent(event) - el2.dispatchEvent(event) + el1.dispatchEvent(new Event('click')) + el2.dispatchEvent(new Event('click')) await timeout() expect(prevFn).toHaveBeenCalledTimes(2) expect(nextFn).toHaveBeenCalledTimes(4) }) + // vuejs/vue#6566 + it('should not fire handler attached by the event itself', async () => { + const el = document.createElement('div') + const child = document.createElement('div') + el.appendChild(child) + document.body.appendChild(el) + const childFn = jest.fn() + const parentFn = jest.fn() + + patchProp(child, 'onClick', null, () => { + childFn() + patchProp(el, 'onClick', null, parentFn) + }) + child.dispatchEvent(new Event('click', { bubbles: true })) + + await timeout() + expect(childFn).toHaveBeenCalled() + expect(parentFn).not.toHaveBeenCalled() + }) + // #2841 test('should patch event correctly in web-components', async () => { class TestElement extends HTMLElement { diff --git a/packages/runtime-dom/src/modules/events.ts b/packages/runtime-dom/src/modules/events.ts index d0f8d364a29..8dbccadef1a 100644 --- a/packages/runtime-dom/src/modules/events.ts +++ b/packages/runtime-dom/src/modules/events.ts @@ -12,38 +12,6 @@ interface Invoker extends EventListener { type EventValue = Function | Function[] -// Async edge case fix requires storing an event listener's attach timestamp. -const [_getNow, skipTimestampCheck] = /*#__PURE__*/ (() => { - let _getNow = Date.now - let skipTimestampCheck = false - if (typeof window !== 'undefined') { - // Determine what event timestamp the browser is using. Annoyingly, the - // timestamp can either be hi-res (relative to page load) or low-res - // (relative to UNIX epoch), so in order to compare time we have to use the - // same timestamp type when saving the flush timestamp. - if (Date.now() > document.createEvent('Event').timeStamp) { - // if the low-res timestamp which is bigger than the event timestamp - // (which is evaluated AFTER) it means the event is using a hi-res timestamp, - // and we need to use the hi-res version for event listeners as well. - _getNow = performance.now.bind(performance) - } - // #3485: Firefox <= 53 has incorrect Event.timeStamp implementation - // and does not fire microtasks in between event propagation, so safe to exclude. - const ffMatch = navigator.userAgent.match(/firefox\/(\d+)/i) - skipTimestampCheck = !!(ffMatch && Number(ffMatch[1]) <= 53) - } - return [_getNow, skipTimestampCheck] -})() - -// To avoid the overhead of repeatedly calling performance.now(), we cache -// and use the same timestamp for all event listeners attached in the same tick. -let cachedNow: number = 0 -const p = /*#__PURE__*/ Promise.resolve() -const reset = () => { - cachedNow = 0 -} -const getNow = () => cachedNow || (p.then(reset), (cachedNow = _getNow())) - export function addEventListener( el: Element, event: string, @@ -105,27 +73,41 @@ function parseName(name: string): [string, EventListenerOptions | undefined] { return [event, options] } +// To avoid the overhead of repeatedly calling Date.now(), we cache +// and use the same timestamp for all event listeners attached in the same tick. +let cachedNow: number = 0 +const p = /*#__PURE__*/ Promise.resolve() +const getNow = () => + cachedNow || (p.then(() => (cachedNow = 0)), (cachedNow = Date.now())) + function createInvoker( initialValue: EventValue, instance: ComponentInternalInstance | null ) { - const invoker: Invoker = (e: Event) => { - // async edge case #6566: inner click event triggers patch, event handler + const invoker: Invoker = (e: Event & { _vts?: number }) => { + // async edge case vuejs/vue#6566 + // inner click event triggers patch, event handler // attached to outer element during patch, and triggered again. This // happens because browsers fire microtask ticks between event propagation. - // 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 + // this no longer happens for templates in Vue 3, but could still be + // theoretically possible for hand-written render functions. + // the solution: we save the timestamp when a handler is attached, + // and also attach the timestamp to any event that was handled by vue + // for the first time (to avoid inconsistent event timestamp implementations + // or events fired from iframes, e.g. #2513) + // The handler would only fire if the event passed to it was fired // AFTER it was attached. - const timeStamp = e.timeStamp || _getNow() - - if (skipTimestampCheck || timeStamp >= invoker.attached - 1) { - callWithAsyncErrorHandling( - patchStopImmediatePropagation(e, invoker.value), - instance, - ErrorCodes.NATIVE_EVENT_HANDLER, - [e] - ) + if (!e._vts) { + e._vts = Date.now() + } else if (e._vts <= invoker.attached) { + return } + callWithAsyncErrorHandling( + patchStopImmediatePropagation(e, invoker.value), + instance, + ErrorCodes.NATIVE_EVENT_HANDLER, + [e] + ) } invoker.value = initialValue invoker.attached = getNow()