fix(tsconfig): when extending, retain pathsBasePath from the original config (#29822)

This fixes a case where we incorrectly used the final config's base path
when resolving relative path mappings in the absence of the baseUrl.

Fixes #29816.
This commit is contained in:
Dmitry Gozman 2024-03-05 16:34:39 -08:00 committed by GitHub
parent 8bf8091cb1
commit a3ed799cd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 64 additions and 25 deletions

View File

@ -44,8 +44,11 @@ interface TsConfig {
export interface LoadedTsConfig {
tsConfigPath: string;
baseUrl?: string;
paths?: { [key: string]: Array<string> };
paths?: {
mapping: { [key: string]: Array<string> };
pathsBasePath: string; // absolute path
};
absoluteBaseUrl?: string;
allowJs?: boolean;
}
@ -132,25 +135,27 @@ function loadTsConfig(
for (const extendedConfig of extendsArray) {
const extendedConfigPath = resolveConfigFile(configFilePath, extendedConfig);
const base = loadTsConfig(extendedConfigPath, references, visited);
// baseUrl should be interpreted as relative to the base tsconfig,
// but we need to update it so it is relative to the original tsconfig being loaded
if (base.baseUrl && base.baseUrl) {
const extendsDir = path.dirname(extendedConfig);
base.baseUrl = path.join(extendsDir, base.baseUrl);
}
// Retain result instance, so that caching works.
Object.assign(result, base, { tsConfigPath: configFilePath });
}
const loadedConfig = Object.fromEntries(Object.entries({
baseUrl: parsedConfig.compilerOptions?.baseUrl,
paths: parsedConfig.compilerOptions?.paths,
allowJs: parsedConfig?.compilerOptions?.allowJs,
}).filter(([, value]) => value !== undefined));
// Retain result instance, so that caching works.
Object.assign(result, loadedConfig);
if (parsedConfig.compilerOptions?.allowJs !== undefined)
result.allowJs = parsedConfig.compilerOptions.allowJs;
if (parsedConfig.compilerOptions?.paths !== undefined) {
// We must store pathsBasePath from the config that defines "paths" and later resolve
// based on this absolute path, when no "baseUrl" is specified. See tsc for reference:
// https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/commandLineParser.ts#L3129
// https://github.com/microsoft/TypeScript/blob/353ccb7688351ae33ccf6e0acb913aa30621eaf4/src/compiler/moduleSpecifiers.ts#L510
result.paths = {
mapping: parsedConfig.compilerOptions.paths,
pathsBasePath: path.dirname(configFilePath),
};
}
if (parsedConfig.compilerOptions?.baseUrl !== undefined) {
// Follow tsc and resolve all relative file paths in the config right away.
// This way it is safe to inherit paths between the configs.
result.absoluteBaseUrl = path.resolve(path.dirname(configFilePath), parsedConfig.compilerOptions.baseUrl);
}
for (const ref of parsedConfig.references || [])
references.push(loadTsConfig(resolveConfigFile(configFilePath, ref.path), references, visited));

View File

@ -30,7 +30,7 @@ import { getFromCompilationCache, currentFileDepsCollector, belongsToNodeModules
const version = require('../../package.json').version;
type ParsedTsConfigData = {
absoluteBaseUrl: string;
pathsBase?: string;
paths: { key: string, values: string[] }[];
allowJs: boolean;
};
@ -58,16 +58,15 @@ export function transformConfig(): TransformConfig {
}
function validateTsConfig(tsconfig: LoadedTsConfig): ParsedTsConfigData {
// Make 'baseUrl' absolute, because it is relative to the tsconfig.json, not to cwd.
// When no explicit baseUrl is set, resolve paths relative to the tsconfig file.
// See https://www.typescriptlang.org/tsconfig#paths
const absoluteBaseUrl = path.resolve(path.dirname(tsconfig.tsConfigPath), tsconfig.baseUrl ?? '.');
const pathsBase = tsconfig.absoluteBaseUrl ?? tsconfig.paths?.pathsBasePath;
// Only add the catch-all mapping when baseUrl is specified
const pathsFallback = tsconfig.baseUrl ? [{ key: '*', values: ['*'] }] : [];
const pathsFallback = tsconfig.absoluteBaseUrl ? [{ key: '*', values: ['*'] }] : [];
return {
allowJs: !!tsconfig.allowJs,
absoluteBaseUrl,
paths: Object.entries(tsconfig.paths || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback)
pathsBase,
paths: Object.entries(tsconfig.paths?.mapping || {}).map(([key, values]) => ({ key, values })).concat(pathsFallback)
};
}
@ -132,7 +131,7 @@ export function resolveHook(filename: string, specifier: string): string | undef
let candidate = value;
if (value.includes('*'))
candidate = candidate.replace('*', matchedPartOfSpecifier);
candidate = path.resolve(tsconfig.absoluteBaseUrl, candidate);
candidate = path.resolve(tsconfig.pathsBase!, candidate);
const existing = resolveImportSpecifierExtension(candidate);
if (existing) {
longestPrefixLength = keyPrefix.length;

View File

@ -505,6 +505,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => {
}`,
'tsconfig.base1.json': `{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
},
}`,
'tsconfig.base2.json': `{
"compilerOptions": {
@ -518,7 +521,9 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => {
},
},
}`,
'a.test.ts': `
'a.test.js': `
// This js file is affected by tsconfig because allowJs is inherited.
// Next line resolve to the final baseUrl ("dir") + relative path mapping ("./foo/bar/util/*").
const { foo } = require('util/file');
import { test, expect } from '@playwright/test';
test('test', ({}, testInfo) => {
@ -534,6 +539,36 @@ test('should support extends in tsconfig.json', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
});
test('should resolve paths relative to the originating config when extending and no baseUrl', async ({ runInlineTest }) => {
const result = await runInlineTest({
'tsconfig.json': `{
"extends": ["./dir/tsconfig.base.json"],
}`,
'dir/tsconfig.base.json': `{
"compilerOptions": {
"paths": {
"~/*": ["../mapped/*"],
},
},
}`,
'a.test.ts': `
// This resolves relative to the base tsconfig that defined path mapping,
// because there is no baseUrl in the final tsconfig.
const { foo } = require('~/file');
import { test, expect } from '@playwright/test';
test('test', ({}, testInfo) => {
expect(foo).toBe('foo');
});
`,
'mapped/file.ts': `
module.exports = { foo: 'foo' };
`,
});
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
});
test('should import packages with non-index main script through path resolver', async ({ runInlineTest }) => {
const result = await runInlineTest({
'app/pkg/main.ts': `