Skip to content

HMR (Hot Module Replacement)

ExtForge ships a file-watching dev server that reloads as little as possible on each change. CSS files hot-swap in matched tabs without a page reload. Content scripts reload only the tabs they affect. Background script changes trigger a full extension reload.

Start the dev server with:

Terminal window
extforge dev
extforge dev --browser firefox

Each category of file change maps to a specific reload strategy. ExtForge classifies a changed file by its path and extension.

Edit kindWhat reloadsTabs touchedExtension restart needed?
Popup / options / sidepanel .tsx (with RFR)Component-level swap, state preservedNo tabsNo
Popup / options / sidepanel JS (no RFR)Full reload of that viewNo tabsNo
CSS file (.css, .scss, .less)CSS hot swapMatched content-script tabs onlyNo
Content-script JSTab reloadMatched content-script tabs onlyNo
Background JSFull extension reloadAll extension surfacesNo — service worker restarts
Manifest / config changeFull extension reloadAll extension surfacesNo
Asset (icon, image)Full extension reloadAll extension surfacesNo
Injected script (page-context)Full extension reloadAll extension surfacesNo

“Full extension reload” means the browser reloads the extension package in-place. Tabs are not closed; the service worker restarts.

CSS hot swap injects new CSS into the tab’s document without a navigation. This is the fastest update path and works for both content-script stylesheets and injected CSS.


For .tsx / .jsx edits in popup, options, or sidepanel, ExtForge can update the React tree without reloading the page — component state survives the swap. This is the Vite / Next.js / Plasmo experience for browser extensions.

Opt in by installing the optional peer deps:

Terminal window
pnpm add -D @swc/core react-refresh

That’s it. ExtForge auto-detects @swc/core, switches the dev pipeline to run JSX through SWC’s react.refresh: true transform, and emits a finer-grained HMR envelope (v: 3) that the in-page client applies via performReactRefresh().

Without the peer deps, dev mode falls back to the v2 envelope (full reload of the popup view) and prints a single warning. No build error — it just gracefully degrades.

✅ Component body changes (JSX, hook state, event handlers) ✅ New components added or removed from a render ✅ Hook signature additions (useState / useEffect / etc.)

❌ Module-level side effects (top-level console.log, localStorage.setItem) ❌ Non-component exports — those force a full reload ❌ Anything outside the src/ui/* tree — content scripts, background, manifest

Server emits the same WebSocket protocol as before, but now picks per-batch:

// Old v2 envelope (still used for non-RFR-eligible changes):
{ "v": 2, "type": "js", "files": ["src/ui/popup/index.ts"], "scriptIds": [] }
// New v3 envelope (UI-only JS changes):
{
"v": 3,
"type": "hmr-update",
"updates": [{ "id": "ui/popup/index.js", "hash": "...", "file": "ui/popup/index.js" }],
"timestamp": 1234567890
}

The client refetches each chunk via chrome-extension://<id>/<file>?t=<hash>, the new module’s RFR header re-registers components, and performReactRefresh() updates the DOM in place. Any failure (network, parse, RFR mismatch) falls through to a clean reload.


When the HMR WebSocket disconnects — for example, after a full extension reload — ExtForge inserts a small floating badge into active extension pages that reads:

ExtForge HMR — reconnecting (#N)

#N is the attempt count. The badge disappears as soon as the connection re-establishes. If you see it persist for more than a few seconds, the dev server may have crashed or the port is in use.

What to do if the badge gets stuck:

  1. Check the terminal for errors. A port conflict surfaces as EXT_HMR_PORT_IN_USE.
  2. Restart with a different port: extforge dev --port 35730.
  3. If the badge never appeared and you want to verify HMR is working, reload the extension page manually — the badge will re-appear briefly during the handshake.

The badge is injected only in dev builds. Production builds strip all HMR client code.


When a rebuild fails — a syntax error, an unresolved import, a manifest validation problem — ExtForge renders a full-page error overlay in every open extension page, the same way Vite and Astro do. The overlay shows:

  • The error code (e.g. EXT_BUILD_FAILED)
  • The human-readable message
  • File path + line:column
  • A source frame with the failing line marked > and a caret ^ pointing at the column
  • The hint (when the underlying ExtForgeError carries one)
  • A docs link for the error code
  • The full stack trace (collapsed)

The overlay is mounted into a Shadow DOM so the host page’s CSS can’t bleed in, and dismisses itself automatically the moment the next rebuild succeeds. You can also click Dismiss to hide it without rebuilding.

// Wire-level envelope sent over the HMR WebSocket on failure:
{
"v": 3,
"type": "build-error",
"timestamp": 1700000000000,
"error": {
"code": "EXT_BUILD_FAILED",
"message": "Unexpected \";\"",
"file": "src/background/index.ts",
"line": 4,
"column": 18,
"frame": " 2 | \n 3 | export const x = 1;\n> 4 | export const y = ;\n | ^\n 5 | ",
"hint": "Fix the syntax error and re-run.",
"docsUrl": "https://extforge.arshadshah.com/errors/EXT_BUILD_FAILED",
"stack": "ExtForgeError: ..."
}
}
// Dismiss envelope on the next successful rebuild:
{ "v": 3, "type": "build-ok", "timestamp": 1700000000123 }

The overlay code is the same in popup, options, sidepanel, and content-script contexts. Page-context (injected) scripts don’t render it — they don’t have a DOM the overlay can attach to.


--once — single build for CI smoke tests

Section titled “--once — single build for CI smoke tests”
Terminal window
extforge dev --once

Runs a single development build and exits. Useful in CI to verify the project compiles in dev mode without starting the watcher. Exits 0 on success, 1 on build errors.

Terminal window
extforge dev --verbose

Logs every file change ExtForge detects, the strategy chosen, and the reload message sent. Produces a lot of output; use for debugging why a specific file is or isn’t triggering the expected reload.

Terminal window
extforge dev --quiet

Suppresses info-level messages. Warnings and errors still print. Useful if you’re running the dev server in a background terminal and don’t want the noise.

Terminal window
extforge dev --json

Emits newline-delimited JSON objects instead of human-readable log lines. Each object has level, message, and optionally data. Combine with --quiet to emit only warnings and errors as JSON.

{"level":"info","message":"HMR server started on ws://localhost:35729"}
{"level":"info","message":"Change detected","data":{"file":"src/content/index.ts","strategy":"tab-reload-targeted"}}
Terminal window
extforge dev --port 35730

Default is 35729. If the port is in use, ExtForge will error with EXT_HMR_PORT_IN_USE rather than auto-incrementing, so CI failures are explicit.

Terminal window
extforge dev --browser firefox

Default is chrome. Must be one of the browsers declared in extforge.config.ts. Building for a browser not in the browsers list will error.


When cross-browser compat checking is enabled, a file can suppress a specific line with:

// extforge-ignore-compat: Chrome-only API, Firefox uses sidebar fallback
chrome.sidePanel.open({ tabId });

The comment must be on the line immediately before the offending call (blank lines and other comments between the suppression and the call are skipped). A bare // extforge-ignore-compat without a reason string is ignored — the reason is required.

This suppression applies to compat warnings only, not to HMR behavior. See the cross-browser guide for the full compat checker documentation.


EXT_HMR_PORT_IN_USE: port 35729 is already bound

Another process is using the HMR port. Either stop that process or pass --port &lt;other&gt;. See EXT_HMR_PORT_IN_USE for the full error reference.

You can also set a permanent port in config:

export default defineConfig({
dev: { port: 35730 },
});

When extforge.config.ts changes, the manifest is regenerated and a full extension reload fires. Content-script HMR clients are reinitialised as part of that reload. If a client appears stale (badge persisting, no reloads firing), manually reload the extension in chrome://extensions — then the new client will connect.

Each content script in the manifest is assigned a stable ID based on its declaration order. If you add, remove, or reorder contentScripts entries in config, the IDs can drift, causing the HMR server to send reload events to the wrong script. After any structural manifest change, do a manual extension reload once to resync.