diff --git a/.changeset/fancy-spies-jog.md b/.changeset/fancy-spies-jog.md
new file mode 100644
index 000000000000..e7c85d526934
--- /dev/null
+++ b/.changeset/fancy-spies-jog.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes CSS Module HMR in dev when a component is rendered both with and without hydration on the same page. Astro now uses path-based class name hashing in dev mode so that editing CSS declarations no longer changes the generated selectors, allowing Vite's CSS HMR to update styles without a full page reload.
diff --git a/packages/astro/e2e/fixtures/hmr/src/pages/scss-module-ssr-and-island.astro b/packages/astro/e2e/fixtures/hmr/src/pages/scss-module-ssr-and-island.astro
new file mode 100644
index 000000000000..20428cb72012
--- /dev/null
+++ b/packages/astro/e2e/fixtures/hmr/src/pages/scss-module-ssr-and-island.astro
@@ -0,0 +1,17 @@
+---
+import ScssModuleHeading from '../components/ScssModuleHeading.jsx';
+---
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/e2e/hmr.test.ts b/packages/astro/e2e/hmr.test.ts
index bf1024d3acfa..24cd3c9230da 100644
--- a/packages/astro/e2e/hmr.test.ts
+++ b/packages/astro/e2e/hmr.test.ts
@@ -110,6 +110,35 @@ test.describe('Styles', () => {
await expect(h).toHaveCSS('color', 'rgb(255, 0, 0)');
});
+ test('SCSS modules shared by SSR and hydrated island refresh with HMR', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/scss-module-ssr-and-island'));
+ await waitForViteToSettle(page);
+
+ page.once('load', throwPageShouldNotReload);
+
+ const ssr = page.locator('#ssr h1');
+ const island = page.locator('#island h1');
+
+ await expect(ssr).toHaveCSS('color', 'rgb(0, 0, 255)');
+ await expect(island).toHaveCSS('color', 'rgb(0, 0, 255)');
+
+ const ssrClass = await ssr.getAttribute('class');
+ const islandClass = await island.getAttribute('class');
+ expect(ssrClass).toBe(islandClass);
+
+ await astro.editFile('./src/styles/scss-module.module.scss', (original) =>
+ original.replace('blue', 'red'),
+ );
+
+ await expect(ssr).toHaveCSS('color', 'rgb(255, 0, 0)');
+ await expect(island).toHaveCSS('color', 'rgb(255, 0, 0)');
+
+ const ssrClassAfter = await ssr.getAttribute('class');
+ const islandClassAfter = await island.getAttribute('class');
+ expect(ssrClassAfter).toBe(ssrClass);
+ expect(islandClassAfter).toBe(islandClass);
+ });
+
test('external SCSS refresh with HMR', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/scss-external'));
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index 3954b670ddb1..1f6ee5551a93 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto';
import nodeFs from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as vite from 'vite';
@@ -288,6 +289,24 @@ export async function createVite(
],
},
build: { assetsDir: settings.config.build.assets },
+ css: !isBuild
+ ? {
+ modules: {
+ // Vite's default postcss-modules scoped-name generator hashes the CSS
+ // *content*, so every declaration edit changes class names. In dev this
+ // breaks HMR for server-rendered HTML because the DOM still references
+ // the previous names. Use a path-based hash instead so selectors stay
+ // stable and Vite's CSS HMR can swap rules without a full page reload.
+ generateScopedName(name, filename) {
+ const hash = createHash('sha256')
+ .update(filename + '\0' + name)
+ .digest('base64url')
+ .substring(0, 5);
+ return `_${name}_${hash}`;
+ },
+ },
+ }
+ : undefined,
environments: {
[ASTRO_VITE_ENVIRONMENT_NAMES.astro]: {
// This is all that's needed to create a new RunnableDevEnvironment
diff --git a/packages/astro/test/0-css.test.ts b/packages/astro/test/0-css.test.ts
index 335287df4703..87daaee8dac1 100644
--- a/packages/astro/test/0-css.test.ts
+++ b/packages/astro/test/0-css.test.ts
@@ -394,6 +394,7 @@ describe('CSS', function () {
after(async () => {
await devServer.stop();
+ fixture.resetAllFiles();
});
it('resolves CSS in public/', async () => {
@@ -479,5 +480,65 @@ describe('CSS', function () {
const el = $('#css-raw');
assert.equal(el.text(), '.foo {color: red;}');
});
+
+ it('keeps CSS Module class names stable for SSR and hydrated island after style edits', async () => {
+ const initialRes = await fixture.fetch('/css-module-hmr');
+ assert.equal(initialRes.status, 200);
+ const initialHtml = await initialRes.text();
+ const $initial = cheerio.load(initialHtml);
+
+ const ssrClass = $initial('#ssr h1').attr('class');
+ const islandClass = $initial('#island h1').attr('class');
+
+ assert.ok(ssrClass, 'SSR instance should have a generated class name');
+ assert.equal(
+ ssrClass,
+ islandClass,
+ 'SSR and hydrated island should use the same generated class name',
+ );
+
+ const initialStyles = $initial('style').text();
+ assert.match(
+ initialStyles,
+ /font-family:\s*fantasy/,
+ 'initial inline styles should contain the original declaration',
+ );
+
+ await fixture.editFile('/src/components/CssModuleHmr.module.css', (contents) =>
+ contents.replace('font-family: fantasy', 'font-family: monospace'),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ const updatedRes = await fixture.fetch('/css-module-hmr');
+ assert.equal(updatedRes.status, 200);
+ const updatedHtml = await updatedRes.text();
+ const $updated = cheerio.load(updatedHtml);
+
+ const ssrClassAfter = $updated('#ssr h1').attr('class');
+ const islandClassAfter = $updated('#island h1').attr('class');
+
+ assert.equal(
+ ssrClassAfter,
+ ssrClass,
+ 'SSR class name should stay stable after the CSS edit',
+ );
+ assert.equal(
+ islandClassAfter,
+ ssrClass,
+ 'hydrated island class name should stay stable after the CSS edit',
+ );
+
+ const updatedStyles = $updated('style').text();
+ assert.doesNotMatch(
+ updatedStyles,
+ /font-family:\s*fantasy/,
+ 'updated inline styles should no longer contain the original declaration',
+ );
+ assert.match(
+ updatedStyles,
+ /font-family:\s*monospace/,
+ 'updated inline styles should contain the new declaration',
+ );
+ });
});
});
diff --git a/packages/astro/test/fixtures/0-css/src/components/CssModuleHmr.jsx b/packages/astro/test/fixtures/0-css/src/components/CssModuleHmr.jsx
new file mode 100644
index 000000000000..bc9dc805359d
--- /dev/null
+++ b/packages/astro/test/fixtures/0-css/src/components/CssModuleHmr.jsx
@@ -0,0 +1,7 @@
+import Styles from './CssModuleHmr.module.css';
+
+export default function CssModuleHmr() {
+ return (
+ CSS Module HMR
+ );
+}
diff --git a/packages/astro/test/fixtures/0-css/src/components/CssModuleHmr.module.css b/packages/astro/test/fixtures/0-css/src/components/CssModuleHmr.module.css
new file mode 100644
index 000000000000..8e1e3298f56d
--- /dev/null
+++ b/packages/astro/test/fixtures/0-css/src/components/CssModuleHmr.module.css
@@ -0,0 +1,3 @@
+.title {
+ font-family: fantasy;
+}
diff --git a/packages/astro/test/fixtures/0-css/src/pages/css-module-hmr.astro b/packages/astro/test/fixtures/0-css/src/pages/css-module-hmr.astro
new file mode 100644
index 000000000000..02f5ee17f720
--- /dev/null
+++ b/packages/astro/test/fixtures/0-css/src/pages/css-module-hmr.astro
@@ -0,0 +1,17 @@
+---
+import CssModuleHmr from '../components/CssModuleHmr.jsx';
+---
+
+
+
+ CSS Module HMR
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/test/units/dev/css-module-scoped-name.test.ts b/packages/astro/test/units/dev/css-module-scoped-name.test.ts
new file mode 100644
index 000000000000..afb2a62af00c
--- /dev/null
+++ b/packages/astro/test/units/dev/css-module-scoped-name.test.ts
@@ -0,0 +1,82 @@
+import * as assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { createVite } from '../../../dist/core/create-vite.js';
+import { createBasicSettings, defaultLogger } from '../test-utils.ts';
+
+type GenerateScopedName = (name: string, filename: string, css: string) => string;
+
+describe('CSS module scoped name in dev', () => {
+ it('provides a stable generateScopedName in dev mode', async () => {
+ const settings = await createBasicSettings();
+ const routesList = { routes: [] };
+ const config = await createVite(
+ {},
+ {
+ settings,
+ logger: defaultLogger,
+ mode: 'development',
+ command: 'dev',
+ routesList,
+ sync: false,
+ },
+ );
+
+ const generateScopedName = config.css?.modules
+ ? (config.css.modules as { generateScopedName?: GenerateScopedName }).generateScopedName
+ : undefined;
+
+ assert.ok(
+ typeof generateScopedName === 'function',
+ 'dev config should have generateScopedName',
+ );
+
+ // Same file + class name should produce the same result regardless of CSS content
+ const name1 = generateScopedName('card', '/src/Card.module.css', '.card { color: red; }');
+ const name2 = generateScopedName('card', '/src/Card.module.css', '.card { color: blue; }');
+ assert.equal(name1, name2, 'class name should be stable across CSS content changes');
+
+ // Different files should produce different names for the same class
+ const nameA = generateScopedName('card', '/src/A.module.css', '.card { color: red; }');
+ const nameB = generateScopedName('card', '/src/B.module.css', '.card { color: red; }');
+ assert.notEqual(nameA, nameB, 'different files should produce different class names');
+
+ // Different class names in the same file should produce different names
+ const nameCard = generateScopedName('card', '/src/Card.module.css', '');
+ const nameTitle = generateScopedName('title', '/src/Card.module.css', '');
+ assert.notEqual(
+ nameCard,
+ nameTitle,
+ 'different classes in the same file should produce different names',
+ );
+
+ // Name should include the local class name for debuggability
+ assert.ok(name1.includes('card'), 'generated name should include the local class name');
+ });
+
+ it('does not set generateScopedName in build mode', async () => {
+ const settings = await createBasicSettings();
+ const routesList = { routes: [] };
+ const config = await createVite(
+ {},
+ {
+ settings,
+ logger: defaultLogger,
+ mode: 'production',
+ command: 'build',
+ routesList,
+ sync: false,
+ },
+ );
+
+ const modules = config.css?.modules;
+ const generateScopedName = modules
+ ? (modules as { generateScopedName?: GenerateScopedName }).generateScopedName
+ : undefined;
+
+ assert.equal(
+ generateScopedName,
+ undefined,
+ 'build config should not override generateScopedName',
+ );
+ });
+});