From f386e462880d545f77859f7ce60f208ffdd210df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 21 Jul 2026 12:41:29 +0200 Subject: [PATCH 01/10] fix(vite): correct tsconfig paths fallback resolution for wildcard aliases 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. --- .../plugins/nx-tsconfig-paths.plugin.spec.ts | 105 ++++++++++++ .../vite/plugins/nx-tsconfig-paths.plugin.ts | 85 +++------- .../utils/nx-tsconfig-paths-load-file.spec.ts | 157 ++++++++++++++++++ .../src/utils/nx-tsconfig-paths-load-file.ts | 74 +++++++++ 4 files changed, 362 insertions(+), 59 deletions(-) create mode 100644 packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts create mode 100644 packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts create mode 100644 packages/vite/src/utils/nx-tsconfig-paths-load-file.ts diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts new file mode 100644 index 00000000000..1591f79ff01 --- /dev/null +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts @@ -0,0 +1,105 @@ +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { join } from 'node:path'; + +// `var` rather than `let`: transitive imports read `workspaceRoot` while the +// module graph is still loading, before a `let` would leave its temporal dead +// zone. +var workspaceRootMock: string | undefined; +jest.mock('@nx/devkit', () => { + const actual = jest.requireActual('@nx/devkit'); + return { + ...actual, + get workspaceRoot() { + return workspaceRootMock ?? actual.workspaceRoot; + }, + }; +}); + +var failRootTsConfigLoad = false; +jest.mock('tsconfig-paths', () => { + const actual = jest.requireActual('tsconfig-paths'); + return { + ...actual, + loadConfig: (path?: string) => + failRootTsConfigLoad && path?.endsWith('tsconfig.base.json') + ? { resultType: 'failed', message: "Couldn't find tsconfig.json" } + : actual.loadConfig(path), + }; +}); + +import { nxViteTsPaths } from './nx-tsconfig-paths.plugin'; + +describe('nxViteTsPaths', () => { + let tempFs: TempFs; + let originalTsConfigPath: string | undefined; + + beforeEach(() => { + tempFs = new TempFs('nx-vite-ts-paths'); + workspaceRootMock = tempFs.tempDir; + originalTsConfigPath = process.env.NX_TSCONFIG_PATH; + failRootTsConfigLoad = false; + }); + + afterEach(() => { + if (originalTsConfigPath === undefined) { + delete process.env.NX_TSCONFIG_PATH; + } else { + process.env.NX_TSCONFIG_PATH = originalTsConfigPath; + } + tempFs.cleanup(); + }); + + const resolveWith = async (importPath: string) => { + const plugin = nxViteTsPaths(); + await (plugin as any).configResolved({ + root: join(tempFs.tempDir, 'app'), + command: 'build', + plugins: [], + }); + return (plugin as any).resolveId(importPath); + }; + + const withProjectTsConfigOutsideWorkspace = async () => { + await tempFs.createFiles({ + 'external/tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@ext/*': ['libs/*'] } }, + }), + 'app/src/main.ts': '', + }); + process.env.NX_TSCONFIG_PATH = join( + tempFs.tempDir, + 'external/tsconfig.json' + ); + }; + + it('should defer to other resolvers when the workspace has no root-level tsconfig', async () => { + await withProjectTsConfigOutsideWorkspace(); + + await expect(resolveWith('@nope/missing')).resolves.toBeNull(); + }); + + it('should defer to other resolvers when the root-level tsconfig cannot be loaded', async () => { + await withProjectTsConfigOutsideWorkspace(); + await tempFs.createFiles({ 'tsconfig.base.json': JSON.stringify({}) }); + failRootTsConfigLoad = true; + + await expect(resolveWith('@nope/missing')).resolves.toBeNull(); + }); + + it('should resolve a workspace alias through the root-level tsconfig', async () => { + await tempFs.createFiles({ + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@repo/util': ['libs/util/index.ts'] }, + }, + }), + 'libs/util/index.ts': '', + 'app/src/main.ts': '', + }); + + await expect(resolveWith('@repo/util')).resolves.toEqual( + join(tempFs.tempDir, 'libs/util/index.ts') + ); + }); +}); diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.ts index 6d1ea513f07..eadfadcceca 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.ts @@ -1,7 +1,6 @@ import { createProjectGraphAsync, getPackageManagerCommand, - joinPathFragments, workspaceRoot, } from '@nx/devkit'; import { @@ -20,7 +19,7 @@ import { } from 'tsconfig-paths'; import { Plugin } from 'vite'; import { warnNxViteTsPathsDeprecation } from '../src/utils/deprecation'; -import { findFile } from '../src/utils/nx-tsconfig-paths-find-file'; +import { loadFileFromPaths } from '../src/utils/nx-tsconfig-paths-load-file'; import { getProjectTsConfigPath } from '../src/utils/options-utils'; import { nxViteBuildCoordinationPlugin } from './nx-vite-build-coordination.plugin'; @@ -40,7 +39,7 @@ export interface nxViteTsPathsOptions { mainFields?: (string | string[])[]; /** * extensions to check when resolving files when package.json resolution fails - * @default ['.ts', '.tsx', '.js', '.jsx', '.json', '.mjs', '.cjs'] + * @default ['.ts', '.tsx', '.js', '.jsx', '.json', '.mts', '.mjs', '.cts', '.cjs', '.css', '.scss', '.less'] **/ extensions?: string[]; /** @@ -74,7 +73,7 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { let matchTsPathEsm: MatchPath; let matchTsPathFallback: MatchPath | undefined; let tsConfigPathsEsm: ConfigLoaderSuccessResult; - let tsConfigPathsFallback: ConfigLoaderSuccessResult; + let tsConfigPathsFallback: ConfigLoaderSuccessResult | undefined; options.extensions ??= [ '.ts', @@ -186,15 +185,20 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { const rootLevelTsConfig = getTsConfig( join(workspaceRoot, 'tsconfig.base.json') ); - const rootLevelParsed = loadConfig(rootLevelTsConfig); - logIt('fallback parsed tsconfig: ', rootLevelParsed); - if (rootLevelParsed.resultType === 'success') { - tsConfigPathsFallback = rootLevelParsed; - matchTsPathFallback = createMatchPath( - resolvePathsBaseUrl(rootLevelTsConfig), - rootLevelParsed.paths, - ['main', 'module'] - ); + // A workspace may have no root-level tsconfig at all. Passing no path to + // `loadConfig` makes it search upwards from the cwd instead, which finds + // an unrelated tsconfig whose directory is not this workspace. + if (rootLevelTsConfig) { + const rootLevelParsed = loadConfig(rootLevelTsConfig); + logIt('fallback parsed tsconfig: ', rootLevelParsed); + if (rootLevelParsed.resultType === 'success') { + tsConfigPathsFallback = rootLevelParsed; + matchTsPathFallback = createMatchPath( + resolvePathsBaseUrl(rootLevelTsConfig), + rootLevelParsed.paths, + ['main', 'module'] + ); + } } }, resolveId(importPath: string) { @@ -221,8 +225,8 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { `Unable to resolve ${importPath} with tsconfig paths. Using fallback file matching.` ); resolvedFile = - loadFileFromPaths(tsConfigPathsEsm, importPath) || - loadFileFromPaths(tsConfigPathsFallback, importPath); + loadFileFromPathsWithLogging(tsConfigPathsEsm, importPath) || + loadFileFromPathsWithLogging(tsConfigPathsFallback, importPath); } else { logIt(`Unable to resolve ${importPath} with tsconfig paths`); } @@ -277,54 +281,17 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { } } - function loadFileFromPaths( - tsconfig: ConfigLoaderSuccessResult, + function loadFileFromPathsWithLogging( + tsconfig: ConfigLoaderSuccessResult | undefined, importPath: string ) { + // The root-level tsconfig is optional: a workspace without one leaves + // `tsConfigPathsFallback` unset, and the import has to defer to Vite. + if (!tsconfig) return undefined; + logIt( `Trying to resolve file from config in ${tsconfig.configFileAbsolutePath}` ); - let resolvedFile: string; - for (const alias in tsconfig.paths) { - const paths = tsconfig.paths[alias]; - - const normalizedImport = alias.replace(/\/\*$/, ''); - - if ( - importPath === normalizedImport || - importPath.startsWith(normalizedImport + '/') - ) { - for (const path of paths) { - const joinedPath = joinPathFragments( - tsconfig.absoluteBaseUrl, - path.replace(/\/\*$/, '') - ); - - resolvedFile = findFile( - importPath.replace(normalizedImport, joinedPath), - options.extensions - ); - - if ( - resolvedFile === undefined && - options.extensions.some((ext) => importPath.endsWith(ext)) - ) { - const foundExtension = options.extensions.find((ext) => - importPath.endsWith(ext) - ); - const pathWithoutExtension = importPath - .replace(normalizedImport, joinedPath) - .slice(0, -foundExtension.length); - resolvedFile = findFile(pathWithoutExtension, options.extensions); - } - - if (resolvedFile !== undefined) { - return resolvedFile; - } - } - } - } - - return resolvedFile; + return loadFileFromPaths(tsconfig, importPath, options.extensions); } } diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts new file mode 100644 index 00000000000..2c18b559722 --- /dev/null +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts @@ -0,0 +1,157 @@ +import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; +import { loadFileFromPaths as loadFileFromPathsMain } from './nx-tsconfig-paths-load-file'; + +describe('@nx/vite nx-tsconfig-paths-load-file', () => { + const extensions = ['.ts', '.tsx', '.js', '.json']; + const fs = new Set([ + '/ws/packages/foo/angular.ts', + '/ws/packages/foo/legacy.js', + '/ws/packages/foo/react/index.ts', + '/ws/packages/baz/src/index.ts', + '/ws/packages/exact/index.ts', + '/ws/packages/exact/thing.ts', + '/ws/packages/one/src/index.ts', + '/ws/packages/weird/$&.ts', + ]); + const existsSyncImpl = ((path: string) => fs.has(path)) as any; + + const loadFileFromPaths = ( + paths: Record, + importPath: string + ) => + loadFileFromPathsMain( + { + absoluteBaseUrl: '/ws', + paths, + } as ConfigLoaderSuccessResult, + importPath, + extensions, + existsSyncImpl + ); + + it('should substitute the wildcard when it is followed by an extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/angular' + ) + ).toEqual('/ws/packages/foo/angular.ts'); + }); + + it('should substitute the wildcard in the middle of the mapped path', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*/index.ts'] }, + '@repo/foo/react' + ) + ).toEqual('/ws/packages/foo/react/index.ts'); + }); + + it('should fall through to the next mapped path when the first does not exist', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts', 'packages/foo/*/index.ts'] }, + '@repo/foo/react' + ) + ).toEqual('/ws/packages/foo/react/index.ts'); + }); + + it('should resolve a trailing wildcard mapped path', () => { + expect( + loadFileFromPaths( + { '@repo/baz/*': ['packages/baz/src/*'] }, + '@repo/baz/index' + ) + ).toEqual('/ws/packages/baz/src/index.ts'); + }); + + it('should resolve an import with an explicit extension', () => { + expect( + loadFileFromPaths( + { '@repo/baz/*': ['packages/baz/src/*'] }, + '@repo/baz/index.js' + ) + ).toEqual('/ws/packages/baz/src/index.ts'); + }); + + it('should not resolve a sibling when the mapped path appends a different extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/legacy.js' + ) + ).toBeUndefined(); + }); + + it('should not resolve a sibling when the mapped path appends the import extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.js'] }, + '@repo/foo/legacy.js' + ) + ).toBeUndefined(); + }); + + it('should not resolve the mapped file when the import repeats the appended extension', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/angular.ts' + ) + ).toBeUndefined(); + }); + + it('should resolve a non-wildcard alias', () => { + expect( + loadFileFromPaths({ '@repo/exact': ['packages/exact'] }, '@repo/exact') + ).toEqual('/ws/packages/exact/index.ts'); + }); + + it('should append the subpath of an import matching a non-wildcard alias', () => { + expect( + loadFileFromPaths( + { '@repo/exact': ['packages/exact'] }, + '@repo/exact/thing' + ) + ).toEqual('/ws/packages/exact/thing.ts'); + }); + + it('should resolve a mid-pattern wildcard pointing at a directory', () => { + expect( + loadFileFromPaths({ '@lib/*': ['packages/*/src'] }, '@lib/one') + ).toEqual('/ws/packages/one/src/index.ts'); + }); + + it('should resolve an import with an explicit extension for a non-wildcard alias', () => { + expect( + loadFileFromPaths( + { '@repo/exact': ['packages/exact'] }, + '@repo/exact/thing.js' + ) + ).toEqual('/ws/packages/exact/thing.ts'); + }); + + it('should not expand $ substitution patterns coming from the import', () => { + expect( + loadFileFromPaths( + { '@repo/weird/*': ['packages/weird/*.ts'] }, + '@repo/weird/$&' + ) + ).toEqual('/ws/packages/weird/$&.ts'); + }); + + it('should not match an alias that is only a partial prefix of the import', () => { + expect( + loadFileFromPaths({ '@repo/ex/*': ['packages/exact/*'] }, '@repo/exact') + ).toBeUndefined(); + }); + + it('should return undefined when no mapped path resolves', () => { + expect( + loadFileFromPaths( + { '@repo/foo/*': ['packages/foo/*.ts'] }, + '@repo/foo/missing' + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts new file mode 100644 index 00000000000..4768fbf4b53 --- /dev/null +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts @@ -0,0 +1,74 @@ +import { joinPathFragments } from '@nx/devkit'; +import { existsSync } from 'node:fs'; +import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; +import { findFile } from './nx-tsconfig-paths-find-file'; + +/** + * Fallback resolver used when `tsconfig-paths` fails to match an import. + * + * A wildcard alias captures the suffix of the import and substitutes it into + * the `*` of each mapped path. The `*` is not always trailing, so the suffix + * cannot simply be appended. + */ +export function loadFileFromPaths( + tsconfig: ConfigLoaderSuccessResult, + importPath: string, + extensions: string[], + existsSyncImpl: typeof existsSync = existsSync +): string { + let resolvedFile: string; + for (const alias in tsconfig.paths) { + const paths = tsconfig.paths[alias]; + + const isWildcard = alias.endsWith('/*'); + const normalizedImport = alias.replace(/\/\*$/, ''); + + if ( + importPath === normalizedImport || + importPath.startsWith(normalizedImport + '/') + ) { + const suffix = importPath.slice(normalizedImport.length + 1); + + for (const path of paths) { + // The replacements go through a function because a string replacement + // would expand `$&` and friends as substitution patterns. + const joinedPath = joinPathFragments( + tsconfig.absoluteBaseUrl, + isWildcard + ? path.replace('*', () => suffix) + : path.replace(/\/\*$/, '') + ); + const candidate = isWildcard + ? joinedPath + : importPath.replace(normalizedImport, () => joinedPath); + + resolvedFile = findFile(candidate, extensions, existsSyncImpl); + + // The candidate ends with the import's own tail only when the wildcard + // is last. Anything the mapped path appends after the `*` + // (`packages/foo/*.ts`) is the tail instead, so dropping an extension + // from it would resolve a sibling the mapping never pointed at. + const endsWithImportTail = !isWildcard || path.endsWith('*'); + + if (resolvedFile === undefined && endsWithImportTail) { + const foundExtension = extensions.find((ext) => + importPath.endsWith(ext) + ); + if (foundExtension) { + resolvedFile = findFile( + candidate.slice(0, -foundExtension.length), + extensions, + existsSyncImpl + ); + } + } + + if (resolvedFile !== undefined) { + return resolvedFile; + } + } + } + } + + return resolvedFile; +} From f987a32f47eb5e57a39814fcea7601ef009d299b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 21 Jul 2026 12:41:39 +0200 Subject: [PATCH 02/10] fix(core): read tsconfig files as JSONC when resolving the paths baseUrl 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. --- .../nx/src/plugins/js/utils/register.spec.ts | 56 ++++++++++++++++++- packages/nx/src/plugins/js/utils/register.ts | 5 +- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/nx/src/plugins/js/utils/register.spec.ts b/packages/nx/src/plugins/js/utils/register.spec.ts index afd26d9a081..26127a8ee97 100644 --- a/packages/nx/src/plugins/js/utils/register.spec.ts +++ b/packages/nx/src/plugins/js/utils/register.spec.ts @@ -1,6 +1,8 @@ -import type { MockInstance } from 'vitest'; +import type { Mock, MockInstance } from 'vitest'; import type { CompilerOptions } from 'typescript'; import { JsxEmit, ModuleKind, ScriptTarget } from 'typescript'; +import { join } from 'path'; +import { TempFs } from '../../../internal-testing-utils/temp-fs'; import { getTranspiler, getTsNodeCompilerOptions, @@ -11,6 +13,7 @@ import { isTsEsmSyntaxError, NODENEXT_ESM_RESOLVER_SOURCE, nodeNextEsmResolveHook, + registerTsConfigPaths, resolveTsNodeEsmCompilerOptions, } from './register'; @@ -21,6 +24,7 @@ import { createRequire, Module } from 'node:module'; import { mockCjsModule, resetCjsMocks, + unmockCjsModule, } from '../../../internal-testing-utils/cjs-mock'; { const req = createRequire(import.meta.url); @@ -688,3 +692,53 @@ new Function('s', 'return import(s)')(process.argv[3]).then( expect(result).toEqual({ ok: true, url: configUrl, kind: 1 }); }, 60_000); }); + +describe('registerTsConfigPaths', () => { + let tempFs: TempFs; + let registerPaths: Mock; + + beforeEach(() => { + tempFs = new TempFs('register-ts-config-paths', false); + // register.ts lazy-requires tsconfig-paths (CJS channel); replace it there. + // Stubbing `register` captures the baseUrl without installing a resolver hook. + registerPaths = vi.fn(() => () => {}); + mockCjsModule(import.meta.url, 'tsconfig-paths', { + ...require('tsconfig-paths'), + register: registerPaths, + }); + }); + + afterEach(() => { + unmockCjsModule(import.meta.url, 'tsconfig-paths'); + tempFs.cleanup(); + }); + + it('should resolve the baseUrl through an extends chain containing JSONC', () => { + tempFs.createFileSync( + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@lib/*': ['libs/*/src/index.ts'] }, + }, + }) + ); + tempFs.createFileSync( + 'project/tsconfig.json', + `{ + "extends": "../tsconfig.base.json", + /* a block comment */ + "compilerOptions": { + // a line comment + "strictPropertyInitialization": false, + }, +}` + ); + + registerTsConfigPaths(join(tempFs.tempDir, 'project', 'tsconfig.json')); + + expect(registerPaths).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: tempFs.tempDir }) + ); + }); +}); diff --git a/packages/nx/src/plugins/js/utils/register.ts b/packages/nx/src/plugins/js/utils/register.ts index 09e513ed243..75be7f2e8f9 100644 --- a/packages/nx/src/plugins/js/utils/register.ts +++ b/packages/nx/src/plugins/js/utils/register.ts @@ -1,7 +1,8 @@ import { dirname, isAbsolute, join, resolve, sep } from 'path'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync } from 'fs'; import type { TsConfigOptions } from 'ts-node'; import type { CompilerOptions } from 'typescript'; +import { readJsonFile } from '../../../utils/fileutils'; import { logger, NX_PREFIX, stripIndent } from '../../../utils/logger'; import { workspaceRoot } from '../../../utils/workspace-root'; import { getRootTsConfigPath, readTsConfigWithoutFiles } from './typescript'; @@ -1427,7 +1428,7 @@ function resolvePathsBaseUrl(tsconfigPath: string): string { const absolute = resolve(queue.shift()!); const dir = dirname(absolute); try { - const raw = JSON.parse(readFileSync(absolute, 'utf-8')); + const raw = readJsonFile(absolute); chain.push({ dir, raw }); const exts: string[] = raw.extends ? Array.isArray(raw.extends) From a6d82f1d0de0bfbd9f4ee00c58ab6d4975ff4d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 6 Aug 2026 14:37:49 +0200 Subject: [PATCH 03/10] cleanup(vite): import TempFs from @nx/devkit/internal-testing-utils 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. --- packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts index 1591f79ff01..16854bc4b8e 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts @@ -1,4 +1,4 @@ -import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; import { join } from 'node:path'; // `var` rather than `let`: transitive imports read `workspaceRoot` while the From e7783caa89588d0dba63ecffd1485f35ffeac034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 16:49:11 +0200 Subject: [PATCH 04/10] fix(vite): resolve fallback tsconfig paths against the declaring config `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. --- .../plugins/nx-tsconfig-paths.plugin.spec.ts | 18 ++++++++++++++++++ .../vite/plugins/nx-tsconfig-paths.plugin.ts | 18 ++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts index 16854bc4b8e..900bafd46fe 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts @@ -102,4 +102,22 @@ describe('nxViteTsPaths', () => { join(tempFs.tempDir, 'libs/util/index.ts') ); }); + + it('should resolve paths inherited through extends against the tsconfig that declares them', async () => { + // No `tsconfig.base.json`: the root-level lookup falls back to the + // project tsconfig, so both resolution passes share its directory and + // nothing masks a base taken from the leaf. + await tempFs.createFiles({ + 'tsconfig.json': JSON.stringify({ + compilerOptions: { paths: { '@repo/util/*': ['libs/util/*'] } }, + }), + 'app/tsconfig.json': JSON.stringify({ extends: '../tsconfig.json' }), + 'libs/util/foo.ts': '', + 'app/src/main.ts': '', + }); + + await expect(resolveWith('@repo/util/foo')).resolves.toEqual( + join(tempFs.tempDir, 'libs/util/foo.ts') + ); + }); }); diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.ts index eadfadcceca..4f60e161bee 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.ts @@ -174,10 +174,13 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { if (parsed.resultType === 'failed') { throw new Error(`Failed loading tsconfig at ${foundTsConfigPath}`); } - tsConfigPathsEsm = parsed; + // `loadConfig` derives `absoluteBaseUrl` from the leaf tsconfig, but + // `paths` resolve against the config that declared them. + const pathsBaseUrl = resolvePathsBaseUrl(foundTsConfigPath); + tsConfigPathsEsm = { ...parsed, absoluteBaseUrl: pathsBaseUrl }; matchTsPathEsm = createMatchPath( - resolvePathsBaseUrl(foundTsConfigPath), + pathsBaseUrl, parsed.paths, options.mainFields ); @@ -192,9 +195,13 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { const rootLevelParsed = loadConfig(rootLevelTsConfig); logIt('fallback parsed tsconfig: ', rootLevelParsed); if (rootLevelParsed.resultType === 'success') { - tsConfigPathsFallback = rootLevelParsed; + const rootLevelPathsBaseUrl = resolvePathsBaseUrl(rootLevelTsConfig); + tsConfigPathsFallback = { + ...rootLevelParsed, + absoluteBaseUrl: rootLevelPathsBaseUrl, + }; matchTsPathFallback = createMatchPath( - resolvePathsBaseUrl(rootLevelTsConfig), + rootLevelPathsBaseUrl, rootLevelParsed.paths, ['main', 'module'] ); @@ -224,6 +231,9 @@ export function nxViteTsPaths(options: nxViteTsPathsOptions = {}) { logIt( `Unable to resolve ${importPath} with tsconfig paths. Using fallback file matching.` ); + // The tsconfig the project builds with need not extend the + // root-level one, so the second pass covers aliases only the + // root-level config declares. resolvedFile = loadFileFromPathsWithLogging(tsConfigPathsEsm, importPath) || loadFileFromPathsWithLogging(tsConfigPathsFallback, importPath); From 7552827ea8ec41543109f80ee7a68d2d40ddabe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 16:49:52 +0200 Subject: [PATCH 05/10] fix(vite): build fallback path candidates with node's path join `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. --- packages/vite/src/utils/nx-tsconfig-paths-load-file.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts index 4768fbf4b53..d2f4c998337 100644 --- a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts @@ -1,5 +1,5 @@ -import { joinPathFragments } from '@nx/devkit'; import { existsSync } from 'node:fs'; +import { join } from 'node:path'; import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; import { findFile } from './nx-tsconfig-paths-find-file'; @@ -32,7 +32,7 @@ export function loadFileFromPaths( for (const path of paths) { // The replacements go through a function because a string replacement // would expand `$&` and friends as substitution patterns. - const joinedPath = joinPathFragments( + const joinedPath = join( tsconfig.absoluteBaseUrl, isWildcard ? path.replace('*', () => suffix) From 0017c225097de33a2de93fd50760909ee559fbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 16:51:57 +0200 Subject: [PATCH 06/10] fix(vite): align the winning alias with TypeScript 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. --- .../plugins/nx-tsconfig-paths.plugin.spec.ts | 56 +++++++++++++++++++ .../src/utils/nx-tsconfig-paths-load-file.ts | 23 +++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts index 900bafd46fe..9bfd95d434b 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts @@ -1,5 +1,6 @@ import { TempFs } from '@nx/devkit/internal-testing-utils'; import { join } from 'node:path'; +import * as ts from 'typescript'; // `var` rather than `let`: transitive imports read `workspaceRoot` while the // module graph is still loading, before a `let` would leave its temporal dead @@ -120,4 +121,59 @@ describe('nxViteTsPaths', () => { join(tempFs.tempDir, 'libs/util/foo.ts') ); }); + + describe('when more than one alias resolves', () => { + const exact = { '@repo/exact': ['packages/exact'] }; + const wildcard = { '@repo/*': ['generic/*'] }; + + const resolveWithTypeScript = ( + paths: Record, + moduleResolution: ts.ModuleResolutionKind, + module: ts.ModuleKind + ) => + ts.resolveModuleName( + '@repo/exact', + join(tempFs.tempDir, 'app/src/main.ts'), + { baseUrl: tempFs.tempDir, paths, module, moduleResolution }, + ts.sys + ).resolvedModule?.resolvedFileName; + + it.each([ + ['the exact alias is declared first', { ...exact, ...wildcard }], + ['the wildcard alias is declared first', { ...wildcard, ...exact }], + ])('should pick the alias TypeScript picks when %s', async (_, paths) => { + await tempFs.createFiles({ + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { baseUrl: '.', paths }, + }), + 'packages/exact/index.ts': '', + 'generic/exact.ts': '', + 'app/src/main.ts': '', + }); + const expected = join(tempFs.tempDir, 'packages/exact/index.ts'); + + expect( + resolveWithTypeScript( + paths, + ts.ModuleResolutionKind.Bundler, + ts.ModuleKind.ESNext + ) + ).toEqual(expected); + expect( + resolveWithTypeScript( + paths, + ts.ModuleResolutionKind.NodeNext, + ts.ModuleKind.NodeNext + ) + ).toEqual(expected); + expect( + resolveWithTypeScript( + paths, + ts.ModuleResolutionKind.Node10, + ts.ModuleKind.CommonJS + ) + ).toEqual(expected); + await expect(resolveWith('@repo/exact')).resolves.toEqual(expected); + }); + }); }); diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts index d2f4c998337..3ba7e1cd7c2 100644 --- a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts @@ -17,7 +17,10 @@ export function loadFileFromPaths( existsSyncImpl: typeof existsSync = existsSync ): string { let resolvedFile: string; - for (const alias in tsconfig.paths) { + for (const alias of sortAliasesBySpecificity( + Object.keys(tsconfig.paths), + importPath + )) { const paths = tsconfig.paths[alias]; const isWildcard = alias.endsWith('/*'); @@ -72,3 +75,21 @@ export function loadFileFromPaths( return resolvedFile; } + +/** + * TypeScript picks between competing aliases by specificity, never by + * declaration order: an exact hit on a non-wildcard alias first, then the + * longest wildcard prefix. + */ +function sortAliasesBySpecificity( + aliases: string[], + importPath: string +): string[] { + const rank = (alias: string) => + alias.endsWith('/*') ? 1 : importPath === alias ? 2 : 0; + const prefixLength = (alias: string) => alias.replace(/\/\*$/, '').length; + + return [...aliases].sort( + (a, b) => rank(b) - rank(a) || prefixLength(b) - prefixLength(a) + ); +} From 9954409514d368949edbedd7462eb9af5e1e569e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 16:52:12 +0200 Subject: [PATCH 07/10] cleanup(vite): correct the fallback resolver doc comment 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`. --- packages/vite/src/utils/nx-tsconfig-paths-load-file.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts index 3ba7e1cd7c2..8c8aeec10a2 100644 --- a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts @@ -4,7 +4,9 @@ import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; import { findFile } from './nx-tsconfig-paths-find-file'; /** - * Fallback resolver used when `tsconfig-paths` fails to match an import. + * Fallback resolver used when `tsconfig-paths` produces nothing that exists on + * disk, whether because no alias matched or because the match pointed at a + * missing file. * * A wildcard alias captures the suffix of the import and substitutes it into * the `*` of each mapped path. The `*` is not always trailing, so the suffix From 1573f85646e47e934ba38eacb5024654dd014615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 16:52:32 +0200 Subject: [PATCH 08/10] cleanup(vite): cover wildcard substitution through the plugin 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. --- .../plugins/nx-tsconfig-paths.plugin.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts index 9bfd95d434b..d5d9e9d831d 100644 --- a/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts +++ b/packages/vite/plugins/nx-tsconfig-paths.plugin.spec.ts @@ -104,6 +104,23 @@ describe('nxViteTsPaths', () => { ); }); + it('should substitute the wildcard of a mapped path pointing at a directory', async () => { + await tempFs.createFiles({ + 'tsconfig.base.json': JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@lib/*': ['packages/*/src'] }, + }, + }), + 'packages/one/src/index.ts': '', + 'app/src/main.ts': '', + }); + + await expect(resolveWith('@lib/one')).resolves.toEqual( + join(tempFs.tempDir, 'packages/one/src/index.ts') + ); + }); + it('should resolve paths inherited through extends against the tsconfig that declares them', async () => { // No `tsconfig.base.json`: the root-level lookup falls back to the // project tsconfig, so both resolution passes share its directory and From a639928f44c9c58d38e848a4bd6070524e868a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 16:53:01 +0200 Subject: [PATCH 09/10] cleanup(vite): anchor the load-file fixtures the way findFile returns 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. --- .../utils/nx-tsconfig-paths-load-file.spec.ts | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts index 2c18b559722..32347687eae 100644 --- a/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts @@ -1,17 +1,21 @@ +import { join, resolve } from 'node:path'; import type { ConfigLoaderSuccessResult } from 'tsconfig-paths'; import { loadFileFromPaths as loadFileFromPathsMain } from './nx-tsconfig-paths-load-file'; describe('@nx/vite nx-tsconfig-paths-load-file', () => { const extensions = ['.ts', '.tsx', '.js', '.json']; + // `findFile` returns `resolve`d paths, which on Windows carry a drive letter + // and backslashes. Anchor the fixtures the same way. + const ws = resolve('/ws'); const fs = new Set([ - '/ws/packages/foo/angular.ts', - '/ws/packages/foo/legacy.js', - '/ws/packages/foo/react/index.ts', - '/ws/packages/baz/src/index.ts', - '/ws/packages/exact/index.ts', - '/ws/packages/exact/thing.ts', - '/ws/packages/one/src/index.ts', - '/ws/packages/weird/$&.ts', + join(ws, 'packages/foo/angular.ts'), + join(ws, 'packages/foo/legacy.js'), + join(ws, 'packages/foo/react/index.ts'), + join(ws, 'packages/baz/src/index.ts'), + join(ws, 'packages/exact/index.ts'), + join(ws, 'packages/exact/thing.ts'), + join(ws, 'packages/one/src/index.ts'), + join(ws, 'packages/weird/$&.ts'), ]); const existsSyncImpl = ((path: string) => fs.has(path)) as any; @@ -21,7 +25,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { ) => loadFileFromPathsMain( { - absoluteBaseUrl: '/ws', + absoluteBaseUrl: ws, paths, } as ConfigLoaderSuccessResult, importPath, @@ -35,7 +39,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/foo/*': ['packages/foo/*.ts'] }, '@repo/foo/angular' ) - ).toEqual('/ws/packages/foo/angular.ts'); + ).toEqual(join(ws, 'packages/foo/angular.ts')); }); it('should substitute the wildcard in the middle of the mapped path', () => { @@ -44,7 +48,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/foo/*': ['packages/foo/*/index.ts'] }, '@repo/foo/react' ) - ).toEqual('/ws/packages/foo/react/index.ts'); + ).toEqual(join(ws, 'packages/foo/react/index.ts')); }); it('should fall through to the next mapped path when the first does not exist', () => { @@ -53,7 +57,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/foo/*': ['packages/foo/*.ts', 'packages/foo/*/index.ts'] }, '@repo/foo/react' ) - ).toEqual('/ws/packages/foo/react/index.ts'); + ).toEqual(join(ws, 'packages/foo/react/index.ts')); }); it('should resolve a trailing wildcard mapped path', () => { @@ -62,7 +66,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/baz/*': ['packages/baz/src/*'] }, '@repo/baz/index' ) - ).toEqual('/ws/packages/baz/src/index.ts'); + ).toEqual(join(ws, 'packages/baz/src/index.ts')); }); it('should resolve an import with an explicit extension', () => { @@ -71,7 +75,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/baz/*': ['packages/baz/src/*'] }, '@repo/baz/index.js' ) - ).toEqual('/ws/packages/baz/src/index.ts'); + ).toEqual(join(ws, 'packages/baz/src/index.ts')); }); it('should not resolve a sibling when the mapped path appends a different extension', () => { @@ -104,7 +108,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { it('should resolve a non-wildcard alias', () => { expect( loadFileFromPaths({ '@repo/exact': ['packages/exact'] }, '@repo/exact') - ).toEqual('/ws/packages/exact/index.ts'); + ).toEqual(join(ws, 'packages/exact/index.ts')); }); it('should append the subpath of an import matching a non-wildcard alias', () => { @@ -113,13 +117,13 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/exact': ['packages/exact'] }, '@repo/exact/thing' ) - ).toEqual('/ws/packages/exact/thing.ts'); + ).toEqual(join(ws, 'packages/exact/thing.ts')); }); it('should resolve a mid-pattern wildcard pointing at a directory', () => { expect( loadFileFromPaths({ '@lib/*': ['packages/*/src'] }, '@lib/one') - ).toEqual('/ws/packages/one/src/index.ts'); + ).toEqual(join(ws, 'packages/one/src/index.ts')); }); it('should resolve an import with an explicit extension for a non-wildcard alias', () => { @@ -128,7 +132,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/exact': ['packages/exact'] }, '@repo/exact/thing.js' ) - ).toEqual('/ws/packages/exact/thing.ts'); + ).toEqual(join(ws, 'packages/exact/thing.ts')); }); it('should not expand $ substitution patterns coming from the import', () => { @@ -137,7 +141,7 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { { '@repo/weird/*': ['packages/weird/*.ts'] }, '@repo/weird/$&' ) - ).toEqual('/ws/packages/weird/$&.ts'); + ).toEqual(join(ws, 'packages/weird/$&.ts')); }); it('should not match an alias that is only a partial prefix of the import', () => { From 17f0492a5ead3660c0997d95a819b7281beb0545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Wed, 26 Aug 2026 17:08:43 +0200 Subject: [PATCH 10/10] fix(vite): scope the alias length tiebreak to wildcards 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. --- .../utils/nx-tsconfig-paths-load-file.spec.ts | 22 +++++++++++++++++++ .../src/utils/nx-tsconfig-paths-load-file.ts | 14 ++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts index 32347687eae..54cdb6cfa80 100644 --- a/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.spec.ts @@ -16,6 +16,8 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { join(ws, 'packages/exact/thing.ts'), join(ws, 'packages/one/src/index.ts'), join(ws, 'packages/weird/$&.ts'), + join(ws, 'packages/broad/exact/thing.ts'), + join(ws, 'packages/narrow/thing.ts'), ]); const existsSyncImpl = ((path: string) => fs.has(path)) as any; @@ -150,6 +152,26 @@ describe('@nx/vite nx-tsconfig-paths-load-file', () => { ).toBeUndefined(); }); + it.each([ + [ + 'the broader alias', + { '@repo': ['packages/broad'], '@repo/exact': ['packages/narrow'] }, + 'packages/broad/exact/thing.ts', + ], + [ + 'the narrower alias', + { '@repo/exact': ['packages/narrow'], '@repo': ['packages/broad'] }, + 'packages/narrow/thing.ts', + ], + ])( + 'should keep the declaration order of non-wildcard aliases matching only a prefix, %s first', + (_, paths, expected) => { + expect(loadFileFromPaths(paths, '@repo/exact/thing')).toEqual( + join(ws, expected) + ); + } + ); + it('should return undefined when no mapped path resolves', () => { expect( loadFileFromPaths( diff --git a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts index 8c8aeec10a2..837d5b42f64 100644 --- a/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts +++ b/packages/vite/src/utils/nx-tsconfig-paths-load-file.ts @@ -87,11 +87,17 @@ function sortAliasesBySpecificity( aliases: string[], importPath: string ): string[] { + const isWildcard = (alias: string) => alias.endsWith('/*'); const rank = (alias: string) => - alias.endsWith('/*') ? 1 : importPath === alias ? 2 : 0; + isWildcard(alias) ? 1 : importPath === alias ? 2 : 0; const prefixLength = (alias: string) => alias.replace(/\/\*$/, '').length; - return [...aliases].sort( - (a, b) => rank(b) - rank(a) || prefixLength(b) - prefixLength(a) - ); + return [...aliases].sort((a, b) => { + const byRank = rank(b) - rank(a); + if (byRank !== 0) return byRank; + + // Prefix length only separates wildcards. Reordering two aliases + // TypeScript matches neither way would change resolution for no gain. + return isWildcard(a) ? prefixLength(b) - prefixLength(a) : 0; + }); }