diff --git a/.changeset/shiny-llamas-repair.md b/.changeset/shiny-llamas-repair.md new file mode 100644 index 000000000000..911d01523b78 --- /dev/null +++ b/.changeset/shiny-llamas-repair.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where requests handled by the dev prerender environment (e.g. `/_image` with `@astrojs/cloudflare`'s `prerenderEnvironment: 'node'`) returned a 500 when a prerendered catch-all route existed, because non-prerendered route modules were imported in an environment where their runtime-specific APIs are unavailable diff --git a/.changeset/tidy-jokes-count.md b/.changeset/tidy-jokes-count.md new file mode 100644 index 000000000000..3804f07209e4 --- /dev/null +++ b/.changeset/tidy-jokes-count.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `experimental.incrementalBuild` re-rendering unchanged routes that import more than one asset. The route's dependency hash depended on the order the assets finished building, so two builds of identical sources could produce different hashes. The hash is now based on the file name each asset resolves to. diff --git a/.changeset/two-actors-go.md b/.changeset/two-actors-go.md new file mode 100644 index 000000000000..f0993b115e3d --- /dev/null +++ b/.changeset/two-actors-go.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Improves `getCollection()` and `getEntry()` performance for entries without local image references diff --git a/.changeset/yellow-pants-watch.md b/.changeset/yellow-pants-watch.md new file mode 100644 index 000000000000..5d8841dcbfc6 --- /dev/null +++ b/.changeset/yellow-pants-watch.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a build error caused by hash collisions in generated content collection image import identifiers diff --git a/packages/astro/src/assets/utils/resolveImports.ts b/packages/astro/src/assets/utils/resolveImports.ts index b6b0d1858fd0..9a8e694206c9 100644 --- a/packages/astro/src/assets/utils/resolveImports.ts +++ b/packages/astro/src/assets/utils/resolveImports.ts @@ -1,6 +1,5 @@ import { isRemotePath, removeBase } from '@astrojs/internal-helpers/path'; import { CONTENT_IMAGE_FLAG, IMAGE_IMPORT_PREFIX } from '../../content/consts.js'; -import { shorthash } from '../../runtime/server/shorthash.js'; import { VALID_INPUT_FORMATS } from '../consts.js'; /** @@ -39,6 +38,3 @@ export function imageSrcToImportId(imageSrc: string, filePath?: string): string } return `${imageSrc}?${params.toString()}`; } - -export const importIdToSymbolName = (importId: string) => - `__ASTRO_IMAGE_IMPORT_${shorthash(importId)}`; diff --git a/packages/astro/src/content/loaders/glob.ts b/packages/astro/src/content/loaders/glob.ts index 9ba25af0967b..ab427f665c34 100644 --- a/packages/astro/src/content/loaders/glob.ts +++ b/packages/astro/src/content/loaders/glob.ts @@ -47,7 +47,7 @@ interface GlobOptions { function generateIdDefault({ entry, base, data }: GenerateIdOptions, isLegacy?: boolean): string { if (data.slug) { - return data.slug as string; + return String(data.slug); } const entryURL = new URL(encodeURI(entry), base); if (isLegacy) { @@ -94,8 +94,11 @@ export function glob(globOptions: GlobOptions & { [secretLegacyFlag]?: boolean } } const isLegacy = !!globOptions[secretLegacyFlag]; - const generateId = + const userGenerateId = globOptions?.generateId ?? ((opts: GenerateIdOptions) => generateIdDefault(opts, isLegacy)); + // Coerce to string so numeric ids from YAML don't cause Set strict-equality mismatches + // against string store keys in the untouched-entries cleanup. See #17624. + const generateId = (opts: GenerateIdOptions) => String(userGenerateId(opts)); const fileToIdMap = new Map(); diff --git a/packages/astro/src/content/mutable-data-store.ts b/packages/astro/src/content/mutable-data-store.ts index 70d946ab46e0..347292ddb000 100644 --- a/packages/astro/src/content/mutable-data-store.ts +++ b/packages/astro/src/content/mutable-data-store.ts @@ -2,7 +2,7 @@ import { existsSync, promises as fs, type PathLike } from 'node:fs'; import { fileURLToPath } from 'node:url'; import * as devalue from 'devalue'; import { forEach } from 'neotraverse'; -import { imageSrcToImportId, importIdToSymbolName } from '../assets/utils/resolveImports.js'; +import { imageSrcToImportId } from '../assets/utils/resolveImports.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { DATA_STORE_MANIFEST_FILE, IMAGE_IMPORT_PREFIX } from './consts.js'; import { @@ -145,8 +145,8 @@ export class MutableDataStore extends ImmutableDataStore { const exports: Array = []; // Sort asset imports to ensure deterministic output across builds const sortedAssetImports = [...this.#assetImports].sort(); - sortedAssetImports.forEach((id) => { - const symbol = importIdToSymbolName(id); + sortedAssetImports.forEach((id, index) => { + const symbol = `__ASTRO_IMAGE_IMPORT_${index}`; imports.push(`import ${symbol} from ${JSON.stringify(id)};`); exports.push(`[${JSON.stringify(id)}, ${symbol}]`); }); diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index b9fe704aec1f..1698f7efdcdd 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -116,7 +116,7 @@ export function createGetCollection({ const result = []; for (const rawEntry of await store.values(collection)) { - const data = updateImageReferencesInData(rawEntry.data, rawEntry.filePath, imageAssetMap); + const data = resolveEntryData(rawEntry, imageAssetMap); let entry = { ...rawEntry, @@ -209,7 +209,7 @@ export function createGetEntry({ liveCollections }: { liveCollections: LiveColle // @ts-expect-error virtual module const { default: imageAssetMap } = await import('astro:asset-imports'); - const data = updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap); + const data = resolveEntryData(entry, imageAssetMap); const result = { ...entry, data, @@ -558,6 +558,15 @@ export function updateImageReferencesInData>( return copy; } +export function resolveEntryData>( + entry: DataEntry, + imageAssetMap?: Map, +): T { + return entry.assetImports?.length + ? updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap) + : structuredClone(entry.data); +} + export async function renderEntry(entry: DataEntry) { if (!entry) { throw new AstroError(AstroErrorData.RenderUndefinedEntryError); diff --git a/packages/astro/src/core/build/plugins/plugin-incremental.ts b/packages/astro/src/core/build/plugins/plugin-incremental.ts index a7506efe629a..676d99a80e6c 100644 --- a/packages/astro/src/core/build/plugins/plugin-incremental.ts +++ b/packages/astro/src/core/build/plugins/plugin-incremental.ts @@ -20,6 +20,7 @@ interface HashableModuleInfo { interface ModuleGraph { getModuleInfo(id: string): HashableModuleInfo | null; + getFileName(referenceId: string): string; } /** Collect the sorted, transitive dependency ids of a module, following static and dynamic imports. */ @@ -46,11 +47,42 @@ function collectTransitiveDeps(graph: ModuleGraph, rootId: string): string[] { return [...deps].sort(); } +/** Each placeholder pattern paired with the token that has to be present for it to match. */ +const ASSET_PLACEHOLDERS = [ + { token: '__ASTRO_ASSET_IMAGE__', pattern: /__ASTRO_ASSET_IMAGE__([\w$]+)__(?:_(.*?)__)?/g }, + { token: '__VITE_ASSET__', pattern: /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g }, +]; + +/** + * Replace the emit handles of imported assets with the file names they resolve + * to. Handles are assigned by the bundler in the order modules finish + * transforming, so two builds of the same sources can hand the same asset a + * different handle, while the file name it resolves to is content-hashed and + * stable. If a handle does not resolve to a file, the placeholder is left as + * it is; the worst case is an unnecessary re-render, not a stale one. + */ +function resolveAssetPlaceholders(graph: ModuleGraph, code: string): string { + let resolved = code; + for (const { token, pattern } of ASSET_PLACEHOLDERS) { + if (!resolved.includes(token)) continue; + resolved = resolved.replace(pattern, (placeholder, handle, postfix = '') => { + try { + return graph.getFileName(handle) + postfix; + } catch { + return placeholder; + } + }); + } + return resolved; +} + /** * Hash a sorted set of module ids together with each module's compiled output. * Hashing the transformed `code` from the bundle (rather than the source file on * disk) reflects what actually ships and covers virtual modules, which have no - * file on disk but still carry generated code. + * 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. */ function hashModules(graph: ModuleGraph, sortedIds: string[]): string { const hasher = crypto.createHash('sha256'); @@ -59,7 +91,7 @@ function hashModules(graph: ModuleGraph, sortedIds: string[]): string { hasher.update('\n'); const code = graph.getModuleInfo(id)?.code; if (code != null) { - hasher.update(code); + hasher.update(resolveAssetPlaceholders(graph, code)); } hasher.update('\n'); } diff --git a/packages/astro/src/core/routing/dev.ts b/packages/astro/src/core/routing/dev.ts index d99a20337cd4..23c3ef4fb8d4 100644 --- a/packages/astro/src/core/routing/dev.ts +++ b/packages/astro/src/core/routing/dev.ts @@ -24,6 +24,7 @@ export async function matchRoute( routesList: RoutesList, pipeline: RunnablePipeline, manifest: SSRManifest, + { prerenderOnly }: { prerenderOnly?: boolean } = {}, ): Promise { const { logger, routeCache } = pipeline; const matches = matchAllRoutes(pathname, routesList); @@ -34,7 +35,16 @@ export async function matchRoute( }); let firstError: unknown = null; + let skippedPrerenderOnly = false; for await (const { route: maybeRoute, filePath } of preloadedMatches) { + // When running as the prerender handler, skip non-prerendered routes + // before importing their components. Their modules may use runtime- + // specific APIs (e.g. cloudflare:workers) unavailable in the prerender + // environment. + if (prerenderOnly && !maybeRoute.prerender) { + skippedPrerenderOnly = true; + continue; + } // attempt to get static paths // if this fails, we have a bad URL match! try { @@ -78,7 +88,15 @@ export async function matchRoute( const altPathname = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); if (altPathname !== pathname) { - return await matchRoute(altPathname, routesList, pipeline, manifest); + return await matchRoute(altPathname, routesList, pipeline, manifest, { prerenderOnly }); + } + + // A non-prerendered route matched but was skipped above. Don't warn or fall + // back to the 404 route (which may be prerendered and would shadow the SSR + // route): returning undefined lets the caller mark the request as not + // handled, so it falls through to the SSR handler and its own full matching. + if (skippedPrerenderOnly) { + return undefined; } if (matches.length) { diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts index 07116d32f84d..9a51a4741b81 100644 --- a/packages/astro/src/vite-plugin-app/app.ts +++ b/packages/astro/src/vite-plugin-app/app.ts @@ -98,12 +98,16 @@ export class AstroServerApp extends BaseApp { this.pipeline.clearActions(); } - async devMatch(pathname: string): Promise { + async devMatch( + pathname: string, + { prerenderOnly }: { prerenderOnly?: boolean } = {}, + ): Promise { const matchedRoute = await matchRoute( pathname, this.manifestData, this.pipeline as unknown as RunnablePipeline, this.manifest, + { prerenderOnly }, ); if (!matchedRoute) { return undefined; @@ -200,7 +204,7 @@ export class AstroServerApp extends BaseApp { controller, pathname, async run() { - const matchedRoute = await self.devMatch(pathname); + const matchedRoute = await self.devMatch(pathname, { prerenderOnly }); if (!matchedRoute) { if (prerenderOnly) { // In prerender-only mode, signal that we didn't handle this diff --git a/packages/astro/test/fixtures/content-layer/src/content/space/numeric-slug.md b/packages/astro/test/fixtures/content-layer/src/content/space/numeric-slug.md new file mode 100644 index 000000000000..9483d0c0a339 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/content/space/numeric-slug.md @@ -0,0 +1,6 @@ +--- +title: Numeric Slug Entry +slug: 20260624 +--- + +Entry with an unquoted numeric slug value. diff --git a/packages/astro/test/fixtures/hmr-middleware/src/pages/index.astro b/packages/astro/test/fixtures/hmr-middleware/src/pages/index.astro index 37d55e639ed6..bc42a2d8bb1b 100644 --- a/packages/astro/test/fixtures/hmr-middleware/src/pages/index.astro +++ b/packages/astro/test/fixtures/hmr-middleware/src/pages/index.astro @@ -1,5 +1,7 @@ --- +import { getCount } from "../utils/notImportedByMiddleware"; const title = Astro.locals.utils.arrayToString(["Hello", "Astro"]); +Astro.response.headers.set("x-index-count", getCount()); --- diff --git a/packages/astro/test/fixtures/hmr-middleware/src/utils/notImportedByMiddleware.js b/packages/astro/test/fixtures/hmr-middleware/src/utils/notImportedByMiddleware.js new file mode 100644 index 000000000000..c3e0c3c50664 --- /dev/null +++ b/packages/astro/test/fixtures/hmr-middleware/src/utils/notImportedByMiddleware.js @@ -0,0 +1,6 @@ +let count = 0; + +export const getCount = () => { + count += 1; + return count; +} diff --git a/packages/astro/test/middleware.test.ts b/packages/astro/test/middleware.test.ts index 33a801fc1e54..fddbad214052 100644 --- a/packages/astro/test/middleware.test.ts +++ b/packages/astro/test/middleware.test.ts @@ -276,4 +276,21 @@ describe('Middleware HMR', () => { assert.equal(response.headers.get('x-test-executed'), '1'); assert.equal(response.headers.get('x-test-other-count'), '1'); }); + + it("shouldn't reload middleware.js when an unrelated module is reloaded", async () => { + let response = await fetchAndAssertTwice(); + assert.equal(response.headers.get('x-index-count'), '2'); + + await fixture.editFile('./src/utils/notImportedByMiddleware.js', (original) => + original.replace('let count = 0;', 'let count = 0;\n//foo\n'), + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // src/utils/notImportedByMiddleware.js should reload + // src/middleware.js shouldn't reload, and neither should src/utils/other.js + response = await fixture.fetch('/'); + assert.equal(response.headers.get('x-test-executed'), '3'); + assert.equal(response.headers.get('x-test-other-count'), '3'); + assert.equal(response.headers.get('x-index-count'), '1'); + }); }); diff --git a/packages/astro/test/units/build/plugin-incremental.test.ts b/packages/astro/test/units/build/plugin-incremental.test.ts new file mode 100644 index 000000000000..1e36ac139c7f --- /dev/null +++ b/packages/astro/test/units/build/plugin-incremental.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { 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'; + +const ROOT = new URL('file:///project/'); +const PAGE_ID = '/project/src/pages/[slug].astro'; +const COMPONENT = 'src/pages/[slug].astro'; +const RED = '/project/src/assets/red.png'; +const BLUE = '/project/src/assets/blue.png'; +const VIDEO = '/project/src/assets/clip.mp4'; + +const HANDLE_ONE = 'VRAku6fjghkApIISiBWPzg'; +const HANDLE_TWO = 'WPGYjwIlzWVNM1bYhOc83w'; + +function moduleInfo( + id: string, + { code = '', importedIds = [] as string[], importers = [] as string[] } = {}, +) { + return { + id, + code, + importedIds, + importers, + dynamicallyImportedIds: [], + dynamicImporters: [] as string[], + meta: {}, + }; +} + +function imageCode(handle: string) { + return `export default {"src":"__ASTRO_ASSET_IMAGE__${handle}__","width":1,"height":1}`; +} + +function assetCode(handle: string) { + return `export default "__VITE_ASSET__${handle}__"`; +} + +function pluginContext( + codeByModule: Record, + fileNames: Record, + importedIds: string[], +) { + const modules = new Map([ + [ + PAGE_ID, + moduleInfo(PAGE_ID, { + code: 'export default page', + importedIds, + importers: [VIRTUAL_PAGE_RESOLVED_MODULE_ID], + }), + ], + ...importedIds.map((id) => [id, moduleInfo(id, { code: codeByModule[id] })] as const), + ]); + + return { + environment: { name: 'prerender' }, + getModuleIds: () => modules.keys(), + getModuleInfo: (id: string) => modules.get(id) ?? null, + getFileName: (handle: string) => { + const fileName = fileNames[handle]; + if (!fileName) throw new Error(`Unknown reference id ${handle}`); + return fileName; + }, + }; +} + +function dependencyHash( + codeByModule: Record, + fileNames: Record, + importedIds = Object.keys(codeByModule), +) { + const internals = { pagesByViteID: new Map([[PAGE_ID, { component: COMPONENT }]]) } as any; + const plugin = pluginIncremental(internals, ROOT) as any; + plugin.generateBundle.call(pluginContext(codeByModule, fileNames, importedIds)); + return internals.pageDependencyHashes.get(COMPONENT); +} + +describe('pluginIncremental', () => { + describe('dependency hash', () => { + it('is stable when the same images are emitted with different handles', () => { + const first = dependencyHash( + { [RED]: imageCode(HANDLE_ONE), [BLUE]: imageCode(HANDLE_TWO) }, + { [HANDLE_ONE]: '_astro/red.aaaa.png', [HANDLE_TWO]: '_astro/blue.bbbb.png' }, + ); + const second = dependencyHash( + { [RED]: imageCode(HANDLE_TWO), [BLUE]: imageCode(HANDLE_ONE) }, + { [HANDLE_TWO]: '_astro/red.aaaa.png', [HANDLE_ONE]: '_astro/blue.bbbb.png' }, + ); + assert.equal(first, second); + }); + + it('is stable when the same non-image assets are emitted with different handles', () => { + const first = dependencyHash( + { [RED]: imageCode(HANDLE_ONE), [VIDEO]: assetCode(HANDLE_TWO) }, + { [HANDLE_ONE]: '_astro/red.aaaa.png', [HANDLE_TWO]: '_astro/clip.cccc.mp4' }, + ); + const second = dependencyHash( + { [RED]: imageCode(HANDLE_TWO), [VIDEO]: assetCode(HANDLE_ONE) }, + { [HANDLE_TWO]: '_astro/red.aaaa.png', [HANDLE_ONE]: '_astro/clip.cccc.mp4' }, + ); + assert.equal(first, second); + }); + + it('changes when an imported image resolves to a different file name', () => { + const code = { [RED]: imageCode(HANDLE_ONE), [BLUE]: imageCode(HANDLE_TWO) }; + const first = dependencyHash(code, { + [HANDLE_ONE]: '_astro/red.aaaa.png', + [HANDLE_TWO]: '_astro/blue.bbbb.png', + }); + const second = dependencyHash(code, { + [HANDLE_ONE]: '_astro/red.dddd.png', + [HANDLE_TWO]: '_astro/blue.bbbb.png', + }); + assert.notEqual(first, second); + }); + + it('keeps hashing when a handle does not resolve to a file', () => { + const code = { [RED]: imageCode(HANDLE_ONE) }; + const first = dependencyHash(code, {}); + const second = dependencyHash(code, {}); + assert.equal(first, second); + assert.match(first, /^[0-9a-f]{64}$/); + }); + }); +}); diff --git a/packages/astro/test/units/content-collections/image-references.test.ts b/packages/astro/test/units/content-collections/image-references.test.ts index 6c04134e7d2e..20ccf23bb8a9 100644 --- a/packages/astro/test/units/content-collections/image-references.test.ts +++ b/packages/astro/test/units/content-collections/image-references.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { updateImageReferencesInData } from '../../../dist/content/runtime.js'; +import { resolveEntryData, updateImageReferencesInData } from '../../../dist/content/runtime.js'; import { imageSrcToImportId } from '../../../dist/assets/utils/resolveImports.js'; import type { ImageMetadata } from '../../../dist/assets/types.js'; @@ -123,3 +123,34 @@ describe('updateImageReferencesInData', () => { assert.equal(result.flags.size, 2); }); }); + +describe('resolveEntryData', () => { + it('clones data without traversing it when assetImports is empty', () => { + const data = { + image: `${IMAGE_PREFIX}./hero.png`, + nested: { count: 1 }, + }; + const result = resolveEntryData( + { id: 'entry', data, filePath: FILE_NAME, assetImports: [] }, + makeImageMap('./hero.png', heroMeta), + ); + + assert.notEqual(result, data); + assert.notEqual(result.nested, data.nested); + assert.equal(result.image, `${IMAGE_PREFIX}./hero.png`); + + result.nested.count = 2; + assert.equal(data.nested.count, 1); + }); + + it('resolves image references when assetImports is present', () => { + const data = { image: `${IMAGE_PREFIX}./hero.png` }; + const result = resolveEntryData( + { id: 'entry', data, filePath: FILE_NAME, assetImports: ['./hero.png'] }, + makeImageMap('./hero.png', heroMeta), + ); + + assert.deepEqual(result.image, heroMeta); + assert.equal(data.image, `${IMAGE_PREFIX}./hero.png`); + }); +}); diff --git a/packages/astro/test/units/content-layer/asset-imports.test.ts b/packages/astro/test/units/content-layer/asset-imports.test.ts new file mode 100644 index 000000000000..e4024e324f25 --- /dev/null +++ b/packages/astro/test/units/content-layer/asset-imports.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import fs from 'node:fs/promises'; +import { MutableDataStore } from '../../../dist/content/mutable-data-store.js'; +import { createTempDir } from './test-helpers.ts'; + +describe('Content Layer - Asset Imports', () => { + it('generates unique symbol names for imports with colliding shorthashes', async () => { + // "Aa" and "BB" produce identical Java-style hashCode values (both = 2112), + // so image paths differing only by this substitution previously generated + // duplicate import identifiers via shorthash(). + const tempDir = createTempDir(); + const assetsFile = new URL('./content-assets.mjs', tempDir); + + const store = new MutableDataStore(); + const filePath = 'src/content/blog/post-1/index.md'; + + // Add entries with asset imports that would collide under shorthash + store.set('blog', 'post-1', { + id: 'post-1', + data: { title: 'Post 1' }, + filePath, + assetImports: ['imgAa.jpg', 'imgBB.jpg'], + }); + + await store.writeAssetImports(assetsFile); + + const code = await fs.readFile(assetsFile, 'utf-8'); + + // Extract all import identifier names + const importNames = [...code.matchAll(/import (\w+) from/g)].map((m) => m[1]); + + assert.equal(importNames.length, 2, 'should have exactly 2 imports'); + assert.notEqual(importNames[0], importNames[1], 'import identifiers must be unique'); + }); +}); diff --git a/packages/astro/test/units/content-layer/glob-loader.test.ts b/packages/astro/test/units/content-layer/glob-loader.test.ts index 3a0966a6ad69..c72e4df41264 100644 --- a/packages/astro/test/units/content-layer/glob-loader.test.ts +++ b/packages/astro/test/units/content-layer/glob-loader.test.ts @@ -64,6 +64,44 @@ describe('Glob Loader', () => { assert.equal(columbia.filePath!.replace(/\\/g, '/'), 'src/content/space/columbia.md'); }); + it('retains entries with numeric slug across multiple syncs', async () => { + const store = new MutableDataStore(); + const settings = createMinimalSettings(root, { + contentEntryTypes: [createMarkdownEntryType()], + }); + const logger = new AstroLogger({ + destination: { write: () => true }, + level: 'silent', + }); + + const collections = { + spacecraft: defineCollection({ + loader: glob({ pattern: '*.md', base: 'src/content/space' }), + }), + }; + + const contentLayer = new ContentLayer({ + settings, + logger, + store, + contentConfigObserver: createTestConfigObserver(collections), + }); + + // First sync + await contentLayer.sync(); + const numericEntry1 = store.values('spacecraft').find((e) => e.id === '20260624'); + assert.ok(numericEntry1, 'Numeric slug entry should exist after first sync'); + const count1 = store.values('spacecraft').length; + + // Second sync — the bug caused entries with numeric slugs to be dropped here + await contentLayer.sync(); + const numericEntry2 = store.values('spacecraft').find((e) => e.id === '20260624'); + assert.ok(numericEntry2, 'Numeric slug entry should persist after second sync'); + const count2 = store.values('spacecraft').length; + + assert.equal(count1, count2, 'Entry count should be stable across syncs'); + }); + it('handles negative matches in glob pattern', async () => { const store = new MutableDataStore(); const settings = createMinimalSettings(root, { diff --git a/packages/astro/test/units/routing/prerender-only-match.test.ts b/packages/astro/test/units/routing/prerender-only-match.test.ts new file mode 100644 index 000000000000..45d640f267de --- /dev/null +++ b/packages/astro/test/units/routing/prerender-only-match.test.ts @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { matchRoute } from '../../../dist/core/routing/dev.js'; +import { makeRoute, spreadPart, staticPart } from './test-helpers.ts'; +import { defaultLogger } from '../test-utils.ts'; +import { RouteCache } from '../../../dist/core/render/route-cache.js'; + +import type { RunnablePipeline } from '../../../dist/vite-plugin-app/pipeline.js'; +import type { RouteData } from '../../../dist/types/public/index.js'; +import type { SSRManifest } from '../../../dist/core/app/types.js'; + +/** + * Creates a minimal mock pipeline and manifest for testing matchRoute. + * `componentLoaders` maps route component paths to functions producing their + * module exports; a loader that throws simulates a module that cannot be + * imported in the current environment (e.g. `cloudflare:workers` in Node). + * `loadedComponents` records every component whose module was requested. + */ +function createMockPipelineAndManifest( + componentLoaders: Record any>, + logger = defaultLogger, +) { + const loadedComponents: string[] = []; + const routeCache = new RouteCache(defaultLogger); + const manifest = { + serverLike: false, + base: '/', + trailingSlash: 'ignore', + rootDir: new URL('file:///fake/'), + routes: [], + buildClientDir: new URL('file:///fake/client/'), + outDir: new URL('file:///fake/'), + } as unknown as SSRManifest; + const pipeline = { + logger, + routeCache, + manifest, + getComponentByRoute(route: RouteData) { + loadedComponents.push(route.component); + return componentLoaders[route.component](); + }, + } as unknown as RunnablePipeline; + return { pipeline, manifest, loadedComponents }; +} + +const trailingSlash = 'ignore'; + +// A non-prerendered endpoint whose module only loads in a specific runtime, +// like @astrojs/cloudflare's /_image endpoint importing `cloudflare:workers`. +const ssrImageEndpoint = makeRoute({ + segments: [[staticPart('_image')]], + trailingSlash, + route: '/_image', + pathname: '/_image', + type: 'endpoint', + component: '@astrojs/cloudflare/image-transform-endpoint', + prerender: false, + origin: 'external', +}); + +const prerenderedCatchAll = makeRoute({ + segments: [[spreadPart('...slug')]], + trailingSlash, + route: '/[...slug]', + pathname: undefined, + component: 'src/pages/[...slug].astro', + prerender: true, +}); + +const prerendered404 = makeRoute({ + segments: [[staticPart('404')]], + trailingSlash, + route: '/404', + pathname: '/404', + component: 'src/pages/404.astro', + prerender: true, +}); + +const runtimeOnlyModule = () => { + throw new Error("Cannot import 'cloudflare:workers' outside the workerd runtime"); +}; + +describe('matchRoute with prerenderOnly', () => { + // Regression test for #17348: a prerendered catch-all makes the dev + // prerender gate route /_image through the Node prerender handler, which + // previously imported the endpoint's module there and crashed. + it('skips non-prerendered routes without importing their components', async () => { + const { pipeline, manifest, loadedComponents } = createMockPipelineAndManifest({ + '@astrojs/cloudflare/image-transform-endpoint': runtimeOnlyModule, + 'src/pages/[...slug].astro': () => ({ + getStaticPaths: () => [{ params: { slug: 'blog' } }], + }), + }); + + const routesList = { routes: [ssrImageEndpoint, prerenderedCatchAll] }; + const result = await matchRoute('/_image', routesList, pipeline, manifest, { + prerenderOnly: true, + }); + + assert.equal(result, undefined, 'Expected no match so the SSR handler takes over'); + assert.ok( + !loadedComponents.includes('@astrojs/cloudflare/image-transform-endpoint'), + 'Expected the non-prerendered component to never be imported', + ); + }); + + it('still imports non-prerendered components without prerenderOnly', async () => { + const { pipeline, manifest } = createMockPipelineAndManifest({ + '@astrojs/cloudflare/image-transform-endpoint': runtimeOnlyModule, + 'src/pages/[...slug].astro': () => ({ + getStaticPaths: () => [{ params: { slug: 'blog' } }], + }), + }); + + const routesList = { routes: [ssrImageEndpoint, prerenderedCatchAll] }; + await assert.rejects( + () => matchRoute('/_image', routesList, pipeline, manifest), + /cloudflare:workers/, + ); + }); + + it('keeps filtering through the .html alt-pathname retry', async () => { + const ssrEndpoint = makeRoute({ + segments: [[staticPart('foo')]], + trailingSlash, + route: '/foo', + pathname: '/foo', + type: 'endpoint', + component: 'src/pages/foo.ts', + prerender: false, + }); + + const { pipeline, manifest, loadedComponents } = createMockPipelineAndManifest({ + 'src/pages/foo.ts': runtimeOnlyModule, + 'src/pages/[...slug].astro': () => ({ + getStaticPaths: () => [{ params: { slug: 'bar' } }], + }), + }); + + // '/foo.html' matches no candidate, so matchRoute retries with '/foo', + // which must keep skipping the non-prerendered endpoint. + const routesList = { routes: [ssrEndpoint, prerenderedCatchAll] }; + const result = await matchRoute('/foo.html', routesList, pipeline, manifest, { + prerenderOnly: true, + }); + + assert.equal(result, undefined, 'Expected no match so the SSR handler takes over'); + assert.ok( + !loadedComponents.includes('src/pages/foo.ts'), + 'Expected the non-prerendered component to never be imported on the retry', + ); + }); + + // A prerendered custom 404 must not shadow the skipped SSR route: the + // prerender handler would render a 404 for /_image instead of letting the + // SSR handler serve it. + it('does not fall back to a prerendered 404 when candidates were skipped', async () => { + const { pipeline, manifest, loadedComponents } = createMockPipelineAndManifest({ + '@astrojs/cloudflare/image-transform-endpoint': runtimeOnlyModule, + 'src/pages/[...slug].astro': () => ({ + getStaticPaths: () => [{ params: { slug: 'blog' } }], + }), + }); + + const routesList = { routes: [ssrImageEndpoint, prerenderedCatchAll, prerendered404] }; + const result = await matchRoute('/_image', routesList, pipeline, manifest, { + prerenderOnly: true, + }); + + assert.equal(result, undefined, 'Expected no match so the SSR handler takes over'); + assert.ok( + !loadedComponents.includes('@astrojs/cloudflare/image-transform-endpoint'), + 'Expected the non-prerendered component to never be imported', + ); + }); + + it('still falls back to the 404 when nothing was skipped', async () => { + const { pipeline, manifest } = createMockPipelineAndManifest({ + '@astrojs/cloudflare/image-transform-endpoint': runtimeOnlyModule, + }); + + const routesList = { routes: [ssrImageEndpoint, prerendered404] }; + const result = await matchRoute('/nope', routesList, pipeline, manifest, { + prerenderOnly: true, + }); + + assert.ok(result, 'Expected the 404 fallback for a genuinely unmatched path'); + assert.equal(result.route.route, '/404'); + }); + + it('does not log NoMatchingStaticPathFound when candidates were skipped', async () => { + const warnings: string[] = []; + const spyLogger = { + warn: (_label: string | null, message: string) => { + warnings.push(message); + }, + error: () => {}, + info: () => {}, + debug: () => {}, + } as unknown as typeof defaultLogger; + + const { pipeline, manifest } = createMockPipelineAndManifest( + { + '@astrojs/cloudflare/image-transform-endpoint': runtimeOnlyModule, + 'src/pages/[...slug].astro': () => ({ + getStaticPaths: () => [{ params: { slug: 'blog' } }], + }), + }, + spyLogger, + ); + + const routesList = { routes: [ssrImageEndpoint, prerenderedCatchAll] }; + await matchRoute('/_image', routesList, pipeline, manifest, { + prerenderOnly: true, + }); + + assert.deepEqual(warnings, [], 'Expected no router warning for skipped SSR candidates'); + }); + + it('returns prerendered matches as usual', async () => { + const { pipeline, manifest } = createMockPipelineAndManifest({ + '@astrojs/cloudflare/image-transform-endpoint': runtimeOnlyModule, + 'src/pages/[...slug].astro': () => ({ + getStaticPaths: () => [{ params: { slug: 'blog' } }], + }), + }); + + const routesList = { routes: [ssrImageEndpoint, prerenderedCatchAll] }; + const result = await matchRoute('/blog', routesList, pipeline, manifest, { + prerenderOnly: true, + }); + + assert.ok(result, 'Expected the prerendered catch-all to match'); + assert.equal(result.route.component, 'src/pages/[...slug].astro'); + }); +}); diff --git a/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts b/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts index ad33f34d647c..5056f8952e6c 100644 --- a/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts +++ b/packages/integrations/cloudflare/test/dev-image-endpoint.test.ts @@ -1,5 +1,6 @@ import * as assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; +import cloudflare from '../dist/index.js'; import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; describe('Dev image endpoint', () => { @@ -51,3 +52,36 @@ describe('Dev image endpoint', () => { assert.equal(res.headers.get('content-type'), 'image/avif'); }); }); + +describe('Dev image endpoint with prerenderEnvironment: node', () => { + let fixture: Fixture; + let devServer: DevServer; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/dev-image-endpoint/', + adapter: cloudflare({ prerenderEnvironment: 'node' }), + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + // Regression test for #17348: the prerendered catch-all route makes the + // dev prerender gate send /_image through the Node prerender handler, + // which previously imported the image endpoint (and its top-level + // `cloudflare:workers` import) in Node and returned a 500. + it('transforms local images when a prerendered catch-all route exists', async () => { + const res = await fixture.fetch('/_image?href=/placeholder.jpg&f=png&w=100'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/png'); + }); + + it('renders the prerendered catch-all page', async () => { + const res = await fixture.fetch('/catch-all-page'); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok(html.includes('id="catch-all"'), 'Expected the catch-all page content'); + }); +}); diff --git a/packages/integrations/cloudflare/test/fixtures/dev-image-endpoint/src/pages/[...slug].astro b/packages/integrations/cloudflare/test/fixtures/dev-image-endpoint/src/pages/[...slug].astro new file mode 100644 index 000000000000..60d094e0515b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/dev-image-endpoint/src/pages/[...slug].astro @@ -0,0 +1,17 @@ +--- +export const prerender = true; + +export function getStaticPaths() { + return [{ params: { slug: 'catch-all-page' } }]; +} +--- + + + + + Catch-all Page + + +

Catch-all page

+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 023d6a45f7be..083b6b51d4ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7119,6 +7119,12 @@ importers: specifier: ^4.22.0 version: 4.22.3 + triage/gh-17624: + dependencies: + astro: + specifier: ^7.2.0 + version: link:../../packages/astro + packages: '@anthropic-ai/sdk@0.91.1':