From 2043e4fc0f906665a04cac2a94db2aeea1f8e225 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:47:19 -0700 Subject: [PATCH] fix(incremental): include CSS source in dependency hash to prevent stale cache hits (#17704)\n\nCSS modules have empty `code` in Vite's prerender bundle because their\ncontent is extracted into separate assets. The incremental build's\n`hashModules()` was hashing only the (empty) bundle code, so CSS edits\nnever changed the dependency hash and cached pages kept referencing\nstale CSS filenames.\n\nWhen a module's code is empty and its ID matches a CSS file pattern,\nread the source file from disk and hash its contents instead." (#17705) --- .changeset/wacky-teeth-wear.md | 5 +++ .../core/build/plugins/plugin-incremental.ts | 19 ++++++++++-- .../units/build/plugin-incremental.test.ts | 31 ++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 .changeset/wacky-teeth-wear.md diff --git a/.changeset/wacky-teeth-wear.md b/.changeset/wacky-teeth-wear.md new file mode 100644 index 000000000000..7b6756a0ca78 --- /dev/null +++ b/.changeset/wacky-teeth-wear.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes incremental builds serving cached HTML that references stale CSS filenames after a stylesheet-only edit diff --git a/packages/astro/src/core/build/plugins/plugin-incremental.ts b/packages/astro/src/core/build/plugins/plugin-incremental.ts index 08d2427c06de..d115990321eb 100644 --- a/packages/astro/src/core/build/plugins/plugin-incremental.ts +++ b/packages/astro/src/core/build/plugins/plugin-incremental.ts @@ -1,11 +1,12 @@ import crypto from 'node:crypto'; +import nodeFs from 'node:fs'; import type { Plugin as VitePlugin } from 'vite'; import { FONTS_SERVER_ADDRESS_PLACEHOLDER } from '../../../assets/fonts/constants.js'; import { PROPAGATED_ASSET_FLAG } from '../../../content/consts.js'; import { hasContentFlag } from '../../../content/utils.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../../constants.js'; import { removeQueryString } from '../../path.js'; -import { rootRelativePath } from '../../viteUtils.js'; +import { CSS_LANGS_RE, rootRelativePath } from '../../viteUtils.js'; import { moduleIsTopLevelPage } from '../graph.js'; import { isContentDataIncrementalModule } from '../incremental-metadata.js'; import type { BuildInternals } from '../internal.js'; @@ -84,6 +85,10 @@ function resolveAssetPlaceholders(graph: ModuleGraph, code: string): string { * file on disk but still carry generated code. Emitted-asset placeholders in * that code are resolved to their file names first, since the handles * themselves are not stable between builds. + * + * CSS modules are a special case: Vite extracts their content during the + * prerender build, leaving `code` as an empty string. For those modules, the + * source file is read from disk so that CSS edits invalidate the hash. */ function hashModules(graph: ModuleGraph, sortedIds: string[]): string { const hasher = crypto.createHash('sha256'); @@ -91,8 +96,18 @@ function hashModules(graph: ModuleGraph, sortedIds: string[]): string { hasher.update(id); hasher.update('\n'); const code = graph.getModuleInfo(id)?.code; - if (code != null) { + if (code != null && code.length > 0) { hasher.update(resolveAssetPlaceholders(graph, code)); + } else if (CSS_LANGS_RE.test(id)) { + // Vite extracts CSS into separate assets, so the module's `code` in the + // prerender bundle is empty. Read the source file so that stylesheet + // changes are reflected in the dependency hash (#17704). + try { + hasher.update(nodeFs.readFileSync(removeQueryString(id), 'utf-8')); + } catch { + // Virtual CSS or unreadable file — skip. The worst case is a + // cache miss (re-render), never a stale hit. + } } hasher.update('\n'); } diff --git a/packages/astro/test/units/build/plugin-incremental.test.ts b/packages/astro/test/units/build/plugin-incremental.test.ts index 1e36ac139c7f..b2732ec82101 100644 --- a/packages/astro/test/units/build/plugin-incremental.test.ts +++ b/packages/astro/test/units/build/plugin-incremental.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; import { pluginIncremental } from '../../../dist/core/build/plugins/plugin-incremental.js'; import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../../dist/vite-plugin-pages/const.js'; @@ -122,5 +125,31 @@ describe('pluginIncremental', () => { assert.equal(first, second); assert.match(first, /^[0-9a-f]{64}$/); }); + + describe('CSS modules (#17704)', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'astro-css-test-')); + after(() => rmSync(tmpDir, { recursive: true, force: true })); + + it('changes when a CSS file on disk is modified', () => { + const cssPath = join(tmpDir, 'global.css'); + writeFileSync(cssPath, 'body { color: red; }'); + const first = dependencyHash({ [cssPath]: '' }, {}); + + writeFileSync(cssPath, 'body { color: blue; }'); + const second = dependencyHash({ [cssPath]: '' }, {}); + + assert.notEqual(first, second); + }); + + it('is stable for an unchanged CSS file', () => { + const cssPath = join(tmpDir, 'styles.css'); + writeFileSync(cssPath, 'body { color: green; }'); + + const first = dependencyHash({ [cssPath]: '' }, {}); + const second = dependencyHash({ [cssPath]: '' }, {}); + + assert.equal(first, second); + }); + }); }); });