'Multiple re-renders when using react as content script

I'm currently learning how to develop chrome extension using React. However, i'm stuck with an issue where react re-renders like 3 times even with a very basic component.

As a result, the content script creates iframes 3 times nested on top of each other -- and one thing i've noticed is querying iframe always returns null on every re-render.

Here's the component that will act as a content script that will be injected inside a iframe.

import { render } from 'react-dom';

interface Props {}

const Sidebar: FC<Props> = () => {
  return <div>some sidebar</div>;
};

// render first on the entry point
const app = document.getElementById('app');


if (app) {
  render(
    <StrictMode>
      <Sidebar />
    </StrictMode>,
    document.getElementById('app')
  );
}

console.log('check');

const id = 'extensionWrapper';
const iframe = document.getElementById(id);
if (!iframe) {
  const iframe = document.createElement('iframe');
  // get the index.html from chrome extension directory
  const content = chrome.extension.getURL('index.html');
  console.log({ content });
  iframe.src = content;
  iframe.id = id;
  document.body.appendChild(iframe);
}

Here's the webpack.config.js

const { resolve } = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

const tsRule = {
  test: /\.tsx?$/,
  exclude: /node_modules/,
  use: 'ts-loader',
};

const plugins = [
  new HTMLWebpackPlugin({
    template: 'src/index.html',
    filename: 'index.html',
    chunks: ['contentScript'],
    inject: 'body',
  }),
  new CopyWebpackPlugin({
    patterns: [
      {
        from: 'public',
        // to is relative to output folder set in output.path
        to: '.',
      },
    ],
  }),
  new CleanWebpackPlugin(),
];

module.exports = {
  mode: 'development',
  devtool: 'cheap-module-source-map',
  entry: {
    // popup: './src/popup-page/popup.tsx',
    contentScript: './src/content-scripts/content-script.tsx',
  },
  output: {
    filename: '[name].js',
    path: resolve(__dirname, 'dist'),
  },
  module: {
    rules: [tsRule],
  },
  plugins,
};

manifest.json

{
  "short_name": "react chrome",
  "name": "react chrome extension",
  "version": "1.0",
  "manifest_version": 2,
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["contentScript.js"]
    }
  ],
  "web_accessible_resources": ["*.html"]
}


Solution 1:[1]

I haven't found an answer on the multiple render, but in case someone stumbled upon in this post -- instead of specifying the content script in manifest.json, i decided to just inject the content script dynamically using the snippet below:

Inside background.ts

const isWebProtocol = (url: string | undefined): boolean => {
  return !!url && url.startsWith('http');
};

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === 'complete' && tab.active === true && isWebProtocol(tab.url)) {
    chrome.tabs.executeScript(tabId, {
      file: 'injectScript.js',
      runAt: 'document_idle',
    });
  }
});

The content script that will be injected on each tab

injectScript.js

const id = 'extensionWrapper';
const iframe = document.getElementById(id);
if (!iframe) {
  const iframe = document.createElement('iframe');
  // get the index.html from chrome extension directory
  const content = chrome.extension.getURL('index.html');

  iframe.src = content;
  iframe.id = id;
  document.body.appendChild(iframe);
}

the main content script that initializes the main react component


contentScript.tsx

import React, { FC, StrictMode } from 'react';
import { render } from 'react-dom';

// import Sidebar from "./content-script.module.css";
import "../style.css";
import "./content-script.css";

interface Props {}

const Sidebar: FC<Props> = () => {
  return <div className="sidebar">some sidebar</div>;
};

// render first on the entry point
const app = document.getElementById('app');


if (app) {
  render(
    <StrictMode>
      <Sidebar />
    </StrictMode>,
    document.getElementById('app')
  );
}

Lastly, the index.html file that contains the element (i.e #app) where the main react component will be injected to

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

Solution 2:[2]

Piggybacking on the other answer, but for Manifest V3:

// serviceWorker.ts
const isWebProtocol = (url: string | undefined): boolean => {
  return !!url && url.startsWith("http");
};

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status === "complete" && tab.active === true && isWebProtocol(tab.url)) {
    chrome.scripting.executeScript({
      files: ["sidebar.js"],
      target: { tabId },
    });
  }
});

// sidebar.ts
const id = "extensionWrapper";
const iframe = document.getElementById(id);

if (!iframe) {
  const iframe = document.createElement("iframe");
  iframe.style.height = "100%";
  iframe.style.width = "400px";
  iframe.style.position = "fixed";
  iframe.style.top = "0px";
  iframe.style.right = "0px";
  iframe.style.zIndex = "2147483647";
  iframe.style.border = "0px";

  const content = chrome.runtime.getURL("index.html");

  iframe.src = content;
  iframe.id = id;
  document.body.appendChild(iframe);
}

// manifest.json
{
  "manifest_version": 3,
  "action": {},
  "background": {
    "service_worker": "./static/js/serviceWorker.js"
  },
  "permissions": ["activeTab"],
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["./static/js/sidebar.js"]
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["index.html"],
      "matches": ["https://*/*", "http://*/*"],
      "extension_ids": ["???"]
    }
  ]
}

I am using Nx and have set up a custom webpack.config.js like so:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const nrwlConfig = require("@nrwl/react/plugins/webpack.js");
const path = require("path");

module.exports = (webpackConfig, context) => {
  nrwlConfig(webpackConfig);

  const template = context.buildOptions?.index ?? context.options?.index;

  // Remove the current html plugin added by NX
  const idx = webpackConfig.plugins.findIndex((plugin) => plugin.constructor.name === "IndexHtmlWebpackPlugin");
  webpackConfig.plugins.splice(idx, 1);

  return {
    ...webpackConfig,
    entry: {
      ...webpackConfig.entry,
      sidebar: "./src/content-scripts/sidebar.ts",
      serviceWorker: "./src/service-worker/serviceWorker.ts",
    },
    output: {
      ...webpackConfig.output,
      filename: "static/js/[name].js",
    },
    optimization: {
      ...webpackConfig.optimization,
      runtimeChunk: false,
    },
    plugins: [
      ...webpackConfig.plugins,
      new HtmlWebpackPlugin({
        chunks: ["polyfills", "main", "sidebar"],
        filename: "index.html",
        template: path.join(process.cwd(), template),
      }),
    ],
  };
};

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 The.Wolfgang.Grimmer
Solution 2 DharmanBot