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:
extforge devextforge dev --browser firefoxReload strategy matrix
Section titled “Reload strategy matrix”Each category of file change maps to a specific reload strategy. ExtForge classifies a changed file by its path and extension.
| Edit kind | What reloads | Tabs touched | Extension restart needed? |
|---|---|---|---|
Popup / options / sidepanel .tsx (with RFR) | Component-level swap, state preserved | No tabs | No |
| Popup / options / sidepanel JS (no RFR) | Full reload of that view | No tabs | No |
CSS file (.css, .scss, .less) | CSS hot swap | Matched content-script tabs only | No |
| Content-script JS | Tab reload | Matched content-script tabs only | No |
| Background JS | Full extension reload | All extension surfaces | No — service worker restarts |
| Manifest / config change | Full extension reload | All extension surfaces | No |
| Asset (icon, image) | Full extension reload | All extension surfaces | No |
| Injected script (page-context) | Full extension reload | All extension surfaces | No |
“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.
React Fast Refresh (true 0-reload)
Section titled “React Fast Refresh (true 0-reload)”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:
pnpm add -D @swc/core react-refreshThat’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.
What it does and doesn’t update
Section titled “What it does and doesn’t update”✅ 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
Protocol envelope (v3)
Section titled “Protocol envelope (v3)”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.
The reconnect badge
Section titled “The reconnect badge”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:
- Check the terminal for errors. A port conflict surfaces as
EXT_HMR_PORT_IN_USE. - Restart with a different port:
extforge dev --port 35730. - 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.
Dev error overlay
Section titled “Dev error overlay”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
ExtForgeErrorcarries 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.
CLI flags for dev
Section titled “CLI flags for dev”--once — single build for CI smoke tests
Section titled “--once — single build for CI smoke tests”extforge dev --onceRuns 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.
--verbose — per-change file detail
Section titled “--verbose — per-change file detail”extforge dev --verboseLogs 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.
--quiet — silence non-warnings
Section titled “--quiet — silence non-warnings”extforge dev --quietSuppresses 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.
--json — machine-readable output
Section titled “--json — machine-readable output”extforge dev --jsonEmits 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"}}--port — HMR WebSocket port
Section titled “--port — HMR WebSocket port”extforge dev --port 35730Default 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.
--browser — target browser
Section titled “--browser — target browser”extforge dev --browser firefoxDefault is chrome. Must be one of the browsers declared in extforge.config.ts. Building for a browser not in the browsers list will error.
Compat suppression in source files
Section titled “Compat suppression in source files”When cross-browser compat checking is enabled, a file can suppress a specific line with:
// extforge-ignore-compat: Chrome-only API, Firefox uses sidebar fallbackchrome.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.
Troubleshooting
Section titled “Troubleshooting”Port in use
Section titled “Port in use”EXT_HMR_PORT_IN_USE: port 35729 is already boundAnother process is using the HMR port. Either stop that process or pass --port <other>. 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 },});Stale clients after manifest edits
Section titled “Stale clients after manifest edits”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.
Content-script ID drift on manifest edits
Section titled “Content-script ID drift on manifest edits”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.