Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

.back, and .pushState - a tale of two histories

This isn't so much a question, but a discovery as a result of an interesting problem. It's also a sort of "Learn from my fail"

I'm attempting to write unit tests for an HTML5 history duck punch for IE (using window.hash as a substitute for state maintenance). The duckpunch works as expected, and during user testing I get consistent results in IE, Chrome and Firefox.

Where the problem comes in is the unit tests. In them I do various combinations of history.pushState(), .replaceState, .back() and .forward(). These work fine in Firefox and IE, but Chrome gave me thoroughly inconsistent results. The below answer explains why.

like image 885
Fordi Avatar asked Jul 11 '12 20:07

Fordi


2 Answers

Consider the following:

var originalPath = window.location.pathname.toString();
history.pushState({ value: 'Foo' }, '', 'state-1');
history.pushState({ value: 'Bar' }, '', 'state-2');

history.pushState({ value: 'Baz' }, '', 'state-3');
history.back();
history.back();
console.log(history.state.value);
//So we can hit refresh and see the results again...
setTimeout(function () {
    history.replaceState(null, '', originalPath);
}, 250);

One would expect this chunk of code to return 'Foo' - and in both Firefox and my IE duck punch, this is exactly what it does - but in Chrome, it responds with 'Baz'.

After some investigation, I worked out the problem: IE and Firefox update the history synchronously, then go asynchronous if any page loads are required. Chrome appears to go asynchronous immediately.

The evidence:

window.onpopstate = function () {
    console.log('ping');
}
history.pushState({ value: 'Foo' }, '', 'state-1');
history.back();
console.log('pong');

In Firefox, this returns 'ping'; 'pong' - indicating that the event is dispatched as part of the history.back() call. In Chrome, this returns 'pong'; 'ping' - indicating that the event is placed in a queue for dispatch.

This wouldn't be so bad if this event dispatch model weren't being used to manage the history and location objects' state - but apparently it is.

window.onpopstate = function () {
    console.log('Event...', history.state, window.location.pathname.toString());
}
history.pushState({ value: 'Foo' }, '', 'state-1');
history.back();
console.log('Inline...', history.state, window.location.pathname.toString());

This is an interesting quirk, and one that requires the use of jQuery Deferred chains to work around properly for my unit tests. I'm not particularly happy about that, but what can you do?

like image 186
Fordi Avatar answered Oct 02 '22 15:10

Fordi


To handle asynchronous back events in unit tests, I used HistoryJS and Jasmine. It was a case of updating a counter in the history event to track when chrome processed the event and Jasmine's async support to block the unit test until we see an event change:

Increment the counter:

History.Adapter.bind(window, 'statechange', function() {

    // Increment the counter
    window.historyEventCounter++;

});

The Jasmine async unit test. The waitsFor will block until the history event occurs:

describe('back navigation', function () {
    it('should change url', function () {

        var initialHistoryEventCount;

        // Cache the initial count

        runs(function() {
            initialHistoryEventCount = window.historyEventCounter;
            History.back();
        });

        // Block until the event counter changes

        waitsFor(function() {
            return (initialHistoryEventCount !== app.historyEventCounter);
        }, "The page should be navigated back", 1000);

        // Now confirm the expected back behaviour. Event failures will time-out.

        runs(function() {
            expect(window.location.href).toEqual("http:// my back url");

            // ... more tests about the page state
        });
    }
}
like image 45
daw Avatar answered Oct 02 '22 15:10

daw