Skip to content

Commit bb4586a

Browse files
aralrocaPrincesseuhclaudematthewp
authored
fix: avoid full-reload in scss modules (#14924)
* fix: avoid full-reload in scss modules * fix: improve regex * fix: ci * test: add test for modules * fix(hmr): prevent full-reload for SCSS/CSS module changes SCSS and CSS module file changes were triggering unnecessary full page reloads during development instead of applying HMR updates. This broke the developer experience by losing component state and scroll position on every style edit. Root cause: In the `astro:hmr-reload` Vite plugin, style files (CSS, SCSS, SASS, LESS, etc.) in the SSR module graph were correctly skipped via a regex check, but the handler returned `undefined` instead of an empty array. In Vite 6, returning `undefined` from a `hotUpdate` hook means "I didn't handle this", causing Vite to propagate through the SSR module graph to `.astro` importers. Since `.astro` pages have no HMR boundary, this triggered a full page reload. Changes to `vite-plugin-hmr-reload`: - Extract `isStyleModule()` helper that checks both `mod.file` and `mod.id` (stripping query params like `?inline`, `?used`) against the style extension regex. This correctly identifies all style-related modules including CSS module variants. - Return `[]` (empty array) when only style modules were encountered in the SSR environment. This tells Vite "handled, nothing to update in SSR", preventing the propagation chain that caused full reloads. The client environment handles CSS HMR natively through framework-specific HMR boundaries (Preact, React, Vue, etc.). Changes to e2e test fixtures: - Update the SCSS module HMR test to use a Preact component with `client:load` instead of a pure server-rendered Astro page. This matches the real-world scenario from the original bug report (issue #14869) where Preact + SCSS modules triggered full reloads. CSS module HMR requires a client-side framework to re-render components with updated class name hashes — pure SSR pages cannot hot-update CSS module class names without a full reload. Closes #14869 * fix(hmr): clarify comment about CSS HMR working for all pages The previous comment suggested CSS HMR only worked through framework-specific boundaries. Vite's built-in style update mechanism handles it for all pages, with or without framework components (covered by the scss-external test). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * update pnpm-lock.yaml * chore: add changeset for SCSS/CSS module HMR fix --------- Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Matthew Phillips <matthew@matthewphillips.info> Co-authored-by: Matthew Phillips <matthewphillips@cloudflare.com>
1 parent 5f3085b commit bb4586a

11 files changed

Lines changed: 114 additions & 0 deletions

File tree

.changeset/famous-heads-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes SCSS and CSS module file changes triggering a full page reload instead of hot-updating styles in place during development
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import preact from '@astrojs/preact';
2+
import { defineConfig } from 'astro/config';
3+
4+
export default defineConfig({
5+
integrations: [preact()],
6+
});

packages/astro/e2e/fixtures/hmr/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
"name": "@e2e/hmr",
33
"version": "0.0.0",
44
"private": true,
5+
"dependencies": {
6+
"@astrojs/preact": "workspace:*",
7+
"preact": "^10.28.2"
8+
},
59
"devDependencies": {
610
"astro": "workspace:*",
711
"sass": "^1.98.0"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import styles from '../styles/scss-module.module.scss';
2+
3+
export default function ScssModuleHeading() {
4+
return <h1 class={styles.scssModule}>This is blue</h1>;
5+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<html>
2+
<head>
3+
<title>Test</title>
4+
</head>
5+
<body>
6+
<h1 class="scss-external">This is blue</h1>
7+
<style>
8+
@import "../styles/scss-external.scss";
9+
</style>
10+
</body>
11+
</html>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
import ScssModuleHeading from '../components/ScssModuleHeading.jsx';
3+
---
4+
5+
<html>
6+
<head>
7+
<title>Test</title>
8+
</head>
9+
<body>
10+
<ScssModuleHeading client:load />
11+
</body>
12+
</html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.scss-external {
2+
color: blue;
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.scssModule {
2+
color: blue;
3+
}

packages/astro/e2e/hmr.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,36 @@ test.describe('Styles', () => {
8484
await expect(h).toHaveCSS('color', 'rgb(255, 0, 0)');
8585
});
8686

87+
test('external SCSS refresh with HMR', async ({ page, astro }) => {
88+
await page.goto(astro.resolveUrl('/scss-external'));
89+
90+
page.once('load', throwPageShouldNotReload);
91+
92+
const h = page.locator('h1');
93+
await expect(h).toHaveCSS('color', 'rgb(0, 0, 255)');
94+
95+
await astro.editFile('./src/styles/scss-external.scss', (original) =>
96+
original.replace('blue', 'red'),
97+
);
98+
99+
await expect(h).toHaveCSS('color', 'rgb(255, 0, 0)');
100+
});
101+
102+
test('SCSS modules refresh with HMR', async ({ page, astro }) => {
103+
await page.goto(astro.resolveUrl('/scss-module'));
104+
105+
page.once('load', throwPageShouldNotReload);
106+
107+
const h = page.locator('h1');
108+
await expect(h).toHaveCSS('color', 'rgb(0, 0, 255)');
109+
110+
await astro.editFile('./src/styles/scss-module.module.scss', (original) =>
111+
original.replace('blue', 'red'),
112+
);
113+
114+
await expect(h).toHaveCSS('color', 'rgb(255, 0, 0)');
115+
});
116+
87117
test('added style tag refresh with full-reload', async ({ page, astro }) => {
88118
await page.goto(astro.resolveUrl('/css-inline-component'));
89119

packages/astro/src/vite-plugin-hmr-reload/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../vite-plugin-pages/const.js';
33
import { getDevCssModuleNameFromPageVirtualModuleName } from '../vite-plugin-css/util.js';
44
import { isAstroServerEnvironment } from '../environments.js';
55

6+
const STYLE_EXT_REGEX = /\.(?:css|scss|sass|less|styl|pcss)$/i;
7+
8+
function isStyleModule(mod: EnvironmentModuleNode): boolean {
9+
if (mod.file && STYLE_EXT_REGEX.test(mod.file)) return true;
10+
// CSS modules and other style files may have query params in their id (e.g. ?used, ?direct)
11+
if (mod.id) {
12+
const idPath = mod.id.split('?')[0];
13+
if (STYLE_EXT_REGEX.test(idPath)) return true;
14+
}
15+
return false;
16+
}
17+
618
/**
719
* The very last Vite plugin to reload the browser if any SSR-only module are updated
820
* which will require a full page reload. This mimics the behaviour of Vite 5 where
@@ -18,10 +30,16 @@ export default function hmrReload(): Plugin {
1830
if (!isAstroServerEnvironment(this.environment)) return;
1931

2032
let hasSsrOnlyModules = false;
33+
let hasSkippedStyleModules = false;
2134

2235
const invalidatedModules = new Set<EnvironmentModuleNode>();
2336
for (const mod of modules) {
2437
if (mod.id == null) continue;
38+
if (isStyleModule(mod)) {
39+
hasSkippedStyleModules = true;
40+
continue;
41+
}
42+
2543
const clientModule = server.environments.client.moduleGraph.getModuleById(mod.id);
2644
if (clientModule != null) continue;
2745

@@ -45,6 +63,16 @@ export default function hmrReload(): Plugin {
4563
server.ws.send({ type: 'full-reload' });
4664
return [];
4765
}
66+
67+
// When style modules were skipped, return an empty array to prevent Vite's
68+
// default SSR HMR propagation. Without this, Vite would propagate through the
69+
// module graph to .astro importers, find no HMR acceptor, and trigger a
70+
// full page reload. The client environment handles CSS HMR natively via
71+
// Vite's built-in style update mechanism, which works for all pages
72+
// (with or without framework components).
73+
if (hasSkippedStyleModules) {
74+
return [];
75+
}
4876
},
4977
},
5078
};

0 commit comments

Comments
 (0)