Skip to content

Commit 71b47c4

Browse files
authored
Invalidate caches on batches of 1000+ watch changes (#1869)
1 parent 42241ec commit 71b47c4

File tree

9 files changed

+510
-33
lines changed

9 files changed

+510
-33
lines changed

internal/project/bulkcache_test.go

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
package project_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/microsoft/typescript-go/internal/bundled"
9+
"github.com/microsoft/typescript-go/internal/core"
10+
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
11+
"github.com/microsoft/typescript-go/internal/project"
12+
"github.com/microsoft/typescript-go/internal/testutil/projecttestutil"
13+
"gotest.tools/v3/assert"
14+
)
15+
16+
func TestBulkCacheInvalidation(t *testing.T) {
17+
t.Parallel()
18+
19+
if !bundled.Embedded {
20+
t.Skip("bundled files are not embedded")
21+
}
22+
23+
// Base file structure for testing
24+
baseFiles := map[string]any{
25+
"/project/tsconfig.json": `{
26+
"compilerOptions": {
27+
"strict": true,
28+
"target": "es2015",
29+
"types": ["node"]
30+
},
31+
"include": ["src/**/*"]
32+
}`,
33+
"/project/src/index.ts": `import { helper } from "./helper"; console.log(helper);`,
34+
"/project/src/helper.ts": `export const helper = "test";`,
35+
"/project/src/utils/lib.ts": `export function util() { return "util"; }`,
36+
37+
"/project/node_modules/@types/node/index.d.ts": `import "./fs"; import "./console";`,
38+
"/project/node_modules/@types/node/fs.d.ts": ``,
39+
"/project/node_modules/@types/node/console.d.ts": ``,
40+
}
41+
42+
t.Run("large number of node_modules changes invalidates only node_modules cache", func(t *testing.T) {
43+
t.Parallel()
44+
test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectNodeModulesInvalidation bool) {
45+
session, utils := projecttestutil.Setup(baseFiles)
46+
47+
// Open a file to create the project
48+
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
49+
50+
// Get initial snapshot and verify config
51+
ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
52+
assert.NilError(t, err)
53+
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015)
54+
55+
snapshotBefore, release := session.Snapshot()
56+
defer release()
57+
configBefore := snapshotBefore.ConfigFileRegistry
58+
59+
// Update tsconfig.json on disk to test that configs don't get reloaded
60+
err = utils.FS().WriteFile("/project/tsconfig.json", `{
61+
"compilerOptions": {
62+
"strict": true,
63+
"target": "esnext",
64+
"types": ["node"]
65+
},
66+
"include": ["src/**/*"]
67+
}`, false)
68+
assert.NilError(t, err)
69+
// Update fs.d.ts in node_modules
70+
err = utils.FS().WriteFile("/project/node_modules/@types/node/fs.d.ts", "new text", false)
71+
assert.NilError(t, err)
72+
73+
// Process the excessive node_modules changes
74+
session.DidChangeWatchedFiles(context.Background(), fileEvents)
75+
76+
// Get language service again to trigger snapshot update
77+
ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
78+
assert.NilError(t, err)
79+
80+
snapshotAfter, release := session.Snapshot()
81+
defer release()
82+
configAfter := snapshotAfter.ConfigFileRegistry
83+
84+
// Config should NOT have been reloaded (target should remain ES2015, not esnext)
85+
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for node_modules-only changes")
86+
87+
// Config registry should be the same instance (no configs reloaded)
88+
assert.Equal(t, configBefore, configAfter, "Config registry should not have changed for node_modules-only changes")
89+
90+
fsDtsText := snapshotAfter.GetFile("/project/node_modules/@types/node/fs.d.ts").Content()
91+
if expectNodeModulesInvalidation {
92+
assert.Equal(t, fsDtsText, "new text")
93+
} else {
94+
assert.Equal(t, fsDtsText, "")
95+
}
96+
}
97+
98+
t.Run("with file existing in cache", func(t *testing.T) {
99+
t.Parallel()
100+
fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated)
101+
// Include two files in the program to trigger a full program creation.
102+
// Exclude fs.d.ts to show that its content still gets invalidated.
103+
fileEvents = append(fileEvents, &lsproto.FileEvent{
104+
Uri: "file:///project/node_modules/@types/node/index.d.ts",
105+
Type: lsproto.FileChangeTypeChanged,
106+
}, &lsproto.FileEvent{
107+
Uri: "file:///project/node_modules/@types/node/console.d.ts",
108+
Type: lsproto.FileChangeTypeChanged,
109+
})
110+
111+
test(t, fileEvents, true)
112+
})
113+
114+
t.Run("without file existing in cache", func(t *testing.T) {
115+
t.Parallel()
116+
fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated)
117+
test(t, fileEvents, false)
118+
})
119+
})
120+
121+
t.Run("large number of changes outside node_modules", func(t *testing.T) {
122+
t.Parallel()
123+
test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigReload bool) {
124+
session, utils := projecttestutil.Setup(baseFiles)
125+
126+
// Open a file to create the project
127+
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
128+
129+
// Get initial state
130+
ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
131+
assert.NilError(t, err)
132+
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015)
133+
134+
// Update tsconfig.json on disk
135+
err = utils.FS().WriteFile("/project/tsconfig.json", `{
136+
"compilerOptions": {
137+
"strict": true,
138+
"target": "esnext",
139+
"types": ["node"]
140+
},
141+
"include": ["src/**/*"]
142+
}`, false)
143+
assert.NilError(t, err)
144+
// Add root file
145+
err = utils.FS().WriteFile("/project/src/rootFile.ts", `console.log("root file")`, false)
146+
assert.NilError(t, err)
147+
148+
session.DidChangeWatchedFiles(context.Background(), fileEvents)
149+
ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
150+
assert.NilError(t, err)
151+
152+
if expectConfigReload {
153+
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Config should have been reloaded for changes outside node_modules")
154+
assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") != nil, "New root file should be present")
155+
} else {
156+
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetES2015, "Config should not have been reloaded for changes outside node_modules")
157+
assert.Check(t, ls.GetProgram().GetSourceFile("/project/src/rootFile.ts") == nil, "New root file should not be present")
158+
}
159+
}
160+
161+
t.Run("with event matching include glob", func(t *testing.T) {
162+
t.Parallel()
163+
fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated)
164+
fileEvents = append(fileEvents, &lsproto.FileEvent{
165+
Uri: "file:///project/src/rootFile.ts",
166+
Type: lsproto.FileChangeTypeCreated,
167+
})
168+
test(t, fileEvents, true)
169+
})
170+
171+
t.Run("without event matching include glob", func(t *testing.T) {
172+
t.Parallel()
173+
fileEvents := generateFileEvents(1001, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated)
174+
test(t, fileEvents, false)
175+
})
176+
})
177+
178+
t.Run("large number of changes outside node_modules causes project reevaluation", func(t *testing.T) {
179+
t.Parallel()
180+
session, utils := projecttestutil.Setup(baseFiles)
181+
182+
// Open a file that will initially use the root tsconfig
183+
session.DidOpenFile(context.Background(), "file:///project/src/utils/lib.ts", 1, baseFiles["/project/src/utils/lib.ts"].(string), lsproto.LanguageKindTypeScript)
184+
185+
// Initially, the file should use the root project (strict mode)
186+
snapshot, release := session.Snapshot()
187+
defer release()
188+
initialProject := snapshot.GetDefaultProject("file:///project/src/utils/lib.ts")
189+
assert.Equal(t, initialProject.Name(), "/project/tsconfig.json", "Should initially use root tsconfig")
190+
191+
// Get language service to verify initial strict mode
192+
ls, err := session.GetLanguageService(context.Background(), "file:///project/src/utils/lib.ts")
193+
assert.NilError(t, err)
194+
assert.Equal(t, ls.GetProgram().Options().Strict, core.TSTrue, "Should initially use strict mode from root config")
195+
196+
// Now create the nested tsconfig (this would normally be detected, but we'll simulate a missed event)
197+
err = utils.FS().WriteFile("/project/src/utils/tsconfig.json", `{
198+
"compilerOptions": {
199+
"strict": false,
200+
"target": "esnext"
201+
}
202+
}`, false)
203+
assert.NilError(t, err)
204+
205+
// Create excessive changes to trigger bulk invalidation
206+
fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated)
207+
208+
// Process the excessive changes - this should trigger project reevaluation
209+
session.DidChangeWatchedFiles(context.Background(), fileEvents)
210+
211+
// Get language service - this should now find the nested config and switch projects
212+
ls, err = session.GetLanguageService(context.Background(), "file:///project/src/utils/lib.ts")
213+
assert.NilError(t, err)
214+
215+
snapshot, release = session.Snapshot()
216+
defer release()
217+
newProject := snapshot.GetDefaultProject("file:///project/src/utils/lib.ts")
218+
219+
// The file should now use the nested tsconfig
220+
assert.Equal(t, newProject.Name(), "/project/src/utils/tsconfig.json", "Should now use nested tsconfig after bulk invalidation")
221+
assert.Equal(t, ls.GetProgram().Options().Strict, core.TSFalse, "Should now use non-strict mode from nested config")
222+
assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext, "Should use esnext target from nested config")
223+
})
224+
225+
t.Run("config file names cache", func(t *testing.T) {
226+
t.Parallel()
227+
test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigDiscovery bool, testName string) {
228+
files := map[string]any{
229+
"/project/src/index.ts": `console.log("test");`, // No tsconfig initially
230+
}
231+
session, utils := projecttestutil.Setup(files)
232+
233+
// Open file without tsconfig - should create inferred project
234+
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
235+
236+
snapshot, release := session.Snapshot()
237+
defer release()
238+
assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil, "Should have inferred project")
239+
assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred)
240+
241+
// Create a tsconfig that would affect this file (simulating a missed creation event)
242+
err := utils.FS().WriteFile("/project/tsconfig.json", `{
243+
"compilerOptions": {
244+
"strict": true
245+
},
246+
"include": ["src/**/*"]
247+
}`, false)
248+
assert.NilError(t, err)
249+
250+
// Process the changes
251+
session.DidChangeWatchedFiles(context.Background(), fileEvents)
252+
253+
// Get language service to trigger config discovery
254+
_, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
255+
assert.NilError(t, err)
256+
257+
snapshot, release = session.Snapshot()
258+
defer release()
259+
newProject := snapshot.GetDefaultProject("file:///project/src/index.ts")
260+
261+
// Check expected behavior
262+
if expectConfigDiscovery {
263+
// Should now use configured project instead of inferred
264+
assert.Equal(t, newProject.Kind, project.KindConfigured, "Should now use configured project after cache invalidation")
265+
assert.Equal(t, newProject.Name(), "/project/tsconfig.json", "Should use the newly discovered tsconfig")
266+
} else {
267+
// Should still use inferred project (config file names cache not cleared)
268+
assert.Assert(t, newProject == snapshot.ProjectCollection.InferredProject(), "Should still use inferred project after node_modules-only changes")
269+
}
270+
}
271+
272+
t.Run("excessive changes only in node_modules does not affect config file names cache", func(t *testing.T) {
273+
t.Parallel()
274+
fileEvents := generateFileEvents(1001, "file:///project/node_modules/generated/file%d.js", lsproto.FileChangeTypeCreated)
275+
test(t, fileEvents, false, "node_modules changes should not clear config cache")
276+
})
277+
278+
t.Run("excessive changes outside node_modules clears config file names cache", func(t *testing.T) {
279+
t.Parallel()
280+
fileEvents := generateFileEvents(1001, "file:///project/src/generated/file%d.ts", lsproto.FileChangeTypeCreated)
281+
// Presence of any tsconfig.json file event triggers rediscovery for config for all open files
282+
fileEvents = append(fileEvents, &lsproto.FileEvent{
283+
Uri: lsproto.DocumentUri("file:///project/src/generated/tsconfig.json"),
284+
Type: lsproto.FileChangeTypeCreated,
285+
})
286+
test(t, fileEvents, true, "non-node_modules changes should clear config cache")
287+
})
288+
})
289+
290+
// Simulate external build tool changing files in dist/ (not included by any project)
291+
t.Run("excessive changes in dist folder do not invalidate", func(t *testing.T) {
292+
t.Parallel()
293+
files := map[string]any{
294+
"/project/src/index.ts": `console.log("test");`, // No tsconfig initially
295+
}
296+
session, utils := projecttestutil.Setup(files)
297+
298+
// Open file without tsconfig - should create inferred project
299+
session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, files["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript)
300+
301+
snapshot, release := session.Snapshot()
302+
defer release()
303+
assert.Equal(t, snapshot.GetDefaultProject("file:///project/src/index.ts").Kind, project.KindInferred)
304+
305+
// Create a tsconfig that would affect this file (simulating a missed creation event)
306+
// This should NOT be discovered after dist-folder changes
307+
err := utils.FS().WriteFile("/project/tsconfig.json", `{
308+
"compilerOptions": {
309+
"strict": true
310+
},
311+
"include": ["src/**/*"]
312+
}`, false)
313+
assert.NilError(t, err)
314+
315+
// Create excessive changes in dist folder only
316+
fileEvents := generateFileEvents(1001, "file:///project/dist/generated/file%d.js", lsproto.FileChangeTypeCreated)
317+
session.DidChangeWatchedFiles(context.Background(), fileEvents)
318+
319+
// File should still use inferred project (config file names cache NOT cleared for dist changes)
320+
_, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts")
321+
assert.NilError(t, err)
322+
323+
snapshot, release = session.Snapshot()
324+
defer release()
325+
newProject := snapshot.GetDefaultProject("file:///project/src/index.ts")
326+
assert.Equal(t, newProject.Kind, project.KindInferred, "dist-folder changes should not cause config discovery")
327+
// This assertion will fail until we implement logic to ignore dist folder changes
328+
})
329+
}
330+
331+
// Helper function to generate excessive file change events
332+
func generateFileEvents(count int, pathTemplate string, changeType lsproto.FileChangeType) []*lsproto.FileEvent {
333+
var events []*lsproto.FileEvent
334+
for i := range count {
335+
events = append(events, &lsproto.FileEvent{
336+
Uri: lsproto.DocumentUri(fmt.Sprintf(pathTemplate, i)),
337+
Type: changeType,
338+
})
339+
}
340+
return events
341+
}

internal/project/configfileregistry.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type ConfigFileRegistry struct {
1919
}
2020

2121
type configFileEntry struct {
22+
fileName string
2223
pendingReload PendingReload
2324
commandLine *tsoptions.ParsedCommandLine
2425
// retainingProjects is the set of projects that have called acquireConfig
@@ -46,6 +47,7 @@ type configFileEntry struct {
4647

4748
func newConfigFileEntry(fileName string) *configFileEntry {
4849
return &configFileEntry{
50+
fileName: fileName,
4951
pendingReload: PendingReloadFull,
5052
rootFilesWatch: NewWatchedFiles(
5153
"root files for "+fileName,
@@ -55,15 +57,17 @@ func newConfigFileEntry(fileName string) *configFileEntry {
5557
}
5658
}
5759

58-
func newExtendedConfigFileEntry(extendingConfigPath tspath.Path) *configFileEntry {
60+
func newExtendedConfigFileEntry(fileName string, extendingConfigPath tspath.Path) *configFileEntry {
5961
return &configFileEntry{
62+
fileName: fileName,
6063
pendingReload: PendingReloadFull,
6164
retainingConfigs: map[tspath.Path]struct{}{extendingConfigPath: {}},
6265
}
6366
}
6467

6568
func (e *configFileEntry) Clone() *configFileEntry {
6669
return &configFileEntry{
70+
fileName: e.fileName,
6771
pendingReload: e.pendingReload,
6872
commandLine: e.commandLine,
6973
// !!! eagerly cloning these maps makes everything more convenient,

0 commit comments

Comments
 (0)