How to build a cross-browser history management system
When we built the history management feature in ASP.NET Futures, we spent considerable time experimenting with the different behaviors of the main browsers out there. The problem with such a feature is that it has to rely on a number of hacks because browser vendors basically never anticipated this need. Now they're thinking about it, so all this may be simplified in a few years, but in the meantime, it's a very complicated feature to build. One of the things that struck me was how little reliable literature is available on the subject. There is a lot of partial information, lots of false or unverified information, but very little that's really comprehensive, reliable and up to date. Good references I found include Brad Neuberg's Really Simple History and Handling Bookmarks and Back Button as well as Mike Stenhouse's Fixing the Back Button and Enabling Bookmarking for Ajax Applications. But it was a lot easier to just experiment directly on the different browsers and verify our theories directly.
This blog post will attempt to be an account of the difficulties we encountered and how we worked around them. It's also a brain dump that will help me and others maintain the feature in the long term. I hope it will evolve over time as feedback comes in, browser bugs get fixed and we ourselves learn more.
Note: all code in the pages attached to this post is pure JavaScript and XHTML and has no dependency to any script library.
How history used to work
It used to be the case that history in the browser just worked: the user navigated from page to page following links and the history stack just got filled, the back button worked as usual and life was good. With dynamic pages that post forms, things got a little less ideal as every post action would make a new entry, no matter if or how little the state of the page changed. Plus, you got this nasty "do you really want to repost this form" dialog that most users had no clue what to do with.
But Ajax applications broke the model in a more fundamental way because most actions are done without navigating away from the page or posting forms. This means that the browser basically has no way of knowing the state of the application changed in a meaningful way and nothing new gets in the history stack. Any naive user who doesn't know the implementation details of your application will expect the back button to work like it has for decades, which is catastrophic because by navigating back to the previous page it knows about, the browser will lose all the user's precious work.
Thanks to a few hacks such as the ones I will expose here, it doesn't have to be that way, and the control of the history stack can be given back to the application developer. We can even make the model better than it used to be and choose exactly what constitutes a meaningful enough state change to warrant an entry in history.
Navigating without moving
The main trick that history managers use is to have the browser believe the user navigated to a new url without the current page and all its JavaScript and DOM state being thrown away. The only part of the url that enables such a thing is the hash part. The hash part is what comes at the end of the url after a pound (#) sign. The original intent of this part of the url was to allow for navigation inside of the document. You would put a special named, href-less anchor tag in your document, and then navigating to #nameOfTheAnchor would just scroll the anchor into view. The page doesn't get reloaded, but it does enter the browser history (almost, we'll see about that in a minute). This is great for long pages with a table of contents for example.
But it's also great in that it's a way for us to manipulate the history stack without unloading the page, which is exactly what we need. So this is one more example of using something for a totally different use than what it's been designed for, but what choice do we have? And it's actually all right as this new usage doesn't conflict with the old one.
So it seems like we have an easy way to get this to work: whenever you want to create a history point, just navigate the page to #SerializedFormOfTheStateOfTheApplication; detect url changes somehow (polling works (almost) everywhere) and re-hydrate the application state from that. That should work, right? Yes, it should, but it doesn't, except on Firefox and Safari 3 for the Mac, for various reasons.
I've prepared a page that implements exactly that logic that
you can try here:
HistoryHash.htm.txt
(download and rename HistoryHash.htm to run on your system)
Here's a summary of the results on some popular browsers:
|
|
IE 7 |
Firefox 2 |
Opera 9 |
Safari 2 Mac |
Safari 3 Windows beta |
Safari 3 Mac beta |
| URL changes | Yes | Yes | Yes | Yes | Yes | Yes |
| Creates history entry | No | Yes | Yes | Yes | Not on first page | Yes |
| Back doesn't kill timers | Yes | Yes |
Not in 9.23 and 9.24 Yes in 9.10 |
Yes | Yes | Yes |
| Back enables forward | Yes | Yes | Yes | Yes | Sometimes | Yes |
| location.hash reflects changes in the url | Yes | Yes | Yes | No | Yes | Yes |
Making it work in IE
The problem in IE comes from the fact that it doesn't create
a history entry when the hash changes. This can be worked
around by navigating an iframe in addition to changing the
hash as IE does create a new history point every time a
frame is navigated. The problem with that technique is that
just like the main page, the iframe can't be just navigated
to a new hash, it needs to move to a different page, which
means a round-trip to the server on every new state, which
is quite wasteful. I've heard of techniques to create the
iframe's contents through clever scripting in the iframe url
and document.writing, but I've never been able to consistently reproduce
it.
<update date="Sep. 14 2007">
One
of the
comments over at Ajaxian
pointed me to
this great article from Julien Lecomte, where he explains how they built the feature over at
YahooUI. It's a super-interesting read and thanks to it, the
document.writing trick suddenly clicked together for me. The
trick is that you need to open the document before you write
to it and close it when you're done if you want the history
point to be created by IE. Somehow I had missed that but
today I tried again and had no difficulty making it work.
The only thing that may be a little tricky is that you need
to pass a url into document.open or the browser will default
to about:blank. That may look ok until you try it under
https, in which case you're going to get the infamous dialog
about mixed contents. This can be solved by using the usual
magical url javascript:'<html></html>' (thanks
to the
toolkit team
for that trick).
Please note that now we don't even have a single request
for the frame, even on the first hit, again thanks to the
magical url.
It's also important to note that the iframe
must absolutely be in the static markup of the page (you
can't create it dynamically) and on the first request it
must point to an existing page on the server.
I've
updated the sample page for IE below to include this trick
and I'll also integrate it into ASP.NET Ajax.
</update>
The frame gets the state passed to it through the search part of the url (the part after a question mark). It then calls back into a function in the main document when it loads. When the user navigates back to a previous state, it's the iframe that really navigates back, not the main page. When it does, its load event fires, which calls back into the main page again. From that callback function, we set the hash so that the browser url reflects the state for bookmarkability, and of course we update the application with that state.
Here's a simplified implementation of this. I didn't hide
the iframe to make clearer what is happening here but that's
of course what you'd do in production code.
HistoryIE.htm.txt
(download and rename HistoryIE.htm to run on your system)
You
will also need the iframe file:
HistoryFrame.htm.txt
(download and rename HistoryFrame.htm to run on your system)
<update date="Mar. 31 2008">
Internet Explorer 8 finally gets rid of the need for
the iframe navigation: changing the url fragment now results
in a new entry being created in browser history. We were
able to make our implementation of history work in IE8 by
just restricting the iframe code to versions earlier than 8.
Internet Explorer now follows exactly the same code path as
Firefox, Safari 3 and Opera 9.5. Not all is perfect though
as IE still has this weird quirk where when navigating away
from the history-managed page causes its history entries to
collapse to just one entry. If you do go back, it will
expand again but this is really confusing to the users.
On
the bright side, IE is the only browser as I write that
implements an HTML5 event that gets triggered when the url
fragment changes, eliminating the need to poll. I really
hope the other browser follow that lead...
</update>
Making it work in Safari 2
While the workaround for IE results in a completely different implementation, at least it makes some sense and is consistent. With Safari, we're lost in bugland. In Safari 2, monitoring the hash from a timer doesn't work as location.hash is not getting updated as the url in the browser's address bar changes. This is a plain bug that is fixed in Safari 3 and the current WebKit builds. The only information we have that gets correctly updated when the user navigates through history is history.length. In other words, we know the index of the history point but we don't know the corresponding state. One thing we can do to work around this is to maintain our own array of states and use the index to retrieve the right state from this array. One caveat is that if we use a JavaScript variable to store this array, navigating to a different page will completely wipe out the state array and kill the history feature. This can be partially worked around by storing the information into a hidden form field instead of a JavaScript variable: form field values are restored by the browser when navigating through history. This works relatively well to maintain history even if the user navigates away to a different page and comes back but what it doesn't handle on the other hand is the user pressing F5.
Here's an implementation of those workarounds (I've left the
history stack field visible to make it easier to understand
what happens but of course in a real application it would be
idden):
HistorySafari2.htm.txt
(download and rename HistorySafari2.htm to run on your
system)
This must be fixed in Safari 3, right?
Well, yes, Safari 3 Mac does the right thing as the unmodified hash code that works on Firefox just works. But on Windows currently other bugs cripple the feature. In the current nightly build and public beta of Safari 3 for Windows, if your page is the first that you load in the browser, no history entries get created when the hash is modified. Another weird bug that I couldn't quite find reliable repro steps for is that under some circumstances, hitting back does not enable forward, making time travel only possible to the past, without any hope of return. Somebody give them a flux capacitor, an old clock and a thunderstorm... Because of this, the Safari 3 for Windows implementation kind of works sometimes but it really needs to be fixed. I filed bug 14080 in WebKit a while ago to track this. Seriously, I don't blame them, it's a beta, betas have bugs and I'm pretty confident this will get fixed pretty soon. I've had pretty good experience with their reactivity in the past.
What about Opera then?
Well, it used to work perfectly well in Opera up until
version 9.10. Then they broke it. In 9.23, and also in 9.24
which is the current version as I'm writing this (not
a beta), for some reason Opera cancels all timers when
hitting back. This completely breaks any history manager
because there is now no way of knowing the user navigated.
This problem
has been reported by several persons on the Opera
forums
and apparently there's a bug open for it, but the closedness of Opera's bug database doesn't
allow us to monitor the status of the issue.
<update date="Sep. 17 2007">
Neil Jenkins
pointed me to a nice trick he's come up with to work around
this bug. The idea is to have a hidden image with the
following src attribute:
javascript:location.href='javascript:onTick();';
Amazingly,
this works and the code gets executed when the user hits
back. Just doing javascript:onTick(); directly doesn't work
for some reason that I don't quite understand. Apparently,
Opera tries to be smart about what can be run as an img src,
but it's easily worked around. If you ask me, script should
simply not be allowed as image urls (as well as in a number
of places) but I suppose that would break too many
hacksapplications.
Anyway, the
workaround does work, and it's simple enough that you should
use it if you need to support Opera 9.23 and 9.24. But you
also need to know that
this is fixed in Opera 9.5
(still in beta as I'm writing this) so it may not be worth
fixing in the long run. Kudos to Opera for their reactivity
on this issue.
HistoryOpera.htm.txt
(download and rename HistoryOpera.htm to run on your
system).
</update>
The state of things
So things are in a pretty grim state currently. It seems like we're going back (pun intended). We used to have a collection of tricks that made possible an implementation of a history manager that worked pretty well in IE, Firefox, Opera and Safari. Now, we only have IE, Firefox and Safari Mac. I just hope this is only temporary and that both Apple and Opera repair their browsers soon.
<update date="Sep. 17 2007">
As
explained in the previous section, there's now a known
workaround for the Opera bug, and the bug itself will be
fixed in 9.5.
</update>
<update date="Feb. 18 2008">
The
WebKit bug
seems to be fixed.
</update>
<update date="Apr. 18 2008">
Apparently I forgot to mention one annoying bug in
Firefox that url-decodes the hash automatically, which gets
in the way of using the hash as a querystring-like state
bag. More details in the bug comments here:
https://bugzilla.mozilla.org/show_bug.cgi?id=378962#c4.
</update>
<update date="Dec. 22 2008">
Some great additional information on ways to help refresh
behave better on IE can be found here:
http://www.overset.com/2008/12/21/internet-explorer-iframe-browser-history-lost-on-reload/
</update>
What's next?
In the next posts, I'll get into more details about the initial request, maintaining meaningful titles and mixing all this with asynchronous operations, which is where it really gets tricky.