From 273680a6020e1d8ae063c2a649c79ec7481bf8fe Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:22:22 +0000 Subject: [PATCH] [Performance] Memoize getPackageManager result This optimization memoizes the result of the `getPackageManager` function per directory. Since the package manager for a project is unlikely to change during the execution of a single CLI command, this significantly reduces redundant recursive filesystem lookups when the function is called multiple times for the same path. Expected impact: - Reduced filesystem I/O operations for package manager detection. - Faster execution in monorepos or scenarios where many packages are processed sequentially. - Measurable performance improvement in high-frequency call paths. Implemented with a module-level `Map` using normalized paths as keys. Added `_resetPackageManagerCache` for test isolation and verified with unit tests. --- .../public/node/node-package-manager.test.ts | 25 ++++++++- .../src/public/node/node-package-manager.ts | 55 +++++++++++++++---- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/packages/cli-kit/src/public/node/node-package-manager.test.ts b/packages/cli-kit/src/public/node/node-package-manager.test.ts index 2e5221a75a9..381607dd75c 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.test.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.test.ts @@ -23,9 +23,10 @@ import { PackageManager, npmLockfile, lockfilesByManager, + _resetPackageManagerCache, } from './node-package-manager.js' import {captureOutput, exec} from './system.js' -import {inTemporaryDirectory, mkdir, touchFile, writeFile} from './fs.js' +import {inTemporaryDirectory, mkdir, removeFile, touchFile, writeFile} from './fs.js' import {joinPath, dirname, normalizePath} from './path.js' import {inferPackageManagerForGlobalCLI} from './is-global.js' import {cacheClear} from '../../private/node/conf-store.js' @@ -852,6 +853,28 @@ describe('writePackageJSON', () => { }) describe('getPackageManager', () => { + afterEach(() => { + _resetPackageManagerCache() + }) + + test('memoizes the result', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + await writePackageJSON(tmpDir, {name: 'mock name'}) + await writeFile(joinPath(tmpDir, 'yarn.lock'), '') + + // When + const first = await getPackageManager(tmpDir) + // Remove the lockfile to ensure the second call doesn't find it if it's not memoized + await removeFile(joinPath(tmpDir, 'yarn.lock')) + const second = await getPackageManager(tmpDir) + + // Then + expect(first).toEqual('yarn') + expect(second).toEqual('yarn') + }) + }) + test('finds if npm is being used', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given — pin NPM in the temp project diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index c2335b46d9d..918aed6a75c 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -2,7 +2,7 @@ import {AbortError, BugError} from './error.js' import {AbortController, AbortSignal} from './abort.js' import {exec} from './system.js' import {fileExists, readFile, writeFile, findPathUp, glob, fileExistsSync} from './fs.js' -import {dirname, joinPath} from './path.js' +import {dirname, joinPath, normalizePath} from './path.js' import {runWithTimer} from './metadata.js' import {inferPackageManagerForGlobalCLI} from './is-global.js' import {outputToken, outputContent, outputDebug} from './output.js' @@ -141,6 +141,19 @@ function packageManagerBinaryCommand( } } +/** + * Memoized value for the package manager. + */ +let memoizedPackageManager: Map = new Map() + +/** + * Resets the memoized package manager cache. + * This is useful for unit tests. + */ +export function _resetPackageManagerCache(): void { + memoizedPackageManager = new Map() +} + /** * Returns the dependency manager used in a directory. * Walks upward from `fromDirectory` so workspace packages (e.g. `extensions/my-fn/package.json`) @@ -151,24 +164,46 @@ function packageManagerBinaryCommand( * @returns The dependency manager */ export async function getPackageManager(fromDirectory: string): Promise { + const normalizedPath = normalizePath(fromDirectory) + if (memoizedPackageManager.has(normalizedPath)) { + return memoizedPackageManager.get(normalizedPath)! + } + + let result: PackageManager = 'npm' let current = fromDirectory outputDebug(outputContent`Looking for a lockfile in ${outputToken.path(current)}...`) while (true) { - if (fileExistsSync(joinPath(current, yarnLockfile))) return 'yarn' + if (fileExistsSync(joinPath(current, yarnLockfile))) { + result = 'yarn' + break + } if (fileExistsSync(joinPath(current, pnpmLockfile)) || fileExistsSync(joinPath(current, pnpmWorkspaceFile))) { - return 'pnpm' + result = 'pnpm' + break + } + if (hasBunLockfileSync(current)) { + result = 'bun' + break + } + if (fileExistsSync(joinPath(current, npmLockfile))) { + result = 'npm' + break } - if (hasBunLockfileSync(current)) return 'bun' - if (fileExistsSync(joinPath(current, npmLockfile))) return 'npm' const parent = dirname(current) - if (parent === current) break + if (parent === current) { + const pm: PackageManager = packageManagerFromUserAgent() + if (pm === 'unknown') { + result = 'npm' + } else { + result = pm + } + break + } current = parent } - const pm: PackageManager = packageManagerFromUserAgent() - if (pm !== 'unknown') return pm - - return 'npm' + memoizedPackageManager.set(normalizedPath, result) + return result } /**