From 261d60e81d4dcce50ca37d2513e3467269ae187c Mon Sep 17 00:00:00 2001 From: "factory[bot]" Date: Thu, 27 Aug 2026 01:47:11 +0000 Subject: [PATCH] fix(build): strip prerender-only entry specifiers from SSR manifest --- .changeset/metal-pans-allow.md | 5 +++ packages/astro/src/core/build/internal.ts | 10 +++++ .../core/build/plugins/plugin-internals.ts | 10 ++++- .../src/core/build/plugins/plugin-manifest.ts | 23 ++++++++++- .../build/prerender-entry-specifiers.test.ts | 39 +++++++++++++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .changeset/metal-pans-allow.md create mode 100644 packages/astro/test/units/build/prerender-entry-specifiers.test.ts diff --git a/.changeset/metal-pans-allow.md b/.changeset/metal-pans-allow.md new file mode 100644 index 000000000000..c4e231e193c8 --- /dev/null +++ b/.changeset/metal-pans-allow.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes SSR manifest containing stale `entryModules` references to prerender-only chunks that no longer exist in the final build output diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 6b541b55c847..75b310ef0ad1 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -34,6 +34,15 @@ export interface BuildInternals { // Used to render pages with the correct specifiers. entrySpecifierToBundleMap: Map; + /** + * Specifiers written to `entrySpecifierToBundleMap` by the prerender environment + * that have not been overwritten by the SSR or client environments. These point + * to chunk files inside the prerender output directory, which is deleted after + * page generation. They must be stripped from the SSR manifest to avoid dangling + * references. + */ + prerenderOnlyEntrySpecifiers: Set; + /** * A map for page-specific information. */ @@ -167,6 +176,7 @@ export function createBuildInternals(): BuildInternals { prerenderCssAssetByModuleKey: new Map(), inlinedScripts: new Map(), entrySpecifierToBundleMap: new Map(), + prerenderOnlyEntrySpecifiers: new Set(), pagesByKeys: new Map(), pagesByViteID: new Map(), pagesByClientOnly: new Map(), diff --git a/packages/astro/src/core/build/plugins/plugin-internals.ts b/packages/astro/src/core/build/plugins/plugin-internals.ts index 9310c48e5f1b..0988c1ae6410 100644 --- a/packages/astro/src/core/build/plugins/plugin-internals.ts +++ b/packages/astro/src/core/build/plugins/plugin-internals.ts @@ -78,6 +78,7 @@ export function pluginInternals( ); } await Promise.all(promises); + const isPrerender = this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.prerender; for (const [_, chunk] of Object.entries(bundle)) { if (chunk.fileName.startsWith(options.settings.config.build.assets)) { internals.clientChunksAndAssets.add(chunk.fileName); @@ -86,7 +87,14 @@ export function pluginInternals( if (chunk.type === 'chunk' && chunk.facadeModuleId) { const specifiers = mapping.get(chunk.facadeModuleId) || new Set([chunk.facadeModuleId]); for (const specifier of specifiers) { - internals.entrySpecifierToBundleMap.set(normalizeEntryId(specifier), chunk.fileName); + const normalizedId = normalizeEntryId(specifier); + internals.entrySpecifierToBundleMap.set(normalizedId, chunk.fileName); + if (isPrerender) { + internals.prerenderOnlyEntrySpecifiers.add(normalizedId); + } else { + // Present in SSR or client — no longer prerender-only + internals.prerenderOnlyEntrySpecifiers.delete(normalizedId); + } } } } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 1279715de892..2813a4318da9 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -101,7 +101,8 @@ export async function manifestBuildPostHook( // HTML on disk already has them inlined, and the SSR worker never renders // these routes. Stripping keeps the entry chunk small on platforms like // Cloudflare Workers that re-parse it on every cold isolate start. - const ssrManifest = stripPrerenderedRouteStyles(manifest); + let ssrManifest = stripPrerenderedRouteStyles(manifest); + ssrManifest = stripPrerenderOnlyEntryModules(ssrManifest, internals); const code = injectManifest(ssrManifest, ssrManifestChunk.code); mutate(ssrManifestChunk.fileName, code, false); } @@ -173,6 +174,26 @@ function stripPrerenderedRouteStyles(manifest: SerializedSSRManifest): Serialize return stripped ? { ...manifest, routes } : manifest; } +/** + * Returns a copy of the manifest with `entryModules` entries removed for + * specifiers that were only emitted by the prerender environment. Those + * chunks live in the prerender output directory, which is deleted after + * page generation, so referencing them from the SSR manifest would produce + * dangling asset URLs at runtime. + */ +function stripPrerenderOnlyEntryModules( + manifest: SerializedSSRManifest, + internals: BuildInternals, +): SerializedSSRManifest { + if (internals.prerenderOnlyEntrySpecifiers.size === 0) return manifest; + const filtered = Object.fromEntries( + Object.entries(manifest.entryModules).filter( + ([key]) => !internals.prerenderOnlyEntrySpecifiers.has(key), + ), + ); + return { ...manifest, entryModules: filtered }; +} + async function buildManifest( opts: StaticBuildOptions, internals: BuildInternals, diff --git a/packages/astro/test/units/build/prerender-entry-specifiers.test.ts b/packages/astro/test/units/build/prerender-entry-specifiers.test.ts new file mode 100644 index 000000000000..aa1286cbf748 --- /dev/null +++ b/packages/astro/test/units/build/prerender-entry-specifiers.test.ts @@ -0,0 +1,39 @@ +import * as assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createBuildInternals } from '../../../dist/core/build/internal.js'; + +describe('prerenderOnlyEntrySpecifiers', () => { + it('is initialized as an empty set', () => { + const internals = createBuildInternals(); + assert.ok(internals.prerenderOnlyEntrySpecifiers instanceof Set); + assert.equal(internals.prerenderOnlyEntrySpecifiers.size, 0); + }); + + it('tracks specifiers added by the prerender environment', () => { + const internals = createBuildInternals(); + internals.prerenderOnlyEntrySpecifiers.add('/project/src/lib/lazy.mjs'); + assert.ok(internals.prerenderOnlyEntrySpecifiers.has('/project/src/lib/lazy.mjs')); + }); + + it('removes specifier when also present in SSR or client environment', () => { + const internals = createBuildInternals(); + // Simulates prerender writing first + internals.prerenderOnlyEntrySpecifiers.add('/project/src/lib/shared.mjs'); + // Simulates SSR overwriting — the specifier is no longer prerender-only + internals.prerenderOnlyEntrySpecifiers.delete('/project/src/lib/shared.mjs'); + assert.equal(internals.prerenderOnlyEntrySpecifiers.has('/project/src/lib/shared.mjs'), false); + }); + + it('retains specifiers not overwritten by later environments', () => { + const internals = createBuildInternals(); + // Prerender adds two entries + internals.prerenderOnlyEntrySpecifiers.add('/project/src/lib/lazy.mjs'); + internals.prerenderOnlyEntrySpecifiers.add('/project/src/lib/shared.mjs'); + // SSR overwrites only shared.mjs + internals.prerenderOnlyEntrySpecifiers.delete('/project/src/lib/shared.mjs'); + // lazy.mjs remains prerender-only + assert.ok(internals.prerenderOnlyEntrySpecifiers.has('/project/src/lib/lazy.mjs')); + assert.equal(internals.prerenderOnlyEntrySpecifiers.has('/project/src/lib/shared.mjs'), false); + assert.equal(internals.prerenderOnlyEntrySpecifiers.size, 1); + }); +});