From 5dde430abb13dfe9391526d6b4fdda6c86877039 Mon Sep 17 00:00:00 2001 From: thelazylama Date: Thu, 6 Aug 2026 23:54:52 +1000 Subject: [PATCH 1/3] fix(dev): match exact client CSS modules during HMR --- .../astro/src/vite-plugin-hmr-reload/index.ts | 57 +++---- .../units/dev/hmr-css-invalidation.test.ts | 154 +++++++++--------- 2 files changed, 105 insertions(+), 106 deletions(-) diff --git a/packages/astro/src/vite-plugin-hmr-reload/index.ts b/packages/astro/src/vite-plugin-hmr-reload/index.ts index 290b0f034978..50ee094dabbc 100644 --- a/packages/astro/src/vite-plugin-hmr-reload/index.ts +++ b/packages/astro/src/vite-plugin-hmr-reload/index.ts @@ -1,4 +1,5 @@ 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'; @@ -6,10 +7,8 @@ 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]); } @@ -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 | 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; } /** @@ -77,15 +65,18 @@ export default function hmrReload(): Plugin { const invalidatedModules = new Set(); 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, diff --git a/packages/astro/test/units/dev/hmr-css-invalidation.test.ts b/packages/astro/test/units/dev/hmr-css-invalidation.test.ts index 3a7c20b0ef32..3f8e3a5e8ce1 100644 --- a/packages/astro/test/units/dev/hmr-css-invalidation.test.ts +++ b/packages/astro/test/units/dev/hmr-css-invalidation.test.ts @@ -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(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', @@ -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; + }, }, }, }, @@ -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], }); @@ -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, }, ); @@ -206,15 +205,20 @@ 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(); @@ -222,10 +226,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, }, ); @@ -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(); @@ -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' }, ]; From b8ebb87414ec36a23ea9116033ece88243db3424 Mon Sep 17 00:00:00 2001 From: thelazylama Date: Fri, 7 Aug 2026 00:06:45 +1000 Subject: [PATCH 2/3] add changeset --- .changeset/exact-style-updates.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/exact-style-updates.md diff --git a/.changeset/exact-style-updates.md b/.changeset/exact-style-updates.md new file mode 100644 index 000000000000..7154dc81ceab --- /dev/null +++ b/.changeset/exact-style-updates.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes unreliable CSS updates when a matching client-side style module is unavailable From f07ba1019fe0c174fc9227a82327ef6f277c5e9b Mon Sep 17 00:00:00 2001 From: thelazylama Date: Fri, 7 Aug 2026 00:16:51 +1000 Subject: [PATCH 3/3] update changeset --- .changeset/exact-style-updates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/exact-style-updates.md b/.changeset/exact-style-updates.md index 7154dc81ceab..1b9350497c59 100644 --- a/.changeset/exact-style-updates.md +++ b/.changeset/exact-style-updates.md @@ -2,4 +2,4 @@ 'astro': patch --- -Fixes unreliable CSS updates when a matching client-side style module is unavailable +Fixes CSS updates incorrectly relying on unrelated client style modules during development