diff --git a/.changeset/fix-svg-content-collection-deadlock.md b/.changeset/fix-svg-content-collection-deadlock.md new file mode 100644 index 000000000000..ef332e8c3445 --- /dev/null +++ b/.changeset/fix-svg-content-collection-deadlock.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes a deadlock that occurred when using SVG images in content collections diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 89c3d802975d..10d4a30ffa95 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -32,6 +32,7 @@ import { isESMImportedImage } from './utils/index.js'; import { emitClientAsset } from './utils/assets.js'; import { hashTransform, propsToFilename } from './utils/hash.js'; import { emitImageMetadata } from './utils/node.js'; +import { CONTENT_IMAGE_FLAG } from '../content/consts.js'; import { getProxyCode } from './utils/proxy.js'; import { makeSvgComponent } from './utils/svg.js'; import { createPlaceholderURL, stringifyPlaceholderURL } from './utils/url.js'; @@ -271,6 +272,15 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl if (!globalThis.astroAsset.referencedImages) globalThis.astroAsset.referencedImages = new Set(); + // Content collection images have the astroContentImageFlag query param. + // Strip it so we can process the image, but remember it so we can avoid + // creating SVG components (which import from the server runtime and cause + // circular dependency deadlocks with top-level await). + const isContentImage = id.includes(CONTENT_IMAGE_FLAG); + if (isContentImage) { + id = removeQueryString(id); + } + if (id !== removeQueryString(id)) { // If our import has any query params, we'll let Vite handle it, nonetheless we'll make sure to not delete it // See https://github.com/withastro/astro/issues/8333 @@ -299,7 +309,11 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl // Since you cannot use image optimization on the client anyway, it's safe to assume that if the user imported // an image on the client, it should be present in the final build. if (isAstroServerEnvironment(this.environment)) { - if (id.endsWith('.svg')) { + // For SVGs imported directly (not via content collections), create a full + // component that can be rendered inline. For content collection SVGs, return + // plain metadata to avoid importing createComponent from the server runtime, + // which would create a circular dependency when combined with TLA. + if (id.endsWith('.svg') && !isContentImage) { const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: 'utf8', }); diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 82634acbf020..b6c1689043d7 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -53,6 +53,11 @@ export function astroContentAssetPropagationPlugin({ message: AstroErrorData.ImageNotFound.message(base), }); } + // Preserve the content image flag in the resolved ID so that downstream plugins + // (e.g. astro:assets:esm) can detect content collection images and avoid creating + // full SVG components, which would import from the server runtime and cause a + // circular module dependency deadlock when combined with top-level await (TLA). + resolved.id = `${resolved.id}?${CONTENT_IMAGE_FLAG}`; return resolved; } if (hasContentFlag(id, CONTENT_RENDER_FLAG)) { diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index fc14fd249953..9cbc2508d1b9 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -622,6 +622,9 @@ export interface AstroUserConfig< * Each pattern can specify `protocol`, `hostname`, and `port`. All three are validated if provided. * The patterns support wildcards for flexible hostname matching: * + * - `*.example.com` - matches exactly one subdomain level (e.g., `sub.example.com` but not `deep.sub.example.com`) + * - `**.example.com` - matches any subdomain depth (e.g., both `sub.example.com` and `deep.sub.example.com`) + * * ```js * { * security: { @@ -641,6 +644,17 @@ export interface AstroUserConfig< * } * ``` * + * In some specific contexts (e.g., applications behind trusted reverse proxies with dynamic domains), you may need to allow all domains. To do this, use an empty object: + * + * ```js + * { + * security: { + * // Allow any domain - use this only when necessary + * allowedDomains: [{}] + * } + * } + * ``` + * * When not configured, `X-Forwarded-Host` headers are not trusted and will be ignored. */ allowedDomains?: Partial[]; diff --git a/packages/astro/test/content-collection-tla-svg.test.js b/packages/astro/test/content-collection-tla-svg.test.js new file mode 100644 index 000000000000..000f7363e71e --- /dev/null +++ b/packages/astro/test/content-collection-tla-svg.test.js @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +// Regression test for https://github.com/withastro/astro/issues/15575 +// SVG images in content collection image() fields combined with top-level await +// caused a circular module dependency deadlock during build. +describe('Content collection with SVG image and TLA', () => { + /** @type {import("./test-utils.js").Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ root: './fixtures/content-collection-tla-svg/' }); + }); + + describe('Build', () => { + before(async () => { + await fixture.build(); + }); + + it('successfully builds pages using TLA with getCollection()', async () => { + const html = await fixture.readFile('/index.html'); + assert.ok(html, 'Expected page to be generated'); + + const $ = cheerio.load(html); + assert.equal($('.title').first().text(), 'Article One'); + }); + + it('resolves SVG image as metadata in content collection', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + const $img = $('.cover').first(); + assert.ok($img.attr('src'), 'Expected cover image to have a src'); + assert.equal($img.attr('width'), '100'); + assert.equal($img.attr('height'), '100'); + }); + }); +}); diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/astro.config.mjs b/packages/astro/test/fixtures/content-collection-tla-svg/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-tla-svg/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/package.json b/packages/astro/test/fixtures/content-collection-tla-svg/package.json new file mode 100644 index 000000000000..e1225402379a --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-tla-svg/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/content-collection-tla-svg", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/src/assets/logo.svg b/packages/astro/test/fixtures/content-collection-tla-svg/src/assets/logo.svg new file mode 100644 index 000000000000..fb6977d9b566 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-tla-svg/src/assets/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/src/content.config.ts b/packages/astro/test/fixtures/content-collection-tla-svg/src/content.config.ts new file mode 100644 index 000000000000..d7cbb599b8fb --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-tla-svg/src/content.config.ts @@ -0,0 +1,16 @@ +import { defineCollection } from 'astro:content'; +import { z } from 'astro/zod'; +import { glob } from 'astro/loaders'; + +const articles = defineCollection({ + loader: glob({ pattern: '**/*.yaml', base: './src/content/articles' }), + schema: ({ image }) => + z.object({ + title: z.string(), + cover: image(), + }), +}); + +export const collections = { + articles, +}; diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/src/content/articles/article-1.yaml b/packages/astro/test/fixtures/content-collection-tla-svg/src/content/articles/article-1.yaml new file mode 100644 index 000000000000..9140969b2e92 --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-tla-svg/src/content/articles/article-1.yaml @@ -0,0 +1,2 @@ +title: Article One +cover: ../../assets/logo.svg diff --git a/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro b/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro new file mode 100644 index 000000000000..f7bf4ff97aef --- /dev/null +++ b/packages/astro/test/fixtures/content-collection-tla-svg/src/pages/index.astro @@ -0,0 +1,21 @@ +--- +import { getCollection } from 'astro:content'; + +// Top-level await - this is the key trigger for the circular dependency bug. +// When combined with SVG images in content collection image() fields, +// the TLA causes a module evaluation deadlock. +const articles = await getCollection('articles'); +--- + + + Articles + + + {articles.map((article) => ( +
+

{article.data.title}

+ +
+ ))} + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7ee0f58652c..be4f2fdebffe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2706,6 +2706,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/content-collection-tla-svg: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/content-collections: dependencies: '@astrojs/mdx':