17.6. Unique URLsAddress, Bookmarks, Cut-And-Paste, Distinct, Favorites, Hyperlink, Location, Link, Mail, Share, Unique, URL Figure 17-18. Unique URLs17.6.1. Goal StorySasha is exploring a recent news event by browsing a map of the area. There's a Unique URL for each location, so she's able to link to it in her blog and add it to her social bookmarking account. 17.6.2. ProblemHow can you assign distinct URLs to different parts of your application? 17.6.3. Forces
17.6.4. SolutionProvide Unique URLs for significant application states. Each time a significant state change occurse.g., the user opens a new product in an e-commerce systemthe application's URL changes accordingly. The objective is straightforward, but as discussed later in this section, the implementation requires a few unusual tricks. This Solution is fairly complex, because it delves into the details of building Unique URLs. Increasingly, though, reusable libraries will save you the effort, so be sure to check out what's available before directly applying any of the techniques here. The Real-World Examples identifies a couple of resources, and there will doubtless be others by the time you read this. In Ajax, most server communication occurs with XMLHttpRequest. Since XMLHttpRequest Calls don't affect the page URL, the URL is, by default, permanently stuck on the same URL that was used to launch the Ajax App, no matter how many transfers occur. The only option you have to manipulate the URL is to use JavaScript. And, in fact, it's very easy to do that with the window.location.href property: window.location.href = newURL; // Caution - Read on before using this. Very easy, but there's one big problem: under normal circumstances, the browser will automatically clear the page and load the new URL, which sort of defeats the purpose of using Ajax in the first place. Fortunately, there's a cunning workaround you can use. It was originally conceived in the Flash world and has more recently been translated to an Ajax context. Mike Stenhouse's demo and article (http://www.contentwithstyle.co.uk/Articles/38/fixing-the-back-button-and-enabling-bookmarking-for-ajax-apps) provides a good summary of the approach, and the Solution here is based heavily on his work. The cunning workaround relies on fragment identifiers those optional components of URLs that follow the hash character (#). In http://ajaxpatterns.org/Main_Page#Background, for example, the fragment identifier is Background. The point of fragment identifiers has traditionally been to get browsers scrolling to different points on a page, which is why they are sometimes called "in-page links" or "named links." If you're looking at the Table of Contents on AjaxPatterns.org (http://ajaxpatterns.org#toc) and you click on the Background linkhttp://ajaxpatterns.org/#Backgroundthe address bar will update and the browser will scroll to the Background section. Here's the point: no reload actually occurs. And yet, the browser behaves as if you clicked on a standard link: the URL in the address bar changes and the Back button sends you back from whence you came (at least for some browsers; more on portability later). You can probably see where this is going. We want Ajax Apps to change the page URL without forcing a reload, and it turns out that changes to fragment identifiers do exactly that. The solution, then, is for our script to change only the fragment component. We could do this by munging the window.location.href property, but there's actually a more direct route: window.location.hash. So when a state change is significant enough, just adjust the URL property: window.location.hash = summary; Throughout this pattern, summary refers to a string representation of the current browser stateat least that portion of the state that's worth keeping. For example, imagine that you have an Ajax interface containing several tabs: Books, Songs, and Movies. You want to assign a Unique URL to each tab, so the summary will be the name of any of these tabs. If there were other significant variables, they would be integrated into the summary string. To keep the fragment synchronized with the current state, you need to identify when state changes occur and update the fragment accordingly. In the following tab example, you can do this by making the onclick handler set the hash property in addition to changing the actual content: songTab.onclick = function( ) { window.location.hash = "songs". // ... now open up the tab } So now the URL changes from http://ajax.shop/ to http://ajax.shop/#songs. That was easy enough, but what happens when you mail the URL to a friend? That friend's browser will open up http://ajax.shop and assume songs is an innocent, old-fashioned, in-page link. It will try to scroll to a songs element and won't find one, but it won't complain about it either; it will simply show the main page as before. We've made the URL change after a state change, but so far we haven't built in any logic to make the state change after a URL change. State and URL need to be in sync. If one changes, the other must change as well. Effectively, there's a mini-protocol specific to your application that defines how state and URL map to each other. In the preceding example, the protocol says "The fragment always represents the currently open tab." Setting the fragment is one side of the protocol; the other side is reading it. After your friend opens http://ajax.shop/#songs, and after his browser unsuccessfully tries to scroll to the nonexistent songs element, we need to read the fragment and update the interface accordingly: window.onload = function( ) { initializeStateFromURL( ); } initializeStateFromURL( ) { var initialTab = window.location.hash; openTab(initialTab); } Good. Now you can mail the link, bookmark it, and link to it from another page. In some browsers at least, it will also go into your history, which means the Back button will work as expected. For a simple implementation, that's good enough. However, the solution still isn't ideal, because what will happen if the application is already loaded? The most likely situations occur when clicking the Back button to revert to a previous state and when retrieving a bookmark while already on that application. In both cases, the URL will change in the address bar, but the application will do absolutely nothing! Remember, we're making the application manipulate fragments precisely because the browser doesn't reload the page when they change. When it comes to Back buttons and bookmarks and the like, we're talking about changes that are initiated by the user, yet the browser treats these with exactly the same inaction as when our script initiates them. So our onload won't be called and the application won't be affected in anyway. To make Unique URLs work properly, we have to resort to something of a cheap trick: continuously polling the fragment property. Whenever the fragment happens to change, the browser script will notice a short time later and update state accordingly. In this example, we could schedule a check every second using setInterval( ): window.onload = function( ) { initializeStateFromURL( ); setInterval(initializeStateFromURL, 1000); } In fact, we should only perform an action if the hash has recently changed, so we perform a check in initializeStateFromURL( ) to see if the hash has recently changed: var recentHash = ""; function pollHash( ) { if (window.location.hash==recentHash) { return; // Nothing has changed since last polled. } recentHash = window.location.hash; // URL has changed. Update the UI accordingly. openTab(initialTab); } The polling adds to performance overhead, but we now have a URL setup that feels like the real thing. One last thing on this technique. I mentioned earlier that you can set window.location.hash manually after state changes. Now that we've considered the polling technique, you can see that it's also possible to do the reverse: change the URL in response to user events, then let the polling mechanism pick it up and change state accordingly. That adds a delay, but one big benefit is that you can easily add a hyperlink on the page to affect the system state. Here's a quick summary of the technique just described, which I'll call the "page-URL" technique:
But wait, there's more! I mentioned earlier that each URL goes into history, but only "for some browsers." IE is one of the outliers here, so the above technique will fail IE's history mechanism, and the Back button won't work as expected. The good news is there's a workaround for it. The bad news is that it's a different technique and won't work properly on Firefox. So if you can live with everything working but IE history, go with the hash-rewrite technique. Otherwise, read on . . . . The IE-specific solution relies on IFrames and on their source URLs, so I'll call it the "IFrame-URL" technique. While IE won't consider a fragment change historically significant, it will recognize changes to the source URL of an embedded IFrame. Each time you change the source URL of an IFrame, the entire page will be added to the history. Think of each page in the history as not just the main URL but the combination of main URL and URLs of any IFrames. Its the combination that must vary in order to create a new history entry. So to get IE history working, we can embed a dummy IFrame and keep changing its source URL to reflect the current state. When the user clicks Back, the source will change to its previous state. In theory, the browser could keep polling the IFrame's source URL to find out when it's updated, but, for some reason, reverted IFrames don't seem to provide the correct source URL. So a workaround is to make the actual IFrame body contain the summary of browser state, i.e., the string which was in the fragment identifier for the previous algorithm. Then, the polling mechanism looks at the IFrame body rather than its source URL. For example, if the user clicks on the Songs tab, the IFrame source will change to dummy.phtml?summary=Songs. The body of dummy.phtml will be made (by the server) to contain this argument verbatim, i.e., the body of the IFrame will be "Songs." When the user later clicks somewhere else and then clicks the Back button, the browser will reset the IFrame's URL to dummy.phtml?summary=Songs and the body will once again be "Songs." The polling mechanism will soon detect the change and open the Songs tab. Now we have history, but we haven't done anything about the URLs. So when the user clicks on the Songs tab, we not only change the IFrame source but also the main document URL using a fragment identifier like before. We also have to make sure that the polling mechanism looks at the URL as well as the IFrame source. If either has changed since last poll, the application state must be updated. In addition, the one that hasn't changed must also be updated, i.e., if the IFrame source has changed, the URL must be updated, and vice versa. Here's a quick summary of the IFrame-URL technique:
Note that this won't work on Firefox, because Firefox will include URL changes as well as IFrame changes in the history. Thus, each state change will actually add two entries to Firefox's history. As mentioned earlier, you'll probably need to implement both algorithms in order to fully support both browsers. A modified version of the second technique will do it as well. Also, be careful when using both approaches because there's a risk your polling mechanism will revert states too eagerly. If the user has begun changing a value and the polling mechanism kicks in, it's possible that the value will change back. If you separate input and output carefully enough, that can be avoided. A workaround is to suspend the polling mechanism at certain times. None of this is ideal, but a Unique URL mechanism is worth fighting for. As a variant on changing the IFrame's source, Brad Neubergbased on a hack by Danny Goodmanhas pointed out that browsers will retain form field data (http://codinginparadise.org/weblog/2005/08/ajax-tutorial-saving-session-across.html). Instead of changing the IFrame source, you set the value of a hidden text field in the IFrame. That will solve the history problem, but you'll still need to do something about the main page URL to give the page a Unique URL. To summarize all of this, here are all the things we'd want from Unique URLs and an explanation of how each can be achieved in Ajax.
URL handling is one of the greatest complaints about Ajax, with myths abounding about how "Ajax breaks the Back button" and how "You can't bookmark Ajax Apps." That an Ajax App can include full page refreshes is enough to debunk these myths. But this pattern shows that even without page refreshes, Unique URLs are still do-able, even if somewhat complicated. This pattern identifies some of the current thinking on Unique URLs, but the problem has not yet been solved in a satisfactory manner. The best advice is to watch for new ideas and delegate to a good library where applicable. 17.6.5. Decisions17.6.5.1. How will you support search engine indexing?Search engines point "robot" scripts to a web site and have them accumulate a collection of pages. The robot works by scooting through the web site, finding standard links to standard URLs, and following them. It won't click on buttons or type in values as a user would, and it probably won't distinguish among fragment identifiers, either. So if it sees links to http://ajax.shop/#Songs and http://ajax.shop/#Movies, it will follow one or the other, but not both. That's a big problem, because it means an entire Ajax App will only have one search link associated with it, and you'll miss out on a lot of potential visits. The simplest approach is to live with a single page and do whatever you can with the initial HTML. Ensure that it contains all of the info required for indexing, focusing on meta tags, headings, and initial content. A more sophisticated technique is to provide a Site Map page that is linked from the main page and that links to all URLs you want indexed with the link text containing suitable descriptions. There is one catch here: you can't link to URLs with fragment identifiers, so you'll need to come up with a way to present search engines with standard URLs even though your application would normally present these using fragment identifiers. For example, have the Site Map link to http://ajax.shop/Movies and configure your server to redirect to http://ajax.shop/#Movies. It's probably reasonable to explicitly check if a robot is performing the search, and if it is, to preserve the URLi.e., when the robot requests http://ajax.shop/Movies, simply output the same contents as the user would see on http://ajax.shop/#Movies. Thus, the search engine will index http://ajax.shop/Movies with the correct content, and when the user clicks on a search result, the server will know (because the client is not a robot) to redirect to http://ajax.shop/#Movies. Search engine strategies for Ajax Apps has been discussed in a http://www.backbase.com/#dev/tech/001_designing_rias_for_sea.xml: detailed paper by Jeremy Hartlet of Backbase. See that paper for more details, though note that some advice is Backbase-specific. 17.6.5.2. What will be the polling interval?The solutions here involve polling either the main page URL, the IFrame URL, or both. What sort of polling interval is appropriate? Anything more than a few hundred milliseconds will cause a noticeable delay (though that's not a showstopper because users are used to delays in loading pages). From a responsiveness perspective, the delay should ideally be as short as possible, but there are two forces at work that will lengthen it:
17.6.5.3. What state differences warrant Unique URLs?In this pattern, I discuss "state changes" abstractly. When I say a "state change" should result in a new URL and a new entry in the browser's history, what kind of state change am I discussing? In an Ajax App, some changes will be significant enough to warrant a new URL, and some won't. Here are some guidelines:
17.6.5.4. What will the URLs look like?Unique URLs requires you to make like an information architect and do some URL design work. Possibly, you'll be controlling only the fragment identifier rather than the entire URL, but even the fragment identifier has usability implications. Here are some guidelines:
17.6.6. Real-World Examples17.6.6.1. PairStairsNat Pryce's PairStairs (http://nat.truemesh.com/stairmaster.html) is an Extreme Programming tool that accepts a list of programmer initials and outputs a corresponding matrix (Figure 17-19). The URL stays synchronized with the initials that are being enterede.g., if you enter "ap ek kc mm," the URL will update so that it ends with #ap_ek_kc_mm. Figure 17-19. PairStairs17.6.6.2. Dojo binding libraryDojo Toolkit's dojo.io.bind (http://dojotoolkit.org/docs/intro_to_dojo_io.html) lets you specify (or autogenerate) a fragment-based URL as part of a web remoting call. After the response arrives, the application's URL will change accordingly. 17.6.6.3. Really Simple History libraryReally Simple History (http://codinginparadise.org/weblog/2005_09_20_archive.html) is a framework that lets you explicitly manage browser history (Figure 17-20). A call to dhtmlHistory.add(fragment, state) will set the URL's fragment identifier to fragment and set a state variable to state. A callback function can be registered to determine when the history has changed (e.g., the Back button was pressed), and it will receive the corresponding location and state. The library has recently been incorporated into the Dojo project. Figure 17-20. Really simple history demo17.6.7. Code Refactoring: AjaxPatterns Unique URL SumThis example refactors the Basic Sum Demo (http://ajaxify.com/run/sum) using both the Page-URL and IFrame-URL techniques. There are actually four implementations here:
17.6.7.1. Unique URL Sum DemoThis first refactoring changes the fragment identifier (hash) when a sum is submitted. The fragment identifier always contains the main page state, which, in this case, is a comma-separated list of the three sum figures (e.g., #3,5,10): function submitSum( ) { definedFigures = { figure1: $("figure1").value, figure2: $("figure2").value, figure3: $("figure3").value } hash = "#" + definedFigures.figure1 + "," + definedFigures.figure2 + "," +definedFigures.figure3; window.location.hash = hash; ajaxCaller.get("sum.phtml", definedFigures, onSumResponse, false, null); } This is only useful if the startup routine actually takes the fragment identifier into account. Thus, a new function, setInitialFigures( ), is called on initialization. It inspects the fragment identifier, sets the parameters, and calls submitSum( ) to update the sum result: function setInitialFigures( ) { figuresRE = /#([-]*[0-9]*),([-]*[0-9]*),([-]*[0-9]*)/; figuresSpec = window.location.hash; if (!figuresRE.test(figuresSpec)) { return; // ignore url if invalid } $("figure1").value = figuresSpec.replace(figuresRE, "$1"); $("figure2").value = figuresSpec.replace(figuresRE, "$2"); $("figure3").value = figuresSpec.replace(figuresRE, "$3"); submitSum( ); } 17.6.7.2. Polling URL Sum DemoThe previous version will update the URL upon state change, but not vice versa. This demo rectifies the problem by polling the URL and updating state if the URL changes. The functionality for updating state from URL is already present in the setInitialFigures( ) function of the previous version. We simply need to keep running it instead of just calling it on startup. So, setInitialFigures( ) has been renamed to pollHash( ) and is run periodically: window.onload = function( ) { ... setInterval(pollHash, 1000); } The function is the same as before, but with just one change: a recentHash variable is maintained to ensure that we only change state if the hash has actually changed. We don't want to change state more times than are necessary: var recentHash = ""; function pollHash( ) { if (window.location.hash==recentHash) { return; // Nothing has changed since last polled. } recentHash = window.location.hash; // ... Same code as in previous setInitialFigures( ) function ... } 17.6.7.3. IFrame Sum DemoThe previous versions won't work on IE because a fragment identifier change is not significant enough to get into IE's history. So here, we'll introduce an IFrame and change its source URL whenever the document changes. The initial HTML includes an IFrame. This is the IFrame whose source URL we'll manipulate in order to add entries to browser history. It's backed by a PHP file that simply mimics the summary argument; the body of the IFrame always matches the source URL, so we can find the summary by inspecting the body. In fact, we should be able to just inspect the source URL directly, but there seems to be some bug in IE that fails to update the source property when you go back to a previous IFrame (even though it actually fetches the content according to that URL). Incidentally, the IFrame would normally be hidden via CSS but is kept visible for demonstration purposes. <iframe id='iFrame' name='iFrame' src='/books/2/755/1/html/2/summary.phtml?summary='></iframe> Each time a sum is submitted, the IFrame's source is updated: function submitSum( ) { ... $("iFrame").src = "summary.phtml?summary=" + summary; } Instead of polling the URL, we're now polling the IFrame's body, which contains the state summary. Remember that in this version, the main page URL is fixedwe're only making IE history work, and not yet making the application bookmarkable. Whenever we notice that the IFrame source has been changed, we assume that it's because the Back button has been clicked. Thus, we pull out the summary from the IFrame body and adjust the application state accordingly: window.onload = function( ) { ... setInterval(pollSummary, 1000); } function pollSummary( ) { summary = window["iFrame"].document.body.innerHTML; if (summary==recentSummary) { return; // Nothing has changed since last polled. } recentSummary = summary; // Set figures according to summary string and call submitSum( ) // to set the result ... } The Back button will now work as expected on both Firefox and IE. 17.6.7.4. Full IFrame Sum DemoThis final version builds on the previous version by including Unique URLs for the main page so that you can bookmark a particular combination of figures. Note that this is an "IFrame URL" demo and won't work properly on Firefox for reasons explained in the preceding Solution. The page contains an IFrame as before, and since we're now synchronizing the URL as well, there are actually three things to keep in sync:
The algorithm is this: when one of these three changes, change the other two and update the sum result. To facilitate this, some convenience functions exist to read and write these properties: function getURLSummary( ) { var url = window.location.href; return URL_RE.test(url) ? url.replace(/.*#(.*)/, "$1") : ""; } function getIFrameSummary( ) { return util.trim(window["iFrame"].document.body.innerHTML); } function getInputsSummary( ) { return $("figure1").value +","+ $("figure2").value +","+ $("figure3").value; } function setURL(summaryString) { window.location.hash = "#" + summaryString; document.title = "Unique URL Sum: " + summaryString; } function setIFrame(summaryString) { $("iFrame").src = "summary.phtml?summary=" + summaryString; } The updateSumResult( ) is also straightforward: function updateSumResult( ) { definedFigures = { figure1: $("figure1").value, figure2: $("figure2").value, figure3: $("figure3").value } ajaxCaller.get("sum.phtml", definedFigures, onSumResponse, false, null); } function onSumResponse(text, headers, callingContext) { self.$("sum").innerHTML = text; } Now for the actual synchronization logic. The first thing that might change is the application state itself. This occurs when the Submit button is clicked and will cause the IFrame and page URL to update: window.onload = function( ) { self.$("addButton").onclick = function( ) { onSubmitted( ); } ... } function onSubmitted( ) { var inputsSummary = getInputsSummary( ); setIFrame(inputsSummary); setURL(inputsSummary); updateSumResult( ); } The other two things that can change are the IFrame source (e.g., if the Back button is clicked) or the URL (e.g., if a bookmark is selected). There's no way to notice these directly, so, as before, we poll for them. A single polling function suffices for both: window.onload = function( ) { ... pollTimer = setInterval(pollSummary, POLL_INTERVAL); } function pollSummary( ) { var iframeSummary = getIFrameSummary( ); var urlSummary = getURLSummary( ); var inputsSummary = getInputsSummary( ); if (urlSummary != inputsSummary) { // URL changed, e.g., bookmark setInputs(urlSummary); setIFrame(urlSummary); updateSumResult( ); } else if (iframeSummary != inputsSummary) { //IFrame changed,e.g., Back button setInputs(iframeSummary); setURL(iframeSummary); updateSumResult( ); } } One final point: The timer is suspended while the user edits a field in order to prevent the URL or IFrame source from reverting the application state:[*]
window.onload = function( ) { ... // Prevent iframe from triggering URL change during editing. The URL // change would in turn trigger changing of the values currently under edit, // which is why it needs to be stopped. for (var i=1; i<=3; i++) { $("figure"+i).onfocus = function( ) { clearInterval(pollTimer); } $("figure"+i).onblur = function( ) { pollTimer = setInterval(pollSummary, POLL_INTERVAL); } } ... } The application is now bookmarkable and has a working history. It's still not ideal, because if you unfocus an edit field, the figures will revert to a previous state. Also, there seems to be a browser issue (in both IE and Firefox), which means that the document URL isn't updated after you manually edit it. Thus, you can change the URL using a bookmark, but if you change it manually, it will not change again in response to an update of application state. 17.6.8. Alternatives17.6.8.1. Occasional refreshSome Ajax Apps perform page refreshes when a major state change occurs. For example, A9 (http://a9.com) uses a lot of Display Morphing (Chapter 5) and Web Remoting (Chapter 6) but nevertheless performs a standard submission for searches, leading to clean URLs such as http://a9.com/ajax. Usually, that's enough granularity, so no special URL manipulation needs to occur. 17.6.8.2. "Here" linkSome early Ajax offerings, like Google Maps and MSN Earth, keep the same URL throughout but offer a dynamic link within the page in case the user needs it. In some cases, there's a standard link to "the current page"; in other cases, the link is generated on demand. Various text is used, such as "Bookmark this Page" or "Link to this page." It's very easy to implement this but, unfortunately, it breaks the URL model that users are familiar with; thus users might not be able to use it. Time will tell whether users will adapt to this new style or whether it will fade away as Ajax developers learn how to deal with Unique URLs. 17.6.9. Want to Know More?
17.6.10. AcknowledgmentsThe Solution is based heavily on Mike Stenhouse's article (http://www.contentwithstyle.co.uk/Articles/38/fixing-the-back-button-and-enabling-bookmarking-for-ajax-apps). |