From f76e5a3aa831c41037d95e024bd3d00773e9d5d5 Mon Sep 17 00:00:00 2001 From: marwan562 Date: Sat, 5 Sep 2026 07:39:05 +0300 Subject: [PATCH 1/2] fix(auto-import): don't suggest # imports that only resolve via condition fallback Reverse mapping in tryGetModuleNameFromExportsOrImports mirrored TS resolver fallback across conditions, suggesting specifiers like #utils/summarize/summarize that resolve via default only after node misses. Node picks first matching condition and throws on miss, so such suggestions crash at runtime with ERR_MODULE_NOT_FOUND. Mirror Node first-match semantics: when a runtime-active condition misses, block later conditions. Types-only conditions are ignored at runtime and don't block. Handles nested conditionals without active runtime keys and preserves array fallback. Closes #64171 --- tsc/internal/modulespecifiers/specifiers.go | 44 ++++- .../modulespecifiers/specifiers_test.go | 165 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 5418ca371aa3a..8f2183c27cbd7 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1308,7 +1308,15 @@ func tryGetModuleNameFromExportsOrImports( } } 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) { @@ -1316,6 +1324,22 @@ func tryGetModuleNameFromExportsOrImports( if len(result) > 0 { return result } + // If this key would be tried at runtime (i.e. it is not a types-only + // condition, which Node ignores) but the target file does not match + // its target, Node would stop here and fail. A later matching + // condition (e.g. "default" after "node") would never be reached, + // so the candidate specifier is invalid and must not be suggested. + // Custom conditions from tsconfig are assumed active at runtime + // (per GetConditions), so they also block. + if isRuntimeCondition(key) { + // If the value is itself a conditional object with no active + // runtime key, Node would skip it (return undefined) and try the + // next outer condition, so do not block in that case. + if value.Type == packagejson.JSONValueTypeObject && !hasActiveRuntimeCondition(value, conditions) { + continue + } + return "" + } } } case packagejson.JSONValueTypeNull: @@ -1324,6 +1348,24 @@ func tryGetModuleNameFromExportsOrImports( return "" } +func isRuntimeCondition(key string) bool { + return key != "types" && !module.IsApplicableVersionedTypesKey(key) +} + +func hasActiveRuntimeCondition(exports packagejson.ExportsOrImports, conditions []string) bool { + if exports.Type != packagejson.JSONValueTypeObject { + return false + } + for key := range exports.AsObject().Keys() { + if key == "default" || slices.Contains(conditions, key) { + if isRuntimeCondition(key) { + return true + } + } + } + return false +} + // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? // Because when this is called by the declaration emitter, `importingSourceFile` is the implementation // file, but `importingSourceFileName` and `toFileName` refer to declaration files (the former to the diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 35269a9df76c9..35dccced63391 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,168 @@ 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"} + + // Array fallback is allowed at runtime: second element match is valid. + 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/b.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "#a" { + t.Errorf("array second-element match should be valid, 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) + } + + // 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")}, + ) + cjsConditions := []string{"require", "types", "node"} + 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) + } + + // 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) + } + }) } From 2b4f515c2b89d626e81dd19b9a0dfd9be0a31a50 Mon Sep 17 00:00:00 2001 From: marwan562 Date: Sat, 5 Sep 2026 07:53:17 +0300 Subject: [PATCH 2/2] fix(auto-import): track terminal vs undefined for conditionals and arrays Address Copilot review: arrays select first valid string target at runtime (no file-existence fallback), and deeper nested conditionals with inactive keys return undefined and should fallback. Switch reverse mapping to tri-state (matched/blocked/skipped) so terminal misses block later fallback while undefined continues. --- tsc/internal/modulespecifiers/specifiers.go | 105 ++++++++++-------- .../modulespecifiers/specifiers_test.go | 38 ++++++- 2 files changed, 92 insertions(+), 51 deletions(-) diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 8f2183c27cbd7..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,68 +1264,77 @@ 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. // Node.js resolves conditionals by picking the first key (in object order) that @@ -1320,52 +1348,35 @@ func tryGetModuleNameFromExportsOrImports( 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) but the target file does not match - // its target, Node would stop here and fail. A later matching - // condition (e.g. "default" after "node") would never be reached, - // so the candidate specifier is invalid and must not be suggested. - // Custom conditions from tsconfig are assumed active at runtime - // (per GetConditions), so they also block. - if isRuntimeCondition(key) { - // If the value is itself a conditional object with no active - // runtime key, Node would skip it (return undefined) and try the - // next outer condition, so do not block in that case. - if value.Type == packagejson.JSONValueTypeObject && !hasActiveRuntimeCondition(value, conditions) { - continue - } - return "" + // 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) } -func hasActiveRuntimeCondition(exports packagejson.ExportsOrImports, conditions []string) bool { - if exports.Type != packagejson.JSONValueTypeObject { - return false - } - for key := range exports.AsObject().Keys() { - if key == "default" || slices.Contains(conditions, key) { - if isRuntimeCondition(key) { - return true - } - } - } - return false -} - // `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`? // Because when this is called by the declaration emitter, `importingSourceFile` is the implementation // file, but `importingSourceFileName` and `toFileName` refer to declaration files (the former to the diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 35dccced63391..b6585c779249c 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -471,19 +471,36 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { } host := &mockModuleSpecifierGenerationHost{currentDir: "/pkg", useCaseSensitiveFileNames: true} conditions := []string{"import", "types", "node"} + cjsConditions := []string{"require", "types", "node"} - // Array fallback is allowed at runtime: second element match is valid. + // 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/b.js", "/pkg", "#a", arrayCond, conditions, MatchingModeExact, true, false); got != "#a" { - t.Errorf("array second-element match should be valid, got %q", got) + 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( @@ -492,11 +509,24 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { )}, collections.MapEntry[string, packagejson.ExportsOrImports]{Key: "default", Value: strExports("./dist/b.js")}, ) - cjsConditions := []string{"require", "types", "node"} 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")},