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
5 changes: 5 additions & 0 deletions .changeset/exact-style-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes CSS updates incorrectly relying on unrelated client style modules during development
57 changes: 24 additions & 33 deletions packages/astro/src/vite-plugin-hmr-reload/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { isRunnableDevEnvironment, type EnvironmentModuleNode, type Plugin } from 'vite';
import { inlineRE, rawRE } from '../vite-plugin-astro-server/util.js';
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';

const STYLE_EXT_REGEX = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/i;
const STYLE_COMPONENT_EXT_REGEX = /\.(astro|svelte|vue)$/i;
const RAW_QUERY_REGEX = /(?:\?|&)raw(?:&|$)/;

function hasStyleExtension(id: string): boolean {
// Style module IDs may include Vite query params such as ?used or ?direct.
return STYLE_EXT_REGEX.test(id.split('?')[0]);
}

Expand All @@ -21,39 +20,28 @@ function isComponentStyleModule(id: string): boolean {
return query.get('type') === 'style' && query.has(extensionMatch[1].toLowerCase());
}

// Whether a style module comes from a file (e.g. global.css, styles.scss)
// or from a component request (e.g. *.astro?astro&type=style, *.svelte?svelte&type=style).
function isCssStringModule(id: string): boolean {
return hasStyleExtension(id) && (rawRE.test(id) || inlineRE.test(id));
}

type StyleModuleType = 'file' | 'component';

function getStyleModuleType(mod: EnvironmentModuleNode): StyleModuleType | undefined {
// CSS imported with ?raw is a JS string export, so SSR importers need to be invalidated
// instead of relying on Vite's client-side CSS HMR handling.
if (mod.id && RAW_QUERY_REGEX.test(mod.id) && hasStyleExtension(mod.id)) return;
if (mod.id && isComponentStyleModule(mod.id)) return 'component';
if (mod.file && hasStyleExtension(mod.file)) return 'file';
// CSS modules and other style files may have query params in their id (e.g. ?used, ?direct)
return mod.id && hasStyleExtension(mod.id) ? 'file' : undefined;
}

function hasClientStyleModuleByFile(
moduleGraph: { getModulesByFile?(file: string): Set<EnvironmentModuleNode> | undefined },
mod: EnvironmentModuleNode,
styleType: StyleModuleType,
): boolean {
if (mod.file == null || moduleGraph.getModulesByFile == null) return false;

const fileModules = moduleGraph.getModulesByFile(mod.file);
if (fileModules == null) return false;

for (const fileMod of fileModules) {
if (fileMod.id == null) continue;
if (styleType === 'component') {
if (isComponentStyleModule(fileMod.id)) return true;
} else if (!RAW_QUERY_REGEX.test(fileMod.id) && hasStyleExtension(fileMod.id)) {
return true;
}
}
return false;
function getClientStyleModuleId(id: string, styleType: StyleModuleType): string {
if (styleType === 'file') return id;

// SSR CSS collection may add `inline`, while client CSS HMR uses the remaining request ID.
const [filename, rawQuery] = id.split('?', 2);
const clientQuery = rawQuery
.split('&')
.filter((parameter) => parameter !== 'inline')
.join('&');
return clientQuery ? `${filename}?${clientQuery}` : filename;
}

/**
Expand All @@ -77,15 +65,18 @@ export default function hmrReload(): Plugin {
const invalidatedModules = new Set<EnvironmentModuleNode>();
for (const mod of modules) {
if (mod.id == null) continue;
const styleType = getStyleModuleType(mod);
const clientModule = server.environments.client.moduleGraph.getModuleById(mod.id);
const isCssString = isCssStringModule(mod.id);
const styleType = isCssString ? undefined : getStyleModuleType(mod);
// CSS string exports need SSR invalidation rather than client-side CSS HMR.
const clientModule = isCssString
? null
: server.environments.client.moduleGraph.getModuleById(
styleType ? getClientStyleModuleId(mod.id, styleType) : mod.id,
);
if (styleType) {
hasStyleModules = true;
// No client module means nothing will apply the CSS update client-side, so force a reload.
if (
clientModule == null &&
!hasClientStyleModuleByFile(server.environments.client.moduleGraph, mod, styleType)
) {
if (clientModule == null) {
this.environment.moduleGraph.invalidateModule(
mod,
invalidatedModules,
Expand Down
154 changes: 81 additions & 73 deletions packages/astro/test/units/dev/hmr-css-invalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,12 @@ describe('astro:hmr-reload CSS invalidation', () => {
modules: Array<{ id: string | null; file?: string }>;
moduleGraphEntries?: Array<[string, { id: string }]>;
clientModuleIds?: string[];
clientModuleFileEntries?: Array<[string, Array<{ id: string; file?: string }>]>;
}) {
const invalidatedModuleGraphIds: string[] = [];
const wsMessages: unknown[] = [];

const moduleGraphEntries = new Map<string, { id: string }>(options.moduleGraphEntries ?? []);
const clientModuleIds = new Set(options.clientModuleIds ?? []);
const clientModuleFileEntries = new Map(
(options.clientModuleFileEntries ?? []).map(([file, modules]) => [
file,
new Set(modules.map((mod) => ({ file, ...mod }))),
]),
);

const environment = {
name: 'ssr',
Expand All @@ -80,7 +73,12 @@ describe('astro:hmr-reload CSS invalidation', () => {
client: {
moduleGraph: {
getModuleById: (id: string) => (clientModuleIds.has(id) ? { id } : null),
getModulesByFile: (file: string) => clientModuleFileEntries.get(file),
getModulesByFile: (file: string) => {
const modules = [...clientModuleIds]
.filter((id) => id.split('?', 1)[0] === file)
.map((id) => ({ id, file }));
return modules.length > 0 ? new Set(modules) : undefined;
},
},
},
},
Expand Down Expand Up @@ -179,9 +177,10 @@ describe('astro:hmr-reload CSS invalidation', () => {
];

for (const id of styleBlockIds) {
const file = id.split('?', 1)[0];
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds } = createMockContext({
modules: [{ id, file: '/src/Component.astro' }],
modules: [{ id, file }],
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleIds: [id],
});
Expand All @@ -191,10 +190,10 @@ describe('astro:hmr-reload CSS invalidation', () => {
const result = hotUpdate.call(
{ environment },
{
modules: [{ id, file: '/src/Component.astro' }],
modules: [{ id, file }],
server,
timestamp: Date.now(),
file: '/src/Component.astro',
file,
},
);

Expand All @@ -206,26 +205,31 @@ describe('astro:hmr-reload CSS invalidation', () => {
}
});

it('uses SSR invalidation for component style block virtual modules missing from the client graph', () => {
for (const id of [
'/src/Component.astro?astro&type=style&index=0&lang.css',
'/src/MotionOneNav.svelte?svelte&type=style&lang.css',
it('uses SSR invalidation for component styles missing an exact client module', () => {
for (const { id, clientModuleIds } of [
{
id: '/src/Component.astro?astro&type=style&index=0&lang.css',
clientModuleIds: ['/src/Component.astro?astro&type=style&index=1&lang.css'],
},
{ id: '/src/MotionOneNav.svelte?svelte&type=style&lang.css', clientModuleIds: [] },
]) {
const file = id.split('?', 1)[0];
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds, wsMessages } = createMockContext({
modules: [{ id, file: '/src/Component.astro' }],
modules: [{ id, file }],
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleIds,
});

const hotUpdate = getHotUpdateHandler();

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

Expand All @@ -242,43 +246,50 @@ describe('astro:hmr-reload CSS invalidation', () => {
}
});

it('uses client CSS HMR when a component style block has a query-varied client module', () => {
const serverStyleId = '/src/MotionOneNav.svelte?svelte&type=style&lang.css';
const clientStyleId = '/src/MotionOneNav.svelte?svelte&type=style&lang.css&used';
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds, wsMessages } = createMockContext({
modules: [{ id: serverStyleId, file: '/src/MotionOneNav.svelte' }],
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleFileEntries: [
['/src/MotionOneNav.svelte', [{ id: clientStyleId, file: '/src/MotionOneNav.svelte' }]],
],
});

const hotUpdate = getHotUpdateHandler();
it('matches inline component style requests to their client style module', () => {
const clientStyleId = '/src/MotionOneNav.svelte?svelte&type=style&lang.css';
const serverStyleIds = [
'/src/MotionOneNav.svelte?inline&svelte&type=style&lang.css',
'/src/MotionOneNav.svelte?svelte&inline&type=style&lang.css',
'/src/MotionOneNav.svelte?svelte&type=style&lang.css&inline',
];

const result = hotUpdate.call(
{ environment },
{
for (const serverStyleId of serverStyleIds) {
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds, wsMessages } = createMockContext({
modules: [{ id: serverStyleId, file: '/src/MotionOneNav.svelte' }],
server,
timestamp: Date.now(),
file: '/src/MotionOneNav.svelte',
},
);
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleIds: [clientStyleId],
});

assert.deepEqual(result, []);
assert.ok(
invalidatedModuleGraphIds.includes(devCssId),
'dev-css module should be invalidated when client style-block HMR can apply the update',
);
assert.deepEqual(wsMessages, []);
const hotUpdate = getHotUpdateHandler();

const result = hotUpdate.call(
{ environment },
{
modules: [{ id: serverStyleId, file: '/src/MotionOneNav.svelte' }],
server,
timestamp: Date.now(),
file: '/src/MotionOneNav.svelte',
},
);

assert.deepEqual(result, []);
assert.ok(
invalidatedModuleGraphIds.includes(devCssId),
'dev-css module should be invalidated when client style HMR can apply the update',
);
assert.ok(!invalidatedModuleGraphIds.includes(serverStyleId));
assert.deepEqual(wsMessages, []);
}
});

it('uses SSR invalidation for CSS files missing from the client graph', () => {
it('uses SSR invalidation for CSS files missing an exact client module', () => {
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds, wsMessages } = createMockContext({
modules: [{ id: '/path/to/global.css', file: '/path/to/global.css' }],
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleIds: ['/path/to/global.css?inline'],
});

const hotUpdate = getHotUpdateHandler();
Expand All @@ -305,42 +316,39 @@ describe('astro:hmr-reload CSS invalidation', () => {
assert.deepEqual(wsMessages, [{ type: 'full-reload' }]);
});

it('uses client CSS HMR when a CSS file has a query-varied client module', () => {
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds, wsMessages } = createMockContext({
modules: [{ id: '/path/to/global.css', file: '/path/to/global.css' }],
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleFileEntries: [
['/path/to/global.css', [{ id: '/path/to/global.css?used', file: '/path/to/global.css' }]],
],
});
it('uses SSR invalidation for CSS string imports', () => {
for (const id of ['/path/to/global.css?raw', '/path/to/global.css?inline']) {
const devCssId = '\0virtual:astro:dev-css:src/pages/index@_@astro';
const { environment, server, invalidatedModuleGraphIds, wsMessages } = createMockContext({
modules: [{ id, file: '/path/to/global.css' }],
moduleGraphEntries: [[devCssId, { id: devCssId }]],
clientModuleIds: [id],
});

const hotUpdate = getHotUpdateHandler();
const hotUpdate = getHotUpdateHandler();

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

assert.deepEqual(result, []);
assert.ok(
invalidatedModuleGraphIds.includes(devCssId),
'dev-css module should be invalidated when client CSS HMR can apply the update',
);
assert.deepEqual(wsMessages, []);
assert.deepEqual(result, []);
assert.ok(invalidatedModuleGraphIds.includes(id));
assert.ok(!invalidatedModuleGraphIds.includes(devCssId));
assert.deepEqual(wsMessages, [{ type: 'full-reload' }]);
}
});

it('does not treat non-style component queries, raw CSS, or type=style alone as style modules', () => {
it('does not treat component scripts or incomplete style queries as style modules', () => {
const nonStyleIds = [
{ id: '/src/Component.astro?astro&type=script&index=0', file: '/src/Component.astro' },
{ id: '/src/Component.svelte?svelte&type=script&lang.ts', file: '/src/Component.svelte' },
{ id: '/src/Component.svelte?type=style&lang.css', file: '/src/Component.svelte' },
{ id: '/src/file.css?raw', file: '/src/file.css' },
{ id: '/src/module.ts?type=style', file: '/src/module.ts' },
{ id: '/src/module.ts?type=style&lang.css', file: '/src/module.ts' },
];
Expand Down
Loading