Skip to content

Commit a02a7ab

Browse files
authored
Follow and respect export maps when generating module specifiers (#46159)
* Follow and respect export maps when generating module specifiers * Type baseline updates from master merge
1 parent 9ed49b6 commit a02a7ab

File tree

40 files changed

+1695
-11
lines changed

40 files changed

+1695
-11
lines changed

Diff for: src/compiler/moduleNameResolver.ts

+10-8
Original file line numberDiff line numberDiff line change
@@ -1708,7 +1708,8 @@ namespace ts {
17081708
return idx === -1 ? { packageName: moduleName, rest: "" } : { packageName: moduleName.slice(0, idx), rest: moduleName.slice(idx + 1) };
17091709
}
17101710

1711-
function allKeysStartWithDot(obj: MapLike<unknown>) {
1711+
/* @internal */
1712+
export function allKeysStartWithDot(obj: MapLike<unknown>) {
17121713
return every(getOwnKeys(obj), k => startsWith(k, "."));
17131714
}
17141715

@@ -1922,14 +1923,15 @@ namespace ts {
19221923
}
19231924
return toSearchResult(/*value*/ undefined);
19241925
}
1926+
}
19251927

1926-
function isApplicableVersionedTypesKey(conditions: string[], key: string) {
1927-
if (conditions.indexOf("types") === -1) return false; // only apply versioned types conditions if the types condition is applied
1928-
if (!startsWith(key, "types@")) return false;
1929-
const range = VersionRange.tryParse(key.substring("types@".length));
1930-
if (!range) return false;
1931-
return range.test(version);
1932-
}
1928+
/* @internal */
1929+
export function isApplicableVersionedTypesKey(conditions: string[], key: string) {
1930+
if (conditions.indexOf("types") === -1) return false; // only apply versioned types conditions if the types condition is applied
1931+
if (!startsWith(key, "types@")) return false;
1932+
const range = VersionRange.tryParse(key.substring("types@".length));
1933+
if (!range) return false;
1934+
return range.test(version);
19331935
}
19341936

19351937
function loadModuleFromNearestNodeModulesDirectory(extensions: Extensions, moduleName: string, directory: string, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined): SearchResult<Resolved> {

Diff for: src/compiler/moduleSpecifiers.ts

+90-3
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,77 @@ namespace ts.moduleSpecifiers {
555555
}
556556
}
557557

558+
const enum MatchingMode {
559+
Exact,
560+
Directory,
561+
Pattern
562+
}
563+
564+
function tryGetModuleNameFromExports(options: CompilerOptions, targetFilePath: string, packageDirectory: string, packageName: string, exports: unknown, conditions: string[], mode = MatchingMode.Exact): { moduleFileToTry: string } | undefined {
565+
if (typeof exports === "string") {
566+
const pathOrPattern = getNormalizedAbsolutePath(combinePaths(packageDirectory, exports), /*currentDirectory*/ undefined);
567+
const extensionSwappedTarget = hasTSFileExtension(targetFilePath) ? removeFileExtension(targetFilePath) + tryGetJSExtensionForFile(targetFilePath, options) : undefined;
568+
switch (mode) {
569+
case MatchingMode.Exact:
570+
if (comparePaths(targetFilePath, pathOrPattern) === Comparison.EqualTo || (extensionSwappedTarget && comparePaths(extensionSwappedTarget, pathOrPattern) === Comparison.EqualTo)) {
571+
return { moduleFileToTry: packageName };
572+
}
573+
break;
574+
case MatchingMode.Directory:
575+
if (containsPath(pathOrPattern, targetFilePath)) {
576+
const fragment = getRelativePathFromDirectory(pathOrPattern, targetFilePath, /*ignoreCase*/ false);
577+
return { moduleFileToTry: getNormalizedAbsolutePath(combinePaths(combinePaths(packageName, exports), fragment), /*currentDirectory*/ undefined) };
578+
}
579+
break;
580+
case MatchingMode.Pattern:
581+
const starPos = pathOrPattern.indexOf("*");
582+
const leadingSlice = pathOrPattern.slice(0, starPos);
583+
const trailingSlice = pathOrPattern.slice(starPos + 1);
584+
if (startsWith(targetFilePath, leadingSlice) && endsWith(targetFilePath, trailingSlice)) {
585+
const starReplacement = targetFilePath.slice(leadingSlice.length, targetFilePath.length - trailingSlice.length);
586+
return { moduleFileToTry: packageName.replace("*", starReplacement) };
587+
}
588+
if (extensionSwappedTarget && startsWith(extensionSwappedTarget, leadingSlice) && endsWith(extensionSwappedTarget, trailingSlice)) {
589+
const starReplacement = extensionSwappedTarget.slice(leadingSlice.length, extensionSwappedTarget.length - trailingSlice.length);
590+
return { moduleFileToTry: packageName.replace("*", starReplacement) };
591+
}
592+
break;
593+
}
594+
}
595+
else if (Array.isArray(exports)) {
596+
return forEach(exports, e => tryGetModuleNameFromExports(options, targetFilePath, packageDirectory, packageName, e, conditions));
597+
}
598+
else if (typeof exports === "object" && exports !== null) { // eslint-disable-line no-null/no-null
599+
if (allKeysStartWithDot(exports as MapLike<unknown>)) {
600+
// sub-mappings
601+
// 3 cases:
602+
// * directory mappings (legacyish, key ends with / (technically allows index/extension resolution under cjs mode))
603+
// * pattern mappings (contains a *)
604+
// * exact mappings (no *, does not end with /)
605+
return forEach(getOwnKeys(exports as MapLike<unknown>), k => {
606+
const subPackageName = getNormalizedAbsolutePath(combinePaths(packageName, k), /*currentDirectory*/ undefined);
607+
const mode = endsWith(k, "/") ? MatchingMode.Directory
608+
: stringContains(k, "*") ? MatchingMode.Pattern
609+
: MatchingMode.Exact;
610+
return tryGetModuleNameFromExports(options, targetFilePath, packageDirectory, subPackageName, (exports as MapLike<unknown>)[k], conditions, mode);
611+
});
612+
}
613+
else {
614+
// conditional mapping
615+
for (const key of getOwnKeys(exports as MapLike<unknown>)) {
616+
if (key === "default" || conditions.indexOf(key) >= 0 || isApplicableVersionedTypesKey(conditions, key)) {
617+
const subTarget = (exports as MapLike<unknown>)[key];
618+
const result = tryGetModuleNameFromExports(options, targetFilePath, packageDirectory, packageName, subTarget, conditions);
619+
if (result) {
620+
return result;
621+
}
622+
}
623+
}
624+
}
625+
}
626+
return undefined;
627+
}
628+
558629
function tryGetModuleNameFromRootDirs(rootDirs: readonly string[], moduleFileName: string, sourceDirectory: string, getCanonicalFileName: (file: string) => string, ending: Ending, compilerOptions: CompilerOptions): string | undefined {
559630
const normalizedTargetPath = getPathRelativeToRootDirs(moduleFileName, rootDirs, getCanonicalFileName);
560631
if (normalizedTargetPath === undefined) {
@@ -586,7 +657,15 @@ namespace ts.moduleSpecifiers {
586657
let moduleFileNameForExtensionless: string | undefined;
587658
while (true) {
588659
// If the module could be imported by a directory name, use that directory's name
589-
const { moduleFileToTry, packageRootPath } = tryDirectoryWithPackageJson(packageRootIndex);
660+
const { moduleFileToTry, packageRootPath, blockedByExports, verbatimFromExports } = tryDirectoryWithPackageJson(packageRootIndex);
661+
if (getEmitModuleResolutionKind(options) !== ModuleResolutionKind.Classic) {
662+
if (blockedByExports) {
663+
return undefined; // File is under this package.json, but is not publicly exported - there's no way to name it via `node_modules` resolution
664+
}
665+
if (verbatimFromExports) {
666+
return moduleFileToTry;
667+
}
668+
}
590669
if (packageRootPath) {
591670
moduleSpecifier = packageRootPath;
592671
isPackageRootPath = true;
@@ -621,12 +700,21 @@ namespace ts.moduleSpecifiers {
621700
// For classic resolution, only allow importing from node_modules/@types, not other node_modules
622701
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Classic && packageName === nodeModulesDirectoryName ? undefined : packageName;
623702

624-
function tryDirectoryWithPackageJson(packageRootIndex: number) {
703+
function tryDirectoryWithPackageJson(packageRootIndex: number): { moduleFileToTry: string, packageRootPath?: string, blockedByExports?: true, verbatimFromExports?: true } {
625704
const packageRootPath = path.substring(0, packageRootIndex);
626705
const packageJsonPath = combinePaths(packageRootPath, "package.json");
627706
let moduleFileToTry = path;
628707
if (host.fileExists(packageJsonPath)) {
629708
const packageJsonContent = JSON.parse(host.readFile!(packageJsonPath)!);
709+
// TODO: Inject `require` or `import` condition based on the intended import mode
710+
const fromExports = packageJsonContent.exports && typeof packageJsonContent.name === "string" ? tryGetModuleNameFromExports(options, path, packageRootPath, packageJsonContent.name, packageJsonContent.exports, ["node", "types"]) : undefined;
711+
if (fromExports) {
712+
const withJsExtension = !hasTSFileExtension(fromExports.moduleFileToTry) ? fromExports : { moduleFileToTry: removeFileExtension(fromExports.moduleFileToTry) + tryGetJSExtensionForFile(fromExports.moduleFileToTry, options) };
713+
return { ...withJsExtension, verbatimFromExports: true };
714+
}
715+
if (packageJsonContent.exports) {
716+
return { moduleFileToTry: path, blockedByExports: true };
717+
}
630718
const versionPaths = packageJsonContent.typesVersions
631719
? getPackageJsonTypesVersionsPaths(packageJsonContent.typesVersions)
632720
: undefined;
@@ -641,7 +729,6 @@ namespace ts.moduleSpecifiers {
641729
moduleFileToTry = combinePaths(packageRootPath, fromPaths);
642730
}
643731
}
644-
645732
// If the file is the main module, it can be imported by the package name
646733
const mainFileRelative = packageJsonContent.typings || packageJsonContent.types || packageJsonContent.main;
647734
if (isString(mainFileRelative)) {

Diff for: src/testRunner/unittests/tsbuild/moduleSpecifiers.ts

+97
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,101 @@ namespace ts {
8888
commandLineArgs: ["-b", "/src", "--verbose"]
8989
});
9090
});
91+
92+
// https://github.com/microsoft/TypeScript/issues/44434 but with `module: node12`, some `exports` maps blocking direct access, and no `baseUrl`
93+
describe("unittests:: tsbuild:: moduleSpecifiers:: synthesized module specifiers across referenced projects resolve correctly", () => {
94+
verifyTsc({
95+
scenario: "moduleSpecifiers",
96+
subScenario: `synthesized module specifiers across projects resolve correctly`,
97+
fs: () => loadProjectFromFiles({
98+
"/src/src-types/index.ts": Utils.dedent`
99+
export * from './dogconfig.js';`,
100+
"/src/src-types/dogconfig.ts": Utils.dedent`
101+
export interface DogConfig {
102+
name: string;
103+
}`,
104+
"/src/src-dogs/index.ts": Utils.dedent`
105+
export * from 'src-types';
106+
export * from './lassie/lassiedog.js';
107+
`,
108+
"/src/src-dogs/dogconfig.ts": Utils.dedent`
109+
import { DogConfig } from 'src-types';
110+
111+
export const DOG_CONFIG: DogConfig = {
112+
name: 'Default dog',
113+
};
114+
`,
115+
"/src/src-dogs/dog.ts": Utils.dedent`
116+
import { DogConfig } from 'src-types';
117+
import { DOG_CONFIG } from './dogconfig.js';
118+
119+
export abstract class Dog {
120+
121+
public static getCapabilities(): DogConfig {
122+
return DOG_CONFIG;
123+
}
124+
}
125+
`,
126+
"/src/src-dogs/lassie/lassiedog.ts": Utils.dedent`
127+
import { Dog } from '../dog.js';
128+
import { LASSIE_CONFIG } from './lassieconfig.js';
129+
130+
export class LassieDog extends Dog {
131+
protected static getDogConfig = () => LASSIE_CONFIG;
132+
}
133+
`,
134+
"/src/src-dogs/lassie/lassieconfig.ts": Utils.dedent`
135+
import { DogConfig } from 'src-types';
136+
137+
export const LASSIE_CONFIG: DogConfig = { name: 'Lassie' };
138+
`,
139+
"/src/tsconfig-base.json": Utils.dedent`
140+
{
141+
"compilerOptions": {
142+
"declaration": true,
143+
"module": "node12"
144+
}
145+
}`,
146+
"/src/src-types/package.json": Utils.dedent`
147+
{
148+
"type": "module",
149+
"exports": "./index.js"
150+
}`,
151+
"/src/src-dogs/package.json": Utils.dedent`
152+
{
153+
"type": "module",
154+
"exports": "./index.js"
155+
}`,
156+
"/src/src-types/tsconfig.json": Utils.dedent`
157+
{
158+
"extends": "../tsconfig-base.json",
159+
"compilerOptions": {
160+
"composite": true
161+
},
162+
"include": [
163+
"**/*"
164+
]
165+
}`,
166+
"/src/src-dogs/tsconfig.json": Utils.dedent`
167+
{
168+
"extends": "../tsconfig-base.json",
169+
"compilerOptions": {
170+
"composite": true
171+
},
172+
"references": [
173+
{ "path": "../src-types" }
174+
],
175+
"include": [
176+
"**/*"
177+
]
178+
}`,
179+
}, ""),
180+
modifyFs: fs => {
181+
fs.writeFileSync("/lib/lib.es2020.full.d.ts", tscWatch.libFile.content);
182+
fs.symlinkSync("/src", "/src/src-types/node_modules");
183+
fs.symlinkSync("/src", "/src/src-dogs/node_modules");
184+
},
185+
commandLineArgs: ["-b", "src/src-types", "src/src-dogs", "--verbose"]
186+
});
187+
});
91188
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
tests/cases/conformance/node/index.ts(2,23): error TS2307: Cannot find module 'inner/other' or its corresponding type declarations.
2+
tests/cases/conformance/node/index.ts(3,14): error TS2742: The inferred type of 'a' cannot be named without a reference to './node_modules/inner/other.js'. This is likely not portable. A type annotation is necessary.
3+
tests/cases/conformance/node/index.ts(3,19): error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', or 'nodenext', and the 'target' option is set to 'es2017' or higher.
4+
5+
6+
==== tests/cases/conformance/node/index.ts (3 errors) ====
7+
// esm format file
8+
import { Thing } from "inner/other";
9+
~~~~~~~~~~~~~
10+
!!! error TS2307: Cannot find module 'inner/other' or its corresponding type declarations.
11+
export const a = (await import("inner")).x();
12+
~
13+
!!! error TS2742: The inferred type of 'a' cannot be named without a reference to './node_modules/inner/other.js'. This is likely not portable. A type annotation is necessary.
14+
~~~~~
15+
!!! error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', or 'nodenext', and the 'target' option is set to 'es2017' or higher.
16+
==== tests/cases/conformance/node/node_modules/inner/index.d.ts (0 errors) ====
17+
// esm format file
18+
export { x } from "./other.js";
19+
==== tests/cases/conformance/node/node_modules/inner/other.d.ts (0 errors) ====
20+
// esm format file
21+
export interface Thing {}
22+
export const x: () => Thing;
23+
==== tests/cases/conformance/node/package.json (0 errors) ====
24+
{
25+
"name": "package",
26+
"private": true,
27+
"type": "module",
28+
"exports": "./index.js"
29+
}
30+
==== tests/cases/conformance/node/node_modules/inner/package.json (0 errors) ====
31+
{
32+
"name": "inner",
33+
"private": true,
34+
"type": "module",
35+
"exports": "./index.js"
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//// [tests/cases/conformance/node/nodeModulesExportsBlocksSpecifierResolution.ts] ////
2+
3+
//// [index.ts]
4+
// esm format file
5+
import { Thing } from "inner/other";
6+
export const a = (await import("inner")).x();
7+
//// [index.d.ts]
8+
// esm format file
9+
export { x } from "./other.js";
10+
//// [other.d.ts]
11+
// esm format file
12+
export interface Thing {}
13+
export const x: () => Thing;
14+
//// [package.json]
15+
{
16+
"name": "package",
17+
"private": true,
18+
"type": "module",
19+
"exports": "./index.js"
20+
}
21+
//// [package.json]
22+
{
23+
"name": "inner",
24+
"private": true,
25+
"type": "module",
26+
"exports": "./index.js"
27+
}
28+
29+
//// [index.js]
30+
export const a = (await import("inner")).x();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
=== tests/cases/conformance/node/index.ts ===
2+
// esm format file
3+
import { Thing } from "inner/other";
4+
>Thing : Symbol(Thing, Decl(index.ts, 1, 8))
5+
6+
export const a = (await import("inner")).x();
7+
>a : Symbol(a, Decl(index.ts, 2, 12))
8+
>(await import("inner")).x : Symbol(x, Decl(index.d.ts, 1, 8))
9+
>"inner" : Symbol("tests/cases/conformance/node/node_modules/inner/index", Decl(index.d.ts, 0, 0))
10+
>x : Symbol(x, Decl(index.d.ts, 1, 8))
11+
12+
=== tests/cases/conformance/node/node_modules/inner/index.d.ts ===
13+
// esm format file
14+
export { x } from "./other.js";
15+
>x : Symbol(x, Decl(index.d.ts, 1, 8))
16+
17+
=== tests/cases/conformance/node/node_modules/inner/other.d.ts ===
18+
// esm format file
19+
export interface Thing {}
20+
>Thing : Symbol(Thing, Decl(other.d.ts, 0, 0))
21+
22+
export const x: () => Thing;
23+
>x : Symbol(x, Decl(other.d.ts, 2, 12))
24+
>Thing : Symbol(Thing, Decl(other.d.ts, 0, 0))
25+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
=== tests/cases/conformance/node/index.ts ===
2+
// esm format file
3+
import { Thing } from "inner/other";
4+
>Thing : any
5+
6+
export const a = (await import("inner")).x();
7+
>a : import("tests/cases/conformance/node/node_modules/inner/other").Thing
8+
>(await import("inner")).x() : import("tests/cases/conformance/node/node_modules/inner/other").Thing
9+
>(await import("inner")).x : () => import("tests/cases/conformance/node/node_modules/inner/other").Thing
10+
>(await import("inner")) : typeof import("tests/cases/conformance/node/node_modules/inner/index")
11+
>await import("inner") : typeof import("tests/cases/conformance/node/node_modules/inner/index")
12+
>import("inner") : Promise<typeof import("tests/cases/conformance/node/node_modules/inner/index")>
13+
>"inner" : "inner"
14+
>x : () => import("tests/cases/conformance/node/node_modules/inner/other").Thing
15+
16+
=== tests/cases/conformance/node/node_modules/inner/index.d.ts ===
17+
// esm format file
18+
export { x } from "./other.js";
19+
>x : () => import("tests/cases/conformance/node/node_modules/inner/other").Thing
20+
21+
=== tests/cases/conformance/node/node_modules/inner/other.d.ts ===
22+
// esm format file
23+
export interface Thing {}
24+
export const x: () => Thing;
25+
>x : () => Thing
26+

0 commit comments

Comments
 (0)