0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-06 22:10:10 -05:00

fix: wait for content layer sync before starting dev server (#12818)

* fix: wait for content layer sync before starting dev server

* Load config earlier

* Wait for fs flush

* Add delay

* Test utils fixes

* Ignore files in dotastro

* Wait for loading config

* Fix test

* Defer watching data store
This commit is contained in:
Matt Kane 2025-01-02 19:49:02 +00:00 committed by GitHub
parent 7b5dc6f0f1
commit 579bd93794
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 91 additions and 46 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fixes race condition where dev server would attempt to load collections before the content had loaded

View file

@ -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');

View file

@ -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();

View file

@ -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<ReturnType<ContentObservable['get']>>([
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;
}

View file

@ -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<ContentPaths, 'config' | 'contentDir'>,
paths: Pick<ContentPaths, 'config' | 'contentDir' | 'root'>,
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<AstroConfig, 'root' | 'srcDir' | 'legacy'>,
{ srcDir, legacy, root }: Pick<AstroConfig, 'root' | 'srcDir' | 'legacy'>,
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),

View file

@ -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);

View file

@ -84,6 +84,37 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS
}
}
let store: MutableDataStore | undefined;
try {
const dataStoreFile = getDataStoreFile(restart.container.settings, true);
if (existsSync(dataStoreFile)) {
store = await MutableDataStore.fromFile(dataStoreFile);
}
} catch (err: any) {
logger.error('content', err.message);
}
if (!store) {
store = new MutableDataStore();
}
await attachContentServerListeners(restart.container);
const config = globalContentConfigObserver.get();
if (config.status === 'error') {
logger.error('content', config.error.message);
}
if (config.status === 'loaded') {
const contentLayer = globalContentLayer.init({
settings: restart.container.settings,
logger,
watcher: restart.container.viteServer.watcher,
store,
});
contentLayer.watchContentConfig();
await contentLayer.sync();
} else {
logger.warn('content', 'Content config not loaded');
}
// Start listening to the port
const devServerAddressInfo = await startContainer(restart.container);
logger.info(
@ -103,35 +134,6 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS
logger.warn('SKIP_FORMAT', msg.fsStrictWarning());
}
await attachContentServerListeners(restart.container);
let store: MutableDataStore | undefined;
try {
const dataStoreFile = getDataStoreFile(restart.container.settings, true);
if (existsSync(dataStoreFile)) {
store = await MutableDataStore.fromFile(dataStoreFile);
}
} catch (err: any) {
logger.error('content', err.message);
}
if (!store) {
store = new MutableDataStore();
}
const config = globalContentConfigObserver.get();
if (config.status === 'error') {
logger.error('content', config.error.message);
}
if (config.status === 'loaded') {
const contentLayer = globalContentLayer.init({
settings: restart.container.settings,
logger,
watcher: restart.container.viteServer.watcher,
store,
});
contentLayer.watchContentConfig();
await contentLayer.sync();
}
logger.info(null, green('watching for file changes...'));

View file

@ -181,6 +181,7 @@ export async function loadFixture(inlineConfig) {
devServer = await dev(mergeConfig(inlineConfig, extraInlineConfig));
config.server.host = parseAddressToHost(devServer.address.address); // update host
config.server.port = devServer.address.port; // update port
await new Promise(resolve => 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;

View file

@ -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),
},
},
];