diff --git a/.changeset/mighty-pugs-retire.md b/.changeset/mighty-pugs-retire.md new file mode 100644 index 0000000000..8520213a4d --- /dev/null +++ b/.changeset/mighty-pugs-retire.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes race condition where dev server would attempt to load collections before the content had loaded diff --git a/packages/astro/e2e/actions-blog.test.js b/packages/astro/e2e/actions-blog.test.js index 84981f078e..03e0c20575 100644 --- a/packages/astro/e2e/actions-blog.test.js +++ b/packages/astro/e2e/actions-blog.test.js @@ -15,15 +15,15 @@ test.afterAll(async () => { test.afterEach(async ({ astro }) => { // Force database reset between tests - await astro.editFile('./db/seed.ts', (original) => original); + await astro.editFile('./db/seed.ts', (original) => original, false); }); test.describe('Astro Actions - Blog', () => { test('Like action', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/blog/first-post/')); - const likeButton = page.getByLabel('Like'); await waitForHydrate(page, likeButton); + await new Promise(resolve => setTimeout(resolve, 500)) await expect(likeButton, 'like button starts with 10 likes').toContainText('10'); await likeButton.click(); await expect(likeButton, 'like button should increment likes').toContainText('11'); @@ -34,7 +34,6 @@ test.describe('Astro Actions - Blog', () => { const likeButton = page.getByLabel('get-request'); const likeCount = page.getByLabel('Like'); - await expect(likeCount, 'like button starts with 10 likes').toContainText('10'); await likeButton.click(); await expect(likeCount, 'like button should increment likes').toContainText('11'); diff --git a/packages/astro/e2e/actions-react-19.test.js b/packages/astro/e2e/actions-react-19.test.js index 3298db1e33..d0274e43f2 100644 --- a/packages/astro/e2e/actions-react-19.test.js +++ b/packages/astro/e2e/actions-react-19.test.js @@ -11,7 +11,7 @@ test.beforeAll(async ({ astro }) => { test.afterEach(async ({ astro }) => { // Force database reset between tests - await astro.editFile('./db/seed.ts', (original) => original); + await astro.editFile('./db/seed.ts', (original) => original, false); }); test.afterAll(async () => { @@ -21,9 +21,9 @@ test.afterAll(async () => { test.describe('Astro Actions - React 19', () => { test('Like action - client pending state', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/blog/first-post/')); - const likeButton = page.getByLabel('likes-client'); await waitForHydrate(page, likeButton); + await new Promise(resolve => setTimeout(resolve, 500)) await expect(likeButton).toBeVisible(); await likeButton.click(); diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 4e7d364841..f1cad4a348 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -16,9 +16,11 @@ import { import type { LoaderContext } from './loaders/types.js'; import type { MutableDataStore } from './mutable-data-store.js'; import { + type ContentObservable, getEntryConfigByExtMap, getEntryDataAndImages, globalContentConfigObserver, + reloadContentConfigObserver, safeStringify, } from './utils.js'; @@ -136,9 +138,27 @@ export class ContentLayer { } async #doSync(options: RefreshContentOptions) { - const contentConfig = globalContentConfigObserver.get(); + let contentConfig = globalContentConfigObserver.get(); const logger = this.#logger.forkIntegrationLogger('content'); + if (contentConfig?.status === 'loading') { + contentConfig = await Promise.race>([ + new Promise((resolve) => { + const unsub = globalContentConfigObserver.subscribe((ctx) => { + unsub(); + resolve(ctx); + }); + }), + new Promise((resolve) => + setTimeout( + () => + resolve({ status: 'error', error: new Error('Content config loading timed out') }), + 5000, + ), + ), + ]); + } + if (contentConfig?.status === 'error') { logger.error(`Error loading content config. Skipping sync.\n${contentConfig.error.message}`); return; @@ -146,7 +166,7 @@ export class ContentLayer { // It shows as loaded with no collections even if there's no config if (contentConfig?.status !== 'loaded') { - logger.error('Content config not loaded, skipping sync'); + logger.error(`Content config not loaded, skipping sync. Status was ${contentConfig?.status}`); return; } @@ -173,11 +193,11 @@ export class ContentLayer { shouldClear = true; } - if (currentConfigDigest && previousConfigDigest !== currentConfigDigest) { + if (previousConfigDigest && previousConfigDigest !== currentConfigDigest) { logger.info('Content config changed'); shouldClear = true; } - if (process.env.ASTRO_VERSION && previousAstroVersion !== process.env.ASTRO_VERSION) { + if (previousAstroVersion && previousAstroVersion !== process.env.ASTRO_VERSION) { logger.info('Astro version changed'); shouldClear = true; } diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 32d53f5db8..8da675c602 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -420,19 +420,28 @@ function getRelativeEntryPath(entry: URL, collection: string, contentDir: URL) { return relativeToCollection; } +function isParentDirectory(parent: URL, child: URL) { + const relative = path.relative(fileURLToPath(parent), fileURLToPath(child)); + return !relative.startsWith('..') && !path.isAbsolute(relative); +} + export function getEntryType( entryPath: string, - paths: Pick, + paths: Pick, contentFileExts: string[], dataFileExts: string[], ): 'content' | 'data' | 'config' | 'ignored' { const { ext } = path.parse(entryPath); const fileUrl = pathToFileURL(entryPath); + const dotAstroDir = new URL('./.astro/', paths.root); + if (fileUrl.href === paths.config.url.href) { return 'config'; } else if (hasUnderscoreBelowContentDirectoryPath(fileUrl, paths.contentDir)) { return 'ignored'; + } else if (isParentDirectory(dotAstroDir, fileUrl)) { + return 'ignored'; } else if (contentFileExts.includes(ext)) { return 'content'; } else if (dataFileExts.includes(ext)) { @@ -712,6 +721,7 @@ export function contentObservable(initialCtx: ContentCtx): ContentObservable { } export type ContentPaths = { + root: URL; contentDir: URL; assetsDir: URL; typesTemplate: URL; @@ -723,12 +733,13 @@ export type ContentPaths = { }; export function getContentPaths( - { srcDir, legacy }: Pick, + { srcDir, legacy, root }: Pick, fs: typeof fsMod = fsMod, ): ContentPaths { const configStats = search(fs, srcDir, legacy?.collections); const pkgBase = new URL('../../', import.meta.url); return { + root: new URL('./', root), contentDir: new URL('./content/', srcDir), assetsDir: new URL('./assets/', srcDir), typesTemplate: new URL('templates/content/types.d.ts', pkgBase), diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts index 30c703b5c2..e0468c654f 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { dataToEsm } from '@rollup/pluginutils'; import glob from 'fast-glob'; import pLimit from 'p-limit'; -import type { Plugin } from 'vite'; +import type { Plugin, ViteDevServer } from 'vite'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { rootRelativePath } from '../core/viteUtils.js'; import type { AstroSettings } from '../types/astro.js'; @@ -51,12 +51,17 @@ export function astroContentVirtualModPlugin({ fs, }: AstroContentVirtualModPluginParams): Plugin { let dataStoreFile: URL; + let devServer: ViteDevServer; return { name: 'astro-content-virtual-mod-plugin', enforce: 'pre', config(_, env) { dataStoreFile = getDataStoreFile(settings, env.command === 'serve'); }, + buildStart() { + // We defer adding the data store file to the watcher until the server is ready + devServer?.watcher.add(fileURLToPath(dataStoreFile)); + }, async resolveId(id) { if (id === VIRTUAL_MODULE_ID) { return RESOLVED_VIRTUAL_MODULE_ID; @@ -155,10 +160,10 @@ export function astroContentVirtualModPlugin({ return fs.readFileSync(modules, 'utf-8'); } }, - configureServer(server) { - const dataStorePath = fileURLToPath(dataStoreFile); - server.watcher.add(dataStorePath); + configureServer(server) { + devServer = server; + const dataStorePath = fileURLToPath(dataStoreFile); function invalidateDataStore() { const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID); diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts index 0af6371647..03cd8ae391 100644 --- a/packages/astro/src/core/dev/dev.ts +++ b/packages/astro/src/core/dev/dev.ts @@ -84,6 +84,37 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise setTimeout(resolve, 100)) return devServer; }, onNextDataStoreChange: (timeout = 5000) => { @@ -284,7 +285,7 @@ export async function loadFixture(inlineConfig) { app.manifest = manifest; return app; }, - editFile: async (filePath, newContentsOrCallback) => { + editFile: async (filePath, newContentsOrCallback, waitForNextWrite = true) => { const fileUrl = new URL(filePath.replace(/^\//, ''), config.root); const contents = await fs.promises.readFile(fileUrl, 'utf-8'); const reset = () => { @@ -299,7 +300,7 @@ export async function loadFixture(inlineConfig) { typeof newContentsOrCallback === 'function' ? newContentsOrCallback(contents) : newContentsOrCallback; - const nextChange = devServer ? onNextChange() : Promise.resolve(); + const nextChange = devServer && waitForNextWrite ? onNextChange() : Promise.resolve(); await fs.promises.writeFile(fileUrl, newContents); await nextChange; return reset; diff --git a/packages/astro/test/units/content-collections/get-entry-type.test.js b/packages/astro/test/units/content-collections/get-entry-type.test.js index 9d60c4c5c0..d8dcb459b8 100644 --- a/packages/astro/test/units/content-collections/get-entry-type.test.js +++ b/packages/astro/test/units/content-collections/get-entry-type.test.js @@ -12,6 +12,7 @@ const fixtures = [ exists: true, }, contentDir: new URL('src/content/', import.meta.url), + root: new URL('.', import.meta.url), }, }, { @@ -22,6 +23,7 @@ const fixtures = [ exists: true, }, contentDir: new URL('_src/content/', import.meta.url), + root: new URL('.', import.meta.url), }, }, ];