diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 5418ca371aa3a..23de385317a79 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1213,9 +1213,28 @@ func tryGetModuleNameFromExportsOrImports( isImports bool, preferTsExtension bool, ) string { + result, _ := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, exports, conditions, mode, isImports, preferTsExtension) + return result +} + +// Inner returns (specifier, blocked). Blocked means a runtime-active target was +// tried but didn't match the file, so Node would stop here and callers must not +// fall through to later conditions or array elements. +func tryGetModuleNameFromExportsOrImportsInner( + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + targetFilePath string, + packageDirectory string, + packageName string, + exports packagejson.ExportsOrImports, + conditions []string, + mode MatchingMode, + isImports bool, + preferTsExtension bool, +) (string, bool) { switch exports.Type { case packagejson.JSONValueTypeNotPresent: - return "" + return "", false case packagejson.JSONValueTypeString: strValue := exports.Value.(string) @@ -1245,83 +1264,117 @@ func tryGetModuleNameFromExportsOrImports( tspath.ComparePaths(targetFilePath, pathOrPattern, compareOpts) == 0 || len(outputFile) > 0 && tspath.ComparePaths(outputFile, pathOrPattern, compareOpts) == 0 || len(declarationFile) > 0 && tspath.ComparePaths(declarationFile, pathOrPattern, compareOpts) == 0 { - return packageName + return packageName, false } case MatchingModeDirectory: if canTryTsExtension && tspath.ContainsPath(targetFilePath, pathOrPattern, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, targetFilePath, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), "") + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false } if len(extensionSwappedTarget) > 0 && tspath.ContainsPath(pathOrPattern, extensionSwappedTarget, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, extensionSwappedTarget, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), "") + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false } if !canTryTsExtension && tspath.ContainsPath(pathOrPattern, targetFilePath, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, targetFilePath, compareOpts) - return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), "") + return tspath.GetNormalizedAbsolutePath(tspath.CombinePaths(tspath.CombinePaths(packageName, strValue), fragment), ""), false } if len(outputFile) > 0 && tspath.ContainsPath(pathOrPattern, outputFile, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, outputFile, compareOpts) - return tspath.CombinePaths(packageName, fragment) + return tspath.CombinePaths(packageName, fragment), false } if len(declarationFile) > 0 && tspath.ContainsPath(pathOrPattern, declarationFile, compareOpts) { fragment := tspath.GetRelativePathFromDirectory(pathOrPattern, declarationFile, compareOpts) jsExtension := getJSExtensionForFile(declarationFile, options) fragmentWithJsExtension := tspath.ChangeExtension(fragment, jsExtension) - return tspath.CombinePaths(packageName, fragmentWithJsExtension) + return tspath.CombinePaths(packageName, fragmentWithJsExtension), false } case MatchingModePattern: leadingSlice, trailingSlice, _ := strings.Cut(pathOrPattern, "*") caseSensitive := host.UseCaseSensitiveFileNames() if canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if len(extensionSwappedTarget) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(extensionSwappedTarget, leadingSlice, trailingSlice, caseSensitive) { starReplacement := extensionSwappedTarget[len(leadingSlice) : len(extensionSwappedTarget)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if !canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitive) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if len(outputFile) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(outputFile, leadingSlice, trailingSlice, caseSensitive) { starReplacement := outputFile[len(leadingSlice) : len(outputFile)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(packageName, starReplacement), false } if len(declarationFile) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(declarationFile, leadingSlice, trailingSlice, caseSensitive) { starReplacement := declarationFile[len(leadingSlice) : len(declarationFile)-len(trailingSlice)] substituted := replaceFirstStar(packageName, starReplacement) jsExtension := module.TryGetJSExtensionForFile(declarationFile, options) if len(jsExtension) > 0 { - return tspath.ChangeFullExtension(substituted, jsExtension) + return tspath.ChangeFullExtension(substituted, jsExtension), false } } } - return "" + // String is an unconditional valid target: if it doesn't match the file, + // Node would still select it and fail, so it's terminal. + return "", true case packagejson.JSONValueTypeArray: + // Arrays are ordered fallbacks for undefined/invalid entries only. A valid + // string target that doesn't match the file still selects that URL at + // runtime and throws on miss, so it blocks later elements. arr := exports.AsArray() for _, e := range arr { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFilePath, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) + result, blocked := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) if len(result) > 0 { - return result + return result, false + } + if blocked { + return "", true } } + return "", false case packagejson.JSONValueTypeObject: - // conditional mapping + // conditional mapping. + // Node.js resolves conditionals by picking the first key (in object order) that + // matches the active conditions and stopping there: if that target fails to + // resolve, it throws instead of falling through to the next matching condition + // (except fallback arrays, which do try each element - see case Array above, + // which intentionally still loops). + // The reverse mapping below must mirror that, otherwise auto-import suggests + // specifiers that only resolve in TS (via fallback) but crash at runtime. + // See https://github.com/microsoft/TypeScript/issues/64171. obj := exports.AsObject() for key, value := range obj.Entries() { if key == "default" || slices.Contains(conditions, key) || slices.Contains(conditions, "types") && module.IsApplicableVersionedTypesKey(key) { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) + result, blocked := tryGetModuleNameFromExportsOrImportsInner(options, host, targetFilePath, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) if len(result) > 0 { - return result + return result, false + } + // If this key would be tried at runtime (i.e. it is not a types-only + // condition, which Node ignores) and its target was terminal + // (tried but didn't match), Node would stop here and fail. A later + // matching condition (e.g. "default" after "node") would never be + // reached, so the candidate is invalid. If the nested value was + // undefined (no active runtime key inside), Node proceeds to the + // next outer condition, so continue. Custom conditions from + // tsconfig are assumed active at runtime (per GetConditions). + if blocked && isRuntimeCondition(key) { + return "", true } } } + return "", false case packagejson.JSONValueTypeNull: - return "" + // Explicit null is terminal at runtime. + return "", true } - return "" + return "", false +} + +func isRuntimeCondition(key string) bool { + return key != "types" && !module.IsApplicableVersionedTypesKey(key) } // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 35269a9df76c9..b6585c779249c 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/packagejson" @@ -344,4 +345,198 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { }) } }) + t.Run("with conditional fallback blocked (issue 64171)", func(t *testing.T) { + t.Parallel() + + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + condExports := func(entries ...collections.MapEntry[string, packagejson.ExportsOrImports]) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList(entries), + }, + } + } + // "#*": { "node": "./dist/*/index.js", "default": "./dist/*.js" } + conditional := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: strExports("./dist/*/index.js")}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/*.js")}, + ) + conditions := []string{"import", "types", "node"} + + tests := []struct { + name string + targetFilePath string + expected string + }{ + { + name: "node condition matches, valid specifier", + targetFilePath: "/pkg/dist/utils/summarize/index.js", + expected: "#utils/summarize", + }, + { + name: "node condition shadows default, invalid specifier blocked", + targetFilePath: "/pkg/dist/utils/summarize/summarize.js", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := tryGetModuleNameFromExportsOrImports( + &core.CompilerOptions{}, + &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true}, + tt.targetFilePath, + "/pkg", + "#*", + conditional, + conditions, + MatchingModePattern, + true, + false, + ) + if result != tt.expected { + t.Errorf("tryGetModuleNameFromExportsOrImports(targetFilePath = %q) = %q, expected %q", tt.targetFilePath, result, tt.expected) + } + }) + } + }) + t.Run("types-only condition does not shadow runtime (issue 64171 follow-up)", func(t *testing.T) { + t.Parallel() + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + conditional := packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList([]collections.MapEntry[string, packagejson.ExportsOrImports]{ + {Key: "types", Value: strExports("./types/*.d.ts")}, + {Key: "default", Value: strExports("./dist/*.js")}, + }), + }, + } + result := tryGetModuleNameFromExportsOrImports( + &core.CompilerOptions{}, + &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true}, + "/pkg/dist/foo.js", + "/pkg", + "#*", + conditional, + []string{"import", "types", "node"}, + MatchingModePattern, + true, + false, + ) + if result != "#foo" { + t.Errorf("expected #foo for runtime file when types misses, got %q", result) + } + }) + t.Run("conditional edge cases (issue 64171)", func(t *testing.T) { + t.Parallel() + strExports := func(s string) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeString, + Value: s, + }, + } + } + condExports := func(entries ...collections.MapEntry[string, packagejson.ExportsOrImports]) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeObject, + Value: collections.NewOrderedMapFromList(entries), + }, + } + } + arrExports := func(elems ...packagejson.ExportsOrImports) packagejson.ExportsOrImports { + return packagejson.ExportsOrImports{ + JSONValue: packagejson.JSONValue{ + Type: packagejson.JSONValueTypeArray, + Value: elems, + }, + } + } + host := &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true} + conditions := []string{"import", "types", "node"} + cjsConditions := []string{"require", "types", "node"} + + // Arrays are not file-existence fallbacks in Node: first valid string wins, + // even if the file is missing. Only undefined/invalid entries fall through. + arrayCond := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports(strExports("./dist/a.js"), strExports("./dist/b.js"))}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/c.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/a.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("array first-element match should be valid, got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "" { + t.Errorf("array second-element match should be blocked (first valid string wins at runtime), got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/c.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "" { + t.Errorf("array miss under node should block default, got %q", got) + } + + // Array with undefined first entry falls through: [{ import: ... }] skipped when import inactive. + arrayUndefinedFirst := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: arrExports( + condExports(collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "import", Value: strExports("./dist/a.js")}), + strExports("./dist/b.js"), + )}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/c.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", arrayUndefinedFirst, cjsConditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("array undefined first entry should fallback to second element, got %q", got) + } + + // Nested conditional with no active runtime key should not block outer default. + // Outer node -> inner { import: ... } with CJS conditions (require, no import): inner skipped, outer default valid. + nestedNoMatch := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "import", Value: strExports("./dist/a.js")}, + )}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", nestedNoMatch, cjsConditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("nested no-active-key should fallback to outer default, got %q", got) + } + + // Deeper nesting: { node: { import: { browser: ./a.js } }, default: ./b.js } + // with active node/import but inactive browser -> undefined, fallback to default valid. + deepNested := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "import", Value: condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "browser", Value: strExports("./dist/a.js")}, + )}, + )}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", deepNested, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("deep nested undefined should fallback to outer default, got %q", got) + } + + // Default-first is terminal: later node unreachable. + defaultFirst := condExports( + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/a.js")}, + collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "node", Value: strExports("./dist/b.js")}, + ) + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/a.js", "/pkg", "#a", defaultFirst, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("default-first match should be valid, got %q", got) + } + if got := tryGetModuleNameFromExportsOrImports(&core.CompilerOptions{}, host, "/pkg/dist/b.js", "/pkg", "#a", defaultFirst, conditions, MatchingModeExact, true, false); got != "" { + t.Errorf("default-first miss should block later node, got %q", got) + } + }) }