Repository: browserslist/browserslist (language: javascript)

Issue:
findConfig performance
Hi there! We love browserslist and the overall postcss stack, but we've recently run into some performace issues with `vite -> postcss -> autoprefixer -> browserslist`. We maintain a mid-sized svelte app with ~2K CSS files / components, and I've noticed browserslist takes up ~2.5s of the build time. Not a deal breaker, but I dislike waste, so I went to investigate. I know you take dev setup performance very seriously, so I thought I'd share my concerns.

<img width="1075" alt="Снимок экрана 2024-12-14 в 15 41 41" src="https://github.com/user-attachments/assets/80519c2f-1771-4712-9fb3-767d58ade28a" />

A sizable portion of this time (1s / 40%) is spent in [parsePackage.](https://github.com/browserslist/browserslist/blob/0639f5484b46b6c8608eab13a22310e9922feb96/node.js#L105) I can see why, as our UI library has a gigantic 16K-line / 1Mb `package.json`, which is read 250 times. Reading the root project's package 2K times adds another 170ms. We _could_ work around this by e.g. setting `process.env.BROWSERSLIST_CONFIG`, but it would be cool to avoid this problem by default.

So, first things, first. __Is reading browserslist configs living in `node_modules` intended?__ Personally, I wouldn't expect stray configs in published libraries to take precedence over the root config of the end project. 

Next, caching. I see findConfig [caches resolved config](https://github.com/browserslist/browserslist/blob/0639f5484b46b6c8608eab13a22310e9922feb96/node.js#L405) for all the directories between the css file and the resolved config. However, in our case the cache does not work:
1. In svelte + vite (and, possibly, other SFC frameworks like Vue), `file` points to a virtual module (e.g. `/Users/vklepov/Documents/project/src/Component.svelte?svelte&type=style&lang.css`), so `isFile` returns `false`, and unless we've hit the exact same component in the past, we go to [findConfigFile branch](https://github.com/browserslist/browserslist/blob/0639f5484b46b6c8608eab13a22310e9922feb96/node.js#L397)
2. `findConfigFile` is not aware of `configCache`, so we end up reading configs over and over again.

I don't see a simple fix for fileness check on virtual modules, but attaching `findConfigFile` to `configCache` should be quite easy, and greatly reduce FS operations — we'd only traverse `eachParent` until we hit a previously visited common ancestor directory.

Speeding up config resolution for our case would be very nice — would love to hear your opinion on this one! There are a few more places that could use extra caching, but I'll file them as separate issues.


Discussion / hints:
> I can see why, as our UI library has a gigantic 16K-line / 1Mb package.json

BTW, do you know that you can use `.browserslistrc` config file instead of `package.json`.

I think it is better the `BROWSERSLIST_CONFIG` because it is hard to sync all tools to use this env (easy to forget to set env var for some tool)

> Is reading browserslist configs living in node_modules intended?

No. But the idea is that we are looking the closest Browserslist config to the source file.

> isFile returns false

We can fix `isFile` for Vite case. PR is welcome.

In this case we need to see configs for path `/Users/vklepov/Documents/project/src/Component.svelte`

> findConfigFile is not aware of configCache

Can you make PR adding it?
> do you know that you can use .browserslistrc config file instead of package.json.

We do use `.browserslistrc`, but this has no effect, because we still read every `package.json` in parent directories until the config is located:

https://github.com/browserslist/browserslist/blob/0639f5484b46b6c8608eab13a22310e9922feb96/node.js#L348-L362

So, in the following setup:

```
node_modules
  lib
    package.json
    (100 x css)
package.json
.browserslistrc
```

For each css in `node_modules/lib` we must read both the lib's and the root project's `package.json`

> We can fix isFile for Vite case. PR is welcome.

I don't think it can be fised easily, because there is a) no file with this name, b) AFAIK, no invalid symbols that you normalize by, and c) no information on whether we're working with a virtual or a regular module

> Can you make PR adding it?

OK, I'll see what can be done =)
> no file with this name

Why? For `/Users/vklepov/Documents/project/src/Component.svelte?svelte&type=style&lang.css` you have `/Users/vklepov/Documents/project/src/Component.svelte` file on FS.

We can try to remove `?.*` component for instance by check:

```js
if (path.match(/\/\w+\.\w+?\w+=\w+(&\w+=\w+)*)$/) {
  let checkPath = path.replace(/?\w+=\w+(&\w+=\w+)*)$/, '')
  if (fs.existsSync(checkPath)) {
    // use checkPath as cache key
  }
}
```
Maybe we can add some fast `package.json` parser? Something like:

```js
if (packageJson.includes('"browserslist"')) {
  let browsers = JSON.parse(packageJson).browserslist
}
```
Anyways, I feel like we could avoid all these problems without excessive trickery by moving cache around, let me try a few things.
> Anyways, I feel like we could avoid all these problems without excessive trickery by moving cache around, let me try a few things.

But let’s not ignore `includes('"browserslist"')` hack. I think it is always a good idea (we need to think not only about your case but about general ways).
I added quick `package.json` check https://github.com/browserslist/browserslist/commit/5b11b68ce6064fa8d1f91173ea1d66ab01032a5e

Fix patch:
diff --git a/node.js b/node.js
index 2b575ad2..7299a773 100644
--- a/node.js
+++ b/node.js
@@ -13,8 +13,10 @@ var FORMAT =
   'of strings with browser queries'
 
 var dataTimeChecked = false
-var filenessCache = {}
-var configCache = {}
+var statCache = {}
+var configPathCache = {}
+var parseConfigCache = {}
+
 function checkExtend(name) {
   var use = ' Use `dangerousExtend` option to disable.'
   if (!CONFIG_PATTERN.test(name) && !SCOPED_CONFIG__PATTERN.test(name)) {
@@ -35,25 +37,43 @@ function checkExtend(name) {
 }
 
 function isFile(file) {
-  if (file in filenessCache) {
-    return filenessCache[file]
-  }
-  var result = fs.existsSync(file) && fs.statSync(file).isFile()
-  if (!process.env.BROWSERSLIST_DISABLE_CACHE) {
-    filenessCache[file] = result
-  }
-  return result
+  return fs.existsSync(file) && fs.statSync(file).isFile()
+}
+function isDirectory(dir) {
+  return fs.existsSync(dir) && fs.statSync(dir).isDirectory()
 }
 
-function eachParent(file, callback) {
-  var dir = isFile(file) ? path.dirname(file) : file
-  var loc = path.resolve(dir)
+function eachParent(file, callback, cache) {
+  var loc = path.resolve(file)
+  var pathsForCacheResult = []
+  var result
   do {
-    if (!pathInRoot(loc)) break
-    var result = callback(loc)
-    if (typeof result !== 'undefined') return result
+    if (!pathInRoot(loc)) {
+      break
+    }
+    if (cache && (loc in cache)) {
+      result = cache[loc]
+      break
+    }
+    pathsForCacheResult.push(loc)
+    
+    if (!isDirectory(loc)) {
+      continue
+    }
+    
+    var locResult = callback(loc)
+    if (typeof locResult !== 'undefined') {
+      result = locResult
+      break
+    }
   } while (loc !== (loc = path.dirname(loc)))
-  return undefined
+  
+  if (cache && !process.env.BROWSERSLIST_DISABLE_CACHE) {
+    pathsForCacheResult.forEach(function (cachePath) {
+      cache[cachePath] = result
+    })
+  }
+  return result
 }
 
 function pathInRoot(p) {
@@ -129,11 +149,17 @@ function parsePackage(file) {
 }
 
 function parsePackageOrReadConfig(file) {
-  if (path.basename(file) === 'package.json') {
-    return parsePackage(file)
-  } else {
-    return module.exports.readConfig(file)
+  if (file in parseConfigCache) {
+    return parseConfigCache[file]
   }
+  
+  var isPackage = path.basename(file) === 'package.json'
+  var result = isPackage ? parsePackage(file) : module.exports.readConfig(file)
+  
+  if (!process.env.BROWSERSLIST_DISABLE_CACHE) {
+    parseConfigCache[file] = result
+  }
+  return result
 }
 
 function latestReleaseTime(agents) {
@@ -240,7 +266,7 @@ module.exports = {
       stats = eachParent(opts.path, function (dir) {
         var file = path.join(dir, 'browserslist-stats.json')
         return isFile(file) ? file : undefined
-      })
+      }, statCache)
     }
     if (typeof stats === 'string') {
       try {
@@ -343,11 +369,12 @@ module.exports = {
     if (!isFile(file)) {
       throw new BrowserslistError("Can't read " + file + ' config')
     }
+
     return module.exports.parseConfig(fs.readFileSync(file))
   },
 
   findConfigFile: function findConfigFile(from) {
-    var resolved = eachParent(from, function (dir) {
+    return eachParent(from, function (dir) {
       var config = path.join(dir, 'browserslist')
       var pkg = path.join(dir, 'package.json')
       var rc = path.join(dir, '.browserslistrc')
@@ -383,41 +410,20 @@ module.exports = {
       } else if (pkgBrowserslist) {
         return pkg
       }
-    })
-
-    return resolved
+    }, configPathCache)
   },
 
   findConfig: function findConfig(from) {
-    from = path.resolve(from)
-
-    var fromDir = isFile(from) ? path.dirname(from) : from
-    if (fromDir in configCache) {
-      return configCache[fromDir]
-    }
-
-    var resolved
     var configFile = this.findConfigFile(from)
-    if (configFile) {
-      resolved = parsePackageOrReadConfig(configFile)
-    }
 
-    if (!process.env.BROWSERSLIST_DISABLE_CACHE) {
-      var configDir = configFile && path.dirname(configFile)
-      eachParent(from, function (dir) {
-        configCache[dir] = resolved
-        if (dir === configDir) {
-          return null
-        }
-      })
-    }
-    return resolved
+    return configFile ? parsePackageOrReadConfig(configFile) : undefined
   },
 
   clearCaches: function clearCaches() {
     dataTimeChecked = false
-    filenessCache = {}
-    configCache = {}
+    statCache = {}
+    configPathCache = {}
+    parseConfigCache = {}
 
     this.cache = {}
   },


Test patch: