diff --git a/examples/basic/app.js b/examples/basic/app.js index 6033475de..ba13679f2 100644 --- a/examples/basic/app.js +++ b/examples/basic/app.js @@ -1,6 +1,30 @@ import Vue from 'vue' import VueRouter from 'vue-router' +// track number of popstate listeners +let numPopstateListeners = 0 +const listenerCountDiv = document.createElement('div') +listenerCountDiv.id = 'popstate-count' +listenerCountDiv.textContent = numPopstateListeners + ' popstate listeners' +document.body.appendChild(listenerCountDiv) + +const originalAddEventListener = window.addEventListener +const originalRemoveEventListener = window.removeEventListener +window.addEventListener = function (name, handler) { + if (name === 'popstate') { + listenerCountDiv.textContent = + ++numPopstateListeners + ' popstate listeners' + } + return originalAddEventListener.apply(this, arguments) +} +window.removeEventListener = function (name, handler) { + if (name === 'popstate') { + listenerCountDiv.textContent = + --numPopstateListeners + ' popstate listeners' + } + return originalRemoveEventListener.apply(this, arguments) +} + // 1. Use plugin. // This installs and , // and injects $router and $route to all router-enabled child components @@ -27,7 +51,7 @@ const router = new VueRouter({ // 4. Create and mount root instance. // Make sure to inject the router. // Route components will be rendered inside . -new Vue({ +const vueInstance = new Vue({ router, data: () => ({ n: 0 }), template: ` @@ -69,3 +93,8 @@ new Vue({ } } }).$mount('#app') + +document.getElementById('unmount').addEventListener('click', () => { + vueInstance.$destroy() + vueInstance.$el.innerHTML = '' +}) diff --git a/examples/basic/index.html b/examples/basic/index.html index 78a0c040f..695d668f5 100644 --- a/examples/basic/index.html +++ b/examples/basic/index.html @@ -1,6 +1,8 @@ ← Examples index + +
diff --git a/examples/hash-mode/app.js b/examples/hash-mode/app.js index c8081cf2a..48b9ab1d6 100644 --- a/examples/hash-mode/app.js +++ b/examples/hash-mode/app.js @@ -1,6 +1,30 @@ import Vue from 'vue' import VueRouter from 'vue-router' +// track number of popstate listeners +let numPopstateListeners = 0 +const listenerCountDiv = document.createElement('div') +listenerCountDiv.id = 'popstate-count' +listenerCountDiv.textContent = numPopstateListeners + ' popstate listeners' +document.body.appendChild(listenerCountDiv) + +const originalAddEventListener = window.addEventListener +const originalRemoveEventListener = window.removeEventListener +window.addEventListener = function (name, handler) { + if (name === 'popstate') { + listenerCountDiv.textContent = + ++numPopstateListeners + ' popstate listeners' + } + return originalAddEventListener.apply(this, arguments) +} +window.removeEventListener = function (name, handler) { + if (name === 'popstate') { + listenerCountDiv.textContent = + --numPopstateListeners + ' popstate listeners' + } + return originalRemoveEventListener.apply(this, arguments) +} + // 1. Use plugin. // This installs and , // and injects $router and $route to all router-enabled child components @@ -28,7 +52,7 @@ const router = new VueRouter({ // 4. Create and mount root instance. // Make sure to inject the router. // Route components will be rendered inside . -new Vue({ +const vueInstance = new Vue({ router, template: `
@@ -47,5 +71,12 @@ new Vue({
{{ $route.hash }}
- ` + `, + methods: { + } }).$mount('#app') + +document.getElementById('unmount').addEventListener('click', () => { + vueInstance.$destroy() + vueInstance.$el.innerHTML = '' +}) diff --git a/examples/hash-mode/index.html b/examples/hash-mode/index.html index 68e93063a..5789d784f 100644 --- a/examples/hash-mode/index.html +++ b/examples/hash-mode/index.html @@ -1,6 +1,8 @@ ← Examples index + +
diff --git a/examples/index.html b/examples/index.html index c0ee5bc16..864beeb3b 100644 --- a/examples/index.html +++ b/examples/index.html @@ -27,6 +27,7 @@

Vue Router Examples

  • Discrete Components
  • Nested Routers
  • Keepalive View
  • +
  • Multiple Apps
  • diff --git a/examples/multi-app/app.js b/examples/multi-app/app.js new file mode 100644 index 000000000..c9d28a0c7 --- /dev/null +++ b/examples/multi-app/app.js @@ -0,0 +1,75 @@ +import Vue from 'vue' +import VueRouter from 'vue-router' + +// track number of popstate listeners +let numPopstateListeners = 0 +const listenerCountDiv = document.getElementById('popcount') +listenerCountDiv.textContent = 0 + +const originalAddEventListener = window.addEventListener +const originalRemoveEventListener = window.removeEventListener +window.addEventListener = function (name, handler) { + if (name === 'popstate') { + listenerCountDiv.textContent = + ++numPopstateListeners + } + return originalAddEventListener.apply(this, arguments) +} +window.removeEventListener = function (name, handler) { + if (name === 'popstate') { + listenerCountDiv.textContent = + --numPopstateListeners + } + return originalRemoveEventListener.apply(this, arguments) +} + +// 1. Use plugin. +// This installs and , +// and injects $router and $route to all router-enabled child components +Vue.use(VueRouter) + +const looper = [1, 2, 3] + +looper.forEach((n) => { + let vueInstance + const mountEl = document.getElementById('mount' + n) + const unmountEl = document.getElementById('unmount' + n) + + mountEl.addEventListener('click', () => { + // 2. Define route components + const Home = { template: '
    home
    ' } + const Foo = { template: '
    foo
    ' } + + // 3. Create the router + const router = new VueRouter({ + mode: 'history', + base: __dirname, + routes: [ + { path: '/', component: Home }, + { path: '/foo', component: Foo } + ] + }) + + // 4. Create and mount root instance. + // Make sure to inject the router. + // Route components will be rendered inside . + vueInstance = new Vue({ + router, + template: ` +
    +

    Basic

    +
      +
    • /
    • +
    • /foo
    • +
    + +
    + ` + }).$mount('#app-' + n) + }) + + unmountEl.addEventListener('click', () => { + vueInstance.$destroy() + vueInstance.$el.innerHTML = '' + }) +}) diff --git a/examples/multi-app/index.html b/examples/multi-app/index.html new file mode 100644 index 000000000..96f094114 --- /dev/null +++ b/examples/multi-app/index.html @@ -0,0 +1,24 @@ + + +← Examples index + + + + + +
    + + + + + +
    + +popstate count: + +
    +
    +
    + + + \ No newline at end of file diff --git a/src/history/base.js b/src/history/base.js index 2b12e1967..7680edb6d 100644 --- a/src/history/base.js +++ b/src/history/base.js @@ -23,6 +23,8 @@ export class History { readyCbs: Array readyErrorCbs: Array errorCbs: Array + listeners: Array + cleanupListeners: Function // implemented by sub-classes +go: (n: number) => void @@ -30,6 +32,7 @@ export class History { +replace: (loc: RawLocation) => void +ensureURL: (push?: boolean) => void +getCurrentLocation: () => string + +setupListeners: Function constructor (router: Router, base: ?string) { this.router = router @@ -41,6 +44,7 @@ export class History { this.readyCbs = [] this.readyErrorCbs = [] this.errorCbs = [] + this.listeners = [] } listen (cb: Function) { @@ -208,6 +212,17 @@ export class History { hook && hook(route, prev) }) } + + setupListeners () { + // Default implementation is empty + } + + teardownListeners () { + this.listeners.forEach(cleanupListener => { + cleanupListener() + }) + this.listeners = [] + } } function normalizeBase (base: ?string): string { diff --git a/src/history/hash.js b/src/history/hash.js index 62b9a3472..b3372e10d 100644 --- a/src/history/hash.js +++ b/src/history/hash.js @@ -20,31 +20,40 @@ export class HashHistory extends History { // this is delayed until the app mounts // to avoid the hashchange listener being fired too early setupListeners () { + if (this.listeners.length > 0) { + return + } + const router = this.router const expectScroll = router.options.scrollBehavior const supportsScroll = supportsPushState && expectScroll if (supportsScroll) { - setupScroll() + this.listeners.push(setupScroll()) } - window.addEventListener( - supportsPushState ? 'popstate' : 'hashchange', - () => { - const current = this.current - if (!ensureSlash()) { - return - } - this.transitionTo(getHash(), route => { - if (supportsScroll) { - handleScroll(this.router, route, current, true) - } - if (!supportsPushState) { - replaceHash(route.fullPath) - } - }) + const handleRoutingEvent = () => { + const current = this.current + if (!ensureSlash()) { + return } + this.transitionTo(getHash(), route => { + if (supportsScroll) { + handleScroll(this.router, route, current, true) + } + if (!supportsPushState) { + replaceHash(route.fullPath) + } + }) + } + const eventType = supportsPushState ? 'popstate' : 'hashchange' + window.addEventListener( + eventType, + handleRoutingEvent ) + this.listeners.push(() => { + window.removeEventListener(eventType, handleRoutingEvent) + }) } push (location: RawLocation, onComplete?: Function, onAbort?: Function) { diff --git a/src/history/html5.js b/src/history/html5.js index e1cdba97d..b41fb5cde 100644 --- a/src/history/html5.js +++ b/src/history/html5.js @@ -8,24 +8,34 @@ import { setupScroll, handleScroll } from '../util/scroll' import { pushState, replaceState, supportsPushState } from '../util/push-state' export class HTML5History extends History { + _startLocation: string + constructor (router: Router, base: ?string) { super(router, base) + this._startLocation = getLocation(this.base) + } + + setupListeners () { + if (this.listeners.length > 0) { + return + } + + const router = this.router const expectScroll = router.options.scrollBehavior const supportsScroll = supportsPushState && expectScroll if (supportsScroll) { - setupScroll() + this.listeners.push(setupScroll()) } - const initLocation = getLocation(this.base) - window.addEventListener('popstate', e => { + const handleRoutingEvent = () => { const current = this.current // Avoiding first `popstate` event dispatched in some browsers but first // history route not updated since async guard at the same time. const location = getLocation(this.base) - if (this.current === START && location === initLocation) { + if (this.current === START && location === this._startLocation) { return } @@ -34,6 +44,10 @@ export class HTML5History extends History { handleScroll(router, route, current, true) } }) + } + window.addEventListener('popstate', handleRoutingEvent) + this.listeners.push(() => { + window.removeEventListener('popstate', handleRoutingEvent) }) } diff --git a/src/index.js b/src/index.js index e95ace6d1..d0fb34b7b 100644 --- a/src/index.js +++ b/src/index.js @@ -98,6 +98,12 @@ export default class VueRouter { // ensure we still have a main app or null if no apps // we do not release the router so it can be reused if (this.app === app) this.app = this.apps[0] || null + + if (!this.app) { + // clean up event listeners + // https://github.com/vuejs/vue-router/issues/2341 + this.history.teardownListeners() + } }) // main app previously initialized @@ -110,17 +116,11 @@ export default class VueRouter { const history = this.history - if (history instanceof HTML5History) { - history.transitionTo(history.getCurrentLocation()) - } else if (history instanceof HashHistory) { - const setupHashListener = () => { + if (history instanceof HTML5History || history instanceof HashHistory) { + const setupListeners = () => { history.setupListeners() } - history.transitionTo( - history.getCurrentLocation(), - setupHashListener, - setupHashListener - ) + history.transitionTo(history.getCurrentLocation(), setupListeners, setupListeners) } history.listen(route => { diff --git a/src/util/scroll.js b/src/util/scroll.js index c6c055a97..5693c17ee 100644 --- a/src/util/scroll.js +++ b/src/util/scroll.js @@ -19,12 +19,10 @@ export function setupScroll () { const stateCopy = extend({}, window.history.state) stateCopy.key = getStateKey() window.history.replaceState(stateCopy, '', absolutePath) - window.addEventListener('popstate', e => { - saveScrollPosition() - if (e.state && e.state.key) { - setStateKey(e.state.key) - } - }) + window.addEventListener('popstate', handlePopState) + return () => { + window.removeEventListener('popstate', handlePopState) + } } export function handleScroll ( @@ -86,6 +84,13 @@ export function saveScrollPosition () { } } +function handlePopState (e) { + saveScrollPosition() + if (e.state && e.state.key) { + setStateKey(e.state.key) + } +} + function getScrollPosition (): ?Object { const key = getStateKey() if (key) { diff --git a/test/e2e/specs/basic.js b/test/e2e/specs/basic.js index f5e6364eb..f4d7d6f51 100644 --- a/test/e2e/specs/basic.js +++ b/test/e2e/specs/basic.js @@ -70,6 +70,11 @@ module.exports = { .assert.cssClassPresent('li:nth-child(8)', 'exact-active') .assert.attributeEquals('li:nth-child(8) a', 'class', '') + // Listener cleanup + .assert.containsText('#popstate-count', '1 popstate listeners') + .click('#unmount') + .assert.containsText('#popstate-count', '0 popstate listeners') + .end() } } diff --git a/test/e2e/specs/hash-mode.js b/test/e2e/specs/hash-mode.js index be5df7d14..569285a23 100644 --- a/test/e2e/specs/hash-mode.js +++ b/test/e2e/specs/hash-mode.js @@ -57,6 +57,12 @@ module.exports = { .waitForElementVisible('#app', 1000) .assert.containsText('.view', 'unicode: ñ') .assert.containsText('#query-t', '%') + + // Listener cleanup + .assert.containsText('#popstate-count', '1 popstate listeners') + .click('#unmount') + .assert.containsText('#popstate-count', '0 popstate listeners') + .end() } } diff --git a/test/e2e/specs/multi-app.js b/test/e2e/specs/multi-app.js new file mode 100644 index 000000000..a6b0a2af1 --- /dev/null +++ b/test/e2e/specs/multi-app.js @@ -0,0 +1,49 @@ +const bsStatus = require('../browserstack-send-status') + +module.exports = { + ...bsStatus(), + + '@tags': ['history'], + + basic: function (browser) { + browser + .url('http://localhost:8080/multi-app/') + .waitForElementVisible('#mount1', 1000) + .assert.containsText('#popcount', '0') + .click('#mount1') + .waitForElementVisible('#app-1 > *', 1000) + .assert.containsText('#popcount', '1') + .click('#mount2') + .waitForElementVisible('#app-2 > *', 1000) + .assert.containsText('#popcount', '2') + .click('#mount3') + .waitForElementVisible('#app-3 > *', 1000) + .assert.containsText('#popcount', '3') + + // They should all be displaying the home page + .assert.containsText('#app-1', 'home') + .assert.containsText('#app-2', 'home') + .assert.containsText('#app-3', 'home') + + // Navigate to foo route + .click('#app-1 li:nth-child(2) a') + .assert.containsText('#app-1', 'foo') + + .click('#app-2 li:nth-child(2) a') + .assert.containsText('#app-2', 'foo') + + .click('#app-3 li:nth-child(2) a') + .assert.containsText('#app-3', 'foo') + + // Unmount all apps + .assert.containsText('#popcount', '3') + .click('#unmount1') + .assert.containsText('#popcount', '2') + .click('#unmount2') + .assert.containsText('#popcount', '1') + .click('#unmount3') + .assert.containsText('#popcount', '0') + + .end() + } +}