perf: Cache package.json location between getNearestPackageJson invocations (#11580)

* perf: Cache package.json location between invocation

Cache package.json location to improve performance of migrations with a lot of files

Closes: #4136

* refactor: Use map. Move tests to appropriate files

Move tests and use Map instead of object as per review comments

* test: Check number of invocations in test

Change test to assert number of stat and readFile calls

* test: Change assert for CI

Added assertion to make both local and CI work

* Create package.json in test

* Create file only if not existed before

* test: Fix test assertion based on platform

* test: Change package.json type

* ci: Trigger tests

---------

Co-authored-by: Bartlomiej Rutkowski <brutkowski@tilt.app>
This commit is contained in:
Bartłomiej Rutkowski 2025-08-18 20:16:55 +02:00 committed by GitHub
parent 4d204adf56
commit b6ffd462dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 119 additions and 1 deletions

View File

@ -40,11 +40,39 @@ export async function importOrRequireFile(
return tryToRequire()
}
const packageJsonCache = new Map<string, object | null>()
const MAX_CACHE_SIZE = 1000
function setPackageJsonCache(paths: string[], packageJson: object | null) {
for (const path of paths) {
// Simple LRU-like behavior: if we're at capacity, remove oldest entry
if (
packageJsonCache.size >= MAX_CACHE_SIZE &&
!packageJsonCache.has(path)
) {
const firstKey = packageJsonCache.keys().next().value
if (firstKey) packageJsonCache.delete(firstKey)
}
packageJsonCache.set(path, packageJson)
}
}
async function getNearestPackageJson(filePath: string): Promise<object | null> {
let currentPath = filePath
const paths: string[] = []
while (currentPath !== path.dirname(currentPath)) {
currentPath = path.dirname(currentPath)
// Check if we have already cached the package.json for this path
if (packageJsonCache.has(currentPath)) {
setPackageJsonCache(paths, packageJsonCache.get(currentPath)!)
return packageJsonCache.get(currentPath)!
}
// Add the current path to the list of paths to cache
paths.push(currentPath)
const potentialPackageJson = path.join(currentPath, "package.json")
try {
@ -54,10 +82,15 @@ async function getNearestPackageJson(filePath: string): Promise<object | null> {
}
try {
return JSON.parse(
const parsedPackage = JSON.parse(
await fs.readFile(potentialPackageJson, "utf8"),
)
// Cache the parsed package.json object and return it
setPackageJsonCache(paths, parsedPackage)
return parsedPackage
} catch {
// If parsing fails, we still cache null to avoid repeated attempts
setPackageJsonCache(paths, null)
return null
}
} catch {
@ -66,5 +99,6 @@ async function getNearestPackageJson(filePath: string): Promise<object | null> {
}
// the top of the file tree is reached
setPackageJsonCache(paths, null)
return null
}

View File

@ -1,6 +1,9 @@
import { expect } from "chai"
import fs from "fs/promises"
import path from "path"
import { strict as assert } from "assert"
import sinon from "sinon"
import fsAsync from "fs"
import { importOrRequireFile } from "../../../src/util/ImportUtils"
@ -177,4 +180,85 @@ describe("ImportUtils.importOrRequireFile", () => {
await fs.rmdir(testDir, { recursive: true })
})
it("Should use cache to find package.json", async () => {
// Create package.json if not exists
const packageJsonPath = path.join(__dirname, "package.json")
const packageJsonAlreadyExisted = fsAsync.existsSync(packageJsonPath)
if (!packageJsonAlreadyExisted) {
await fs.writeFile(
packageJsonPath,
JSON.stringify({ name: "test-package" }),
"utf8",
)
}
const statSpy = sinon.spy(fsAsync.promises, "stat")
const readFileSpy = sinon.spy(fsAsync.promises, "readFile")
assert.equal(
statSpy.callCount,
0,
"stat should not be called before importOrRequireFile",
)
assert.equal(
readFileSpy.callCount,
0,
"readFile should not be called before importOrRequireFile",
)
const filePath1 = path.join(__dirname, "file1.js")
const filePath2 = path.join(__dirname, "file2.js")
const filePath3 = path.join(__dirname, "file3.js")
await fs.writeFile(filePath1, "", "utf8")
await fs.writeFile(filePath2, "", "utf8")
await fs.writeFile(filePath3, "", "utf8")
// Trigger the first import to create the cache
await importOrRequireFile(filePath1)
// Get the number of calls to stat and readFile after the first import
const numberOfStatCalls = statSpy.callCount
const numberOfReadFileCalls = readFileSpy.callCount
assert.equal(
numberOfStatCalls,
1,
"stat should be called for the first import",
)
assert.equal(
numberOfReadFileCalls,
1,
"readFile should be called for the first import",
)
// Trigger next imports to check if cache is used
await importOrRequireFile(filePath2)
await importOrRequireFile(filePath3)
assert.equal(
statSpy.callCount,
numberOfStatCalls,
"stat should be called only during the first import",
)
assert.equal(
readFileSpy.callCount,
numberOfReadFileCalls,
"readFile should be called only during the first import",
)
// Clean up test files
await fs.unlink(filePath1)
await fs.unlink(filePath2)
await fs.unlink(filePath3)
// If package.json was created by this test, remove it
if (!packageJsonAlreadyExisted) {
await fs.unlink(packageJsonPath)
}
sinon.restore()
})
})