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
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,The above could result in slow executions and slow DOM manipulations.
Possible Solutions
$(document).on("click", "#btnEZsaltGo", ezsaltButtonClicked);
document
object which is always present, and if the event target matches#btnEZsaltGo
selector thenezsaltButtonClicked
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 theezsaltButtonClicked
handler.As
@Brahma Dev
suggested, tryezsaltButton.on("click", ezsaltButtonClicked);
and use it beforeezsaltButton
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.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.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.
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:
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 returnstrue
if the button was found and the event was attached, andfalse
otherwise.The observer is set up to monitor the entire
document.body
for changes, including the addition of child elements. That is specified in theconfig
object withchildList: true
andsubtree: true
, meaning it will observe not just direct children ofbody
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 filemanifest.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 thecontent_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.The
js
key specifies the JavaScript file(s) that contain your content script logic. If yourMutationObserver
logic is in a file namedcontentScript.js
, you would list it here. This file must be included in your extension’s directory: If you specify"js": ["contentScript.js"]
, thecontentScript.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.