mirror of
https://github.com/microsoft/TypeScript.git
synced 2025-11-18 17:21:48 +00:00
Handle delayed or missed watches for project updates (#59625)
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
FileWatcherCallback,
|
||||
FileWatcherEventKind,
|
||||
find,
|
||||
forEachAncestorDirectory,
|
||||
getAllowJSCompilerOption,
|
||||
getBaseFileName,
|
||||
getDirectoryPath,
|
||||
@@ -291,6 +292,13 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,
|
||||
return host.realpath ? host.realpath(s) : s;
|
||||
}
|
||||
|
||||
function clearFirstAncestorEntry(fileOrDirectoryPath: Path) {
|
||||
forEachAncestorDirectory(
|
||||
getDirectoryPath(fileOrDirectoryPath),
|
||||
ancestor => cachedReadDirectoryResult.delete(ensureTrailingDirectorySeparator(ancestor)) ? true : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function addOrDeleteFileOrDirectory(fileOrDirectory: string, fileOrDirectoryPath: Path) {
|
||||
const existingResult = getCachedFileSystemEntries(fileOrDirectoryPath);
|
||||
if (existingResult !== undefined) {
|
||||
@@ -302,6 +310,7 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,
|
||||
|
||||
const parentResult = getCachedFileSystemEntriesForBaseDir(fileOrDirectoryPath);
|
||||
if (!parentResult) {
|
||||
clearFirstAncestorEntry(fileOrDirectoryPath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -339,6 +348,9 @@ export function createCachedDirectoryStructureHost(host: DirectoryStructureHost,
|
||||
if (parentResult) {
|
||||
updateFilesOfFileSystemEntry(parentResult, getBaseNameOfFileName(fileName), eventKind === FileWatcherEventKind.Created);
|
||||
}
|
||||
else {
|
||||
clearFirstAncestorEntry(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilesOfFileSystemEntry(parentResult: SortedAndCanonicalizedMutableFileSystemEntries, baseName: string, fileExists: boolean): void {
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
length,
|
||||
map,
|
||||
mapDefinedIterator,
|
||||
memoize,
|
||||
missingFileModifiedTime,
|
||||
MultiMap,
|
||||
noop,
|
||||
@@ -1912,6 +1913,11 @@ export class ProjectService {
|
||||
this.watchPackageJsonFile(file, fileOrDirectoryPath, wildCardWatcher);
|
||||
}
|
||||
|
||||
if (!fsResult?.fileExists) {
|
||||
// Ensure we send sourceFileChange
|
||||
this.sendSourceFileChange(fileOrDirectoryPath);
|
||||
}
|
||||
|
||||
const configuredProjectForConfig = this.findConfiguredProjectByProjectName(configFileName);
|
||||
if (
|
||||
isIgnoredFileFromWildCardWatching({
|
||||
@@ -3838,6 +3844,34 @@ export class ProjectService {
|
||||
this.logger.close();
|
||||
}
|
||||
|
||||
private sendSourceFileChange(inPath: Path | undefined) {
|
||||
this.filenameToScriptInfo.forEach(info => {
|
||||
if (this.openFiles.has(info.path)) return; // Skip open files
|
||||
if (!info.fileWatcher) return; // not watched file
|
||||
const eventKind = memoize(() =>
|
||||
this.host.fileExists(info.fileName) ?
|
||||
info.deferredDelete ?
|
||||
FileWatcherEventKind.Created :
|
||||
FileWatcherEventKind.Changed :
|
||||
FileWatcherEventKind.Deleted
|
||||
);
|
||||
if (inPath) {
|
||||
// Skip node modules and files that are not in path
|
||||
if (isScriptInfoWatchedFromNodeModules(info) || !info.path.startsWith(inPath)) return;
|
||||
// If we are sending delete event and its already deleted, ignore
|
||||
if (eventKind() === FileWatcherEventKind.Deleted && info.deferredDelete) return;
|
||||
// In change cases, its hard to know if this is marked correctly across files and projects, so just send the event
|
||||
this.logger.info(`Invoking sourceFileChange on ${info.fileName}:: ${eventKind()}`);
|
||||
}
|
||||
|
||||
// Handle as if file is changed or deleted
|
||||
this.onSourceFileChanged(
|
||||
info,
|
||||
eventKind(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This function rebuilds the project for every file opened by the client
|
||||
* This does not reload contents of open files from disk. But we could do that if needed
|
||||
@@ -3850,19 +3884,7 @@ export class ProjectService {
|
||||
// as there is no need to load contents of the files from the disk
|
||||
|
||||
// Reload script infos
|
||||
this.filenameToScriptInfo.forEach(info => {
|
||||
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) ?
|
||||
info.deferredDelete ?
|
||||
FileWatcherEventKind.Created :
|
||||
FileWatcherEventKind.Changed :
|
||||
FileWatcherEventKind.Deleted,
|
||||
);
|
||||
});
|
||||
this.sendSourceFileChange(/*inPath*/ undefined);
|
||||
// Cancel all project updates since we will be updating them now
|
||||
this.pendingProjectUpdates.forEach((_project, projectName) => {
|
||||
this.throttledOperations.cancel(projectName);
|
||||
|
||||
@@ -556,33 +556,33 @@ export class TestServerHost implements server.ServerHost, FormatDiagnosticsHost,
|
||||
this.addFileOrFolderInFolder(baseFolder, newFile);
|
||||
}
|
||||
|
||||
renameFolder(folderName: string, newFolderName: string) {
|
||||
renameFolder(folderName: string, newFolderName: string, skipFolderEntryWatches?: true) {
|
||||
const fullPath = getNormalizedAbsolutePath(folderName, this.currentDirectory);
|
||||
const path = this.toPath(fullPath);
|
||||
const folder = this.fs.get(path) as FsFolder;
|
||||
Debug.assert(!!folder);
|
||||
|
||||
const newFullPath = getNormalizedAbsolutePath(newFolderName, this.currentDirectory);
|
||||
const newFolder = this.toFsFolder(newFullPath);
|
||||
|
||||
// Invoke watches for files in the folder as deleted (from old path)
|
||||
this.renameFolderEntries(folder, newFolder, skipFolderEntryWatches);
|
||||
|
||||
// Only remove the folder
|
||||
this.removeFileOrFolder(folder, /*isRenaming*/ true);
|
||||
|
||||
// Add updated folder with new folder name
|
||||
const newFullPath = getNormalizedAbsolutePath(newFolderName, this.currentDirectory);
|
||||
const newFolder = this.toFsFolder(newFullPath);
|
||||
const newPath = newFolder.path;
|
||||
const basePath = getDirectoryPath(path);
|
||||
Debug.assert(basePath !== path);
|
||||
Debug.assert(basePath === getDirectoryPath(newPath));
|
||||
const basePath = getDirectoryPath(newPath);
|
||||
this.ensureFileOrFolder({ path: getDirectoryPath(newFullPath) });
|
||||
const baseFolder = this.fs.get(basePath) as FsFolder;
|
||||
this.addFileOrFolderInFolder(baseFolder, newFolder);
|
||||
|
||||
// Invoke watches for files in the folder as deleted (from old path)
|
||||
this.renameFolderEntries(folder, newFolder);
|
||||
}
|
||||
|
||||
private renameFolderEntries(oldFolder: FsFolder, newFolder: FsFolder) {
|
||||
private renameFolderEntries(oldFolder: FsFolder, newFolder: FsFolder, skipWatches: true | undefined) {
|
||||
for (const entry of oldFolder.entries) {
|
||||
this.fs.delete(entry.path);
|
||||
this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Deleted, entry.fullPath);
|
||||
if (!skipWatches) this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Deleted, entry.fullPath);
|
||||
|
||||
entry.fullPath = combinePaths(newFolder.fullPath, getBaseFileName(entry.fullPath));
|
||||
entry.path = this.toPath(entry.fullPath);
|
||||
@@ -591,9 +591,9 @@ export class TestServerHost implements server.ServerHost, FormatDiagnosticsHost,
|
||||
}
|
||||
this.fs.set(entry.path, entry);
|
||||
this.setInode(entry.path);
|
||||
this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Created, entry.fullPath);
|
||||
if (!skipWatches) this.invokeFileAndFsWatches(entry.fullPath, FileWatcherEventKind.Created, entry.fullPath);
|
||||
if (isFsFolder(entry)) {
|
||||
this.renameFolderEntries(entry, entry);
|
||||
this.renameFolderEntries(entry, entry, skipWatches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as ts from "../../_namespaces/ts.js";
|
||||
import { dedent } from "../../_namespaces/Utils.js";
|
||||
import { jsonToReadableText } from "../helpers.js";
|
||||
import { libContent } from "../helpers/contents.js";
|
||||
import {
|
||||
baselineTsserverLogs,
|
||||
closeFilesForSession,
|
||||
openFilesForSession,
|
||||
protocolTextSpanFromSubstring,
|
||||
TestSession,
|
||||
textSpanFromSubstring,
|
||||
verifyGetErrRequest,
|
||||
@@ -207,4 +210,89 @@ describe("unittests:: tsserver:: getEditsForFileRename", () => {
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
[true, false].forEach(canUseWatchEvents =>
|
||||
it(`works when moving file to and from folder${canUseWatchEvents ? " canUseWatchEvents" : ""}`, () => {
|
||||
const alertText = dedent`
|
||||
export function alert(message: string) {
|
||||
console.log(\`ALERT: \${message}\`);
|
||||
}
|
||||
`;
|
||||
const projectRootPath = "/home/src/myprojects/project";
|
||||
const indexTs = `${projectRootPath}/index.ts`;
|
||||
const indexFileText = dedent`
|
||||
import { alert } from '@app/components/whatever/alert';
|
||||
alert('Hello, world!');
|
||||
`;
|
||||
const componentsWhatever = `${projectRootPath}/components/whatever`;
|
||||
const functionsWhatever = `${projectRootPath}/functions/whatever`;
|
||||
const host = createServerHost({
|
||||
"/home/src/myprojects/project/tsconfig.json": jsonToReadableText({
|
||||
compilerOptions: {
|
||||
target: "es2016",
|
||||
module: "commonjs",
|
||||
paths: {
|
||||
"@app/*": [
|
||||
"./*",
|
||||
],
|
||||
},
|
||||
esModuleInterop: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
},
|
||||
}),
|
||||
[indexTs]: indexFileText,
|
||||
[`${componentsWhatever}/alert.ts`]: alertText,
|
||||
[`${functionsWhatever}/placeholder.txt`]: "",
|
||||
"/a/lib/lib.es2016.full.d.ts": libContent,
|
||||
});
|
||||
const session = new TestSession({ host, canUseWatchEvents, canUseEvents: true });
|
||||
openFilesForSession([{ file: indexTs, projectRootPath }], session);
|
||||
host.renameFolder(componentsWhatever, functionsWhatever, /*skipFolderEntryWatches*/ true);
|
||||
if (canUseWatchEvents) session.invokeWatchChanges();
|
||||
openFilesForSession([{
|
||||
file: `${functionsWhatever}/alert.ts`,
|
||||
content: alertText,
|
||||
projectRootPath,
|
||||
}], session);
|
||||
session.executeCommandSeq<ts.server.protocol.GetEditsForFileRenameRequest>({
|
||||
command: ts.server.protocol.CommandTypes.GetEditsForFileRename,
|
||||
arguments: { oldFilePath: componentsWhatever, newFilePath: functionsWhatever },
|
||||
});
|
||||
// Apply edit to index.ts
|
||||
session.executeCommandSeq<ts.server.protocol.UpdateOpenRequest>({
|
||||
command: ts.server.protocol.CommandTypes.UpdateOpen,
|
||||
arguments: {
|
||||
changedFiles: [{
|
||||
fileName: indexTs,
|
||||
textChanges: [{
|
||||
...protocolTextSpanFromSubstring(indexFileText, "@app/components/whatever/alert"),
|
||||
newText: "@app/functions/whatever/alert",
|
||||
}],
|
||||
}],
|
||||
},
|
||||
});
|
||||
host.runQueuedTimeoutCallbacks();
|
||||
host.renameFolder(functionsWhatever, componentsWhatever, /*skipFolderEntryWatches*/ true);
|
||||
session.executeCommandSeq<ts.server.protocol.UpdateOpenRequest>({
|
||||
command: ts.server.protocol.CommandTypes.UpdateOpen,
|
||||
arguments: {
|
||||
openFiles: [{
|
||||
file: `${componentsWhatever}/alert.ts`,
|
||||
fileContent: alertText,
|
||||
projectRootPath,
|
||||
}],
|
||||
closedFiles: [`${functionsWhatever}/alert.ts`],
|
||||
},
|
||||
});
|
||||
session.executeCommandSeq<ts.server.protocol.GetEditsForFileRenameRequest>({
|
||||
command: ts.server.protocol.CommandTypes.GetEditsForFileRename,
|
||||
arguments: { oldFilePath: functionsWhatever, newFilePath: componentsWhatever },
|
||||
});
|
||||
if (canUseWatchEvents) session.invokeWatchChanges();
|
||||
host.runQueuedTimeoutCallbacks();
|
||||
baselineTsserverLogs("getEditsForFileRename", `works when moving file to and from folder${canUseWatchEvents ? " canUseWatchEvents" : ""}`, session);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1375,19 +1375,38 @@ describe("unittests:: tsserver:: projects::", () => {
|
||||
host.runQueuedTimeoutCallbacks();
|
||||
|
||||
// file is deleted but watches are not yet invoked
|
||||
const originalFileExists = host.fileExists;
|
||||
host.fileExists = s => s === fileA.path ? false : originalFileExists.call(host, s);
|
||||
const invokeFileWatcher = host.invokeFileWatcher;
|
||||
const fileWatches: Parameters<typeof host.invokeFileWatcher>[] = [];
|
||||
host.invokeFileWatcher = (fileFullPath, eventKind, modifiedTime) => {
|
||||
fileWatches.push([fileFullPath, eventKind, modifiedTime]);
|
||||
};
|
||||
const invokeFsWatchesCallbacks = host.invokeFsWatchesCallbacks;
|
||||
const fsWatches: Parameters<typeof host.invokeFsWatchesCallbacks>[] = [];
|
||||
host.invokeFsWatchesCallbacks = (fullPath, eventName, eventFullPath, useTildeSuffix) => {
|
||||
fsWatches.push([fullPath, eventName, eventFullPath, useTildeSuffix]);
|
||||
};
|
||||
const invokeFsWatchesRecursiveCallbacks = host.invokeFsWatchesRecursiveCallbacks;
|
||||
const fsWatchesRecursive: Parameters<typeof host.invokeFsWatchesRecursiveCallbacks>[] = [];
|
||||
host.invokeFsWatchesRecursiveCallbacks = (fullPath, eventName, eventFullPath, useTildeSuffix) => {
|
||||
fsWatchesRecursive.push([fullPath, eventName, eventFullPath, useTildeSuffix]);
|
||||
};
|
||||
|
||||
host.deleteFile(fileA.path);
|
||||
host.ensureFileOrFolder(fileSubA);
|
||||
closeFilesForSession([fileA], session);
|
||||
|
||||
// This should create inferred project since fileSubA not on the disk
|
||||
openFile(fileSubA);
|
||||
|
||||
host.runQueuedTimeoutCallbacks(); // Update configured project and projects for open file
|
||||
host.fileExists = originalFileExists;
|
||||
host.invokeFileWatcher = invokeFileWatcher;
|
||||
host.invokeFsWatchesCallbacks = invokeFsWatchesCallbacks;
|
||||
host.invokeFsWatchesRecursiveCallbacks = invokeFsWatchesRecursiveCallbacks;
|
||||
|
||||
// Actually trigger the file move
|
||||
host.deleteFile(fileA.path);
|
||||
host.ensureFileOrFolder(fileSubA);
|
||||
fileWatches.forEach(args => host.invokeFileWatcher(...args));
|
||||
fsWatches.forEach(args => host.invokeFsWatchesCallbacks(...args));
|
||||
fsWatchesRecursive.forEach(args => host.invokeFsWatchesRecursiveCallbacks(...args));
|
||||
|
||||
verifyGetErrRequest({ session, files: [fileB, fileSubA], existingTimeouts: true });
|
||||
baselineTsserverLogs("projects", "handles delayed directory watch invoke on file creation", session);
|
||||
|
||||
Reference in New Issue
Block a user