mirror of https://github.com/vuejs/core.git
fix(runtime-dom): fix event timestamp check in iframes
fix #2513 fix #3933 close #5474
This commit is contained in:
parent
a71f9ac41a
commit
5ee40532a6
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue