17

I have created a web app which utilizes the history pushState and replaceState methods in order to navigate through pages while also updating the history.

The script itself works almost perfectly; it will load pages correctly, and throw page errors when they need to be thrown. However, I've noticed a strange problem where pushState will push multiple, duplicate entries (and replace entries before it) to the history.

For example, let's say I do the following (in order):

  1. Load up index.php (history will be: Index)

  2. Navigate to profile.php (history will be: Profile, Index)

  3. Navigate to search.php (history will be: Search, Search, Index)

  4. Navigate to dashboard.php

Then finally, this is what will come up in my history (in order of most recent to oldest):

Dashboard
Dashboard
Dashboard
Search
Index

The problem with this is that when a user clicks the forward or back buttons, they will either get redirected to the incorrect page, or have to click multiple times in order to go back once. That, and it'll make no sense if they go and check their history.

This is what I have so far:

var Traveller = function(){
    this._initialised = false;

    this._pageData = null;
    this._pageRequest = null;

    this._history = [];
    this._currentPath = null;
    this.abort = function(){
        if(this._pageRequest){
            this._pageRequest.abort();
        }
    };
    // initialise traveller (call replaceState on load instead of pushState)
    return this.init();
};

/*1*/Traveller.prototype.init = function(){
    // get full pathname and request the relevant page to load up
    this._initialLoadPath = (window.location.pathname + window.location.search);
    this.send(this._initialLoadPath);
};
/*2*/Traveller.prototype.send = function(path){
    this._currentPath = path.replace(/^\/+|\/+$/g, "");

    // abort any running requests to prevent multiple
    // pages from being loaded into the DOM
    this.abort();

    return this._pageRequest = _ajax({
        url: path,
        dataType: "json",
        success: function(response){
            // render the page to the dom using the json data returned
            // (this part has been skipped in the render method as it
            // doesn't involve manipulating the history object at all
            window.Traveller.render(response);
        }
    });
};
/*3*/Traveller.prototype.render = function(data){
    this._pageData = data;
    this.updateHistory();
};
/*4*/Traveller.prototype.updateHistory = function(){
    /* example _pageData would be:
    {
        "page": {
            "title": "This is a title",
            "styles": [ "stylea.css", "styleb.css" ],
            "scripts": [ "scripta.js", "scriptb.js" ]
        }
    }
    */
    var state = this._pageData;
    if(!this._initialised){
        window.history.replaceState(state, state.title, "/" + this._currentPath);
        this._initialised = true;
    } else {
        window.history.pushState(state, state.title, "/" + this._currentPath);  
    }
    document.title = state.title;
};

Traveller.prototype.redirect = function(href){
    this.send(href);
};

// initialise traveller
window.Traveller = new Traveller();

document.addEventListener("click", function(event){
    if(event.target.tagName === "a"){
        var link = event.target;
        if(link.target !== "_blank" && link.href !== "#"){
            event.preventDefault();
            // example link would be /profile.php
            window.Traveller.redirect(link.href);
        }
    }
});

All help is appreciated,
Cheers.

GROVER.
  • 4,071
  • 2
  • 19
  • 66
  • So you only want to propagate a history change if the the target is different from the last entry? – Aluan Haddad Oct 30 '19 at 14:28
  • Is this happening seemingly randomly? Or are specific pages always the ones that cause duplicate history entries. – SamVK Oct 30 '19 at 14:29
  • @AluanHaddad no I only want to propagate a history change if there's a history change (ie. when a user is navigating through the site). Similar to what would happen in a normal situation... (going from index to profile to search on StackOverflow would return exactly that in my history) – GROVER. Oct 30 '19 at 14:32
  • @SamVK Doesn't matter what page, after navigating around from the initial load up page (ie. index.php), entries will duplicate themselves. – GROVER. Oct 30 '19 at 14:33
  • you skipped the code in `render` method - does it by any chance manipulate `document.title`? – Aprillion Nov 02 '19 at 09:59
  • @Aprillion no it doesn’t :) – GROVER. Nov 02 '19 at 09:59
  • 1
    Well, I am not quite sure about it so writing in comments. I see that you're adding browser history things in your `updateHistory` function. Now, `updateHistory` may be getting called twice, 1st, when you're initializing Traveller (`window.Traveller = new Traveller();`, `constructor` -> `init` -> `send` -> `render` -> `updateHistory`), then also by `redirect` from the `click` eventListener. I haven't tested it, just wild guessing, so adding it as a comment and not an answer. – Akshit Arora Nov 02 '19 at 10:05
  • @AkshitArora that's correct. it will update the history every time someone navigates through the website by clicking on a link – GROVER. Nov 02 '19 at 10:55
  • @Aprillion The `updateHistory` method does, however. – GROVER. Nov 03 '19 at 11:18
  • yeah, but that one is happening AFTER `history.pushState` which is OK as long as the operation is synchronous.. updating title BEFORE the new state was pushed to history would explain the symptoms - but I can't see that happening in the code shown – Aprillion Nov 03 '19 at 12:55
  • Apologies, entered quickly and couldn't delete the previous comment. I made a mistake in my initial assessment. I'm curious on this line - `var state = this._pageData;` if the `state` actually does get the proper value, can you add a console log after that and see? Actually, add some logs also for both the if and else after it. Something like `console.log('state is', state)` and then `console.log('replacing with', state)` for `if`, `console.log('pushing with', state)` for `else`. I'm curious of the results. – Alex Pappas Nov 04 '19 at 10:24
  • @ColdCerberus Ah no worries, happens :) Ok will do. – GROVER. Nov 04 '19 at 10:25
  • If this JS code is included in all your pages, then `window.Traveller = new Traveller();` will be executed at every page redirect, resetting the value of `window.Traveller` and `this._initialised` in your `updateHistory()` function. Not sure if this is your desired behavior. – Anis R. Nov 08 '19 at 00:28
  • @AnisR. It’s included in the initial load, and then isn’t reloaded in when the user redirects themselves :) – GROVER. Nov 08 '19 at 04:39
  • What I meant is that when a page is reloaded, scripts are reloaded too. – Anis R. Nov 08 '19 at 13:15

2 Answers2

5

Do you have a onpopstate handler ?

If yes, then check there also if you're not pushing to history. That some entries are removed/replaced in the history list might be a big sign. Indeed, see this SO answer:

Keep in mind that history.pushState() sets a new state as the newest history state. And window.onpopstate is called when navigating (backward/forward) between states that you have set.

So do not pushState when the window.onpopstate is called, as this will set the new state as the last state and then there is nothing to go forward to.

I once had exactly the same problem as you describe, but it was actually caused by me going back and forward to try to understand the bug, which would eventually trigger the popState handler. From that handler, it would then call history.push. So at the end, I also had some duplicated entries, and some missing, without any logical explanation.

I removed the call to history.push, replaced it by a history.replace after checking some conditions, and after it worked like a charm :)

EDIT-->TIP

If you can't locate which code is calling history.pushState:

Try by overwritting the history.pushState and replaceState functions with the following code:

window.pushStateOriginal = window.history.pushState.bind(window.history);
window.history.pushState = function () {
    var args = Array.prototype.slice.call(arguments, 0);
    let allowPush  = true;
    debugger;
    if (allowPush ) {
        window.pushStateOriginal(...args);
    }
}
//the same for replaceState
window.replaceStateOriginal = window.history.replaceState.bind(window.history);
window.history.replaceState = function () {
    var args = Array.prototype.slice.call(arguments, 0);
    let allowReplace  = true;
    debugger;
    if (allowReplace) {
        window.replaceStateOriginal(...args);
    }
}

Then each time the breakpoints are trigerred, have a look to the call stack.

In the console, if you want to prevent a pushState, just enter allowPush = false; or allowReplace = false; before resuming. This way, you are not going to miss any history.pushState, and can go up and find the code that calls it :)

Bonjour123
  • 1,421
  • 12
  • 13
  • I do, but it doesn’t push or replace history at all :/ just renders the page – GROVER. Nov 08 '19 at 13:27
  • Really weird. Try to overwrite the history.push function with the following code: _const hP = history.pushState history.pushState = (loc)=>{ debugger; hP(loc)}_ and do the same for history.replaceState. Then each time the breakpoints are trigerred, have a look to the call stack – Bonjour123 Nov 08 '19 at 13:58
  • I checked the call stack and everything seems to be done correctly - yet the history doesn't want to work. Why must Mozilla make everything so difficult :( – GROVER. Nov 09 '19 at 10:32
  • Thanks :) And if you try on Chrome, what results do you have ? – Bonjour123 Nov 09 '19 at 13:56
1

I was having the same issue on a very simple app where I was only calling window.history.pushState() from on place in the code. Everything worked great in FireFox but had the duplicate/overwrite issue you mentioned. Turned out the fix was to change:

document.title = "My New Title";
window.history.pushState(null, null, '/some/path');

to

window.history.pushState(null, null, '/some/path');
document.title = "My New Title";

...and now it works like a dream in both browsers.