diff --git a/.changeset/major-ads-follow.md b/.changeset/major-ads-follow.md new file mode 100644 index 000000000000..4781f65ee909 --- /dev/null +++ b/.changeset/major-ads-follow.md @@ -0,0 +1,5 @@ +--- +'@astrojs/internal-helpers': patch +--- + +Fixes incremental build cache invalidation caused by Shiki mutating the `langAlias` config object when loading languages diff --git a/.changeset/witty-carrots-invent.md b/.changeset/witty-carrots-invent.md new file mode 100644 index 000000000000..d536be100581 --- /dev/null +++ b/.changeset/witty-carrots-invent.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `getCollection()` and `getEntry()` throwing `DataCloneError` when a collection schema transform returns a `Temporal.PlainDate` or other class instance. diff --git a/packages/astro/src/content/data-store.ts b/packages/astro/src/content/data-store.ts index 9d19ba3883b7..248f2b039da3 100644 --- a/packages/astro/src/content/data-store.ts +++ b/packages/astro/src/content/data-store.ts @@ -35,6 +35,15 @@ export interface DataEntry = Record; + /** + * Locations of image fields within `data`, recorded when the entry is stored. + * Each path is the sequence of keys from `data` to a field that holds an image + * src string. At read time these fields are resolved to `ImageMetadata` without + * traversing or cloning the rest of `data`, so sibling values that devalue can + * serialize but `structuredClone` cannot (e.g. class instances) are left + * untouched. + */ + imageImports?: (string | number)[][]; } /** diff --git a/packages/astro/src/content/mutable-data-store.ts b/packages/astro/src/content/mutable-data-store.ts index 347292ddb000..89af09562cfb 100644 --- a/packages/astro/src/content/mutable-data-store.ts +++ b/packages/astro/src/content/mutable-data-store.ts @@ -349,11 +349,17 @@ export default new Map([\n${lines.join(',\n')}]); } } const foundAssets = new Set(assetImports); - // Check for image imports in the data. These will have been prefixed during schema parsing - forEach(data, (_, val) => { + const imageImports: (string | number)[][] = []; + // Image fields are prefixed during schema parsing. Record their locations and + // strip the prefix so the stored data holds a plain, devalue-serializable src + // string. The recorded paths let read-time resolution rewrite only these fields + // without traversing or cloning the rest of the data. + forEach(data, function (ctx, val) { if (typeof val === 'string' && val.startsWith(IMAGE_IMPORT_PREFIX)) { const src = val.replace(IMAGE_IMPORT_PREFIX, ''); foundAssets.add(src); + imageImports.push(ctx.path.map((segment) => segment as string | number)); + ctx.update(src); } }); @@ -378,6 +384,10 @@ export default new Map([\n${lines.join(',\n')}]); this.addAssetImports(entry.assetImports, filePath); } + if (imageImports.length) { + entry.imageImports = imageImports; + } + if (digest) { entry.digest = digest; } diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts index 1698f7efdcdd..d63ec52077ca 100644 --- a/packages/astro/src/content/runtime.ts +++ b/packages/astro/src/content/runtime.ts @@ -1,6 +1,5 @@ import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; import { escape } from 'html-escaper'; -import { forEach } from 'neotraverse'; import * as z from 'zod/v4'; import type * as zCore from 'zod/v4/core'; import type { GetImageResult, ImageMetadata } from '../assets/types.js'; @@ -27,7 +26,7 @@ import type { LiveDataEntryResult, } from '../types/public/content.js'; import { defineCollection as defineCollectionOrig } from './config.js'; -import { IMAGE_IMPORT_PREFIX, type LIVE_CONTENT_TYPE } from './consts.js'; +import type { LIVE_CONTENT_TYPE } from './consts.js'; import { type DataEntry, globalDataStore } from './data-store.js'; import { LiveCollectionCacheHintError, @@ -515,56 +514,96 @@ async function updateImageReferencesInBody(html: string, fileName: string) { }); } +/** + * Resolves the image src at `path` within `data` to its `ImageMetadata` (or a + * renderable SVG component). Returns the resolved value, or `undefined` when the + * image is not in the asset map and the plain src already stored in `data` should + * be kept. + */ +function resolveImageAtPath( + src: string, + fileName: string | undefined, + imageAssetMap: Map | undefined, +): unknown { + const id = imageSrcToImportId(src, fileName); + if (!id) { + return undefined; + } + const imported = imageAssetMap?.get(id) as + | (ImageMetadata & { + __svgData?: { + attributes: Record; + children: string; + styles: string[]; + }; + }) + | undefined; + if (!imported) { + return undefined; + } + if (imported.__svgData) { + // Reconstruct the renderable SVG component from the data embedded at build + // time. We cannot call createSvgComponent inside the SVG Vite module itself + // because that would import the server runtime across a dynamic-import + // boundary, recreating the TLA circular-dependency deadlock (see #15575). + const { __svgData: svgData, ...meta } = imported; + return createSvgComponent({ meta: meta as ImageMetadata, ...svgData }); + } + return imported; +} + +/** + * Writes `value` at `path` within `target`, copying only the containers along + * that path so the shared store entry is never mutated. Sibling values and every + * container off the path are shared by reference, so values that `structuredClone` + * cannot handle (e.g. `Temporal` objects or class instances from Zod transforms) + * are never touched. + */ +function setAtPathCopying>( + target: T, + path: (string | number)[], + value: unknown, +): T { + if (path.length === 0) { + return target; + } + const [key, ...rest] = path; + const copy: any = Array.isArray(target) ? target.slice() : { ...target }; + copy[key] = rest.length === 0 ? value : setAtPathCopying(copy[key], rest, value); + return copy; +} + export function updateImageReferencesInData>( data: T, fileName?: string, imageAssetMap?: Map, + imageImports?: (string | number)[][], ): T { - const copy = structuredClone(data); - forEach(copy, function (ctx, val) { - if (typeof val === 'string' && val.startsWith(IMAGE_IMPORT_PREFIX)) { - const src = val.replace(IMAGE_IMPORT_PREFIX, ''); - - const id = imageSrcToImportId(src, fileName); - if (!id) { - ctx.update(src); - return; - } - const imported = imageAssetMap?.get(id) as - | (ImageMetadata & { - __svgData?: { - attributes: Record; - children: string; - styles: string[]; - }; - }) - | undefined; - if (imported) { - if (imported.__svgData) { - // Reconstruct the renderable SVG component from the data embedded at build - // time. We cannot call createSvgComponent inside the SVG Vite module itself - // because that would import the server runtime across a dynamic-import - // boundary, recreating the TLA circular-dependency deadlock (see #15575). - const { __svgData: svgData, ...meta } = imported; - ctx.update(createSvgComponent({ meta: meta as ImageMetadata, ...svgData })); - } else { - ctx.update(imported); - } - } else { - ctx.update(src); - } + if (!imageImports?.length) { + return data; + } + let result = data; + for (const path of imageImports) { + let src: unknown = result; + for (const key of path) { + src = (src as Record)?.[key]; } - }); - return copy; + if (typeof src !== 'string') { + continue; + } + const resolved = resolveImageAtPath(src, fileName, imageAssetMap); + if (resolved !== undefined) { + result = setAtPathCopying(result, path, resolved); + } + } + return result; } export function resolveEntryData>( entry: DataEntry, imageAssetMap?: Map, ): T { - return entry.assetImports?.length - ? updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap) - : structuredClone(entry.data); + return updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap, entry.imageImports); } export async function renderEntry(entry: DataEntry) { 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 20ccf23bb8a9..b9586e08d0b6 100644 --- a/packages/astro/test/units/content-collections/image-references.test.ts +++ b/packages/astro/test/units/content-collections/image-references.test.ts @@ -4,7 +4,6 @@ import { resolveEntryData, updateImageReferencesInData } from '../../../dist/con import { imageSrcToImportId } from '../../../dist/assets/utils/resolveImports.js'; import type { ImageMetadata } from '../../../dist/assets/types.js'; -const IMAGE_PREFIX = '__ASTRO_IMAGE_'; const FILE_NAME = 'src/content/blog/post.md'; function makeImageMap(src: string, meta: ImageMetadata): Map { @@ -21,59 +20,42 @@ const heroMeta: ImageMetadata = { }; describe('updateImageReferencesInData', () => { - it('replaces a top-level image placeholder with resolved ImageMetadata', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; + it('replaces a top-level image src with resolved ImageMetadata', () => { + const data = { image: './hero.png' }; const map = makeImageMap('./hero.png', heroMeta); - const result = updateImageReferencesInData(data, FILE_NAME, map); + const result = updateImageReferencesInData(data, FILE_NAME, map, [['image']]); assert.deepEqual(result.image, heroMeta); }); it('resolves an image nested inside an object', () => { - const data = { cover: { src: `${IMAGE_PREFIX}./hero.png`, alt: 'Hero' } }; + const data = { cover: { src: './hero.png', alt: 'Hero' } }; const map = makeImageMap('./hero.png', heroMeta); - const result = updateImageReferencesInData(data, FILE_NAME, map); + const result = updateImageReferencesInData(data, FILE_NAME, map, [['cover', 'src']]); assert.deepEqual(result.cover.src, heroMeta); + assert.equal(result.cover.alt, 'Hero'); }); it('resolves images nested inside an array', () => { - const data = { - gallery: [`${IMAGE_PREFIX}./hero.png`, `${IMAGE_PREFIX}./hero.png`], - }; + const data = { gallery: ['./hero.png', './hero.png'] }; const map = makeImageMap('./hero.png', heroMeta); - const result = updateImageReferencesInData(data, FILE_NAME, map); + const result = updateImageReferencesInData(data, FILE_NAME, map, [ + ['gallery', 0], + ['gallery', 1], + ]); assert.deepEqual(result.gallery[0], heroMeta); assert.deepEqual(result.gallery[1], heroMeta); }); - it('falls back to the raw src string when the id is not in the map', () => { - const data = { image: `${IMAGE_PREFIX}./missing.png` }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); + it('keeps the raw src string when the id is not in the map', () => { + const data = { image: './missing.png' }; + const result = updateImageReferencesInData(data, FILE_NAME, new Map(), [['image']]); assert.equal(result.image, './missing.png'); }); - it('leaves non-image strings unchanged', () => { + it('returns the data unchanged when there are no image imports', () => { const data = { title: 'Hello', slug: 'hello-world' }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.equal(result.title, 'Hello'); - assert.equal(result.slug, 'hello-world'); - }); - - it('handles an empty imageAssetMap gracefully', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.equal(result.image, './hero.png'); - }); - - it('handles undefined imageAssetMap — falls back to raw src', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; - const result = updateImageReferencesInData(data, FILE_NAME, undefined); - assert.equal(result.image, './hero.png'); - }); - - it('handles data with no image fields', () => { - const data = { title: 'My Post', tags: ['a', 'b'], count: 3 }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.deepEqual(result, data); + const result = updateImageReferencesInData(data, FILE_NAME, new Map(), undefined); + assert.equal(result, data); }); it('resolves multiple different images in the same entry', () => { @@ -91,66 +73,74 @@ describe('updateImageReferencesInData', () => { [heroId, heroMeta], [thumbId, thumbMeta], ]); - const data = { - hero: `${IMAGE_PREFIX}./hero.png`, - thumb: `${IMAGE_PREFIX}./thumb.png`, - }; - const result = updateImageReferencesInData(data, FILE_NAME, map); + const data = { hero: './hero.png', thumb: './thumb.png' }; + const result = updateImageReferencesInData(data, FILE_NAME, map, [['hero'], ['thumb']]); assert.deepEqual(result.hero, heroMeta); assert.deepEqual(result.thumb, thumbMeta); }); - it('preserves Map instances', () => { - const data = { - metadata: new Map([ - ['title', 'Hello'], - ['description', 'World'], - ]), + it('does not mutate the original data', () => { + const data = { image: './hero.png' }; + const map = makeImageMap('./hero.png', heroMeta); + const result = updateImageReferencesInData(data, FILE_NAME, map, [['image']]); + assert.deepEqual(result.image, heroMeta); + assert.equal(data.image, './hero.png'); + assert.notEqual(result, data); + }); + + it('shares non-image sibling values by reference without cloning them', () => { + // A value structuredClone cannot handle (it has a method), standing in for + // the class instances produced by Zod transforms (e.g. Temporal.PlainDate). + const publishedOn = { + iso: '2026-08-04', + format() { + return this.iso; + }, }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); - assert.equal(result.metadata.get('title'), 'Hello'); - assert.equal(result.metadata.get('description'), 'World'); - assert.equal(result.metadata.get('cover'), undefined); - assert.equal(result.metadata.size, 2); + assert.throws(() => structuredClone(publishedOn), /DataCloneError|could not be cloned/); + + const data = { image: './hero.png', publishedOn }; + const map = makeImageMap('./hero.png', heroMeta); + const result = updateImageReferencesInData(data, FILE_NAME, map, [['image']]); + + assert.deepEqual(result.image, heroMeta); + assert.equal(result.publishedOn, publishedOn); + assert.equal(result.publishedOn.format(), '2026-08-04'); }); - it('preserves Set instances', () => { - const data = { flags: new Set(['showTitle', 'showDescription']) }; - const result = updateImageReferencesInData(data, FILE_NAME, new Map()); + it('preserves Map and Set siblings by reference', () => { + const metadata = new Map([['title', 'Hello']]); + const flags = new Set(['showTitle']); + const data = { image: './hero.png', metadata, flags }; + const map = makeImageMap('./hero.png', heroMeta); + const result = updateImageReferencesInData(data, FILE_NAME, map, [['image']]); + + assert.deepEqual(result.image, heroMeta); + assert.equal(result.metadata, metadata); + assert.equal(result.metadata.get('title'), 'Hello'); + assert.equal(result.flags, flags); assert.equal(result.flags.has('showTitle'), true); - assert.equal(result.flags.has('showDescription'), true); - assert.equal(result.flags.has('showCover'), false); - 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 }, - }; + it('returns the data by reference when there are no image imports', () => { + const data = { title: 'Hello', nested: { count: 1 } }; const result = resolveEntryData( - { id: 'entry', data, filePath: FILE_NAME, assetImports: [] }, + { id: 'entry', data, filePath: FILE_NAME }, 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); + assert.equal(result, data); }); - it('resolves image references when assetImports is present', () => { - const data = { image: `${IMAGE_PREFIX}./hero.png` }; + it('resolves image references at their recorded paths', () => { + const data = { image: './hero.png' }; const result = resolveEntryData( - { id: 'entry', data, filePath: FILE_NAME, assetImports: ['./hero.png'] }, + { id: 'entry', data, filePath: FILE_NAME, imageImports: [['image']] }, makeImageMap('./hero.png', heroMeta), ); assert.deepEqual(result.image, heroMeta); - assert.equal(data.image, `${IMAGE_PREFIX}./hero.png`); + assert.equal(data.image, './hero.png'); }); }); diff --git a/packages/astro/test/units/content-collections/mutable-data-store.test.ts b/packages/astro/test/units/content-collections/mutable-data-store.test.ts index a15b2590aa3d..eef3efe004ca 100644 --- a/packages/astro/test/units/content-collections/mutable-data-store.test.ts +++ b/packages/astro/test/units/content-collections/mutable-data-store.test.ts @@ -150,4 +150,43 @@ describe('MutableDataStore', () => { 'key2 should be present in the written file (this will FAIL before the fix)', ); }); + + it('strips image prefixes and records their paths as out-of-band imageImports', () => { + const store = new MutableDataStore(); + const scoped = store.scopedStore('blog'); + const entryFilePath = 'src/content/blog/post.md'; + + scoped.set({ + id: 'post', + filePath: entryFilePath, + data: { + cover: '__ASTRO_IMAGE_./hero.png', + gallery: ['__ASTRO_IMAGE_./a.png'], + nested: { icon: '__ASTRO_IMAGE_./icon.png' }, + title: 'Hello', + }, + }); + + const entry = store.get('blog', 'post') as any; + + // The stored data holds plain, serializable src strings — no prefixes. + assert.equal(entry.data.cover, './hero.png'); + assert.equal(entry.data.gallery[0], './a.png'); + assert.equal(entry.data.nested.icon, './icon.png'); + assert.equal(entry.data.title, 'Hello'); + + // The image field locations are recorded out-of-band. + assert.deepEqual(entry.imageImports, [['cover'], ['gallery', 0], ['nested', 'icon']]); + assert.deepEqual(new Set(entry.assetImports), new Set(['./hero.png', './a.png', './icon.png'])); + }); + + it('does not set imageImports when the entry has no images', () => { + const store = new MutableDataStore(); + const scoped = store.scopedStore('blog'); + + scoped.set({ id: 'plain', data: { title: 'Hello' } }); + + const entry = store.get('blog', 'plain') as any; + assert.equal(entry.imageImports, undefined); + }); }); diff --git a/packages/internal-helpers/src/shiki.ts b/packages/internal-helpers/src/shiki.ts index 502b8d74a670..dd3e64dbddcb 100644 --- a/packages/internal-helpers/src/shiki.ts +++ b/packages/internal-helpers/src/shiki.ts @@ -173,7 +173,9 @@ async function createShikiHighlighterInternal({ const highlighter = await createHighlighter({ langs: ['plaintext', ...langs], - langAlias, + // Shallow-clone to prevent Shiki's Registry.loadLanguage() from mutating + // the caller's object when it registers built-in language aliases. + langAlias: { ...langAlias }, themes: Object.values(themes).length ? Object.values(themes) : [theme], engine: shikiEngine, }); diff --git a/packages/internal-helpers/test/shiki.test.ts b/packages/internal-helpers/test/shiki.test.ts new file mode 100644 index 000000000000..3312e9096ad5 --- /dev/null +++ b/packages/internal-helpers/test/shiki.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createShikiHighlighter } from '../dist/shiki.js'; + +describe('createShikiHighlighter', () => { + it('does not mutate the provided langAlias object', async () => { + const langAlias = {}; + const highlighter = await createShikiHighlighter({ langAlias }); + + // Highlight a JavaScript code block, which causes Shiki to register + // built-in aliases (js, cjs, mjs) for the "javascript" grammar. + await highlighter.codeToHtml('const x = 1;', 'javascript', {}); + + assert.deepStrictEqual( + langAlias, + {}, + 'langAlias should not be mutated by Shiki language loading', + ); + }); +});