diff --git a/core/package.json b/core/package.json index 0c25ce6fe1c..fa9f615cb4c 100644 --- a/core/package.json +++ b/core/package.json @@ -37,6 +37,7 @@ "acorn": "^8.8.1", "assert-never": "^1.2.1", "axios": "^1.2.0", + "exclusive-promises": "^1.0.3", "express": "^4.18.2", "fs-extra": "^11.1.0", "get-port": "^5", diff --git a/core/src/detect-components.ts b/core/src/detect-components.ts index 50eb323950c..3a5dde5adb3 100644 --- a/core/src/detect-components.ts +++ b/core/src/detect-components.ts @@ -1,5 +1,6 @@ import type { RPCs } from "@previewjs/api"; import type { TypeAnalyzer } from "@previewjs/type-analyzer"; +import { exclusivePromiseRunner } from "exclusive-promises"; import fs from "fs-extra"; import path from "path"; import type { FrameworkPlugin, Workspace } from "."; @@ -8,7 +9,11 @@ import { findFiles } from "./find-files"; type ProjectComponents = RPCs.DetectComponentsResponse["components"]; -export async function detectComponents( +// Prevent concurrent running of detectComponents() +// to avoid corrupting the cache and optimise for cache hits. +const oneAtATime = exclusivePromiseRunner(); + +export function detectComponents( workspace: Workspace, frameworkPlugin: FrameworkPlugin, typeAnalyzer: TypeAnalyzer, @@ -17,65 +22,69 @@ export async function detectComponents( forceRefresh?: boolean; } = {} ): Promise { - const cacheFilePath = path.join( - getCacheDir(workspace.rootDirPath), - "components.json" - ); - const absoluteFilePaths = options.filePaths - ? options.filePaths.map((filePath) => - path.join(workspace.rootDirPath, filePath) + return oneAtATime(async () => { + const cacheFilePath = path.join( + getCacheDir(workspace.rootDirPath), + "components.json" + ); + const absoluteFilePaths = options.filePaths + ? options.filePaths.map((filePath) => + path.join(workspace.rootDirPath, filePath) + ) + : await findFiles( + workspace.rootDirPath, + "**/*.@(js|jsx|ts|tsx|svelte|vue)" + ); + const filePathsSet = new Set( + absoluteFilePaths.map((absoluteFilePath) => + path + .relative(workspace.rootDirPath, absoluteFilePath) + .replace(/\\/g, "/") ) - : await findFiles( - workspace.rootDirPath, - "**/*.@(js|jsx|ts|tsx|svelte|vue)" - ); - const filePathsSet = new Set( - absoluteFilePaths.map((absoluteFilePath) => - path.relative(workspace.rootDirPath, absoluteFilePath).replace(/\\/g, "/") - ) - ); - const existingCacheLastModified = - !options.forceRefresh && fs.existsSync(cacheFilePath) - ? fs.statSync(cacheFilePath).mtimeMs - : 0; - const existingCache: ProjectComponents = existingCacheLastModified - ? JSON.parse(fs.readFileSync(cacheFilePath, "utf8")) - : {}; - const changedAbsoluteFilePaths = absoluteFilePaths.filter( - (absoluteFilePath) => { - const entry = workspace.reader.readSync(absoluteFilePath); - return ( - entry?.kind === "file" && - entry.lastModifiedMillis() > existingCacheLastModified - ); + ); + const existingCacheLastModified = + !options.forceRefresh && fs.existsSync(cacheFilePath) + ? fs.statSync(cacheFilePath).mtimeMs + : 0; + const existingCache: ProjectComponents = existingCacheLastModified + ? JSON.parse(fs.readFileSync(cacheFilePath, "utf8")) + : {}; + const changedAbsoluteFilePaths = absoluteFilePaths.filter( + (absoluteFilePath) => { + const entry = workspace.reader.readSync(absoluteFilePath); + return ( + entry?.kind === "file" && + entry.lastModifiedMillis() > existingCacheLastModified + ); + } + ); + const recycledComponents = Object.fromEntries( + Object.entries(existingCache).filter(([filePath]) => + filePathsSet.has(filePath) + ) + ); + const refreshedComponents = await detectComponentsCore( + workspace, + frameworkPlugin, + typeAnalyzer, + changedAbsoluteFilePaths + ); + const allComponents = { + ...recycledComponents, + ...refreshedComponents, + }; + const components = Object.keys(allComponents) + .sort() + .reduce((ordered, filePath) => { + ordered[filePath] = allComponents[filePath]!; + return ordered; + }, {}); + if (!options.filePaths) { + await fs.mkdirp(path.dirname(cacheFilePath)); + await fs.writeFile(cacheFilePath, JSON.stringify(components)); } - ); - const recycledComponents = Object.fromEntries( - Object.entries(existingCache).filter(([filePath]) => - filePathsSet.has(filePath) - ) - ); - const refreshedComponents = await detectComponentsCore( - workspace, - frameworkPlugin, - typeAnalyzer, - changedAbsoluteFilePaths - ); - const allComponents = { - ...recycledComponents, - ...refreshedComponents, - }; - const components = Object.keys(allComponents) - .sort() - .reduce((ordered, filePath) => { - ordered[filePath] = allComponents[filePath]!; - return ordered; - }, {}); - if (!options.filePaths) { - await fs.mkdirp(path.dirname(cacheFilePath)); - await fs.writeFile(cacheFilePath, JSON.stringify(components)); - } - return { components }; + return { components }; + }); } async function detectComponentsCore( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e0737665de..82a37c069db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,7 @@ importers: acorn: ^8.8.1 assert-never: ^1.2.1 axios: ^1.2.0 + exclusive-promises: ^1.0.3 express: ^4.18.2 fs-extra: ^11.1.0 get-port: ^5 @@ -185,6 +186,7 @@ importers: acorn: 8.8.1 assert-never: 1.2.1 axios: 1.2.0 + exclusive-promises: 1.0.3 express: 4.18.2 fs-extra: 11.1.0 get-port: 5.1.1 @@ -20121,7 +20123,6 @@ packages: /exclusive-promises/1.0.3: resolution: {integrity: sha512-z0UMcMYxVkvVgsFv1hSHHEs2mt+mTOhXNgZG5vQCdqtP7qGNwPfLwyAblqAgWErJ/kleMmSh7MhCin18ZPBNeQ==} - dev: true /exec-sh/0.3.6: resolution: {integrity: sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==}