diff --git a/packages/fluxible-router/docs/api/handleHistory.md b/packages/fluxible-router/docs/api/handleHistory.md index b426a006..6fb8583f 100644 --- a/packages/fluxible-router/docs/api/handleHistory.md +++ b/packages/fluxible-router/docs/api/handleHistory.md @@ -9,6 +9,7 @@ The `handleHistory` higher-order component handles the browser history state man | `checkRouteOnPageLoad` | `false` | Performs navigate on first page load | | `enableScroll` | `true` | Saves scroll position in history state | | `historyCreator` | [`History`](../../lib/History.js) | A factory for creating the history implementation | +| `ignorePopstateOnPageLoad` | `false` | A boolean value or a function that returns a boolean value. [Browsers tend to handle the popstate event differently on page load. Chrome (prior to v34) and Safari always emit a popstate event on page load, but Firefox doesn't.](https://developer.mozilla.org/en-US/docs/Web/Events/popstate) This flag is for ignoring popstate event triggered on page load if that causes issue for your application, as reported in [issue #349](https://github.com/yahoo/fluxible/issues/349). | ## Example Usage diff --git a/packages/fluxible-router/lib/handleHistory.js b/packages/fluxible-router/lib/handleHistory.js index bc3368ed..8bce8a65 100644 --- a/packages/fluxible-router/lib/handleHistory.js +++ b/packages/fluxible-router/lib/handleHistory.js @@ -5,7 +5,7 @@ /*global window */ 'use strict'; var React = require('react'); -var debug = require('debug')('RoutingContainer'); +var debug = require('debug')('FluxibleRouter:handleHistory'); var handleRoute = require('../lib/handleRoute'); var navigateAction = require('../lib/navigateAction'); var History = require('./History'); @@ -22,7 +22,8 @@ var defaultOptions = { enableScroll: true, historyCreator: function () { return new History(); - } + }, + ignorePopstateOnPageLoad: false }; // Begin listening for popstate so they are not missed prior to instantiation @@ -44,6 +45,14 @@ var historyCreated = false; function createComponent(Component, opts) { var options = Object.assign({}, defaultOptions, opts); + function shouldIgnorePopstateOnPageLoad() { + var ignore = options.ignorePopstateOnPageLoad; + if ('function' === typeof ignore) { + return ignore(); + } + return !!ignore; + } + function HistoryHandler(props, context) { React.Component.apply(this, arguments); } @@ -77,6 +86,15 @@ function createComponent(Component, opts) { this._saveScrollPosition = this.constructor.prototype._saveScrollPosition.bind(this); this._history = options.historyCreator(); + + this._ignorePageLoadPopstate = shouldIgnorePopstateOnPageLoad(); + if (this._ignorePageLoadPopstate) { + // populate the state object, so that all pages loaded will have a non-null + // history.state object, which we can use later to distinguish pageload popstate + // event from regular popstate events + this._history.replaceState(this._history.getState() || {}); + } + this._scrollTimer = null; if (options.checkRouteOnPageLoad) { @@ -119,22 +137,30 @@ function createComponent(Component, opts) { this._scrollTimer = window.setTimeout(this._saveScrollPosition, 150); }, _onHistoryChange: function (e) { + debug('history listener invoked', e); + if (this._ignorePageLoadPopstate) { + // 1) e.state (null) and history.state (not null) + // -- this is popstate triggered on pageload in Safari browser. + // history.state is not null, because if _ignorePageLoadPopstate + // is true, we replaceState in componentDidMount() to set state obj + // 2) e.state(not null) and history.state (not null) + // -- regular popstate triggered by forward/back button click and history.go(n) + // 3) history.state (null) + // -- this is not a valid scenario, as we update the state before + // _onHistoryChange gets invoked in componentDidMount() + var stateFromHistory = this._history.getState(); + var isPageloadPopstate = e.state === null && !!stateFromHistory; + debug('history listener detecting pageload popstate', e.state, stateFromHistory); + if (isPageloadPopstate) { + debug('history listener skipped pageload popstate'); + return; + } + } var props = this.props; var url = this._history.getUrl(); var currentRoute = props.currentRoute || {}; var nav = props.currentNavigate || {}; - // Add currentNavigate.externalUrl checking for https://github.com/yahoo/fluxible/issues/349: - // "Safari popstate issue causing handleHistory.js to execute the navigateAction on page load". - // This needs app to dispatch "externalUrl" as part of the payload for the NAVIGATE_START event - // on server side, which contains the absolute url user sees in browser when the request is made. - // For client side navigation, "externalUrl" field is not needed and is not set by fluxible-router. - var externalUrl = nav.externalUrl; - if (externalUrl && externalUrl === window.location.href.split('#')[0]) { - // this is the initial page load, omit the popstate event erroneously fired by Safari browsers. - return; - } - var currentUrl = currentRoute.url; var onBeforeUnloadText = typeof window.onbeforeunload === 'function' ? window.onbeforeunload() : ''; @@ -151,7 +177,7 @@ function createComponent(Component, opts) { var pageTitle = navParams.pageTitle || null; - debug('history listener invoked', e, url, currentUrl); + debug('history listener url, currentUrl:', url, currentUrl, this.props); if (!confirmResult) { // Pushes the previous history state back on top to set the correct url @@ -249,6 +275,14 @@ function createComponent(Component, opts) { * @param {boolean} opts.enableScroll=true Scrolls to saved scroll position in history state; * scrolls to (0, 0) if there is no scroll position saved in history state. * @param {function} opts.historyCreator A factory for creating the history implementation + * @param {boolean|function} opts.ignorePopstateOnPageLoad=false A boolean value or a function that + * returns a boolean value. Browsers tend to handle the popstate event + * differently on page load. Chrome (prior to v34) and Safari always emit + * a popstate event on page load, but Firefox doesn't + * (https://developer.mozilla.org/en-US/docs/Web/Events/popstate) + * This flag is for ignoring popstate event triggered on page load + * if that causes issue for your application, as reported in + * https://github.com/yahoo/fluxible/issues/349. * @returns {React.Component} */ module.exports = function handleHistory(Component, opts) { diff --git a/packages/fluxible-router/tests/unit/lib/handleHistory-test.js b/packages/fluxible-router/tests/unit/lib/handleHistory-test.js index 54d1bbe0..a996d0b3 100644 --- a/packages/fluxible-router/tests/unit/lib/handleHistory-test.js +++ b/packages/fluxible-router/tests/unit/lib/handleHistory-test.js @@ -264,6 +264,55 @@ describe('handleHistory', function () { done(); }, 10); }); + it('execute navigation action for pageload popstate when ignorePopstateOnPageLoad is false', function (done) { + var routeStore = mockContext.getStore('RouteStore'); + routeStore._handleNavigateStart({url: '/foo', method: 'GET'}); + var MockAppComponent = mockCreator({ + checkRouteOnPageLoad: false, + historyCreator: function () { + return historyMock('/browserUrl', {a: 1}); + }, + ignorePopstateOnPageLoad: false + }); + + // simulate page load popstate + window.dispatchEvent(Object.assign(new Event('popstate'), {state: null})); + + ReactTestUtils.renderIntoDocument( + + ); + + setTimeout(function() { + expect(mockContext.executeActionCalls.length).to.equal(1); + expect(mockContext.executeActionCalls[0].action).to.be.a('function'); + expect(mockContext.executeActionCalls[0].payload.type).to.equal('popstate'); + expect(mockContext.executeActionCalls[0].payload.url).to.equal('/browserUrl'); + done(); + }, 150); + }); + it('skip navigation action for pageload popstate when ignorePopstateOnPageLoad is true', function (done) { + var routeStore = mockContext.getStore('RouteStore'); + routeStore._handleNavigateStart({url: '/foo', method: 'GET'}); + var MockAppComponent = mockCreator({ + checkRouteOnPageLoad: false, + historyCreator: function () { + return historyMock('/browserUrl', {a: 1}); + }, + ignorePopstateOnPageLoad: true + }); + + // simulate page load popstate + window.dispatchEvent(Object.assign(new Event('popstate'), {state: null})); + + ReactTestUtils.renderIntoDocument( + + ); + + setTimeout(function() { + expect(mockContext.executeActionCalls.length).to.equal(0); + done(); + }, 150); + }); describe('window.onbeforeunload', function () { beforeEach(function () { global.window.confirm = function () { return false; };