How Content Script HMR Loaders Enable Hot Module Replacement in Chrome Extensions

Client-side HMR loaders wrap content scripts in an asynchronous IIFE that dynamically imports Vite's HMR client and establishes a Chrome runtime Port, forwarding WebSocket payloads to enable instant module updates without reloading the entire extension.

When developing Chrome extensions with crxjs/vite-plugin, the development server leverages specialized loaders to inject hot module replacement (HMR) capabilities directly into content scripts. The file content-dev-loader.ts serves as the critical bootstrapper that bridges Vite's standard HMR runtime with the isolated execution context of browser extension content scripts, allowing developers to see code changes instantly while the background service worker remains active.

The Dev Loader Template Architecture

At the heart of the system lies packages/vite-plugin/src/client/iife/content-dev-loader.ts, a template that compiles into an Immediately Invoked Function Expression (IIFE) injected as the actual content script entry point during development. This loader orchestrates three sequential operations: executing an optional preamble, initializing the Vite HMR client, and mounting the real content script module.

The template relies on three string placeholders replaced at build time:

  • __PREAMBLE__: Path to framework-specific HMR helpers (e.g., React's Fast Refresh runtime)
  • __CLIENT__: Virtual module ID /@vite/client containing Vite's HMR client
  • __SCRIPT__: Relative path to the actual content script source file
// packages/vite-plugin/src/client/iife/content-dev-loader.ts
declare const __PREAMBLE__: string
declare const __CLIENT__: string
declare const __SCRIPT__: string

const injectTime = performance.now()
;(async () => {
  // ① Framework preamble (optional)
  if (__PREAMBLE__) await import(/* @vite-ignore */ chrome.runtime.getURL(__PREAMBLE__))

  // ② Vite HMR client setup
  await import(/* @vite-ignore */ chrome.runtime.getURL(__CLIENT__))

  // ③ Content script execution with performance metrics
  const { onExecute } = await import(/* @vite-ignore */ chrome.runtime.getURL(__SCRIPT__)) as ContentScriptAPI.ModuleExports
  onExecute?.({ perf: { injectTime, loadTime: performance.now() - injectTime } })
})().catch(console.error)

export {}

The onExecute hook exported by your content script serves as the deterministic entry point, receiving an object containing injectTime and loadTime performance measurements. When HMR triggers an update, the loader re-imports the module and invokes onExecute again with fresh timing data.

Generating the Loader at Build Time

The createDevLoader function in packages/vite-plugin/src/node/contentScripts.ts transforms the template into executable JavaScript by replacing placeholders with concrete file names. This function accepts an object containing preamble, client, and fileName strings, performing simple string replacement to produce the final loader asset.

// packages/vite-plugin/src/node/contentScripts.ts
export function createDevLoader({
  preamble,
  client,
  fileName,
}: {
  preamble: string
  client: string
  fileName: string
}): string {
  return contentDevLoader
    .replace(/__PREAMBLE__/g, JSON.stringify(preamble))
    .replace(/__CLIENT__/g, JSON.stringify(client))
    .replace(/__SCRIPT__/g, JSON.stringify(fileName))
    .replace(/__TIMESTAMP__/g, JSON.stringify(Date.now()))
}

The function returns a valid JavaScript string where JSON.stringify() ensures proper escaping of file paths. The resulting code is emitted as a virtual asset with a generated filename (e.g., src/content/main-loader.js), which the manifest registers as the actual content script entry point during development.

Integrating with the Vite Build Graph

The pluginContentScripts module registers these virtual files with Rollup through the add() helper function, ensuring the loader and its dependencies become part of the module graph. When processing content script registrations, the plugin emits three distinct virtual modules:

  1. Preamble module (optional React/Preact HMR setup)
  2. Vite client module (/@vite/client)
  3. Loader asset (the generated IIFE wrapper)
// packages/vite-plugin/src/node/plugin-contentScripts.ts (excerpt)
if (type === 'loader') {
  let preamble = { fileName: '' }
  if (preambleCode) preamble = add({ type: 'module', id: preambleId })

  const client = add({ type: 'module', id: viteClientId })
  const file   = add({ type: 'module', id })

  const loader = add({
    type: 'asset',
    id: getFileName({ type: 'loader', id }),
    source: createDevLoader({
      preamble: preamble.fileName,
      client:   client.fileName,
      fileName: file.fileName,
    }),
  })
  script.fileName = loader.fileName
}

Here, viteClientId resolves to the virtual module /@vite/client, while preambleId maps to /@crx/preamble. The script.fileName assignment ensures Chrome loads the wrapper script rather than the raw source file, establishing the HMR pipeline before user code executes.

The HMR Runtime Bridge

Because content scripts cannot directly access Vite's WebSocket connection, the system employs packages/vite-plugin/src/client/es/hmr-content-port.ts to forward HMR payloads through Chrome's extension messaging API. The HMRPort class creates a persistent chrome.runtime.Port connection to the background service worker, which receives WebSocket messages from the dev server and relays them to the content script.

// packages/vite-plugin/src/client/es/hmr-content-port.ts
declare const __CRX_HMR_TIMEOUT__: number

export class HMRPort {
  private port: chrome.runtime.Port | undefined
  private callbacks = new Map<string, Set<(event: any) => void>>()

  constructor() {
    // Keep-alive ping and reconnection logic
    setInterval(() => {
      try { this.port?.postMessage({ data: 'ping' }) }
      catch (e) { if (e.message.includes('Extension context invalidated.')) location.reload() }
    }, __CRX_HMR_TIMEOUT__)
    setInterval(this.initPort, 5 * 60 * 1000)
    this.initPort()
  }

  initPort = () => {
    this.port?.disconnect()
    this.port = chrome.runtime.connect({ name: '@crx/client' })
    this.port.onMessage.addListener(this.handleMessage.bind(this))
  }

  handleMessage = (message: any) => {
    const payload = JSON.parse(message.data)
    if (payload.type === 'custom' && payload.event.startsWith('crx:')) {
      if (payload.event === 'crx:runtime-reload')
        setTimeout(() => location.reload(), 500)
      else
        this.forward(JSON.stringify(payload.data))
    } else {
      this.forward(message.data) // Standard Vite HMR updates
    }
  }
}

The handleMessage method distinguishes between standard Vite HMR payloads (forwarded directly to the client) and custom crx: events. When the dev server signals a full runtime reload via crx:runtime-reload, the bridge triggers a page reload after a 500ms delay to ensure a clean state.

End-to-End Hot Module Replacement Flow

The complete HMR lifecycle for content scripts involves coordinated communication between the dev server, background service worker, and the client-side loader:

  1. File change detection: Vite detects modifications to a module imported by the content script
  2. WebSocket broadcast: The dev server pushes an HMR update payload via WebSocket to the background service worker (handled in plugin-hmr.ts)
  3. Port forwarding: The background worker transmits the payload through the chrome.runtime.Port established by HMRPort
  4. Client processing: The loader's imported /@vite/client receives the forwarded message via HMRPort.handleMessage
  5. Module re-execution: Vite's HMR runtime invalidates the old module and re-imports it, triggering the onExecute hook with updated code
  6. State preservation: The content script updates without reloading the extension or refreshing the host page

Important limitation: Content scripts configured to run in the MAIN world (executing in the page's JavaScript context rather than the isolated world) cannot utilize this HMR system. For these scripts, the plugin falls back to static file generation and emits a console warning, as the MAIN world lacks access to chrome.runtime APIs required for the port communication.

Summary

  • content-dev-loader.ts acts as an IIFE wrapper that imports the Vite HMR client and the actual content script module, exposing an onExecute lifecycle hook.
  • createDevLoader generates the final loader code by replacing template placeholders (__PREAMBLE__, __CLIENT__, __SCRIPT__) with specific file paths at build time.
  • pluginContentScripts integrates these virtual modules into Rollup's build graph, ensuring the loader becomes the registered entry point in the extension manifest.
  • hmr-content-port.ts bridges the gap between Vite's WebSocket server and content scripts by forwarding HMR payloads through a chrome.runtime.Port connection to the background worker.
  • Main-world limitation: Content scripts running in world: 'MAIN' cannot use HMR and fall back to static builds due to restricted extension API access.

Frequently Asked Questions

Why does the loader use chrome.runtime.getURL() for imports?

Content scripts execute in isolated contexts where standard relative imports fail. The chrome.runtime.getURL() API converts relative paths into absolute chrome-extension:// URLs, ensuring the browser can locate virtual modules like /@vite/client and the preamble scripts stored in the extension's package.

What happens if the background service worker disconnects during HMR?

The HMRPort class implements automatic reconnection logic with a keep-alive ping interval defined by __CRX_HMR_TIMEOUT__. If the port disconnects (e.g., due to service worker termination), initPort recreates the connection. If the extension context becomes invalid, the code triggers a full location.reload() to restore the development environment.

Can I use HMR with content scripts that don't export onExecute?

While the loader attempts to call onExecute?.(), omitting this export prevents the script from receiving performance metrics and may cause errors if your code expects specific initialization timing. Although the module will still hot-reload due to Vite's underlying HMR, relying on side effects at the top level of your module rather than inside onExecute can lead to memory leaks or duplicate event listeners during updates.

Does this HMR system work in production builds?

No, the content-dev-loader.ts and HMRPort infrastructure are exclusively development features. Production builds use content-prod-loader.ts (or direct source files) without the WebSocket client or runtime port connections, resulting in static, optimized JavaScript bundles suitable for the Chrome Web Store.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →