From 3e3a9deba5cc7afb6deade23056d8724fcbcd014 Mon Sep 17 00:00:00 2001 From: Gavin Kline Date: Fri, 9 Oct 2026 20:29:39 -1000 Subject: [PATCH 1/3] Memoize package.json exports lookups when generating module specifiers Generating a module specifier for a file inside a package with an "exports" map scans every entry of the map, resolving and comparing a candidate path for each, until the subpath that publishes the target file is found. The nodebuilder caches the final specifier per importing file, so declaration emit repeated the scan for every file that referenced the same target: on a build with many declaration files and dependencies with large exports maps it was most of the time spent on module specifiers and about eight percent of all allocations. The answer depends only on the parsed package.json, the package directory and name, the target file and the resolution mode, so hosts now keep a memo of it keyed by exactly those inputs. The compiler's Program owns one and its emit host forwards to it; the auto-import alias resolver owns one; hosts may return nil to opt out. Keying by the parsed package.json means a re-read file never serves a stale answer. Co-Authored-By: Claude Fable 5.1 --- tsc/internal/compiler/emitHost.go | 5 ++ tsc/internal/compiler/program.go | 7 +++ tsc/internal/ls/autoimport/aliasresolver.go | 6 ++ tsc/internal/modulespecifiers/exportscache.go | 56 +++++++++++++++++++ tsc/internal/modulespecifiers/specifiers.go | 8 +-- .../modulespecifiers/specifiers_test.go | 4 ++ tsc/internal/modulespecifiers/types.go | 3 + .../tstransforms/importelision_test.go | 5 ++ 8 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tsc/internal/modulespecifiers/exportscache.go diff --git a/tsc/internal/compiler/emitHost.go b/tsc/internal/compiler/emitHost.go index 46c160faa67fa..624c25acaaad0 100644 --- a/tsc/internal/compiler/emitHost.go +++ b/tsc/internal/compiler/emitHost.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/checker" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/modulespecifiers" "github.com/microsoft/TypeScript/tsc/internal/outputpaths" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/printer" @@ -76,6 +77,10 @@ func (host *emitHost) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *pac return host.program.GetPackageJsonInfo(pkgJsonPath) } +func (host *emitHost) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { + return host.program.PackageExportsSpecifierCache() +} + func (host *emitHost) GetSourceOfProjectReferenceIfOutputIncluded(file ast.HasFileName) tspath.RootedFilePath { return host.program.GetSourceOfProjectReferenceIfOutputIncluded(file) } diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 2c7aa08030d8a..9eda5812e62ae 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -118,6 +118,8 @@ type Program struct { declarationDiagnosticCache collections.SyncMap[*ast.SourceFile, []*ast.Diagnostic] + packageExportsSpecifierCache modulespecifiers.PackageExportsSpecifierCache + programDiagnostics []*ast.Diagnostic hasEmitBlockingDiagnostics collections.Set[tspath.PathKey] contentMapperOptionDiagnostics []*ast.Diagnostic @@ -173,6 +175,11 @@ func (p *Program) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.Root return "" } +// PackageExportsSpecifierCache implements modulespecifiers.ModuleSpecifierGenerationHost. +func (p *Program) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { + return &p.packageExportsSpecifierCache +} + // GetPackageJsonInfo implements checker.Program. func (p *Program) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *packagejson.InfoCacheEntry { directory := pkgJsonPath.Directory() diff --git a/tsc/internal/ls/autoimport/aliasresolver.go b/tsc/internal/ls/autoimport/aliasresolver.go index f34db1590fc0e..16f183e6e31cd 100644 --- a/tsc/internal/ls/autoimport/aliasresolver.go +++ b/tsc/internal/ls/autoimport/aliasresolver.go @@ -7,6 +7,7 @@ import ( "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/modulespecifiers" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/symlinks" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" @@ -28,6 +29,7 @@ type aliasResolver struct { symlinks map[tspath.PathKey]pathAndFileName onFailedAmbientModuleLookup func(source ast.HasFileName, moduleName string) resolvedModules collections.SyncMap[tspath.PathKey, *collections.SyncMap[module.ModeAwareCacheKey, *module.ResolvedModule]] + packageExportsSpecifiers modulespecifiers.PackageExportsSpecifierCache } func newAliasResolver( @@ -187,6 +189,10 @@ func (r *aliasResolver) GetJSXRuntimeImportSpecifier(path tspath.PathKey) (modul } // GetNearestAncestorDirectoryWithPackageJson implements checker.Program. +func (r *aliasResolver) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { + return &r.packageExportsSpecifiers +} + func (r *aliasResolver) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath { panic("unimplemented") } diff --git a/tsc/internal/modulespecifiers/exportscache.go b/tsc/internal/modulespecifiers/exportscache.go new file mode 100644 index 0000000000000..010d41ffb27a6 --- /dev/null +++ b/tsc/internal/modulespecifiers/exportscache.go @@ -0,0 +1,56 @@ +package modulespecifiers + +import ( + "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" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +// PackageExportsSpecifierCache memoizes the subpath that a package.json "exports" map assigns to a +// target file. Finding it scans every entry of the map, and the same (package, target) pair is looked +// up once per file that imports the target, so hosts that generate many module specifiers keep one of +// these. Entries are keyed by the parsed package.json, so a re-read package.json never serves stale +// results. The zero value is ready to use and safe for concurrent use. +type PackageExportsSpecifierCache struct { + entries collections.SyncMap[packageExportsSpecifierKey, tspath.ModuleSpecifier] +} + +type packageExportsSpecifierKey struct { + packageJson *packagejson.PackageJson + packageDirectory tspath.RootedDirectoryPath + packageName string + targetFileName tspath.RootedFilePath + mode core.ResolutionMode +} + +// getModuleNameFromExports returns the specifier under which packageJson's "exports" map publishes +// targetFileName, or "" when it is not exported, consulting the host's cache when it has one. +func getModuleNameFromExports( + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + targetFileName tspath.RootedFilePath, + packageDirectory tspath.RootedDirectoryPath, + packageName string, + packageJson *packagejson.PackageJson, + mode core.ResolutionMode, +) tspath.ModuleSpecifier { + cache := host.PackageExportsSpecifierCache() + if cache == nil { + return tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) + } + key := packageExportsSpecifierKey{ + packageJson: packageJson, + packageDirectory: packageDirectory, + packageName: packageName, + targetFileName: targetFileName, + mode: mode, + } + if specifier, ok := cache.entries.Load(key); ok { + return specifier + } + specifier := tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) + cache.entries.Store(key, specifier) + return specifier +} diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 96357d5d77005..45943713584fa 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -896,18 +896,16 @@ func tryDirectoryWithPackageJson( importMode = core.ResolutionModeESM } - conditions := module.GetConditions(options, importMode) - var fromExports tspath.ModuleSpecifier if packageJsonContent != nil && packageJsonContent.Fields.Exports.Type != packagejson.JSONValueTypeNotPresent { - fromExports = tryGetModuleNameFromExports( + fromExports = getModuleNameFromExports( options, host, pathObj.FileName, packageRootDirectory, packageName, - packageJsonContent.Fields.Exports, - conditions, + packageJsonContent, + importMode, ) } if len(fromExports) > 0 { diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 3bb7fb7853f01..06a496095dc90 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -160,6 +160,10 @@ func (h *mockModuleSpecifierGenerationHost) GetPackageJsonInfo(pkgJsonPath tspat return nil } +func (h *mockModuleSpecifierGenerationHost) PackageExportsSpecifierCache() *PackageExportsSpecifierCache { + return nil +} + func (h *mockModuleSpecifierGenerationHost) GetDefaultResolutionModeForFile(file ast.HasFileName) core.ResolutionMode { return core.ResolutionModeNone } diff --git a/tsc/internal/modulespecifiers/types.go b/tsc/internal/modulespecifiers/types.go index a58549cbc358a..7289bfcafe8e7 100644 --- a/tsc/internal/modulespecifiers/types.go +++ b/tsc/internal/modulespecifiers/types.go @@ -63,6 +63,9 @@ type ModuleSpecifierGenerationHost interface { GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *packagejson.InfoCacheEntry + // PackageExportsSpecifierCache returns the host's memo of package.json "exports" lookups, or nil + // when the host does not keep one. + PackageExportsSpecifierCache() *PackageExportsSpecifierCache GetDefaultResolutionModeForFile(file ast.HasFileName) core.ResolutionMode GetResolvedModuleFromModuleSpecifier(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) *module.ResolvedModule GetModeForUsageLocation(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) core.ResolutionMode diff --git a/tsc/internal/transformers/tstransforms/importelision_test.go b/tsc/internal/transformers/tstransforms/importelision_test.go index b6654955a7c5d..282bd8ace2857 100644 --- a/tsc/internal/transformers/tstransforms/importelision_test.go +++ b/tsc/internal/transformers/tstransforms/importelision_test.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/checker" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/modulespecifiers" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/printer" "github.com/microsoft/TypeScript/tsc/internal/symlinks" @@ -86,6 +87,10 @@ func (p *fakeProgram) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *pac return nil } +func (p *fakeProgram) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { + return nil +} + func (p *fakeProgram) GetRedirectTargets(path tspath.PathKey) []tspath.RootedFilePath { return nil } From d657d34e8bb99731b184298fc3181d2be64b7e6f Mon Sep 17 00:00:00 2001 From: Gavin Kline Date: Fri, 9 Oct 2026 22:44:38 -1000 Subject: [PATCH 2/3] Resolve path patterns once and hoist per-target work in specifier lookups Lookups through a tsconfig "paths" (or package "typesVersions") table resolved every pattern against the table's base directory and recomputed the candidate specifiers for the target file once per pattern, although neither depends on the other. With a table of a few hundred entries, as in workspaces that map every library, this dominated declaration emit. - The resolved patterns of a table are now computed once per host and kept in the module specifier generation cache, keyed by the table and its base directory. The cache, previously only for package.json "exports" lookups, is renamed GenerationCache accordingly. - The candidate specifiers are computed once per lookup. - "exports" and "imports" lookups compute the target's emitted file names once per lookup instead of once per entry, and build an entry's specifier only when the entry matches. - "exports" lookups are also keyed by the compiler options, so a host serving several option sets never returns another set's answer; a new test with custom conditions failed without this. Co-Authored-By: Claude Opus 5.5 --- tsc/internal/compiler/emitHost.go | 4 +- tsc/internal/compiler/program.go | 8 +- tsc/internal/ls/autoimport/aliasresolver.go | 12 +- tsc/internal/modulespecifiers/cache.go | 117 ++++++++ tsc/internal/modulespecifiers/cache_test.go | 194 +++++++++++++ tsc/internal/modulespecifiers/exportscache.go | 56 ---- tsc/internal/modulespecifiers/specifiers.go | 273 ++++++++++-------- .../modulespecifiers/specifiers_test.go | 30 +- tsc/internal/modulespecifiers/types.go | 6 +- .../tstransforms/importelision_test.go | 2 +- 10 files changed, 491 insertions(+), 211 deletions(-) create mode 100644 tsc/internal/modulespecifiers/cache.go create mode 100644 tsc/internal/modulespecifiers/cache_test.go delete mode 100644 tsc/internal/modulespecifiers/exportscache.go diff --git a/tsc/internal/compiler/emitHost.go b/tsc/internal/compiler/emitHost.go index 624c25acaaad0..b09739fffefc5 100644 --- a/tsc/internal/compiler/emitHost.go +++ b/tsc/internal/compiler/emitHost.go @@ -77,8 +77,8 @@ func (host *emitHost) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *pac return host.program.GetPackageJsonInfo(pkgJsonPath) } -func (host *emitHost) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { - return host.program.PackageExportsSpecifierCache() +func (host *emitHost) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return host.program.ModuleSpecifierGenerationCache() } func (host *emitHost) GetSourceOfProjectReferenceIfOutputIncluded(file ast.HasFileName) tspath.RootedFilePath { diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 9eda5812e62ae..fdc2b050f5c66 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -118,7 +118,7 @@ type Program struct { declarationDiagnosticCache collections.SyncMap[*ast.SourceFile, []*ast.Diagnostic] - packageExportsSpecifierCache modulespecifiers.PackageExportsSpecifierCache + moduleSpecifierGenerationCache modulespecifiers.GenerationCache programDiagnostics []*ast.Diagnostic hasEmitBlockingDiagnostics collections.Set[tspath.PathKey] @@ -175,9 +175,9 @@ func (p *Program) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.Root return "" } -// PackageExportsSpecifierCache implements modulespecifiers.ModuleSpecifierGenerationHost. -func (p *Program) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { - return &p.packageExportsSpecifierCache +// ModuleSpecifierGenerationCache implements modulespecifiers.ModuleSpecifierGenerationHost. +func (p *Program) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return &p.moduleSpecifierGenerationCache } // GetPackageJsonInfo implements checker.Program. diff --git a/tsc/internal/ls/autoimport/aliasresolver.go b/tsc/internal/ls/autoimport/aliasresolver.go index 16f183e6e31cd..85234c6b71c38 100644 --- a/tsc/internal/ls/autoimport/aliasresolver.go +++ b/tsc/internal/ls/autoimport/aliasresolver.go @@ -26,10 +26,10 @@ type aliasResolver struct { rootFiles []*ast.SourceFile // symlinks maps from realpath to symlinked path and file name - symlinks map[tspath.PathKey]pathAndFileName - onFailedAmbientModuleLookup func(source ast.HasFileName, moduleName string) - resolvedModules collections.SyncMap[tspath.PathKey, *collections.SyncMap[module.ModeAwareCacheKey, *module.ResolvedModule]] - packageExportsSpecifiers modulespecifiers.PackageExportsSpecifierCache + symlinks map[tspath.PathKey]pathAndFileName + onFailedAmbientModuleLookup func(source ast.HasFileName, moduleName string) + resolvedModules collections.SyncMap[tspath.PathKey, *collections.SyncMap[module.ModeAwareCacheKey, *module.ResolvedModule]] + moduleSpecifierGenerationCache modulespecifiers.GenerationCache } func newAliasResolver( @@ -189,8 +189,8 @@ func (r *aliasResolver) GetJSXRuntimeImportSpecifier(path tspath.PathKey) (modul } // GetNearestAncestorDirectoryWithPackageJson implements checker.Program. -func (r *aliasResolver) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { - return &r.packageExportsSpecifiers +func (r *aliasResolver) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { + return &r.moduleSpecifierGenerationCache } func (r *aliasResolver) GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath { diff --git a/tsc/internal/modulespecifiers/cache.go b/tsc/internal/modulespecifiers/cache.go new file mode 100644 index 0000000000000..bb7f33608d9d9 --- /dev/null +++ b/tsc/internal/modulespecifiers/cache.go @@ -0,0 +1,117 @@ +package modulespecifiers + +import ( + "strings" + + "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" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +// GenerationCache holds results that a host reuses across module specifier generation. Finding the +// subpath that a package.json "exports" map assigns to a file, and resolving the patterns of a "paths" +// or "typesVersions" table, each scan a whole table, and the same table is consulted once per file that +// needs a specifier, so hosts that generate many specifiers keep one of these. Entries are keyed by the +// table they were derived from and the compiler options, so neither a re-read package.json nor a +// different set of options is served stale results. The zero value is ready to use and safe for +// concurrent use. +type GenerationCache struct { + exports collections.SyncMap[packageExportsSpecifierKey, tspath.ModuleSpecifier] + pathPatterns collections.SyncMap[pathPatternsKey, []pathPattern] +} + +type packageExportsSpecifierKey struct { + options *core.CompilerOptions // conditions, custom conditions, and output extensions + packageJson *packagejson.PackageJson + packageDirectory tspath.RootedDirectoryPath + packageName string + targetFileName tspath.RootedFilePath + mode core.ResolutionMode +} + +// getModuleNameFromExports returns the specifier under which packageJson's "exports" map publishes +// targetFileName, or "" when it is not exported, consulting the host's cache when it has one. +func getModuleNameFromExports( + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + targetFileName tspath.RootedFilePath, + packageDirectory tspath.RootedDirectoryPath, + packageName string, + packageJson *packagejson.PackageJson, + mode core.ResolutionMode, +) tspath.ModuleSpecifier { + cache := host.ModuleSpecifierGenerationCache() + if cache == nil { + return tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) + } + key := packageExportsSpecifierKey{ + options: options, + packageJson: packageJson, + packageDirectory: packageDirectory, + packageName: packageName, + targetFileName: targetFileName, + mode: mode, + } + if specifier, ok := cache.exports.Load(key); ok { + return specifier + } + specifier := tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) + cache.exports.Store(key, specifier) + return specifier +} + +type pathPatternsKey struct { + paths *collections.OrderedMap[string, []string] + baseDirectory tspath.RootedDirectoryPath +} + +// pathPattern is one pattern of a "paths" or "typesVersions" table, resolved against the table's base +// directory and split at its wildcard. +type pathPattern struct { + key string // the table key the pattern belongs to + pattern string + prefix string // the pattern before its "*", or the whole pattern when it has none + suffix string // the pattern after its "*" + hasWildcard bool + hasExtension bool +} + +// getPathPatterns returns the patterns of paths in table order, resolved against baseDirectory. They +// depend only on the table, so they are resolved once per host when the host keeps a cache. +func getPathPatterns(host ModuleSpecifierGenerationHost, paths *collections.OrderedMap[string, []string], baseDirectory tspath.RootedDirectoryPath) []pathPattern { + cache := host.ModuleSpecifierGenerationCache() + if cache == nil { + return resolvePathPatterns(paths, baseDirectory, host.CaseSensitivity()) + } + key := pathPatternsKey{paths: paths, baseDirectory: baseDirectory} + if patterns, ok := cache.pathPatterns.Load(key); ok { + return patterns + } + patterns, _ := cache.pathPatterns.LoadOrStore(key, resolvePathPatterns(paths, baseDirectory, host.CaseSensitivity())) + return patterns +} + +func resolvePathPatterns(paths *collections.OrderedMap[string, []string], baseDirectory tspath.RootedDirectoryPath, caseSensitivity tspath.CaseSensitivity) []pathPattern { + patterns := make([]pathPattern, 0, paths.Size()) + for key, values := range paths.Entries() { + for _, patternText := range values { + normalized := tspath.NormalizePath(patternText) + pattern := resolvePathPatternIfInSameVolume(normalized, baseDirectory, caseSensitivity) + if len(pattern) == 0 { + pattern = normalized + } + prefix, suffix, hasWildcard := strings.Cut(pattern, "*") + patterns = append(patterns, pathPattern{ + key: key, + pattern: pattern, + prefix: prefix, + suffix: suffix, + hasWildcard: hasWildcard, + hasExtension: len(tspath.TryGetExtensionFromPath(pattern)) > 0, + }) + } + } + return patterns +} diff --git a/tsc/internal/modulespecifiers/cache_test.go b/tsc/internal/modulespecifiers/cache_test.go new file mode 100644 index 0000000000000..dc1d5818f5652 --- /dev/null +++ b/tsc/internal/modulespecifiers/cache_test.go @@ -0,0 +1,194 @@ +package modulespecifiers + +import ( + "fmt" + "strings" + "testing" + + "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" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +type cachingModuleSpecifierGenerationHost struct { + mockModuleSpecifierGenerationHost + cache GenerationCache +} + +func (h *cachingModuleSpecifierGenerationHost) ModuleSpecifierGenerationCache() *GenerationCache { + return &h.cache +} + +func parsePackageJson(t testing.TB, content string) *packagejson.PackageJson { + t.Helper() + fields, err := packagejson.Parse([]byte(content)) + if err != nil { + t.Fatal(err) + } + return &packagejson.PackageJson{Fields: fields, Parseable: true} +} + +func TestGenerationCacheExports(t *testing.T) { + t.Parallel() + + packageJson := parsePackageJson(t, `{ + "name": "pkg", + "exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./feature": { + "import": { "types": "./dist/esm/feature.d.mts" }, + "require": { "types": "./dist/cjs/feature.d.cts" } + }, + "./source": { "source": "./src/source.ts", "default": "./dist/source.js" }, + "./utils/*": { "types": "./dist/utils/*.d.ts" } + } + }`) + const packageDirectory = tspath.RootedDirectoryPath("/node_modules/pkg") + nodeNext := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext} + withSourceCondition := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext, CustomConditions: []string{"source"}} + + tests := []struct { + name string + target tspath.RootedFilePath + options *core.CompilerOptions + mode core.ResolutionMode + want string + }{ + {"root", "/node_modules/pkg/dist/index.d.ts", nodeNext, core.ResolutionModeESM, "pkg"}, + {"import condition", "/node_modules/pkg/dist/esm/feature.d.mts", nodeNext, core.ResolutionModeESM, "pkg/feature"}, + {"import condition from require", "/node_modules/pkg/dist/esm/feature.d.mts", nodeNext, core.ResolutionModeCommonJS, ""}, + {"require condition", "/node_modules/pkg/dist/cjs/feature.d.cts", nodeNext, core.ResolutionModeCommonJS, "pkg/feature"}, + {"custom condition", "/node_modules/pkg/src/source.ts", withSourceCondition, core.ResolutionModeESM, "pkg/source"}, + {"custom condition not enabled", "/node_modules/pkg/src/source.ts", nodeNext, core.ResolutionModeESM, ""}, + {"pattern", "/node_modules/pkg/dist/utils/strings.d.ts", nodeNext, core.ResolutionModeESM, "pkg/utils/strings"}, + {"not exported", "/node_modules/pkg/dist/internal.d.ts", nodeNext, core.ResolutionModeESM, ""}, + } + + uncached := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive} + cached := &cachingModuleSpecifierGenerationHost{mockModuleSpecifierGenerationHost: *uncached} + // Run every case twice against one cache, so that the second pass is answered from entries the + // other cases stored and would expose a key that omits the options, mode, or target. + for pass := range 2 { + for _, tt := range tests { + uncachedResult := getModuleNameFromExports(tt.options, uncached, tt.target, packageDirectory, "pkg", packageJson, tt.mode) + if uncachedResult.AsString() != tt.want { + t.Errorf("%s: uncached lookup = %q, want %q", tt.name, uncachedResult, tt.want) + } + if cachedResult := getModuleNameFromExports(tt.options, cached, tt.target, packageDirectory, "pkg", packageJson, tt.mode); cachedResult.AsString() != tt.want { + t.Errorf("%s (pass %d): cached lookup = %q, want %q", tt.name, pass, cachedResult, tt.want) + } + } + } + + // A re-read package.json is a different *PackageJson and must not be answered from the old entries. + reread := parsePackageJson(t, `{ "name": "pkg", "exports": { "./moved": "./dist/index.d.ts" } }`) + if got := getModuleNameFromExports(nodeNext, cached, "/node_modules/pkg/dist/index.d.ts", packageDirectory, "pkg", reread, core.ResolutionModeESM); got.AsString() != "pkg/moved" { + t.Errorf("lookup after re-reading package.json = %q, want %q", got, "pkg/moved") + } +} + +func TestGenerationCachePathPatterns(t *testing.T) { + t.Parallel() + + paths := collections.NewOrderedMapWithSizeHint[string, []string](4) + paths.Set("@org/lib", []string{"./libs/lib/src/index.ts"}) + paths.Set("@org/lib/*", []string{"./libs/lib/src/*"}) + paths.Set("@app/*", []string{"./dist/*.d.ts"}) + paths.Set("@abs/*", []string{"/repo/shared/*"}) + options := &core.CompilerOptions{Module: core.ModuleKindESNext, ModuleResolution: core.ModuleResolutionKindBundler} + allowedEndings := []ModuleSpecifierEnding{ModuleSpecifierEndingMinimal, ModuleSpecifierEndingIndex, ModuleSpecifierEndingJsExtension} + + tests := []struct { + name string + baseDirectory tspath.RootedDirectoryPath + target tspath.RootedFilePath + relativeToBaseUrl string + want string + }{ + {"exact entry", "/repo", "/repo/libs/lib/src/index.ts", "libs/lib/src/index.ts", "@org/lib"}, + {"wildcard entry", "/repo", "/repo/libs/lib/src/models/user.ts", "libs/lib/src/models/user.ts", "@org/lib/models/user"}, + {"pattern with extension", "/repo", "/repo/dist/haha.d.ts", "dist/haha.d.ts", "@app/haha"}, + {"not mapped", "/repo", "/repo/other/file.ts", "other/file.ts", ""}, + // An absolute pattern resolves differently against each base directory, so these two would + // disagree if the resolved patterns were shared between base directories. + {"absolute pattern from its own root", "/repo", "/repo/shared/util.ts", "shared/util.ts", "@abs/util"}, + {"absolute pattern from a sibling", "/repo/packages/app", "/repo/shared/util.ts", "../../shared/util.ts", "@abs/util"}, + } + + uncached := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive, existingFiles: map[tspath.RootedFilePath]bool{}} + cached := &cachingModuleSpecifierGenerationHost{mockModuleSpecifierGenerationHost: *uncached} + for pass := range 2 { + for _, tt := range tests { + if got := tryGetModuleNameFromPaths(tt.relativeToBaseUrl, tt.target, paths, allowedEndings, tt.baseDirectory, uncached, options); got != tt.want { + t.Errorf("%s: uncached lookup = %q, want %q", tt.name, got, tt.want) + } + if got := tryGetModuleNameFromPaths(tt.relativeToBaseUrl, tt.target, paths, allowedEndings, tt.baseDirectory, cached, options); got != tt.want { + t.Errorf("%s (pass %d): cached lookup = %q, want %q", tt.name, pass, got, tt.want) + } + } + } +} + +// exportsMapWithSubpaths returns a package.json with count conditional subpath exports, shaped like +// the exports map of a package that publishes one entry point per module. +func exportsMapWithSubpaths(count int) string { + var b strings.Builder + b.WriteString(`{ "name": "pkg", "exports": { ".": { "types": "./dist/dts/index.d.ts", "import": "./dist/esm/index.js", "default": "./dist/cjs/index.js" }`) + for i := range count { + fmt.Fprintf(&b, `, "./Module%d": { "types": "./dist/dts/Module%d.d.ts", "import": "./dist/esm/Module%d.js", "default": "./dist/cjs/Module%d.js" }`, i, i, i, i) + } + b.WriteString(` } }`) + return b.String() +} + +func BenchmarkTryGetModuleNameFromExports(b *testing.B) { + const subpaths = 180 + packageJson := parsePackageJson(b, exportsMapWithSubpaths(subpaths)) + options := &core.CompilerOptions{Module: core.ModuleKindNodeNext, ModuleResolution: core.ModuleResolutionKindNodeNext} + conditions := module.GetConditions(options, core.ResolutionModeESM) + host := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseInsensitive} + target := tspath.RootedFilePathFromNormalized(fmt.Sprintf("/Users/Dev/project/node_modules/pkg/dist/dts/Module%d.d.ts", subpaths-1)) + packageDirectory := tspath.RootedDirectoryPath("/Users/Dev/project/node_modules/pkg") + want := tspath.ToModuleSpecifier(fmt.Sprintf("pkg/Module%d", subpaths-1)) + + b.ReportAllocs() + for b.Loop() { + if got := tryGetModuleNameFromExports(options, host, target, packageDirectory, "pkg", packageJson.Fields.Exports, conditions); got != want { + b.Fatalf("got %q, want %q", got, want) + } + } +} + +func BenchmarkTryGetModuleNameFromPaths(b *testing.B) { + benchmarkTryGetModuleNameFromPaths(b, &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseInsensitive, existingFiles: map[tspath.RootedFilePath]bool{}}) +} + +func BenchmarkTryGetModuleNameFromPathsCached(b *testing.B) { + benchmarkTryGetModuleNameFromPaths(b, &cachingModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseInsensitive, existingFiles: map[tspath.RootedFilePath]bool{}}) +} + +// benchmarkTryGetModuleNameFromPaths looks up a file through a "paths" table shaped like the one in an Nx +// workspace, with an exact and a wildcard entry per library, where the file's library comes last. +func benchmarkTryGetModuleNameFromPaths(b *testing.B, host ModuleSpecifierGenerationHost) { + const libraries = 200 + paths := collections.NewOrderedMapWithSizeHint[string, []string](2 * libraries) + for i := range libraries { + paths.Set(fmt.Sprintf("@org/lib%d", i), []string{fmt.Sprintf("./libs/lib%d/src/index.ts", i)}) + paths.Set(fmt.Sprintf("@org/lib%d/*", i), []string{fmt.Sprintf("./libs/lib%d/src/*", i)}) + } + options := &core.CompilerOptions{Module: core.ModuleKindESNext, ModuleResolution: core.ModuleResolutionKindBundler} + baseDirectory := tspath.RootedDirectoryPath("/Users/Dev/workspace") + target := tspath.RootedFilePathFromNormalized(fmt.Sprintf("/Users/Dev/workspace/libs/lib%d/src/models/user.ts", libraries-1)) + relativeToBaseUrl := fmt.Sprintf("libs/lib%d/src/models/user.ts", libraries-1) + allowedEndings := []ModuleSpecifierEnding{ModuleSpecifierEndingMinimal, ModuleSpecifierEndingIndex, ModuleSpecifierEndingJsExtension} + want := fmt.Sprintf("@org/lib%d/models/user", libraries-1) + + b.ReportAllocs() + for b.Loop() { + if got := tryGetModuleNameFromPaths(relativeToBaseUrl, target, paths, allowedEndings, baseDirectory, host, options); got != want { + b.Fatalf("got %q, want %q", got, want) + } + } +} diff --git a/tsc/internal/modulespecifiers/exportscache.go b/tsc/internal/modulespecifiers/exportscache.go deleted file mode 100644 index 010d41ffb27a6..0000000000000 --- a/tsc/internal/modulespecifiers/exportscache.go +++ /dev/null @@ -1,56 +0,0 @@ -package modulespecifiers - -import ( - "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" - "github.com/microsoft/TypeScript/tsc/internal/tspath" -) - -// PackageExportsSpecifierCache memoizes the subpath that a package.json "exports" map assigns to a -// target file. Finding it scans every entry of the map, and the same (package, target) pair is looked -// up once per file that imports the target, so hosts that generate many module specifiers keep one of -// these. Entries are keyed by the parsed package.json, so a re-read package.json never serves stale -// results. The zero value is ready to use and safe for concurrent use. -type PackageExportsSpecifierCache struct { - entries collections.SyncMap[packageExportsSpecifierKey, tspath.ModuleSpecifier] -} - -type packageExportsSpecifierKey struct { - packageJson *packagejson.PackageJson - packageDirectory tspath.RootedDirectoryPath - packageName string - targetFileName tspath.RootedFilePath - mode core.ResolutionMode -} - -// getModuleNameFromExports returns the specifier under which packageJson's "exports" map publishes -// targetFileName, or "" when it is not exported, consulting the host's cache when it has one. -func getModuleNameFromExports( - options *core.CompilerOptions, - host ModuleSpecifierGenerationHost, - targetFileName tspath.RootedFilePath, - packageDirectory tspath.RootedDirectoryPath, - packageName string, - packageJson *packagejson.PackageJson, - mode core.ResolutionMode, -) tspath.ModuleSpecifier { - cache := host.PackageExportsSpecifierCache() - if cache == nil { - return tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) - } - key := packageExportsSpecifierKey{ - packageJson: packageJson, - packageDirectory: packageDirectory, - packageName: packageName, - targetFileName: targetFileName, - mode: mode, - } - if specifier, ok := cache.entries.Load(key); ok { - return specifier - } - specifier := tryGetModuleNameFromExports(options, host, targetFileName, packageDirectory, packageName, packageJson.Fields.Exports, module.GetConditions(options, mode)) - cache.entries.Store(key, specifier) - return specifier -} diff --git a/tsc/internal/modulespecifiers/specifiers.go b/tsc/internal/modulespecifiers/specifiers.go index 45943713584fa..65e66f725397a 100644 --- a/tsc/internal/modulespecifiers/specifiers.go +++ b/tsc/internal/modulespecifiers/specifiers.go @@ -1007,6 +1007,7 @@ func tryGetModuleNameFromExports( exports packagejson.ExportsOrImports, conditions []string, ) tspath.ModuleSpecifier { + target := newExportsOrImportsTarget(targetFileName, options, host, false /*isImports*/, false /*preferTsExtension*/) if exports.IsSubpaths() { // sub-mappings // 3 cases: @@ -1014,14 +1015,13 @@ func tryGetModuleNameFromExports( // * pattern mappings (contains a *) // * exact mappings (no *, does not end with /) for k, subk := range exports.AsObject().Entries() { - subPackageName := tspath.ResolvePathWithoutTrailingDirectorySeparator(packageName, k) mode := MatchingModeExact if strings.HasSuffix(k, "/") { mode = MatchingModeDirectory } else if strings.Contains(k, "*") { mode = MatchingModePattern } - result := tryGetModuleNameFromExportsOrImports(options, host, targetFileName, packageDirectory, subPackageName, subk, conditions, mode /*isImports*/, false /*preferTsExtension*/, false) + result := tryGetModuleNameFromExportsOrImports(options, host, &target, packageDirectory, packageName, k, subk, conditions, mode) if len(result) > 0 { return tspath.ToModuleSpecifier(result) } @@ -1030,14 +1030,13 @@ func tryGetModuleNameFromExports( return tspath.ToModuleSpecifier(tryGetModuleNameFromExportsOrImports( options, host, - targetFileName, + &target, packageDirectory, packageName, + "", /*subpath*/ exports, conditions, MatchingModeExact, - /*isImports*/ false, - /*preferTsExtension*/ false, )) } @@ -1070,6 +1069,7 @@ func tryGetModuleNameFromPackageJsonImports( return "" // not present or invalid for imports case packagejson.JSONValueTypeObject: conditions := module.GetConditions(options, importMode) + target := newExportsOrImportsTarget(moduleFileName, options, host, true /*isImports*/, preferTsExtension) top := imports.AsObject() entries := top.Entries() for k, value := range entries { @@ -1088,14 +1088,13 @@ func tryGetModuleNameFromPackageJsonImports( result := tryGetModuleNameFromExportsOrImports( options, host, - moduleFileName, + &target, ancestorDirectoryWithPackageJson, k, + "", /*subpath*/ value, conditions, mode, - true, - preferTsExtension, ) if len(result) > 0 { return tspath.ToModuleSpecifier(result) @@ -1121,94 +1120,89 @@ func tryGetModuleNameFromPaths( compilerOptions *core.CompilerOptions, ) string { caseSensitivity := host.CaseSensitivity() - for key, values := range paths.Entries() { - for _, patternText := range values { - normalized := tspath.NormalizePath(patternText) - pattern := resolvePathPatternIfInSameVolume(normalized, baseDirectory, caseSensitivity) - if len(pattern) == 0 { - pattern = normalized - } - prefix, suffix, ok := strings.Cut(pattern, "*") - - // In module resolution, if `pattern` itself has an extension, a file with that extension is looked up directly, - // meaning a '.ts' or '.d.ts' extension is allowed to resolve. This is distinct from the case where a '*' substitution - // causes a module specifier to have an extension, i.e. the extension comes from the module specifier in a JS/TS file - // and matches the '*'. For example: - // - // Module Specifier | Path Mapping (key: [pattern]) | Interpolation | Resolution Action - // ---------------------->------------------------------->--------------------->--------------------------------------------------------------- - // import "@app/foo" -> "@app/*": ["./src/app/*.ts"] -> "./src/app/foo.ts" -> tryFile("./src/app/foo.ts") || [continue resolution algorithm] - // import "@app/foo.ts" -> "@app/*": ["./src/app/*"] -> "./src/app/foo.ts" -> [continue resolution algorithm] - // - // (https://github.com/microsoft/TypeScript/blob/ad4ded80e1d58f0bf36ac16bea71bc10d9f09895/src/compiler/moduleNameResolver.ts#L2509-L2516) - // - // The interpolation produced by both scenarios is identical, but only in the former, where the extension is encoded in - // the path mapping rather than in the module specifier, will we prioritize a file lookup on the interpolation result. - // (In fact, currently, the latter scenario will necessarily fail since no resolution mode recognizes '.ts' as a valid - // extension for a module specifier.) - // - // Here, this means we need to be careful about whether we generate a match from the target filename (typically with a - // .ts extension) or the possible relative module specifiers representing that file: - // - // Filename | Relative Module Specifier Candidates | Path Mapping | Filename Result | Module Specifier Results - // --------------------<----------------------------------------------<------------------------------<-------------------||---------------------------- - // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*.d.ts"] <- @app/haha || (none) - // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*"] <- (none) || @app/haha, @app/haha.js - // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*.d.ts"] <- @app/foo/index || (none) - // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*"] <- (none) || @app/foo, @app/foo/index, @app/foo/index.js - // dist/wow.js.js <- dist/wow.js, dist/wow.js.js <- "@app/*": ["./dist/*.js"] <- @app/wow.js || @app/wow, @app/wow.js - // - // The "Filename Result" can be generated only if `pattern` has an extension. Care must be taken that the list of - // relative module specifiers to run the interpolation (a) is actually valid for the module resolution mode, (b) takes - // into account the existence of other files (e.g. 'dist/wow.js' cannot refer to 'dist/wow.js.js' if 'dist/wow.js' - // exists) and (c) that they are ordered by preference. The last row shows that the filename result and module - // specifier results are not mutually exclusive. Note that the filename result is a higher priority in module - // resolution, but as long criteria (b) above is met, I don't think its result needs to be the highest priority result - // in module specifier generation. I have included it last, as it's difficult to tell exactly where it should be - // sorted among the others for a particular value of `importModuleSpecifierEnding`. - - var candidates []specPair - for _, ending := range allowedEndings { - result := processEnding( - tspath.ToModuleSpecifier(relativeToBaseUrl), - fileName, - []ModuleSpecifierEnding{ending}, - compilerOptions, - host, - ) - candidates = append(candidates, specPair{ - ending: ending, - value: result, - }) - } - if len(tspath.TryGetExtensionFromPath(pattern)) > 0 { - candidates = append(candidates, specPair{ - ending: ModuleSpecifierEndingJsExtension, - value: tspath.ToModuleSpecifier(relativeToBaseUrl), - }) - } + // The specifiers relativeToBaseUrl can be written as, in order of preference. They don't depend on the + // pattern, so they are computed once; the last entry, the file name as written, is only considered by + // patterns that have an extension themselves (see below). + candidates := make([]specPair, 0, len(allowedEndings)+1) + for _, ending := range allowedEndings { + candidates = append(candidates, specPair{ + ending: ending, + value: processEnding( + tspath.ToModuleSpecifier(relativeToBaseUrl), + fileName, + []ModuleSpecifierEnding{ending}, + compilerOptions, + host, + ), + }) + } + candidates = append(candidates, specPair{ + ending: ModuleSpecifierEndingJsExtension, + value: tspath.ToModuleSpecifier(relativeToBaseUrl), + }) + for _, p := range getPathPatterns(host, paths, baseDirectory) { + // In module resolution, if `pattern` itself has an extension, a file with that extension is looked up directly, + // meaning a '.ts' or '.d.ts' extension is allowed to resolve. This is distinct from the case where a '*' substitution + // causes a module specifier to have an extension, i.e. the extension comes from the module specifier in a JS/TS file + // and matches the '*'. For example: + // + // Module Specifier | Path Mapping (key: [pattern]) | Interpolation | Resolution Action + // ---------------------->------------------------------->--------------------->--------------------------------------------------------------- + // import "@app/foo" -> "@app/*": ["./src/app/*.ts"] -> "./src/app/foo.ts" -> tryFile("./src/app/foo.ts") || [continue resolution algorithm] + // import "@app/foo.ts" -> "@app/*": ["./src/app/*"] -> "./src/app/foo.ts" -> [continue resolution algorithm] + // + // (https://github.com/microsoft/TypeScript/blob/ad4ded80e1d58f0bf36ac16bea71bc10d9f09895/src/compiler/moduleNameResolver.ts#L2509-L2516) + // + // The interpolation produced by both scenarios is identical, but only in the former, where the extension is encoded in + // the path mapping rather than in the module specifier, will we prioritize a file lookup on the interpolation result. + // (In fact, currently, the latter scenario will necessarily fail since no resolution mode recognizes '.ts' as a valid + // extension for a module specifier.) + // + // Here, this means we need to be careful about whether we generate a match from the target filename (typically with a + // .ts extension) or the possible relative module specifiers representing that file: + // + // Filename | Relative Module Specifier Candidates | Path Mapping | Filename Result | Module Specifier Results + // --------------------<----------------------------------------------<------------------------------<-------------------||---------------------------- + // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*.d.ts"] <- @app/haha || (none) + // dist/haha.d.ts <- dist/haha, dist/haha.js <- "@app/*": ["./dist/*"] <- (none) || @app/haha, @app/haha.js + // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*.d.ts"] <- @app/foo/index || (none) + // dist/foo/index.d.ts <- dist/foo, dist/foo/index, dist/foo/index.js <- "@app/*": ["./dist/*"] <- (none) || @app/foo, @app/foo/index, @app/foo/index.js + // dist/wow.js.js <- dist/wow.js, dist/wow.js.js <- "@app/*": ["./dist/*.js"] <- @app/wow.js || @app/wow, @app/wow.js + // + // The "Filename Result" can be generated only if `pattern` has an extension. Care must be taken that the list of + // relative module specifiers to run the interpolation (a) is actually valid for the module resolution mode, (b) takes + // into account the existence of other files (e.g. 'dist/wow.js' cannot refer to 'dist/wow.js.js' if 'dist/wow.js' + // exists) and (c) that they are ordered by preference. The last row shows that the filename result and module + // specifier results are not mutually exclusive. Note that the filename result is a higher priority in module + // resolution, but as long criteria (b) above is met, I don't think its result needs to be the highest priority result + // in module specifier generation. I have included it last, as it's difficult to tell exactly where it should be + // sorted among the others for a particular value of `importModuleSpecifierEnding`. + + patternCandidates := candidates[:len(allowedEndings)] + if p.hasExtension { + patternCandidates = candidates + } - if ok { - for _, c := range candidates { - value := c.value.AsString() - if len(value) >= len(prefix)+len(suffix) && - stringutil.HasPrefix(value, prefix, caseSensitivity.IsCaseSensitive()) && // TODO: possible strada bug: these are not case-switched in strada - stringutil.HasSuffix(value, suffix, caseSensitivity.IsCaseSensitive()) && - validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) { - matchedStar := value[len(prefix) : len(value)-len(suffix)] - if !tspath.PathIsRelative(matchedStar) { - return replaceFirstStar(key, matchedStar) - } + if p.hasWildcard { + for _, c := range patternCandidates { + value := c.value.AsString() + if len(value) >= len(p.prefix)+len(p.suffix) && + stringutil.HasPrefix(value, p.prefix, caseSensitivity.IsCaseSensitive()) && // TODO: possible strada bug: these are not case-switched in strada + stringutil.HasSuffix(value, p.suffix, caseSensitivity.IsCaseSensitive()) && + validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) { + matchedStar := value[len(p.prefix) : len(value)-len(p.suffix)] + if !tspath.PathIsRelative(matchedStar) { + return replaceFirstStar(p.key, matchedStar) } } - } else if core.Some(candidates, func(c specPair) bool { - return c.ending != ModuleSpecifierEndingMinimal && pattern == c.value.AsString() - }) || - core.Some(candidates, func(c specPair) bool { - return c.ending == ModuleSpecifierEndingMinimal && pattern == c.value.AsString() && validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) - }) { - return key } + } else if core.Some(patternCandidates, func(c specPair) bool { + return c.ending != ModuleSpecifierEndingMinimal && p.pattern == c.value.AsString() + }) || + core.Some(patternCandidates, func(c specPair) bool { + return c.ending == ModuleSpecifierEndingMinimal && p.pattern == c.value.AsString() && validateEnding(c, relativeToBaseUrl, fileName, compilerOptions, host) + }) { + return p.key } } return "" @@ -1225,39 +1219,69 @@ func validateEnding(c specPair, relativeToBaseUrl string, fileName tspath.Rooted return c.ending != ModuleSpecifierEndingMinimal || c.value == processEnding(tspath.ToModuleSpecifier(relativeToBaseUrl), fileName, []ModuleSpecifierEnding{c.ending}, compilerOptions, host) } +// exportsOrImportsTarget is the file that a package.json "exports" or "imports" lookup searches for, +// in each form an entry may name it. The forms depend only on the target, so a lookup computes them +// once instead of once per entry. +type exportsOrImportsTarget struct { + fileName tspath.RootedFilePath + extensionSwappedTarget tspath.RootedFilePath // the JS file a TS target is emitted as + outputFile tspath.RootedFilePath // "imports" only: the target's JS output + declarationFile tspath.RootedFilePath // "imports" only: the target's declaration output + canTryTsExtension bool +} + +func newExportsOrImportsTarget( + targetFileName tspath.RootedFilePath, + options *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + isImports bool, + preferTsExtension bool, +) exportsOrImportsTarget { + target := exportsOrImportsTarget{ + fileName: targetFileName, + canTryTsExtension: preferTsExtension && targetFileName.HasImplementationTSFileExtension(), + } + // possible strada bug? Always uses compilerOptions of the host project, not those applicable to the targeted package.json! + if isImports { + target.outputFile = outputpaths.GetOutputJSFileNameWorker(targetFileName, options, host) + target.declarationFile = outputpaths.GetOutputDeclarationFileNameWorker(targetFileName, options, host) + } + if targetFileName.HasTSFileExtension() { + target.extensionSwappedTarget = targetFileName.RemoveFileExtension().AppendSuffix(module.TryGetJSExtensionForFileName(targetFileName, options)) + } + return target +} + +// exportsEntryName returns the specifier published by the "exports" entry under subpath, or by the +// package itself when subpath is empty. Lookups build it only for the entry that matches. +func exportsEntryName(packageName string, subpath string) string { + if subpath == "" { + return packageName + } + return tspath.ResolvePathWithoutTrailingDirectorySeparator(packageName, subpath) +} + func tryGetModuleNameFromExportsOrImports( options *core.CompilerOptions, host ModuleSpecifierGenerationHost, - targetFileName tspath.RootedFilePath, + target *exportsOrImportsTarget, packageDirectory tspath.RootedDirectoryPath, packageName string, + subpath string, exports packagejson.ExportsOrImports, conditions []string, mode MatchingMode, - isImports bool, - preferTsExtension bool, ) string { - packageSpecifier := tspath.ToModuleSpecifier(packageName) switch exports.Type { case packagejson.JSONValueTypeNotPresent: return "" case packagejson.JSONValueTypeString: strValue := exports.Value.(string) - - // possible strada bug? Always uses compilerOptions of the host project, not those applicable to the targeted package.json! - var outputFile tspath.RootedFilePath - var declarationFile tspath.RootedFilePath - if isImports { - outputFile = outputpaths.GetOutputJSFileNameWorker(targetFileName, options, host) - declarationFile = outputpaths.GetOutputDeclarationFileNameWorker(targetFileName, options, host) - } - - var extensionSwappedTarget tspath.RootedFilePath - if targetFileName.HasTSFileExtension() { - extensionSwappedTarget = targetFileName.RemoveFileExtension().AppendSuffix(module.TryGetJSExtensionForFileName(targetFileName, options)) - } - canTryTsExtension := preferTsExtension && targetFileName.HasImplementationTSFileExtension() - + targetFileName := target.fileName + extensionSwappedTarget := target.extensionSwappedTarget + outputFile := target.outputFile + declarationFile := target.declarationFile + canTryTsExtension := target.canTryTsExtension caseSensitivity := host.CaseSensitivity() switch mode { @@ -1270,7 +1294,7 @@ func tryGetModuleNameFromExportsOrImports( caseSensitivity.CompareFilePaths(targetFileName, resolvedTarget) == 0 || len(outputFile) > 0 && caseSensitivity.CompareFilePaths(outputFile, resolvedTarget) == 0 || len(declarationFile) > 0 && caseSensitivity.CompareFilePaths(declarationFile, resolvedTarget) == 0 { - return packageName + return exportsEntryName(packageName, subpath) } case MatchingModeDirectory: resolvedTarget := packageDirectory.ResolveDirectory(tspath.RemoveTrailingDirectorySeparator(strValue)) @@ -1280,50 +1304,49 @@ func tryGetModuleNameFromExportsOrImports( tspath.RootedPath(resolvedTarget)) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, targetFileName) - return packageSpecifier.Resolve(strValue, fragment.AsString()).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).Resolve(strValue, fragment.AsString()).AsString() } if len(extensionSwappedTarget) > 0 && caseSensitivity.ContainsFilePath(resolvedTarget, extensionSwappedTarget) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, extensionSwappedTarget) - return packageSpecifier.Resolve(strValue, fragment.AsString()).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).Resolve(strValue, fragment.AsString()).AsString() } if !canTryTsExtension && caseSensitivity.ContainsFilePath(resolvedTarget, targetFileName) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, targetFileName) - return packageSpecifier.Resolve(strValue, fragment.AsString()).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).Resolve(strValue, fragment.AsString()).AsString() } if len(outputFile) > 0 && caseSensitivity.ContainsFilePath(resolvedTarget, outputFile) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, outputFile) - return packageSpecifier.CombineRelative(fragment).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).CombineRelative(fragment).AsString() } if len(declarationFile) > 0 && caseSensitivity.ContainsFilePath(resolvedTarget, declarationFile) { fragment, _ := caseSensitivity.RelativePathFromDirectory(resolvedTarget, declarationFile) jsExtension := getJSExtensionForFileName(declarationFile, options) fragmentWithJsExtension := fragment.ChangeExtension(jsExtension) - return packageSpecifier.CombineRelative(fragmentWithJsExtension).AsString() + return tspath.ToModuleSpecifier(exportsEntryName(packageName, subpath)).CombineRelative(fragmentWithJsExtension).AsString() } case MatchingModePattern: pathOrPattern := tspath.ResolvePath(packageDirectory.AsString(), strValue) leadingSlice, trailingSlice, _ := strings.Cut(pathOrPattern, "*") - caseSensitivity := host.CaseSensitivity() targetFilePath := targetFileName.AsString() if canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if extensionSwappedTargetString := extensionSwappedTarget.AsString(); len(extensionSwappedTargetString) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(extensionSwappedTargetString, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := extensionSwappedTargetString[len(leadingSlice) : len(extensionSwappedTargetString)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if !canTryTsExtension && stringutil.HasPrefixAndSuffixWithoutOverlap(targetFilePath, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := targetFilePath[len(leadingSlice) : len(targetFilePath)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if outputFileString := outputFile.AsString(); len(outputFileString) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(outputFileString, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := outputFileString[len(leadingSlice) : len(outputFileString)-len(trailingSlice)] - return replaceFirstStar(packageName, starReplacement) + return replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) } if declarationFileString := declarationFile.AsString(); len(declarationFileString) > 0 && stringutil.HasPrefixAndSuffixWithoutOverlap(declarationFileString, leadingSlice, trailingSlice, caseSensitivity.IsCaseSensitive()) { starReplacement := declarationFileString[len(leadingSlice) : len(declarationFileString)-len(trailingSlice)] - substituted := replaceFirstStar(packageName, starReplacement) + substituted := replaceFirstStar(exportsEntryName(packageName, subpath), starReplacement) jsExtension := module.TryGetJSExtensionForFileName(declarationFile, options) if len(jsExtension) > 0 { return tspath.ChangeFullExtension(substituted, jsExtension) @@ -1334,7 +1357,7 @@ func tryGetModuleNameFromExportsOrImports( case packagejson.JSONValueTypeArray: arr := exports.AsArray() for _, e := range arr { - result := tryGetModuleNameFromExportsOrImports(options, host, targetFileName, packageDirectory, packageName, e, conditions, mode, isImports, preferTsExtension) + result := tryGetModuleNameFromExportsOrImports(options, host, target, packageDirectory, packageName, subpath, e, conditions, mode) if len(result) > 0 { return result } @@ -1344,7 +1367,7 @@ 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, targetFileName, packageDirectory, packageName, value, conditions, mode, isImports, preferTsExtension) + result := tryGetModuleNameFromExportsOrImports(options, host, target, packageDirectory, packageName, subpath, value, conditions, mode) if len(result) > 0 { return result } diff --git a/tsc/internal/modulespecifiers/specifiers_test.go b/tsc/internal/modulespecifiers/specifiers_test.go index 06a496095dc90..aa42047532fbc 100644 --- a/tsc/internal/modulespecifiers/specifiers_test.go +++ b/tsc/internal/modulespecifiers/specifiers_test.go @@ -160,7 +160,7 @@ func (h *mockModuleSpecifierGenerationHost) GetPackageJsonInfo(pkgJsonPath tspat return nil } -func (h *mockModuleSpecifierGenerationHost) PackageExportsSpecifierCache() *PackageExportsSpecifierCache { +func (h *mockModuleSpecifierGenerationHost) ModuleSpecifierGenerationCache() *GenerationCache { return nil } @@ -462,31 +462,31 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { Value: "./dist/internal/", } host := &mockModuleSpecifierGenerationHost{caseSensitivity: tspath.CaseSensitive} + options := &core.CompilerOptions{} + target := newExportsOrImportsTarget("/pkg/dist/internal/file.ts", options, host, true /*isImports*/, false /*preferTsExtension*/) if result := tryGetModuleNameFromExportsOrImports( - &core.CompilerOptions{}, + options, host, - "/pkg/dist/internal/file.ts", + &target, "/pkg", "#internal/", + "", /*subpath*/ exports, nil, MatchingModeDirectory, - true, - false, ); result == "" { t.Fatal("directory target with a trailing separator should match") } if result := tryGetModuleNameFromExportsOrImports( - &core.CompilerOptions{}, + options, host, - "/pkg/dist/internal/file.ts", + &target, "/pkg", "#internal", + "", /*subpath*/ exports, nil, MatchingModeExact, - true, - false, ); result != "" { t.Fatalf("exact target with a trailing separator matched as %q", result) } @@ -515,20 +515,22 @@ func TestTryGetModuleNameFromExportsOrImports(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + options := &core.CompilerOptions{} + host := &mockModuleSpecifierGenerationHost{} + target := newExportsOrImportsTarget(tspath.RootedFilePathFromNormalized(tt.targetFilePath), options, host, false /*isImports*/, false /*preferTsExtension*/) result := tryGetModuleNameFromExportsOrImports( - &core.CompilerOptions{}, - &mockModuleSpecifierGenerationHost{}, - tspath.RootedFilePathFromNormalized(tt.targetFilePath), + options, + host, + &target, "/pkg", "./src/things/*", + "", /*subpath*/ packagejson.ExportsOrImports{ Type: packagejson.JSONValueTypeString, Value: "./src/things/*/index.js", }, []string{}, MatchingModePattern, - false, - false, ) if result != tt.expected { t.Errorf("tryGetModuleNameFromExportsOrImports(targetFilePath = %q) = %v, expected %v", tt.targetFilePath, result, tt.expected) diff --git a/tsc/internal/modulespecifiers/types.go b/tsc/internal/modulespecifiers/types.go index 7289bfcafe8e7..90ce032a90665 100644 --- a/tsc/internal/modulespecifiers/types.go +++ b/tsc/internal/modulespecifiers/types.go @@ -63,9 +63,9 @@ type ModuleSpecifierGenerationHost interface { GetNearestAncestorDirectoryWithPackageJson(dirname tspath.RootedDirectoryPath) tspath.RootedDirectoryPath GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *packagejson.InfoCacheEntry - // PackageExportsSpecifierCache returns the host's memo of package.json "exports" lookups, or nil - // when the host does not keep one. - PackageExportsSpecifierCache() *PackageExportsSpecifierCache + // ModuleSpecifierGenerationCache returns the cache the host keeps across module specifier + // generation, or nil when it does not keep one. + ModuleSpecifierGenerationCache() *GenerationCache GetDefaultResolutionModeForFile(file ast.HasFileName) core.ResolutionMode GetResolvedModuleFromModuleSpecifier(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) *module.ResolvedModule GetModeForUsageLocation(file ast.HasFileName, moduleSpecifier *ast.StringLiteralLike) core.ResolutionMode diff --git a/tsc/internal/transformers/tstransforms/importelision_test.go b/tsc/internal/transformers/tstransforms/importelision_test.go index 282bd8ace2857..a3aec6638dbac 100644 --- a/tsc/internal/transformers/tstransforms/importelision_test.go +++ b/tsc/internal/transformers/tstransforms/importelision_test.go @@ -87,7 +87,7 @@ func (p *fakeProgram) GetPackageJsonInfo(pkgJsonPath tspath.RootedFilePath) *pac return nil } -func (p *fakeProgram) PackageExportsSpecifierCache() *modulespecifiers.PackageExportsSpecifierCache { +func (p *fakeProgram) ModuleSpecifierGenerationCache() *modulespecifiers.GenerationCache { return nil } From 9b4cd939dd4f16d74d447a1b75394d15ab704dbf Mon Sep 17 00:00:00 2001 From: Gavin Kline Date: Fri, 9 Oct 2026 22:55:45 -1000 Subject: [PATCH 3/3] Share the typesVersions paths table across GetVersionPaths results GetVersionPaths returns VersionPaths by value and GetPaths builds the paths table lazily on the receiver, so every caller built a table of its own on its copy: module resolution through a package with typesVersions rebuilt it on every resolution, and module specifier generation, which now caches resolved patterns by table identity, would never have found a cached entry and would have kept adding new ones. Build the table once when the version paths are selected so that every copy shares it. Co-Authored-By: Claude Opus 5.5 --- tsc/internal/modulespecifiers/cache.go | 4 +++- tsc/internal/packagejson/cache.go | 3 +++ tsc/internal/packagejson/packagejson_test.go | 22 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tsc/internal/modulespecifiers/cache.go b/tsc/internal/modulespecifiers/cache.go index bb7f33608d9d9..21a3e040b7e7d 100644 --- a/tsc/internal/modulespecifiers/cache.go +++ b/tsc/internal/modulespecifiers/cache.go @@ -79,7 +79,9 @@ type pathPattern struct { } // getPathPatterns returns the patterns of paths in table order, resolved against baseDirectory. They -// depend only on the table, so they are resolved once per host when the host keeps a cache. +// depend only on the table, so they are resolved once per host when the host keeps a cache. The cache +// is keyed by the table's identity, so paths must be a table that lives as long as its source: the +// compiler options' "paths", or the "typesVersions" table of a parsed package.json. func getPathPatterns(host ModuleSpecifierGenerationHost, paths *collections.OrderedMap[string, []string], baseDirectory tspath.RootedDirectoryPath) []pathPattern { cache := host.ModuleSpecifierGenerationCache() if cache == nil { diff --git a/tsc/internal/packagejson/cache.go b/tsc/internal/packagejson/cache.go index 01004398d3238..293361f6587c8 100644 --- a/tsc/internal/packagejson/cache.go +++ b/tsc/internal/packagejson/cache.go @@ -68,6 +68,9 @@ func (p *PackageJson) GetVersionPaths(trace func(m *diagnostics.Message, args .. Version: key, pathsJSON: value.AsObject(), } + // Build the table now, so that every copy returned below shares it instead of + // rebuilding it on each GetPaths call. + p.versionPaths.GetPaths() return } } diff --git a/tsc/internal/packagejson/packagejson_test.go b/tsc/internal/packagejson/packagejson_test.go index e47bb49e293af..41107fadc7589 100644 --- a/tsc/internal/packagejson/packagejson_test.go +++ b/tsc/internal/packagejson/packagejson_test.go @@ -181,3 +181,25 @@ func TestParse(t *testing.T) { }) } } + +func TestGetVersionPathsSharesTable(t *testing.T) { + t.Parallel() + fields, err := packagejson.Parse([]byte(`{ "name": "pkg", "typesVersions": { "*": { "*": ["./types/*"] } } }`)) + if err != nil { + t.Fatal(err) + } + p := &packagejson.PackageJson{Fields: fields, Parseable: true} + first := p.GetVersionPaths(nil) + second := p.GetVersionPaths(nil) + firstPaths, secondPaths := first.GetPaths(), second.GetPaths() + if firstPaths == nil { + t.Fatal("expected typesVersions paths") + } + // Module specifier generation caches resolved patterns by table identity. + if firstPaths != secondPaths { + t.Error("each GetVersionPaths result built its own paths table") + } + if values, ok := firstPaths.Get("*"); !ok || len(values) != 1 || values[0] != "./types/*" { + t.Errorf("paths table = %v", firstPaths) + } +}