225

So now that HTML5 introduces history.pushState to change the browsers history, websites start using this in combination with Ajax instead of changing the fragment identifier of the URL.

Sadly that means that those calls cannot be detect anymore by onhashchange.

My question is: Is there a reliable way (hack? ;)) to detect when a website uses history.pushState? The specification does not state anything about events that are raised (at least I couldn't find anything).
I tried to create a facade and replaced window.history with my own JavaScript object, but it didn't have any effect at all.

Further explanation: I'm developing a Firefox add-on that needs to detect these changes and act accordingly.
I know there was a similar question a few days ago that asked whether listening to some DOM events would be efficient but I would rather not rely on that because these events can be generated for a lot of different reasons.

Update:

Here is a jsfiddle (use Firefox 4 or Chrome 8) that shows that onpopstate is not triggered when pushState is called (or am I doing something wrong? Feel free to improve it!).

Update 2:

Another (side) problem is that window.location is not updated when using pushState (but I read about this already here on SO I think).

Zsolt Meszaros
  • 21,961
  • 19
  • 54
  • 57
Felix Kling
  • 795,719
  • 175
  • 1,089
  • 1,143
  • Related: [How to detect page navigation on YouTube and modify its appearance seamlessly?](https://stackoverflow.com/q/34077641) – SuperStormer Mar 10 '22 at 23:39

16 Answers16

246

5.5.9.1 Event definitions

The popstate event is fired in certain cases when navigating to a session history entry.

According to this, there is no reason for popstate to be fired when you use pushState. But an event such as pushstate would come in handy. Because history is a host object, you should be careful with it, but Firefox seems to be nice in this case. This code works just fine:

(function(history){
    var pushState = history.pushState;
    history.pushState = function(state) {
        if (typeof history.onpushstate == "function") {
            history.onpushstate({state: state});
        }
        // ... whatever else you want to do
        // maybe call onhashchange e.handler
        return pushState.apply(history, arguments);
    };
})(window.history);

Your jsfiddle becomes:

window.onpopstate = history.onpushstate = function(e) { ... }

You can monkey-patch window.history.replaceState in the same way.

Note: of course you can add onpushstate simply to the global object, and you can even make it handle more events via add/removeListener

KARASZI István
  • 30,900
  • 8
  • 101
  • 128
gblazex
  • 49,155
  • 12
  • 98
  • 91
  • You said you replaced the whole `history` object. That may be unnecessary in this case. – gblazex Jan 03 '11 at 14:04
  • @galambalazs: Yes probably. Maybe (don't know) `window.history` is readonly but the properties of the history object are not... Thanks a bunch for this solution :) – Felix Kling Jan 03 '11 at 14:07
  • According to the spec there is a "pagetransition" event, it seems its not Implemented yet. – Mohamed Mansour Jan 11 '11 at 00:08
  • @galambalazs, that solution is ingenious, it works perfectly in ff4 and chrome, but unfortunately when i use it in opera11, im getting the following error when the history.replacestate function is called: "Uncaught exception: TypeError: 'window.history.replaceState' is not a function" i wonder why it works in chrome and ff and not opera... any ideas, any solutions? – user280109 Jan 25 '11 at 08:01
  • @user280109 - As I said in my answer `history` is a *host object*, it's not guaranteed that the above method works cross-browser. But the question was about a **Firefox extension**, so I tried to solve it in Firefox. – gblazex Jan 25 '11 at 10:59
  • @galambalaza , i know the original question was asking about firefox, what im asking for is advice on how to make it work for opera, any ideas, would be most welcome. – user280109 Jan 25 '11 at 12:03
  • 2
    @user280109 - I would tell you if I knew. :) I think there's no way to do this in Opera atm. – gblazex Jan 25 '11 at 12:23
  • i figured out a solution to my problem, i just do a test for "history.replacestate" before i call your code, and only execute it if replacestate exists, thanks again. – user280109 Jan 25 '11 at 12:54
  • seems like i spoke to soon, now weirdly, i have the code working fine in all browsers except ff4b10, do you know if the history.pushstate function is stable in FF4, the above trapping code only seems to work intermittently for me – user280109 Jan 26 '11 at 22:01
  • Probably a basic question, but why are you building a function and calling it with `window.history` as a parameter instead of executing the code directly and using `window.history` in the place of the `history` parameter? – cprcrack Nov 14 '13 at 23:47
  • 1
    @cprcrack It is for keeping the global scope clean. You have to save the native `pushState` method for later use. So instead of a global variable I chose to encapsulate the whole code into an IIFE: http://en.wikipedia.org/wiki/Immediately-invoked_function_expression – gblazex Nov 16 '13 at 14:55
  • Note that just calling history.pushState() or history.replaceState() won't trigger a popstate event. The popstate event is only triggered by doing a browser action such as clicking on the back button (or calling history.back() in JavaScript). According to [developer.mozilla.org](https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onpopstate) – Kesong Xie Jun 08 '15 at 19:35
  • https://blog.mozilla.org/addons/2014/04/10/changes-to-unsafewindow-for-the-add-on-sdk/ – Gleb Sevruk Jun 12 '15 at 13:48
  • what is the best solution for evergreen browsers? I don't want to use polling – SuperUberDuper Jul 13 '15 at 09:48
  • What is the purpose of the `if (typeof history.onpushstate == "function")` check exactly? This is not defined in any browser I have tried... :^) Maybe some old browser version? – user2173353 Mar 24 '17 at 09:40
  • That is OUR custom event handler defined below. Read the whole answer. – gblazex Apr 26 '17 at 07:01
  • This code works In web pages but I am not able to use in one of my chrome extensions, I injected one JS file which contains this code. , any solution ? – Prathamesh Rasam Jul 11 '18 at 11:45
  • Downvoted, polling is much better than monkey patching, this monkey patching can be undone by any other script and you get NO notification if that happens – Ivan Castellanos Nov 26 '18 at 00:50
  • Here's an example of Google's autotrack library overriding pushstate and replaceState to track url changes: https://github.com/googleanalytics/autotrack/blob/74a4b5b62e1c2fd4eb2e77cdef3829e449aafe38/lib/plugins/url-change-tracker.js – tekniskt Dec 03 '18 at 11:19
  • Modifying History object may cause incompatibilities in the future releases of ECMA – Zortext Mar 20 '20 at 13:59
  • @IvanCastellanos _monkey patching can be undone by any other script and you get NO notification if that happens_... this is unlikely as the other monkey patch wouldn't be able to distinguish the original from yours and vice-versa. The only exception is if the other monkey patch breaks history by not calling the _original_ (existing) handler, and in that case that code shouldn't be used because it would be breaking your website. – pmoleri Jan 22 '21 at 14:23
38

I do this with simple proxy. This is an alternative to prototype

window.history.pushState = new Proxy(window.history.pushState, {
  apply: (target, thisArg, argArray) => {
    // trigger here what you need
    return target.apply(thisArg, argArray);
  },
});
Avindra Goolcharan
  • 4,032
  • 3
  • 41
  • 39
Kalanj Djordje Djordje
  • 1,162
  • 1
  • 12
  • 15
  • This worked for me, only had to add my trigger to `window.onpopstate` to cover the user pressing the back button. – Avindra Goolcharan Aug 01 '21 at 22:19
  • 4
    This worked perfectly for me. (I do not need to support IE.) However, just a heads up: this is TypeScript. To convert to pure JavaScript, you'll need to remove the `: any` on each argument. I'd also note that `: any` is redundant in TypeScript as well. – trlkly Dec 18 '21 at 23:44
  • 3
    I have since found that a slight change. As written, the "triggered" code fires before the change to the page history. This makes it hard to test the new URL. I've since changed the code inside the apply: `let output = target.apply(thisArg, argArray); /* trigger code */ return output;` – trlkly Mar 19 '22 at 10:10
17

Finally found the "correct" (no monkeypatching, no risk of breaking other code) way to do this! It requires adding a privilege to your extension (which, yes person who helpfully pointed this out in the comments, it's for the extension API which is what was asked for) and using the background page (not just a content script), but it does work.

The event you want is browser.webNavigation.onHistoryStateUpdated, which is fired when a page uses the history API to change the URL. It only fires for sites that you have permission to access, and you can also use a URL filter to further cut down on the spam if you need to. It requires the webNavigation permission (and of course host permission for the relevant domain(s)).

The event callback gets the tab ID, the URL that is being "navigated" to, and other such details. If you need to take an action in the content script on that page when the event fires, either inject the relevant script directly from the background page, or have the content script open a port to the background page when it loads, have the background page save that port in a collection indexed by tab ID, and send a message across the relevant port (from the background script to the content script) when the event fires.

CBHacking
  • 1,984
  • 16
  • 20
  • 23
    that API is only available to web extensions and add-ons and is not a normal DOM API, unfortunately – ccnokes Jan 05 '21 at 17:35
  • 5
    Correct, but that's what the OP was trying to build as per the question. For purely web stuff, I think you don't have any choice but to wrap the functions and/or events. – CBHacking Jan 06 '21 at 06:18
7

I used to use this:

var _wr = function(type) {
    var orig = history[type];
    return function() {
        var rv = orig.apply(this, arguments);
        var e = new Event(type);
        e.arguments = arguments;
        window.dispatchEvent(e);
        return rv;
    };
};
history.pushState = _wr('pushState'), history.replaceState = _wr('replaceState');

window.addEventListener('replaceState', function(e) {
    console.warn('THEY DID IT AGAIN!');
});

It's almost the same as galambalazs did.

It's usually overkill though. And it might not work in all browsers. (I only care about my version of my browser.)

(And it leaves a var _wr, so you might want to wrap it or something. I didn't care about that.)

Community
  • 1
  • 1
Rudie
  • 52,220
  • 42
  • 131
  • 173
7

Thank @KalanjDjordjeDjordje for his answer. I tried to make his idea a complete solution:

const onChangeState = (state, title, url, isReplace) => { 
    // define your listener here ...
}

// set onChangeState() listener:
['pushState', 'replaceState'].forEach((changeState) => {
    // store original values under underscored keys (`window.history._pushState()` and `window.history._replaceState()`):
    window.history['_' + changeState] = window.history[changeState]
    
    window.history[changeState] = new Proxy(window.history[changeState], {
        apply (target, thisArg, argList) {
            const [state, title, url] = argList
            onChangeState(state, title, url, changeState === 'replaceState')
            
            return target.apply(thisArg, argList)
        },
    })
})
Mir-Ismaili
  • 13,974
  • 8
  • 82
  • 100
6

In addition to other answers. Instead of storing the original function, we can use the History interface.

history.pushState = function()
{
    // ...

    History.prototype.pushState.apply(history, arguments);
}
Doliman100
  • 81
  • 1
  • 2
  • 4
3

I'd rather not overwrite the native history method so this simple implementation creates my own function called eventedPush state which just dispatches an event and returns history.pushState(). Either way works fine but I find this implementation a bit cleaner as native methods will continue to perform as future developers expect.

function eventedPushState(state, title, url) {
    var pushChangeEvent = new CustomEvent("onpushstate", {
        detail: {
            state,
            title,
            url
        }
    });
    document.dispatchEvent(pushChangeEvent);
    return history.pushState(state, title, url);
}

document.addEventListener(
    "onpushstate",
    function(event) {
        console.log(event.detail);
    },
    false
);

eventedPushState({}, "", "new-slug"); 
jopfre
  • 530
  • 3
  • 12
1

galambalazs's answer monkey patches window.history.pushState and window.history.replaceState, but for some reason it stopped working for me. Here's an alternative that's not as nice because it uses polling:

(function() {
    var previousState = window.history.state;
    setInterval(function() {
        if (previousState !== window.history.state) {
            previousState = window.history.state;
            myCallback();
        }
    }, 100);
})();
Community
  • 1
  • 1
Flimm
  • 136,138
  • 45
  • 251
  • 267
1

Since you're asking about a Firefox addon, here's the code that I got to work. Using unsafeWindow is no longer recommended, and errors out when pushState is called from a client script after being modified:

Permission denied to access property history.pushState

Instead, there's an API called exportFunction which allows the function to be injected into window.history like this:

var pushState = history.pushState;

function pushStateHack (state) {
    if (typeof history.onpushstate == "function") {
        history.onpushstate({state: state});
    }

    return pushState.apply(history, arguments);
}

history.onpushstate = function(state) {
    // callback here
}

exportFunction(pushStateHack, unsafeWindow.history, {defineAs: 'pushState', allowCallbacks: true});
nathancahill
  • 10,452
  • 9
  • 51
  • 91
1

Well, I see many examples of replacing the pushState property of history but I'm not sure that's a good idea, I'd prefer to create a service event based with a similar API to history that way you can control not only push state but replace state as well and it open doors for many other implementations not relying on global history API. Please check the following example:

function HistoryAPI(history) {
    EventEmitter.call(this);
    this.history = history;
}

HistoryAPI.prototype = utils.inherits(EventEmitter.prototype);

const prototype = {
    pushState: function(state, title, pathname){
        this.emit('pushstate', state, title, pathname);
        this.history.pushState(state, title, pathname);
    },

    replaceState: function(state, title, pathname){
        this.emit('replacestate', state, title, pathname);
        this.history.replaceState(state, title, pathname);
    }
};

Object.keys(prototype).forEach(key => {
    HistoryAPI.prototype = prototype[key];
});

If you need the EventEmitter definition, the code above is based on the NodeJS event emitter: https://github.com/nodejs/node/blob/36732084db9d0ff59b6ce31e839450cd91a156be/lib/events.js. utils.inherits implementation can be found here: https://github.com/nodejs/node/blob/36732084db9d0ff59b6ce31e839450cd91a156be/lib/util.js#L970

Victor Queiroz
  • 123
  • 1
  • 10
  • I just edited my answer with the requested information, please check! – Victor Queiroz Nov 09 '17 at 22:09
  • @VictorQueiroz That a nice & clean idea to encapsulate the functions. But if some third part library calls for pushState, your HistoryApi won't be notified. – Yairopro Jul 15 '18 at 10:00
1

I don't think it's an good idea do modify native functions even if you can, and you should always keep your application scope, so an good approach is not using the global pushState function, instead, use one of your own:

function historyEventHandler(state){ 
    // your stuff here
} 

window.onpopstate = history.onpushstate = historyEventHandler

function pushHistory(...args){
    history.pushState(...args)
    historyEventHandler(...args)
}
<button onclick="pushHistory(...)">Go to happy place</button>

Notice that if any other code use the native pushState function, you will not get an event trigger (but if this happens, you should check your code)

Maxwell s.c
  • 1,583
  • 15
  • 29
0

Based on the solution given by @gblazex, in case you want to follow the same approach, but using arrow functions, follow up the below example in your javascript logic:

private _currentPath:string;    
((history) => {
          //tracks "forward" navigation event
          var pushState = history.pushState;
          history.pushState =(state, key, path) => {
              this._notifyNewUrl(path);
              return pushState.apply(history,[state,key,path]); 
          };
        })(window.history);

//tracks "back" navigation event
window.addEventListener('popstate', (e)=> {
  this._onUrlChange();
});

Then, implement another function _notifyUrl(url) that triggers any required action you may need when the current page url is updated ( even if the page has not been loaded at all )

  private _notifyNewUrl (key:string = window.location.pathname): void {
    this._path=key;
    // trigger whatever you need to do on url changes
    console.debug(`current query: ${this._path}`);
  }
Alberto S.
  • 1,805
  • 23
  • 39
0

Since I just wanted the new URL, I've adapted the codes of @gblazex and @Alberto S. to get this:

(function(history){

  var pushState = history.pushState;
    history.pushState = function(state, key, path) {
    if (typeof history.onpushstate == "function") {
      history.onpushstate({state: state, path: path})
    }
    pushState.apply(history, arguments)
  }
  
  window.onpopstate = history.onpushstate = function(e) {
    console.log(e.path)
  }

})(window.history);
Andre Lopes
  • 99
  • 1
  • 4
-1

I think this topic needs a more modern solution.

I'm sure nsIWebProgressListener was around back then I'm surprised no one mentioned it.

From a framescript (for e10s compatability):

let webProgress = docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebProgress);
webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW | Ci.nsIWebProgress.NOTIFY_LOCATION);

Then listening in the onLoacationChange

onLocationChange: function onLocationChange(webProgress, request, locationURI, flags) {
       if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT

That will apparently catch all pushState's. But there is a comment warning that it "ALSO triggers for pushState". So we need to do some more filtering here to ensure it's just pushstate stuff.

Based on: https://github.com/jgraham/gecko/blob/55d8d9aa7311386ee2dabfccb481684c8920a527/toolkit/modules/addons/WebNavigation.jsm#L18

And: resource://gre/modules/WebNavigationContent.js

Noitidart
  • 35,443
  • 37
  • 154
  • 323
-1

You could bind to the window.onpopstate event?

https://developer.mozilla.org/en/DOM%3awindow.onpopstate

From the docs:

An event handler for the popstate event on the window.

A popstate event is dispatched to the window every time the active history entry changes. If the history entry being activated was created by a call to history.pushState() or was affected by a call to history.replaceState(), the popstate event's state property contains a copy of the history entry's state object.

stef
  • 14,172
  • 2
  • 48
  • 70
  • 10
    I tried this already and the event is only triggered when the users goes back in the history (or any of the `history.go`, `.back`) functions are called. But not on `pushState`. Here is my attempt to test it, maybe I do something wrong: http://jsfiddle.net/fkling/vV9vd/ It seems only related in that way that *if* the history was changed by `pushState`, the corresponding state object is passed to the event handler when the other methods are called. – Felix Kling Jan 02 '11 at 23:34
  • 2
    Ah. In that case the only thing I can think of is to register a timeout to look at the length of the history stack and fire an event if the stack size has changed. – stef Jan 02 '11 at 23:44
  • 1
    Ok this is an interesting idea. The only thing is that the timeout would have to fire often enough so that the user won't notice any (long) delay (I have to load and show data for the new URL). I always try to avoid timeouts and polling where possible, but until now this seems to be the only solution. I will still wait for other proposals. But thank you very much for now! – Felix Kling Jan 02 '11 at 23:55
  • It might be even enough to check the length of the history stick on every click. – Felix Kling Jan 03 '11 at 09:13
  • 1
    Yes - that would work. I've been having a play with this - one issue is the replaceState call which would not throw an event because the size of the stack would not have changed. – stef Jan 03 '11 at 09:23
-7

As standard states:

Note that just calling history.pushState() or history.replaceState() won't trigger a popstate event. The popstate event is only triggered by doing a browser action such as clicking on the back button (or calling history.back() in JavaScript)

we need to call history.back() to trigeer WindowEventHandlers.onpopstate

So insted of:

history.pushState(...)

do:

history.pushState(...)
history.pushState(...)
history.back()