From cbba4de17ae0a13b6b27d426045d0191680e96a6 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sat, 15 Aug 2026 23:55:08 +0000 Subject: [PATCH] module: cache nearest parent package.json per directory getNearestParentPackageJSON() memoized its answer per file, so every module loaded still made one native call, and TraverseParent() builds several std::filesystem::path temporaries per directory level and serializes the whole package.json, which the JS side then usually discarded because it already had that package.json deserialized. The native traversal starts at the directory of the given path, so the answer only depends on that directory: key the memo by it (following NormalizePath()'s trailing-separator rule), so that all modules in a directory share one native call. When the permission model is enabled the traversal also depends on the read permissions in effect at call time, so that configuration keeps the per-file cache. Loading a 1000-module tree spread over ~240 directories goes from 1000 to 236 native calls. Signed-off-by: Shelley Vohr --- lib/internal/modules/package_json_reader.js | 68 +++++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/lib/internal/modules/package_json_reader.js b/lib/internal/modules/package_json_reader.js index 6c6bf0383bc3..c33cf166f53b 100644 --- a/lib/internal/modules/package_json_reader.js +++ b/lib/internal/modules/package_json_reader.js @@ -23,7 +23,8 @@ const { ERR_MODULE_NOT_FOUND, }, } = require('internal/errors'); -const { kEmptyObject } = require('internal/util'); +const { kEmptyObject, isWindows } = require('internal/util'); +const permission = require('internal/process/permission'); const modulesBinding = internalBinding('modules'); const path = require('path'); const { validateString } = require('internal/validators'); @@ -140,10 +141,18 @@ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) { } /** - * A cache mapping a module's path to its parent `package.json` file's path. - * This is used in concert with `deserializedPackageJSONCache` to improve - * the performance of `getNearestParentPackageJSON` when called repeatedly - * on the same module paths. + * A cache mapping a directory to the path of the nearest `package.json` at or + * above it (`null` when there is none). The native traversal for a module + * starts at the module's directory, so every module in a directory shares one + * entry and one native call. Used in concert with + * `deserializedPackageJSONCache`. + */ +const directoryToParentPackageJSONPathCache = new SafeMap(); + +/** + * When the permission model is enabled the native traversal also depends on + * the read permissions in effect at the time of the call, so results are only + * remembered per exact module path, as before. */ const moduleToParentPackageJSONCache = new SafeMap(); @@ -158,6 +167,21 @@ const moduleToParentPackageJSONCache = new SafeMap(); */ const deserializedPackageJSONCache = new SafeMap(); +/** + * The directory the native nearest-parent traversal starts from for `checkPath` + * (see BindingData::NormalizePath/TraverseParent): the path itself when it has + * a trailing separator, its dirname otherwise. + * @param {string} checkPath + * @returns {string} + */ +function getTraversalStartDirectory(checkPath) { + const last = checkPath[checkPath.length - 1]; + if (last === '/' || (isWindows && last === '\\')) { + return StringPrototypeSlice(checkPath, 0, -1); + } + return path.dirname(checkPath); +} + /** * Get the nearest parent package.json file from a given path. * Return the package.json data and the path to the package.json file, or undefined. @@ -165,23 +189,29 @@ const deserializedPackageJSONCache = new SafeMap(); * @returns {undefined | DeserializedPackageConfig} */ function getNearestParentPackageJSON(checkPath) { - const parentPackageJSONPath = moduleToParentPackageJSONCache.get(checkPath); - if (parentPackageJSONPath !== undefined) { - return deserializedPackageJSONCache.get(parentPackageJSONPath); + const permissionEnabled = permission.isEnabled(); + const cache = permissionEnabled ? moduleToParentPackageJSONCache : directoryToParentPackageJSONPathCache; + const key = permissionEnabled ? checkPath : getTraversalStartDirectory(checkPath); + let parentPackageJSONPath = cache.get(key); + if (parentPackageJSONPath === undefined) { + const result = modulesBinding.getNearestParentPackageJSON(checkPath); + if (result === undefined) { + parentPackageJSONPath = null; + } else { + const packageConfig = deserializePackageJSON(checkPath, result); + parentPackageJSONPath = packageConfig.path; + if (!deserializedPackageJSONCache.has(parentPackageJSONPath)) { + deserializedPackageJSONCache.set(parentPackageJSONPath, packageConfig); + } + } + cache.set(key, parentPackageJSONPath); } - const result = modulesBinding.getNearestParentPackageJSON(checkPath); - const packageConfig = deserializePackageJSON(checkPath, result); - - moduleToParentPackageJSONCache.set(checkPath, packageConfig.path); - - const maybeCachedPackageConfig = deserializedPackageJSONCache.get(packageConfig.path); - if (maybeCachedPackageConfig !== undefined) { - return maybeCachedPackageConfig; + if (parentPackageJSONPath === null) { + // No package.json above this path: same shape as before, carrying the queried path. + return deserializePackageJSON(checkPath, undefined); } - - deserializedPackageJSONCache.set(packageConfig.path, packageConfig); - return packageConfig; + return deserializedPackageJSONCache.get(parentPackageJSONPath); } /**