173

I'm writing a Greasemonkey script for a site which at some point modifies location.href.

How can I get an event (via window.addEventListener or something similar) when window.location.href changes on a page? I also need access to the DOM of the document pointing to the new/modified url.

I've seen other solutions which involve timeouts and polling, but I'd like to avoid that if possible.

newenglander
  • 2,019
  • 24
  • 55
Johan Dahlin
  • 25,300
  • 6
  • 40
  • 55
  • 1
    For YouTube: [javascript - How to detect page navigation on YouTube and modify HTML before page is rendered? - Stack Overflow](https://stackoverflow.com/questions/34077641/how-to-detect-page-navigation-on-youtube-and-modify-html-before-page-is-rendered) – user202729 Mar 18 '20 at 13:21

12 Answers12

189

I use this script in my extension "Grab Any Media" and work fine ( like youtube case )

var oldHref = document.location.href;

window.onload = function() {
    var bodyList = document.querySelector("body")

    var observer = new MutationObserver(function(mutations) {
        if (oldHref != document.location.href) {
            oldHref = document.location.href;
            /* Changed ! your code here */
        }
    });
    
    var config = {
        childList: true,
        subtree: true
    };
    
    observer.observe(bodyList, config);
};

With the latest javascript specification

const observeUrlChange = () => {
  let oldHref = document.location.href;
  const body = document.querySelector("body");
  const observer = new MutationObserver(mutations => {
    if (oldHref !== document.location.href) {
      oldHref = document.location.href;
      /* Changed ! your code here */
    }
  });
  observer.observe(body, { childList: true, subtree: true });
};

window.onload = observeUrlChange;

Compressed with OpenAI

window.onload = () => new MutationObserver(mutations => mutations.forEach(() => oldHref !== document.location.href && (oldHref = document.location.href, /* Changed ! your code here */))).observe(document.querySelector("body"), { childList: true, subtree: true });
AdrienBrault
  • 7,747
  • 4
  • 31
  • 42
Leonardo Ciaccio
  • 2,846
  • 1
  • 15
  • 17
  • 1
    The only solution that could have worked for youtube: [Report Only] Refused to create a worker from 'https://www.youtube.com/sw.js' because it violates the following Content Security Policy directive: "worker-src 'none'". – Simon Meusel Apr 22 '18 at 14:15
  • MutationObserver –  Aug 22 '19 at 05:43
  • 7
    Worked out of the box with no trouble for my use case, God bless! It's annoying there's no native event for this yet (popstate did not work for me) but one day! I would use `window.addEventListener("load", () => {})` instead of window.onload though :) – Sanchit Batra Mar 09 '20 at 09:46
  • this works, detects history.pushState even in extension context – YangombiUmpakati Mar 12 '20 at 12:45
  • 1
    This is the smartest solution to this problem (listening for changes in the body). Also works within React to detect URL changes. Thanks a lot! – s.r. Oct 24 '21 at 19:44
  • 1
    put this in content script but it is not firing. – S. W. G. Mar 24 '22 at 13:03
  • 2
    Hello I am new to using MutationObservers and love this solution,its the only one I found that worked sofar for YT redirects. Just wanted to note that you could make it more efficient by moving the `if (oldHref != document.location.href)` check before your `forEach` call as you would only need to do the check once. – SShah Apr 30 '22 at 11:34
  • This doesn't work if I just click a link, it changes the location but the body itself doesn't change instantly, only after some user interaction. – Богуслав Павлишинець Jan 09 '23 at 11:51
  • You are not allowed to reassign to variables declared with `const`; it needs to be `let oldHref = ''` for it to work. Also, you could save space by passing `document.body` directly, instead of declaring a variable for that. But thanks for the innovative use of MutationObserver (if anything in the DOM changes, check if the URL changed also => clever :) ). – FFD Feb 25 '23 at 18:52
116

popstate event:

The popstate event is fired when the active history entry changes. [...] The popstate event is only triggered by doing a browser action such as a click on the back button (or calling history.back() in JavaScript)

So, listening to popstate event and sending a popstate event when using history.pushState() should be enough to take action on href change:

window.addEventListener('popstate', listener);

const pushUrl = (href) => {
  history.pushState({}, '', href);
  window.dispatchEvent(new Event('popstate'));
};
PHF
  • 1,819
  • 1
  • 15
  • 18
  • 2
    But will the popstate fire before the content is loaded? – user2782001 Mar 23 '17 at 21:29
  • 31
    unusable if don't have any control over the piece of code that `pushState` – Nathan Gouy Feb 05 '20 at 17:22
  • 5
    This does not always work, it relies on a popstate event to be fired. For example navigating within youtube it won't trigger, only if you press back or forward in history – TrySpace May 27 '20 at 08:37
  • this assume you have visibility or know if pushState is called.. it won't work everywhere, like comments above state as well – mikey Feb 12 '21 at 01:05
  • If you don't control the callers of `history.pushState`, then you can instead override `history.pushState` with a function that delegates to the original function, then calls your `listener` function. – Ben Hutchison Jan 22 '23 at 13:08
  • Fixed URL... MDN is acting up...: https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event – Andrew Feb 07 '23 at 04:32
40

You can't avoid polling, there isn't any event for href change.

Using intervals is quite light anyways if you don't go overboard. Checking the href every 50ms or so will not have any significant effect on performance if you're worried about that.

Tatu Ulmanen
  • 123,288
  • 34
  • 187
  • 185
  • 4
    @AlexanderMills: fortunately there are these new solutions of `popstate` and `hashchange`. – serv-inc Apr 24 '17 at 20:07
  • 2
    This is not true. – inf3rno Aug 18 '18 at 12:42
  • @MartinZvarík Even in 2010 it was possible to use the unload event plus a micro or macro task to get the new document loaded. – inf3rno Oct 17 '18 at 02:06
  • 24
    Unfortunately this answer is still correct in 2020. [popstate only fires when the user uses the forward/back buttons](https://stackoverflow.com/a/14150776/5776910) and hashchange only fires for hash changes. Unless you have control over the code that's causing the location to change, you have to poll ‍♀️ – Rico Kahler Apr 10 '20 at 20:23
  • @RicoKahler yep, i tested and saw the same, `popsate` & `hashchange` don't work with some app frameworks, the url does change as seen but these 2 are not fired – Dee May 08 '23 at 09:59
27

There is a default onhashchange event that you can use.

Documented HERE

And can be used like this:

function locationHashChanged( e ) {
    console.log( location.hash );
    console.log( e.oldURL, e.newURL );
    if ( location.hash === "#pageX" ) {
        pageX();
    }
}

window.onhashchange = locationHashChanged;

If the browser doesn't support oldURL and newURL you can bind it like this:

//let this snippet run before your hashChange event binding code
if( !window.HashChangeEvent )( function() {
    let lastURL = document.URL;
    window.addEventListener( "hashchange", function( event ) {
        Object.defineProperty( event, "oldURL", { enumerable: true, configurable: true, value: lastURL } );
        Object.defineProperty( event, "newURL", { enumerable: true, configurable: true, value: document.URL } );
        lastURL = document.URL;
    } );
} () );
iSkore
  • 7,394
  • 3
  • 34
  • 59
13

Through Jquery, just try

$(window).on('beforeunload', function () {
    //your code goes here on location change 
});

By using javascript:

window.addEventListener("beforeunload", function (event) {
   //your code goes here on location change 
});

Refer Document : https://developer.mozilla.org/en-US/docs/Web/Events/beforeunload

10

In supporting browsers, use the new Navigation API which is currently being implemented as a replacement for the History API:

navigation.addEventListener('navigate', () => {
  console.log('page changed');
});

This already works in Chromium browsers, but Firefox and Safari are currently missing it as of March 2023.

It simplifies the problem considerably, and is being designed specifically with single page applications in mind which is the main sore spot with existing solutions. No more nasty expensive MutationObserver calls!

lawrence-witt
  • 8,094
  • 3
  • 13
  • 32
7

Have you tried beforeUnload? This event fires immediately before the page responds to a navigation request, and this should include the modification of the href.

    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
    <HTML>
    <HEAD>
    <TITLE></TITLE>
    <META NAME="Generator" CONTENT="TextPad 4.6">
    <META NAME="Author" CONTENT="?">
    <META NAME="Keywords" CONTENT="?">
    <META NAME="Description" CONTENT="?">
    </HEAD>

         <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.3/jquery.min.js" type="text/javascript"></script>
            <script type="text/javascript">
            $(document).ready(function(){
                $(window).unload(
                        function(event) {
                            alert("navigating");
                        }
                );
                $("#theButton").click(
                    function(event){
                        alert("Starting navigation");
                        window.location.href = "http://www.bbc.co.uk";
                    }
                );

            });
            </script>


    <BODY BGCOLOR="#FFFFFF" TEXT="#000000" LINK="#FF0000" VLINK="#800000" ALINK="#FF00FF" BACKGROUND="?">

        <button id="theButton">Click to navigate</button>

        <a href="http://www.google.co.uk"> Google</a>
    </BODY>
    </HTML>

Beware, however, that your event will fire whenever you navigate away from the page, whether this is because of the script, or somebody clicking on a link. Your real challenge, is detecting the different reasons for the event being fired. (If this is important to your logic)

belugabob
  • 4,302
  • 22
  • 22
  • 1
    I'm not sure if this will work because often the hash changes do no involve a page reload. – samandmoore Aug 19 '10 at 13:32
  • I need access to the new DOM as well, I updated the question to clarify that. – Johan Dahlin Aug 19 '10 at 13:33
  • OK - didn't consider the `hash` situation - will think about that one. As for being able to access the new DOM, then you are out of luck (as far as accesssing this from an event handler is concerned) as the event will fire before the new DOM has been loaded. You might be able to incorporate the logic into the onload event of the new page, but you may have the same issues with respect to identifying whether you need to carry out the logic for every load of that page. Can you provide some more details about what you are trying to achieve, including page flow? – belugabob Aug 19 '10 at 13:38
  • 1
    I just tried accessing the location.href inside the onbeforeunload event handler, and it shows the original url, not the target url. – CMCDragonkai Feb 24 '17 at 11:42
  • Beforeunload can cancel the unloading of the page, so the unload event is better, if you don't want to cancel navigation. – inf3rno Aug 18 '18 at 12:44
6

Try this script which will let you run code whenever the URL changes (without a pageload, like an Single Page Application):

var previousUrl = '';
var observer = new MutationObserver(function(mutations) {
  if (location.href !== previousUrl) {
      previousUrl = location.href;
      console.log(`URL changed to ${location.href}`);
    }
});
d-_-b
  • 21,536
  • 40
  • 150
  • 256
5

based on the answer from "Leonardo Ciaccio", modified code is here: i.e. removed for loop and reassign the Body Element if it is removed

window.addEventListener("load", function () {
  let oldHref = document.location.href,
    bodyDOM = document.querySelector("body");
  function checkModifiedBody() {
    let tmp = document.querySelector("body");
    if (tmp != bodyDOM) {
      bodyDOM = tmp;
      observer.observe(bodyDOM, config);
    }
  }
  const observer = new MutationObserver(function (mutations) {
    if (oldHref != document.location.href) {
      oldHref = document.location.href;
      console.log("the location href is changed!");
      window.requestAnimationFrame(checkModifiedBody)
    }
  });
  const config = {
    childList: true,
    subtree: true
  };
  observer.observe(bodyDOM, config);
}, false);
Chester Fung
  • 182
  • 1
  • 10
3

Well there is 2 ways to change the location.href. Either you can write location.href = "y.html", which reloads the page or can use the history API which does not reload the page. I experimented with the first a lot recently.

If you open a child window and capture the load of the child page from the parent window, then different browsers behave very differently. The only thing that is common, that they remove the old document and add a new one, so for example adding readystatechange or load event handlers to the old document does not have any effect. Most of the browsers remove the event handlers from the window object too, the only exception is Firefox. In Chrome with Karma runner and in Firefox you can capture the new document in the loading readyState if you use unload + next tick. So you can add for example a load event handler or a readystatechange event handler or just log that the browser is loading a page with a new URI. In Chrome with manual testing (probably GreaseMonkey too) and in Opera, PhantomJS, IE10, IE11 you cannot capture the new document in the loading state. In those browsers the unload + next tick calls the callback a few hundred msecs later than the load event of the page fires. The delay is typically 100 to 300 msecs, but opera simetime makes a 750 msec delay for next tick, which is scary. So if you want a consistent result in all browsers, then you do what you want to after the load event, but there is no guarantee the location won't be overridden before that.

var uuid = "win." + Math.random();
var timeOrigin = new Date();
var win = window.open("about:blank", uuid, "menubar=yes,location=yes,resizable=yes,scrollbars=yes,status=yes");


var callBacks = [];
var uglyHax = function (){
    var done = function (){
        uglyHax();
        callBacks.forEach(function (cb){
            cb();
        });
    };
    win.addEventListener("unload", function unloadListener(){
        win.removeEventListener("unload", unloadListener); // Firefox remembers, other browsers don't
        setTimeout(function (){
            // IE10, IE11, Opera, PhantomJS, Chrome has a complete new document at this point
            // Chrome on Karma, Firefox has a loading new document at this point
            win.document.readyState; // IE10 and IE11 sometimes fails if I don't access it twice, idk. how or why
            if (win.document.readyState === "complete")
                done();
            else
                win.addEventListener("load", function (){
                    setTimeout(done, 0);
                });
        }, 0);
    });
};
uglyHax();


callBacks.push(function (){
    console.log("cb", win.location.href, win.document.readyState);
    if (win.location.href !== "http://localhost:4444/y.html")
        win.location.href = "http://localhost:4444/y.html";
    else
        console.log("done");
});
win.location.href = "http://localhost:4444/x.html";

If you run your script only in Firefox, then you can use a simplified version and capture the document in a loading state, so for example a script on the loaded page cannot navigate away before you log the URI change:

var uuid = "win." + Math.random();
var timeOrigin = new Date();
var win = window.open("about:blank", uuid, "menubar=yes,location=yes,resizable=yes,scrollbars=yes,status=yes");


var callBacks = [];
win.addEventListener("unload", function unloadListener(){
    setTimeout(function (){
        callBacks.forEach(function (cb){
            cb();
        });
    }, 0);
});


callBacks.push(function (){
    console.log("cb", win.location.href, win.document.readyState);
    // be aware that the page is in loading readyState, 
    // so if you rewrite the location here, the actual page will be never loaded, just the new one
    if (win.location.href !== "http://localhost:4444/y.html")
        win.location.href = "http://localhost:4444/y.html";
    else
        console.log("done");
});
win.location.href = "http://localhost:4444/x.html";

If we are talking about single page applications which change the hash part of the URI, or use the history API, then you can use the hashchange and the popstate events of the window respectively. Those can capture even if you move in history back and forward until you stay on the same page. The document does not changes by those and the page is not really reloaded.

inf3rno
  • 24,976
  • 11
  • 115
  • 197
3

ReactJS and other SPA applications use the history object

You can listen to window.history updating with the following code:

function watchHistoryEvents() {
    const { pushState, replaceState } = window.history;

    window.history.pushState = function (...args) {
        pushState.apply(window.history, args);
        window.dispatchEvent(new Event('pushState'));
    };

    window.history.replaceState = function (...args) {
        replaceState.apply(window.history, args);
        window.dispatchEvent(new Event('replaceState'));
    };

    window.addEventListener('popstate', () => console.log('popstate event'));
    window.addEventListener('replaceState', () => console.log('replaceState event'));
    window.addEventListener('pushState', () => console.log('pushState event'));
}
watchHistoryEvents();
zemil
  • 3,235
  • 2
  • 24
  • 33
0

Also, I found a useful solution with MutationObserver:

function watchLocation() {
    const observable = () => document.location.pathname;

    let oldValue = observable();
    const observer = new MutationObserver(() => {
        const newValue = observable();

        if (oldValue !== newValue) {
            console.log(`changed: ${oldValue} -> ${newValue}`);
            oldValue = newValue;
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });
}

MutationObserver API Documantation

zemil
  • 3,235
  • 2
  • 24
  • 33