From 6e41679a96582da3e0a60bdbf123c33ba0e86b31 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 5 Oct 2017 00:52:47 -0400 Subject: [PATCH] fix: use MessageChannel for nextTick fix #6566, #6690 --- src/core/util/env.js | 53 +++++++----------- .../web/compiler/directives/model.js | 5 +- src/platforms/web/runtime/modules/events.js | 13 ++--- test/e2e/specs/async-edge-cases.html | 54 +++++++++++++++++++ test/e2e/specs/async-edge-cases.js | 34 ++++++++++++ 5 files changed, 113 insertions(+), 46 deletions(-) create mode 100644 test/e2e/specs/async-edge-cases.html create mode 100644 test/e2e/specs/async-edge-cases.js diff --git a/src/core/util/env.js b/src/core/util/env.js index 57e65b20c5..3eb9ddd6c9 100644 --- a/src/core/util/env.js +++ b/src/core/util/env.js @@ -1,7 +1,6 @@ /* @flow */ -/* globals MutationObserver */ +/* globals MessageChannel */ -import { noop } from 'shared/util' import { handleError } from './error' // can we use __proto__? @@ -80,41 +79,29 @@ export const nextTick = (function () { } } - // the nextTick behavior leverages the microtask queue, which can be accessed - // via either native Promise.then or MutationObserver. - // MutationObserver has wider support, however it is seriously bugged in - // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It - // completely stops working after triggering a few times... so, if native - // Promise is available, we will use it: - /* istanbul ignore if */ // $flow-disable-line - if (typeof Promise !== 'undefined' && isNative(Promise)) { - var p = Promise.resolve() - var logError = err => { handleError(err, null, 'nextTick') } + // An asynchronous deferring mechanism. + // In pre 2.4, we used to use microtasks (Promise/MutationObserver) + // but microtasks actually has too high a priority and fires in between + // supposedly sequential events (e.g. #4521, #6690) or even between + // bubbling of the same event (#6566). Technically setImmediate should be + // the ideal choice, but it's not available everywhere; and the only polyfill + // that consistently queues the callback after all DOM events triggered in the + // same loop is by using MessageChannel. + /* istanbul ignore if */ + if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { timerFunc = () => { - p.then(nextTickHandler).catch(logError) - // in problematic UIWebViews, Promise.then doesn't completely break, but - // it can get stuck in a weird state where callbacks are pushed into the - // microtask queue but the queue isn't being flushed, until the browser - // needs to do some other work, e.g. handle a timer. Therefore we can - // "force" the microtask queue to be flushed by adding an empty timer. - if (isIOS) setTimeout(noop) + setImmediate(nextTickHandler) } - } else if (!isIE && typeof MutationObserver !== 'undefined' && ( - isNative(MutationObserver) || - // PhantomJS and iOS 7.x - MutationObserver.toString() === '[object MutationObserverConstructor]' + } else if (typeof MessageChannel !== 'undefined' && ( + isNative(MessageChannel) || + // PhantomJS + MessageChannel.toString() === '[object MessageChannelConstructor]' )) { - // use MutationObserver where native Promise is not available, - // e.g. PhantomJS, iOS7, Android 4.4 - var counter = 1 - var observer = new MutationObserver(nextTickHandler) - var textNode = document.createTextNode(String(counter)) - observer.observe(textNode, { - characterData: true - }) + const channel = new MessageChannel() + const port = channel.port2 + channel.port1.onmessage = nextTickHandler timerFunc = () => { - counter = (counter + 1) % 2 - textNode.data = String(counter) + port.postMessage(1) } } else { // fallback to setTimeout diff --git a/src/platforms/web/compiler/directives/model.js b/src/platforms/web/compiler/directives/model.js index 7c662629b6..98dd5a5213 100644 --- a/src/platforms/web/compiler/directives/model.js +++ b/src/platforms/web/compiler/directives/model.js @@ -9,7 +9,6 @@ let warn // in some cases, the event used has to be determined at runtime // so we used some reserved tokens during compile. export const RANGE_TOKEN = '__r' -export const CHECKBOX_RADIO_TOKEN = '__c' export default function model ( el: ASTElement, @@ -86,7 +85,7 @@ function genCheckboxModel ( : `:_q(${value},${trueValueBinding})` ) ) - addHandler(el, CHECKBOX_RADIO_TOKEN, + addHandler(el, 'change', `var $$a=${value},` + '$$el=$event.target,' + `$$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});` + @@ -109,7 +108,7 @@ function genRadioModel ( let valueBinding = getBindingAttr(el, 'value') || 'null' valueBinding = number ? `_n(${valueBinding})` : valueBinding addProp(el, 'checked', `_q(${value},${valueBinding})`) - addHandler(el, CHECKBOX_RADIO_TOKEN, genAssignmentCode(value, valueBinding), null, true) + addHandler(el, 'change', genAssignmentCode(value, valueBinding), null, true) } function genSelect ( diff --git a/src/platforms/web/runtime/modules/events.js b/src/platforms/web/runtime/modules/events.js index 104ef12ccf..da052deb56 100644 --- a/src/platforms/web/runtime/modules/events.js +++ b/src/platforms/web/runtime/modules/events.js @@ -2,28 +2,21 @@ import { isDef, isUndef } from 'shared/util' import { updateListeners } from 'core/vdom/helpers/index' -import { isChrome, isIE, supportsPassive } from 'core/util/env' -import { RANGE_TOKEN, CHECKBOX_RADIO_TOKEN } from 'web/compiler/directives/model' +import { isIE, supportsPassive } from 'core/util/env' +import { RANGE_TOKEN } from 'web/compiler/directives/model' // normalize v-model event tokens that can only be determined at runtime. // it's important to place the event as the first in the array because // the whole point is ensuring the v-model callback gets called before // user-attached handlers. function normalizeEvents (on) { - let event /* istanbul ignore if */ if (isDef(on[RANGE_TOKEN])) { // IE input[type=range] only supports `change` event - event = isIE ? 'change' : 'input' + const event = isIE ? 'change' : 'input' on[event] = [].concat(on[RANGE_TOKEN], on[event] || []) delete on[RANGE_TOKEN] } - if (isDef(on[CHECKBOX_RADIO_TOKEN])) { - // Chrome fires microtasks in between click/change, leads to #4521 - event = isChrome ? 'click' : 'change' - on[event] = [].concat(on[CHECKBOX_RADIO_TOKEN], on[event] || []) - delete on[CHECKBOX_RADIO_TOKEN] - } } let target: HTMLElement diff --git a/test/e2e/specs/async-edge-cases.html b/test/e2e/specs/async-edge-cases.html new file mode 100644 index 0000000000..fe5aa6160d --- /dev/null +++ b/test/e2e/specs/async-edge-cases.html @@ -0,0 +1,54 @@ + + + + + + + + + + +
+
+ {{ num }} + +
+
+ + + +
+
+ +
+
+ +
+
+ countA: {{countA}} +
+
+ countB: {{countB}} +
+
+ + + + diff --git a/test/e2e/specs/async-edge-cases.js b/test/e2e/specs/async-edge-cases.js new file mode 100644 index 0000000000..2873409400 --- /dev/null +++ b/test/e2e/specs/async-edge-cases.js @@ -0,0 +1,34 @@ +module.exports = { + 'async edge cases': function (browser) { + browser + .url('http://localhost:8080/test/e2e/specs/async-edge-cases.html') + // #4510 + .assert.containsText('#case-1', '1') + .assert.checked('#case-1 input', false) + + .click('#case-1 input') + .assert.containsText('#case-1', '2') + .assert.checked('#case-1 input', true) + + .click('#case-1 input') + .assert.containsText('#case-1', '3') + .assert.checked('#case-1 input', false) + + // #6566 + .assert.containsText('#case-2 button', 'Expand is True') + .assert.containsText('.count-a', 'countA: 0') + .assert.containsText('.count-b', 'countB: 0') + + .click('#case-2 button') + .assert.containsText('#case-2 button', 'Expand is False') + .assert.containsText('.count-a', 'countA: 1') + .assert.containsText('.count-b', 'countB: 0') + + .click('#case-2 button') + .assert.containsText('#case-2 button', 'Expand is True') + .assert.containsText('.count-a', 'countA: 1') + .assert.containsText('.count-b', 'countB: 1') + + .end() + } +}