Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fancy-spies-jog.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import ScssModuleHeading from '../components/ScssModuleHeading.jsx';
---

<html>
<head>
<title>Test</title>
</head>
<body>
<div id="ssr">
<ScssModuleHeading />
</div>
<div id="island">
<ScssModuleHeading client:load />
</div>
</body>
</html>
29 changes: 29 additions & 0 deletions packages/astro/e2e/hmr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
19 changes: 19 additions & 0 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import nodeFs from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as vite from 'vite';
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions packages/astro/test/0-css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ describe('CSS', function () {

after(async () => {
await devServer.stop();
fixture.resetAllFiles();
});

it('resolves CSS in public/', async () => {
Expand Down Expand Up @@ -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 () => {
Comment thread
astro-factory[bot] marked this conversation as resolved.
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',
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Styles from './CssModuleHmr.module.css';

export default function CssModuleHmr() {
return (
<h1 className={Styles.title}>CSS Module HMR</h1>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.title {
font-family: fantasy;
}
17 changes: 17 additions & 0 deletions packages/astro/test/fixtures/0-css/src/pages/css-module-hmr.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import CssModuleHmr from '../components/CssModuleHmr.jsx';
---

<html>
<head>
<title>CSS Module HMR</title>
</head>
<body>
<div id="ssr">
<CssModuleHmr />
</div>
<div id="island">
<CssModuleHmr client:load />
</div>
</body>
</html>
82 changes: 82 additions & 0 deletions packages/astro/test/units/dev/css-module-scoped-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as assert from 'node:assert/strict';
Comment thread
astro-factory[bot] marked this conversation as resolved.
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',
);
});
});
Loading