From 2a85780dd27e0d7479ee5d982efa246021fd84df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sun, 4 Oct 2026 19:02:47 +0200 Subject: [PATCH 1/2] Update the inferred project program when opening a file whose default project is up to date --- .../workspaceSymbolNewInferredProject_test.go | 50 +++++++++++++++++++ .../project/projectcollectionbuilder.go | 8 +-- 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go diff --git a/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go b/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go new file mode 100644 index 0000000000000..5fc9e11527f29 --- /dev/null +++ b/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go @@ -0,0 +1,50 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +// workspace/symbol used to crash when an open file got kicked out of its tsconfig project +// and then another file from that project was opened. The orphaned file ends up in a fresh +// inferred project, and nothing was building a program for it. +func TestWorkspaceSymbolNewInferredProject(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @Filename: /home/src/projects/p/tsconfig.json +// @noOpen: true +{ "files": ["a.ts", "b.ts"] } + +// @Filename: /home/src/projects/p/a.ts +import "./e"; + +// @Filename: /home/src/projects/p/b.ts +// @noOpen: true +export const b = 1; + +// @Filename: /home/src/projects/p/e.ts +export const e = 1; +` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + + // e.ts isn't listed in tsconfig.json, it only gets pulled in by the import in a.ts. + // Once that import is gone, e.ts has nowhere to live. workspace/symbol only refreshes + // the tsconfig project, so e.ts doesn't get its inferred project just yet. + f.GoToFile(t, "/home/src/projects/p/a.ts") + f.Replace(t, 0, len(`import "./e";`), "") + f.VerifyWorkspaceSymbol(t, []*fourslash.VerifyWorkspaceSymbolCase{ + {Pattern: "b", Includes: new([]*lsproto.SymbolInformation{})}, + }) + + // The tsconfig project is already up to date, so opening b.ts takes the fast path. + // That's also when the inferred project for e.ts gets created. + f.GoToFile(t, "/home/src/projects/p/b.ts") + f.VerifyWorkspaceSymbol(t, []*fourslash.VerifyWorkspaceSymbolCase{ + {Pattern: "b", Includes: new([]*lsproto.SymbolInformation{})}, + }) +} diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 2a190503e8468..7a63cbc3b06e9 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -667,9 +667,11 @@ func (b *ProjectCollectionBuilder) didRequestFile(fileName tspath.RootedFilePath if result.Value() != nil && result.Value().containsFile(path) { if hasChanges { b.cleanupInferredProject(logger) - if b.inferredProject.Value() != nil { - b.updateProgram(b.inferredProject, logger) - } + } + // Opening this file may have created the inferred project or changed its roots, + // even if the default project didn't change + if b.inferredProject.Value() != nil { + b.updateProgram(b.inferredProject, logger) } return } From 9629f92c9b0af5d1d2a73385d79d706e3f8ecfdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 7 Oct 2026 23:34:37 +0200 Subject: [PATCH 2/2] Refresh the inferred project when loading project trees --- .../workspaceSymbolNewInferredProject_test.go | 15 +++++++++++---- tsc/internal/project/projectcollectionbuilder.go | 15 ++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go b/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go index 5fc9e11527f29..55bff5331e00e 100644 --- a/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go +++ b/tsc/internal/fourslash/tests/workspaceSymbolNewInferredProject_test.go @@ -10,7 +10,8 @@ import ( // workspace/symbol used to crash when an open file got kicked out of its tsconfig project // and then another file from that project was opened. The orphaned file ends up in a fresh -// inferred project, and nothing was building a program for it. +// inferred project, and nothing was building a program for it. Before that, the orphaned +// file's symbols were missing because loading project trees didn't refresh the inferred project. func TestWorkspaceSymbolNewInferredProject(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") @@ -33,16 +34,22 @@ export const e = 1; defer done() // e.ts isn't listed in tsconfig.json, it only gets pulled in by the import in a.ts. - // Once that import is gone, e.ts has nowhere to live. workspace/symbol only refreshes - // the tsconfig project, so e.ts doesn't get its inferred project just yet. + // Once that import is gone, e.ts should move to the inferred project. f.GoToFile(t, "/home/src/projects/p/a.ts") f.Replace(t, 0, len(`import "./e";`), "") f.VerifyWorkspaceSymbol(t, []*fourslash.VerifyWorkspaceSymbolCase{ {Pattern: "b", Includes: new([]*lsproto.SymbolInformation{})}, + {Pattern: "e", Includes: new([]*lsproto.SymbolInformation{{ + Name: "e", + Kind: lsproto.SymbolKindVariable, + Location: lsproto.Location{ + Uri: "file:///home/src/projects/p/e.ts", + Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 13}, End: lsproto.Position{Line: 0, Character: 14}}, + }, + }})}, }) // The tsconfig project is already up to date, so opening b.ts takes the fast path. - // That's also when the inferred project for e.ts gets created. f.GoToFile(t, "/home/src/projects/p/b.ts") f.VerifyWorkspaceSymbol(t, []*fourslash.VerifyWorkspaceSymbolCase{ {Pattern: "b", Includes: new([]*lsproto.SymbolInformation{})}, diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 7a63cbc3b06e9..99872b4cb6eee 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -667,11 +667,9 @@ func (b *ProjectCollectionBuilder) didRequestFile(fileName tspath.RootedFilePath if result.Value() != nil && result.Value().containsFile(path) { if hasChanges { b.cleanupInferredProject(logger) - } - // Opening this file may have created the inferred project or changed its roots, - // even if the default project didn't change - if b.inferredProject.Value() != nil { - b.updateProgram(b.inferredProject, logger) + if b.inferredProject.Value() != nil { + b.updateProgram(b.inferredProject, logger) + } } return } @@ -762,6 +760,13 @@ func (b *ProjectCollectionBuilder) DidRequestProjectTrees(projectTreeRequest *Pr } wg.RunAndWait() + // Updated configured projects may have moved open files in or out of the inferred project. + // Callers iterate over all language service projects, so the inferred one needs a program too. + b.cleanupInferredProject(logger) + if b.inferredProject.Value() != nil { + b.updateProgram(b.inferredProject, logger) + } + if logger != nil { elapsed := time.Since(startTime) logger.Log(fmt.Sprintf("Completed project tree request for %v in %v", projectTreeRequest.Projects(), elapsed))