Search code examples
javascriptjqueryhtmlbrowser-historyhtml5-history

Ajax with history.pushState and popstate - what do I do when popstate state property is null?


I'm trying out the HTML5 history API with ajax loading of content.

I've got a bunch of test pages connected by relative links. I have this JS, which handles clicks on those links. When a link is clicked the handler grabs its href attribute and passes it to ajaxLoadPage(), which loads content from the requested page into the content area of the current page. (My PHP pages are set up to return a full HTML page if you request them normally, but only a chunk of content if ?fragment=true is appended to the URL of the request.)

Then my click handler calls history.pushState() to display the URL in the address bar and add it to the browser history.

$(document).ready(function(){

    var content = $('#content');

    var ajaxLoadPage = function (url) {
        console.log('Loading ' + url + ' fragment');
        content.load(url + '?fragment=true');
    }

    // Handle click event of all links with href not starting with http, https or #
    $('a').not('[href^=http], [href^=https], [href^=#]').on('click', function(e){

        e.preventDefault();
        var href = $(this).attr('href');
        ajaxLoadPage(href);
        history.pushState({page:href}, null, href);

    });

    // This mostly works - only problem is when popstate happens and state is null
    // e.g. when we try to go back to the initial page we loaded normally
    $(window).bind('popstate', function(event){
        console.log('Popstate');
        var state = event.originalEvent.state;
        console.log(state);
        if (state !== null) {
            if (state.page !== undefined) {
                ajaxLoadPage(state.page);
            }
        }
    });

});

When you add URLs to the history with pushState you also need to include an event handler for the popstate event to deal with clicks on the back or forward buttons. (If you don't do this, clicking back shows the URL you pushed to history in the address bar, but the page isn't updated.) So my popstate handler grabs the URL saved in the state property of each entry I created, and passes it to ajaxLoadPage to load the appropriate content.

This works OK for pages my click handler added to the history. But what happens with pages the browser added to history when I requested them "normally"? Say I land on my first page normally and then navigate through my site with clicks that do that ajax loading - if I then try to go back through the history to that first page, the last click shows the URL for the first page, but doesn't load the page in the browser. Why is that?

I can sort of see this has something to do with the state property of that last popstate event. The state property is null for that event, because it's only entries added to the history by pushState() or replaceState() that can give it a value. But my first loading of the page was a "normal" request - how come the browser doesn't just step back and load the initial URL normally?


Solution

  • I still don't understand why the back button behaves like this - I'd have thought the browser would be happy to step back to an entry that was created by a normal request. Maybe when you insert other entries with pushState the history stops behaving in the normal way. But I found a way to make my code work better. You can't always depend on the state property containing the URL you want to step back to. But stepping back through history changes the URL in the address bar as you would expect, so it may be more reliable to load your content based on window.location. Following this great example I've changed my popstate handler so it loads content based on the URL in the address bar instead of looking for a URL in the state property.

    One thing you have to watch out for is that some browsers (like Chrome) fire a popstate event when you initially hit a page. When this happens you're liable to reload your initial page's content unnecessarily. So I've added some bits of code from the excellent pjax to ignore that initial pop.

    $(document).ready(function(){
    
        // Used to detect initial (useless) popstate.
        // If history.state exists, pushState() has created the current entry so we can
        // assume browser isn't going to fire initial popstate
        var popped = ('state' in window.history && window.history.state !== null), initialURL = location.href;
    
        var content = $('#content');
    
        var ajaxLoadPage = function (url) {
    
            console.log('Loading ' + url + ' fragment');
            content.load(url + '?fragment=true');
    
        }
    
        // Handle click event of all links with href not starting with http, https or #
        $('a').not('[href^=http], [href^=https], [href^=#]').on('click', function(e){
    
            e.preventDefault();
            var href = $(this).attr('href');
            ajaxLoadPage(href);
            history.pushState({page:href}, null, href);
    
        });
    
        $(window).bind('popstate', function(event){
    
            // Ignore inital popstate that some browsers fire on page load
            var initialPop = !popped && location.href == initialURL;
            popped = true;
            if (initialPop) return;
    
            console.log('Popstate');
    
            // By the time popstate has fired, location.pathname has been changed
            ajaxLoadPage(location.pathname);
    
        });
    
    });
    

    One improvement you could make to this JS is only to attach the click event handler if the browser supports the history API.