fix(language-server): recover project for external templates in solution-style workspaces

In a composite/solution-style workspace (e.g. an Nx monorepo, where an
app's tsconfig.json only contains project references), TypeScript can
never resolve a config file for an HTML file, since HTML files are not
listed in any referenced project. angular/vscode-ng-language-service#2165
worked around this in onDidOpenTextDocument by briefly opening the
sibling TS file so the right project loads when a template is opened
first.

However, getDefaultProjectForScriptInfo - the recovery path used by
getLSAndScriptInfo and onDidChangeTextDocument when a script info has no
configured project - did not receive the same workaround. When an open
template loses its project association (e.g. its component file is
closed and the project graph updates), every subsequent request on the
template fails with "No config file" and returns null indefinitely,
until the user manually reopens the component file.

Apply the same sibling-TS best effort in getDefaultProjectForScriptInfo,
and additionally attach the template's script info to the configured
project of its component when the config lookup still comes back empty
(openClientFile does not repeat the config lookup for already-open
files).

Also skip the sibling lookup when the .ts file does not exist, so
non-component HTML files (e.g. src/index.html) do not trigger an
open/close and config search that cannot succeed.

Fixes #69768

(cherry picked from commit 3f8d9d6ea6)
This commit is contained in:
Shayan
2026-07-14 20:29:05 +05:00
committed by Jessica Janiuk
parent 3a82a16314
commit 14fbe04612
@@ -535,7 +535,21 @@ export class Session {
// If they are already part of a ConfiguredProject then the following is
// not needed.
if (!project || project.projectKind !== ts.server.ProjectKind.Configured) {
const {configFileName} = this.projectService.openClientFile(scriptInfo.fileName);
let {configFileName} = this.projectService.openClientFile(scriptInfo.fileName);
if (configFileName === undefined && isExternalTemplate(scriptInfo.fileName)) {
// When an external template loses its configured project after it was opened (e.g.
// because the corresponding component file was closed and the project graph was updated),
// the same best-effort used in `onDidOpenTextDocument` is needed here.
if (this.loadProjectForExternalTemplate(scriptInfo.fileName)) {
({configFileName} = this.projectService.openClientFile(scriptInfo.fileName));
}
if (configFileName === undefined) {
const componentProject = this.attachToComponentProject(scriptInfo);
if (componentProject) {
return componentProject;
}
}
}
if (!configFileName) {
// Failed to find a config file. There is nothing we could do.
this.error(`No config file for ${scriptInfo.fileName}`);
@@ -552,6 +566,49 @@ export class Session {
return project;
}
/**
* In a composite/solution-style project with references, TypeScript will _not_ open a project
* for an HTML file unless the file is explicitly included in the files/includes list. This is
* quite unlikely to be the case for HTML files. As a best-effort to fix this, we attempt to open
* a TS file with the same name so that its project is loaded. Most of the time, this is going to
* be the component file for the external template.
* https://github.com/angular/vscode-ng-language-service/issues/2149
*
* Returns whether the component file was opened, i.e. whether the config lookup for the template
* is worth retrying.
*/
private loadProjectForExternalTemplate(templatePath: string): boolean {
const maybeComponentTsPath = componentPathForTemplate(templatePath);
if (
this.projectService.openFiles.has(this.projectService.toPath(maybeComponentTsPath)) ||
// Non-component HTML files (e.g. `src/index.html`) have no sibling `.ts`; skip the
// open/close so we don't walk directory trees looking for a config that can't exist.
!this.host.fileExists(maybeComponentTsPath)
) {
return false;
}
this.projectService.openClientFile(maybeComponentTsPath);
this.projectService.closeClientFile(maybeComponentTsPath);
return true;
}
/**
* Attaches an external template to the configured project of its component. Needed because
* `openClientFile` does not repeat the config lookup for a file that is already open, so it can
* still report no config file even though the component's project has since been loaded.
*/
private attachToComponentProject(scriptInfo: ts.server.ScriptInfo): ts.server.Project | null {
const componentProject = this.projectService
.getScriptInfo(componentPathForTemplate(scriptInfo.fileName))
?.containingProjects.find(isConfiguredProject);
if (!componentProject) {
return null;
}
scriptInfo.detachAllProjects();
scriptInfo.attachToProject(componentProject);
return componentProject;
}
private onDidOpenTextDocument(params: lsp.DidOpenTextDocumentParams) {
const {uri, languageId, text} = params.textDocument;
const filePath = uriToFilePath(uri);
@@ -568,19 +625,16 @@ export class Session {
// buffer in the user's editor which has not been saved to disk.
// See https://github.com/angular/vscode-ng-language-service/issues/632
let result = this.projectService.openClientFile(filePath, text, scriptKind);
// If the first opened file is an HTML file and the project is a composite/solution-style
// project with references, TypeScript will _not_ open a project unless the file is explicitly
// included in the files/includes list. This is quite unlikely to be the case for HTML files.
// As a best-effort to fix this, we attempt to open a TS file with the same name. Most of the
// time, this is going to be the component file for the external template.
// https://github.com/angular/vscode-ng-language-service/issues/2149
if (result.configFileName === undefined && languageId === LanguageId.HTML) {
const maybeComponentTsPath = filePath.replace(/\.html$/, '.ts');
if (!this.projectService.openFiles.has(this.projectService.toPath(maybeComponentTsPath))) {
this.projectService.openClientFile(maybeComponentTsPath);
this.projectService.closeClientFile(maybeComponentTsPath);
if (this.loadProjectForExternalTemplate(filePath)) {
result = this.projectService.openClientFile(filePath, text, scriptKind);
}
if (result.configFileName === undefined) {
const scriptInfo = this.projectService.getScriptInfo(filePath);
if (scriptInfo) {
this.attachToComponentProject(scriptInfo);
}
}
}
const {configFileName, configFileErrors} = result;
@@ -790,3 +844,7 @@ function isTypeScriptFile(path: string): boolean {
function isExternalTemplate(path: string): boolean {
return !isTypeScriptFile(path);
}
function componentPathForTemplate(templatePath: string): string {
return templatePath.replace(/\.html$/, '.ts');
}