Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/fix-stale-css-hmr-16780.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'astro': patch
---

Fixes stale inline CSS in server-rendered HTML after CSS file edits during dev

When editing a CSS file (`.css`, `.scss`, etc.) during development, the inline `<style>` tags in server-rendered HTML would retain old CSS content instead of updating. This caused a brief flash of old CSS (FOUC) on fresh page loads before Vite's client-side HMR corrected the styles.

The fix ensures that Astro's per-route dev CSS virtual modules are invalidated in both the SSR module graph and the module runner's evaluation cache when a style file changes, so the next page render picks up the fresh CSS.
15 changes: 15 additions & 0 deletions packages/astro/src/vite-plugin-hmr-reload/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isRunnableDevEnvironment, type EnvironmentModuleNode, type Plugin } from 'vite';
import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../vite-plugin-pages/const.js';
import { RESOLVED_MODULE_DEV_CSS_PREFIX } from '../vite-plugin-css/const.js';
import { getDevCssModuleNameFromPageVirtualModuleName } from '../vite-plugin-css/util.js';
import { isAstroServerEnvironment } from '../environments.js';

Expand Down Expand Up @@ -94,6 +95,20 @@ export default function hmrReload(): Plugin {
// Vite's built-in style update mechanism, which works for all pages
// (with or without framework components).
if (hasSkippedStyleModules) {
// Invalidate all per-route dev CSS virtual modules so the next SSR request
// re-collects CSS with updated content. Without this, the inline <style>
// tags injected for anti-FOUC would serve stale CSS after HMR updates.
for (const [id, mod] of this.environment.moduleGraph.idToModuleMap) {
if (id.startsWith(RESOLVED_MODULE_DEV_CSS_PREFIX)) {
this.environment.moduleGraph.invalidateModule(mod, undefined, timestamp, true);
if (isRunnableDevEnvironment(this.environment)) {
const runnerMod = this.environment.runner.evaluatedModules.getModuleById(id);
if (runnerMod) {
this.environment.runner.evaluatedModules.invalidateModule(runnerMod);
}
}
}
}
return [];
}
},
Expand Down
217 changes: 217 additions & 0 deletions packages/astro/test/units/dev/hmr-css-invalidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import hmrReload from '../../../dist/vite-plugin-hmr-reload/index.js';

/**
* Tests for CSS HMR invalidation of SSR dev-css virtual modules.
*
* When a CSS file changes during dev, the astro:hmr-reload plugin must
* invalidate the per-route virtual:astro:dev-css:* modules in the SSR
* environment so the next SSR render picks up fresh CSS content.
* Without this, the server-rendered inline <style> tags serve stale CSS.
*
* Note: Runner evaluation cache invalidation (via isRunnableDevEnvironment)
* requires a real Vite RunnableDevEnvironment instance and cannot be unit
* tested with mocks. That path is verified through manual integration testing.
*/
describe('astro:hmr-reload CSS invalidation', () => {
/**
* Creates a mock environment and context for testing the hotUpdate handler.
* The environment mock is not a real RunnableDevEnvironment, so
* isRunnableDevEnvironment() will return false. This means runner cache
* invalidation won't be tested here, but module graph invalidation will.
*/
function createMockContext(options: {
modules: Array<{ id: string | null; file?: string }>;
moduleGraphEntries?: Array<[string, { id: string }]>;
}) {
const invalidatedModuleGraphIds: string[] = [];

const moduleGraphEntries = new Map<string, { id: string }>(
options.moduleGraphEntries ?? [],
);

const environment = {
name: 'ssr',
moduleGraph: {
idToModuleMap: moduleGraphEntries,
getModuleById: (id: string) => moduleGraphEntries.get(id) ?? null,
invalidateModule: (mod: { id: string }, _seen?: Set<unknown>, _ts?: number, _isHmr?: boolean) => {
invalidatedModuleGraphIds.push(mod.id);
},
},
};

const server = {
environments: {
client: {
moduleGraph: {
getModuleById: (_id: string) => null as object | null,
},
},
},
ws: { send: () => {} },
};

return {
environment,
server,
invalidatedModuleGraphIds,
};
}

it('invalidates dev-css virtual modules in module graph when a CSS file changes', () => {
const devCssId1 = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const devCssId2 = '\0virtual:astro:dev-css:src/pages/posts/[id]@_@astro';

const { environment, server, invalidatedModuleGraphIds } = createMockContext({
modules: [{ id: '/path/to/global.css', file: '/path/to/global.css' }],
moduleGraphEntries: [
[devCssId1, { id: devCssId1 }],
[devCssId2, { id: devCssId2 }],
['some-other-module', { id: 'some-other-module' }],
],
});

const plugin = hmrReload();
const hotUpdate = plugin.hotUpdate as { order: string; handler: Function };

const result = hotUpdate.handler.call(
{ environment },
{
modules: [{ id: '/path/to/global.css', file: '/path/to/global.css' }],
server,
timestamp: Date.now(),
file: '/path/to/global.css',
},
);

// Should return empty array (handled, no full reload)
assert.deepEqual(result, []);

// Both dev-css virtual modules should be invalidated in the module graph
assert.ok(
invalidatedModuleGraphIds.includes(devCssId1),
'dev-css module for index should be invalidated in module graph',
);
assert.ok(
invalidatedModuleGraphIds.includes(devCssId2),
'dev-css module for dynamic route should be invalidated in module graph',
);

// Non-dev-css modules should NOT be invalidated
assert.ok(
!invalidatedModuleGraphIds.includes('some-other-module'),
'non-dev-css modules should not be invalidated',
);
});

it('invalidates dev-css modules for SCSS file changes', () => {
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';

const { environment, server, invalidatedModuleGraphIds } = createMockContext({
modules: [{ id: '/path/to/styles.scss', file: '/path/to/styles.scss' }],
moduleGraphEntries: [
[devCssId, { id: devCssId }],
],
});

const plugin = hmrReload();
const hotUpdate = plugin.hotUpdate as { order: string; handler: Function };

const result = hotUpdate.handler.call(
{ environment },
{
modules: [{ id: '/path/to/styles.scss', file: '/path/to/styles.scss' }],
server,
timestamp: Date.now(),
file: '/path/to/styles.scss',
},
);

assert.deepEqual(result, []);
assert.ok(
invalidatedModuleGraphIds.includes(devCssId),
'dev-css module should be invalidated for SCSS changes',
);
});

it('does not invalidate dev-css modules when no style modules are present', () => {
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';

const { environment, server, invalidatedModuleGraphIds } = createMockContext({
modules: [{ id: '/path/to/component.astro', file: '/path/to/component.astro' }],
moduleGraphEntries: [
[devCssId, { id: devCssId }],
],
});

// The .astro file exists in the client module graph too
server.environments.client.moduleGraph.getModuleById = (id: string) =>
id === '/path/to/component.astro' ? { id } : null;

const plugin = hmrReload();
const hotUpdate = plugin.hotUpdate as { order: string; handler: Function };

const result = hotUpdate.handler.call(
{ environment },
{
modules: [{ id: '/path/to/component.astro', file: '/path/to/component.astro' }],
server,
timestamp: Date.now(),
file: '/path/to/component.astro',
},
);

// For client-visible module changes, the handler returns undefined
assert.equal(result, undefined);

// The dev-css module should NOT be invalidated (CSS invalidation is for style-only changes)
assert.equal(invalidatedModuleGraphIds.length, 0);
});

it('returns empty array for CSS changes to prevent full page reload', () => {
const { environment, server } = createMockContext({
modules: [{ id: '/path/to/styles.css', file: '/path/to/styles.css' }],
});

const plugin = hmrReload();
const hotUpdate = plugin.hotUpdate as { order: string; handler: Function };

const result = hotUpdate.handler.call(
{ environment },
{
modules: [{ id: '/path/to/styles.css', file: '/path/to/styles.css' }],
server,
timestamp: Date.now(),
file: '/path/to/styles.css',
},
);

// Must return [] to prevent Vite's default SSR HMR propagation
assert.deepEqual(result, []);
});

it('handles empty dev-css module map gracefully', () => {
const { environment, server, invalidatedModuleGraphIds } = createMockContext({
modules: [{ id: '/path/to/styles.css', file: '/path/to/styles.css' }],
moduleGraphEntries: [],
});

const plugin = hmrReload();
const hotUpdate = plugin.hotUpdate as { order: string; handler: Function };

const result = hotUpdate.handler.call(
{ environment },
{
modules: [{ id: '/path/to/styles.css', file: '/path/to/styles.css' }],
server,
timestamp: Date.now(),
file: '/path/to/styles.css',
},
);

assert.deepEqual(result, []);
assert.equal(invalidatedModuleGraphIds.length, 0);
});
});
Loading