Visitar URL original
Hash compiled CSS output in incremental build to detect Sass partial … · withastro/astro@80f9f1d · GitHub
Skip to content

Commit 80f9f1d

Browse files
astro-factory[bot]factory[bot]matthewp
authored
Hash compiled CSS output in incremental build to detect Sass partial changes (#17994)
* fix(incremental): hash compiled CSS instead of raw source to detect preprocessor partial changes (#17974) * fix(lint): specify generic constructor arguments in createTransitiveGraphCache default param * fix(incremental): replay untransformed image references on restore (#17993) * fix(incremental): make referencedImages optional in prerender metadata type * test(incremental): expect referencedImages in collected prerender metadata --------- Co-authored-by: factory[bot] <factory[bot]@users.noreply.github.com> Co-authored-by: Matthew Phillips <matthewphillips@cloudflare.com> Co-authored-by: Matthew Phillips <matthew@matthewphillips.info>
1 parent bc6572f commit 80f9f1d

23 files changed

Lines changed: 294 additions & 23 deletions

File tree

‎.changeset/six-poems-slide.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes `experimental.incrementalBuild` restoring pages with stale CSS after a preprocessor partial changes or missing original images referenced by restored pages

‎packages/astro/src/assets/build/generate.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,17 @@ export function restoreStaticImages(images: SerializedStaticImage[]): void {
388388
}
389389
}
390390

391+
/** Preserve originals referenced without transforms by skipped pages. */
392+
export function restoreReferencedImages(fsPaths: string[]): void {
393+
if (!globalThis.astroAsset) {
394+
globalThis.astroAsset = { referencedImages: new Set() };
395+
}
396+
const referencedImages = (globalThis.astroAsset.referencedImages ??= new Set());
397+
for (const fsPath of fsPaths) {
398+
referencedImages.add(fsPath);
399+
}
400+
}
401+
391402
async function loadImage(path: string, env: AssetEnv): Promise<ImageData> {
392403
if (isRemotePath(path)) {
393404
return await loadRemoteImage(path, undefined, env.imageConfig);

‎packages/astro/src/assets/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ declare global {
3939
| undefined;
4040
staticImages?: AssetsGlobalStaticImagesList;
4141
referencedImages?: Set<string>;
42+
recordReferencedImage?: ((fsPath: string) => void) | undefined;
4243
};
4344
}
4445

‎packages/astro/src/assets/utils/proxy.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ export function getProxyCode(options: ImageMetadata, isSSR: boolean): string {
1313
}
1414
${
1515
!isSSR
16-
? `if (target[name] !== undefined && globalThis.astroAsset) globalThis.astroAsset?.referencedImages?.add(${stringifiedFSPath});`
16+
? `if (target[name] !== undefined && globalThis.astroAsset) {
17+
globalThis.astroAsset.referencedImages?.add(${stringifiedFSPath});
18+
globalThis.astroAsset.recordReferencedImage?.(${stringifiedFSPath});
19+
}`
1720
: ''
1821
}
1922
return target[name];

‎packages/astro/src/assets/vite-plugin-assets.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
removeBase,
1313
removeQueryString,
1414
} from '../core/path.js';
15-
import { recordStaticImage } from '../core/render-scope/record.js';
15+
import { recordReferencedImage, recordStaticImage } from '../core/render-scope/record.js';
1616
import { normalizePath } from '../core/viteUtils.js';
1717
import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js';
1818
import { isAstroServerEnvironment } from '../environments.js';
@@ -322,6 +322,7 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
322322
buildStart() {
323323
if (!isBuild) return;
324324
globalThis.astroAsset.addStaticImage = addStaticImageFactory(settings);
325+
globalThis.astroAsset.recordReferencedImage = recordReferencedImage;
325326
},
326327
// In build, rewrite paths to ESM imported images in code to their final location
327328
async renderChunk(code) {

‎packages/astro/src/core/build/generate.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
generateImagesForPath,
1010
getStaticImageList,
1111
prepareAssetsGenerationEnv,
12+
restoreReferencedImages,
1213
restoreStaticImages,
1314
} from '../../assets/build/generate.js';
1415
import {
@@ -646,6 +647,9 @@ async function generatePathWithPrerenderer(
646647
const restoredImages = cache.previousStaticImages(route.component, pathname);
647648
if (restoredImages) restoreStaticImages(restoredImages);
648649

650+
const restoredReferencedImages = cache.previousReferencedImages(route.component, pathname);
651+
if (restoredReferencedImages) restoreReferencedImages(restoredReferencedImages);
652+
649653
// Likewise, the route contributes no response headers when it is not
650654
// rendered. Replay them so a `staticHeaders` adapter still writes this
651655
// route into its headers file.
@@ -658,8 +662,8 @@ async function generatePathWithPrerenderer(
658662
}
659663

660664
// Record in the new cache so orphan detection knows this path is still alive,
661-
// carrying forward the content entries, image transforms, and headers the
662-
// path resolved last build.
665+
// carrying forward the content entries, image metadata, and headers the path
666+
// resolved last build.
663667
cache.record(
664668
route.component,
665669
dependencyHash,
@@ -668,6 +672,7 @@ async function generatePathWithPrerenderer(
668672
relativeOutFile,
669673
cache.previousContentEntryKeys(route.component, pathname),
670674
restoredImages,
675+
restoredReferencedImages,
671676
restoredHeaders,
672677
);
673678

@@ -715,6 +720,7 @@ async function generatePathWithPrerenderer(
715720
});
716721
const contentEntryKeys = result?.metadata?.contentEntryKeys;
717722
const staticImages = result?.metadata?.staticImages;
723+
const referencedImages = result?.metadata?.referencedImages;
718724
// Headers are collected only for `staticHeaders` adapters (see `renderPath`).
719725
// Persist them so a skipped path can replay its route into the headers file.
720726
const headers = cache ? [...(routeToHeaders.get(pathname)?.headers ?? [])] : undefined;
@@ -743,6 +749,7 @@ async function generatePathWithPrerenderer(
743749
relativeOutFile,
744750
contentEntryKeys,
745751
staticImages,
752+
referencedImages,
746753
headers,
747754
);
748755
}

‎packages/astro/src/core/build/incremental.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export interface IncrementalPathEntry {
2323
* keep the images its restored HTML references from 404ing.
2424
*/
2525
staticImages?: SerializedStaticImage[];
26+
/**
27+
* Absolute source paths of images this path referenced without a transform.
28+
* Replaying these for a skipped path prevents image generation from deleting
29+
* originals referenced by restored HTML.
30+
*/
31+
referencedImages?: string[];
2632
/**
2733
* Response header name/value pairs a `staticHeaders` adapter collected for this
2834
* path (chiefly the CSP header when delivered as a header rather than a `<meta>`
@@ -212,6 +218,11 @@ export class IncrementalBuildCache {
212218
return this.#previous?.routes[routeComponent]?.paths[pathname]?.staticImages;
213219
}
214220

221+
/** The untransformed image references recorded by a path in the previous build. */
222+
previousReferencedImages(routeComponent: string, pathname: string): string[] | undefined {
223+
return this.#previous?.routes[routeComponent]?.paths[pathname]?.referencedImages;
224+
}
225+
215226
/**
216227
* The response headers a path collected in the previous build, so a skipped
217228
* path can replay them into a `staticHeaders` adapter's headers file.
@@ -229,6 +240,7 @@ export class IncrementalBuildCache {
229240
outputFile: string,
230241
contentEntryKeys?: string[],
231242
staticImages?: SerializedStaticImage[],
243+
referencedImages?: string[],
232244
headers?: [string, string][],
233245
): void {
234246
let routeEntry = this.#next.routes[routeComponent];
@@ -248,6 +260,9 @@ export class IncrementalBuildCache {
248260
if (Object.keys(contentHashes).length > 0) pathEntry.contentHashes = contentHashes;
249261
}
250262
if (staticImages && staticImages.length > 0) pathEntry.staticImages = staticImages;
263+
if (referencedImages && referencedImages.length > 0) {
264+
pathEntry.referencedImages = referencedImages;
265+
}
251266
if (headers && headers.length > 0) pathEntry.headers = headers;
252267
routeEntry.paths[pathname] = pathEntry;
253268
}

‎packages/astro/src/core/build/plugins/plugin-incremental.ts‎

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,16 @@ function resolveAssetPlaceholders(graph: ModuleGraph, code: string): string {
8787
* themselves are not stable between builds.
8888
*
8989
* CSS modules are a special case: Vite extracts their content during the
90-
* prerender build, leaving `code` as an empty string. For those modules, the
91-
* source file is read from disk so that CSS edits invalidate the hash.
90+
* prerender build, leaving `code` as an empty string. For those modules the
91+
* compiled CSS captured during `transform` is used instead, which reflects
92+
* the full output of any CSS preprocessor (Sass partials, Less imports,
93+
* `additionalData`, etc.) rather than just the entry file on disk (#17974).
9294
*/
93-
function hashModules(graph: ModuleGraph, sortedIds: string[]): string {
95+
function hashModules(
96+
graph: ModuleGraph,
97+
sortedIds: string[],
98+
compiledCss: Map<string, string>,
99+
): string {
94100
const hasher = crypto.createHash('sha256');
95101
for (const id of sortedIds) {
96102
hasher.update(id);
@@ -99,14 +105,21 @@ function hashModules(graph: ModuleGraph, sortedIds: string[]): string {
99105
if (code != null && code.length > 0) {
100106
hasher.update(resolveAssetPlaceholders(graph, code));
101107
} else if (CSS_LANGS_RE.test(id)) {
102-
// Vite extracts CSS into separate assets, so the module's `code` in the
103-
// prerender bundle is empty. Read the source file so that stylesheet
104-
// changes are reflected in the dependency hash (#17704).
105-
try {
106-
hasher.update(nodeFs.readFileSync(removeQueryString(id), 'utf-8'));
107-
} catch {
108-
// Virtual CSS or unreadable file — skip. The worst case is a
109-
// cache miss (re-render), never a stale hit.
108+
// Vite extracts CSS into separate assets, so the module's `code` in
109+
// the prerender bundle is empty. Use the compiled CSS captured during
110+
// transform, which includes resolved preprocessor imports (#17974).
111+
// Fall back to the raw source file for modules the transform hook
112+
// did not see (e.g. virtual CSS modules).
113+
const compiled = compiledCss.get(id);
114+
if (compiled != null) {
115+
hasher.update(compiled);
116+
} else {
117+
try {
118+
hasher.update(nodeFs.readFileSync(removeQueryString(id), 'utf-8'));
119+
} catch {
120+
// Virtual CSS or unreadable file — skip. The worst case is a
121+
// cache miss (re-render), never a stale hit.
122+
}
110123
}
111124
}
112125
hasher.update('\n');
@@ -119,7 +132,10 @@ function hashModules(graph: ModuleGraph, sortedIds: string[]): string {
119132
* Strongly connected components collapse cycles into a DAG, whose hashes can be
120133
* folded into every importer without walking shared dependencies again for each root.
121134
*/
122-
function createTransitiveGraphCache(graph: HashableModuleGraph): TransitiveGraphCache {
135+
function createTransitiveGraphCache(
136+
graph: HashableModuleGraph,
137+
compiledCss: Map<string, string> = new Map<string, string>(),
138+
): TransitiveGraphCache {
123139
const modules = new Map<string, HashableModuleInfo | null>();
124140
const dependencies = new Map<string, string[]>();
125141
const excludedModules = new Set<string>();
@@ -213,7 +229,7 @@ function createTransitiveGraphCache(graph: HashableModuleGraph): TransitiveGraph
213229
const ready = unresolvedDependencies.flatMap((count, index) => (count === 0 ? [index] : []));
214230
for (const componentIndex of ready) {
215231
const hasher = crypto.createHash('sha256');
216-
hasher.update(hashModules(graph, components[componentIndex]));
232+
hasher.update(hashModules(graph, components[componentIndex], compiledCss));
217233
const dependencyHashes = [...componentDependencies[componentIndex]]
218234
.map((dependencyIndex) => componentHashes.get(dependencyIndex)!)
219235
.sort();
@@ -357,6 +373,12 @@ function collectContentEntryHashes(
357373
* render modules sit behind `content-data`-pruned bridges.
358374
*/
359375
export function pluginIncremental(internals: BuildInternals, root: URL): VitePlugin {
376+
// Compiled CSS captured during transform, keyed by module id. Preprocessor
377+
// output (Sass, Less, etc.) includes resolved partials and additionalData,
378+
// so hashing it instead of the raw source detects changes in transitive
379+
// preprocessor dependencies that are invisible to the Vite module graph.
380+
const compiledCss = new Map<string, string>();
381+
360382
return {
361383
name: '@astro/plugin-incremental',
362384
applyToEnvironment(environment) {
@@ -365,13 +387,21 @@ export function pluginIncremental(internals: BuildInternals, root: URL): VitePlu
365387
environment.name === ASTRO_VITE_ENVIRONMENT_NAMES.client
366388
);
367389
},
390+
transform(code, id) {
391+
if (
392+
this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender &&
393+
CSS_LANGS_RE.test(id)
394+
) {
395+
compiledCss.set(id, code);
396+
}
397+
},
368398
generateBundle() {
369399
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
370400
foldClientDependencies(this, internals);
371401
return;
372402
}
373403

374-
const transitiveGraph = createTransitiveGraphCache(this);
404+
const transitiveGraph = createTransitiveGraphCache(this, compiledCss);
375405
const hashes = new Map<string, string>();
376406
const serverIslandComponents = new Set<string>();
377407
for (const id of this.getModuleIds()) {

‎packages/astro/src/core/render-scope/collect.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getInstalledRenderScope, type RenderCollectors } from './scope.js';
55
export interface CollectedPrerenderMetadata {
66
contentEntryKeys: string[];
77
staticImages: SerializedStaticImage[];
8+
referencedImages: string[];
89
}
910

1011
let warnedNoScope = false;
@@ -40,13 +41,18 @@ export async function collectPrerenderMetadata<T>(
4041
}
4142
return { value: await fn(), metadata: undefined };
4243
}
43-
const store: RenderCollectors = { contentEntries: new Set(), staticImages: [] };
44+
const store: RenderCollectors = {
45+
contentEntries: new Set(),
46+
staticImages: [],
47+
referencedImages: new Set(),
48+
};
4449
const value = await scope.run(store, fn);
4550
return {
4651
value,
4752
metadata: {
4853
contentEntryKeys: [...store.contentEntries!],
4954
staticImages: [...store.staticImages!],
55+
referencedImages: [...store.referencedImages!],
5056
},
5157
};
5258
}

‎packages/astro/src/core/render-scope/record.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,8 @@ export function recordContentEntryRender(filePath: string | undefined): void {
2020
export function recordStaticImage(image: SerializedStaticImage): void {
2121
getInstalledRenderScope()?.getStore()?.staticImages?.push(image);
2222
}
23+
24+
/** Records an untransformed image reference against the active render. */
25+
export function recordReferencedImage(fsPath: string): void {
26+
getInstalledRenderScope()?.getStore()?.referencedImages?.add(fsPath);
27+
}

0 commit comments

Comments
 (0)