Skip to content

Commit 268ae46

Browse files
authored
(perf) also cache unresolved modules (#1015)
This change also makes resolveModuleNames return early if the module to be resolved was already tried to be resolved but without success. Now, `undefined` is returned immediately in this case without trying to resolve again. Such unresolved resolutions are deleted whenever a new file is created. This increases performance especially on projects with many unresolved modules. During this change it was made sure that SnapshotManager is now only called through the service. This also fixes a small bug where a TS/JS file was not removed from the module resolution after it was deleted.
1 parent 72a8554 commit 268ae46

File tree

8 files changed

+121
-30
lines changed

8 files changed

+121
-30
lines changed

packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ export class LSAndTSDocResolver {
105105
this.docManager.releaseDocument(pathToUrl(filePath));
106106
}
107107

108+
/**
109+
* @internal Public for tests only
110+
*/
108111
async getSnapshotManager(filePath: string): Promise<SnapshotManager> {
109112
return (await this.getTSService(filePath)).snapshotManager;
110113
}

packages/language-server/src/plugins/typescript/SnapshotManager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export interface TsFilesSpec {
88
exclude?: readonly string[];
99
}
1010

11+
/**
12+
* Should only be used by `service.ts`
13+
*/
1114
export class SnapshotManager {
1215
private documents: Map<string, DocumentSnapshot> = new Map();
1316
private lastLogged = new Date(new Date().getTime() - 60_001);

packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ import { SignatureHelpProviderImpl } from './features/SignatureHelpProvider';
6060
import { UpdateImportsProviderImpl } from './features/UpdateImportsProvider';
6161
import { isNoTextSpanInGeneratedCode, SnapshotFragmentMap } from './features/utils';
6262
import { LSAndTSDocResolver } from './LSAndTSDocResolver';
63-
import { ignoredBuildDirectories, SnapshotManager } from './SnapshotManager';
63+
import { LanguageServiceContainer } from './service';
64+
import { ignoredBuildDirectories } from './SnapshotManager';
6465
import { convertToLocationRange, getScriptKindFromFileName, symbolKindFromString } from './utils';
6566

6667
export class TypeScriptPlugin
@@ -350,7 +351,7 @@ export class TypeScriptPlugin
350351
}
351352

352353
async onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): Promise<void> {
353-
const doneUpdateProjectFiles = new Set<SnapshotManager>();
354+
const doneUpdateProjectFiles = new Set<LanguageServiceContainer>();
354355

355356
for (const { fileName, changeType } of onWatchFileChangesParas) {
356357
const pathParts = fileName.split(/\/|\\/);
@@ -365,19 +366,19 @@ export class TypeScriptPlugin
365366
continue;
366367
}
367368

368-
const snapshotManager = await this.getSnapshotManager(fileName);
369+
const tsService = await this.lsAndTsDocResolver.getTSService(fileName);
369370
if (changeType === FileChangeType.Created) {
370-
if (!doneUpdateProjectFiles.has(snapshotManager)) {
371-
snapshotManager.updateProjectFiles();
372-
doneUpdateProjectFiles.add(snapshotManager);
371+
if (!doneUpdateProjectFiles.has(tsService)) {
372+
tsService.updateProjectFiles();
373+
doneUpdateProjectFiles.add(tsService);
373374
}
374375
} else if (changeType === FileChangeType.Deleted) {
375-
snapshotManager.delete(fileName);
376-
} else if (snapshotManager.has(fileName)) {
376+
tsService.deleteSnapshot(fileName);
377+
} else if (tsService.hasFile(fileName)) {
377378
// Only allow existing files to be update
378379
// Otherwise, new files would still get loaded
379380
// into snapshot manager after update
380-
snapshotManager.updateTsOrJsFile(fileName);
381+
tsService.updateTsOrJsFile(fileName);
381382
}
382383
}
383384
}
@@ -428,8 +429,7 @@ export class TypeScriptPlugin
428429
}
429430

430431
/**
431-
*
432-
* @internal
432+
* @internal Public for tests only
433433
*/
434434
public getSnapshotManager(fileName: string) {
435435
return this.lsAndTsDocResolver.getSnapshotManager(fileName);

packages/language-server/src/plugins/typescript/module-loader.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,38 @@
11
import ts from 'typescript';
2+
import { getLastPartOfPath } from '../../utils';
3+
import { DocumentSnapshot } from './DocumentSnapshot';
4+
import { createSvelteSys } from './svelte-sys';
25
import {
3-
isVirtualSvelteFilePath,
46
ensureRealSvelteFilePath,
5-
getExtensionFromScriptKind
7+
getExtensionFromScriptKind,
8+
isVirtualSvelteFilePath
69
} from './utils';
7-
import { DocumentSnapshot } from './DocumentSnapshot';
8-
import { createSvelteSys } from './svelte-sys';
910

1011
/**
1112
* Caches resolved modules.
1213
*/
1314
class ModuleResolutionCache {
14-
private cache = new Map<string, ts.ResolvedModule>();
15+
private cache = new Map<string, ts.ResolvedModule | undefined>();
1516

1617
/**
1718
* Tries to get a cached module.
19+
* Careful: `undefined` can mean either there's no match found, or that the result resolved to `undefined`.
1820
*/
1921
get(moduleName: string, containingFile: string): ts.ResolvedModule | undefined {
2022
return this.cache.get(this.getKey(moduleName, containingFile));
2123
}
2224

2325
/**
24-
* Caches resolved module, if it is not undefined.
26+
* Checks if has cached module.
27+
*/
28+
has(moduleName: string, containingFile: string): boolean {
29+
return this.cache.has(this.getKey(moduleName, containingFile));
30+
}
31+
32+
/**
33+
* Caches resolved module (or undefined).
2534
*/
2635
set(moduleName: string, containingFile: string, resolvedModule: ts.ResolvedModule | undefined) {
27-
if (!resolvedModule) {
28-
return;
29-
}
3036
this.cache.set(this.getKey(moduleName, containingFile), resolvedModule);
3137
}
3238

@@ -36,7 +42,21 @@ class ModuleResolutionCache {
3642
*/
3743
delete(resolvedModuleName: string): void {
3844
this.cache.forEach((val, key) => {
39-
if (val.resolvedFileName === resolvedModuleName) {
45+
if (val?.resolvedFileName === resolvedModuleName) {
46+
this.cache.delete(key);
47+
}
48+
});
49+
}
50+
51+
/**
52+
* Deletes everything from cache that resolved to `undefined`
53+
* and which might match the path.
54+
*/
55+
deleteUnresolvedResolutionsFromCache(path: string): void {
56+
const fileNameWithoutEnding = getLastPartOfPath(path).split('.').shift() || '';
57+
this.cache.forEach((val, key) => {
58+
const moduleName = key.split(':::').pop() || '';
59+
if (!val && moduleName.includes(fileNameWithoutEnding)) {
4060
this.cache.delete(key);
4161
}
4262
});
@@ -71,6 +91,8 @@ export function createSvelteModuleLoader(
7191
readFile: svelteSys.readFile,
7292
readDirectory: svelteSys.readDirectory,
7393
deleteFromModuleCache: (path: string) => moduleCache.delete(path),
94+
deleteUnresolvedResolutionsFromCache: (path: string) =>
95+
moduleCache.deleteUnresolvedResolutionsFromCache(path),
7496
resolveModuleNames
7597
};
7698

@@ -79,9 +101,8 @@ export function createSvelteModuleLoader(
79101
containingFile: string
80102
): Array<ts.ResolvedModule | undefined> {
81103
return moduleNames.map((moduleName) => {
82-
const cachedModule = moduleCache.get(moduleName, containingFile);
83-
if (cachedModule) {
84-
return cachedModule;
104+
if (moduleCache.has(moduleName, containingFile)) {
105+
return moduleCache.get(moduleName, containingFile);
85106
}
86107

87108
const resolvedModule = resolveModuleName(moduleName, containingFile);

packages/language-server/src/plugins/typescript/service.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
import { dirname, resolve } from 'path';
22
import ts from 'typescript';
3+
import { TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol';
4+
import { getPackageInfo } from '../../importPackage';
35
import { Document } from '../../lib/documents';
6+
import { configLoader } from '../../lib/documents/configLoader';
47
import { Logger } from '../../logger';
5-
import { getPackageInfo } from '../../importPackage';
68
import { DocumentSnapshot } from './DocumentSnapshot';
79
import { createSvelteModuleLoader } from './module-loader';
810
import { ignoredBuildDirectories, SnapshotManager } from './SnapshotManager';
911
import { ensureRealSvelteFilePath, findTsConfigPath } from './utils';
10-
import { configLoader } from '../../lib/documents/configLoader';
1112

1213
export interface LanguageServiceContainer {
1314
readonly tsconfigPath: string;
1415
readonly compilerOptions: ts.CompilerOptions;
16+
/**
17+
* @internal Public for tests only
18+
*/
1519
readonly snapshotManager: SnapshotManager;
1620
getService(): ts.LanguageService;
1721
updateSnapshot(documentOrFilePath: Document | string): DocumentSnapshot;
1822
deleteSnapshot(filePath: string): void;
23+
updateProjectFiles(): void;
24+
updateTsOrJsFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void;
25+
/**
26+
* Checks if a file is present in the project.
27+
* Unlike `fileBelongsToProject`, this doesn't run a file search on disk.
28+
*/
29+
hasFile(filePath: string): boolean;
1930
/**
2031
* Careful, don't call often, or it will hurt performance.
2132
* Only works for TS versions that have ScriptKind.Deferred
@@ -131,6 +142,9 @@ async function createLanguageService(
131142
getService: () => languageService,
132143
updateSnapshot,
133144
deleteSnapshot,
145+
updateProjectFiles,
146+
updateTsOrJsFile,
147+
hasFile,
134148
fileBelongsToProject,
135149
snapshotManager
136150
};
@@ -153,6 +167,10 @@ async function createLanguageService(
153167
return prevSnapshot;
154168
}
155169

170+
if (!prevSnapshot) {
171+
svelteModuleLoader.deleteUnresolvedResolutionsFromCache(filePath);
172+
}
173+
156174
const newSnapshot = DocumentSnapshot.fromDocument(document, transformationConfig);
157175

158176
snapshotManager.set(filePath, newSnapshot);
@@ -171,6 +189,7 @@ async function createLanguageService(
171189
return prevSnapshot;
172190
}
173191

192+
svelteModuleLoader.deleteUnresolvedResolutionsFromCache(filePath);
174193
const newSnapshot = DocumentSnapshot.fromFilePath(
175194
filePath,
176195
docContext.createDocument,
@@ -188,6 +207,7 @@ async function createLanguageService(
188207
return doc;
189208
}
190209

210+
svelteModuleLoader.deleteUnresolvedResolutionsFromCache(fileName);
191211
doc = DocumentSnapshot.fromFilePath(
192212
fileName,
193213
docContext.createDocument,
@@ -197,8 +217,23 @@ async function createLanguageService(
197217
return doc;
198218
}
199219

220+
function updateProjectFiles(): void {
221+
snapshotManager.updateProjectFiles();
222+
}
223+
224+
function hasFile(filePath: string): boolean {
225+
return snapshotManager.has(filePath);
226+
}
227+
200228
function fileBelongsToProject(filePath: string): boolean {
201-
return snapshotManager.has(filePath) || getParsedConfig().fileNames.includes(filePath);
229+
return hasFile(filePath) || getParsedConfig().fileNames.includes(filePath);
230+
}
231+
232+
function updateTsOrJsFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void {
233+
if (!snapshotManager.has(fileName)) {
234+
svelteModuleLoader.deleteUnresolvedResolutionsFromCache(fileName);
235+
}
236+
snapshotManager.updateTsOrJsFile(fileName, changes);
202237
}
203238

204239
function getParsedConfig() {

packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const fileNameToAbsoluteUri = (file: string) => {
3030
return pathToUrl(join(testFilesDir, file));
3131
};
3232

33-
describe.only('CompletionProviderImpl', () => {
33+
describe('CompletionProviderImpl', () => {
3434
function setup(filename: string) {
3535
const docManager = new DocumentManager(
3636
(textDocument) => new Document(textDocument.uri, textDocument.text)

packages/language-server/test/plugins/typescript/features/DiagnosticsProvider.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as assert from 'assert';
2+
import { existsSync, unlinkSync, writeFileSync } from 'fs';
23
import * as path from 'path';
34
import ts from 'typescript';
45
import { Document, DocumentManager } from '../../../../src/lib/documents';
56
import { LSConfigManager } from '../../../../src/ls-config';
67
import { DiagnosticsProviderImpl } from '../../../../src/plugins/typescript/features/DiagnosticsProvider';
78
import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDocResolver';
8-
import { pathToUrl } from '../../../../src/utils';
9+
import { pathToUrl, urlToPath } from '../../../../src/utils';
910

1011
const testDir = path.join(__dirname, '..', 'testfiles', 'diagnostics');
1112

@@ -25,7 +26,7 @@ describe('DiagnosticsProvider', () => {
2526
uri: pathToUrl(filePath),
2627
text: ts.sys.readFile(filePath) || ''
2728
});
28-
return { plugin, document, docManager };
29+
return { plugin, document, docManager, lsAndTsDocResolver };
2930
}
3031

3132
it('provides diagnostics', async () => {
@@ -936,4 +937,28 @@ describe('DiagnosticsProvider', () => {
936937
const diagnostics = await plugin.getDiagnostics(document);
937938
assert.deepStrictEqual(diagnostics, []);
938939
});
940+
941+
it('notices creation and deletion of imported module', async () => {
942+
const { plugin, document, lsAndTsDocResolver } = setup('unresolvedimport.svelte');
943+
944+
const diagnostics1 = await plugin.getDiagnostics(document);
945+
assert.deepStrictEqual(diagnostics1.length, 1);
946+
947+
// back-and-forth-conversion normalizes slashes
948+
const newFilePath = urlToPath(pathToUrl(path.join(testDir, 'doesntexistyet.js'))) || '';
949+
writeFileSync(newFilePath, 'export default function foo() {}');
950+
assert.ok(existsSync(newFilePath));
951+
await lsAndTsDocResolver.getSnapshot(newFilePath);
952+
953+
try {
954+
const diagnostics2 = await plugin.getDiagnostics(document);
955+
assert.deepStrictEqual(diagnostics2.length, 0);
956+
lsAndTsDocResolver.deleteSnapshot(newFilePath);
957+
} finally {
958+
unlinkSync(newFilePath);
959+
}
960+
961+
const diagnostics3 = await plugin.getDiagnostics(document);
962+
assert.deepStrictEqual(diagnostics3.length, 1);
963+
}).timeout(5000);
939964
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<script lang="ts">
2+
import foo from './doesntexistyet';
3+
foo;
4+
</script>

0 commit comments

Comments
 (0)