incrementalBuild: Fonts API embeds a random ephemeral port in generated code, making dependencyHash non-deterministic and blocking all caching
pkg: astro- P3: minor bugtriage: fix verified
### Astro Info
```block
Astro v7.2.0
Node v22.21.1
System Linux (x64)
Package Manager pnpm
Output static
Adapter none
Integrations @astrojs/tailwind (v6.0.2)
@astrojs/react (v6.0.2)
@astrojs/svelte
@astrojs/sitemap (v3.7.3)
astro-robots-txt (v1.0.0)
```
### Describe the Bug
With `experimental.incrementalBuild: true`, `build.concurrency: 1`, and the Fonts API (`fonts: [...]` using `fontProviders.fontsource()` / `fontProviders.google()`) all enabled together, **no page is ever skipped on a rebuild**, even with zero source changes between builds.
Root cause: the generated `virtual:astro:assets/fonts/runtime/font-file-url-resolver` module embeds a live, per-build ephemeral socket address/port into its compiled code, which feeds into the incremental build's per-route `dependencyHash` and makes it non-deterministic build over build.
Since every page in a typical site imports a shared layout that renders `<Font />`, this one module poisons `dependencyHash` for effectively every route, defeating the cache site-wide.
**How I found it:** I temporarily instrumented `hashModules()` in `node_modules/astro/dist/core/build/plugins/plugin-incremental.js` to log each transitively-included module's id plus a hash of `graph.getModuleInfo(id)?.code`, across two consecutive builds with identical source. Every module hashed identically **except one**: `virtual:astro:assets/fonts/runtime/font-file-url-resolver`. Diffing that module's actual generated code between the two builds:
```diff
- address: {"address":"::","family":"IPv6","port":34261},
+ address: {"address":"::","family":"IPv6","port":41219},
```
That's a literal ephemeral socket port baked into the built module's source text — almost certainly from whatever local server/connection the font-fetching pipeline opens during the build (e.g. to fetch/proxy font files from the configured provider). Since this value is different every process invocation by definition, any route that transitively imports this module can never produce a stable `dependencyHash`.
**Confirmation — removing the Fonts API alone fixes it.** To isolate the variable, I removed the `fonts` config and all `<Font />` usage from the same site (~4,122 pages, otherwise unchanged) and re-ran the same test:
| | Run 1 (cold) | Run 2 (warm, nothing cleaned) | Run 3 (warm again) |
|---|---|---|---|
| With Fonts API (`incrementalBuild: true`) | 38.9s | 33.0s — **0 / 4,122 cached** | 36.5s — 0 / 4,122 |
| Without Fonts API (`incrementalBuild: true`) | 37.6s | **14.9s — 4,117 / 4,122 cached** | **21.4s — 4,117 / 4,122 cached** |
| Without Fonts API (`incrementalBuild: false`, control) | 31.0s | 30.8s (no change, as expected) | — |
With the Fonts API out of the module graph, `incrementalBuild` performs exactly as documented — roughly a 60% reduction on the warm build. The control (`incrementalBuild: false` on the same fonts-free codebase) shows no such improvement, ruling out warm OS/network caches as the explanation.
**Impact:** any project using the Fonts API from a shared layout — the common case — cannot benefit from `incrementalBuild` at all, silently. There's no warning; the feature just does nothing while still paying its bookkeeping overhead (computing/writing `incremental-build.json`, hashing every module graph on every build).
**Related, smaller footgun found in the same investigation** (happy to file separately if preferred): custom content loaders implementing `Loader.load({ store, parseData, generateDigest, ... })` get zero incremental-build benefit if they call `store.set({ id, data })` without an explicit `digest` — `entry.digest` stays `undefined`, and any downstream code doing `cacheKey: String(entry.digest)` silently gets the literal string `"undefined"` for every entry rather than an error or a type warning. Nothing signals that `digest` was expected; a JSDoc note on `LoaderContext.generateDigest`, or a dev-mode warning when `load()` never invokes it, would help.
### What's the expected result?
On the second build, pages whose `cacheKey` and dependency graph are unchanged should be skipped and logged with `(cached)` or `(restored)` (per `generatePathWithPrerenderer` in `node_modules/astro/dist/core/build/generate.js`).
One of:
- The font-file-url-resolver module doesn't embed ephemeral connection info in its generated code (resolve it at runtime instead of baking it into the built module's source text).
- If the port is genuinely only needed in dev, it's excluded from output that participates in `hashModules()` for production/prerender builds.
- At minimum, a warning is surfaced when `incrementalBuild` is enabled alongside config known to produce non-deterministic build output, instead of silently caching nothing.
### Link to Minimal Reproducible Example
I don't have a hosted StackBlitz/GitHub minimal reproduction yet -- this was found investigating a real production site. Steps to reproduce from a fresh project:
1. `astro.config.mjs`:
```js
export default defineConfig({
experimental: { incrementalBuild: true },
fonts: [
{ provider: fontProviders.fontsource(), name: "SomeFont", cssVariable: "--font-some" },
],
build: { concurrency: 1 },
});
```
2. Render `<Font cssVariable="--font-some" />` from a shared layout used by all pages, and return a stable, non-empty `cacheKey` from `getStaticPaths()` for at least one route (e.g. a content digest).
3. Run `astro build` twice in a row, touching nothing between runs (leave `dist/` and `node_modules/.astro/` alone).
4. Inspect `node_modules/.astro/incremental-build.json` after each run -- `routes["<route file>"].dependencyHash` differs between the two.
I can build and host a proper minimal reproduction if that's needed to move this forward -- let me know.
### Participation
- [ ] I am willing to submit a pull request for this issue.
3 条评论