Skip to content

[Bug]: tsconfig extends through a symlinked node_modules package fails to resolve (pnpm) #15717

Description

@orgads

System Info

  System:
    OS: Linux 6.6 Debian GNU/Linux forky/sid (WSL2)
    CPU: (28) x64 Intel(R) Core(TM) i7-14700T
    Memory: 18.96 GB / 31.16 GB
    Shell: 5.9.2 - /bin/zsh
  Binaries:
    Node: 24.21.0
    npm: 11.19.0
    pnpm: 12.4.2
  npmPackages:
    @rspack/cli: 2.2.4 => 2.2.4
    @rspack/core: 2.2.4 => 2.2.4

Details

When resolve.tsConfig points at a tsconfig.json whose extends chain passes
through a package in node_modules, and that package is a symlink (the normal
pnpm layout), rspack fails to load the chain:

ERROR in ./entry.ts 1:1-31
  × Tsconfig not found @tsconfig/node22/tsconfig.json

The chain is:

./tsconfig.json
  extends "shared-config/tsconfig.json"          <- node_modules/shared-config (symlink)
    extends "@tsconfig/node22/tsconfig.json"     <- only reachable from the symlink TARGET

@tsconfig/node22 is a dependency of shared-config, so under pnpm it lives at
node_modules/.pnpm/shared-config@.../node_modules/@tsconfig/node22, next to the
real shared-config directory. It is deliberately not a dependency of the root
project, so it is not present in the root node_modules.

It looks like the nested extends is resolved starting from the symlink path
(<root>/node_modules/shared-config/), which walks up to <root>/node_modules/
and finds nothing, instead of from the file's real path, which would walk up to
<root>/node_modules/.pnpm/shared-config@.../node_modules/ and find it.

Evidence that this is symlink-specific: if the same package tree is materialised
without a symlink (npm-style node_modules/shared-config/ as a real directory
with its own node_modules/@tsconfig/node22), the identical build succeeds.

tsc resolves the same chain without complaint — npx tsc --showConfig prints the
fully merged config — so this is an rspack/rspack-resolver divergence from
TypeScript's own extends resolution.

This is easy to hit unintentionally: any monorepo that ships a shared base
tsconfig as an npm package which itself extends @tsconfig/* will break under
pnpm.

Where I actually hit it

Via Rstest, which sets
resolve.tsConfig automatically. There the failure is much harder to diagnose,
because the resolve error is swallowed: autoExternalNodeModules treats any
resolver error as "externalize this request"
(autoExternalNodeModules in dist/rsbuild~0.js:
if (err) return callback(void 0, request, 'node-commonjs')), and
IgnoreModuleNotFoundErrorPlugin strips the build error. Every module in the
project is then externalized to Node, and the only visible symptom is a runtime
Cannot find module '.../foo.js' imported from '.../a.spec.ts'. Patching that
line to log the error yields the real cause:

RspackResolver(TsconfigNotFound("@tsconfig/node22/tsconfig.json"))

That part may deserve a separate issue on the Rstest side; filing here because
plain @rspack/cli reproduces it with no Rstest involved.

Reproduce Steps

Save as repro.sh and run it in an empty directory (needs npm, pnpm, network):

#!/bin/sh
set -eu
rm -rf rspack-tsconfig-extends-repro
mkdir -p rspack-tsconfig-extends-repro/pkg
cd rspack-tsconfig-extends-repro

# A package that ships a tsconfig which itself extends another package.
cat > pkg/package.json <<'EOF2'
{
  "name": "shared-config",
  "version": "1.0.0",
  "files": ["tsconfig.json"],
  "dependencies": { "@tsconfig/node22": "^22.0.0" }
}
EOF2
cat > pkg/tsconfig.json <<'EOF2'
{
  "extends": "@tsconfig/node22/tsconfig.json",
  "compilerOptions": { "strict": true }
}
EOF2
(cd pkg && npm pack --pack-destination .. >/dev/null)
rm -rf pkg

cat > package.json <<'EOF2'
{
  "name": "rspack-tsconfig-extends-repro",
  "private": true,
  "dependencies": { "shared-config": "file:./shared-config-1.0.0.tgz" },
  "devDependencies": { "@rspack/cli": "2.2.4", "@rspack/core": "2.2.4", "typescript": "^5" }
}
EOF2
cat > tsconfig.json <<'EOF2'
{
  "extends": "shared-config/tsconfig.json",
  "compilerOptions": { "baseUrl": ".", "paths": { "~/*": ["./*"] } }
}
EOF2
echo 'export const hello = 42;' > foo.ts
cat > entry.ts <<'EOF2'
import { hello } from '~/foo';
console.log(hello);
EOF2
cat > rspack.config.mjs <<'EOF2'
import path from 'node:path';
export default {
  mode: 'development',
  entry: './entry.ts',
  devtool: false,
  resolve: {
    extensions: ['.ts', '.js'],
    tsConfig: { configFile: path.resolve('tsconfig.json') }
  },
  module: {
    rules: [{
      test: /\.ts$/,
      loader: 'builtin:swc-loader',
      options: { jsc: { parser: { syntax: 'typescript' } } }
    }]
  },
  output: { path: path.resolve('dist'), filename: 'main.js' }
};
EOF2

pnpm install --silent

echo; echo "--- node_modules layout (pnpm symlink) ---"
ls -l node_modules/shared-config

echo; echo "--- tsc (works) ---"
npx tsc --showConfig | head -5

echo; echo "--- rspack build (fails) ---"
npx rspack build || true

Observed output:

--- node_modules layout (pnpm symlink) ---
lrwxrwxrwx ... node_modules/shared-config -> .pnpm/shared-config@file+shared-config-1.0.0.tgz/node_modules/shared-config

--- tsc (works) ---
{
    "compilerOptions": {
        "lib": [
            "es2024",
            "esnext.array",

--- rspack build (fails) ---
ERROR in ./entry.ts 1:1-31
  × Tsconfig not found @tsconfig/node22/tsconfig.json

Expected: the build succeeds, as it does with tsc and as it does when
node_modules/shared-config is a real directory rather than a symlink.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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