Self-service
Describe the bug
Yarn's builtin TypeScript compat patch (builtin<compat/typescript>) sets isInNodeModules = true for cross-workspace module paths in getAllModulePathsWorker (in src/compiler/moduleSpecifiers.ts). This is correct for PnP resolution semantics, but downstream TypeScript code in moduleSpecifierCache.ts assumes that isInNodeModules === true implies the path literally contains the string /node_modules/. Since PnP workspace paths don't contain /node_modules/, an unchecked indexOf("/node_modules/") returns -1, and the resulting arithmetic always produces substring(0, 12) — the first 12 characters of the path. If those 12 characters happen to form a valid directory, TypeScript creates a recursive directory watcher on it, causing VS Code to hang.
The watched path is always the first 12 characters of the resolved module path. This is not limited to $HOME — it affects any project whose absolute path starts with a valid 12-character directory prefix. See Path examples below.
This was first reported to the TypeScript team (microsoft/TypeScript#63373), who confirmed this is outside their scope since the isInNodeModules invariant is broken by Yarn's compat patch — upstream TypeScript guarantees isInNodeModules === true only for paths that literally contain /node_modules/.
To reproduce
A minimal reproduction repo is available: https://github.com/sebaspf/yarnpkg-berry-bug
Setup
- Yarn PnP monorepo with two workspaces:
shared (library) and app (consumer)
shared builds to dist/ with declaration: true
app depends on shared: "workspace:^" and imports types/values from it
- VS Code settings include
"source.addMissingImports": "always" in editor.codeActionsOnSave
- VS Code uses the Yarn PnP TypeScript SDK (
.yarn/sdks/typescript/lib)
Steps
- Clone the project (main branch) to a location matching the Path examples
- Ensure in the path are a large number of files.
- run
yarn install && yarn workspace shared build
- Open the project in VS Code
- Open
app/src/index.ts (which has an unresolved symbol exported by shared) and edit.
- Save the file —
source.addMissingImports triggers importFixes → getModuleSpecifiersWithCacheInfo
- VS Code GUI hangs as tsserver recursively watches the directory produced by
substring(0, 12) of the project path (for me $HOME)
The trigger is not limited to source.addMissingImports. Any operation that calls getModuleSpecifiersWithCacheInfo will trigger the bug, including auto-import completions (typing and accepting an auto-import suggestion for a symbol from another workspace).
Root Cause Analysis
The PnP compat patch
In packages/plugin-compat/extra/typescript/, the compat patch modifies getAllModulePathsWorker in src/compiler/moduleSpecifiers.ts:
// Original TypeScript:
let isInNodeModules = pathContainsNodeModules(path);
// ↑ Only true when path literally contains "/node_modules/"
// After Yarn's compat patch:
let isInNodeModules = pathContainsNodeModules(path);
const pnpapi = getPnpApi(path);
if (!isInNodeModules && pnpapi) {
const fromLocator = pnpapi.findPackageLocator(info.importingSourceFileName);
const toLocator = pnpapi.findPackageLocator(path);
if (fromLocator && toLocator && fromLocator !== toLocator) {
isInNodeModules = true; // ← Set for cross-workspace refs WITHOUT /node_modules/ in path
}
}
For workspace cross-references (e.g., app → shared), findPackageLocator returns different locators, so isInNodeModules becomes true even though the path is something like:
/home/alice/projects/my-project/shared/dist/index.d.ts
which contains no /node_modules/ segment.
The downstream bug in TypeScript
In src/server/moduleSpecifierCache.ts, the set() method trusts isInNodeModules and uses indexOf("/node_modules/") without checking for -1:
if (p.isInNodeModules) {
const nodeModulesPath = p.path.substring(
0,
p.path.indexOf(nodeModulesPathPart) + nodeModulesPathPart.length - 1
// ↑ Returns -1 for PnP workspace paths!
);
host.watchNodeModulesForPackageJsonChanges(nodeModulesPath);
// ↑ Creates a RECURSIVE directory watcher on the computed path
}
When indexOf returns -1, the arithmetic always reduces to substring(0, 12):
substring(0, -1 + "/node_modules/".length - 1)
= substring(0, -1 + 14 - 1)
= substring(0, 12)
This means the first 12 characters of the module's resolved path are extracted and used as a directory to watch recursively. The result depends entirely on the project's absolute path.
Path examples
Note: I have only tested it on Linux
The bug is not specific to any particular directory — it triggers whenever substring(0, 12) produces a valid path that contains many files:
| Project path |
Module path |
substring(0,12) |
Watched dir |
Impact |
/home/alice/projects/my-app/... |
/home/alice/projects/my-app/shared/dist/index.d.ts |
/home/alice/ |
User's home directory |
Severe — watches all of $HOME |
/home/a/projects/my-app/... |
/home/a/proj... |
/home/a/proj |
Likely invalid path |
No impact (path doesn't exist) |
/Users/alice/dev/my-app/... |
/Users/alice... |
/Users/alice |
macOS home directory |
Severe — watches all of $HOME |
/opt/projects/my-app/... |
/opt/project... |
/opt/project |
Likely invalid |
No impact |
/workspace/a/my-app/... |
/workspace/a... |
/workspace/a |
Container workspace |
Moderate — depends on contents |
/tmp/dev/proj/my-app/... |
/tmp/dev/pro... |
/tmp/dev/pro |
Likely invalid |
No impact |
/var/www/html/my-app/... |
/var/www/htm... |
/var/www/htm |
Likely invalid |
No impact |
C:\Users\bob\projects\... |
C:\Users\bob... |
C:\Users\bob\ |
Windows home directory |
Severe — watches all of %USERPROFILE% |
Key insight: the severity depends on whether the first 12 characters of the project's absolute path happen to form a valid directory containing many files. Users whose username length causes $HOME to be exactly 12 characters (e.g., /home/alice/, /Users/alice/) are most affected, but the problem can manifest anywhere where the 12-character prefix resolves to a large directory.
Even when substring(0, 12) doesn't produce a valid path (and the watcher silently fails), the bug still wastes resources attempting to set up the watch and may produce confusing error logs.
There is a second instance of the same bug
In src/compiler/moduleNameResolver.ts, readPackageJsonPeerDependencies():
const nodeModules = packageDirectory.substring(
0,
packageDirectory.lastIndexOf("node_modules") + "node_modules".length
) + directorySeparator;
Same pattern: lastIndexOf returns -1, producing substring(0, -1 + 12) + "/" = substring(0, 11) + "/" — the first 11 characters of the path plus a /. This truncated prefix is then used to resolve peer dependency package.json files from an incorrect directory.
Suggested Fix
The fix can be applied in Yarn's compat patch by guarding the indexOf result before using it. This is a one-line guard in each location:
Fix for Bug 1 (moduleSpecifierCache.ts:42):
if (p.isInNodeModules) {
const nodeModulesIndex = p.path.indexOf(nodeModulesPathPart);
if (nodeModulesIndex !== -1) { // ← Guard: skip if no /node_modules/ in path
const nodeModulesPath = p.path.substring(0, nodeModulesIndex + nodeModulesPathPart.length - 1);
const key = host.toPath(nodeModulesPath);
if (!containedNodeModulesWatchers?.has(key)) {
(containedNodeModulesWatchers ||= new Map()).set(
key,
host.watchNodeModulesForPackageJsonChanges(nodeModulesPath),
);
}
}
}
Fix for Bug 2 (moduleNameResolver.ts:2426):
const nodeModulesIdx = packageDirectory.lastIndexOf("node_modules");
if (nodeModulesIdx === -1) return undefined; // ← Guard: no node_modules in path
const nodeModules = packageDirectory.substring(0, nodeModulesIdx + "node_modules".length) + directorySeparator;
Skipping the watcher when the path has no /node_modules/ is safe: workspace packages are already tracked by normal project file watchers, and PnP zip-cached packages (from .yarn/cache/) do have /node_modules/ in their extracted paths, so they continue to be watched correctly.
Alternative: fix the isInNodeModules semantics
Instead of patching downstream consumers, the compat patch could be modified to not set isInNodeModules = true for workspace packages. However, this may affect other TypeScript behavior that relies on the isInNodeModules flag for cross-package import resolution. The guard approach is safer and more surgical.
Environment
System:
OS: Linux 6.6 Ubuntu 24.04.3 LTS 24.04.3 LTS (Noble Numbat)
CPU: (12) x64 Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
Binaries:
Node: 24.11.0 - /tmp/xfs-02dd9ed6/node
Yarn: 4.12.0 - /tmp/xfs-02dd9ed6/yarn
npm: 11.8.0
- **TypeScript**: 5.9.3 (also confirmed on 5.8.3 and 6.0.2)
- **VS Code**: with Yarn PnP SDK (`@yarnpkg/sdks vscode`)
Additional context
Workaround
Apply a yarn patch to TypeScript that adds the -1 guards. A patch file is included in the reproduction repo under patches/.
Alternatively, open the project from a path whose first 12 characters do not form a valid directory:
ln -s /home/alice/projects/my-project /tmp/my-project
cd /tmp/my-project && code .
# substring(0, 12) → "/tmp/my-proj" — not a valid directory
Self-service
Describe the bug
Yarn's builtin TypeScript compat patch (
builtin<compat/typescript>) setsisInNodeModules = truefor cross-workspace module paths ingetAllModulePathsWorker(insrc/compiler/moduleSpecifiers.ts). This is correct for PnP resolution semantics, but downstream TypeScript code inmoduleSpecifierCache.tsassumes thatisInNodeModules === trueimplies the path literally contains the string/node_modules/. Since PnP workspace paths don't contain/node_modules/, an uncheckedindexOf("/node_modules/")returns-1, and the resulting arithmetic always producessubstring(0, 12)— the first 12 characters of the path. If those 12 characters happen to form a valid directory, TypeScript creates a recursive directory watcher on it, causing VS Code to hang.The watched path is always the first 12 characters of the resolved module path. This is not limited to
$HOME— it affects any project whose absolute path starts with a valid 12-character directory prefix. See Path examples below.This was first reported to the TypeScript team (microsoft/TypeScript#63373), who confirmed this is outside their scope since the
isInNodeModulesinvariant is broken by Yarn's compat patch — upstream TypeScript guaranteesisInNodeModules === trueonly for paths that literally contain/node_modules/.To reproduce
A minimal reproduction repo is available: https://github.com/sebaspf/yarnpkg-berry-bug
Setup
shared(library) andapp(consumer)sharedbuilds todist/withdeclaration: trueappdepends onshared: "workspace:^"and imports types/values from it"source.addMissingImports": "always"ineditor.codeActionsOnSave.yarn/sdks/typescript/lib)Steps
yarn install && yarn workspace shared buildapp/src/index.ts(which has an unresolved symbol exported byshared) and edit.source.addMissingImportstriggersimportFixes→getModuleSpecifiersWithCacheInfosubstring(0, 12)of the project path (for me$HOME)The trigger is not limited to
source.addMissingImports. Any operation that callsgetModuleSpecifiersWithCacheInfowill trigger the bug, including auto-import completions (typing and accepting an auto-import suggestion for a symbol from another workspace).Root Cause Analysis
The PnP compat patch
In
packages/plugin-compat/extra/typescript/, the compat patch modifiesgetAllModulePathsWorkerinsrc/compiler/moduleSpecifiers.ts:For workspace cross-references (e.g.,
app→shared),findPackageLocatorreturns different locators, soisInNodeModulesbecomestrueeven though the path is something like:which contains no
/node_modules/segment.The downstream bug in TypeScript
In
src/server/moduleSpecifierCache.ts, theset()method trustsisInNodeModulesand usesindexOf("/node_modules/")without checking for-1:When
indexOfreturns-1, the arithmetic always reduces tosubstring(0, 12):This means the first 12 characters of the module's resolved path are extracted and used as a directory to watch recursively. The result depends entirely on the project's absolute path.
Path examples
Note: I have only tested it on Linux
The bug is not specific to any particular directory — it triggers whenever
substring(0, 12)produces a valid path that contains many files:substring(0,12)/home/alice/projects/my-app/.../home/alice/projects/my-app/shared/dist/index.d.ts/home/alice/$HOME/home/a/projects/my-app/.../home/a/proj.../home/a/proj/Users/alice/dev/my-app/.../Users/alice.../Users/alice$HOME/opt/projects/my-app/.../opt/project.../opt/project/workspace/a/my-app/.../workspace/a.../workspace/a/tmp/dev/proj/my-app/.../tmp/dev/pro.../tmp/dev/pro/var/www/html/my-app/.../var/www/htm.../var/www/htmC:\Users\bob\projects\...C:\Users\bob...C:\Users\bob\%USERPROFILE%Key insight: the severity depends on whether the first 12 characters of the project's absolute path happen to form a valid directory containing many files. Users whose username length causes
$HOMEto be exactly 12 characters (e.g.,/home/alice/,/Users/alice/) are most affected, but the problem can manifest anywhere where the 12-character prefix resolves to a large directory.Even when
substring(0, 12)doesn't produce a valid path (and the watcher silently fails), the bug still wastes resources attempting to set up the watch and may produce confusing error logs.There is a second instance of the same bug
In
src/compiler/moduleNameResolver.ts,readPackageJsonPeerDependencies():Same pattern:
lastIndexOfreturns-1, producingsubstring(0, -1 + 12) + "/"=substring(0, 11) + "/"— the first 11 characters of the path plus a/. This truncated prefix is then used to resolve peer dependencypackage.jsonfiles from an incorrect directory.Suggested Fix
The fix can be applied in Yarn's compat patch by guarding the
indexOfresult before using it. This is a one-line guard in each location:Fix for Bug 1 (
moduleSpecifierCache.ts:42):Fix for Bug 2 (
moduleNameResolver.ts:2426):Skipping the watcher when the path has no
/node_modules/is safe: workspace packages are already tracked by normal project file watchers, and PnP zip-cached packages (from.yarn/cache/) do have/node_modules/in their extracted paths, so they continue to be watched correctly.Alternative: fix the
isInNodeModulessemanticsInstead of patching downstream consumers, the compat patch could be modified to not set
isInNodeModules = truefor workspace packages. However, this may affect other TypeScript behavior that relies on theisInNodeModulesflag for cross-package import resolution. The guard approach is safer and more surgical.Environment
System: OS: Linux 6.6 Ubuntu 24.04.3 LTS 24.04.3 LTS (Noble Numbat) CPU: (12) x64 Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz Binaries: Node: 24.11.0 - /tmp/xfs-02dd9ed6/node Yarn: 4.12.0 - /tmp/xfs-02dd9ed6/yarn npm: 11.8.0 - **TypeScript**: 5.9.3 (also confirmed on 5.8.3 and 6.0.2) - **VS Code**: with Yarn PnP SDK (`@yarnpkg/sdks vscode`)Additional context
Workaround
Apply a
yarn patchto TypeScript that adds the-1guards. A patch file is included in the reproduction repo underpatches/.Alternatively, open the project from a path whose first 12 characters do not form a valid directory: