Skip to main content

Building Chrome Extensions with React & Vite: Fixing HMR and Content Script Reloading

 If you have attempted to bring the modern React developer experience (DX) to Chrome Extension development, you have likely hit a specific, workflow-killing wall. You set up Vite, get the popup rendering locally, but the moment you touch a Content Script, the abstraction leaks.

HMR stops working. Styles don't update. You find yourself manually reloading the extension in chrome://extensions and refreshing the target web page just to see a one-line code change.

This creates a feedback loop latency that makes modern UI development impossible. This guide details exactly why standard Vite configurations fail in the browser extension context and provides a rigorous, production-ready architecture using React, Vite, TypeScript, and CRXJS to solve it.

The Root Cause: Why Standard HMR Fails in Extensions

To fix the problem, we must understand the architecture of the browser runtime. Standard Vite HMR relies on a WebSocket connection between the browser client and the local development server (usually ws://localhost:5173).

1. The Context Isolation Barrier

Content Scripts do not run in the same context as your popup or options page. They run inside the context of the host page (e.g., google.com).

When Vite tries to inject its HMR client into a Content Script, two security mechanisms often trigger:

  1. CSP (Content Security Policy): The host page may block script execution from external sources (localhost).
  2. Mixed Content: If the host page is HTTPS (secure), Chrome blocks WebSocket connections to an insecure ws:// localhost endpoint.

2. The Orphaned Script Problem

Even if you establish a connection, Chrome Extensions have a unique lifecycle quirk. When an extension reloads (triggered by a rebuild), the Chrome Runtime sends a signal to invalidate the previous context.

However, the DOM elements injected by your previous Content Script remain on the page. The Javascript context that created them is dead (disconnected from the extension API), but the HTML remains. This leads to the "Extension context invalidated" error and duplicate UI elements stacking up on the page until you manually refresh the tab.

The Solution: A Dedicated Vite Ecosystem

We will not hack a Webpack config. We will use @crxjs/vite-plugin. This plugin is currently the gold standard for bridging Vite's build process with the Chrome Manifest V3 API. It handles HMR injection and content script reloading automatically.

Step 1: Initialize the Project

Start with a fresh Vite + React + TypeScript scaffold.

npm create vite@latest chrome-ext-pro -- --template react-ts
cd chrome-ext-pro
npm install
npm install @crxjs/vite-plugin@beta -D

Note: We use the beta version of CRXJS as it supports Vite 4/5 and the latest ESM handling better than the stable legacy version.

Step 2: Formalize the Manifest

Create a manifest.json file in your root directory (not inside public). CRXJS reads this file to determine the entry points for the build.

{
  "manifest_version": 3,
  "name": "React Vite Extension",
  "version": "1.0.0",
  "action": {
    "default_popup": "index.html"
  },
  "content_scripts": [
    {
      "js": ["src/content/index.tsx"],
      "matches": ["https://*.google.com/*"],
      "run_at": "document_end"
    }
  ]
}

Step 3: Configure Vite

Modify vite.config.ts. The crucial step here is importing the manifest and passing it to the plugin. This tells Vite to treat src/content/index.tsx as a script entry point rather than a static asset.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';

export default defineConfig({
  plugins: [
    react(),
    crx({ manifest }),
  ],
  server: {
    // strictPort ensures the HMR client connects to the correct port
    port: 5173,
    strictPort: true,
    hmr: {
      clientPort: 5173,
    },
  },
});

The Injection Strategy: Shadow DOM and React Roots

Simply appending a React App to document.body is dangerous. The host page's CSS will bleed into your extension, and your extension's generic global styles (like resets) might break the host page.

We must use the Shadow DOM to create a style-encapsulated boundary.

Create src/content/index.tsx. This file acts as the bootstrapper. It detects if our root exists, handles cleanup (solving the orphan problem), and mounts the React application.

import { createRoot } from 'react-dom/client';
import ContentApp from './ContentApp';
import './content.css'; // Global styles for the shadow root

const ROOT_ID = 'my-extension-root';

function mount() {
  // 1. Cleanup previous instances (Fixes the "Orphaned Script" issue)
  const existingRoot = document.getElementById(ROOT_ID);
  if (existingRoot) {
    existingRoot.remove();
  }

  // 2. Create the Host Element
  const hostElement = document.createElement('div');
  hostElement.id = ROOT_ID;
  
  // High z-index ensures it floats above page content
  hostElement.style.position = 'fixed';
  hostElement.style.zIndex = '2147483647'; 
  hostElement.style.top = '0';
  hostElement.style.right = '0';

  document.body.appendChild(hostElement);

  // 3. Create Shadow DOM (Style Encapsulation)
  const shadowRoot = hostElement.attachShadow({ mode: 'open' });

  // 4. Inject Styles
  // In Vite content scripts, imported CSS is injected into the head by default.
  // We need to manually move relevant styles into our Shadow Root.
  const styleElement = document.createElement('style');
  // Simple reset for the shadow DOM context
  styleElement.textContent = `
    :host { all: initial; } 
    div { font-family: sans-serif; }
  `;
  shadowRoot.appendChild(styleElement);

  // 5. Mount React
  const root = createRoot(shadowRoot);
  root.render(<ContentApp />);
}

// Wait for DOM to be ready
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', mount);
} else {
  mount();
}

The Component Layer

Create src/content/ContentApp.tsx. This is a standard React component.

import { useState } from 'react';

export default function ContentApp() {
  const [isOpen, setIsOpen] = useState(true);

  if (!isOpen) return null;

  return (
    <div style={{
      padding: '20px',
      backgroundColor: '#1a1a1a',
      color: 'white',
      borderRadius: '0 0 0 10px',
      boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
    }}>
      <h2 style={{ margin: '0 0 10px 0', fontSize: '16px' }}>
        Dev Mode Active
      </h2>
      <p style={{ margin: '0 0 15px 0', fontSize: '14px', color: '#ccc' }}>
        Edit this text in VS Code and watch it update instantly.
      </p>
      <button 
        onClick={() => setIsOpen(false)}
        style={{
          padding: '8px 16px',
          background: '#646cff',
          border: 'none',
          borderRadius: '4px',
          color: 'white',
          cursor: 'pointer'
        }}
      >
        Close
      </button>
    </div>
  );
}

Handling CSS in Shadow DOM

The code above includes a manual style injection. However, if you are importing CSS files (e.g., Tailwind or CSS Modules), Vite usually injects these into the document <head>. This renders them useless inside your Shadow Root.

To fix this with @crxjs/vite-plugin, you can import the CSS specifically as a string using the ?inline query suffix (a Vite feature) and inject it.

Update src/content/index.tsx to handle external CSS imports:

// Import CSS as a string
import styles from './content.css?inline'; 

// ... inside the mount function
const styleSheet = document.createElement('style');
styleSheet.textContent = styles;
shadowRoot.appendChild(styleSheet);

This ensures your Tailwind or custom CSS applies only to your extension UI, maintaining complete isolation from the host page.

Deep Dive: Why This Architecture Works

This setup solves the retention and refreshing issues through three specific mechanisms:

  1. HMR Proxying: The @crxjs plugin wraps the standard Vite HMR client. It establishes a unified connection that proxies HMR events securely, bypassing the Mixed Content errors that typically block localhost connections on HTTPS sites.
  2. Manifest-Driven Build: By treating manifest.json as the source of truth, Vite constructs a dependency graph that understands the difference between a popup (HTML entry) and a content script (JS entry).
  3. Shadow DOM Boundary: By mounting into a Shadow Root, we prevent the "CSS War" where site styles break your extension. This reduces debugging time significantly.

Common Pitfalls and Edge Cases

The "Extension Context Invalidated" Error

You will still see this error in the console when you edit the vite.config.ts or manifest.json. These changes require a full extension reload. However, for standard React component changes, HMR will handle the update gracefully without severing the context connection.

Handling Assets (Images/Fonts)

In a standard web app, you import images and they resolve to a URL. In a Chrome Extension, resources must be explicitly web-accessible.

If you import an image in your React component: import logo from '../../assets/logo.png';

You must ensure manifest.json allows the browser to load it:

"web_accessible_resources": [
  {
    "resources": ["assets/*"],
    "matches": ["<all_urls>"]
  }
]

Conclusion

Building Chrome Extensions doesn't require abandoning modern tooling. By understanding the underlying constraints of the Chrome Runtime—specifically context isolation and resource security—you can leverage Vite and CRXJS to build a development environment that rivals standard web development.

You now have a setup where saving a file updates the DOM on google.com (or your target site) in milliseconds, with full style encapsulation and TypeScript safety.