Skip to content

Commit fd1d9ee

Browse files
fix(build): Optimize incremental builds with shared content graph hashing (#17630)
* fix(build): avoid repeated content graph hashing * chore: add incremental build changeset * fix(build): hash incremental page graphs once * test(build): cover shared graphs with integration tests * test(build): update chunked content fixture path --------- Co-authored-by: Matthew Phillips <matthewphillips@cloudflare.com>
1 parent f098322 commit fd1d9ee

13 files changed

Lines changed: 230 additions & 60 deletions

File tree

.changeset/quick-graphs-share.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 incremental builds becoming prohibitively slow for sites with many pages or content entries that share a large dependency graph.

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

Lines changed: 167 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,13 @@ interface ModuleGraph {
2424
getFileName(referenceId: string): string;
2525
}
2626

27-
/** Collect the sorted, transitive dependency ids of a module, following static and dynamic imports. */
28-
function collectTransitiveDeps(graph: ModuleGraph, rootId: string): string[] {
29-
const deps = new Set<string>();
30-
const queue = [rootId];
31-
while (queue.length > 0) {
32-
const current = queue.pop()!;
33-
if (deps.has(current)) continue;
34-
35-
const modInfo = graph.getModuleInfo(current);
36-
if (isContentDataIncrementalModule(modInfo)) continue;
37-
38-
deps.add(current);
39-
if (!modInfo) continue;
27+
interface HashableModuleGraph extends ModuleGraph {
28+
getModuleIds(): IterableIterator<string>;
29+
}
4030

41-
for (const dep of modInfo.importedIds) {
42-
if (!deps.has(dep)) queue.push(dep);
43-
}
44-
for (const dep of modInfo.dynamicallyImportedIds) {
45-
if (!deps.has(dep)) queue.push(dep);
46-
}
47-
}
48-
return [...deps].sort();
31+
interface TransitiveGraphCache {
32+
hashes: Map<string, string>;
33+
serverIslandModules: Set<string>;
4934
}
5035

5136
/** Each placeholder pattern paired with the token that has to be present for it to match. */
@@ -114,12 +99,151 @@ function hashModules(graph: ModuleGraph, sortedIds: string[]): string {
11499
return hasher.digest('hex');
115100
}
116101

102+
/**
103+
* Build a transitive hash for every module with one pass over the dependency graph.
104+
* Strongly connected components collapse cycles into a DAG, whose hashes can be
105+
* folded into every importer without walking shared dependencies again for each root.
106+
*/
107+
function createTransitiveGraphCache(graph: HashableModuleGraph): TransitiveGraphCache {
108+
const modules = new Map<string, HashableModuleInfo | null>();
109+
const dependencies = new Map<string, string[]>();
110+
const excludedModules = new Set<string>();
111+
const pending = [...graph.getModuleIds()];
112+
for (const id of pending) {
113+
if (modules.has(id)) continue;
114+
115+
const info = graph.getModuleInfo(id);
116+
modules.set(id, info);
117+
if (isContentDataIncrementalModule(info)) {
118+
excludedModules.add(id);
119+
continue;
120+
}
121+
122+
const importedIds = [...(info?.importedIds ?? []), ...(info?.dynamicallyImportedIds ?? [])];
123+
dependencies.set(id, importedIds);
124+
pending.push(...importedIds);
125+
}
126+
for (const id of excludedModules) modules.delete(id);
127+
for (const [id, importedIds] of dependencies) {
128+
dependencies.set(
129+
id,
130+
importedIds.filter((importedId) => !excludedModules.has(importedId)),
131+
);
132+
}
133+
134+
const reverseDependencies = new Map<string, string[]>();
135+
for (const id of modules.keys()) reverseDependencies.set(id, []);
136+
for (const [id, importedIds] of dependencies) {
137+
for (const importedId of importedIds) reverseDependencies.get(importedId)?.push(id);
138+
}
139+
140+
const visited = new Set<string>();
141+
const finishOrder: string[] = [];
142+
for (const rootId of modules.keys()) {
143+
if (visited.has(rootId)) continue;
144+
visited.add(rootId);
145+
const stack: Array<[string, number]> = [[rootId, 0]];
146+
while (stack.length > 0) {
147+
const frame = stack[stack.length - 1];
148+
const importedIds = dependencies.get(frame[0]) ?? [];
149+
if (frame[1] < importedIds.length) {
150+
const importedId = importedIds[frame[1]++];
151+
if (!visited.has(importedId)) {
152+
visited.add(importedId);
153+
stack.push([importedId, 0]);
154+
}
155+
} else {
156+
finishOrder.push(frame[0]);
157+
stack.pop();
158+
}
159+
}
160+
}
161+
162+
const componentByModule = new Map<string, number>();
163+
const components: string[][] = [];
164+
for (const rootId of finishOrder.toReversed()) {
165+
if (componentByModule.has(rootId)) continue;
166+
const componentIndex = components.length;
167+
const component: string[] = [];
168+
const stack = [rootId];
169+
componentByModule.set(rootId, componentIndex);
170+
while (stack.length > 0) {
171+
const id = stack.pop()!;
172+
component.push(id);
173+
for (const importerId of reverseDependencies.get(id) ?? []) {
174+
if (!componentByModule.has(importerId)) {
175+
componentByModule.set(importerId, componentIndex);
176+
stack.push(importerId);
177+
}
178+
}
179+
}
180+
components.push(component.sort());
181+
}
182+
183+
const componentDependencies = components.map(() => new Set<number>());
184+
const componentImporters = components.map(() => new Set<number>());
185+
for (const [id, importedIds] of dependencies) {
186+
const componentIndex = componentByModule.get(id)!;
187+
for (const importedId of importedIds) {
188+
const dependencyIndex = componentByModule.get(importedId)!;
189+
if (dependencyIndex === componentIndex) continue;
190+
componentDependencies[componentIndex].add(dependencyIndex);
191+
componentImporters[dependencyIndex].add(componentIndex);
192+
}
193+
}
194+
195+
const componentHashes = new Map<number, string>();
196+
const componentHasServerIsland = new Map<number, boolean>();
197+
const unresolvedDependencies = componentDependencies.map((items) => items.size);
198+
const ready = unresolvedDependencies.flatMap((count, index) => (count === 0 ? [index] : []));
199+
for (const componentIndex of ready) {
200+
const hasher = crypto.createHash('sha256');
201+
hasher.update(hashModules(graph, components[componentIndex]));
202+
const dependencyHashes = [...componentDependencies[componentIndex]]
203+
.map((dependencyIndex) => componentHashes.get(dependencyIndex)!)
204+
.sort();
205+
for (const dependencyHash of dependencyHashes) {
206+
hasher.update('\n');
207+
hasher.update(dependencyHash);
208+
}
209+
componentHashes.set(componentIndex, hasher.digest('hex'));
210+
componentHasServerIsland.set(
211+
componentIndex,
212+
components[componentIndex].some(
213+
(id) => (modules.get(id)?.meta?.astro?.serverComponents?.length ?? 0) > 0,
214+
) ||
215+
[...componentDependencies[componentIndex]].some((dependencyIndex) =>
216+
componentHasServerIsland.get(dependencyIndex),
217+
),
218+
);
219+
220+
for (const importerIndex of componentImporters[componentIndex]) {
221+
unresolvedDependencies[importerIndex]--;
222+
if (unresolvedDependencies[importerIndex] === 0) ready.push(importerIndex);
223+
}
224+
}
225+
226+
return {
227+
hashes: new Map(
228+
[...componentByModule].map(([id, componentIndex]) => [
229+
id,
230+
componentHashes.get(componentIndex)!,
231+
]),
232+
),
233+
serverIslandModules: new Set(
234+
[...componentByModule]
235+
.filter(([, componentIndex]) => componentHasServerIsland.get(componentIndex))
236+
.map(([id]) => id),
237+
),
238+
};
239+
}
240+
117241
/**
118242
* Hash the transitive graph of each client entrypoint and accumulate the result
119243
* against every page that uses it, keyed by page component.
120244
*/
121245
function collectClientEntrypointHashes(
122-
graph: ModuleGraph,
246+
transitiveHashes: Map<string, string>,
123247
entrypointIds: Iterable<string>,
124248
pagesByEntrypoint: Map<string, Set<PageBuildData>>,
125249
hashesByComponent: Map<string, string[]>,
@@ -128,7 +252,8 @@ function collectClientEntrypointHashes(
128252
const pages = pagesByEntrypoint.get(entrypointId);
129253
if (!pages?.size) continue;
130254

131-
const hash = hashModules(graph, collectTransitiveDeps(graph, entrypointId));
255+
const hash = transitiveHashes.get(entrypointId);
256+
if (!hash) continue;
132257
for (const pageData of pages) {
133258
let list = hashesByComponent.get(pageData.component);
134259
if (!list) {
@@ -149,19 +274,20 @@ function collectClientEntrypointHashes(
149274
* build we hash each entrypoint's transitive graph and fold it into the dependency
150275
* hash of every route that uses it.
151276
*/
152-
function foldClientDependencies(graph: ModuleGraph, internals: BuildInternals): void {
277+
function foldClientDependencies(graph: HashableModuleGraph, internals: BuildInternals): void {
153278
const baseHashes = internals.pageDependencyHashes;
154279
if (!baseHashes) return;
155280

281+
const { hashes: transitiveHashes } = createTransitiveGraphCache(graph);
156282
const hashesByComponent = new Map<string, string[]>();
157283
collectClientEntrypointHashes(
158-
graph,
284+
transitiveHashes,
159285
internals.discoveredClientOnlyComponents.keys(),
160286
internals.pagesByClientOnly,
161287
hashesByComponent,
162288
);
163289
collectClientEntrypointHashes(
164-
graph,
290+
transitiveHashes,
165291
internals.discoveredScripts,
166292
internals.pagesByScriptId,
167293
hashesByComponent,
@@ -188,35 +314,22 @@ function foldClientDependencies(graph: ModuleGraph, internals: BuildInternals):
188314
* entry's graph into another route's hash.
189315
*/
190316
function collectContentEntryHashes(
191-
graph: ModuleGraph & { getModuleIds(): IterableIterator<string> },
317+
graph: HashableModuleGraph,
192318
root: URL,
319+
transitiveHashes: Map<string, string>,
193320
): Map<string, string> {
194321
const entryHashes = new Map<string, string>();
195322
for (const id of graph.getModuleIds()) {
196323
if (!hasContentFlag(id, PROPAGATED_ASSET_FLAG)) continue;
197324
// e.g. "/abs/src/content/docs/one.mdx?astroPropagatedAssets" -> render module id.
198325
const renderModuleId = removeQueryString(id);
199326
const key = rootRelativePath(root, renderModuleId, false);
200-
const deps = collectTransitiveDeps(graph, renderModuleId);
201-
entryHashes.set(key, hashModules(graph, deps));
327+
const hash = transitiveHashes.get(renderModuleId);
328+
if (hash) entryHashes.set(key, hash);
202329
}
203330
return entryHashes;
204331
}
205332

206-
/**
207-
* Whether any module in a page's render graph uses a server island. The Astro
208-
* compiler records `server:defer` usage as `serverComponents` metadata on the
209-
* module that hosts it, so a page (or one of its layouts/components) that renders
210-
* an island is detectable from the graph it already walks for hashing.
211-
*/
212-
function pageContainsServerIsland(graph: ModuleGraph, ids: string[]): boolean {
213-
for (const id of ids) {
214-
const serverComponents = graph.getModuleInfo(id)?.meta?.astro?.serverComponents;
215-
if (serverComponents?.length) return true;
216-
}
217-
return false;
218-
}
219-
220333
/**
221334
* Captures a dependency hash for each prerendered page route during the build.
222335
*
@@ -243,6 +356,7 @@ export function pluginIncremental(internals: BuildInternals, root: URL): VitePlu
243356
return;
244357
}
245358

359+
const transitiveGraph = createTransitiveGraphCache(this);
246360
const hashes = new Map<string, string>();
247361
const serverIslandComponents = new Set<string>();
248362
for (const id of this.getModuleIds()) {
@@ -253,16 +367,22 @@ export function pluginIncremental(internals: BuildInternals, root: URL): VitePlu
253367
const pageData = getPageDataByViteID(internals, info.id);
254368
if (!pageData) continue;
255369

256-
const deps = collectTransitiveDeps(this, info.id);
370+
const hash = transitiveGraph.hashes.get(info.id);
371+
if (!hash) continue;
372+
257373
// Key by component path (e.g. "src/pages/blog/[slug].astro")
258-
hashes.set(pageData.component, hashModules(this, deps));
259-
if (pageContainsServerIsland(this, deps)) {
374+
hashes.set(pageData.component, hash);
375+
if (transitiveGraph.serverIslandModules.has(info.id)) {
260376
serverIslandComponents.add(pageData.component);
261377
}
262378
}
263379

264380
internals.pageDependencyHashes = hashes;
265-
internals.contentEntryRenderHashes = collectContentEntryHashes(this, root);
381+
internals.contentEntryRenderHashes = collectContentEntryHashes(
382+
this,
383+
root,
384+
transitiveGraph.hashes,
385+
);
266386
internals.serverIslandPageComponents = serverIslandComponents;
267387
},
268388
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import mdx from '@astrojs/mdx';
2+
import { defineConfig } from 'astro/config';
3+
4+
export default defineConfig({
5+
integrations: [mdx()],
6+
});

packages/astro/test/fixtures/incremental-build/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"version": "0.0.0",
44
"private": true,
55
"dependencies": {
6+
"@astrojs/mdx": "workspace:*",
67
"astro": "workspace:*"
78
}
89
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p class="shared">Shared v1</p>

packages/astro/test/fixtures/incremental-build/src/content.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { glob } from 'astro/loaders';
33
import { z } from 'astro/zod';
44

55
const docs = defineCollection({
6-
loader: glob({ pattern: '**/*.md', base: './src/content/docs' }),
6+
loader: glob({ pattern: '**/*.mdx', base: './src/content/docs' }),
77
schema: z.object({
88
title: z.string(),
99
}),

packages/astro/test/fixtures/incremental-build/src/content/docs/a.md

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Doc A
3+
---
4+
5+
import Shared from '../../components/Shared.astro';
6+
7+
<Shared />
8+
9+
Alpha content.

packages/astro/test/fixtures/incremental-build/src/content/docs/b.md

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Doc B
3+
---
4+
5+
import Shared from '../../components/Shared.astro';
6+
7+
<Shared />
8+
9+
Beta content.

0 commit comments

Comments
 (0)