From 25458646fb116e8b3d69b88a1085b6777e2f12c3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:12:18 -0700 Subject: [PATCH 1/5] Restrict cached typings to inferred packages --- tsc/internal/project/ata/discovertypings.go | 2 +- tsc/internal/project/ata/discovertypings_test.go | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tsc/internal/project/ata/discovertypings.go b/tsc/internal/project/ata/discovertypings.go index ad0b9e760cfe8..879058de07017 100644 --- a/tsc/internal/project/ata/discovertypings.go +++ b/tsc/internal/project/ata/discovertypings.go @@ -86,7 +86,7 @@ func DiscoverTypings( // Add the cached typing locations for inferred typings that are already installed packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { registryEntry := typesRegistry[name] - if inferredTypings[name] == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { + if inferred, ok := inferredTypings[name]; ok && inferred == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { inferredTypings[name] = typing.TypingsLocation } return true diff --git a/tsc/internal/project/ata/discovertypings_test.go b/tsc/internal/project/ata/discovertypings_test.go index 5bf9b33b4e6f2..c601e22dec2fd 100644 --- a/tsc/internal/project/ata/discovertypings_test.go +++ b/tsc/internal/project/ata/discovertypings_test.go @@ -84,8 +84,9 @@ func TestDiscoverTypings(t *testing.T) { t.Parallel() logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ - "/home/src/projects/project/app.js": "", - "/home/src/projects/project/node.d.ts": "", + "/home/src/projects/project/app.js": "", + "/home/src/projects/project/jquery.d.ts": "", + "/home/src/projects/project/node.d.ts": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) cache := collections.SyncMap[string, *ata.CachedTyping]{} @@ -94,6 +95,10 @@ func TestDiscoverTypings(t *testing.T) { TypingsLocation: "/home/src/projects/project/node.d.ts", Version: &version, }) + cache.Store("jquery", &ata.CachedTyping{ + TypingsLocation: "/home/src/projects/project/jquery.d.ts", + Version: &version, + }) unresolvedImports := collections.NewSetFromItems("fs", "bar") cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, @@ -107,7 +112,8 @@ func TestDiscoverTypings(t *testing.T) { "/home/src/projects/project", &cache, map[string]map[string]string{ - "node": projecttestutil.TypesRegistryConfig(), + "jquery": projecttestutil.TypesRegistryConfig(), + "node": projecttestutil.TypesRegistryConfig(), }, ) assert.DeepEqual(t, cachedTypingPaths, []string{ From 1df4257df02898255982907bb9b5bc6180e9d248 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:17:01 -0700 Subject: [PATCH 2/5] Preserve ATA state across project lifecycles --- tsc/internal/project/ata/ata.go | 2 + tsc/internal/project/ata/ata_test.go | 176 ++++++++++ tsc/internal/project/project.go | 128 +++++++- tsc/internal/project/projectcollection.go | 34 +- .../project/projectcollectionbuilder.go | 309 ++++++++++++++++-- tsc/internal/project/session.go | 59 +++- tsc/internal/project/snapshot.go | 34 +- 7 files changed, 662 insertions(+), 80 deletions(-) diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index e6f0b1c7504db..71c4704b1c861 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -27,6 +27,8 @@ type TypingsInfo struct { func (ti TypingsInfo) Equals(other TypingsInfo) bool { return ti.TypeAcquisition.Equals(other.TypeAcquisition) && ti.CompilerOptions.GetAllowJS() == other.CompilerOptions.GetAllowJS() && + (ti.CompilerOptions.Types == nil) == (other.CompilerOptions.Types == nil) && + slices.Equal(ti.CompilerOptions.Types, other.CompilerOptions.Types) && ti.UnresolvedImports.Equals(other.UnresolvedImports) } diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index abedba27dc5ed..e007e13201177 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -3,16 +3,30 @@ package ata_test import ( "context" "slices" + "sync" "testing" + "time" "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" "gotest.tools/v3/assert" ) +func waitForInstall(t *testing.T, installStarted <-chan struct{}) { + t.Helper() + select { + case <-installStarted: + return + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for typings installation to start") + } +} + func TestATA(t *testing.T) { t.Parallel() if !bundled.Embedded { @@ -89,6 +103,168 @@ func TestATA(t *testing.T) { assert.Equal(t, len(utils.Client().RefreshDiagnosticsCalls()), 1) }) + t.Run("configured project ignores ATA results from before manifest invalidation", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + err = utils.FS().WriteFile(packageJSONURI.FileName(), `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0", + "jquery": "^3.1.0" + } + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + waitForInstall(t, installStarted) + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + err = utils.FS().WriteFile(packageJSONURI.FileName(), files["/user/username/projects/project/package.json"].(string)) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be applied after the manifest changes") + }) + + t.Run("configured project ignores ATA results from a previous project lifetime", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + }, + }) + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + CloseFiles: &collections.Set[tspath.Path]{}, + }) + assert.NilError(t, err) + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + snapshot.Deref() + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + release() + session.WaitForBackgroundTasks() + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be applied to a replacement project") + }) + t.Run("inferred projects", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 79954831210c9..26003f475f229 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -173,6 +173,84 @@ type Project struct { installedTypingsInfo *ata.TypingsInfo // typingsFiles are the root files added by the typings installer. typingsFiles []string + // installedTypingsFileNames are the JavaScript files used during the most + // recently completed typings installation. + installedTypingsFileNames []string + // installedTypingsFilesToWatch are discovery inputs whose changes require + // typings discovery to run again. + installedTypingsFilesToWatch []string + // ataInvalidationSnapshotID is the latest snapshot that invalidated this + // project's ATA discovery inputs. + ataInvalidationSnapshotID uint64 +} + +type inferredProjectATAState struct { + installedTypingsInfo *ata.TypingsInfo + installedTypingsFileNames []string + installedTypingsFilesToWatch []string + typingsFiles []string + typingsWatch *WatchedFiles[PatternsAndIgnored] +} + +func (p *Project) inferredProjectATAState() *inferredProjectATAState { + if p.installedTypingsInfo == nil && len(p.installedTypingsFilesToWatch) == 0 { + return nil + } + return &inferredProjectATAState{ + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), + installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), + typingsFiles: slices.Clone(p.typingsFiles), + typingsWatch: p.typingsWatch, + } +} + +func (s *inferredProjectATAState) canApply(project *Project, fs *snapshotFSBuilder, watchEnabled bool) bool { + if s == nil || s.installedTypingsInfo == nil { + return false + } + if !watchEnabled && len(s.installedTypingsFilesToWatch) > 0 { + return false + } + if !s.installedTypingsInfo.Equals(project.ComputeTypingsInfo()) || + !slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) { + return false + } + for _, fileName := range s.typingsFiles { + if !fs.FileExists(fileName, fs.toPath(fileName)) { + return false + } + } + return true +} + +func (s *inferredProjectATAState) apply(project *Project) { + typingsFilesChanged := !slices.Equal(project.typingsFiles, s.typingsFiles) + project.installedTypingsInfo = s.installedTypingsInfo + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) + project.typingsFiles = slices.Clone(s.typingsFiles) + project.typingsWatch = s.typingsWatch + if typingsFilesChanged { + project.dirty = true + project.dirtyFilePath = "" + } +} + +func (s *inferredProjectATAState) canApplyWatchState(project *Project, watchEnabled bool) bool { + return s != nil && + s.installedTypingsInfo == nil && + watchEnabled && + slices.Equal(s.installedTypingsFileNames, project.ComputeTypingsFileNames()) +} + +func (s *inferredProjectATAState) applyWatchState(project *Project) { + project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) + project.installedTypingsFilesToWatch = slices.Concat( + slices.Clone(s.installedTypingsFilesToWatch), + s.typingsFiles, + ) + project.typingsWatch = s.typingsWatch } var _ ls.Project = (*Project)(nil) @@ -203,6 +281,7 @@ func NewInferredProject( logger *logging.LogTree, ) *Project { p := NewProject(inferredProjectID.AsID(), KindInferred, currentDirectory, builder, logger) + p.ataInvalidationSnapshotID = builder.inferredProjectATAInvalidationSnapshotID if compilerOptions == nil { compilerOptions = &core.CompilerOptions{ AllowJs: core.TSTrue, @@ -278,10 +357,11 @@ func NewProject( logger.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), id, currentDirectory)) } project := &Project{ - Kind: kind, - id: id, - currentDirectory: currentDirectory, - dirty: true, + Kind: kind, + id: id, + currentDirectory: currentDirectory, + dirty: true, + ataInvalidationSnapshotID: builder.newSnapshotID, } project.programFilesWatch = NewWatchedFiles( @@ -291,12 +371,7 @@ func NewProject( createResolutionLookupGlobMapper(builder.sessionOptions.CurrentDirectory, builder.sessionOptions.DefaultLibraryPath, project.currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()), ) if builder.sessionOptions.TypingsLocation != "" { - project.typingsWatch = NewWatchedFiles( - "typings installer files", - lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, - lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, - core.Identity, - ) + project.typingsWatch = newTypingsWatch(builder) } project.contentMapperWatch = NewWatchedFilesForPaths( "content mapper configuration files for "+string(id), @@ -309,6 +384,15 @@ func NewProject( return project } +func newTypingsWatch(builder *ProjectCollectionBuilder) *WatchedFiles[PatternsAndIgnored] { + return NewWatchedFiles( + "typings installer files", + lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, + lsproto.GetClientCapabilities(builder.ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, + core.Identity, + ) +} + func (p *Project) CurrentDirectory() string { return p.currentDirectory } @@ -418,8 +502,11 @@ func (p *Project) Clone() *Project { moduleResolverFactory: p.moduleResolverFactory, moduleResolverID: p.moduleResolverID, - installedTypingsInfo: p.installedTypingsInfo, - typingsFiles: p.typingsFiles, + installedTypingsInfo: p.installedTypingsInfo, + installedTypingsFileNames: p.installedTypingsFileNames, + installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, + typingsFiles: p.typingsFiles, + ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, } } @@ -666,7 +753,8 @@ func (p *Project) ShouldTriggerATA(snapshotID uint64) bool { return true } - return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) + return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) || + !slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) } func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { @@ -676,3 +764,17 @@ func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { UnresolvedImports: p.GetUnresolvedImports(), } } + +func (p *Project) ComputeTypingsFileNames() []string { + if p.Program == nil { + return nil + } + var fileNames []string + for _, file := range p.Program.GetSourceFiles() { + if tspath.HasJSFileExtension(file.FileName()) && !p.Program.IsSourceFileFromExternalLibrary(file) { + fileNames = append(fileNames, file.FileName()) + } + } + slices.Sort(fileNames) + return fileNames +} diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index c43d9da8d633b..8e8598741d592 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -31,6 +31,12 @@ type ProjectCollection struct { // inferredProject is a fallback project that is used when no configured // project can be found for an open file. inferredProject *Project + // inferredProjectATAState preserves acquired typings while there is no active + // inferred project, so reopening a loose file does not wait for ATA again. + inferredProjectATAState *inferredProjectATAState + // inferredProjectATAInvalidationSnapshotID is the latest snapshot that + // invalidated inferred-project ATA discovery inputs. + inferredProjectATAInvalidationSnapshotID uint64 // apiState tracks the projects and files that API clients have explicitly // opened so they are kept loaded across snapshots. apiState APIState @@ -71,6 +77,16 @@ type apiOpenedFile struct { func (c *ProjectCollection) ConfigFileRegistry() *ConfigFileRegistry { return c.configFileRegistry } +func (c *ProjectCollection) inferredProjectTypingsWatch() *WatchedFiles[PatternsAndIgnored] { + if c.inferredProject != nil { + return c.inferredProject.typingsWatch + } + if c.inferredProjectATAState != nil { + return c.inferredProjectATAState.typingsWatch + } + return nil +} + func (c *ProjectCollection) ConfiguredProject(path tspath.Path) *Project { return c.configuredProjects[ConfiguredProjectID(path)] } @@ -328,14 +344,16 @@ func (c *ProjectCollection) findDefaultConfiguredProjectWorker(path tspath.Path, // clone creates a shallow copy of the project collection. func (c *ProjectCollection) clone() *ProjectCollection { return &ProjectCollection{ - toPath: c.toPath, - configFileRegistry: c.configFileRegistry, - configuredProjects: c.configuredProjects, - syntheticProjects: c.syntheticProjects, - openFiles: c.openFiles, - inferredProject: c.inferredProject, - fileDefaultProjects: c.fileDefaultProjects, - apiState: c.apiState, + toPath: c.toPath, + configFileRegistry: c.configFileRegistry, + configuredProjects: c.configuredProjects, + syntheticProjects: c.syntheticProjects, + openFiles: c.openFiles, + inferredProject: c.inferredProject, + inferredProjectATAState: c.inferredProjectATAState, + inferredProjectATAInvalidationSnapshotID: c.inferredProjectATAInvalidationSnapshotID, + fileDefaultProjects: c.fileDefaultProjects, + apiState: c.apiState, } } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index eb3752678485c..a3472b21142f7 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -56,11 +56,13 @@ type ProjectCollectionBuilder struct { defaultProjectsInvalidated bool openFilesChanged bool - fileDefaultProjects map[tspath.Path]ID - configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] - syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] - inferredProject *dirty.Box[*Project] - createdPrograms []*Project + fileDefaultProjects map[tspath.Path]ID + configuredProjects *dirty.SyncMap[ConfiguredProjectID, *Project] + syntheticProjects *dirty.SyncMap[SyntheticProjectID, *Project] + inferredProject *dirty.Box[*Project] + inferredProjectATAState *inferredProjectATAState + inferredProjectATAInvalidationSnapshotID uint64 + createdPrograms []*Project apiState APIState } @@ -86,27 +88,29 @@ func newProjectCollectionBuilder( ) *ProjectCollectionBuilder { openFiles := openFilePaths(overlays) return &ProjectCollectionBuilder{ - ctx: ctx, - fs: fs, - overlays: overlays, - toPath: fs.toPath, - compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, - inferredContentMappers: inferredContentMappers, - inferredContentMapperExtensions: inferredContentMapperExtensions, - sessionOptions: sessionOptions, - parseCache: parseCache, - contentMappedParseCache: contentMappedParseCache, - extendedConfigCache: extendedConfigCache, - contentMapperHost: contentMapperHost, - base: oldProjectCollection, - configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.Path) bool { _, ok := overlays[path]; return ok }, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), - newSnapshotID: newSnapshotID, - openFilesChanged: !openFiles.Equals(&oldProjectCollection.openFiles), - configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects), - syntheticProjects: dirty.NewSyncMap(oldProjectCollection.syntheticProjects), - inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), - apiState: oldAPIState.clone(), - client: client, + ctx: ctx, + fs: fs, + overlays: overlays, + toPath: fs.toPath, + compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, + inferredContentMappers: inferredContentMappers, + inferredContentMapperExtensions: inferredContentMapperExtensions, + sessionOptions: sessionOptions, + parseCache: parseCache, + contentMappedParseCache: contentMappedParseCache, + extendedConfigCache: extendedConfigCache, + contentMapperHost: contentMapperHost, + base: oldProjectCollection, + configFileRegistryBuilder: newConfigFileRegistryBuilder(lsproto.GetClientCapabilities(ctx).Workspace.DidChangeWatchedFiles.RelativePatternSupport, fs, func(path tspath.Path) bool { _, ok := overlays[path]; return ok }, oldConfigFileRegistry, extendedConfigCache, newSnapshotID, sessionOptions, customConfigFileName, nil), + newSnapshotID: newSnapshotID, + openFilesChanged: !openFiles.Equals(&oldProjectCollection.openFiles), + configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects), + syntheticProjects: dirty.NewSyncMap(oldProjectCollection.syntheticProjects), + inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), + inferredProjectATAState: oldProjectCollection.inferredProjectATAState, + inferredProjectATAInvalidationSnapshotID: oldProjectCollection.inferredProjectATAInvalidationSnapshotID, + apiState: oldAPIState.clone(), + client: client, } } @@ -148,6 +152,14 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo ensureCloned() newProjectCollection.inferredProject = newInferredProject } + if b.inferredProjectATAState != b.base.inferredProjectATAState { + ensureCloned() + newProjectCollection.inferredProjectATAState = b.inferredProjectATAState + } + if b.inferredProjectATAInvalidationSnapshotID != b.base.inferredProjectATAInvalidationSnapshotID { + ensureCloned() + newProjectCollection.inferredProjectATAInvalidationSnapshotID = b.inferredProjectATAInvalidationSnapshotID + } configFileRegistry := b.configFileRegistryBuilder.Finalize() if configFileRegistry != b.base.configFileRegistry { @@ -453,6 +465,40 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log return true }) + b.forEachProject(func(entry dirty.Value[*Project]) bool { + projectID := entry.Value().ID() + if entry.ChangeIf( + func(project *Project) bool { + return fileChangeSummaryAffectsTypingsWatch( + summary, + project.installedTypingsFilesToWatch, + project.typingsFiles, + b.sessionOptions.TypingsLocation, + b.fs.fs.UseCaseSensitiveFileNames(), + ) + }, + func(project *Project) { + project.installedTypingsInfo = nil + }, + ) { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("typings watch changes", logger) + } + } + return true + }) + + if b.inferredProjectATAState != nil && fileChangeSummaryAffectsTypingsWatch( + summary, + b.inferredProjectATAState.installedTypingsFilesToWatch, + b.inferredProjectATAState.typingsFiles, + b.sessionOptions.TypingsLocation, + b.fs.fs.UseCaseSensitiveFileNames(), + ) { + b.invalidateInferredProjectATAState("typings watch changes", logger) + } + // Handle opened file if summary.Opened != "" || summary.Reopened != "" { fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName() @@ -462,6 +508,134 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log } } +func fileChangeSummaryAffectsTypingsWatch( + summary FileChangeSummary, + filesToWatch []string, + typingsFiles []string, + typingsLocation string, + useCaseSensitiveFileNames bool, +) bool { + if summary.InvalidateAll { + return true + } + if len(filesToWatch) == 0 { + return false + } + comparePathsOptions := tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: useCaseSensitiveFileNames, + } + affectsWatch := func(uri lsproto.DocumentUri) bool { + fileName := uri.FileName() + return slices.ContainsFunc(slices.Concat(filesToWatch, typingsFiles), func(watchedPath string) bool { + if tspath.ComparePaths(watchedPath, fileName, comparePathsOptions) == 0 || + tspath.ContainsPath(watchedPath, fileName, comparePathsOptions) { + return true + } + switch tspath.GetBaseFileName(watchedPath) { + case "node_modules": + return tspath.ComparePaths(tspath.CombinePaths(tspath.GetDirectoryPath(watchedPath), "package.json"), fileName, comparePathsOptions) == 0 + case "bower_components": + return tspath.ComparePaths(tspath.CombinePaths(tspath.GetDirectoryPath(watchedPath), "bower.json"), fileName, comparePathsOptions) == 0 + } + return false + }) || typingsLocation != "" && tspath.ContainsPath(typingsLocation, fileName, comparePathsOptions) + } + for uri := range summary.Changed.Keys() { + if affectsWatch(uri) { + return true + } + } + for uri := range summary.Created.Keys() { + if affectsWatch(uri) { + return true + } + } + for uri := range summary.Deleted.Keys() { + if affectsWatch(uri) { + return true + } + } + return false +} + +func (b *ProjectCollectionBuilder) DidInvalidateTypingsWatchState(logger *logging.LogTree) { + b.forEachProject(func(entry dirty.Value[*Project]) bool { + b.invalidateProjectATAState(entry.Value().ID()) + entry.ChangeIf( + func(project *Project) bool { + return project.installedTypingsInfo != nil + }, + func(project *Project) { + project.installedTypingsInfo = nil + }, + ) + return true + }) + b.invalidateInferredProjectATAState("excessive file changes", logger) +} + +func (b *ProjectCollectionBuilder) invalidateProjectATAState(projectID ID) { + setProjectGeneration := func(project dirty.Value[*Project]) { + if project == nil || project.Value() == nil { + return + } + project.ChangeIf( + func(project *Project) bool { + return project.ataInvalidationSnapshotID != b.newSnapshotID + }, + func(project *Project) { + project.ataInvalidationSnapshotID = b.newSnapshotID + }, + ) + } + if _, inferred := projectID.Inferred(); inferred { + b.inferredProjectATAInvalidationSnapshotID = b.newSnapshotID + setProjectGeneration(b.inferredProject) + return + } + if syntheticProjectID, ok := projectID.Synthetic(); ok { + if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { + setProjectGeneration(project) + } + return + } + if configuredProjectID, ok := projectID.Configured(); ok { + if project, loaded := b.configuredProjects.Load(configuredProjectID); loaded { + setProjectGeneration(project) + } + } +} + +func (b *ProjectCollectionBuilder) ataInvalidationSnapshotID(projectID ID) uint64 { + if _, inferred := projectID.Inferred(); inferred { + return b.inferredProjectATAInvalidationSnapshotID + } + if syntheticProjectID, ok := projectID.Synthetic(); ok { + if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { + return project.Value().ataInvalidationSnapshotID + } + return 0 + } + if configuredProjectID, ok := projectID.Configured(); ok { + if project, loaded := b.configuredProjects.Load(configuredProjectID); loaded { + return project.Value().ataInvalidationSnapshotID + } + } + return 0 +} + +func (b *ProjectCollectionBuilder) invalidateInferredProjectATAState(reason string, logger *logging.LogTree) { + b.invalidateProjectATAState(inferredProjectID.AsID()) + b.clearInferredProjectATAState(reason, logger) +} + +func (b *ProjectCollectionBuilder) clearInferredProjectATAState(reason string, logger *logging.LogTree) { + b.inferredProjectATAState = nil + if logger != nil { + logger.Log("Invalidating cached inferred project ATA state due to " + reason) + } +} + func (b *ProjectCollectionBuilder) refreshContentMapperProjectForChanges(entry dirty.Value[*Project], paths []tspath.Path, refreshAll bool, logger *logging.LogTree) { project := entry.Value() if project.Program == nil || project.contentMapperWatchedFiles == nil { @@ -812,7 +986,7 @@ func (b *ProjectCollectionBuilder) ensureProjectTree( } } -func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, logger *logging.LogTree) { +func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, logger *logging.LogTree) { updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) { project.ChangeIf( func(p *Project) bool { @@ -822,15 +996,16 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState // Consistency check: the ATA demands (project options, unresolved imports) of this project // has not changed since the time the ATA request was dispatched; the change can still be // applied to this project in its current state. - return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) + return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) && + slices.Equal(ataChange.FileNames, p.ComputeTypingsFileNames()) }, func(p *Project) { - // We checked before triggering this change (in Session.triggerATAForUpdatedProjects) that - // the set of typings files is actually different. p.installedTypingsInfo = ataChange.TypingsInfo + p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) + p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) p.typingsFiles = ataChange.TypingsFiles typingsWatchGlobs := getTypingsLocationsGlobs( - ataChange.TypingsFilesToWatch, + slices.Concat(ataChange.TypingsFilesToWatch, ataChange.TypingsFiles), b.sessionOptions.TypingsLocation, b.sessionOptions.CurrentDirectory, p.currentDirectory, @@ -845,8 +1020,47 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState for projectID, ataChange := range ataChanges { logger.Embed(ataChange.Logs) + if fileChangeSummaryAffectsTypingsWatch( + fileChanges, + slices.Concat(ataChange.TypingsFilesToWatch, ataChange.FileNames), + ataChange.TypingsFiles, + b.sessionOptions.TypingsLocation, + b.fs.fs.UseCaseSensitiveFileNames(), + ) { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("typings watch changes", logger) + } + } + if ataChange.SnapshotID < b.ataInvalidationSnapshotID(projectID) { + if logger != nil { + logger.Logf("Ignoring stale ATA state for project %s", projectID) + } + continue + } if _, ok := projectID.Inferred(); ok { - updateProject(b.inferredProject, ataChange) + if b.inferredProject.Value() != nil { + updateProject(b.inferredProject, ataChange) + } else { + typingsWatch := newTypingsWatch(b) + if b.inferredProjectATAState != nil && b.inferredProjectATAState.typingsWatch != nil { + typingsWatch = b.inferredProjectATAState.typingsWatch + } + typingsWatchGlobs := getTypingsLocationsGlobs( + slices.Concat(ataChange.TypingsFilesToWatch, ataChange.TypingsFiles), + b.sessionOptions.TypingsLocation, + b.sessionOptions.CurrentDirectory, + b.sessionOptions.CurrentDirectory, + b.fs.fs.UseCaseSensitiveFileNames(), + ) + b.inferredProjectATAState = &inferredProjectATAState{ + installedTypingsInfo: ataChange.TypingsInfo, + installedTypingsFileNames: slices.Clone(ataChange.FileNames), + installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), + typingsFiles: slices.Clone(ataChange.TypingsFiles), + typingsWatch: typingsWatch.Clone(typingsWatchGlobs), + } + } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { if project, loaded := b.syntheticProjects.Load(syntheticProjectID); loaded { updateProject(project, ataChange) @@ -895,6 +1109,10 @@ func (b *ProjectCollectionBuilder) markProjectsAffectedByConfigChanges( logger *logging.LogTree, ) bool { for projectID := range configChangeResult.affectedProjects { + b.invalidateProjectATAState(projectID) + if _, inferred := projectID.Inferred(); inferred { + b.clearInferredProjectATAState("config changes", logger) + } var project dirty.Value[*Project] if _, ok := projectID.Inferred(); ok { project = b.inferredProject @@ -1365,6 +1583,7 @@ func (b *ProjectCollectionBuilder) deleteInferredProject(logger *logging.LogTree return true }) } + b.inferredProjectATAState = project.inferredProjectATAState() b.inferredProject.Delete() return true } @@ -1537,6 +1756,32 @@ func (b *ProjectCollectionBuilder) updateProgram(entry dirty.Value[*Project], lo elapsed := time.Since(startTime) logger.Log(fmt.Sprintf("Program update for %s completed in %v", projectID, elapsed)) } + if _, inferred := projectID.Inferred(); inferred && b.inferredProjectATAState != nil { + state := b.inferredProjectATAState + b.inferredProjectATAState = nil + if entry.ChangeIf( + func(project *Project) bool { + return state.canApply(project, b.fs, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.apply(project) + }, + ) { + if logger != nil { + logger.Log("Reusing cached inferred project ATA state") + } + filesChanged = b.updateProgram(entry, logger) || filesChanged + } else { + entry.ChangeIf( + func(project *Project) bool { + return state.canApplyWatchState(project, b.sessionOptions.WatchEnabled) + }, + func(project *Project) { + state.applyWatchState(project) + }, + ) + } + } return filesChanged } diff --git a/tsc/internal/project/session.go b/tsc/internal/project/session.go index ca4bce7cba6db..a98a199e545c9 100644 --- a/tsc/internal/project/session.go +++ b/tsc/internal/project/session.go @@ -1629,12 +1629,16 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er newSnapshot.ProjectCollection.ProjectsByID(), func(_ ID, addedProject *Project) { errors = append(errors, s.updateWatch(ctx, nil, addedProject.programFilesWatch)...) - errors = append(errors, s.updateWatch(ctx, nil, addedProject.typingsWatch)...) + if addedProject.Kind != KindInferred { + errors = append(errors, s.updateWatch(ctx, nil, addedProject.typingsWatch)...) + } errors = append(errors, s.updateWatch(ctx, nil, addedProject.contentMapperWatch)...) }, func(_ ID, removedProject *Project) { errors = append(errors, s.updateWatch(ctx, removedProject.programFilesWatch, nil)...) - errors = append(errors, s.updateWatch(ctx, removedProject.typingsWatch, nil)...) + if removedProject.Kind != KindInferred { + errors = append(errors, s.updateWatch(ctx, removedProject.typingsWatch, nil)...) + } errors = append(errors, s.updateWatch(ctx, removedProject.contentMapperWatch, nil)...) }, func(_ ID, oldProject, newProject *Project) { @@ -1645,11 +1649,13 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er errors = append(errors, s.updateWatch(ctx, nil, newProject.programFilesWatch)...) } } - if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { - errors = append(errors, s.updateWatch(ctx, oldProject.typingsWatch, newProject.typingsWatch)...) - } else { - if s.watches.IsPending(newProject.typingsWatch.ID()) { - errors = append(errors, s.updateWatch(ctx, nil, newProject.typingsWatch)...) + if newProject.Kind != KindInferred { + if oldProject.typingsWatch.ID() != newProject.typingsWatch.ID() { + errors = append(errors, s.updateWatch(ctx, oldProject.typingsWatch, newProject.typingsWatch)...) + } else { + if s.watches.IsPending(newProject.typingsWatch.ID()) { + errors = append(errors, s.updateWatch(ctx, nil, newProject.typingsWatch)...) + } } } if oldProject.contentMapperWatch.ID() != newProject.contentMapperWatch.ID() { @@ -1659,6 +1665,13 @@ func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) er } }, ) + oldInferredTypingsWatch := oldSnapshot.ProjectCollection.inferredProjectTypingsWatch() + newInferredTypingsWatch := newSnapshot.ProjectCollection.inferredProjectTypingsWatch() + if oldInferredTypingsWatch.ID() != newInferredTypingsWatch.ID() { + errors = append(errors, s.updateWatch(ctx, oldInferredTypingsWatch, newInferredTypingsWatch)...) + } else if newInferredTypingsWatch != nil && s.watches.IsPending(newInferredTypingsWatch.ID()) { + errors = append(errors, s.updateWatch(ctx, nil, newInferredTypingsWatch)...) + } if oldSnapshot.autoImportsWatch.ID() != newSnapshot.autoImportsWatch.ID() { errors = append(errors, s.updateWatch(ctx, oldSnapshot.autoImportsWatch, newSnapshot.autoImportsWatch)...) @@ -1996,10 +2009,11 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { } typingsInfo := project.ComputeTypingsInfo() + fileNames := project.ComputeTypingsFileNames() request := &ata.TypingsInstallRequest{ ProjectID: project.ID(), TypingsInfo: &typingsInfo, - FileNames: core.Map(project.Program.GetSourceFiles(), func(file *ast.SourceFile) string { return file.FileName() }), + FileNames: fileNames, ProjectRootPath: project.currentDirectory, CompilerOptions: project.CommandLine.CompilerOptions(), CurrentDirectory: s.options.CurrentDirectory, @@ -2022,16 +2036,29 @@ func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { s.logger.Log(logTree.String()) } } else { - if !slices.Equal(result.TypingsFiles, project.typingsFiles) { + if !slices.Equal(result.TypingsFiles, project.typingsFiles) || + project.installedTypingsInfo == nil || + !typingsInfo.Equals(*project.installedTypingsInfo) || + !slices.Equal(fileNames, project.installedTypingsFileNames) || + !slices.Equal(result.FilesToWatch, project.installedTypingsFilesToWatch) { s.pendingATAChangesMu.Lock() - defer s.pendingATAChangesMu.Unlock() - s.pendingATAChanges[project.ID()] = &ATAStateChange{ - TypingsInfo: &typingsInfo, - TypingsFiles: result.TypingsFiles, - TypingsFilesToWatch: result.FilesToWatch, - Logs: logTree, + projectID := project.ID() + pendingChange := s.pendingATAChanges[projectID] + updatedPendingChange := pendingChange == nil || pendingChange.SnapshotID <= newSnapshot.ID() + if updatedPendingChange { + s.pendingATAChanges[projectID] = &ATAStateChange{ + SnapshotID: newSnapshot.ID(), + TypingsInfo: &typingsInfo, + FileNames: fileNames, + TypingsFiles: result.TypingsFiles, + TypingsFilesToWatch: result.FilesToWatch, + Logs: logTree, + } + } + s.pendingATAChangesMu.Unlock() + if updatedPendingChange { + s.ScheduleDiagnosticsRefresh() } - s.ScheduleDiagnosticsRefresh() } } }) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 6040a639a2697..5aa053bd6bb9b 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -419,8 +419,12 @@ type SnapshotChange struct { // ATAStateChange represents a change to a project's ATA state. type ATAStateChange struct { + // SnapshotID is the snapshot whose project state triggered the ATA request. + SnapshotID uint64 // TypingsInfo is the new typings info for the project. TypingsInfo *ata.TypingsInfo + // FileNames are the JavaScript files used for typings discovery. + FileNames []string // TypingsFiles is the new list of typing files for the project. TypingsFiles []string // TypingsFilesToWatch is the new list of typing files to watch for changes. @@ -496,6 +500,7 @@ func (s *Snapshot) Clone( } start := time.Now() + hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -549,22 +554,26 @@ func (s *Snapshot) Clone( client, ) - if len(change.ataChanges) != 0 { - projectCollectionBuilder.DidUpdateATAState(change.ataChanges, logger.Fork("DidUpdateATAState")) + if hadExcessiveWatchEvents { + projectCollectionBuilder.DidInvalidateTypingsWatchState(logger.Fork("DidInvalidateTypingsWatchState")) } projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName")) - if change.compilerOptionsForInferredProjects != nil && projectCollectionBuilder.inferredProject.Value() != nil { - projectCollectionBuilder.updateInferredProject( - projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), - change.compilerOptionsForInferredProjects, - projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), - projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, - projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), - logger.Fork("DidChangeCompilerOptionsForInferredProjects"), - ) + if change.compilerOptionsForInferredProjects != nil { + projectCollectionBuilder.invalidateInferredProjectATAState("inferred compiler options changes", logger.Fork("InvalidateInferredProjectATAState")) + if projectCollectionBuilder.inferredProject.Value() != nil { + projectCollectionBuilder.updateInferredProject( + projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), + change.compilerOptionsForInferredProjects, + projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), + projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, + projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), + logger.Fork("DidChangeCompilerOptionsForInferredProjects"), + ) + } } if change.contentMapperContributions != nil { + projectCollectionBuilder.invalidateInferredProjectATAState("content mapper changes", logger.Fork("InvalidateInferredProjectATAState")) projectCollectionBuilder.DidChangeContentMapperContributions(logger.Fork("DidChangeContentMapperContributions")) } if change.newConfig != nil { @@ -574,6 +583,9 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if len(change.ataChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, change.fileChanges, logger.Fork("DidUpdateATAState")) + } var apiError error if change.apiRequest != nil { From 49c879d2e57eb9e951c583895dce2b76b3e8f384 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:17:19 -0700 Subject: [PATCH 3/5] test: Cover inferred project ATA reuse --- tsc/internal/project/ata/ata_test.go | 422 +++++++++++++++++++++ tsc/internal/project/contentmapper_test.go | 18 + 2 files changed, 440 insertions(+) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index e007e13201177..8e544c5c9641c 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -9,11 +9,14 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/glob" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) @@ -302,6 +305,416 @@ func TestATA(t *testing.T) { assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") }) + t.Run("inferred project retains typings after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("inferred project retains ATA result completed after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("inferred project rejects ATA result invalidated after closing last file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseInstallOnce sync.Once + release := func() { releaseInstallOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + err = utils.FS().WriteFile(packageJSONURI.FileName(), `{ + "name": "test", + "dependencies": { + "commander": "^14.0.0", + "jquery": "^3.1.0" + } + }`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + session.Configure(lsutil.ParseUserPreferences(map[string]any{ + "js/ts": map[string]any{ + "tsserver": map[string]any{ + "automaticTypeAcquisition": map[string]any{ + "enabled": false, + }, + }, + }, + })) + err = utils.FS().WriteFile(packageJSONURI.FileName(), files["/user/username/projects/project/package.json"].(string)) + assert.NilError(t, err) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: packageJSONURI, + Type: lsproto.FileChangeTypeChanged, + }}) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + commanderTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile == nil, "stale commander typings should not be retained after the manifest changes") + }) + + t.Run("inferred project retains typings after closing changed file", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + const changedContent = "// changed" + session.DidChangeFile(context.Background(), uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: changedContent}, + }}) + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, changedContent, lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening a changed file") + }) + + t.Run("inferred project does not reuse typings for unrelated roots", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project1/app.js": ``, + "/user/username/projects/project1/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + "/user/username/projects/project2/app.js": ``, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + firstURI := lsproto.DocumentUri("file:///user/username/projects/project1/app.js") + session.DidOpenFile(context.Background(), firstURI, 1, files["/user/username/projects/project1/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), firstURI) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), firstURI) + session.WaitForBackgroundTasks() + + secondURI := lsproto.DocumentUri("file:///user/username/projects/project2/app.js") + session.DidOpenFile(context.Background(), secondURI, 1, files["/user/username/projects/project2/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(context.Background(), secondURI) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused for an unrelated root") + }) + + t.Run("inferred project does not reuse typings after manifest change", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + var typingsWatcherID project.WatcherID + for _, call := range utils.Client().WatchFilesCalls() { + for _, watcher := range call.Watchers { + if watcher.GlobPattern.Pattern != nil { + g, parseErr := glob.Parse(*watcher.GlobPattern.Pattern) + if parseErr == nil && g.Match("/user/username/projects/project/package.json") { + typingsWatcherID = call.ID + break + } + } + } + if typingsWatcherID != "" { + break + } + } + assert.Assert(t, typingsWatcherID != "", "expected typings watcher to be registered") + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + assert.Assert(t, !slices.ContainsFunc(utils.Client().UnwatchFilesCalls(), func(call struct { + Ctx context.Context + ID project.WatcherID + }, + ) bool { + return call.ID == typingsWatcherID + }), "typings watcher should remain registered while the inferred project is inactive") + err = utils.FS().WriteFile("/user/username/projects/project/package.json", `{"name":"test"}`) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{ + Uri: "file:///user/username/projects/project/package.json", + Type: lsproto.FileChangeTypeChanged, + }}) + + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") + }) + + t.Run("inferred project does not reuse typings after compiler options change", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidChangeCompilerOptionsForInferredProjects(context.Background(), &core.CompilerOptions{ + AllowJs: core.TSTrue, + Types: []string{}, + }) + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after inferred compiler options change") + }) + + t.Run("inferred project does not reuse typings after filesystem replacement", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + + session.DidCloseFile(ctx, uri) + session.WaitForBackgroundTasks() + replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + }, false)) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + ReplaceFileSystem: true, + }) + assert.NilError(t, err) + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile == nil, "jquery types should not be reused after replacing the filesystem") + }) + t.Run("type acquisition with disableFilenameBasedTypeAcquisition:true", func(t *testing.T) { t.Parallel() @@ -603,6 +1016,15 @@ func TestATA(t *testing.T) { assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) // JS resolution should be dropped assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) + + session.DidCloseFile(context.Background(), uri) + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + ls, err = session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + program = ls.GetProgram() + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) + assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) }) t.Run("expired cache entry (inferred project, should install typings)", func(t *testing.T) { diff --git a/tsc/internal/project/contentmapper_test.go b/tsc/internal/project/contentmapper_test.go index c95d7eeec4479..a704bdf119ece 100644 --- a/tsc/internal/project/contentmapper_test.go +++ b/tsc/internal/project/contentmapper_test.go @@ -982,6 +982,7 @@ func TestContentMapperInferredProjectSurvivesTypingsInstall(t *testing.T) { PositionEncoding: lsproto.PositionEncodingKindUTF8, LoggingEnabled: true, RunExternalCode: true, + WatchEnabled: true, }, &projecttestutil.TypingsInstallerOptions{ PackageToFile: map[string]string{ "jquery": `declare const $: { x: number }`, @@ -1032,6 +1033,23 @@ func TestContentMapperInferredProjectSurvivesTypingsInstall(t *testing.T) { } } assert.Assert(t, typingsFile != nil, "expected installed typings in the inferred program (the typings-augmented rebuild did not happen)") + + session.DidCloseFile(ctx, boxURI) + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, boxURI, 1, files["/home/loose/app.box"].(string), lsproto.LanguageKind("box")) + languageService, err = session.GetLanguageService(ctx, boxURI) + assert.NilError(t, err) + boxFile = languageService.GetProgram().GetSourceFile("/home/loose/app.box") + assert.Assert(t, boxFile != nil) + assert.Assert(t, boxFile.ContentMapper() != "") + typingsFile = nil + for _, file := range languageService.GetProgram().SourceFiles() { + if strings.HasSuffix(file.FileName(), "@types/jquery/index.d.ts") { + typingsFile = file + break + } + } + assert.Assert(t, typingsFile != nil, "expected cached typings immediately after reopening a content-mapped root") } func TestContentMapperCreatedFileAdoptedByConfiguredProject(t *testing.T) { From aef9b618cca37cb3385ae75ffd332a1571cf1f74 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:00:08 -0700 Subject: [PATCH 4/5] Handle unbuilt inferred projects during ATA An inferred project can exist without a program while cached ATA state is still authoritative. Keep that state and its watcher until a built program validates it, and reject results older than the latest installed or confirmed state. --- tsc/internal/project/ata/ata_test.go | 434 ++++++++++++++++++ tsc/internal/project/project.go | 15 + tsc/internal/project/projectcollection.go | 6 +- .../project/projectcollectionbuilder.go | 44 +- 4 files changed, 494 insertions(+), 5 deletions(-) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 8e544c5c9641c..70a8cebc0d75a 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -3,6 +3,7 @@ package ata_test import ( "context" "slices" + "strings" "sync" "testing" "time" @@ -30,6 +31,60 @@ func waitForInstall(t *testing.T, installStarted <-chan struct{}) { } } +func typingsWatchIDs(utils *projecttestutil.SessionUtils) []project.WatcherID { + var ids []project.WatcherID + for _, call := range utils.Client().WatchFilesCalls() { + if strings.HasPrefix(string(call.ID), "typings installer files watcher") { + ids = append(ids, call.ID) + } + } + return ids +} + +func unwatchedAny(utils *projecttestutil.SessionUtils, ids []project.WatcherID) bool { + return slices.ContainsFunc(utils.Client().UnwatchFilesCalls(), func(call struct { + Ctx context.Context + ID project.WatcherID + }, + ) bool { + return slices.Contains(ids, call.ID) + }) +} + +func leaveUnbuiltInferredProject(t *testing.T, ctx context.Context, session *project.Session, utils *projecttestutil.SessionUtils) { + t.Helper() + const config = "/user/username/projects/other/tsconfig.json" + const file = "/user/username/projects/other/x.js" + openProjects := &collections.Set[string]{} + openProjects.Add(config) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + OpenProjects: openProjects, + OpenFiles: map[tspath.Path]string{utils.ToPath(file): file}, + }) + assert.NilError(t, err) + snapshot.Deref() + closeProjects := &collections.Set[tspath.Path]{} + closeProjects.Add(utils.ToPath(config)) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseProjects: closeProjects}) + assert.NilError(t, err) + snapshot.Deref() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseFiles: &collections.Set[tspath.Path]{}}) + assert.NilError(t, err) + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() == nil, "replacement inferred project should be unbuilt") + snapshot.Deref() +} + +func closeUnbuiltInferredProject(t *testing.T, ctx context.Context, session *project.Session, utils *projecttestutil.SessionUtils) { + t.Helper() + closeFiles := &collections.Set[tspath.Path]{} + closeFiles.Add(utils.ToPath("/user/username/projects/other/x.js")) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{CloseFiles: closeFiles}) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() +} + func TestATA(t *testing.T) { t.Parallel() if !bundled.Embedded { @@ -630,6 +685,385 @@ func TestATA(t *testing.T) { assert.Assert(t, typingsFile == nil, "jquery types should not be reused after the manifest changes") }) + t.Run("cached inferred typings survive an unbuilt replacement project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/x.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + session.WaitForBackgroundTasks() + watchIDs := typingsWatchIDs(utils) + assert.Assert(t, len(watchIDs) > 0) + + leaveUnbuiltInferredProject(t, ctx, session, utils) + session.WaitForBackgroundTasks() + assert.Assert(t, !unwatchedAny(utils, watchIDs), "cached ATA watch must stay registered while the replacement project is unbuilt") + + closeUnbuiltInferredProject(t, ctx, session, utils) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("ATA result survives an unbuilt replacement project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/x.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/jquery@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + leaveUnbuiltInferredProject(t, ctx, session, utils) + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + closeUnbuiltInferredProject(t, ctx, session, utils) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("cached inferred typings survive an LSP-created unbuilt project", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/other/tsconfig.json": `{"compilerOptions":{"allowJs":true}}`, + "/user/username/projects/other/y.js": ``, + "/user/username/projects/other/z.js": ``, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + yURI := lsproto.DocumentUri("file:///user/username/projects/other/y.js") + zURI := lsproto.DocumentUri("file:///user/username/projects/other/z.js") + configURI := lsproto.DocumentUri("file:///user/username/projects/other/tsconfig.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + session.WaitForBackgroundTasks() + watchIDs := typingsWatchIDs(utils) + assert.Assert(t, len(watchIDs) > 0) + + session.DidOpenFile(ctx, yURI, 1, "", lsproto.LanguageKindJavaScript) + assert.NilError(t, utils.FS().WriteFile(configURI.FileName(), `{"compilerOptions":{"allowJs":true},"exclude":["y.js"]}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: configURI, Type: lsproto.FileChangeTypeChanged}}) + session.WaitForBackgroundTasks() + session.DidOpenFile(ctx, zURI, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() == nil, "replacement inferred project should be unbuilt") + session.WaitForBackgroundTasks() + assert.Assert(t, !unwatchedAny(utils, watchIDs), "cached ATA watch must stay registered while the replacement project is unbuilt") + + session.DidCloseFile(ctx, yURI) + session.WaitForBackgroundTasks() + assert.Assert(t, session.Snapshot().ProjectCollection.InferredProject() == nil) + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not replace newer cached state", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "commander";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, `import "commander";`, lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 3, ``, lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not replace newer built project state", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"commander":"^1.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + manifestURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + + assert.NilError(t, utils.FS().WriteFile(manifestURI.FileName(), `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: manifestURI, Type: lsproto.FileChangeTypeChanged}}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + + release() + session.WaitForBackgroundTasks() + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + session.DidCloseFile(ctx, uri) + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 2, "", lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("older ATA result does not invalidate newer state while closing", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "commander";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, `import "commander";`, lsproto.LanguageKindJavaScript) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + deadline := time.Now().Add(10 * time.Second) + for { + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + if ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil { + break + } + assert.Assert(t, time.Now().Before(deadline), "newer ATA result was not applied") + time.Sleep(10 * time.Millisecond) + } + + release() + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 3, ``, lsproto.LanguageKindJavaScript) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("superseded ATA result does not replace state confirmed by a newer program", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + "commander": `declare const commander: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + installStarted := make(chan struct{}, 1) + releaseInstall := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseInstall) }) } + defer release() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + if slices.Contains(args, "@types/commander@latest") { + installStarted <- struct{}{} + <-releaseInstall + } + return originalNpmInstall(cwd, args) + } + + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import "commander";`}, + }}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + waitForInstall(t, installStarted) + session.DidChangeFile(ctx, uri, 3, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: ``}, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + release() + session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + snapshot.Deref() + + session.DidOpenFile(ctx, uri, 4, "", lsproto.LanguageKindJavaScript) + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + t.Run("inferred project does not reuse typings after compiler options change", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 26003f475f229..e85d90f82ce7d 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -182,6 +182,9 @@ type Project struct { // ataInvalidationSnapshotID is the latest snapshot that invalidated this // project's ATA discovery inputs. ataInvalidationSnapshotID uint64 + // installedTypingsSnapshotID is the snapshot that triggered the most recently + // applied typings installation. + installedTypingsSnapshotID uint64 } type inferredProjectATAState struct { @@ -190,18 +193,27 @@ type inferredProjectATAState struct { installedTypingsFilesToWatch []string typingsFiles []string typingsWatch *WatchedFiles[PatternsAndIgnored] + snapshotID uint64 } func (p *Project) inferredProjectATAState() *inferredProjectATAState { if p.installedTypingsInfo == nil && len(p.installedTypingsFilesToWatch) == 0 { return nil } + snapshotID := p.installedTypingsSnapshotID + if p.installedTypingsInfo != nil && + p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) && + slices.Equal(p.installedTypingsFileNames, p.ComputeTypingsFileNames()) && + p.ProgramLastUpdate > snapshotID { + snapshotID = p.ProgramLastUpdate + } return &inferredProjectATAState{ installedTypingsInfo: p.installedTypingsInfo, installedTypingsFileNames: slices.Clone(p.installedTypingsFileNames), installedTypingsFilesToWatch: slices.Clone(p.installedTypingsFilesToWatch), typingsFiles: slices.Clone(p.typingsFiles), typingsWatch: p.typingsWatch, + snapshotID: snapshotID, } } @@ -231,6 +243,7 @@ func (s *inferredProjectATAState) apply(project *Project) { project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) project.typingsFiles = slices.Clone(s.typingsFiles) project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID if typingsFilesChanged { project.dirty = true project.dirtyFilePath = "" @@ -251,6 +264,7 @@ func (s *inferredProjectATAState) applyWatchState(project *Project) { s.typingsFiles, ) project.typingsWatch = s.typingsWatch + project.installedTypingsSnapshotID = s.snapshotID } var _ ls.Project = (*Project)(nil) @@ -507,6 +521,7 @@ func (p *Project) Clone() *Project { installedTypingsFilesToWatch: p.installedTypingsFilesToWatch, typingsFiles: p.typingsFiles, ataInvalidationSnapshotID: p.ataInvalidationSnapshotID, + installedTypingsSnapshotID: p.installedTypingsSnapshotID, } } diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index 8e8598741d592..0b6b805416a73 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -78,12 +78,12 @@ type apiOpenedFile struct { func (c *ProjectCollection) ConfigFileRegistry() *ConfigFileRegistry { return c.configFileRegistry } func (c *ProjectCollection) inferredProjectTypingsWatch() *WatchedFiles[PatternsAndIgnored] { - if c.inferredProject != nil { - return c.inferredProject.typingsWatch - } if c.inferredProjectATAState != nil { return c.inferredProjectATAState.typingsWatch } + if c.inferredProject != nil { + return c.inferredProject.typingsWatch + } return nil } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index a3472b21142f7..7067a3be8dca4 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/contentmapper" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/debug" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" @@ -152,6 +153,11 @@ func (b *ProjectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCo ensureCloned() newProjectCollection.inferredProject = newInferredProject } + if b.inferredProjectATAState != nil { + if inferred := b.inferredProject.Value(); inferred != nil { + debug.Assert(inferred.Program == nil, "cached inferred ATA state coexists with a built inferred project") + } + } if b.inferredProjectATAState != b.base.inferredProjectATAState { ensureCloned() newProjectCollection.inferredProjectATAState = b.inferredProjectATAState @@ -987,6 +993,30 @@ func (b *ProjectCollectionBuilder) ensureProjectTree( } func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAStateChange, fileChanges FileChangeSummary, logger *logging.LogTree) { + installedTypingsSnapshotID := func(projectID ID) uint64 { + if _, inferred := projectID.Inferred(); inferred { + if state := b.inferredProjectATAState; state != nil { + return state.snapshotID + } + if project := b.inferredProject.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } else if syntheticID, synthetic := projectID.Synthetic(); synthetic { + if entry, loaded := b.syntheticProjects.Load(syntheticID); loaded { + if project := entry.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } + } else if configuredID, configured := projectID.Configured(); configured { + if entry, loaded := b.configuredProjects.Load(configuredID); loaded { + if project := entry.Value(); project != nil { + return project.installedTypingsSnapshotID + } + } + } + return 0 + } + updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) { project.ChangeIf( func(p *Project) bool { @@ -1001,6 +1031,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState }, func(p *Project) { p.installedTypingsInfo = ataChange.TypingsInfo + p.installedTypingsSnapshotID = ataChange.SnapshotID p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) p.typingsFiles = ataChange.TypingsFiles @@ -1020,6 +1051,12 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState for projectID, ataChange := range ataChanges { logger.Embed(ataChange.Logs) + if ataChange.SnapshotID < installedTypingsSnapshotID(projectID) { + if logger != nil { + logger.Logf("Ignoring ATA state older than installed state for project %s", projectID) + } + continue + } if fileChangeSummaryAffectsTypingsWatch( fileChanges, slices.Concat(ataChange.TypingsFilesToWatch, ataChange.FileNames), @@ -1039,7 +1076,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState continue } if _, ok := projectID.Inferred(); ok { - if b.inferredProject.Value() != nil { + if inferred := b.inferredProject.Value(); inferred != nil && inferred.Program != nil { updateProject(b.inferredProject, ataChange) } else { typingsWatch := newTypingsWatch(b) @@ -1059,6 +1096,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState installedTypingsFilesToWatch: slices.Clone(ataChange.TypingsFilesToWatch), typingsFiles: slices.Clone(ataChange.TypingsFiles), typingsWatch: typingsWatch.Clone(typingsWatchGlobs), + snapshotID: ataChange.SnapshotID, } } } else if syntheticProjectID, ok := projectID.Synthetic(); ok { @@ -1583,7 +1621,9 @@ func (b *ProjectCollectionBuilder) deleteInferredProject(logger *logging.LogTree return true }) } - b.inferredProjectATAState = project.inferredProjectATAState() + if project.Program != nil { + b.inferredProjectATAState = project.inferredProjectATAState() + } b.inferredProject.Delete() return true } From 40f67b5f7a6dc3113e1002c7f149dc2be5d74b04 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:39:39 -0700 Subject: [PATCH 5/5] Separate ATA inputs from installer outputs Typings cache writes are outputs of acquisition, not discovery changes, and can arrive in the same flush as their result. Keep those events available to the program while excluding them from ATA invalidation, without losing real package-owned inputs or full invalidations. --- tsc/internal/project/ata/ata_test.go | 305 +++++++++++++++++- tsc/internal/project/filechange.go | 22 ++ tsc/internal/project/project.go | 10 +- .../project/projectcollectionbuilder.go | 26 +- tsc/internal/project/snapshot.go | 22 +- 5 files changed, 366 insertions(+), 19 deletions(-) diff --git a/tsc/internal/project/ata/ata_test.go b/tsc/internal/project/ata/ata_test.go index 70a8cebc0d75a..4e9273b45d25e 100644 --- a/tsc/internal/project/ata/ata_test.go +++ b/tsc/internal/project/ata/ata_test.go @@ -2,6 +2,7 @@ package ata_test import ( "context" + "fmt" "slices" "strings" "sync" @@ -441,14 +442,314 @@ func TestATA(t *testing.T) { release() session.WaitForBackgroundTasks() + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil, "the ATA result should be delivered while no inferred project exists") + snapshot.Deref() + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + typingsFile := inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + }) + + t.Run("ATA install writes delivered with the result do not discard it", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/a/app.js": ``, + "/user/username/projects/a/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/a/package.json": `{"name":"a","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/b/app.js": ``, + "/user/username/projects/b/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/b/package.json": `{"name":"b","dependencies":{"commander":"^14.0.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + aURI := lsproto.DocumentUri("file:///user/username/projects/a/app.js") + bURI := lsproto.DocumentUri("file:///user/username/projects/b/app.js") + commanderTypings := projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts" + session.DidOpenFile(ctx, aURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, aURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + assert.Assert(t, utils.WatchesFile(strings.ToLower(commanderTypings)), "the typings location should be watched") + + session.DidOpenFile(ctx, bURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + commanderTypings), + Type: lsproto.FileChangeTypeCreated, + }}) + ls, err := session.GetLanguageService(ctx, bURI) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(commanderTypings) != nil, "the install's own writes should not discard its result") + }) + + t.Run("source edit delivered with an ATA result rejects obsolete typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": `import "jquery";`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + originalNpmInstall := utils.NpmExecutor().NpmInstallFunc + jqueryInstallStarted := make(chan struct{}, 1) + releaseJqueryInstall := make(chan struct{}) + releaseCommanderInstall := make(chan struct{}) + var releaseOnce sync.Once + releaseCommander := func() { releaseOnce.Do(func() { close(releaseCommanderInstall) }) } + defer releaseCommander() + utils.NpmExecutor().NpmInstallFunc = func(cwd string, args []string) ([]byte, error) { + switch { + case slices.Contains(args, "@types/jquery@latest"): + jqueryInstallStarted <- struct{}{} + <-releaseJqueryInstall + case slices.Contains(args, "@types/commander@latest"): + <-releaseCommanderInstall + } + return originalNpmInstall(cwd, args) + } + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + waitForInstall(t, jqueryInstallStarted) + close(releaseJqueryInstall) + session.WaitForBackgroundTasks() + session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: `import "commander";`}, + }}) ls, err := session.GetLanguageService(ctx, uri) assert.NilError(t, err) - typingsFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") - assert.Assert(t, typingsFile != nil, "jquery types should be available immediately after reopening") + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") == nil) + }) + + t.Run("package-owned typings deleted with the result invalidate discovery", func(t *testing.T) { + t.Parallel() + + const bundledTypes = "/user/username/projects/typings/foo.d.ts" + files := map[string]any{ + "/user/username/projects/project/app.js": `import "foo";`, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"foo":"^1.0.0"}}`, + "/user/username/projects/project/node_modules/foo/package.json": `{"name":"foo","types":"../../../typings/foo.d.ts"}`, + bundledTypes: `declare const bundledFoo: { x: number }`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"foo": `declare const foo: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + assert.NilError(t, utils.FS().Remove(bundledTypes)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + bundledTypes), + Type: lsproto.FileChangeTypeDeleted, + }}) + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, !slices.Contains(ls.GetProgram().CommandLine().FileNames(), bundledTypes)) + session.WaitForBackgroundTasks() + ls, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/foo/index.d.ts") != nil) + }) + + t.Run("ATA install for another project does not discard cached inferred typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + "/user/username/projects/c/app.js": ``, + "/user/username/projects/c/tsconfig.json": `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}`, + "/user/username/projects/c/package.json": `{"name":"c","dependencies":{"commander":"^14.0.0"}}`, + } + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + cURI := lsproto.DocumentUri("file:///user/username/projects/c/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, cURI, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{ + Uri: lsproto.DocumentUri("file://" + projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts"), + Type: lsproto.FileChangeTypeCreated, + }}) + _, err = session.GetLanguageService(ctx, cURI) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + }) + + t.Run("excessive typings cache writes do not discard cached inferred typings", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, _ := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + events := make([]*lsproto.FileEvent, 1001) + for i := range events { + events[i] = &lsproto.FileEvent{ + Uri: lsproto.DocumentUri(fmt.Sprintf("file://%s/node_modules/@types/generated/%d.d.ts", projecttestutil.TestTypingsLocation, i)), + Type: lsproto.FileChangeTypeCreated, + } + } + session.DidChangeWatchedFiles(ctx, events) + session.WaitForBackgroundTasks() + + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + inferred := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) }) + t.Run("excessive cache writes do not hide filesystem replacement invalidation", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{"jquery": `declare const $: { x: number }`}, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + session.DidCloseFile(ctx, uri) + snapshot, err := session.APIUpdate(ctx, project.FileChangeSummary{}, nil) + assert.NilError(t, err) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + snapshot.Deref() + + summary := project.FileChangeSummary{} + for i := range 1001 { + summary.Created.Add(lsproto.DocumentUri(fmt.Sprintf("file://%s/node_modules/@types/generated/%d.d.ts", projecttestutil.TestTypingsLocation, i))) + } + replacement := bundled.WrapFS(vfstest.FromMap(map[string]string{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{"name":"test"}`, + projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": `declare const $: { x: number }`, + }, false)) + snapshot, err = session.APIUpdate(ctx, summary, &project.APISnapshotRequest{ + FileSystem: replacement, + ReplaceFileSystem: true, + }) + assert.NilError(t, err) + snapshot.Deref() + + snapshot, err = session.APIUpdate(ctx, project.FileChangeSummary{}, &project.APISnapshotRequest{ + FileSystem: replacement, + OpenFiles: map[tspath.Path]string{ + utils.ToPath(uri.FileName()): uri.FileName(), + }, + }) + assert.NilError(t, err) + defer snapshot.Deref() + inferred := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, inferred != nil && inferred.GetProgram() != nil) + assert.Assert(t, inferred.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") == nil) + }) + + for _, configured := range []bool{false, true} { + name := "inferred" + if configured { + name = "configured" + } + t.Run("typings added by a manifest change reach the "+name+" program", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{"name":"test","dependencies":{"jquery":"^3.1.0"}}`, + } + if configured { + files["/user/username/projects/project/tsconfig.json"] = `{"compilerOptions":{"allowJs":true},"typeAcquisition":{"enable":true}}` + } + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TypingsInstallerOptions{ + PackageToFile: map[string]string{ + "commander": `declare const commander: { x: number }`, + "jquery": `declare const $: { x: number }`, + }, + }) + ctx := context.Background() + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + packageJSONURI := lsproto.DocumentUri("file:///user/username/projects/project/package.json") + session.DidOpenFile(ctx, uri, 1, "", lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + _, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + + assert.NilError(t, utils.FS().WriteFile(packageJSONURI.FileName(), `{"name":"test","dependencies":{"commander":"^14.0.0","jquery":"^3.1.0"}}`)) + session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{Uri: packageJSONURI, Type: lsproto.FileChangeTypeChanged}}) + _, err = session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(ctx, uri) + assert.NilError(t, err) + program := ls.GetProgram() + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) + assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) + }) + } + t.Run("inferred project rejects ATA result invalidated after closing last file", func(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/filechange.go b/tsc/internal/project/filechange.go index 251b63fcf335d..dcec6e9e5d1ed 100644 --- a/tsc/internal/project/filechange.go +++ b/tsc/internal/project/filechange.go @@ -3,6 +3,7 @@ package project import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/tspath" ) const excessiveChangeThreshold = 1000 @@ -96,3 +97,24 @@ func mergeFileChangeSummary(dst *FileChangeSummary, src FileChangeSummary) { dst.IncludesWatchChangeOutsideNodeModules = true } } + +func (f FileChangeSummary) withoutChangesWithin(directory string, useCaseSensitiveFileNames bool) FileChangeSummary { + if directory == "" { + return f + } + options := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: useCaseSensitiveFileNames} + filter := func(uris collections.Set[lsproto.DocumentUri]) collections.Set[lsproto.DocumentUri] { + var result collections.Set[lsproto.DocumentUri] + for uri := range uris.Keys() { + fileName := uri.FileName() + if tspath.ComparePaths(directory, fileName, options) != 0 && !tspath.ContainsPath(directory, fileName, options) { + result.Add(uri) + } + } + return result + } + f.Changed = filter(f.Changed) + f.Created = filter(f.Created) + f.Deleted = filter(f.Deleted) + return f +} diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index e85d90f82ce7d..32b99bc4d0671 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -241,7 +241,7 @@ func (s *inferredProjectATAState) apply(project *Project) { project.installedTypingsInfo = s.installedTypingsInfo project.installedTypingsFileNames = slices.Clone(s.installedTypingsFileNames) project.installedTypingsFilesToWatch = slices.Clone(s.installedTypingsFilesToWatch) - project.typingsFiles = slices.Clone(s.typingsFiles) + project.setTypingsFiles(slices.Clone(s.typingsFiles)) project.typingsWatch = s.typingsWatch project.installedTypingsSnapshotID = s.snapshotID if typingsFilesChanged { @@ -541,6 +541,14 @@ func (p *Project) SetCommandLine(commandLine *tsoptions.ParsedCommandLine) { p.dirtyFilePath = "" } +func (p *Project) setTypingsFiles(typingsFiles []string) { + if !slices.Equal(p.typingsFiles, typingsFiles) { + p.commandLineWithTypingsFiles = nil + p.commandLineWithTypingsFilesOnce = sync.Once{} + } + p.typingsFiles = typingsFiles +} + // getCommandLineWithTypingsFiles returns the command line augmented with typing files if ATA is enabled. func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine { if len(p.typingsFiles) == 0 { diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 7067a3be8dca4..2816b610b637d 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -471,6 +471,16 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log return true }) + // Handle opened file + if summary.Opened != "" || summary.Reopened != "" { + fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName() + path := b.toPath(fileName) + openFileResult := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger) + b.cleanupConfiguredProjects(&openFileResult.retain, logger) + } +} + +func (b *ProjectCollectionBuilder) DidChangeTypingsWatchInputs(summary FileChangeSummary, logger *logging.LogTree) { b.forEachProject(func(entry dirty.Value[*Project]) bool { projectID := entry.Value().ID() if entry.ChangeIf( @@ -479,7 +489,6 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log summary, project.installedTypingsFilesToWatch, project.typingsFiles, - b.sessionOptions.TypingsLocation, b.fs.fs.UseCaseSensitiveFileNames(), ) }, @@ -499,26 +508,16 @@ func (b *ProjectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, log summary, b.inferredProjectATAState.installedTypingsFilesToWatch, b.inferredProjectATAState.typingsFiles, - b.sessionOptions.TypingsLocation, b.fs.fs.UseCaseSensitiveFileNames(), ) { b.invalidateInferredProjectATAState("typings watch changes", logger) } - - // Handle opened file - if summary.Opened != "" || summary.Reopened != "" { - fileName := core.FirstNonZero(summary.Opened, summary.Reopened).FileName() - path := b.toPath(fileName) - openFileResult := b.ensureConfiguredProjectAndAncestorsForFile(fileName, path, logger) - b.cleanupConfiguredProjects(&openFileResult.retain, logger) - } } func fileChangeSummaryAffectsTypingsWatch( summary FileChangeSummary, filesToWatch []string, typingsFiles []string, - typingsLocation string, useCaseSensitiveFileNames bool, ) bool { if summary.InvalidateAll { @@ -544,7 +543,7 @@ func fileChangeSummaryAffectsTypingsWatch( return tspath.ComparePaths(tspath.CombinePaths(tspath.GetDirectoryPath(watchedPath), "bower.json"), fileName, comparePathsOptions) == 0 } return false - }) || typingsLocation != "" && tspath.ContainsPath(typingsLocation, fileName, comparePathsOptions) + }) } for uri := range summary.Changed.Keys() { if affectsWatch(uri) { @@ -1034,7 +1033,7 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState p.installedTypingsSnapshotID = ataChange.SnapshotID p.installedTypingsFileNames = slices.Clone(ataChange.FileNames) p.installedTypingsFilesToWatch = slices.Clone(ataChange.TypingsFilesToWatch) - p.typingsFiles = ataChange.TypingsFiles + p.setTypingsFiles(ataChange.TypingsFiles) typingsWatchGlobs := getTypingsLocationsGlobs( slices.Concat(ataChange.TypingsFilesToWatch, ataChange.TypingsFiles), b.sessionOptions.TypingsLocation, @@ -1061,7 +1060,6 @@ func (b *ProjectCollectionBuilder) DidUpdateATAState(ataChanges map[ID]*ATAState fileChanges, slices.Concat(ataChange.TypingsFilesToWatch, ataChange.FileNames), ataChange.TypingsFiles, - b.sessionOptions.TypingsLocation, b.fs.fs.UseCaseSensitiveFileNames(), ) { b.invalidateProjectATAState(projectID) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 5aa053bd6bb9b..625721d0f1868 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -501,6 +501,10 @@ func (s *Snapshot) Clone( start := time.Now() hadExcessiveWatchEvents := change.fileChanges.HasExcessiveWatchEvents() + var unfilteredFileChanges FileChangeSummary + if hadExcessiveWatchEvents { + unfilteredFileChanges = change.fileChanges.Clone() + } inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -521,6 +525,17 @@ func (s *Snapshot) Clone( overlays = layeredFS.Overlays() fs := newSnapshotFSBuilderFromSource(layeredFS, s.fs.cacheFiles, s.fs.cacheDirectories, s.fs.nodeModulesRealpathAliases, store.toPath) change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions, s.overlays(), overlays) + typingsWatchChanges := change.fileChanges + if hadExcessiveWatchEvents { + typingsWatchChanges = unfilteredFileChanges + typingsWatchChanges.InvalidateAll = typingsWatchChanges.InvalidateAll || change.fileChanges.InvalidateAll + } + if typingsLocation := store.options.TypingsLocation; typingsLocation != "" { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(typingsLocation, fs.fs.UseCaseSensitiveFileNames()) + if realTypingsLocation := fs.fs.Realpath(typingsLocation); realTypingsLocation != typingsLocation { + typingsWatchChanges = typingsWatchChanges.withoutChangesWithin(realTypingsLocation, fs.fs.UseCaseSensitiveFileNames()) + } + } compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { @@ -554,7 +569,7 @@ func (s *Snapshot) Clone( client, ) - if hadExcessiveWatchEvents { + if typingsWatchChanges.HasExcessiveWatchEvents() { projectCollectionBuilder.DidInvalidateTypingsWatchState(logger.Fork("DidInvalidateTypingsWatchState")) } @@ -583,8 +598,11 @@ func (s *Snapshot) Clone( if !change.fileChanges.IsEmpty() { projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) } + if !typingsWatchChanges.IsEmpty() { + projectCollectionBuilder.DidChangeTypingsWatchInputs(typingsWatchChanges, logger.Fork("DidChangeTypingsWatchInputs")) + } if len(change.ataChanges) != 0 { - projectCollectionBuilder.DidUpdateATAState(change.ataChanges, change.fileChanges, logger.Fork("DidUpdateATAState")) + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, typingsWatchChanges, logger.Fork("DidUpdateATAState")) } var apiError error