-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Description
Summary
After PR #9380 (v26.3.1) rewrote resolvePackageDir for ESM support and PR #9392 (v26.3.2) added resolveActualPath for pnpm hoisted mode, aliased dependencies (where the dependency key in package.json differs from the actual package name) still fail to resolve correctly, resulting in "Failed to read package.json" errors and missing dependencies in the final build.
Update: This issue persists in v26.7.0 (latest release). See the update section at the bottom.
Environment
- electron-builder: 26.3.0 (works), 26.3.1+ (broken), 26.3.2+ (partially fixed but aliased deps still broken), 26.7.0 (latest, still broken)
- pnpm: 10.x
- node-linker:
hoisted(configured in.npmrc) - Node: 22.x
- OS: Windows 11
Reproduction
- Create a project with pnpm and
node-linker=hoistedin.npmrc - Add aliased dependencies to
package.json:
{
"dependencies": {
"my-native-addon": "https://example.com/releases/native-addon-1.0.0-win64.tar.gz",
"another-addon": "https://example.com/releases/another-1.0.0-win64.tar.gz"
}
}- The tarballs contain
package.jsonfiles with different names:my-native-addontarball contains"name": "@vendor/native-addon"another-addontarball contains"name": "real-addon-name"
- Run
pnpm install && electron-builder build
Observed Behavior
In hoisted mode, pnpm installs these packages at:
node_modules/my-native-addon/(by dependency key/alias)node_modules/another-addon/(by dependency key/alias)
But pnpm list --json reports:
{
"my-native-addon": {
"from": "@vendor/native-addon",
"version": "1.0.0",
"path": "node_modules/.pnpm/@vendor+native-addon_xxx/node_modules/@vendor/native-addon"
},
"another-addon": {
"from": "real-addon-name",
"version": "1.0.0",
"path": "node_modules/.pnpm/real-addon-name@https++_xxx/node_modules/real-addon-name"
}
}The .pnpm paths do NOT exist on disk (they are virtual). The actual packages are at the dependency key locations.
During packaging, electron-builder logs:
• Failed to read package.json for node_modules\.pnpm\@vendor+native-addon_xxx\node_modules\@vendor\native-addon: Cannot find module
• Failed to read package.json for node_modules\.pnpm\real-addon-name@https++_xxx\node_modules\real-addon-name: Cannot find module
• cannot find path for dependency name=@vendor/native-addon reference=1.0.0
• cannot find path for dependency name=real-addon-name reference=1.0.0
Result: These dependencies are excluded from the build. Installer size significantly reduced due to missing native modules.
Secondary Issue: Silent Failures
The "Failed to read package.json" errors are logged as warnings (log.warn) rather than errors, allowing the build to continue and produce a broken artifact. This makes the problem difficult to detect:
- The build completes successfully with exit code 0
- The installer is created, but with critical dependencies missing
- The only indication of a problem is:
- Warning messages that are easy to miss in build logs
- Dramatically reduced installer size (e.g., 434MB → 212MB)
- Application crashes at runtime when trying to use the missing modules
This silent failure mode delays problem detection until runtime testing or production deployment, making it a particularly dangerous regression. The build should fail fast when critical dependencies cannot be resolved.
Root Cause (v26.3.2)
In pnpmNodeModulesCollector.ts (v26.3.2), the resolveActualPath method uses depTree.from to construct the hoisted path:
private async resolveActualPath(depTree: PnpmDependency): Promise<string> {
if (await this.isHoisted.value) {
const packageName = depTree.name || depTree.from // <-- ISSUE
if (packageName) {
const hoistedPath = path.join(this.rootDir, "node_modules", packageName)
if (await this.existsMemoized(hoistedPath)) {
return hoistedPath
}
}
}
return depTree.path // Falls back to non-existent .pnpm path
}For aliased packages:
depTree.nameisundefineddepTree.fromis the real package name (@vendor/native-addon,real-addon-name)packageName=depTree.fromhoistedPath=node_modules/@vendor/native-addon(does NOT exist)- Actual location =
node_modules/my-native-addon(the alias/key)
The method returns the .pnpm virtual path, which also does not exist, causing the "Cannot find module" error.
When getProductionDependencies fails to read package.json, it returns { path: p, prodDeps: {}, optionalDependencies: {} } (v26.3.2+) instead of throwing an error. This empty prodDeps causes all sub-dependencies to be filtered out, resulting in incomplete builds.
Additionally, collectAllDependencies stores dependencies by iteration key:
for (const [key, value] of Object.entries(tree.dependencies || {})) {
this.allDependencies.set(`${key}@${value.version}`, { ...value, path: json.path })
}But packageVersionString uses from:
packageVersionString(pkg) {
return `${pkg.from}@${pkg.version}`
}This creates a mismatch: allDependencies has "my-native-addon@1.0.0", but _getNodeModules looks up "@vendor/native-addon@1.0.0", failing to find it.
Update: Status in v26.7.0 (Latest Release)
The latest release (v26.7.0) has partially addressed the issue but the core problem with aliased dependencies remains unfixed.
What Changed in v26.7.0
-
Improved
collectAllDependencies: Now uses the iteration key (alias name) instead offrom:for (const [key, value] of Object.entries(tree.dependencies || {})) { const pkg = await this.cache.locatePackageVersion({ pkgName: key, parentDir: this.rootDir, requiredRange: value.version }) this.allDependencies.set(`${key}@${value.version}`, { ...value, path: pkg?.packageDir ?? value.path }) }
This correctly stores aliased packages as
"my-native-addon@1.0.0"inallDependencies. -
New
locatePackageVersionmethod: Replaces the problematicresolveActualPath. This method searches:- Direct
node_modules/pkgName(would findmy-native-addon) - Upward hoisted search
- Downward non-hoisted search
- Direct
What Still Fails in v26.7.0
The packageVersionString method still uses from (real package name):
protected packageVersionString(pkg: PnpmDependency): string {
// we use 'from' field because 'name' may be different in case of aliases
return `${pkg.from}@${pkg.version}`
}This creates a key mismatch:
allDependencieshas:"my-native-addon@1.0.0"(alias name)productionGraphuses:"@vendor/native-addon@1.0.0"(real name frompackageVersionString)_getNodeModuleslooks up:"@vendor/native-addon@1.0.0"→ Not found!
Result in _getNodeModules:
const p = this.allDependencies.get(`${d.name}@${reference}`)?.path
if (p === undefined) {
log.warn({ name: d.name, reference }, "cannot find path for dependency")
continue
}The lookup fails and logs "cannot find path for dependency" (still a warning, not an error).
Secondary Issue: Silent Failures (Still Present in v26.7.0)
The silent failure mode also remains in v26.7.0:
locatePackageVersionreturnsnullon failure (no exception thrown)- Only
log.debugmessages are emitted for missing optional dependencies - Build continues with exit code 0 despite missing critical dependencies
Conclusion for v26.7.0
v26.7.0 partially improves the situation but does not fix the core issue. The fundamental problem is that packageVersionString returns the real package name while collectAllDependencies stores by alias name, causing lookup failures in _getNodeModules.
The suggested fix (passing the dependency key through the call chain, or using consistent keys) would still resolve both issues.
Version Test Results
| Version | Result | Notes |
|---|---|---|
| 26.3.0 | ✅ Works | Old synchronous resolvePackageDir works fine |
| 26.3.1 | ❌ Broken | All pnpm hoisted packages fail (PR #9380) |
| 26.3.2+ | Non-aliased packages work, aliased still fail (PR #9392) | |
| 26.7.0 | Improved collectAllDependencies, but aliased deps still fail due to key mismatch |
Suggested Fix
Primary Fix: Support Aliased Dependencies
The packageVersionString method (or the calling code) needs to use consistent keys with collectAllDependencies.
Option A: Change packageVersionString to return the iteration key instead of from when available.
Option B: Pass the dependency key through the call chain and use it consistently:
// In extractProductionDependencyGraph:
for (const [key, dependency] of Object.entries(deps)) {
const childDependencyId = `${key}@${dependency.version}` // Use key, not from
await this.extractProductionDependencyGraph(dependency, childDependencyId)
return childDependencyId
}Secondary Fix: Fail Fast on Missing Dependencies
When dependencies cannot be resolved, the build should fail with a clear error message rather than continuing with an incomplete dependency tree. This would catch the problem immediately during development rather than in production.
Related Issues/PRs
- fix: properly collect node_modules when they're ESM and we're node>=16 #9380: ESM-aware
resolvePackageDirrewrite (introduced the regression in v26.3.1) - fix: returning actual path in hoisted mode for pnpm since it can still return virtual paths #9392: Added
resolveActualPathfor pnpm hoisted mode (partial fix in v26.3.2) - Warn while building (electron-builder from 26.0.19 to 26.3.1), why? #9393: "Warn while building" - reported fixed in 26.3.2, but did not cover aliased deps
- Failed to read package.json for ...\node_modules\.pnpm\X@Y\node_modules\X: ENOENT: no such file or directory, open '...\node_modules\.pnpm\X@Y\node_modules\X\package.json' #9515: Similar issue for different edge cases
Impact
This affects any project using:
- pnpm with
node-linker=hoisted - Dependencies installed from tarballs/GitHub URLs where the package name differs from the dependency key
- Common in native modules with custom distribution (e.g., prebuilt native addons hosted on GitHub releases)
The result is incomplete builds with missing native modules, making the application non-functional. The silent failure mode makes this particularly dangerous as broken builds can slip through CI/CD pipelines if not carefully validated.