From 614730a4c4dae0a4a90b8b742cf813971efa9162 Mon Sep 17 00:00:00 2001 From: Ashleigh Yeoman <117744792+ashleigh-yeoman@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:03:20 +0100 Subject: [PATCH 1/7] Add missing test case (#17610) Merged PR that was missing this case: https://github.com/withastro/astro/pull/17605 --- .../hmr-middleware/src/pages/index.astro | 2 ++ .../src/utils/notImportedByMiddleware.js | 6 ++++++ packages/astro/test/middleware.test.ts | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 packages/astro/test/fixtures/hmr-middleware/src/utils/notImportedByMiddleware.js 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'); + }); }); From fba468c228d8661d2383a80c74206075201a187b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Gawe=C5=82?= Date: Fri, 7 Aug 2026 14:50:47 +0200 Subject: [PATCH 2/7] perf(content): skip image traversal without imports (#17547) Co-authored-by: Matthew Phillips --- .changeset/two-actors-go.md | 5 +++ packages/astro/src/content/runtime.ts | 13 ++++++-- .../image-references.test.ts | 33 ++++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 .changeset/two-actors-go.md 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/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/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`); + }); +}); From 4328c736b18d36576429186445c8c89e8cd0d4b6 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:53:11 -0700 Subject: [PATCH 3/7] Fix /_image 500 when catch-all prerendered route is combined with prerenderEnvironment: 'node' (#17349) * fix: skip non-prerendered route imports in prerender handler to avoid cloudflare:workers error When `prerenderOnly` is true, `matchRoute` now skips routes with `prerender: false` before importing their components. This prevents the Node prerender environment from trying to import modules that use runtime-specific APIs (e.g. `cloudflare:workers`) which are unavailable in Node. Fixes #17348 * chore: remove stray triage/repro-17348 importer from pnpm-lock.yaml The importer was committed from the triage bot's local repro checkout. `/triage/` is gitignored, so the directory does not exist for other checkouts and the dangling lockfile entry breaks frozen-lockfile installs in CI. The source changes in this PR do not touch any dependencies, so the lockfile needs no changes. * fix: forward prerenderOnly through matchRoute's alt-pathname retry The retry that strips `.html`/`index.html` re-ran matching without the prerenderOnly filter, so such requests could still import SSR-only modules (e.g. cloudflare:workers) in the Node prerender environment. * chore: add changeset * test: cover prerender-only route matching in dev Unit tests pin the two behaviors that had no coverage: prerenderOnly skips non-prerendered routes before their modules are imported, and the filter survives the .html alt-pathname retry. An integration test reproduces #17348 end to end: with prerenderEnvironment 'node' and a prerendered catch-all route, /_image must fall through to workerd instead of importing cloudflare:workers in Node. All three fail against the pre-fix sources. * fix: return undefined instead of 404 fallback when prerender-only matching skipped candidates When matchRoute skips non-prerendered candidates in prerenderOnly mode, falling through to the custom 404 lookup could return a prerendered 404.astro, making the prerender handler render a 404 for requests an SSR route would have handled (/_image, /api/*, actions, server islands). Returning undefined lets handleRequest mark the request as not handled so it falls through to the SSR handler, which does its own full matching. This also stops the spurious NoMatchingStaticPathFound warning that fired for every skipped SSR candidate. * test: pin prerender-only fall-through semantics Covers the two review findings: a prerendered custom 404 must not shadow a skipped SSR route (the request falls through to the SSR handler instead), and NoMatchingStaticPathFound is not logged when candidates were skipped in prerenderOnly mode. Also keeps a control test that the 404 fallback still applies when nothing was skipped. --------- Co-authored-by: Alexander Niebuhr <45965090+alexanderniebuhr@users.noreply.github.com> --- .changeset/shiny-llamas-repair.md | 5 + packages/astro/src/core/routing/dev.ts | 20 +- packages/astro/src/vite-plugin-app/app.ts | 8 +- .../routing/prerender-only-match.test.ts | 236 ++++++++++++++++++ .../test/dev-image-endpoint.test.ts | 34 +++ .../src/pages/[...slug].astro | 17 ++ 6 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 .changeset/shiny-llamas-repair.md create mode 100644 packages/astro/test/units/routing/prerender-only-match.test.ts create mode 100644 packages/integrations/cloudflare/test/fixtures/dev-image-endpoint/src/pages/[...slug].astro 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/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/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

+ + From cb435129d17e318ae9ffa18dc595b5ed056ff4af Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:53:37 -0700 Subject: [PATCH 4/7] fix(content): coerce glob loader generateId return value to string (#17624) (#17625) When a glob() content collection entry's slug is an unquoted YAML number (e.g. `slug: 20260624`), `generateIdDefault()` returned it as a JS number via `data.slug as string` (a no-op type assertion). The numeric id caused `untouchedEntries.delete(id)` to silently no-op (Set uses ===, and number !== string), so the cleanup pass deleted the entry on every sync after the first. Fix: use `String(data.slug)` in `generateIdDefault()` and wrap the user-facing `generateId` with `String()` defensively. --- packages/astro/src/content/loaders/glob.ts | 7 +++- .../src/content/space/numeric-slug.md | 6 +++ .../units/content-layer/glob-loader.test.ts | 38 +++++++++++++++++++ pnpm-lock.yaml | 6 +++ 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/astro/test/fixtures/content-layer/src/content/space/numeric-slug.md 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/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/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/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': From 16e0d9d5c1b289242d317887e8334506627f2233 Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" <108291165+astrobot-houston@users.noreply.github.com> Date: Fri, 7 Aug 2026 07:56:57 -0700 Subject: [PATCH 5/7] fix(content): use sequential indices for image import identifiers to prevent hash collisions (#17595) (#17602) Co-authored-by: Matthew Phillips --- .changeset/yellow-pants-watch.md | 5 +++ .../astro/src/assets/utils/resolveImports.ts | 3 -- .../astro/src/content/mutable-data-store.ts | 6 ++-- .../units/content-layer/asset-imports.test.ts | 36 +++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 .changeset/yellow-pants-watch.md create mode 100644 packages/astro/test/units/content-layer/asset-imports.test.ts 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..54899afe7e76 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'; /** @@ -40,5 +39,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/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/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'); + }); +}); From d6d5e759ad3dd5ebd27ce0e3a99ba59dfa16b1ee Mon Sep 17 00:00:00 2001 From: "Houston (Bot)" Date: Fri, 7 Aug 2026 14:58:52 +0000 Subject: [PATCH 6/7] [ci] format --- packages/astro/src/assets/utils/resolveImports.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/astro/src/assets/utils/resolveImports.ts b/packages/astro/src/assets/utils/resolveImports.ts index 54899afe7e76..9a8e694206c9 100644 --- a/packages/astro/src/assets/utils/resolveImports.ts +++ b/packages/astro/src/assets/utils/resolveImports.ts @@ -38,4 +38,3 @@ export function imageSrcToImportId(imageSrc: string, filePath?: string): string } return `${imageSrc}?${params.toString()}`; } - From 3a890d226482e98c41650fb66c0c14d45eba2717 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Fri, 7 Aug 2026 20:06:47 +0500 Subject: [PATCH 7/7] fix(incremental): use resolved file names for imported assets in the dependency hash (#17616) --- .changeset/tidy-jokes-count.md | 5 + .../core/build/plugins/plugin-incremental.ts | 36 ++++- .../units/build/plugin-incremental.test.ts | 126 ++++++++++++++++++ 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 .changeset/tidy-jokes-count.md create mode 100644 packages/astro/test/units/build/plugin-incremental.test.ts 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/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/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}$/); + }); + }); +});