How to get notified about changes of the history via history.pushState?

JavascriptFirefox AddonBrowser HistoryPushstate

Javascript Problem Overview


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).

Javascript Solutions


Solution 1 - Javascript

>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

Solution 2 - Javascript

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.

Solution 3 - Javascript

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);
  },
});

Solution 4 - Javascript

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.)

Solution 5 - Javascript

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"); 

Solution 6 - Javascript

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);
}

Solution 7 - Javascript

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)
        },
    })
})

Solution 8 - Javascript

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});

Solution 9 - Javascript

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

Solution 10 - Javascript

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)

Solution 11 - Javascript

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);
})();

Solution 12 - Javascript

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}`);
  }

Solution 13 - Javascript

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);

Solution 14 - Javascript

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.

Solution 15 - Javascript

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

Solution 16 - Javascript

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()

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionFelix KlingView Question on Stackoverflow
Solution 1 - JavascriptgblazexView Answer on Stackoverflow
Solution 2 - JavascriptCBHackingView Answer on Stackoverflow
Solution 3 - JavascriptKalanj Djordje DjordjeView Answer on Stackoverflow
Solution 4 - JavascriptRudieView Answer on Stackoverflow
Solution 5 - JavascriptjopfreView Answer on Stackoverflow
Solution 6 - JavascriptDoliman100View Answer on Stackoverflow
Solution 7 - JavascriptMir-IsmailiView Answer on Stackoverflow
Solution 8 - JavascriptnathancahillView Answer on Stackoverflow
Solution 9 - JavascriptVictor QueirozView Answer on Stackoverflow
Solution 10 - JavascriptMaxwell s.cView Answer on Stackoverflow
Solution 11 - JavascriptFlimmView Answer on Stackoverflow
Solution 12 - JavascriptAlberto S.View Answer on Stackoverflow
Solution 13 - JavascriptAndre LopesView Answer on Stackoverflow
Solution 14 - JavascriptstefView Answer on Stackoverflow
Solution 15 - JavascriptNoitidartView Answer on Stackoverflow
Solution 16 - Javascriptuser2360102View Answer on Stackoverflow