Skip to content

fix(vite): correct tsconfig paths fallback resolution - #36424

Open
leosvelperez wants to merge 10 commits into
masterfrom
gh-36400
Open

fix(vite): correct tsconfig paths fallback resolution#36424
leosvelperez wants to merge 10 commits into
masterfrom
gh-36400

Conversation

@leosvelperez

@leosvelperez leosvelperez commented Jul 21, 2026

Copy link
Copy Markdown
Member

Current Behavior

The nxViteTsPaths plugin looks up aliases in two passes. The first asks tsconfig-paths. It checks only for .js, .json and .node files. Most aliases miss it. They fall to a second pass nx runs itself. That pass has five bugs:

  • An alias fails when the * is not last in the mapped path. "@app/*": ["packages/*/src"] never resolves. Vite gets a plain name instead.
  • An import holding $&, $` or $' gets mangled before the lookup.
  • A project can inherit paths through extends. nx reads them against the project folder, not the folder that set them. Every alias points at a missing file.
  • When two aliases match one import, nx takes the one listed first, not the most specific one.
  • On Windows, nx drops the drive letter and searches the wrong volume.

A workspace may have no root-level tsconfig. nx searches upward from the current folder. It finds a tsconfig outside the workspace, or throws.

nx also reads the extends chain as strict JSON to find the file that sets paths. One comment or stray comma drops that file. Its aliases point at the wrong folder. Users hit this through the @nx/vitest executor.

Expected Behavior

Aliases resolve wherever the * sits in the mapped path. "@app/*": ["packages/*/src"] finds packages/one/src/index.ts. An import holding a $ sequence finds the file it names.

A project that inherits paths reads them against the folder that set them. When two aliases match, nx picks the one TypeScript picks. The order they are listed in stops mattering. On Windows the drive letter survives.

A workspace with no root-level tsconfig hands the import back to Vite. A project-level tsconfig still works on its own.

nx walks the extends chain the way TypeScript does. Comments and stray commas leave aliases pointing where the tsconfig says.

Related Issue(s)

Fixes #36400

Implementation Notes

  • Resolution is checked against ts.resolveModuleName. The check spans mapped-path shapes, import extensions and three module resolution modes. Wildcard substitution and the choice of alias agree with TypeScript in every case measured.
  • Picking the most specific alias changes which file a workspace gets. It affects a workspace that lists a broad alias before a narrow one and leans on the old order.
  • Three cases stay looser than TypeScript. A non-wildcard alias can match a prefix. A lookup can fall through to another alias. A plain import can match a "/*" key. Workspaces build on all three today, so tightening them waits for a major.

View session information ↗

@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit 17f0492
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6a9558b98690800008a4f894
😎 Deploy Preview https://deploy-preview-36424--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit 17f0492
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6a9558b9ea1be0000886588a
😎 Deploy Preview https://deploy-preview-36424--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@leosvelperez leosvelperez self-assigned this Jul 21, 2026
@nx-cloud

nx-cloud Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 17f0492

Command Status Duration Result
nx affected --targets=lint,oxlint,test,build,e2... ⏳ In Progress ... View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 4s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 1m 1s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 19s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 1s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-31 10:38:56 UTC

@leosvelperez
leosvelperez marked this pull request as ready for review July 21, 2026 11:42
@leosvelperez
leosvelperez requested a review from a team as a code owner July 21, 2026 11:42
@leosvelperez
leosvelperez requested a review from lourw July 21, 2026 11:42
@leosvelperez
leosvelperez force-pushed the gh-36400 branch 2 times, most recently from 8e8c94c to 797ed44 Compare August 6, 2026 12:41
nx-cloud[bot]

This comment was marked as outdated.

@leosvelperez leosvelperez changed the title fix(vite): correct tsconfig paths fallback resolution for wildcard aliases fix(vite): correct tsconfig paths fallback resolution Aug 26, 2026

@nx-cloud nx-cloud Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud has identified a flaky task in your failed CI:

🔂 Since the failure was identified as flaky, we triggered a CI rerun by adding an empty commit to this branch.

Nx Cloud View detailed reasoning in Nx Cloud ↗

🔔 Heads up, your workspace has pending recommendations ↗ to auto-apply fixes for similar failures.


🎓 Learn more about Self-Healing CI on nx.dev

…iases

The fallback resolver used when `tsconfig-paths` cannot match an import
dropped everything a mapped path placed after the `*`, so an alias mapped
to `packages/*/src` or `packages/foo/*.ts` never resolved. It also ran the
import through a string replacement, which expanded `$&` and friends as
substitution patterns.

The captured suffix is now substituted into the wildcard through function
replacements. Retrying without the import extension is limited to mapped
paths whose wildcard is last: anything a mapping appends after the `*` is
not the import's own tail, and slicing it off resolves a sibling the
mapping never pointed at.

`configResolved` also assumed a workspace always has a root-level
tsconfig. Without one, `loadConfig` searched upwards from the cwd and
either picked up an unrelated tsconfig outside the workspace, throwing
during config resolution, or left the fallback unset and threw on every
unresolvable import. Both cases now defer to Vite's own resolution.
Walking the `extends` chain to find the tsconfig that declares `paths`
parsed each file with `JSON.parse` inside a silent try/catch. A single
comment or trailing comma anywhere in the chain dropped that file and
everything it extends, so the baseUrl fell back to the directory of the
leaf tsconfig and every alias resolved under a directory that does not
exist. Reading through `readJsonFile` accepts the same JSONC TypeScript
does.
Route TempFs through the @nx/devkit/internal-testing-utils subpath export,
matching the other plugin specs. The bare nx/src import tripped the devkit
import-boundary lint rule.
`loadConfig` from `tsconfig-paths` derives `absoluteBaseUrl` from the leaf
tsconfig it was handed, but `paths` values resolve against the config that
declared them. The fallback resolver read that leaf-derived value, so a
project tsconfig inheriting `paths` through `extends` resolved every mapped
path against the project directory instead of the declaring one.

The primary matcher already corrected the base with `resolvePathsBaseUrl`.
Hand the same value to the fallback, as the expo and react-native metro
resolvers do.

A workspace with a root-level `tsconfig.base.json` masks this: the second
fallback pass loads that config, whose directory is the workspace root. The
added test therefore names the root config `tsconfig.json`, which the
root-level lookup skips in favour of the project tsconfig.
`joinPathFragments` runs its result through `normalizePath`, which strips a
leading drive letter and rewrites backslashes. Its own doc comment says the
output is not meant for reading files off disk. The candidates built here go
straight to `existsSync`, so on Windows a workspace on a drive other than the
one holding `process.cwd()` lost the drive and probed the wrong volume.

`join` is used rather than `resolve` so a drive-absolute mapping value keeps
being treated as it is today.

No test: on POSIX the two produce identical output for the path shapes the
resolver builds, so nothing here can go red on a POSIX runner.
When more than one alias matched an import, the fallback resolver took
whichever the tsconfig declared first. TypeScript instead picks by
specificity, and does so regardless of declaration order: an exact hit on a
non-wildcard alias wins outright, then the longest wildcard prefix.

With `{ "@repo/*": ["generic/*"], "@repo/exact": ["packages/exact"] }` and an
import of `@repo/exact`, the resolver returned `generic/exact.ts` while
TypeScript, checked here in all three module resolution modes, returns
`packages/exact/index.ts`. Reversing the two declarations flipped the
resolver's answer and left TypeScript's unchanged.

This orders only the alias. Falling through the values of a single alias is
what TypeScript does too, so the inner loop is untouched.
The resolver also runs when `tsconfig-paths` did match an alias but the file
it pointed at is missing, which the caller decides with `existsSync`.
The unit spec pins the substitution itself. This drives it through
`configResolved` and `resolveId`, so the mapped path reaching the resolver is
the one the plugin actually builds.
… paths

The injected `existsSync` was backed by a set of POSIX literals, so on Windows
every lookup missed the `resolve`d, drive-prefixed path the resolver actually
probes and the whole suite failed.
Ordering by prefix length also reordered two non-wildcard aliases that both
matched only a prefix of the import, which is a shape TypeScript does not
resolve at all:

    { "@repo": ["packages/broad"], "@repo/exact": ["packages/narrow"] }

For `@repo/exact/thing` that flipped the answer from
`packages/broad/exact/thing.ts` to `packages/narrow/thing.ts` while TypeScript,
checked in all three module resolution modes, resolves neither. The tiebreak
exists to implement TypeScript's longest-wildcard-prefix rule, so it now
applies only where TypeScript applies it and everything else keeps the order
it was declared in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

loadFileFromPaths in @nx/vite breaks wildcard tsconfig.paths resolution after upgrade from 22.6.4 → 22.7.6

1 participant