Skip to content

pnpm hoisted mode: aliased (renamed) dependencies not resolved after v26.3.1 #9580

@koizuka

Description

@koizuka

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

  1. Create a project with pnpm and node-linker=hoisted in .npmrc
  2. 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"
  }
}
  1. The tarballs contain package.json files with different names:
    • my-native-addon tarball contains "name": "@vendor/native-addon"
    • another-addon tarball contains "name": "real-addon-name"
  2. 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:

  1. The build completes successfully with exit code 0
  2. The installer is created, but with critical dependencies missing
  3. 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.name is undefined
  • depTree.from is the real package name (@vendor/native-addon, real-addon-name)
  • packageName = depTree.from
  • hoistedPath = 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

  1. Improved collectAllDependencies: Now uses the iteration key (alias name) instead of from:

    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" in allDependencies.

  2. New locatePackageVersion method: Replaces the problematic resolveActualPath. This method searches:

    • Direct node_modules/pkgName (would find my-native-addon)
    • Upward hoisted search
    • Downward non-hoisted search

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:

  • allDependencies has: "my-native-addon@1.0.0" (alias name)
  • productionGraph uses: "@vendor/native-addon@1.0.0" (real name from packageVersionString)
  • _getNodeModules looks 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:

  • locatePackageVersion returns null on failure (no exception thrown)
  • Only log.debug messages 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+ ⚠️ Partial Non-aliased packages work, aliased still fail (PR #9392)
26.7.0 ⚠️ Partial 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions