How CRXJS Hot Module Replacement Works with Content Scripts: A Deep Dive
CRXJS extends Vite's HMR system to enable hot reloading of Chrome extension content scripts by intercepting WebSocket messages, detecting file changes, and injecting updated bundles via a runtime loader, while forcing full extension reloads for background script changes.
CRXJS (chrome-extension-tools) bridges the gap between Vite's fast development server and Chrome's extension architecture. When developing browser extensions, CRXJS Hot Module Replacement allows content scripts—the JavaScript injected into web pages—to update instantly without requiring a full browser extension reload, preserving the rapid feedback loop that makes Vite productive.
The Architecture of CRXJS HMR for Content Scripts
The system operates through a specialized pipeline that intercepts Vite's standard HMR flow and adds Chrome-extension-specific handling. This process involves server-side message decoration, module change detection, and runtime bundle injection.
Server-Side WebSocket Decoration
The HMR plugin first decorates Vite's WebSocket send method to maintain a dual-channel communication system. Located in src/node/plugin-hmr.ts (lines 57-73), this decoration forwards standard Vite HMR payloads unchanged while simultaneously emitting a custom "crx:content-script-payload" event. This approach preserves Vite's native HMR pipeline while providing the extension runtime with a dedicated hook for error handling and content-script-specific messages.
Detecting Changed Modules
When Vite notifies the plugin of a hot update via handleHotUpdate (beginning at line 88 in src/node/plugin-hmr.ts), the system collects changed modules into three distinct groups:
- Relative files (
relFiles): Project files imported by the content script - File-system files (
fsFiles): Modules resolved through Vite's file system - Virtual modules: Generated modules such as UnoCSS CSS that don't exist on disk
This classification occurs at lines 88-104 and enables the plugin to distinguish between physical source files and synthetic dependencies.
Content Script Update Strategy
For regular content scripts, the plugin determines whether to trigger an update by checking if the changed file is the script itself or one of its direct imports. This logic at lines 59-68 in src/node/plugin-hmr.ts examines both relFiles and the module graph (modules.some(isImporter...)). When matches occur, the plugin calls update on every changed file plus any associated virtual modules, ensuring the browser receives the fresh bundle without page reload.
Manifest-Declared CSS Handling
Content scripts often include CSS declared directly in the manifest. CRXJS handles these through synthetic virtual modules. When a CSS file declared in the manifest changes, the system:
- Creates a virtual module ID following the pattern
/@crx/content-css/<index> - Resolves this in
src/node/plugin-contentScripts_declared.ts(lines 95-125) as a set ofimport "<path>"statements - Updates the CSS files and rewrites the synthetic module when changes occur (lines 35-57 in
src/node/plugin-hmr.ts)
This virtual module approach allows manifest-declared CSS to participate in HMR exactly like JavaScript modules.
Runtime Injection and the Content Script Loader
The final piece of the HMR puzzle occurs inside the browser. When CRXJS builds a content script, it appends a tiny runtime loader located at src/client/iife/content-pro-main-loader.ts. This 10-line IIFE wrapper:
- Imports the user-exported
onExecutefunction from the module API - Invokes
onExecutewith performance metrics - Runs inside the content script's isolated world every time the script injects
Because the loader executes on every injection, when Vite pushes a fresh bundle via HMR, the browser simply evaluates the new loader code. This loader then imports and executes the updated module, instantly reflecting changes without requiring a full extension reload or page refresh.
Code Examples
Simple Content Script with HMR
Enable hot reloading in your content script by exporting onExecute and optionally handling HMR events:
// src/content/main.ts
export function onExecute({ perf }: { perf: { injectTime: number; loadTime: number } }) {
console.log('[CRXJS] Content script loaded', perf);
}
// Enable Vite HMR inside the content script (optional)
if (import.meta.hot) {
import.meta.hot.accept(() => {
console.log('[CRXJS] Hot‑updated content script');
});
}
When built, CRXJS wraps this file with content-pro-main-loader.ts, which calls onExecute automatically. The HMR hook allows you to handle custom logic during updates.
Manifest-Declared CSS with HMR
Declare CSS in your manifest for automatic bundling and hot reloading:
// manifest.json (MV3)
{
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content/main.ts"],
"css": ["src/styles/content.css"]
}
]
}
The plugin creates a virtual module /@crx/content-css/0 containing:
import "/src/styles/content.css";
When src/styles/content.css changes, the HMR system updates both the physical file and the virtual module, injecting the new styles without reloading the extension.
Background Script Change Forces Full Reload
Background scripts cannot be hot-replaced due to Chrome's architecture. When you modify a background script:
// src/background/main.ts
chrome.runtime.onInstalled.addListener(() => {
console.log('Extension installed');
});
The plugin detects this change in handleHotUpdate (lines 21-31 in src/node/plugin-hmr.ts) and sends the crxRuntimeReload payload. This triggers a complete extension reload, which is necessary for background changes to take effect.
Key Implementation Files
Understanding the CRXJS HMR system requires familiarity with these specific source files:
| File | Role | Location |
|---|---|---|
src/node/plugin-hmr.ts |
Core HMR plugin that decorates Vite's WebSocket, handles hot updates, and determines whether to reload content scripts or the entire extension. | packages/vite-plugin/src/node/plugin-hmr.ts |
src/node/plugin-contentScripts_declared.ts |
Generates virtual modules for manifest-declared CSS and registers them for HMR resolution. | packages/vite-plugin/src/node/plugin-contentScripts_declared.ts |
src/node/virtualFileIds.ts |
Defines virtual-module ID schemes (/@crx/content-css/<index>) and provides type-guards for virtual file resolution. |
packages/vite-plugin/src/node/virtualFileIds.ts |
src/client/iife/content-pro-main-loader.ts |
Runtime IIFE loader appended to content scripts; imports and executes onExecute on every injection to enable HMR. |
packages/vite-plugin/src/client/iife/content-pro-main-loader.ts |
src/node/plugin-manifest.ts |
Inserts synthetic CSS virtual modules into the manifest during serve mode. | packages/vite-plugin/src/node/plugin-manifest.ts |
src/node/fileWriter.ts |
Writes updated bundle files to the dev-out directory when HMR updates occur. | packages/vite-plugin/src/node/fileWriter.ts |
These files collectively give CRXJS developers a seamless "live-reload" experience for content scripts, mirroring the fast feedback loop that Vite provides for regular web apps, while respecting Chrome-extension constraints.
Summary
- CRXJS Hot Module Replacement extends Vite's HMR system to support Chrome extension content scripts through a specialized plugin architecture.
- The system decorates Vite's WebSocket in
src/node/plugin-hmr.tsto forward HMR payloads while emitting custom"crx:content-script-payload"events for extension-specific handling. - Changed modules are categorized into relative files, file-system files, and virtual modules during the
handleHotUpdatehook (lines 88-104). - Content scripts receive hot updates via the
updatemechanism, while background script changes trigger a full extension reload through thecrxRuntimeReloadpayload. - Manifest-declared CSS uses virtual modules (
/@crx/content-css/<index>) to participate in HMR, resolved insrc/node/plugin-contentScripts_declared.ts. - The runtime loader at
src/client/iife/content-pro-main-loader.tsexecutes updated modules by importingonExecuteon every injection, completing the HMR cycle.
Frequently Asked Questions
How does CRXJS HMR differ from standard Vite HMR?
Standard Vite HMR updates modules in a single web page context, but CRXJS Hot Module Replacement must account for Chrome's isolated worlds and extension architecture. CRXJS decorates Vite's WebSocket to emit custom "crx:content-script-payload" events and uses a runtime IIFE loader to re-execute content scripts in their isolated context. Additionally, CRXJS forces full extension reloads for background script changes, whereas Vite would simply update the module.
What triggers a full extension reload instead of hot module replacement?
Changes to background scripts or any of their imports trigger a full extension reload rather than HMR. In src/node/plugin-hmr.ts (lines 21-31), the plugin detects background script changes and sends a crxRuntimeReload payload to the browser. This is necessary because Chrome does not support hot-swapping background service workers or persistent background pages; the entire extension context must be destroyed and recreated.
How does CRXJS handle CSS hot reloading for content scripts?
CRXJS creates virtual modules for CSS files declared in the manifest. When a content script includes CSS via the manifest's css array, the plugin generates a virtual module ID like /@crx/content-css/0 that imports the physical CSS file. During handleHotUpdate, changes to CSS files trigger updates to both the physical file and its virtual module wrapper, allowing the browser to hot-reload styles without reloading the extension.
Can I use import.meta.hot in content scripts?
Yes, you can use import.meta.hot in content scripts for custom HMR handling. The CRXJS Hot Module Replacement system preserves Vite's import.meta.hot API within content scripts. When a module updates, the runtime loader (src/client/iife/content-pro-main-loader.ts) re-executes the module, triggering any import.meta.hot.accept() callbacks you have defined. This allows you to implement custom cleanup or state preservation logic during hot updates.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →