Skip to content

Commit 8dd6985

Browse files
cnruddclaude
andauthored
Enable webpack HMR for CSS/SCSS in dev (#68)
## Summary Turns on webpack Hot Module Replacement for SCSS/CSS in the dev server. Style edits now apply in place without a full page reload β€” scroll position and in-app state are preserved. ## Changes - `devServer.hot: false` β†’ `true` in the `configureWebpack` devServer block. - Pass `{esModule: false}` to `style-loader`, matching the option already set on `css-loader`. ## Why the `esModule` change is needed Just flipping `hot: true` is not enough on its own. With it enabled, webpack-dev-server *does* push hot-updates for SCSS changes, but the page reloads anyway. Tracing the abort with a temporary `sessionStorage` capture in `webpack/hot/dev-server.js` showed: ``` Aborted because ./src/desktop/App.scss is not accepted Update propagation: ./src/desktop/App.scss -> ./src/desktop/AppComponent.ts -> ./src/apps/app.ts ``` The SCSS wrapper module was self-accepting via `style-loader`, but then *invalidating itself* mid-update. The trigger is in `style-loader@4`'s HMR template: ```js if (!isEqualLocals(oldLocals, isNamedExport ? namedExport : content.locals, isNamedExport)) { module.hot.invalidate(); return; } ``` With `style-loader`'s default `esModule: true`, `isNamedExport` is set to `!content.locals` β€” true for non-CSS-modules SCSS β€” so the comparison runs against the inner module's *named exports* rather than its `locals` object. Each rebuild produces fresh named-export bindings that fail the `isEqualLocals` check, so `invalidate()` fires on every update and the HMR client falls back to a full reload via the abort path in `webpack/hot/dev-server.js`. Setting `esModule: false` on `style-loader` routes the check through the `content.locals` path instead. For SCSS without CSS Modules, `locals` is `undefined` on both sides β€” equal β€” so `invalidate()` doesn't fire, the wrapper's `module.hot.accept()` handler applies the new styles in place, and propagation stops. (`css-loader` was already configured with `esModule: false`, so this is consistent across the pipeline.) Verified by editing `App.scss` repeatedly with a `window.__marker` set before each edit: the marker survives every time, indicating no page reload. ## Why only CSS β€” JS/TS still requires a full reload CSS HMR works because style-loader generates a small self-accepting wrapper module whose only job is to swap a `<style>` tag. There is no application state in that wrapper to preserve across the swap, so the replacement is safe and local. Bringing the same experience to JS/TS modules requires React Fast Refresh, which is a much larger change to the framework: - **Component detection** β€” the Fast Refresh Babel plugin identifies components by `PascalCase` exports and a small set of recognized HOC patterns (`React.memo`, `React.forwardRef`). Hoist components are exported as `lowercase` element factories returned by `hoistCmp.factory(...)`, which the standard detector skips. We would need either a custom detector or a marker that `hoistCmp` attaches to its returns so the runtime recognizes them. - **Model lifecycle** β€” `HoistModel` instances live outside React, are tracked by `InstanceManager`, hold `@observable` / `@bindable` state via MobX, own `@managed` children, and have explicit `destroy()` semantics. When HMR swaps a class definition, the existing instance's decorators and observable identity don't migrate cleanly. `InstanceManager` would need a hot-reload hook that disposes and recreates linked models when their defining module is replaced. - **Service singletons** β€” `HoistService` instances are installed once at boot and exposed on `XH`. HMR has no story for re-registering them without breaking every model that captured a reference, so services would likely need to be treated as a hard boundary that forces a full reload when their module changes. None of that is impossible, but it's framework work β€” days, not hours β€” and would want an RFC. The CSS change here is independent and unblocks the most common iteration pain point (style tweaks) right away. ## Test plan - [x] Edit an `.scss` file in a Hoist dev app and confirm styles update without page reload. - [x] Confirm in-app state (open tabs, scroll position, form values) survives the edit. - [x] Edit a `.tsx` file and confirm the existing full-reload behavior is unchanged. - [ ] Production build (`yarn build`) still uses `MiniCssExtractPlugin` (the `style-loader` block is dev-only) β€” confirm no regression. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 899c39c commit 8dd6985

2 files changed

Lines changed: 12 additions & 2 deletions

File tree

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## v13.0.0-SNAPSHOT - unreleased
4+
5+
### 🎁 New Features
6+
7+
* Enabled webpack Hot Module Replacement for CSS/SCSS in development. Style edits now apply in
8+
place without a full page reload, preserving scroll position and in-app state. JS/TS edits
9+
continue to trigger a full reload.
10+
311
## v12.1.1 - 2026-05-19
412

513
### βš™οΈ Technical

β€ŽconfigureWebpack.jsβ€Ž

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,9 @@ async function configureWebpack(env) {
527527
// 3) Production builds use MiniCssExtractPlugin to break built styles into dedicated output files
528528
// (vs. tags injected into DOM) for production builds. Note relies on MiniCssExtractPlugin being
529529
// called within the prod plugins section.
530-
prodBuild ? MiniCssExtractPlugin.loader : 'style-loader',
530+
prodBuild
531+
? MiniCssExtractPlugin.loader
532+
: {loader: 'style-loader', options: {esModule: false}},
531533

532534
// 2) Resolve @imports within CSS, similar to module support in JS.
533535
{
@@ -716,7 +718,7 @@ async function configureWebpack(env) {
716718
: {
717719
host: devHost,
718720
port: devWebpackPort,
719-
hot: false, // Hot module replacement is not currently supported by Hoist, but live reload is.
721+
hot: true, // Hot module replacement is only supported for SCSS. JS/TS files trigger live reload.
720722
client: {overlay: devClientOverlay},
721723
server:
722724
devHttps === true

0 commit comments

Comments
Β (0)