Why is it soo hard to inject a content script only once at site load?

What to do

  • I need to use browser.tabs.executeScript to inject a content script.
  • It should only run once for each site. (e.g. after reload, when a new tab is created and a site is loaded)
  • Basically all like when I would specify it in content_scripts in the manifest.json.

So why not use the declarative way with manifest.json?

(ugh, that code in the headline is huge, looks like a CSS issue…)

Simply, because the code I need to inject is generated programmatically. Actually, in my case, it is the ID of the corresponding tab. Basically, all I want to do is letting my content script know in which tab (per ID) it is running.
This is later needed for communicating to the background script and applying further CSS changes.

Show me code

Thus, what i do is this:

browser.tabs.executeScript(tab.id, {
    code: `const MY_TAB_ID = ${tab.id}`,
    allFrames: true,
    runAt: "document_start"
});

Now, this needs to be triggered for each new tab or reload etc. So I’ve looked at the tabs.onCreated, but this obviously misses tab reloads. So onUpdated seems to be the right way.
However, it triggers for all sort of arcane tab updates. Hash anchor changed? -> Update. Tab muted -> Update.
So I needed to filter it and based on looking at some of the updates that come in, I came up with this filter:

const TAB_FILTER_URLS = ["http://*/*", "https://*/*"];
// [...]
browser.tabs.onUpdated.addListener((tabId, changeInfo, tabInfo) => {
    /*
    only run additional injection if:
        * the url is changed (when navigating to a new tab)
        * the status is still loading (to exclude simple #anchor changes)
    */
    if ("url" in changeInfo && tabInfo.status === "loading") {
        console.log("insert content script into tab", tabId, tabInfo);
        insertContentScriptFast(tabInfo).catch(console.error); // function executes code above
    }
}, {
    urls: TAB_FILTER_URLS
});

So what is the problem?

Now I notice, this still runs twice for a site load. (tested on https://pinafore.social/)
This is really strange…

So why is it so hard to run a content script only once? (per site load)

And BTW, have you found a solution on how to do it properly? (no,l checking inside the content script whether it has run already, is no solution. See below.)

Related

Even the official add-on example “beastify” choose the easy way of just checking in the content script whether it has ruin already.

While I could do the same, this is not only an ugly workaround, but actually hard for me, as time matters for me (because of another issue) and I cannot inject a content script, check the result, and inject the other one, only afterwards…
So I would need to check that each time…

If you want to see the bigger content, here is the whole JS file:

First of all, I think you can use the Content script defined in manifest after all.
If the only issue you have is missing some “tabId”, you can just ask background script for the value once the content scripts starts to load.

But even with browser.tabs.executeScript I would rather use a “.js” file and await the returned Promise and then send it the “tabId” through sendMessage (if content script registers message handler on the top level execution, it will receive it).

Regarding the problem with with dynamic injecting - only once - when page loads… I really feel your pain here :slight_smile:. I’ve been trying different approaches over the years and I’ve come up with three options:

  1. the “beastify” approach with preventing code from running multiple times is actually ok for some specific use-cases
  2. you can register browser.tabs.onUpdated listener and then inject when TabStatus changes to “loading”. To prevent multiple injections, you ping the tab to see if it’s already there, something like this:
browser.tabs.onUpdated.addListener(onTabUpdated);

async function onTabUpdated (tabId, changeInfo, tab) {
  // when page starts to load, it has LOADING state and URL in the change state object (but who knows if we can count on it...) 
  if (!(changeInfo.status === browser.tabs.TabStatus.LOADING && changeInfo.url)) return;    
  // make sure we don't inject it multiple times
  if (await browser.tabs.sendMessage(tabId, {type: 'ping'}).catch(() => false)) return;     
  await browser.tabs.executeScript(tabId, {file: 'cs.js', runAt: 'document_start'}); 
}
  1. the best solution seems to be the browser.webNavigation.onDOMContentLoaded event. If I understand the docs correctly, it will fire only once per page (see also the diagram). The only issue here is that you need a webNavigation permission for that. For example:
browser.webNavigation.onDOMContentLoaded.addListener(onDomLoaded);

async function onDomLoaded({tabId, url, processId, frameId, timeStamp}) {
  if (frameId) return;    // we care only about top-level windows
  await browser.tabs.executeScript(tabId, {file: 'cs.js', runAt: 'document_start'}); 
}

EDIT:
After reading your other question, I think your approach with injecting pre-generated code with injected settings values is actually the best way to solve your use case :slight_smile: (to be sure to avoid any race conditions). And to solve the issue with already injected code you should be able to use the 1. approach with combination with 2. or 3. :slight_smile:

1 Like

Actually yes. But you cannot solve all use cases for it: I still need to manually inject the tab ID into each single tab (because I obviously need to inject different values there).

So this involves a roundtrip from content to background script, which I think could be slower. (again I want it to be fast, now for non-technical reasons, but I do not want to wait too long to apply the CSS changes.)

But what is way harder is, that, if this requests originates from my content script, I have no idea what tab sent it. I mean, it’s still just some random content script asking for a tab ID. I just have no idea, what ID I need to reply to each of these requests. You see the issue?

Or wait… yes, I can do so as the request itself contains a MessageSender including the tab ID. So taht’s great then… :smiley: