What's the most prudent way to ensure programmatical content script injections only happen once per site?

On user action (click on button in popup) my extension will use browser.tabs.executeScript on given tab.
On consecutive user actions I just want the already injected script to react onMessage, but don’t want to insert the content script again.

What is the best way to check if a content script is already present?

Some possible solutions:

Some developers would execute a script just for adding a variable with a boolean value to window and then - before executing their content script - check that boolean (by using a distinct call to executeScript).
One could manually keep track (in a store file) of tab ids that a script has been inserted into, then use tabs.onUpdated.addListener to remove a given record from the store).

const hasContentScript = async tabId => {
  try {
    await browser.tabs.sendMessage(tabId, {/*...*/});
    return true;
  } catch (error) {
    // maybe check error first here (could be sth else than missing recipient)
    return false;

A seems too much overhead, B is very error-prawn (i.e. when reloading a page FF will fire with an url change, whereas chrome doesn’t).
I came up with C, but would like some feedback if this is a good idea or not - Thank you!

I would keep it as simple as possible. So the “A” option, but no need to check the presence from the background script, you can do it in the content script.

First of all, make sure your code can injected multiple times, so “no global const declarations” - you can simply wrap the whole content script code into one big IIFE.

And then detect it in the beginning using:

if  (window['___already_here']) throw 'already_here'; 
else window['___already_here'] = true;
1 Like

Oh nice! Haven’t thought of that - but yes, it doesn’t really matter if the content script gets executed multiple times, as long as it stops right at the beginning when window.___already_here evaluates to true.
I just have to make sure to only register a listener for communicating to the background if it evaluates to false.

In case anyone with the same question stumbles upon this I want to share some test experiences:

I wanted await window.tabs.executeScript(tabId, { file: 'path/to/script.js' }) to return [false] or [true].
But doing that inside my content script wasn’t possible, since I am using webpack for bundling all scripts (-> sandboxing, so I’d always get [{...}] as a result.

This problem obviously also persists when using a separate script for checking:

import config from 'path/to/config.json';

(() => {
  let alreadyExecuted = !!window[`${config.prefixHash}injected`];
  if (!alreadyExecuted) window[`${config.prefixHash}injected`] = true;
  return alreadyExecuted;

So we’re left with 2 options here:

  1. Exclude this lil snippet from webpack bundling - i.e. by using copy-webpack-plugin (see example at the bottom)
  2. Use the inline script option of executeScript: { code: '// your test snippet here' }

Here is how you’d use copy-webpack-plugin:

config.plugin('copy-cs-check-script').use(require('copy-webpack-plugin'), [
    patterns: [
        from: 'path/to/cs-check.js',
        to: 'path/to/dist/cs-check.js',
        transform(content) {
          return `var prefixHash = '${prefixHash}'; ${content}`;

The transform option is useful for dynamic script content - in my case I’m using a random hash string for prefixing all sorts of stuff (css selectors, or here: the global variable I attach to the window object).
Make sure you use var for globals, since let or const would throw errors in the browser console on each consecutive execution of the snippet.

As of now I’m still undecided wether to choose from above 2 options or going with alternative C from the original post.

Those are some crazy workarounds :smiley:.
But yeah, if you need to know if the page is already there or await its execution, you better use some messaging.

To detect that the script is already there you can ping it with something like: browser.tabs.sendMessage({type: 'ping'}).catch(() => false)

But to await some async job inside you may need to wait for the script to send you some message.

Also, to stay “future-proof” you may want to switch to “scripting” API:

(it requires “scripting” permission - not alerted to users though and it works in current ESR 102)
1 Like