Back-button problem
We face tons of problems while creating ajax-based RIA. It seems like the most popular is so called ‘back-button problem’.
I’m going to share my solution of this problem. I hope it will help you.
The idea is very simple. We have the object, whose job is to load required content to the main container. Lets name it PageFlow. PageFlow has a public method loadPage that accepts a pageName hash as a parameter and loads the content according to the pageName.
There is also an object named History, which is initialized immediately after the page load. History determines the anchor part of the current page URL (the part that goes after #) and finds the corresponding pageName hash. Then it calls pageFlow.loadPage with pageName as a parameter. History object also memorizes when user cliks on anchored links.
So the seqeuence will look like this: when user clicks on the link with the item’s id, History object is invoked. History then calls PageFlow.loadPage(), which loads the content to the page. If user clicks on backward or forward button, everything will work as expected.
Now let’s assume we are developing a shop that has a page with a list of all items (anchor ‘all’) and a details page for each item (anchor ‘id’). Our framework of choice is jQuery.
/**
* History managment, for ajax-based pages
* @class History
* @constructor
*/
History = function () {
var
/**
* @property currentHash
* @private
*/
currentHash,
/**
* @property _callback
* @private
*/
_callback,
historyBackStack,
historyForwardStack,
isFirst,
dontCheck,
check = function () {
var i, hash;
if($.browser.msie) {
// On IE, check for location.hash of iframe
var ihistory = $("#APHistory")[0];
var iframe = ihistory.contentDocument || ihistory.contentWindow.document;
hash = iframe.location.hash;
if(hash != currentHash) {
location.hash = hash;
currentHash = hash;
_callback(hash.replace(/^#/, ''));
}
} else if ($.browser.safari) {
if (dontCheck) {
var historyDelta = history.length - historyBackStack.length;
if (historyDelta) { // back or forward button has been pushed
isFirst = false;
if (historyDelta = 0) {
_callback(document.URL.split('#')[1]);
} else {
_callback('');
}
isFirst = true;
}
}
} else {
// otherwise, check for location.hash
hash = location.hash;
if(hash != currentHash) {
currentHash = hash;
_callback(hash.replace(/^#/, ''));
}
}
};
return {
initialize : function (callback) {
_callback = callback;
currentHash = location.hash;
if ($.browser.msie) {
// To stop the callback firing twice during initilization if no hash present
if (currentHash == '') {
currentHash = '#';
}
// add hidden iframe for IE
$("body").prepend('');
var iframe = $("#APHistory")[0].contentWindow.document;
iframe.open();
iframe.close();
iframe.location.hash = currentHash;
} else if ($.browser.safari) {
// etablish back/forward stacks
historyBackStack = [];
historyBackStack.length = history.length;
historyForwardStack = [];
isFirst = true;
dontCheck = false;
}
_callback(currentHash.replace(/^#/, ''));
setInterval(check, 100);
},
add : function (hash) {
// This makes the looping function do something
historyBackStack.push(hash);
historyForwardStack.length = 0; // clear forwardStack (true click occured)
isFirst = true;
},
/**
*
* @param hash {String} desiring hash without first #
*/
load: function(hash) {
var newhash;
if ($.browser.safari) {
newhash = hash;
} else {
newhash = '#' + hash;
location.hash = newhash;
}
currentHash = newhash;
if ($.browser.msie) {
var ihistory = $("#APHistory")[0]; // TODO: need contentDocument?
var iframe = ihistory.contentWindow.document;
iframe.open();
iframe.close();
iframe.location.hash = newhash;
_callback(hash);
}
else if ($.browser.safari) {
dontCheck = true;
// Manually keep track of the history values for Safari
this.add(hash);
// Wait a while before allowing checking so that Safari has time to update the "history" object
// correctly (otherwise the check loop would detect a false change in hash).
var fn = function() {AP.History.setCheck(false);};
window.setTimeout(fn, 200);
_callback(hash);
// N.B. "location.hash=" must be the last line of code for Safari as execution stops afterwards.
// By explicitly using the "location.hash" command (instead of using a variable set to "location.hash") the
// URL in the browser and the "history" object are both updated correctly.
location.hash = newhash;
}
else {
_callback(hash);
}
},
/**
* Set need we check, or not.
* @param check {Boolean}
* @protected
*/
setCheck : function (check) {
dontCheck = check;
},
/**
* @method getCurrentHash
* @return {String}
*/
getCurrentHash : function () {
return currentHash;
}
};
}();
PageFlow object, that I describe above looks like this:
/**
* Page Flow controller - load hash-specific data, show appropriate container and all that
* @Class PageFlow
*/
var PageFlow = function () {
/**
* show whole list of goods
* @method loadListOfGoods
* @private
*/
loadListOfGoods = function () {
$('#goodsItemDetails').css('display', 'none');
$('#listOfGoodsWorkArea').css('display', 'block');
},
/**
* show detailed goods item view
* @method loadGoodsItemDetails
* @private
*/
loadGoodsItemDetails = function (id) {
var item = M.ListOfGoods.getGoodsItemById(id);
if (item.pluralizedProfit.length == 0) {
item.pluralizedProfit = item.pluralizedPrice;
}
// fill container with appropriate data
M.Renderer.renderGoodsItemDetails([item]);
// show goodsItemDetails
$('#goodsItemDetails').css('display', 'block');
$('#listOfGoodsWorkArea').css('display', 'none');
},
return {
/**
* decide what page to load
* @method loadPage
* @param pageName {String|Number} location.hash with stripped `#` sign
* @public
*/
loadPage : function (pageName) {
if (L.isUndefined(pageName)) {
if (M.ClientURI.isMainPage()) {
loadListOfGoods();
}
}
// if pageName is number
if (L.isNumber(pageName) || pageName.replace(/\d+/, '').length == 0) {
// need to load goods item details page with provided id
loadGoodsItemDetails(pageName);
} else {
switch (pageName) {
case 'all':
loadListOfGoods();
break;
}
}
}
};
}();
Things are getting simple now.
History object is initialized on page load, given PageFlow.loadPage as a callback parameter. All links where ‘rel’ attribute is ‘history’ are assigned an event listener that calls History.load istead of default jumping to the anchor.
$(function () {
$('a @rel=[history]').click(function () {
History.load(this.href.replace(/^.*#/, ''));
return false;
});
History.initialize(PageFlow.loadPage);
});