How to detect URL changes in SPA - javascript

Note
Solution should be in pure Javascript - No external library and frameworks.
In SPA, Everything gets routed using routing mechanism.
I just want to listen to an even whenever any part of url changes (Not only hash. Any change I want to detect)
Following is example of SPA,
https://www.google.in/
https://www.google.in/women
https://www.google.in/girl
Now whenever url changes from https://www.hopscotch.in/ to https://www.hopscotch.in/women, I want to capture that event.
I tried,
window.addEventListener("hashchange",function(event){
console.log(this); // this gets fired only when hash changes
});

In addition to Quentin's answer: setInterval is a pretty bad way to check for these changes: it's either never on time or gets fired too often.
What you really should do is watch for user-input events. Click is the most obvious one but don't forget about keyboard input. Should one of these events occur it will mostly just take one tick for the url to change. So in a quick mockup in code it's:
let url = location.href;
document.body.addEventListener('click', ()=>{
requestAnimationFrame(()=>{
url!==location.href&&console.log('url changed');
url = location.href;
});
}, true);
(just drop it into Github console to see it work)
Together with a popstate listener this should be enough for most use cases.

Under normal circumstances, there isn't an event when the URL changes. You are loading a new document (although you have load and so on)
If you are setting a new URL with JavaScript (i.e. with pushState) then there isn't an event, but you don't need one because you're already explicitly writing code around it, so you just add whatever else you need to that code.
You'll get a popstate event if the URL changes back though your pushState history via the browser back button or similar.
Consequently, there is no good generic way to hook into every SPA. The closest you could come would be to use setInterval and inspect the value of location.href to see if it changed since the last inspection.

This script will let you detect changes to a URL within a SPA:
var previousUrl = '';
var observer = new MutationObserver(function(mutations) {
if (location.href !== previousUrl) {
previousUrl = location.href;
console.log(`URL changed to ${location.href}`);
}
});
const config = {subtree: true, childList: true};
observer.observe(document, config);
When you're done observing, be sure to cancel the observer, using the variable from the previous example:
observer.disconnect();

I've implemented d-_-b's answer in my codebase, but I figured out that (at least in the website I'm working on) when the URL changed we still had the title from the previous page, so I changed it to listen to changes to the document.title instead of the location.href.
Here is my working solution:
let currentTitle = document.title;
const observer = new MutationObserver(function (mutations) {
if (document.title !== currentTitle) {
console.log('title changed', document.title);
console.log('location also changed', document.location.href);
// here you can add your own code
currentTitle = document.title;
}
});
const config = { subtree: true, childList: true };
observer.observe(document, config);
window.addEventListener('beforeunload', function (event) {
observer.disconnect();
});

Related

Firefox Extension location.href not redirecting

I'm using something rather generic here, but essentially I want to be able to load a new tab at my desired URL when selecting my extension, then when at that tab, redirect to a new URL. (The add on should run some code at the first page before redirecting, but that's for another day).
The code I have at the moment is
var buttons = require('sdk/ui/button/action');
var tabs = require("sdk/tabs");
var button = buttons.ActionButton({
id: "redirect",
label: "redirect",
icon: {
"16": "./icon-16.png"
},
onClick: handleClick
});
function handleClick(state) {
tabs.open({
url: "http://www.google.com",
onReady: loadRedirect
});
function loadRedirect(tab) {
tab.attach({
contentScript: "location.href = 'www.youtube.com;'"
});
}
}
When running this however, the 2nd URL appends to the first, rather than replaces, and then gets stuck in an infinite load/refresh loop until I close the browser.
I assume I'm missing something absolutely obvious, but I wasn't able to find anything while searching around.
You are trying to change the location.href to a string that has no scheme. It is assumed that it is a URL within the current domain. Because it also does not start with a / it is assumed to be relative to the current page. Thus, it is appended to the current google.com URL. The page becomes ready; fires the ready event; and your onReady handler is called. Your handler then changes the URL, causing the page to be reloaded, and, again, fire the ready event, which starts the process over again. This is your infinite loop.
To actually get to www.youtube.com, you could change your code to:
function loadRedirect(tab) {
tab.attach({
contentScript: "location.href = 'https://www.youtube.com';"
});
}
Which could have been done without the need to inject a content script by assigning to tab.url:
function loadRedirect(tab) {
tab.url = 'https://www.youtube.com';
}
However, that will not prevent the infinite loop. There are many ways to do so. The easiest is to just remove the ready listener:
function loadRedirect(tab) {
tab.off('ready',loadRedirect);
tab.url = 'https://www.youtube.com';
}

Ajax with history.pushState and popstate - what do I do when popstate state property is null?

I'm trying out the HTML5 history API with ajax loading of content.
I've got a bunch of test pages connected by relative links. I have this JS, which handles clicks on those links. When a link is clicked the handler grabs its href attribute and passes it to ajaxLoadPage(), which loads content from the requested page into the content area of the current page. (My PHP pages are set up to return a full HTML page if you request them normally, but only a chunk of content if ?fragment=true is appended to the URL of the request.)
Then my click handler calls history.pushState() to display the URL in the address bar and add it to the browser history.
$(document).ready(function(){
var content = $('#content');
var ajaxLoadPage = function (url) {
console.log('Loading ' + url + ' fragment');
content.load(url + '?fragment=true');
}
// Handle click event of all links with href not starting with http, https or #
$('a').not('[href^=http], [href^=https], [href^=#]').on('click', function(e){
e.preventDefault();
var href = $(this).attr('href');
ajaxLoadPage(href);
history.pushState({page:href}, null, href);
});
// This mostly works - only problem is when popstate happens and state is null
// e.g. when we try to go back to the initial page we loaded normally
$(window).bind('popstate', function(event){
console.log('Popstate');
var state = event.originalEvent.state;
console.log(state);
if (state !== null) {
if (state.page !== undefined) {
ajaxLoadPage(state.page);
}
}
});
});
When you add URLs to the history with pushState you also need to include an event handler for the popstate event to deal with clicks on the back or forward buttons. (If you don't do this, clicking back shows the URL you pushed to history in the address bar, but the page isn't updated.) So my popstate handler grabs the URL saved in the state property of each entry I created, and passes it to ajaxLoadPage to load the appropriate content.
This works OK for pages my click handler added to the history. But what happens with pages the browser added to history when I requested them "normally"? Say I land on my first page normally and then navigate through my site with clicks that do that ajax loading - if I then try to go back through the history to that first page, the last click shows the URL for the first page, but doesn't load the page in the browser. Why is that?
I can sort of see this has something to do with the state property of that last popstate event. The state property is null for that event, because it's only entries added to the history by pushState() or replaceState() that can give it a value. But my first loading of the page was a "normal" request - how come the browser doesn't just step back and load the initial URL normally?
This is an older question but there is a much simpler answer using native javascript for this issue.
For the initial state you should not be using history.pushState but rather history.replaceState.
All arguments are the same for both methods with the only difference is that pushState creates a NEW history record and thus is the source of your problem. replaceState only replaces the state of that history record and will behave as expected, that is go back to the initial starting page.
I ran into the same issue as the original question. This line
var initialPop = !popped && location.href == initialURL;
should be changed to
var initialPop = !popped;
This is sufficient to catch the initial pop. Then you do not need to add the original page to the pushState. i.e. remove the following:
var home = 'index.html';
history.pushState({page:home}, null, home);
The final code based on AJAX tabs (and using Mootools):
if ( this.supports_history_api() ) {
var popped = ('state' in window.history && window.history.state !== null)
, changeTabBack = false;
window.addEvent('myShowTabEvent', function ( url ) {
if ( url && !changingTabBack )
setLocation(url);
else
changingTabBack = false;
//Make sure you do not add to the pushState after clicking the back button
});
window.addEventListener("popstate", function(e) {
var initialPop = !popped;
popped = true;
if ( initialPop )
return;
var tabLink = $$('a[href="' + location.pathname + '"][data-toggle*=tab]')[0];
if ( tabLink ) {
changingTabBack = true;
tabLink.tab('show');
}
});
}
I still don't understand why the back button behaves like this - I'd have thought the browser would be happy to step back to an entry that was created by a normal request. Maybe when you insert other entries with pushState the history stops behaving in the normal way. But I found a way to make my code work better. You can't always depend on the state property containing the URL you want to step back to. But stepping back through history changes the URL in the address bar as you would expect, so it may be more reliable to load your content based on window.location. Following this great example I've changed my popstate handler so it loads content based on the URL in the address bar instead of looking for a URL in the state property.
One thing you have to watch out for is that some browsers (like Chrome) fire a popstate event when you initially hit a page. When this happens you're liable to reload your initial page's content unnecessarily. So I've added some bits of code from the excellent pjax to ignore that initial pop.
$(document).ready(function(){
// Used to detect initial (useless) popstate.
// If history.state exists, pushState() has created the current entry so we can
// assume browser isn't going to fire initial popstate
var popped = ('state' in window.history && window.history.state !== null), initialURL = location.href;
var content = $('#content');
var ajaxLoadPage = function (url) {
console.log('Loading ' + url + ' fragment');
content.load(url + '?fragment=true');
}
// Handle click event of all links with href not starting with http, https or #
$('a').not('[href^=http], [href^=https], [href^=#]').on('click', function(e){
e.preventDefault();
var href = $(this).attr('href');
ajaxLoadPage(href);
history.pushState({page:href}, null, href);
});
$(window).bind('popstate', function(event){
// Ignore inital popstate that some browsers fire on page load
var initialPop = !popped && location.href == initialURL;
popped = true;
if (initialPop) return;
console.log('Popstate');
// By the time popstate has fired, location.pathname has been changed
ajaxLoadPage(location.pathname);
});
});
One improvement you could make to this JS is only to attach the click event handler if the browser supports the history API.
I actually found myself with a similar need today and found the code you provided to be very useful. I came to the same problem you did, and I believe all that you're missing is pushing your index file or home page to the history in the same manner that you are all subsequent pages.
Here is an example of what I did to resolve this (not sure if it's the RIGHT answer, but it's simple and it works!):
var home = 'index.html';
history.pushState({page:home}, null, home);
Hope this helps!
I realize this is an old question, but when trying to manage state easily like this, it might be better to take the following approach:
$(window).on('popstate',function(e){
var state = e.originalEvent.state;
if(state != null){
if(state.hasOwnProperty('window')){
//callback on window
window[state.window].call(window,state);
}
}
});
in this way, you can specify an optional callback function on the state object when adding to history, then when popstate is trigger, this function would be called with the state object as a parameter.
function pushState(title,url,callback)
{
var state = {
Url : url,
Title : title,
};
if(window[callback] && typeof window[callback] === 'function')
{
state.callback = callback;
}
history.pushState(state,state.Title,state.Url);
}
You could easily extend this to suit your needs.
And Finally says:
I'd have thought the browser would be happy to step back to an entry that was created by a normal request.
I found an explanation of that strange browser's behavior here. The explanation is
you should save the state when your site is loaded the first time and thereafter every time it changes state
I tested this - it works.
It means there is no need in loading your content based on window.location.
I hope I don't mislead.

Accessing every document that a user currently views from an extension

I'm writing an extension that checks every document a user views on certain data structures, does some back-end server calls and displays the results as a dialog.The problem is starting and continuing the sequence properly with event listeners. My actual idea is:
Load: function()
{
var Listener = function(){ Fabogore.Start();};
var ListenerTab = window.gBrowser.selectedTab;
ListenerTab.addEventListener("load",Listener,true);
}
(...)
ListenerTab.removeEventListener("load", Listener, true);
Fabogore.Load();
The Fabogore.Load function is first initialized when the browser gets opened. It works only once I get these data structures, but not afterwards. But theoretically the script should initialize a new listener, so maybe it's the selectedTab. I also tried listening to focus events.
If someone has got an alternative solution how to access a page a user is currently viewing I would feel comfortable as well.
The common approach is using a progress listener. If I understand correctly, you want to get a notification whenever a browser tab finished loading. So the important method in your progress listener would be onStateChange (it needs to have all the other methods as well however):
onStateChange: function(aWebProgress, aRequest, aFlag, aStatus)
{
if ((aFlag & Components.interfaces.nsIWebProgressListener.STATE_STOP) &&
(aFlag & Components.interfaces.nsIWebProgressListener.STATE_IS_WINDOW) &&
aWebProgress.DOMWindow == aWebProgress.DOMWindow.top)
{
// A window finished loading and it is the top-level frame in its tab
Fabogore.Start(aWebProgress.DOMWindow);
}
},
Ok, I found a way which works from the MDN documentation, and achieves that every document a user opens can be accessed by your extension. Accessing every document a user focuses is too much, I want the code to be executed only once. So I start with initializing the Exentsion, and Listen to DOMcontentloaded Event
window.addEventListener("load", function() { Fabogore.init(); }, false);
var Fabogore = {
init: function() {
var appcontent = document.getElementById("appcontent"); // browser
if(appcontent)
appcontent.addEventListener("DOMContentLoaded", Fabogore.onPageLoad, true);
},
This executes the code every Time a page is loaded. Now what's important is, that you execute your code with the new loaded page, and not with the old one. You can acces this one with the variable aEvent:
onPageLoad: function(aEvent)
{
var doc = aEvent.originalTarget;//Document which initiated the event
With the variable "doc" you can check data structures using XPCNativeWrapper etc. Thanks Wladimir for bringing me in the right direction, I suppose if you need a more sophisticated event listening choose his way with the progress listeners.

Popstate on page's load in Chrome

I am using History API for my web app and have one issue.
I do Ajax calls to update some results on the page and use history.pushState() in order to update the browser's location bar without page reload. Then, of course, I use window.popstate in order to restore previous state when back-button is clicked.
The problem is well-known — Chrome and Firefox treat that popstate event differently. While Firefox doesn't fire it up on the first load, Chrome does. I would like to have Firefox-style and not fire the event up on load since it just updates the results with exactly the same ones on load. Is there a workaround except using History.js? The reason I don't feel like using it is — it needs way too many JS libraries by itself and, since I need it to be implemented in a CMS with already too much JS, I would like to minimize JS I am putting in it.
So, would like to know whether there is a way to make Chrome not fire up popstate on load or, maybe, somebody tried to use History.js as all libraries mashed up together into one file.
In Google Chrome in version 19 the solution from #spliter stopped working. As #johnnymire pointed out, history.state in Chrome 19 exists, but it's null.
My workaround is to add window.history.state !== null into checking if state exists in window.history:
var popped = ('state' in window.history && window.history.state !== null), initialURL = location.href;
I tested it in all major browsers and in Chrome versions 19 and 18. It looks like it works.
In case you do not want to take special measures for each handler you add to onpopstate, my solution might be interesting for you. A big plus of this solution is also that onpopstate events can be handled before the page loading has been finished.
Just run this code once before you add any onpopstate handlers and everything should work as expected (aka like in Mozilla ^^).
(function() {
// There's nothing to do for older browsers ;)
if (!window.addEventListener)
return;
var blockPopstateEvent = document.readyState!="complete";
window.addEventListener("load", function() {
// The timeout ensures that popstate-events will be unblocked right
// after the load event occured, but not in the same event-loop cycle.
setTimeout(function(){ blockPopstateEvent = false; }, 0);
}, false);
window.addEventListener("popstate", function(evt) {
if (blockPopstateEvent && document.readyState=="complete") {
evt.preventDefault();
evt.stopImmediatePropagation();
}
}, false);
})();
How it works:
Chrome, Safari and probably other webkit browsers fire the onpopstate event when the document has been loaded. This is not intended, so we block popstate events until the the first event loop cicle after document has been loaded. This is done by the preventDefault and stopImmediatePropagation calls (unlike stopPropagation stopImmediatePropagation stops all event handler calls instantly).
However, since the document's readyState is already on "complete" when Chrome fires onpopstate erroneously, we allow opopstate events, which have been fired before document loading has been finished to allow onpopstate calls before the document has been loaded.
Update 2014-04-23: Fixed a bug where popstate events have been blocked if the script is executed after the page has been loaded.
Using setTimeout only isn't a correct solution because you have no idea how long it will take for the content to be loaded so it's possible the popstate event is emitted after the timeout.
Here is my solution:
https://gist.github.com/3551566
/*
* Necessary hack because WebKit fires a popstate event on document load
* https://code.google.com/p/chromium/issues/detail?id=63040
* https://bugs.webkit.org/process_bug.cgi
*/
window.addEventListener('load', function() {
setTimeout(function() {
window.addEventListener('popstate', function() {
...
});
}, 0);
});
The solution has been found in jquery.pjax.js lines 195-225:
// Used to detect initial (useless) popstate.
// If history.state exists, assume browser isn't going to fire initial popstate.
var popped = ('state' in window.history), initialURL = location.href
// popstate handler takes care of the back and forward buttons
//
// You probably shouldn't use pjax on pages with other pushState
// stuff yet.
$(window).bind('popstate', function(event){
// Ignore inital popstate that some browsers fire on page load
var initialPop = !popped && location.href == initialURL
popped = true
if ( initialPop ) return
var state = event.state
if ( state && state.pjax ) {
var container = state.pjax
if ( $(container+'').length )
$.pjax({
url: state.url || location.href,
fragment: state.fragment,
container: container,
push: false,
timeout: state.timeout
})
else
window.location = location.href
}
})
A more direct solution than reimplementing pjax is set a variable on pushState, and check for the variable on popState, so the initial popState doesn't inconsistently fire on load (not a jquery-specific solution, just using it for events):
$(window).bind('popstate', function (ev){
if (!window.history.ready && !ev.originalEvent.state)
return; // workaround for popstate on load
});
// ... later ...
function doNavigation(nextPageId) {
window.history.ready = true;
history.pushState(state, null, 'content.php?id='+ nextPageId);
// ajax in content instead of loading server-side
}
Webkit's initial onpopstate event has no state assigned, so you can use this to check for the unwanted behaviour:
window.onpopstate = function(e){
if(e.state)
//do something
};
A comprehensive solution, allowing for navigation back to the original page, would build on this idea:
<body onload="init()">
page 1
page 2
<div id="content"></div>
</body>
<script>
function init(){
openURL(window.location.href);
}
function doClick(e){
if(window.history.pushState)
openURL(e.getAttribute('href'), true);
else
window.open(e.getAttribute('href'), '_self');
}
function openURL(href, push){
document.getElementById('content').innerHTML = href + ': ' + (push ? 'user' : 'browser');
if(window.history.pushState){
if(push)
window.history.pushState({href: href}, 'your page title', href);
else
window.history.replaceState({href: href}, 'your page title', href);
}
}
window.onpopstate = function(e){
if(e.state)
openURL(e.state.href);
};
</script>
While this could still fire twice (with some nifty navigation), it can be handled simply with a check against the previous href.
This is my workaround.
window.setTimeout(function() {
window.addEventListener('popstate', function() {
// ...
});
}, 1000);
Here's my solution:
var _firstload = true;
$(function(){
window.onpopstate = function(event){
var state = event.state;
if(_firstload && !state){
_firstload = false;
}
else if(state){
_firstload = false;
// you should pass state.some_data to another function here
alert('state was changed! back/forward button was pressed!');
}
else{
_firstload = false;
// you should inform some function that the original state returned
alert('you returned back to the original state (the home state)');
}
}
})
The best way to get Chrome to not fire popstate on a page load is to up-vote https://code.google.com/p/chromium/issues/detail?id=63040. They've known Chrome isn't in compliance with the HTML5 spec for two full years now and still haven't fixed it!
In case of use event.state !== null returning back in history to first loaded page won't work in non mobile browsers.
I use sessionStorage to mark when ajax navigation really starts.
history.pushState(url, null, url);
sessionStorage.ajNavStarted = true;
window.addEventListener('popstate', function(e) {
if (sessionStorage.ajNavStarted) {
location.href = (e.state === null) ? location.href : e.state;
}
}, false);
The presented solutions have a problem on page reload. The following seems to work better, but I have only tested Firefox and Chrome. It uses the actuality, that there seems to be a difference between e.event.state and window.history.state.
window.addEvent('popstate', function(e) {
if(e.event.state) {
window.location.reload(); // Event code
}
});
I know you asked against it, but you should really just use History.js as it clears up a million browser incompatibilities. I went the manual fix route only to later find there were more and more problems that you'll only find out way down the road. It really isn't that hard nowadays:
<script src="//cdnjs.cloudflare.com/ajax/libs/history.js/1.8/native.history.min.js" type="text/javascript"></script>
And read the api at https://github.com/browserstate/history.js
This solved the problem for me. All I did was set a timeout function which delays the execution of the function long enough to miss the popstate event that is fired on pageload
if (history && history.pushState) {
setTimeout(function(){
$(window).bind("popstate", function() {
$.getScript(location.href);
});
},3000);
}
You can create an event and fire it after your onload handler.
var evt = document.createEvent("PopStateEvent");
evt.initPopStateEvent("popstate", false, false, { .. state object ..});
window.dispatchEvent(evt);
Note, this is slightly broke in Chrome/Safari, but I have submitted the patch in to WebKit and it should be available soon, but it is the "most correct" way.
This worked for me in Firefox and Chrome
window.onpopstate = function(event) { //back button click
console.log("onpopstate");
if (event.state) {
window.location.reload();
}
};

How can I detect an address bar change with JavaScript?

I have a Ajax heavy application that may have a URL such as
http://example.com/myApp/#page=1
When a user manipulates the site, the address bar can change to something like
http://example.com/myApp/#page=5
without reloading the page.
My problem is the following sequence:
A user bookmarks the first URL.
The user manipulates the application such that the second URL is the current state.
The user clicks on the bookmark created in step 1.
The URL in the address bar changes from http://example.com/myApp/#page=5 to http://example.com/myApp/#page=1, but I don't know of a way to detect the change happened.
If I detect a change some JavaScript would act on it.
HTML5 introduces a hashchange event which allows you to register for notifications of url hash changes without polling for them with a timer.
It it supported by all major browsers (Firefox 3.6, IE8, Chrome, other Webkit-based browsers), but I'd still highly suggest to use a library which handles the event for you - i.e. by using a timer in browsers not supporting the HTML5 event and using the event otherwise.
window.onhashchange = function() {
alert("hashtag changed");
};
For further information on the event, see https://developer.mozilla.org/en/dom/window.onhashchange and http://msdn.microsoft.com/en-us/library/cc288209%28VS.85%29.aspx.
check the current address periodically using setTimeout/interval:
var oldLocation = location.href;
setInterval(function() {
if(location.href != oldLocation) {
// do your action
oldLocation = location.href
}
}, 1000); // check every second
You should extend the location object to expose an event that you can bind to.
ie:
window.location.prototype.changed = function(e){};
(function() //create a scope so 'location' is not global
{
var location = window.location.href;
setInterval(function()
{
if(location != window.location.href)
{
location = window.location.href;
window.location.changed(location);
}
}, 1000);
})();
window.location.changed = function(e)
{
console.log(e);//outputs http://newhref.com
//this is fired when the window changes location
}
SWFaddress is an excellent library for these types of things.

Categories

Resources