fix: apply ESM .js extension fallback to tsconfig path alias resolution

Path alias imports (e.g. `@/utils.js` via tsconfig paths) now correctly
strip JS-family extensions and retry with TS equivalents when the literal
.js file does not exist. This applies the same stripJsExtension fallback
already used for relative imports to the alias resolution branch.

Fixes #1528
This commit is contained in:
chouzz 2026-05-12 23:40:16 +08:00
parent a2f1b07700
commit f3350696c8
2 changed files with 71 additions and 3 deletions

View file

@ -72,6 +72,13 @@ export const resolveImportPath = (
const resolved = tryResolveWithExtensions(rewritten, allFiles);
if (resolved) return cache(resolved);
// ESM fallback: strip .js/.jsx/.mjs/.cjs and retry with TS equivalents
const strippedAlias = stripJsExtension(rewritten);
if (strippedAlias !== null) {
const esmResolved = tryResolveWithExtensions(strippedAlias, allFiles);
if (esmResolved) return cache(esmResolved);
}
// Try suffix matching as fallback
const parts = rewritten.split('/').filter(Boolean);
const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index);
@ -132,9 +139,6 @@ export const resolveImportPath = (
// TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
// .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
// NOTE: This fallback only applies to relative imports. Path alias imports
// (e.g. @/utils.js via tsconfig paths) do not yet strip .js extensions —
// that is a known limitation tracked for follow-up.
if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
const stripped = stripJsExtension(basePath);
if (stripped !== null) {

View file

@ -151,3 +151,67 @@ describe('stripJsExtension', () => {
it('returns null for .ts', () => expect(stripJsExtension('foo/bar.ts')).toBeNull());
it('returns null for no extension', () => expect(stripJsExtension('foo/bar')).toBeNull());
});
describe('ESM extension resolution — path aliases with .js extensions', () => {
function resolveWithAlias(
currentFile: string,
importPath: string,
ctx: ReturnType<typeof makeCtx>,
aliases: [string, string][],
baseUrl = '.',
): string | null {
return resolveImportPath(
currentFile,
importPath,
ctx.allFilesSet,
ctx.files,
ctx.normalized,
ctx.cache,
SupportedLanguages.TypeScript,
{ aliases, baseUrl },
ctx.index,
);
}
it('resolves @/utils.js to src/utils.ts via alias', () => {
const ctx = makeCtx(['src/index.ts', 'src/utils.ts']);
const result = resolveWithAlias('src/index.ts', '@/utils.js', ctx, [['@/', 'src/']], '.');
expect(result).toBe('src/utils.ts');
});
it('resolves @/component.jsx to src/component.tsx via alias', () => {
const ctx = makeCtx(['src/index.ts', 'src/component.tsx']);
const result = resolveWithAlias(
'src/index.ts',
'@/component.jsx',
ctx,
[['@/', 'src/']],
'.',
);
expect(result).toBe('src/component.tsx');
});
it('prefers actual .js file over TS fallback in alias resolution', () => {
const ctx = makeCtx(['src/index.ts', 'src/utils.js', 'src/utils.ts']);
const result = resolveWithAlias('src/index.ts', '@/utils.js', ctx, [['@/', 'src/']], '.');
expect(result).toBe('src/utils.js');
});
it('resolves alias with baseUrl prefix', () => {
const ctx = makeCtx(['app/src/index.ts', 'app/src/helpers/token.ts']);
const result = resolveWithAlias(
'app/src/index.ts',
'~/helpers/token.js',
ctx,
[['~/', 'src/']],
'app',
);
expect(result).toBe('app/src/helpers/token.ts');
});
it('returns null when alias .js import has no matching source', () => {
const ctx = makeCtx(['src/index.ts']);
const result = resolveWithAlias('src/index.ts', '@/missing.js', ctx, [['@/', 'src/']], '.');
expect(result).toBeNull();
});
});