#15717·rspack

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

Author: orgadsCreated Sep 16, 2026Updated Sep 16, 2026

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):

bash
#!/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.