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', + ); + }); +});