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/wacky-teeth-wear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes incremental builds serving cached HTML that references stale CSS filenames after a stylesheet-only edit
19 changes: 17 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-incremental.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -84,15 +85,29 @@ 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');
for (const id of sortedIds) {
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');
}
Expand Down
31 changes: 30 additions & 1 deletion packages/astro/test/units/build/plugin-incremental.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
});
});
Loading