Skip to content
This repository was archived by the owner on Nov 21, 2025. It is now read-only.

Commit f3a1c76

Browse files
atscottKeen Yee Liau
authored andcommitted
fix: prevent project from closing when only a template file is open
After opening a file, TypeScript does a cleanup step to remove/close "orphaned" projects: https://github.com/microsoft/TypeScript/blob/a894f8ad2b0ff35b1bcfd91defba994136c68b18/src/server/editorServices.ts#L3337 As part of this, TS checks to make sure there are no open files for the configured project: https://github.com/microsoft/TypeScript/blob/a894f8ad2b0ff35b1bcfd91defba994136c68b18/src/server/project.ts#L2333 If the only open file for the project is an HTML file, TS will not know about it because we do not register those with the project (instead, we associate the template file's `ScriptInfo` with the project, but do not make the association in the other direction). Because we do not have this two way association between the project and the HTML `ScriptInfo`s, TypeScript closes the configured project on actions like "go to definition" that open a `d.ts` file outside the project. This is problematic for two particular reasons: 1. The project should not be closed in the first place. We will take a performance hit when the user inevitably goes back to the template file because now the project has to be re-opened. 2. The template file's `ScriptInfo` still tracks the closed project in its `containingProjects` array. As part of the cleanup for closing a project, TS would detach the `ScriptInfo`s from the project (https://github.com/microsoft/TypeScript/blob/a894f8ad2b0ff35b1bcfd91defba994136c68b18/src/server/project.ts#L752) but again, because we don't have the two-way association as mentioned above, this does not happen. This results in a crash the next time a request is made from the template file. This commit addresses both of the issues above. First, we prevent the project from being closed by creating an external project associated with the configured project for the template file. When TypeScript checks if a project has open refs/files, it returns `true` if there are external project references (https://github.com/microsoft/TypeScript/blob/a894f8ad2b0ff35b1bcfd91defba994136c68b18/src/server/project.ts#L2334). This means that the clean up after opening a new file process as mentioned above will not result in our project for the template being closed. Second, when retrieving a script info for a file we check if the project as been closed. If it has, we detach the script info from the project, log an error, and simply return `undefined`. To our knowledge, this should not happen because of the first solution we implemented, but it would prevent future crashes if there are other scenarios we missed.
1 parent 65be7da commit f3a1c76

File tree

2 files changed

+103
-13
lines changed

2 files changed

+103
-13
lines changed

integration/lsp/ivy_spec.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {MessageConnection} from 'vscode-jsonrpc';
1010
import * as lsp from 'vscode-languageserver-protocol';
11-
11+
import {URI} from 'vscode-uri';
1212
import {ProjectLanguageService, ProjectLanguageServiceParams} from '../../common/notifications';
1313
import {NgccProgress, NgccProgressToken, NgccProgressType} from '../../common/progress';
1414

@@ -69,7 +69,7 @@ describe('Angular Ivy language server', () => {
6969
client.sendNotification(lsp.DidOpenTextDocumentNotification.type, {
7070
textDocument: {
7171
uri: `file://${FOO_TEMPLATE}`,
72-
languageId: 'typescript',
72+
languageId: 'html',
7373
version: 1,
7474
text: `{{ doesnotexist }}`,
7575
},
@@ -81,6 +81,42 @@ describe('Angular Ivy language server', () => {
8181
expect(diagnostics[0].message)
8282
.toBe(`Property 'doesnotexist' does not exist on type 'FooComponent'.`);
8383
});
84+
85+
it('does not break after opening `.d.ts` file from external template', async () => {
86+
client.sendNotification(lsp.DidOpenTextDocumentNotification.type, {
87+
textDocument: {
88+
uri: `file://${FOO_TEMPLATE}`,
89+
languageId: 'html',
90+
version: 1,
91+
text: `<div *ngIf="false"></div>`,
92+
},
93+
});
94+
const languageServiceEnabled = await waitForNgcc(client);
95+
expect(languageServiceEnabled).toBeTrue();
96+
const response = await client.sendRequest(lsp.DefinitionRequest.type, {
97+
textDocument: {
98+
uri: `file://${FOO_TEMPLATE}`,
99+
},
100+
position: {line: 0, character: 7},
101+
}) as lsp.LocationLink[];
102+
// 2 results - the NgIf class and the ngIf input
103+
expect(Array.isArray(response)).toBe(true);
104+
const {targetUri} = response[0];
105+
expect(targetUri.endsWith('angular/common/common.d.ts')).toBeTrue();
106+
// Open the `.d.ts` file
107+
openTextDocument(client, URI.parse(targetUri).fsPath);
108+
// try a hover operation again on *ngIf
109+
const hoverResponse = await client.sendRequest(lsp.HoverRequest.type, {
110+
textDocument: {
111+
uri: `file://${FOO_TEMPLATE}`,
112+
},
113+
position: {line: 0, character: 7},
114+
});
115+
expect(hoverResponse?.contents).toContain({
116+
language: 'typescript',
117+
value: 'declare (property) NgIf<boolean>.ngIf: boolean',
118+
});
119+
});
84120
});
85121

86122
function onNgccProgress(client: MessageConnection): Promise<string> {

server/src/session.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class Session {
4444
private readonly projectService: ts.server.ProjectService;
4545
private readonly logger: ts.server.Logger;
4646
private readonly ivy: boolean;
47+
private readonly configuredProjToExternalProj = new Map<string, string>();
4748
private diagnosticsTimeout: NodeJS.Timeout|null = null;
4849
private isProjectLoading = false;
4950

@@ -307,6 +308,7 @@ export class Session {
307308
scriptInfo.detachAllProjects();
308309
scriptInfo.attachToProject(project);
309310
}
311+
this.createExternalProject(project);
310312

311313
return project;
312314
}
@@ -377,13 +379,62 @@ export class Session {
377379
}
378380
}
379381

382+
/**
383+
* Creates an external project with the same config path as `project` so that TypeScript keeps the
384+
* project open when navigating away from `html` files.
385+
*/
386+
private createExternalProject(project: ts.server.Project) {
387+
if (isConfiguredProject(project) &&
388+
!this.configuredProjToExternalProj.has(project.projectName)) {
389+
const extProjectName = `${project.projectName}-external`;
390+
project.projectService.openExternalProject({
391+
projectFileName: extProjectName,
392+
rootFiles: [{fileName: project.getConfigFilePath()}],
393+
options: {}
394+
});
395+
this.configuredProjToExternalProj.set(project.projectName, extProjectName);
396+
}
397+
}
398+
380399
private onDidCloseTextDocument(params: lsp.DidCloseTextDocumentParams) {
381400
const {textDocument} = params;
382401
const filePath = uriToFilePath(textDocument.uri);
383402
if (!filePath) {
384403
return;
385404
}
386405
this.projectService.closeClientFile(filePath);
406+
this.maybeCloseExternalProject(filePath);
407+
}
408+
409+
/**
410+
* We open external projects for files so that we can prevent TypeScript from closing a project
411+
* when there is open external HTML template that still references the project. This function
412+
* checks if there are no longer any open files in the project for the given `filePath`. If there
413+
* aren't, we also close the external project that was created.
414+
*/
415+
private maybeCloseExternalProject(filePath: string) {
416+
const scriptInfo = this.projectService.getScriptInfo(filePath);
417+
if (!scriptInfo) {
418+
return;
419+
}
420+
for (const [configuredProjName, externalProjName] of this.configuredProjToExternalProj) {
421+
const configuredProj = this.projectService.findProject(configuredProjName);
422+
if (!configuredProj || configuredProj.isClosed()) {
423+
this.projectService.closeExternalProject(externalProjName);
424+
this.configuredProjToExternalProj.delete(configuredProjName);
425+
continue;
426+
}
427+
// See if any openFiles belong to the configured project.
428+
// if not, close external project this.projectService.openFiles
429+
const openFiles = toArray(this.projectService.openFiles.keys());
430+
if (!openFiles.some(file => {
431+
const scriptInfo = this.projectService.getScriptInfo(file);
432+
return scriptInfo?.isAttached(configuredProj);
433+
})) {
434+
this.projectService.closeExternalProject(externalProjName);
435+
this.configuredProjToExternalProj.delete(configuredProjName);
436+
}
437+
}
387438
}
388439

389440
private onDidChangeTextDocument(params: lsp.DidChangeTextDocumentParams) {
@@ -519,15 +570,10 @@ export class Session {
519570
if (!project?.languageServiceEnabled) {
520571
return;
521572
}
522-
// TODO(kyliau): For some reasons, the project could end up in an inconsistent
523-
// state where the language service is undefined. It could be that the project
524-
// is closed (?!). While we investigate this issue, put in a temporary fix
525-
// to force the project to reload.
526-
// Also note that the return type of getLanguageService() is deceiving,
527-
// because it could return undefined
528-
// https://github.com/microsoft/TypeScript/blob/1c1cd9b08d8bf1c77abb57d195cc6d79b1093390/src/server/project.ts#L797
529-
if (project.getLanguageService() === undefined) {
530-
project.markAsDirty();
573+
if (project.isClosed()) {
574+
scriptInfo.detachFromProject(project);
575+
this.logger.info(`Failed to get language service for closed project ${project.projectName}.`);
576+
return undefined;
531577
}
532578
return {
533579
languageService: project.getLanguageService(),
@@ -592,8 +638,8 @@ export class Session {
592638
private onCompletionResolve(item: lsp.CompletionItem): lsp.CompletionItem {
593639
const data = readNgCompletionData(item);
594640
if (data === null) {
595-
// This item wasn't tagged with Angular LS completion data - it probably didn't originate from
596-
// this language service.
641+
// This item wasn't tagged with Angular LS completion data - it probably didn't originate
642+
// from this language service.
597643
return item;
598644
}
599645

@@ -725,3 +771,11 @@ export class Session {
725771
return false;
726772
}
727773
}
774+
775+
function toArray<T>(it: ts.Iterator<T>): T[] {
776+
const results: T[] = [];
777+
for (let itResult = it.next(); !itResult.done; itResult = it.next()) {
778+
results.push(itResult.value);
779+
}
780+
return results;
781+
}

0 commit comments

Comments
 (0)