How to get notified about changes of the history via history.pushState?
JavascriptFirefox AddonBrowser HistoryPushstateJavascript 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.
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()