From 6fb39d9b62cbb634e95ec00fe5ef85d84da3bdbd Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 16 Jan 2026 12:13:28 -0800 Subject: [PATCH] feat(language-server): Support client-side file watching via `onDidChangeWatchedFiles` This implements `onDidChangedWatchedFiles` in the language server, which allows the client to communicate changes to files rather than having the server create system file/directory watchers. This option is enabled in the extension via the `angular.server.useClientSideFileWatcher` setting. When enabled, the extension registers a FileSystemWatcher for .ts, .html, and package.json files and forwards events to the server. The server completely disables its internal native file watchers (via a new 'ServerHost' implementation that stubs watchFile/watchDirectory). This is significantly more performant and reliable than native watching for several reasons: - Deduplication: VS Code already watches the workspace. Piggybacking on these events prevents the server from duplicating thousands of file watchers. - OS Limits: Since the server opens zero watcher handles, it is impossible to hit OS limits (ENOSPC), no matter how large the repo is. - Optimization: VS Code's watcher uses highly optimized native implementations (like Parcel Watcher in Rust/C++) which handle recursive directory watching far better than Node.js's 'fs.watch'. - Debouncing: The client aggregates extremely frequent file events (e.g., during 'git checkout'), reducing the flood of processing requests to the server. This option was tested in one very large internal project and observed ~10-50x improvement of initialization times. fixes #66543 --- .../client/src/client.ts | 25 +++++- vscode-ng-language-service/package.json | 5 ++ .../server/src/cmdline_utils.ts | 2 + .../src/handlers/did_change_watched_files.ts | 24 ++++++ .../server/src/server.ts | 2 +- .../server/src/server_host.ts | 82 ++++++++++++++++++- .../server/src/session.ts | 28 ++----- 7 files changed, 137 insertions(+), 31 deletions(-) create mode 100644 vscode-ng-language-service/server/src/handlers/did_change_watched_files.ts diff --git a/vscode-ng-language-service/client/src/client.ts b/vscode-ng-language-service/client/src/client.ts index b6ea1341cb7..27ab2ccd070 100644 --- a/vscode-ng-language-service/client/src/client.ts +++ b/vscode-ng-language-service/client/src/client.ts @@ -51,6 +51,20 @@ export class AngularLanguageClient implements vscode.Disposable { }, }); + const config = vscode.workspace.getConfiguration(); + const useClientSideWatching = config.get('angular.server.useClientSideFileWatcher'); + const fileEvents = [ + // Notify the server about file changes to tsconfig.json contained in the workspace + vscode.workspace.createFileSystemWatcher('**/tsconfig.json'), + ]; + if (useClientSideWatching) { + fileEvents.push(vscode.workspace.createFileSystemWatcher('**/*.ts')); + fileEvents.push(vscode.workspace.createFileSystemWatcher('**/*.html')); + // While we don't need general JSON watching, TypeScript relies on package.json for module resolution, type acquisition, and auto-imports. + // If we don't watch it, npm install changes or dependency updates might be missed by the Language Service + fileEvents.push(vscode.workspace.createFileSystemWatcher('**/package.json')); + } + this.outputChannel = vscode.window.createOutputChannel(this.name); // Options to control the language client this.clientOptions = { @@ -62,10 +76,7 @@ export class AngularLanguageClient implements vscode.Disposable { {scheme: 'file', language: 'typescript'}, ], synchronize: { - fileEvents: [ - // Notify the server about file changes to tsconfig.json contained in the workspace - vscode.workspace.createFileSystemWatcher('**/tsconfig.json'), - ], + fileEvents, }, // Don't let our output console pop open revealOutputChannelOn: lsp.RevealOutputChannelOn.Never, @@ -439,6 +450,12 @@ export class AngularLanguageClient implements vscode.Disposable { const tsProbeLocations = [...getProbeLocations(this.context.extensionPath)]; args.push('--tsProbeLocations', tsProbeLocations.join(',')); + const supportClientSide = config.get('angular.server.useClientSideFileWatcher'); + + if (supportClientSide) { + args.push('--useClientSideFileWatcher'); + } + return args; } diff --git a/vscode-ng-language-service/package.json b/vscode-ng-language-service/package.json index ad042b9e656..4ae9283d695 100644 --- a/vscode-ng-language-service/package.json +++ b/vscode-ng-language-service/package.json @@ -153,6 +153,11 @@ "type": "string", "default": "", "markdownDescription": "A comma-separated list of error codes in templates whose diagnostics should be ignored." + }, + "angular.server.useClientSideFileWatcher": { + "type": "boolean", + "default": true, + "markdownDescription": "When enabled, the Angular Language Service will delegate file watching to VS Code instead of creating its own internal file watchers. This can significantly improve performance (greater than 10x faster initialization) and reduce resource usage in large repositories." } } }, diff --git a/vscode-ng-language-service/server/src/cmdline_utils.ts b/vscode-ng-language-service/server/src/cmdline_utils.ts index beacfe3a264..c865689d13a 100644 --- a/vscode-ng-language-service/server/src/cmdline_utils.ts +++ b/vscode-ng-language-service/server/src/cmdline_utils.ts @@ -70,6 +70,7 @@ interface CommandLineOptions { disableLetSyntax: boolean; angularCoreVersion?: string; suppressAngularDiagnosticCodes?: string; + useClientSideFileWatcher?: boolean; } export function parseCommandLine(argv: string[]): CommandLineOptions { @@ -95,6 +96,7 @@ export function parseCommandLine(argv: string[]): CommandLineOptions { disableLetSyntax: hasArgument(argv, '--disableLetSyntax'), angularCoreVersion: findArgument(argv, '--angularCoreVersion'), suppressAngularDiagnosticCodes: findArgument(argv, '--suppressAngularDiagnosticCodes'), + useClientSideFileWatcher: hasArgument(argv, '--useClientSideFileWatcher'), }; } diff --git a/vscode-ng-language-service/server/src/handlers/did_change_watched_files.ts b/vscode-ng-language-service/server/src/handlers/did_change_watched_files.ts new file mode 100644 index 00000000000..5b77a2fe877 --- /dev/null +++ b/vscode-ng-language-service/server/src/handlers/did_change_watched_files.ts @@ -0,0 +1,24 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as lsp from 'vscode-languageserver/node'; +import {uriToFilePath} from '../utils'; +import {ServerHost} from '../server_host'; +import * as ts from 'typescript/lib/tsserverlibrary'; + +export function onDidChangeWatchedFiles( + params: lsp.DidChangeWatchedFilesParams, + logger: ts.server.Logger, + host: ServerHost, +) { + for (const change of params.changes) { + const filePath = uriToFilePath(change.uri); + logger.info(`Received file change event for ${filePath} type ${change.type}`); + host.notifyFileChange(filePath, change.type); + } +} diff --git a/vscode-ng-language-service/server/src/server.ts b/vscode-ng-language-service/server/src/server.ts index 2d38aaaf99e..9bd5b328243 100644 --- a/vscode-ng-language-service/server/src/server.ts +++ b/vscode-ng-language-service/server/src/server.ts @@ -35,7 +35,7 @@ function main() { const isG3 = ts.resolvedPath.includes('/google3/'); // ServerHost provides native OS functionality - const host = new ServerHost(isG3); + const host = new ServerHost(isG3, options.useClientSideFileWatcher ?? false); // Establish a new server session that encapsulates lsp connection. const session = new Session({ diff --git a/vscode-ng-language-service/server/src/server_host.ts b/vscode-ng-language-service/server/src/server_host.ts index 58bf1e78cbf..cab4ee60044 100644 --- a/vscode-ng-language-service/server/src/server_host.ts +++ b/vscode-ng-language-service/server/src/server_host.ts @@ -7,6 +7,8 @@ */ import * as ts from 'typescript/lib/tsserverlibrary'; +import * as path from 'path'; +import * as lsp from 'vscode-languageserver/node'; const NOOP_WATCHER: ts.FileWatcher = { close() {}, @@ -22,8 +24,16 @@ export class ServerHost implements ts.server.ServerHost { readonly args: string[]; readonly newLine: string; readonly useCaseSensitiveFileNames: boolean; + private readonly fileWatchers = new Map>(); + private readonly directoryWatchers = new Map< + string, + Set<{callback: ts.DirectoryWatcherCallback; recursive: boolean}> + >(); - constructor(readonly isG3: boolean) { + constructor( + readonly isG3: boolean, + private readonly useClientSideFileWatcher: boolean, + ) { this.args = ts.sys.args; this.newLine = ts.sys.newLine; this.useCaseSensitiveFileNames = ts.sys.useCaseSensitiveFileNames; @@ -59,7 +69,24 @@ export class ServerHost implements ts.server.ServerHost { pollingInterval?: number, options?: ts.WatchOptions, ): ts.FileWatcher { - return ts.sys.watchFile!(path, callback, pollingInterval, options); + if (!this.useClientSideFileWatcher) { + return ts.sys.watchFile!(path, callback, pollingInterval, options); + } + + const callbacks = this.fileWatchers.get(path) ?? new Set(); + callbacks.add(callback); + this.fileWatchers.set(path, callbacks); + return { + close: () => { + const callbacks = this.fileWatchers.get(path); + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + this.fileWatchers.delete(path); + } + } + }, + }; } watchDirectory( @@ -71,7 +98,56 @@ export class ServerHost implements ts.server.ServerHost { if (this.isG3 && path.startsWith('/google/src')) { return NOOP_WATCHER; } - return ts.sys.watchDirectory!(path, callback, recursive, options); + if (!this.useClientSideFileWatcher) { + return ts.sys.watchDirectory!(path, callback, recursive, options); + } + const callbacks = this.directoryWatchers.get(path) ?? new Set(); + const watcher = {callback, recursive: !!recursive}; + callbacks.add(watcher); + this.directoryWatchers.set(path, callbacks); + return { + close: () => { + const callbacks = this.directoryWatchers.get(path); + if (callbacks) { + callbacks.delete(watcher); + if (callbacks.size === 0) { + this.directoryWatchers.delete(path); + } + } + }, + }; + } + + notifyFileChange(fileName: string, type: lsp.FileChangeType): void { + if (!this.useClientSideFileWatcher) { + return; + } + + const callbacks = this.fileWatchers.get(fileName); + if (callbacks) { + callbacks.forEach((callback) => + callback( + fileName, + type === lsp.FileChangeType.Deleted + ? ts.FileWatcherEventKind.Deleted + : ts.FileWatcherEventKind.Changed, + ), + ); + } + + for (const [dirPath, watchers] of this.directoryWatchers) { + if (fileName.startsWith(dirPath)) { + // If it's a direct child or recursive watch + const relative = path.relative(dirPath, fileName); + const isDirectChild = !relative.includes(path.sep); + + for (const watcher of watchers) { + if (watcher.recursive || isDirectChild) { + watcher.callback(fileName); + } + } + } + } } resolvePath(path: string): string { diff --git a/vscode-ng-language-service/server/src/session.ts b/vscode-ng-language-service/server/src/session.ts index cb7c9951d90..8a8e4043d89 100644 --- a/vscode-ng-language-service/server/src/session.ts +++ b/vscode-ng-language-service/server/src/session.ts @@ -6,20 +6,11 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - ApplyRefactoringResult, - isNgLanguageService, - NgLanguageService, - PluginConfig, -} from '@angular/language-service/api'; +import {isNgLanguageService, NgLanguageService, PluginConfig} from '@angular/language-service/api'; import * as ts from 'typescript/lib/tsserverlibrary'; import {promisify} from 'util'; -import {getLanguageService as getHTMLLanguageService} from 'vscode-html-languageservice'; -import {getSCSSLanguageService} from 'vscode-css-languageservice'; -import {TextDocument} from 'vscode-languageserver-textdocument'; import * as lsp from 'vscode-languageserver/node'; -import {ServerOptions} from '../../common/initialize'; import { ProjectLanguageService, ProjectLoadingFinish, @@ -28,30 +19,19 @@ import { } from '../../common/notifications'; import { GetComponentsWithTemplateFile, - GetTcbParams, GetTcbRequest, - GetTcbResponse, GetTemplateLocationForComponent, - GetTemplateLocationForComponentParams, IsInAngularProject, - IsInAngularProjectParams, } from '../../common/requests'; import {tsDiagnosticToLspDiagnostic} from './diagnostic'; -import {getHTMLVirtualContent, getSCSSVirtualContent, isInlineStyleNode} from './embedded_support'; import {ServerHost} from './server_host'; -import {documentationToMarkdown} from './text_render'; import { filePathToUri, - getMappedDefinitionInfo, isConfiguredProject, isDebugMode, - lspPositionToTsPosition, lspRangeToTsPositions, MruTracker, - tsDisplayPartsToText, - tsFileTextChangesToLspWorkspaceEdit, - tsTextSpanToLspRange, uriToFilePath, } from './utils'; import {onCodeAction, onCodeActionResolve} from './handlers/code_actions'; @@ -65,6 +45,7 @@ import {onRenameRequest, onPrepareRename} from './handlers/rename'; import {onSignatureHelp} from './handlers/signature'; import {onGetTcb} from './handlers/tcb'; import {onGetTemplateLocationForComponent, isInAngularProject} from './handlers/template_info'; +import {onDidChangeWatchedFiles} from './handlers/did_change_watched_files'; export interface SessionOptions { host: ServerHost; @@ -87,8 +68,6 @@ enum LanguageId { HTML = 'html', } -// Empty definition range for files without `scriptInfo` -const EMPTY_RANGE = lsp.Range.create(0, 0, 0, 0); const setImmediateP = promisify(setImmediate); const alwaysSuppressDiagnostics: number[] = [ @@ -104,6 +83,7 @@ export class Session { readonly connection: lsp.Connection; readonly projectService: ts.server.ProjectService; readonly logger: ts.server.Logger; + private readonly host: ServerHost; private readonly logToConsole: boolean; private readonly openFiles = new MruTracker(); readonly includeAutomaticOptionalChainCompletions: boolean; @@ -128,6 +108,7 @@ export class Session { this.includeCompletionsWithSnippetText = options.includeCompletionsWithSnippetText; this.includeCompletionsForModuleExports = options.includeCompletionsForModuleExports; this.logger = options.logger; + this.host = options.host; this.logToConsole = options.logToConsole; this.defaultPreferences = { ...this.defaultPreferences, @@ -243,6 +224,7 @@ export class Session { private addProtocolHandlers(conn: lsp.Connection) { conn.onInitialize((p) => onInitialize(this, p)); + conn.onDidChangeWatchedFiles((p) => onDidChangeWatchedFiles(p, this.logger, this.host)); conn.onDidOpenTextDocument((p) => this.onDidOpenTextDocument(p)); conn.onDidCloseTextDocument((p) => this.onDidCloseTextDocument(p)); conn.onDidChangeTextDocument((p) => this.onDidChangeTextDocument(p));