fix(runtime-dom): fix event timestamp check in iframes

fix #2513
fix #3933
close #5474
This commit is contained in:
Evan You 2022-10-14 16:00:03 +08:00
parent a71f9ac41a
commit 5ee40532a6
2 changed files with 70 additions and 75 deletions

View File

@ -5,30 +5,28 @@ const timeout = () => new Promise(r => setTimeout(r))
describe(`runtime-dom: events patching`, () => { describe(`runtime-dom: events patching`, () => {
it('should assign event handler', async () => { it('should assign event handler', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn() const fn = jest.fn()
patchProp(el, 'onClick', null, fn) patchProp(el, 'onClick', null, fn)
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn).toHaveBeenCalledTimes(3) expect(fn).toHaveBeenCalledTimes(3)
}) })
it('should update event handler', async () => { it('should update event handler', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const prevFn = jest.fn() const prevFn = jest.fn()
const nextFn = jest.fn() const nextFn = jest.fn()
patchProp(el, 'onClick', null, prevFn) patchProp(el, 'onClick', null, prevFn)
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
patchProp(el, 'onClick', prevFn, nextFn) patchProp(el, 'onClick', prevFn, nextFn)
await timeout() await timeout()
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(prevFn).toHaveBeenCalledTimes(1) expect(prevFn).toHaveBeenCalledTimes(1)
expect(nextFn).toHaveBeenCalledTimes(2) expect(nextFn).toHaveBeenCalledTimes(2)
@ -36,11 +34,10 @@ describe(`runtime-dom: events patching`, () => {
it('should support multiple event handlers', async () => { it('should support multiple event handlers', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const fn1 = jest.fn() const fn1 = jest.fn()
const fn2 = jest.fn() const fn2 = jest.fn()
patchProp(el, 'onClick', null, [fn1, fn2]) patchProp(el, 'onClick', null, [fn1, fn2])
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn1).toHaveBeenCalledTimes(1) expect(fn1).toHaveBeenCalledTimes(1)
expect(fn2).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledTimes(1)
@ -48,58 +45,55 @@ describe(`runtime-dom: events patching`, () => {
it('should unassign event handler', async () => { it('should unassign event handler', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn() const fn = jest.fn()
patchProp(el, 'onClick', null, fn) patchProp(el, 'onClick', null, fn)
patchProp(el, 'onClick', fn, null) patchProp(el, 'onClick', fn, null)
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn).not.toHaveBeenCalled() expect(fn).not.toHaveBeenCalled()
}) })
it('should support event option modifiers', async () => { it('should support event option modifiers', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn() const fn = jest.fn()
patchProp(el, 'onClickOnceCapture', null, fn) patchProp(el, 'onClickOnceCapture', null, fn)
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
}) })
it('should unassign event handler with options', async () => { it('should unassign event handler with options', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn() const fn = jest.fn()
patchProp(el, 'onClickCapture', null, fn) patchProp(el, 'onClickCapture', null, fn)
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
patchProp(el, 'onClickCapture', fn, null) patchProp(el, 'onClickCapture', fn, null)
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
}) })
it('should support native onclick', async () => { it('should support native onclick', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
// string should be set as attribute // string should be set as attribute
const fn = ((window as any).__globalSpy = jest.fn()) const fn = ((window as any).__globalSpy = jest.fn())
patchProp(el, 'onclick', null, '__globalSpy(1)') patchProp(el, 'onclick', null, '__globalSpy(1)')
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
delete (window as any).__globalSpy delete (window as any).__globalSpy
expect(fn).toHaveBeenCalledWith(1) expect(fn).toHaveBeenCalledWith(1)
const fn2 = jest.fn() const fn2 = jest.fn()
patchProp(el, 'onclick', '__globalSpy(1)', fn2) patchProp(el, 'onclick', '__globalSpy(1)', fn2)
const event = new Event('click')
el.dispatchEvent(event) el.dispatchEvent(event)
await timeout() await timeout()
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
@ -108,13 +102,12 @@ describe(`runtime-dom: events patching`, () => {
it('should support stopImmediatePropagation on multiple listeners', async () => { it('should support stopImmediatePropagation on multiple listeners', async () => {
const el = document.createElement('div') const el = document.createElement('div')
const event = new Event('click')
const fn1 = jest.fn((e: Event) => { const fn1 = jest.fn((e: Event) => {
e.stopImmediatePropagation() e.stopImmediatePropagation()
}) })
const fn2 = jest.fn() const fn2 = jest.fn()
patchProp(el, 'onClick', null, [fn1, fn2]) patchProp(el, 'onClick', null, [fn1, fn2])
el.dispatchEvent(event) el.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(fn1).toHaveBeenCalledTimes(1) expect(fn1).toHaveBeenCalledTimes(1)
expect(fn2).toHaveBeenCalledTimes(0) expect(fn2).toHaveBeenCalledTimes(0)
@ -125,15 +118,15 @@ describe(`runtime-dom: events patching`, () => {
const el1 = document.createElement('div') const el1 = document.createElement('div')
const el2 = document.createElement('div') const el2 = document.createElement('div')
const event = new Event('click') // const event = new Event('click')
const prevFn = jest.fn() const prevFn = jest.fn()
const nextFn = jest.fn() const nextFn = jest.fn()
patchProp(el1, 'onClick', null, prevFn) patchProp(el1, 'onClick', null, prevFn)
patchProp(el2, 'onClick', null, prevFn) patchProp(el2, 'onClick', null, prevFn)
el1.dispatchEvent(event) el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(event) el2.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(prevFn).toHaveBeenCalledTimes(2) expect(prevFn).toHaveBeenCalledTimes(2)
expect(nextFn).toHaveBeenCalledTimes(0) expect(nextFn).toHaveBeenCalledTimes(0)
@ -141,19 +134,39 @@ describe(`runtime-dom: events patching`, () => {
patchProp(el1, 'onClick', prevFn, nextFn) patchProp(el1, 'onClick', prevFn, nextFn)
patchProp(el2, 'onClick', prevFn, nextFn) patchProp(el2, 'onClick', prevFn, nextFn)
el1.dispatchEvent(event) el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(event) el2.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(prevFn).toHaveBeenCalledTimes(2) expect(prevFn).toHaveBeenCalledTimes(2)
expect(nextFn).toHaveBeenCalledTimes(2) expect(nextFn).toHaveBeenCalledTimes(2)
el1.dispatchEvent(event) el1.dispatchEvent(new Event('click'))
el2.dispatchEvent(event) el2.dispatchEvent(new Event('click'))
await timeout() await timeout()
expect(prevFn).toHaveBeenCalledTimes(2) expect(prevFn).toHaveBeenCalledTimes(2)
expect(nextFn).toHaveBeenCalledTimes(4) 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 // #2841
test('should patch event correctly in web-components', async () => { test('should patch event correctly in web-components', async () => {
class TestElement extends HTMLElement { class TestElement extends HTMLElement {

View File

@ -12,38 +12,6 @@ interface Invoker extends EventListener {
type EventValue = Function | Function[] 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( export function addEventListener(
el: Element, el: Element,
event: string, event: string,
@ -105,27 +73,41 @@ function parseName(name: string): [string, EventListenerOptions | undefined] {
return [event, options] 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( function createInvoker(
initialValue: EventValue, initialValue: EventValue,
instance: ComponentInternalInstance | null instance: ComponentInternalInstance | null
) { ) {
const invoker: Invoker = (e: Event) => { const invoker: Invoker = (e: Event & { _vts?: number }) => {
// async edge case #6566: inner click event triggers patch, event handler // async edge case vuejs/vue#6566
// inner click event triggers patch, event handler
// attached to outer element during patch, and triggered again. This // attached to outer element during patch, and triggered again. This
// happens because browsers fire microtask ticks between event propagation. // happens because browsers fire microtask ticks between event propagation.
// the solution is simple: we save the timestamp when a handler is attached, // this no longer happens for templates in Vue 3, but could still be
// and the handler would only fire if the event passed to it was fired // 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. // AFTER it was attached.
const timeStamp = e.timeStamp || _getNow() if (!e._vts) {
e._vts = Date.now()
if (skipTimestampCheck || timeStamp >= invoker.attached - 1) { } else if (e._vts <= invoker.attached) {
callWithAsyncErrorHandling( return
patchStopImmediatePropagation(e, invoker.value),
instance,
ErrorCodes.NATIVE_EVENT_HANDLER,
[e]
)
} }
callWithAsyncErrorHandling(
patchStopImmediatePropagation(e, invoker.value),
instance,
ErrorCodes.NATIVE_EVENT_HANDLER,
[e]
)
} }
invoker.value = initialValue invoker.value = initialValue
invoker.attached = getNow() invoker.attached = getNow()