Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-svg-content-collection-deadlock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a deadlock that occurred when using SVG images in content collections
16 changes: 15 additions & 1 deletion packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
});
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/content/vite-plugin-content-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
14 changes: 14 additions & 0 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<RemotePattern>[];
Expand Down
40 changes: 40 additions & 0 deletions packages/astro/test/content-collection-tla-svg.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'astro/config';

export default defineConfig({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/content-collection-tla-svg",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
title: Article One
cover: ../../assets/logo.svg
Original file line number Diff line number Diff line change
@@ -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');
---
<html>
<head>
<title>Articles</title>
</head>
<body>
{articles.map((article) => (
<div class="article">
<h2 class="title">{article.data.title}</h2>
<img class="cover" src={article.data.cover.src} width={article.data.cover.width} height={article.data.cover.height} />
</div>
))}
</body>
</html>
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading