skip to Main Content

I have a Chrome Extension (manifest version 3) which adds a custom button to a few different ServiceTitan web pages and then attaches a click event handler to that button. This all works perfectly when it is done within a single browser page only. But when I open multiple web pages in different tabs, and the button appears on each page, my click event seems to not attach sometimes to the button on one of the tabs/pages. Still works fine within other tabs. So it’s somewhat sporadic, but also quite reproducible. As I navigate around to different pages and observe my custom button, and click it, very soon I’ll encounter a tab in which the click event didn’t attach to my button.

Here’s how I attach my click event:

$("#btnEZsaltGo").on("click", function(){ezsaltButtonClicked()});

Again, this works great most of the time, but with multiple tabs in play, sometimes it doesn’t. But the button always appears, in all cases.

Note: I am not using a background script, just a content script.

How can I make my click event attach to my custom button in each tab, in all cases?

Here is full source:

var interval = 0;
var rootEZsaltURL;

console.log("EZsaltGo extension loaded...");

if(location.href.indexOf("next.servicetitan.com") > -1 || location.href.indexOf("integration.servicetitan.com") > -1){ // Sandbox ServiceTitan
    rootEZsaltURL = "https://test.ezsaltgo.com/ServiceTitanLanding.php?CustomerID="; // TEST EzsaltGo
}else if(location.href.indexOf("go.servicetitan.com") > -1){ // LIVE ServiceTitan
    rootEZsaltURL = "https://ezsaltgo.com/ServiceTitanLanding.php?CustomerID="; // LIVE EZsaltGo
}

(function() { // initial page load (static page content)
    interval = setInterval(function(){ checkForEZsaltButton(); }, 1000);
})();

function checkForEZsaltButton() {
    if($("#btnEZsaltGo").length == 0){ // only create the ezsalt button if it's not already there
        var insertBeforeAnchorElement; // insert the EZsaltGo button BEFORE this element
        var insertAfterAnchorElement; // insert the EZsaltGo button AFTER this element
        var pageType;
        var buttonMarginLeft = 0;
        var buttonMarginRight = 0;
        var hasBillingAddress = ($("span:contains('Billing Address')").length > 0);
        var hasServiceAddress = ($("span:contains('Service Address')").length > 0);
        var hasMoreActionsMenu = ($('div[data-testid="more-actions-menu"]').length > 0);
        var hasCustomerIDSpan = ($("span:contains('Customer ID')").length > 0);
        var hasCustomerIDLink = ($('a[role="link"][href*="/Customer/"]').length > 0);
        var isModal = ($("h2.Modal__header-title").length > 0);
        
        if((hasBillingAddress || hasServiceAddress) && hasMoreActionsMenu && (hasCustomerIDSpan || (!hasCustomerIDSpan && hasCustomerIDLink))){
            // clearInterval(interval);
            // console.log("Customer DIV loaded!! Interval stopped.");
            
            if(isModal){
                // we're inside the modal customer window
                pageType = "modal";
                insertAfterAnchorElement = $("h2.Modal__header-title"); // 
            }else{
                // we're inside a normal ST web page - let's figure out which page exactly

                if(location.href.indexOf("/customer/") > 0) pageType = "customer";
                if(location.href.indexOf("/location/") > 0) pageType = "location";

                if(pageType == "customer"){ 
                    insertBeforeAnchorElement = $("div.ButtonGroup").parent();
                    // no margin padding needed
                }
                
                if(pageType == "location"){ 
                    insertBeforeAnchorElement = $("div.ButtonGroup").parent().parent();
                    buttonMarginRight = 19;
                }
            }
            
            var ezsaltButton = $('<button id="btnEZsaltGo" class="Button Button--blue Button--solid Button--focus-visible" style="' + (buttonMarginRight > 0 ? "margin-right: " + buttonMarginRight + "px" : "") + (buttonMarginLeft > 0 ? "margin-left: " + buttonMarginLeft + "px" : "") + '">EZsaltGo</button>');
            
            if(insertBeforeAnchorElement){
                ezsaltButton.insertBefore(insertBeforeAnchorElement);
            }else if(insertAfterAnchorElement){
                ezsaltButton.insertAfter(insertAfterAnchorElement);
            }else{
                return; // we don't have anything to anchor against, so just return
            }
            
            $("#btnEZsaltGo").on("click", function(){ezsaltButtonClicked()});
            
            // attach click event to the little X at the top right of the modal - if that is clicked, the modal will close, and we need to resume checking
            if($(".Modal__header-close").length > 0){
                $(".Modal__header-close").on("click", function(){
                    //interval = setInterval(function(){ checkForEZsaltButton(); }, 1000);
                    console.log("Modal closed. Interval check resuming...");
                });
            }
        }
    }else{
        // check whether click event handler has been attached - doesn't work

        // var clickHandlers = $("#btnEZsaltGo").data('events')?.click; // clickHandlers always null even when button is there and click handler is attached
        // if(clickHandlers && clickHandlers.length > 0) doSomething(); //  clickHandlers always null even when button is there and click handler is attached
    }
}

function ezsaltButtonClicked(){
    console.log("EZsalt button clicked!");
    
    if(rootEZsaltURL && rootEZsaltURL != ""){
        var customerID = getCustomerIDForEZsalt();
        
        if(customerID && customerID != ""){
            window.open(rootEZsaltURL + customerID);    
        }else{
            alert("Unable to locate Customer ID. Contact EZsalt."); 
        }
    }else{
        alert("EZsalt URL could not be obtained. Contact EZsalt."); 
    }
}

function getCustomerIDForEZsalt(){
    var hasCustomerIDSpan = ($("span:contains('Customer ID')").length > 0);
    
    if(hasCustomerIDSpan){
        var customerIDSpan = $("span:contains('Customer ID')");
        var customerIDChunk = customerIDSpan.next('p');
        return $.trim(customerIDChunk.html());
    }else{
        var hasCustomerIDLink = ($('a[role="link"][href*="/Customer/"]').length > 0);
        
        if(hasCustomerIDLink){
            var hrefChunk = $('a[role="link"][href*="/Customer/"]').attr('href');
            var customerID = $.trim(hrefChunk.match(/d+/)[0]);
            return customerID;
        }
    }
    
    return ""; // something went wrong - should never get this far
}

I have experimented with this function as a possible way to solve it:

function isClickHandlerAttachedToButton(){
    // check whether click event handler has been attached
    var clickHandlers = $("#btnEZsaltGo").data('events')?.click;
    var clickHandlerIsAttached = (clickHandlers && clickHandlers.length > 0);
    return clickHandlerIsAttached;
}

But it returns null 100% of the time, even when the click event is obviously attached to the button because I can click it and it works (the new page launches).

2

Answers


  1. Possible Cause

    Your DOM is not ready after ezsaltButton insertion, by the time you execute $("#btnEZsaltGo").on("click", function(){ezsaltButtonClicked()}); So, $("#btnEZsaltGo") returns an empty jQuery object. This may not happen everytime, but can happen,

    • if the page is heavy with scripts and has too many DOM elements,
    • if page does complex executions keeping the main thread very busy,
    • if too many tabs added, resulting in less CPU, memory resources allocated for each tab.

    The above could result in slow executions and slow DOM manipulations.


    Possible Solutions

    1. $(document).on("click", "#btnEZsaltGo", ezsaltButtonClicked);

      • The click event is attached to document object which is always present, and if the event target matches #btnEZsaltGo selector then ezsaltButtonClicked handler is invoked.
      • #btnEZsaltGo element need not be present on the DOM at the time of executing the above code, but must be present at the time of clicking the button to reach the ezsaltButtonClicked handler.
      • This format could be a very tiny bit slower than the regular jQuery event listener attachment.
    2. As @Brahma Dev suggested, try ezsaltButton.on("click", ezsaltButtonClicked); and use it before ezsaltButton insertion.

      • ezsaltButton jQuery object is readily available always, as you have initialised it just a few statements prior, whereas $("#btnEZsaltGo") will search for the element from the DOM and prepare a jQuery object if found.
      • By using ezsaltButton.on("click", ... you ensure the click event handler is always attached to the expected button even before you attach it to DOM. Once you attach it to the DOM, the button and its click event is available for interaction.
      • Simple & Effective.
    Login or Signup to reply.
  2. When a content script is executed in each tab, it operates independently within that tab’s context. The sporadic behavior you observed, where the click event does not attach sometimes, could be due to timing issues, the dynamic loading of page content, or the script execution order.

    [Browser]--(contains)-->[Tab 1]--(runs)-->[Content Script]
                   |-(contains)-->[Tab 2]--(runs)-->[Content Script]
                   |-(contains)-->[Tab N]--(runs)-->[Content Script]
                   'Each Content Script independently tries to attach a click event to the custom button'
    

    You could try and take ideas from "Use a MutationObserver to Handle DOM Nodes that Don’t Exist Yet ", from Alex MacArthur, which is about how to efficiently handle dynamically added elements in the DOM using the MutationObserver API.

    Adapting the example from the article:

    // Function to attach click event listener to the EZsaltGo button
    function attachClickListener() {
      const ezsaltButton = document.getElementById('btnEZsaltGo');
      if (ezsaltButton) {
        ezsaltButton.addEventListener('click', ezsaltButtonClicked);
        console.log('EZsaltGo click event attached.');
        return true; // Indicates the button was found and event was attached
      }
      return false; // Indicates the button was not found
    }
    
    // Function to handle EZsaltGo button click events
    function ezsaltButtonClicked() {
      console.log('EZsalt button clicked!');
      // Your existing logic for when the button is clicked
    }
    
    // Setup MutationObserver to monitor for DOM changes
    const observer = new MutationObserver((mutations, observer) => {
      // Attempt to attach click event listener to the EZsaltGo button
      if (attachClickListener()) {
        // If the button is found and the event listener is attached, disconnect the observer
        observer.disconnect();
        console.log('MutationObserver disconnected.');
      }
    });
    
    // Specify what changes to observe: child elements being added or removed
    const config = { childList: true, subtree: true };
    
    // Start observing the entire body for changes
    observer.observe(document.body, config);
    
    // Try to attach the click event listener immediately in case the button already exists
    attachClickListener();
    

    The attachClickListener() function attempts to find the #btnEZsaltGo button in the DOM. If it finds the button, it attaches the click event listener to it and logs a message to the console. The function returns true if the button was found and the event was attached, and false otherwise.

    The observer is set up to monitor the entire document.body for changes, including the addition of child elements. That is specified in the config object with childList: true and subtree: true, meaning it will observe not just direct children of body but also deeper descendants.
    Before starting to observe DOM changes, the script immediately calls attachClickListener() in case the button exists in the DOM at the time the script runs. That covers the scenario where the button is part of the initial page load.

    As the page content changes (e.g., as a result of AJAX requests or user actions), the observer’s callback function attempts to attach the click event listener to the button. If successful, it disconnects the observer to stop monitoring for changes, as its job is done.


    To make sure the content script, which includes the MutationObserver logic, is correctly injected into the pages where you want to attach event listeners to the custom button, you need to modify the Chrome Extension’s manifest file manifest.json

    Determine the URLs of the pages where your custom button will appear. For example, if your button is meant to appear on all pages of a specific domain, your target URL pattern might be something like "*://*.example.com/*".

    In your extension’s manifest.json file, you will specify the content script settings under the content_scripts section. That includes the paths to the JavaScript files that should be injected, as well as the patterns of URLs where the scripts should run.

    {
      "manifest_version": 3,
      "name": "Your Extension Name",
      "version": "1.0",
      "permissions": ["activeTab"],
      "content_scripts": [
        {
          "matches": ["*://*.example.com/*"],
          "js": ["contentScript.js"],
          "run_at": "document_idle"
        }
      ]
    }
    

    The js key specifies the JavaScript file(s) that contain your content script logic. If your MutationObserver logic is in a file named contentScript.js, you would list it here. This file must be included in your extension’s directory: If you specify "js": ["contentScript.js"], the contentScript.js file should be located at the root of your extension’s directory.

    The run_at optional key specifies when the script should be injected. "document_idle" means the script will run after the document has been fully parsed but possibly before other resources like images have finished loading.

    Then try and load it into Chrome via the Extensions page (chrome://extensions/), enabling "Developer mode" and using the "Load unpacked" option to select your extension’s directory.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search