Search code examples
javascriptevent-handlingfirefox-addonfirefox-addon-webextensions

How to prevent previously added event listeners from being called?


I am creating a content script as a browser extension.

I wish to change the functionality of some of the page, and I have thus identified I need to stop certain events. Normally, one could register an event listener on the event and call stopImmediatePropagation, but this only works if your event listener is the first one registered, which mine, as a content script, is not.

Before continuing with my own convoluted solution that almost works, I would like to ask if you see some simple solution to this problem. If so, the rest of the post is unnecessary.

The solution I found was to have two scripts. The first executes before the page loads and replaces the prototype.addEventListener so that when it is first called, a firstListener function is created and added first as a listener to the event type of my choice. The actual addEventListener is then called, and the addEventListener method is switched back to the original.
The code for this follows:

const injection = document.createElement("script");
injection.textContent = `HTMLSelectElement.prototype.originalAddEventListener = HTMLSelectElement.prototype.addEventListener;
HTMLSelectElement.prototype.addEventListener = function(a, b, c) {
    this.firstListener = event => { };
    this.originalAddEventListener("change", event => this.firstListener(event));
    this.originalAddEventListener(a, b, c);
    this.addEventListener = this.originalAddEventListener;
};`;
(document.head || document.documentElement).appendChild(injection);
injection.remove();

The second script, containing the main functionality of my extension, can then be loaded after the page has loaded, as normal. It can find the desired elements and replace their firstListener method with whatever they want.
An example of this follows:

document.body.querySelectorAll("select").forEach(element => {
    element.firstListener = event => event.stopImmediatePropagation(); //I could, of course, have more complicated processing logic here
});

Problem is, for some reason, this second script does not overwrite the firstListener method. Calling the same script from the console, however, works. Why is that, and how could I get around it?


Apparently content scripts are somewhat isolated from the web page, and get a clean view of the page contents, which was causing the code to work in the console, but not in my content script. Firefox, at least, offers some ways to get past that.

Simply using element.wrappedJSObject.firstListener did allow me to redefine the function, but now the web page lacked the necessary permissions to call this new function, as it belonged to my content script. Instead exportFunction(firstListenerFunc, element, { defineAs: "firstListener" }) did the trick.

After learning about exportFunction, however, I rewrote the first script as such instead:

const origAddEventListener = HTMLSelectElement.prototype.addEventListener;
exportFunction(tempAddEventListener, HTMLSelectElement.prototype, { defineAs: "addEventListener" });
function tempAddEventListener(type, ev, options) {
    this.firstListener = event => { };
    origAddEventListener.call(this, "change", event => this.firstListener(event));
    exportFunction(origAddEventListener, this, { defineAs: "addEventListener" });
    this.addEventListener(type, ev, options);
}

This allows me to keep firstListener as a function that exists only in my content scripts and keeps the page objects nearly identical to their original form, at least from their perspective.


Solution

  • Someone brought to my attention a generally easier way to solve this problem, using the lesser known phase of event processing called "Capturing".

    This answer mostly relies on information from here, if you want a more thorough explanation on the subject.

    In my case, I wanted to stop change events on some select elements. Most events registered are called during the "Bubbling" phase, which starts at the target element and is propagated upwards through each of its parents.
    To stop all such events, you can just register an event listener on the parent of the target element, set the capture option to true, and call stopImmediatePropagation from there.
    An example:

    document.body.querySelectorAll("select").forEach(dropdown => {
        dropdown.parentElement.addEventListener("change", event => {
            if (event.target !== dropdown) { //Make sure that this event was indeed on the element we wanted to track
                return;
            }
            //Insert whatever other logic here
            event.stopImmediatePropagation();
        }, { capture: true });
    });
    

    The "Capturing" phase happens before the "Bubbling" phase, and in reverse order. So it starts from html, then body, then div, then our select, as an example. If there are some event listeners listening for this event on the capture phase, you could always add your event listener even closer to the root of the document to intercept those as well.