diff --git a/server/camlistored/ui/blob_item_container_react.js b/server/camlistored/ui/blob_item_container_react.js index b5a02544f..aac4c4ab0 100644 --- a/server/camlistored/ui/blob_item_container_react.js +++ b/server/camlistored/ui/blob_item_container_react.js @@ -379,7 +379,7 @@ cam.BlobItemContainerReact = React.createClass({ // NOTE: This method causes the URL bar to throb for a split second (at least on Chrome), so it should not be called constantly. updateHistory_: function() { - this.props.history.replaceState({scroll:this.state.scroll}); + this.props.history.replaceState(cam.object.extend(this.props.history.state, {scroll:this.state.scroll})); }, fillVisibleAreaWithResults_: function() { diff --git a/server/camlistored/ui/index.js b/server/camlistored/ui/index.js index 42c7668c7..15e629ccc 100644 --- a/server/camlistored/ui/index.js +++ b/server/camlistored/ui/index.js @@ -101,9 +101,11 @@ cam.IndexPage = React.createClass({ this.baseURL_ = newURL.resolve(new goog.Uri(this.props.config.uiRoot)); this.navigator_ = new cam.Navigator(this.props.eventTarget, this.props.location, this.props.history); - this.navigator_.onNavigate = this.handleNavigate_; + this.navigator_.onWillNavigate = this.handleWillNavigate_; + this.navigator_.onDidNavigate = this.handleDidNavigate_; - this.handleNavigate_(newURL); + this.handleWillNavigate_(newURL); + this.handleDidNavigate_(); }, componentDidMount: function() { @@ -165,6 +167,10 @@ cam.IndexPage = React.createClass({ }, setSelection_: function(selection) { + this.props.history.replaceState(cam.object.extend(this.props.history.state, { + selection: selection, + }), '', this.props.location.href); + this.setState({selection: selection}); this.setState({sidebarVisible: !goog.object.isEmpty(selection)}); }, @@ -339,7 +345,7 @@ cam.IndexPage = React.createClass({ } }, - handleNavigate_: function(newURL) { + handleWillNavigate_: function(newURL) { if (!goog.string.startsWith(newURL.toString(), this.baseURL_.toString())) { return false; } @@ -349,10 +355,14 @@ cam.IndexPage = React.createClass({ this.updateChildSearchSession_(targetBlobref, newURL); this.pruneSearchSessionCache_(); this.setState({currentURL: newURL}); - this.setSelection_({}); return true; }, + handleDidNavigate_: function() { + var s = this.props.history.state && this.props.history.state.selection; + this.setSelection_(s || {}); + }, + updateTargetSearchSession_: function(targetBlobref) { if (targetBlobref) { this.targetSearchSession_ = this.getSearchSession_(targetBlobref, {blobRefPrefix: targetBlobref}); @@ -630,7 +640,8 @@ cam.IndexPage = React.createClass({ } var blobref = goog.object.getAnyKey(this.state.selection); - if (this.childSearchSession_.getMeta(blobref).camliType != 'permanode') { + var m = this.childSearchSession_.getMeta(blobref); + if (!m || m.camliType != 'permanode') { return null; } diff --git a/server/camlistored/ui/navigator.js b/server/camlistored/ui/navigator.js index b2b040772..19bb3adb1 100644 --- a/server/camlistored/ui/navigator.js +++ b/server/camlistored/ui/navigator.js @@ -29,7 +29,11 @@ cam.Navigator = function(win, location, history) { this.location_ = location; this.history_ = history; this.handlers_ = []; - this.updateState_('replace', location.href); + + // This is needed so that in handlePopState_, we can differentiate navigating back to this frame from the initial load. + // We can't just initialize to {} because there can already be interesting state (e.g., in the case of the user pressing the refresh button). + history.replaceState(cam.object.extend(history.state), '', location.href); + this.win_.addEventListener('click', this.handleClick_.bind(this)); this.win_.addEventListener('popstate', this.handlePopState_.bind(this)); }; @@ -55,10 +59,16 @@ cam.Navigator.shouldHandleClick = function(e) { }; // Client should set this to handle navigation. -// If this method returns true, then Navigator considers the navigation handled locally, and will add an entry to history using pushState(). If this method returns false, Navigator lets the navigation fall through to the browser. -// @param goog.Uri newURL The URL to navigate to. At this point location.href has already been updated - this is just the parsed representation. +// +// This is called before the navigation has actually taken place: location.href will refer to the old URL, not the new one. Also, history.state will refer to previous state. +// +// If client returns true, then Navigator considers the navigation handled locally, and will add an entry to history using pushState(). If this method returns false, Navigator lets the navigation fall through to the browser. +// @param goog.Uri newURL The URL to navigate to. // @return boolean Whether the navigation was handled locally. -cam.Navigator.prototype.onNavigate = function(newURL) {}; +cam.Navigator.prototype.onWillNavigate = function(newURL) {}; + +// Called after a local (pushState) navigation has been performed. At this point, location.href and history.state have been updated. +cam.Navigator.prototype.onDidNavigate = function() {}; // Programmatically initiate a navigation to a URL. Useful for triggering navigations from things other than hyperlinks. // @param goog.Uri url The URL to navigate to. @@ -92,6 +102,9 @@ cam.Navigator.prototype.handleClick_ = function(e) { // Handles navigation via popstate. cam.Navigator.prototype.handlePopState_ = function(e) { + // WebKit and older Chrome versions will fire a spurious initial popstate event after load. + // We can differentiate this event from ones corresponding to frames we generated ourselves with pushState() or replaceState() because our own frames always have a non-empty state. + // See: http://stackoverflow.com/questions/6421769/popstate-on-pages-load-in-chrome if (!e.state) { return; } @@ -101,24 +114,13 @@ cam.Navigator.prototype.handlePopState_ = function(e) { }; cam.Navigator.prototype.dispatchImpl_ = function(url, addState) { - if (this.onNavigate(url)) { + if (this.onWillNavigate(url)) { if (addState) { - this.updateState_('push', url.toString()); + // Pass an empty object rather than null or undefined so that we can filter out spurious initial popstate events in handlePopState_. + this.history_.pushState({}, '', url.toString()); } + this.onDidNavigate(); return true; } return false; }; - -// @param {string} type 'push' or 'replace', the type of state modification to do. -// @param {string} url The URL to update the history state to. -cam.Navigator.prototype.updateState_ = function(type, url) { - var f = (type == 'push' && this.history_.pushState) || (type == 'replace' && this.history_.replaceState) || null; - if (!f) { - throw new Error('Unexpected type: ' + type); - } - - // The empty object is needed to differentiate between the initial load and subsequent navigations because browsers. - // It is passed back to us in e.state in handlePopState_. See: http://stackoverflow.com/questions/6421769/popstate-on-pages-load-in-chrome - f.call(this.history_, {}, '', url); -}; diff --git a/server/camlistored/ui/navigator_test.js b/server/camlistored/ui/navigator_test.js index bb58b61a6..4da1c8c64 100644 --- a/server/camlistored/ui/navigator_test.js +++ b/server/camlistored/ui/navigator_test.js @@ -60,7 +60,7 @@ describe('cam.Navigator', function() { mockHistory = new MockHistory(); handler = new Handler(); navigator = new cam.Navigator(mockWindow, mockLocation, mockHistory); - navigator.onNavigate = handler.handle; + navigator.onWillNavigate = handler.handle; }); it ('#constructor - seed initial state', function() { @@ -69,7 +69,7 @@ describe('cam.Navigator', function() { it('#navigate - no handler', function() { // We should do network navigation. - navigator.onNavigate = function(){}; + navigator.onWillNavigate = function(){}; navigator.navigate(url); assert.equal(mockLocation.href, url.toString()); assert.equal(mockHistory.states.length, 1);