Keep scriptInfo and project alive even after file delete till next file open (#57492)

This commit is contained in:
Sheetal Nandi
2024-04-12 10:43:09 -07:00
committed by GitHub
parent 551a600e84
commit 4e294963c8
244 changed files with 210245 additions and 3635 deletions
+295 -176
View File
@@ -160,6 +160,7 @@ import {
isDynamicFileName,
isInferredProject,
isInferredProjectName,
isProjectDeferredClose,
ITypingsInstaller,
Logger,
LogLevel,
@@ -1023,14 +1024,14 @@ export class ProjectService {
*
* @internal
*/
readonly filenameToScriptInfo = new Map<string, ScriptInfo>();
readonly filenameToScriptInfo = new Map<Path, ScriptInfo>();
private readonly nodeModulesWatchers = new Map<Path, NodeModulesWatcher>();
/**
* Contains all the deleted script info's version information so that
* it does not reset when creating script info again
* (and could have potentially collided with version where contents mismatch)
*/
private readonly filenameToScriptInfoVersion = new Map<string, number>();
private readonly filenameToScriptInfoVersion = new Map<Path, number>();
// Set of all '.js' files ever opened.
private readonly allJsFilesForOpenFileTelemetry = new Map<string, true>();
@@ -1066,7 +1067,7 @@ export class ProjectService {
/**
* Open files: with value being project root path, and key being Path of the file that is open
*/
readonly openFiles: Map<string, NormalizedPath | undefined> = new Map<Path, NormalizedPath | undefined>();
readonly openFiles: Map<Path, NormalizedPath | undefined> = new Map<Path, NormalizedPath | undefined>();
/** @internal */
readonly configFileForOpenFiles = new Map<Path, NormalizedPath | false>();
/**
@@ -1352,6 +1353,7 @@ export class ProjectService {
}
private delayUpdateProjectGraph(project: Project) {
if (isProjectDeferredClose(project)) return;
project.markAsDirty();
if (isBackgroundProject(project)) return;
const projectName = project.getProjectName();
@@ -1377,7 +1379,7 @@ export class ProjectService {
const event: ProjectsUpdatedInBackgroundEvent = {
eventName: ProjectsUpdatedInBackgroundEvent,
data: {
openFiles: arrayFrom(this.openFiles.keys(), path => this.getScriptInfoForPath(path as Path)!.fileName),
openFiles: arrayFrom(this.openFiles.keys(), path => this.getScriptInfoForPath(path)!.fileName),
},
};
this.eventHandler(event);
@@ -1591,11 +1593,12 @@ export class ProjectService {
}
private onSourceFileChanged(info: ScriptInfo, eventKind: FileWatcherEventKind) {
Debug.assert(!info.isScriptOpen());
if (eventKind === FileWatcherEventKind.Deleted) {
// File was deleted
this.handleDeletedFile(info);
this.handleDeletedFile(info, /*deferredDelete*/ true);
}
else if (!info.isScriptOpen()) {
else {
if (info.deferredDelete) info.deferredDelete = undefined;
// file has been changed which might affect the set of referenced files in projects that include
// this file and set of inferred projects
info.delayReloadNonMixedContentFile();
@@ -1609,7 +1612,7 @@ export class ProjectService {
if (info.sourceMapFilePath) {
if (isString(info.sourceMapFilePath)) {
const sourceMapFileInfo = this.getScriptInfoForPath(info.sourceMapFilePath);
this.delayUpdateSourceInfoProjects(sourceMapFileInfo && sourceMapFileInfo.sourceInfos);
this.delayUpdateSourceInfoProjects(sourceMapFileInfo?.sourceInfos);
}
else {
this.delayUpdateSourceInfoProjects(info.sourceMapFilePath.sourceInfos);
@@ -1635,28 +1638,17 @@ export class ProjectService {
}
}
private handleDeletedFile(info: ScriptInfo) {
this.stopWatchingScriptInfo(info);
if (!info.isScriptOpen()) {
private handleDeletedFile(info: ScriptInfo, deferredDelete: boolean) {
Debug.assert(!info.isScriptOpen());
this.delayUpdateProjectGraphs(info.containingProjects, /*clearSourceMapperCache*/ false);
this.handleSourceMapProjects(info);
info.detachAllProjects();
if (deferredDelete) {
info.delayReloadNonMixedContentFile();
info.deferredDelete = true;
}
else {
this.deleteScriptInfo(info);
// capture list of projects since detachAllProjects will wipe out original list
const containingProjects = info.containingProjects.slice();
info.detachAllProjects();
// update projects to make sure that set of referenced files is correct
this.delayUpdateProjectGraphs(containingProjects, /*clearSourceMapperCache*/ false);
this.handleSourceMapProjects(info);
info.closeSourceMapFileWatcher();
// need to recalculate source map from declaration file
if (info.declarationInfoPath) {
const declarationInfo = this.getScriptInfoForPath(info.declarationInfoPath);
if (declarationInfo) {
declarationInfo.sourceMapFilePath = undefined;
}
}
}
}
@@ -1784,21 +1776,24 @@ export class ProjectService {
/** @internal */
private onConfigFileChanged(canonicalConfigFilePath: NormalizedPath, eventKind: FileWatcherEventKind) {
const configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath)!;
const project = this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath);
const wasDefferedClose = project?.deferredClose;
if (eventKind === FileWatcherEventKind.Deleted) {
// Update the cached status
// We arent updating or removing the cached config file presence info as that will be taken care of by
// releaseParsedConfig when the project is closed or doesnt need this config any more (depending on tracking open files)
configFileExistenceInfo.exists = false;
// Remove the configured project for this config file
const project = configFileExistenceInfo.config?.projects.has(canonicalConfigFilePath) ?
this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath) :
undefined;
if (project) this.removeProject(project);
// Deferred remove the configured project for this config file
if (project) project.deferredClose = true;
}
else {
// Update the cached status
configFileExistenceInfo.exists = true;
if (wasDefferedClose) {
project.deferredClose = undefined;
project.markAsDirty();
}
}
// Update projects watching config
@@ -1810,11 +1805,9 @@ export class ProjectService {
// Otherwise, we scheduled the update on configured project graph,
// we would need to schedule the project reload for only the root of inferred projects
// Get open files to reload projects for
this.reloadConfiguredProjectForFiles(
configFileExistenceInfo.openFilesImpactedByConfigFile,
/*clearSemanticCache*/ false,
/*delayReload*/ true,
eventKind !== FileWatcherEventKind.Deleted ?
this.delayReloadConfiguredProjectsForFile(
configFileExistenceInfo,
!wasDefferedClose && eventKind !== FileWatcherEventKind.Deleted ?
identity : // Reload open files if they are root of inferred project
returnTrue, // Reload all the open files impacted by config file
"Change in config file detected",
@@ -1822,6 +1815,44 @@ export class ProjectService {
this.delayEnsureProjectForOpenFiles();
}
/**
* This function goes through all the openFiles and tries to file the config file for them.
* If the config file is found and it refers to existing project, it schedules the reload it for reload
* If there is no existing project it just opens the configured project for the config file
* shouldReloadProjectFor provides a way to filter out files to reload configured project for
*/
private delayReloadConfiguredProjectsForFile(
configFileExistenceInfo: ConfigFileExistenceInfo | undefined,
shouldReloadProjectFor: (infoIsRootOfInferredProject: boolean) => boolean,
reason: string,
) {
const updatedProjects = new Set<ConfiguredProject>();
// try to reload config file for all open files
configFileExistenceInfo?.openFilesImpactedByConfigFile?.forEach((infoIsRootOfInferredProject, path) => {
// Invalidate default config file name for open file
this.configFileForOpenFiles.delete(path);
// Filter out the files that need to be ignored
if (!shouldReloadProjectFor(infoIsRootOfInferredProject)) {
return;
}
const info = this.getScriptInfoForPath(path)!;
Debug.assert(info.isScriptOpen());
// This tries to search for a tsconfig.json for the given file. If we found it,
// we first detect if there is already a configured project created for it: if so,
// we re- read the tsconfig file content and update the project only if we havent already done so
// otherwise we create a new one.
const configFileName = this.getConfigFileNameForFile(info);
if (configFileName) {
const project = this.findConfiguredProjectByProjectName(configFileName) || this.createConfiguredProject(configFileName);
if (tryAddToSet(updatedProjects, project)) {
project.pendingUpdateLevel = ProgramUpdateLevel.Full;
project.pendingUpdateReason = reason;
this.delayUpdateProjectGraph(project);
}
}
});
}
private removeProject(project: Project) {
this.logger.info("`remove Project::");
project.print(/*writeProjectFileNames*/ true, /*writeFileExplaination*/ true, /*writeFileVersionAndText*/ false);
@@ -1930,7 +1961,7 @@ export class ProjectService {
private assignOrphanScriptInfosToInferredProject() {
// collect orphaned files and assign them to inferred project just like we treat open of a file
this.openFiles.forEach((projectRootPath, path) => {
const info = this.getScriptInfoForPath(path as Path)!;
const info = this.getScriptInfoForPath(path)!;
// collect all orphaned script infos from open files
if (info.isOrphan()) {
this.assignOrphanScriptInfoToInferredProject(info, projectRootPath);
@@ -2012,19 +2043,21 @@ export class ProjectService {
this.watchClosedScriptInfo(info);
}
else {
this.handleDeletedFile(info);
this.handleDeletedFile(info, /*deferredDelete*/ false);
}
return ensureProjectsForOpenFiles;
}
private deleteScriptInfo(info: ScriptInfo) {
Debug.assert(!info.isScriptOpen());
this.filenameToScriptInfo.delete(info.path);
this.filenameToScriptInfoVersion.set(info.path, info.textStorage.version);
this.stopWatchingScriptInfo(info);
const realpath = info.getRealpathIfDifferent();
if (realpath) {
this.realpathToScriptInfos!.remove(realpath, info); // TODO: GH#18217
}
info.closeSourceMapFileWatcher();
}
private configFileExists(configFileName: NormalizedPath, canonicalConfigFilePath: NormalizedPath, info: OpenScriptInfoOrClosedOrConfigFileInfo) {
@@ -2332,7 +2365,7 @@ export class ProjectService {
this.logger.info("Open files: ");
this.openFiles.forEach((projectRootPath, path) => {
const info = this.getScriptInfoForPath(path as Path)!;
const info = this.getScriptInfoForPath(path)!;
this.logger.info(`\tFileName: ${info.fileName} ProjectRootPath: ${projectRootPath}`);
this.logger.info(`\t\tProjects: ${info.containingProjects.map(p => p.getProjectName())}`);
});
@@ -2344,7 +2377,8 @@ export class ProjectService {
findConfiguredProjectByProjectName(configFileName: NormalizedPath): ConfiguredProject | undefined {
// make sure that casing of config file name is consistent
const canonicalConfigFilePath = asNormalizedPath(this.toCanonicalFileName(configFileName));
return this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath);
const result = this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath);
return !result?.deferredClose ? result : undefined;
}
private getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath: string): ConfiguredProject | undefined {
@@ -2494,6 +2528,7 @@ export class ProjectService {
this.documentRegistry,
configFileExistenceInfo.config.cachedDirectoryStructureHost,
);
Debug.assert(!this.configuredProjects.has(canonicalConfigFilePath));
this.configuredProjects.set(canonicalConfigFilePath, project);
this.createConfigFileWatcherForParsedConfig(configFileName, canonicalConfigFilePath, project);
return project;
@@ -2748,6 +2783,7 @@ export class ProjectService {
scriptKind,
hasMixedContent,
project.directoryStructureHost,
/*deferredDeleteOk*/ false,
));
path = scriptInfo.path;
const existingValue = projectRootFilesMap.get(path);
@@ -2982,13 +3018,19 @@ export class ProjectService {
}
/** @internal */
getOrCreateScriptInfoNotOpenedByClient(uncheckedFileName: string, currentDirectory: string, hostToQueryFileExistsOn: DirectoryStructureHost) {
getOrCreateScriptInfoNotOpenedByClient(
uncheckedFileName: string,
currentDirectory: string,
hostToQueryFileExistsOn: DirectoryStructureHost,
deferredDeleteOk: boolean,
) {
return this.getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(
toNormalizedPath(uncheckedFileName),
currentDirectory,
/*scriptKind*/ undefined,
/*hasMixedContent*/ undefined,
hostToQueryFileExistsOn,
deferredDeleteOk,
);
}
@@ -3007,7 +3049,13 @@ export class ProjectService {
/** @internal */
logErrorForScriptInfoNotFound(fileName: string): void {
const names = arrayFrom(this.filenameToScriptInfo.entries(), ([path, scriptInfo]) => ({ path, fileName: scriptInfo.fileName }));
const names = arrayFrom(
mapDefinedIterator(
this.filenameToScriptInfo.entries(),
entry => entry[1].deferredDelete ? undefined : entry,
),
([path, scriptInfo]) => ({ path, fileName: scriptInfo.fileName }),
);
this.logger.msg(`Could not find file ${JSON.stringify(fileName)}.\nAll files are: ${JSON.stringify(names)}`, Msg.Err);
}
@@ -3104,7 +3152,7 @@ export class ProjectService {
this.refreshScriptInfosInDirectory(dirPath);
}
else {
const info = this.getScriptInfoForPath(fileOrDirectoryPath);
const info = this.filenameToScriptInfo.get(fileOrDirectoryPath);
if (info) {
if (isScriptInfoWatchedFromNodeModules(info)) {
this.refreshScriptInfo(info);
@@ -3194,9 +3242,25 @@ export class ProjectService {
}
}
private getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(fileName: NormalizedPath, currentDirectory: string, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, hostToQueryFileExistsOn: DirectoryStructureHost | undefined) {
private getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(
fileName: NormalizedPath,
currentDirectory: string,
scriptKind: ScriptKind | undefined,
hasMixedContent: boolean | undefined,
hostToQueryFileExistsOn: DirectoryStructureHost | undefined,
deferredDeleteOk: boolean,
) {
if (isRootedDiskPath(fileName) || isDynamicFileName(fileName)) {
return this.getOrCreateScriptInfoWorker(fileName, currentDirectory, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent, hostToQueryFileExistsOn);
return this.getOrCreateScriptInfoWorker(
fileName,
currentDirectory,
/*openedByClient*/ false,
/*fileContent*/ undefined,
scriptKind,
!!hasMixedContent,
hostToQueryFileExistsOn,
deferredDeleteOk,
);
}
// This is non rooted path with different current directory than project service current directory
@@ -3211,18 +3275,39 @@ export class ProjectService {
return undefined;
}
private getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName: NormalizedPath, currentDirectory: string, fileContent: string | undefined, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined) {
return this.getOrCreateScriptInfoWorker(fileName, currentDirectory, /*openedByClient*/ true, fileContent, scriptKind, hasMixedContent);
getOrCreateScriptInfoForNormalizedPath(
fileName: NormalizedPath,
openedByClient: boolean,
fileContent?: string,
scriptKind?: ScriptKind,
hasMixedContent?: boolean,
hostToQueryFileExistsOn?: { fileExists(path: string): boolean; },
) {
return this.getOrCreateScriptInfoWorker(
fileName,
this.currentDirectory,
openedByClient,
fileContent,
scriptKind,
!!hasMixedContent,
hostToQueryFileExistsOn,
/*deferredDeleteOk*/ false,
);
}
getOrCreateScriptInfoForNormalizedPath(fileName: NormalizedPath, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: { fileExists(path: string): boolean; }) {
return this.getOrCreateScriptInfoWorker(fileName, this.currentDirectory, openedByClient, fileContent, scriptKind, hasMixedContent, hostToQueryFileExistsOn);
}
private getOrCreateScriptInfoWorker(fileName: NormalizedPath, currentDirectory: string, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: { fileExists(path: string): boolean; }) {
private getOrCreateScriptInfoWorker(
fileName: NormalizedPath,
currentDirectory: string,
openedByClient: boolean,
fileContent: string | undefined,
scriptKind: ScriptKind | undefined,
hasMixedContent: boolean,
hostToQueryFileExistsOn: { fileExists(path: string): boolean; } | undefined,
deferredDeleteOk: boolean,
) {
Debug.assert(fileContent === undefined || openedByClient, "ScriptInfo needs to be opened by client to be able to set its user defined content");
const path = normalizedPathToPath(fileName, currentDirectory, this.toCanonicalFileName);
let info = this.getScriptInfoForPath(path);
let info = this.filenameToScriptInfo.get(path);
if (!info) {
const isDynamic = isDynamicFileName(fileName);
Debug.assert(isRootedDiskPath(fileName) || isDynamic || openedByClient, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nScript info with non-dynamic relative file name can only be open script info or in context of host currentDirectory`);
@@ -3232,7 +3317,7 @@ export class ProjectService {
if (!openedByClient && !isDynamic && !(hostToQueryFileExistsOn || this.host).fileExists(fileName)) {
return;
}
info = new ScriptInfo(this.host, fileName, scriptKind!, !!hasMixedContent, path, this.filenameToScriptInfoVersion.get(path)); // TODO: GH#18217
info = new ScriptInfo(this.host, fileName, scriptKind!, hasMixedContent, path, this.filenameToScriptInfoVersion.get(path));
this.filenameToScriptInfo.set(info.path, info);
this.filenameToScriptInfoVersion.delete(info.path);
if (!openedByClient) {
@@ -3243,6 +3328,14 @@ export class ProjectService {
this.openFilesWithNonRootedDiskPath.set(this.toCanonicalFileName(fileName), info);
}
}
else if (info.deferredDelete) {
Debug.assert(!info.isDynamic);
// If the file is not opened by client and the file doesnot exist on the disk, return
if (!openedByClient && !(hostToQueryFileExistsOn || this.host).fileExists(fileName)) {
return deferredDeleteOk ? info : undefined;
}
info.deferredDelete = undefined;
}
if (openedByClient) {
// Opening closed script info
// either it was created just now, or was part of projects but was closed
@@ -3264,13 +3357,19 @@ export class ProjectService {
}
getScriptInfoForPath(fileName: Path) {
return this.filenameToScriptInfo.get(fileName);
const info = this.filenameToScriptInfo.get(fileName);
return !info || !info.deferredDelete ? info : undefined;
}
/** @internal */
getDocumentPositionMapper(project: Project, generatedFileName: string, sourceFileName?: string): DocumentPositionMapper | undefined {
// Since declaration info and map file watches arent updating project's directory structure host (which can cache file structure) use host
const declarationInfo = this.getOrCreateScriptInfoNotOpenedByClient(generatedFileName, project.currentDirectory, this.host);
const declarationInfo = this.getOrCreateScriptInfoNotOpenedByClient(
generatedFileName,
project.currentDirectory,
this.host,
/*deferredDeleteOk*/ false,
);
if (!declarationInfo) {
if (sourceFileName) {
// Project contains source file and it generates the generated file name
@@ -3303,16 +3402,16 @@ export class ProjectService {
}
// Create the mapper
let sourceMapFileInfo: ScriptInfo | undefined;
let mapFileNameFromDeclarationInfo: string | undefined;
let sourceMapFileInfo: ScriptInfo | string | undefined;
let readMapFile: ReadMapFile | undefined = (mapFileName, mapFileNameFromDts) => {
const mapInfo = this.getOrCreateScriptInfoNotOpenedByClient(mapFileName, project.currentDirectory, this.host);
if (!mapInfo) {
mapFileNameFromDeclarationInfo = mapFileNameFromDts;
return undefined;
}
sourceMapFileInfo = mapInfo;
const mapInfo = this.getOrCreateScriptInfoNotOpenedByClient(
mapFileName,
project.currentDirectory,
this.host,
/*deferredDeleteOk*/ true,
);
sourceMapFileInfo = mapInfo || mapFileNameFromDts;
if (!mapInfo || mapInfo.deferredDelete) return undefined;
const snap = mapInfo.getSnapshot();
if (mapInfo.documentPositionMapper !== undefined) return mapInfo.documentPositionMapper;
return getSnapshotText(snap);
@@ -3326,21 +3425,23 @@ export class ProjectService {
);
readMapFile = undefined; // Remove ref to project
if (sourceMapFileInfo) {
declarationInfo.sourceMapFilePath = sourceMapFileInfo.path;
sourceMapFileInfo.declarationInfoPath = declarationInfo.path;
sourceMapFileInfo.documentPositionMapper = documentPositionMapper || false;
sourceMapFileInfo.sourceInfos = this.addSourceInfoToSourceMap(sourceFileName, project, sourceMapFileInfo.sourceInfos);
}
else if (mapFileNameFromDeclarationInfo) {
declarationInfo.sourceMapFilePath = {
watcher: this.addMissingSourceMapFile(
project.currentDirectory === this.currentDirectory ?
mapFileNameFromDeclarationInfo :
getNormalizedAbsolutePath(mapFileNameFromDeclarationInfo, project.currentDirectory),
declarationInfo.path,
),
sourceInfos: this.addSourceInfoToSourceMap(sourceFileName, project),
};
if (!isString(sourceMapFileInfo)) {
declarationInfo.sourceMapFilePath = sourceMapFileInfo.path;
sourceMapFileInfo.declarationInfoPath = declarationInfo.path;
if (!sourceMapFileInfo.deferredDelete) sourceMapFileInfo.documentPositionMapper = documentPositionMapper || false;
sourceMapFileInfo.sourceInfos = this.addSourceInfoToSourceMap(sourceFileName, project, sourceMapFileInfo.sourceInfos);
}
else {
declarationInfo.sourceMapFilePath = {
watcher: this.addMissingSourceMapFile(
project.currentDirectory === this.currentDirectory ?
sourceMapFileInfo :
getNormalizedAbsolutePath(sourceMapFileInfo, project.currentDirectory),
declarationInfo.path,
),
sourceInfos: this.addSourceInfoToSourceMap(sourceFileName, project),
};
}
}
else {
declarationInfo.sourceMapFilePath = false;
@@ -3351,7 +3452,12 @@ export class ProjectService {
private addSourceInfoToSourceMap(sourceFileName: string | undefined, project: Project, sourceInfos?: Set<Path>) {
if (sourceFileName) {
// Attach as source
const sourceInfo = this.getOrCreateScriptInfoNotOpenedByClient(sourceFileName, project.currentDirectory, project.directoryStructureHost)!;
const sourceInfo = this.getOrCreateScriptInfoNotOpenedByClient(
sourceFileName,
project.currentDirectory,
project.directoryStructureHost,
/*deferredDeleteOk*/ false,
)!;
(sourceInfos || (sourceInfos = new Set())).add(sourceInfo.path);
}
return sourceInfos;
@@ -3386,14 +3492,19 @@ export class ProjectService {
}
// Need to look for other files.
const info = this.getOrCreateScriptInfoNotOpenedByClient(fileName, (project || this).currentDirectory, project ? project.directoryStructureHost : this.host);
const info = this.getOrCreateScriptInfoNotOpenedByClient(
fileName,
(project || this).currentDirectory,
project ? project.directoryStructureHost : this.host,
/*deferredDeleteOk*/ false,
);
if (!info) return undefined;
// Attach as source
if (declarationInfo && isString(declarationInfo.sourceMapFilePath) && info !== declarationInfo) {
const sourceMapInfo = this.getScriptInfoForPath(declarationInfo.sourceMapFilePath);
if (sourceMapInfo) {
(sourceMapInfo.sourceInfos || (sourceMapInfo.sourceInfos = new Set())).add(info.path);
(sourceMapInfo.sourceInfos ??= new Set()).add(info.path);
}
}
@@ -3451,6 +3562,7 @@ export class ProjectService {
this.externalProjectToConfiguredProjectMap.forEach(projects =>
projects.forEach(project => {
if (
!project.deferredClose &&
!project.isClosed() &&
project.hasExternalProjectRef() &&
project.pendingUpdateLevel === ProgramUpdateLevel.Full &&
@@ -3514,7 +3626,14 @@ export class ProjectService {
if (this.openFiles.has(info.path)) return; // Skip open files
if (!info.fileWatcher) return; // not watched file
// Handle as if file is changed or deleted
this.onSourceFileChanged(info, this.host.fileExists(info.fileName) ? FileWatcherEventKind.Changed : FileWatcherEventKind.Deleted);
this.onSourceFileChanged(
info,
this.host.fileExists(info.fileName) ?
info.deferredDelete ?
FileWatcherEventKind.Created :
FileWatcherEventKind.Changed :
FileWatcherEventKind.Deleted,
);
});
// Cancel all project updates since we will be updating them now
this.pendingProjectUpdates.forEach((_project, projectName) => {
@@ -3530,7 +3649,7 @@ export class ProjectService {
});
// Reload Projects
this.reloadConfiguredProjectForFiles(this.openFiles as Map<Path, NormalizedPath | undefined>, /*clearSemanticCache*/ true, /*delayReload*/ false, returnTrue, "User requested reload projects");
this.reloadConfiguredProjectForFiles("User requested reload projects");
this.externalProjects.forEach(project => {
this.clearSemanticCache(project);
project.updateGraph();
@@ -3545,26 +3664,19 @@ export class ProjectService {
/**
* This function goes through all the openFiles and tries to file the config file for them.
* If the config file is found and it refers to existing project, it reloads it either immediately
* or schedules it for reload depending on delayReload option
* If there is no existing project it just opens the configured project for the config file
* reloadForInfo provides a way to filter out files to reload configured project for
*/
private reloadConfiguredProjectForFiles<T>(openFiles: Map<Path, T> | undefined, clearSemanticCache: boolean, delayReload: boolean, shouldReloadProjectFor: (openFileValue: T) => boolean, reason: string) {
const updatedProjects = new Map<string, true>();
private reloadConfiguredProjectForFiles(reason: string) {
const updatedProjects = new Set<ConfiguredProject>();
const reloadChildProject = (child: ConfiguredProject) => {
if (!updatedProjects.has(child.canonicalConfigFilePath)) {
updatedProjects.set(child.canonicalConfigFilePath, true);
this.reloadConfiguredProject(child, reason, /*isInitialLoad*/ false, clearSemanticCache);
if (tryAddToSet(updatedProjects, child)) {
this.reloadConfiguredProject(child, reason, /*isInitialLoad*/ false, /*clearSemanticCache*/ true);
}
};
// try to reload config file for all open files
openFiles?.forEach((openFileValue, path) => {
this.openFiles?.forEach((_projectRoot, path) => {
// Invalidate default config file name for open file
this.configFileForOpenFiles.delete(path);
// Filter out the files that need to be ignored
if (!shouldReloadProjectFor(openFileValue)) {
return;
}
const info = this.getScriptInfoForPath(path)!; // TODO: GH#18217
Debug.assert(info.isScriptOpen());
@@ -3575,37 +3687,28 @@ export class ProjectService {
const configFileName = this.getConfigFileNameForFile(info);
if (configFileName) {
const project = this.findConfiguredProjectByProjectName(configFileName) || this.createConfiguredProject(configFileName);
if (!updatedProjects.has(project.canonicalConfigFilePath)) {
updatedProjects.set(project.canonicalConfigFilePath, true);
if (delayReload) {
project.pendingUpdateLevel = ProgramUpdateLevel.Full;
project.pendingUpdateReason = reason;
if (clearSemanticCache) this.clearSemanticCache(project);
this.delayUpdateProjectGraph(project);
}
else {
// reload from the disk
this.reloadConfiguredProject(project, reason, /*isInitialLoad*/ false, clearSemanticCache);
// If this project does not contain this file directly, reload the project till the reloaded project contains the script info directly
if (!projectContainsInfoDirectly(project, info)) {
const referencedProject = forEachResolvedProjectReferenceProject(
if (tryAddToSet(updatedProjects, project)) {
// reload from the disk
this.reloadConfiguredProject(project, reason, /*isInitialLoad*/ false, /*clearSemanticCache*/ true);
// If this project does not contain this file directly, reload the project till the reloaded project contains the script info directly
if (!projectContainsInfoDirectly(project, info)) {
const referencedProject = forEachResolvedProjectReferenceProject(
project,
info.path,
child => {
reloadChildProject(child);
return projectContainsInfoDirectly(child, info);
},
ProjectReferenceProjectLoadKind.FindCreate,
);
if (referencedProject) {
// Reload the project's tree that is already present
forEachResolvedProjectReferenceProject(
project,
info.path,
child => {
reloadChildProject(child);
return projectContainsInfoDirectly(child, info);
},
ProjectReferenceProjectLoadKind.FindCreate,
/*fileName*/ undefined,
reloadChildProject,
ProjectReferenceProjectLoadKind.Find,
);
if (referencedProject) {
// Reload the project's tree that is already present
forEachResolvedProjectReferenceProject(
project,
/*fileName*/ undefined,
reloadChildProject,
ProjectReferenceProjectLoadKind.Find,
);
}
}
}
}
@@ -3656,7 +3759,7 @@ export class ProjectService {
this.printProjects();
this.openFiles.forEach((projectRootPath, path) => {
const info = this.getScriptInfoForPath(path as Path)!;
const info = this.getScriptInfoForPath(path)!;
// collect all orphaned script infos from open files
if (info.isOrphan()) {
this.assignOrphanScriptInfoToInferredProject(info, projectRootPath);
@@ -3755,10 +3858,7 @@ export class ProjectService {
return originalLocation;
function addOriginalConfiguredProject(originalProject: ConfiguredProject) {
if (!project.originalConfiguredProjects) {
project.originalConfiguredProjects = new Set();
}
project.originalConfiguredProjects.add(originalProject.canonicalConfigFilePath);
(project.originalConfiguredProjects ??= new Set()).add(originalProject.canonicalConfigFilePath);
}
}
@@ -3775,8 +3875,23 @@ export class ProjectService {
});
}
private getOrCreateOpenScriptInfo(fileName: NormalizedPath, fileContent: string | undefined, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, projectRootPath: NormalizedPath | undefined) {
const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, projectRootPath ? this.getNormalizedAbsolutePath(projectRootPath) : this.currentDirectory, fileContent, scriptKind, hasMixedContent)!; // TODO: GH#18217
private getOrCreateOpenScriptInfo(
fileName: NormalizedPath,
fileContent: string | undefined,
scriptKind: ScriptKind | undefined,
hasMixedContent: boolean | undefined,
projectRootPath: NormalizedPath | undefined,
) {
const info = this.getOrCreateScriptInfoWorker(
fileName,
projectRootPath ? this.getNormalizedAbsolutePath(projectRootPath) : this.currentDirectory,
/*openedByClient*/ true,
fileContent,
scriptKind,
!!hasMixedContent,
/*hostToQueryFileExistsOn*/ undefined,
/*deferredDeleteOk*/ true,
)!;
this.openFiles.set(info.path, projectRootPath);
return info;
}
@@ -3990,7 +4105,7 @@ export class ProjectService {
private removeOrphanConfiguredProjects(toRetainConfiguredProjects: readonly ConfiguredProject[] | ConfiguredProject | undefined) {
const toRemoveConfiguredProjects = new Map(this.configuredProjects);
const markOriginalProjectsAsUsed = (project: Project) => {
if (!project.isOrphan() && project.originalConfiguredProjects) {
if (project.originalConfiguredProjects && (isConfiguredProject(project) || !project.isOrphan())) {
project.originalConfiguredProjects.forEach(
(_value, configuredProjectPath) => {
const project = this.getConfiguredProjectByCanonicalConfigFilePath(configuredProjectPath);
@@ -4012,16 +4127,14 @@ export class ProjectService {
this.inferredProjects.forEach(markOriginalProjectsAsUsed);
this.externalProjects.forEach(markOriginalProjectsAsUsed);
this.configuredProjects.forEach(project => {
if (!toRemoveConfiguredProjects.has(project.canonicalConfigFilePath)) return;
// If project has open ref (there are more than zero references from external project/open file), keep it alive as well as any project it references
if (project.hasOpenRef()) {
retainConfiguredProject(project);
}
else if (toRemoveConfiguredProjects.has(project.canonicalConfigFilePath)) {
// If the configured project for project reference has more than zero references, keep it alive
forEachReferencedProject(
project,
ref => isRetained(ref) && retainConfiguredProject(project),
);
// If the configured project for project reference has more than zero references, keep it alive
else if (forEachReferencedProject(project, ref => isRetained(ref))) {
retainConfiguredProject(project);
}
});
@@ -4029,7 +4142,7 @@ export class ProjectService {
toRemoveConfiguredProjects.forEach(project => this.removeProject(project));
function isRetained(project: ConfiguredProject) {
return project.hasOpenRef() || !toRemoveConfiguredProjects.has(project.canonicalConfigFilePath);
return !toRemoveConfiguredProjects.has(project.canonicalConfigFilePath) || project.hasOpenRef();
}
function retainConfiguredProject(project: ConfiguredProject) {
@@ -4045,14 +4158,15 @@ export class ProjectService {
private removeOrphanScriptInfos() {
const toRemoveScriptInfos = new Map(this.filenameToScriptInfo);
this.filenameToScriptInfo.forEach(info => {
if (info.deferredDelete) return;
// If script info is open or orphan, retain it and its dependencies
if (!info.isScriptOpen() && info.isOrphan() && !info.isContainedByBackgroundProject()) {
// Otherwise if there is any source info that is alive, this alive too
if (!info.sourceMapFilePath) return;
let sourceInfos: Set<Path> | undefined;
if (isString(info.sourceMapFilePath)) {
const sourceMapInfo = this.getScriptInfoForPath(info.sourceMapFilePath);
sourceInfos = sourceMapInfo && sourceMapInfo.sourceInfos;
const sourceMapInfo = this.filenameToScriptInfo.get(info.sourceMapFilePath);
sourceInfos = sourceMapInfo?.sourceInfos;
}
else {
sourceInfos = info.sourceMapFilePath.sourceInfos;
@@ -4070,13 +4184,22 @@ export class ProjectService {
// Retain this script info
toRemoveScriptInfos.delete(info.path);
// If we retained declaration file, retain source map and sources as well
if (info.sourceMapFilePath) {
let sourceInfos: Set<Path> | undefined;
if (isString(info.sourceMapFilePath)) {
// And map file info and source infos
toRemoveScriptInfos.delete(info.sourceMapFilePath);
const sourceMapInfo = this.getScriptInfoForPath(info.sourceMapFilePath);
sourceInfos = sourceMapInfo && sourceMapInfo.sourceInfos;
const sourceMapInfo = this.filenameToScriptInfo.get(info.sourceMapFilePath);
if (sourceMapInfo?.deferredDelete) {
info.sourceMapFilePath = {
watcher: this.addMissingSourceMapFile(sourceMapInfo.fileName, info.path),
sourceInfos: sourceMapInfo.sourceInfos,
};
}
else {
toRemoveScriptInfos.delete(info.sourceMapFilePath);
}
sourceInfos = sourceMapInfo?.sourceInfos;
}
else {
sourceInfos = info.sourceMapFilePath.sourceInfos;
@@ -4087,12 +4210,8 @@ export class ProjectService {
}
});
toRemoveScriptInfos.forEach(info => {
// if there are not projects that include this script info - delete it
this.stopWatchingScriptInfo(info);
this.deleteScriptInfo(info);
info.closeSourceMapFileWatcher();
});
// if there are not projects that include this script info - delete it
toRemoveScriptInfos.forEach(info => this.deleteScriptInfo(info));
}
private telemetryOnOpenFile(scriptInfo: ScriptInfo): void {
@@ -4142,7 +4261,7 @@ export class ProjectService {
synchronizeProjectList(knownProjects: protocol.ProjectVersionInfo[], includeProjectReferenceRedirectInfo?: boolean): ProjectFilesWithTSDiagnostics[] {
const files: ProjectFilesWithTSDiagnostics[] = [];
this.collectChanges(knownProjects, this.externalProjects, includeProjectReferenceRedirectInfo, files);
this.collectChanges(knownProjects, this.configuredProjects.values(), includeProjectReferenceRedirectInfo, files);
this.collectChanges(knownProjects, mapDefinedIterator(this.configuredProjects.values(), p => p.deferredClose ? undefined : p), includeProjectReferenceRedirectInfo, files);
this.collectChanges(knownProjects, this.inferredProjects, includeProjectReferenceRedirectInfo, files);
return files;
}
@@ -4610,28 +4729,28 @@ export class ProjectService {
// Process all pending plugins, partitioned by project. This way a project with few plugins doesn't need to wait
// on a project with many plugins.
await Promise.all(map(pendingPlugins, ([project, promises]) => this.enableRequestedPluginsForProjectAsync(project, promises)));
let sendProjectsUpdatedInBackgroundEvent = false;
await Promise.all(map(pendingPlugins, async ([project, promises]) => {
// Await all pending plugin imports. This ensures all requested plugin modules are fully loaded
// prior to patching the language service, and that any promise rejections are observed.
const results = await Promise.all(promises);
if (project.isClosed() || isProjectDeferredClose(project)) {
this.logger.info(`Cancelling plugin enabling for ${project.getProjectName()} as it is ${project.isClosed() ? "closed" : "deferred close"}`);
// project is not alive, so don't enable plugins.
return;
}
sendProjectsUpdatedInBackgroundEvent = true;
for (const result of results) {
this.endEnablePlugin(project, result);
}
// Plugins may have modified external files, so mark the project as dirty.
this.delayUpdateProjectGraph(project);
}));
// Clear the pending operation and notify the client that projects have been updated.
this.currentPluginEnablementPromise = undefined;
this.sendProjectsUpdatedInBackgroundEvent();
}
private async enableRequestedPluginsForProjectAsync(project: Project, promises: Promise<BeginEnablePluginResult>[]) {
// Await all pending plugin imports. This ensures all requested plugin modules are fully loaded
// prior to patching the language service, and that any promise rejections are observed.
const results = await Promise.all(promises);
if (project.isClosed()) {
// project is not alive, so don't enable plugins.
return;
}
for (const result of results) {
this.endEnablePlugin(project, result);
}
// Plugins may have modified external files, so mark the project as dirty.
this.delayUpdateProjectGraph(project);
if (sendProjectsUpdatedInBackgroundEvent) this.sendProjectsUpdatedInBackgroundEvent();
}
configurePlugin(args: protocol.ConfigurePluginRequestArguments) {
+42 -3
View File
@@ -657,7 +657,12 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo
}
private getOrCreateScriptInfoAndAttachToProject(fileName: string) {
const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClient(fileName, this.currentDirectory, this.directoryStructureHost);
const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClient(
fileName,
this.currentDirectory,
this.directoryStructureHost,
/*deferredDeleteOk*/ false,
);
if (scriptInfo) {
const existingValue = this.rootFilesMap.get(scriptInfo.path);
if (existingValue && existingValue.info !== scriptInfo) {
@@ -678,7 +683,12 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo
getScriptVersion(filename: string) {
// Don't attach to the project if version is asked
const info = this.projectService.getOrCreateScriptInfoNotOpenedByClient(filename, this.currentDirectory, this.directoryStructureHost);
const info = this.projectService.getOrCreateScriptInfoNotOpenedByClient(
filename,
this.currentDirectory,
this.directoryStructureHost,
/*deferredDeleteOk*/ false,
);
return (info && info.getLatestVersion())!; // TODO: GH#18217
}
@@ -1298,6 +1308,7 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo
}
}
/** @internal */
markAsDirty() {
if (!this.dirty) {
this.projectStateVersion++;
@@ -1658,7 +1669,12 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo
// by the host for files in the program when the program is retrieved above but
// the program doesn't contain external files so this must be done explicitly.
inserted => {
const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClient(inserted, this.currentDirectory, this.directoryStructureHost);
const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClient(
inserted,
this.currentDirectory,
this.directoryStructureHost,
/*deferredDeleteOk*/ false,
);
scriptInfo?.attachToProject(this);
},
removed => this.detachScriptInfoFromProject(removed),
@@ -2584,6 +2600,7 @@ export class AutoImportProviderProject extends Project {
return !some(this.rootFileNames);
}
/** @internal */
override isOrphan() {
return true;
}
@@ -2619,6 +2636,7 @@ export class AutoImportProviderProject extends Project {
return !!this.rootFileNames?.length;
}
/** @internal */
override markAsDirty() {
this.rootFileNames = undefined;
super.markAsDirty();
@@ -2713,6 +2731,9 @@ export class ConfiguredProject extends Project {
/** @internal */
skipConfigDiagEvent?: true;
/** @internal */
deferredClose?: boolean;
/** @internal */
constructor(
configFileName: NormalizedPath,
@@ -2773,6 +2794,7 @@ export class ConfiguredProject extends Project {
* @returns: true if set of files in the project stays the same and false - otherwise.
*/
override updateGraph(): boolean {
if (this.deferredClose) return false;
const isInitialLoad = this.isInitialLoadPending();
this.isInitialLoadPending = returnFalse;
const updateLevel = this.pendingUpdateLevel;
@@ -2892,6 +2914,12 @@ export class ConfiguredProject extends Project {
super.close();
}
/** @internal */
override markAsDirty() {
if (this.deferredClose) return;
super.markAsDirty();
}
/** @internal */
addExternalProjectReference() {
this.externalProjectRefCount++;
@@ -2941,6 +2969,7 @@ export class ConfiguredProject extends Project {
}
const configFileExistenceInfo = this.projectService.configFileExistenceInfoCache.get(this.canonicalConfigFilePath)!;
if (this.deferredClose) return !!configFileExistenceInfo.openFilesImpactedByConfigFile?.size;
if (this.projectService.hasPendingProjectUpdate(this)) {
// If there is pending update for this project,
// we dont know if this project would be needed by any of the open files impacted by this config file
@@ -2966,6 +2995,11 @@ export class ConfiguredProject extends Project {
) || false;
}
/** @internal */
override isOrphan(): boolean {
return !!this.deferredClose;
}
/** @internal */
hasExternalProjectRef() {
return !!this.externalProjectRefCount;
@@ -3023,3 +3057,8 @@ export function isExternalProject(project: Project): project is ExternalProject
export function isBackgroundProject(project: Project): project is AutoImportProviderProject | AuxiliaryProject {
return project.projectKind === ProjectKind.AutoImportProvider || project.projectKind === ProjectKind.Auxiliary;
}
/** @internal */
export function isProjectDeferredClose(project: Project): project is ConfiguredProject {
return isConfiguredProject(project) && !!project.deferredClose;
}
+19 -2
View File
@@ -25,6 +25,7 @@ import {
IScriptSnapshot,
isString,
LineInfo,
missingFileModifiedTime,
orderedRemoveItem,
Path,
ScriptKind,
@@ -44,6 +45,7 @@ import {
isConfiguredProject,
isExternalProject,
isInferredProject,
isProjectDeferredClose,
maxFileSize,
NormalizedPath,
Project,
@@ -180,6 +182,14 @@ export class TextStorage {
const reloaded = this.reload(newText);
this.fileSize = fileSize; // NB: after reload since reload clears it
this.ownFileText = !tempFileName || tempFileName === this.info.fileName;
// In case we update this text before mTime gets updated to present file modified time
// because its schedule to do that later, update the mTime so we dont re-update the text
// Eg. with npm ci where file gets created and editor calls say get error request before
// the timeout to update the file stamps in node_modules is run
// Test:: watching npm install in codespaces where workspaces folder is hosted at root
if (this.ownFileText && this.info.mTime === missingFileModifiedTime.getTime()) {
this.info.mTime = (this.host.getModifiedTime!(this.info.fileName) || missingFileModifiedTime).getTime();
}
return reloaded;
}
@@ -398,6 +408,9 @@ export class ScriptInfo {
/** @internal */
documentPositionMapper?: DocumentPositionMapper | false;
/** @internal */
deferredDelete?: boolean;
constructor(
private readonly host: ServerHost,
readonly fileName: NormalizedPath,
@@ -567,7 +580,10 @@ export class ScriptInfo {
case 0:
return Errors.ThrowNoProject();
case 1:
return ensurePrimaryProjectKind(this.containingProjects[0]);
return ensurePrimaryProjectKind(
!isProjectDeferredClose(this.containingProjects[0]) ?
this.containingProjects[0] : undefined,
);
default:
// If this file belongs to multiple projects, below is the order in which default project is used
// - for open script info, its default configured project during opening is default if info is part of it
@@ -583,6 +599,7 @@ export class ScriptInfo {
for (let index = 0; index < this.containingProjects.length; index++) {
const project = this.containingProjects[index];
if (isConfiguredProject(project)) {
if (project.deferredClose) continue;
if (!project.isSourceOfProjectReferenceRedirect(this.fileName)) {
// If we havent found default configuredProject and
// its not the last one, find it and use that one if there
@@ -676,7 +693,7 @@ export class ScriptInfo {
}
isOrphan() {
return !forEach(this.containingProjects, p => !p.isOrphan());
return this.deferredDelete || !forEach(this.containingProjects, p => !p.isOrphan());
}
/** @internal */
+1
View File
@@ -1575,6 +1575,7 @@ export class Session<TMessage = string> implements EventSender {
fileNameToSearch,
noDtsProject.currentDirectory,
noDtsProject.directoryStructureHost,
/*deferredDeleteOk*/ false,
);
if (!info) continue;
if (!noDtsProject.containsScriptInfo(info)) {