Skip to content

Commit f315ef9

Browse files
authored
Ensure node_modules buckets are removed from auto-import registry once they no longer exist (#4303)
1 parent a7ae3a1 commit f315ef9

5 files changed

Lines changed: 243 additions & 29 deletions

File tree

internal/ls/autoimport/registry.go

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,6 @@ func (b *registryBuilder) updateBucketAndDirectoryExistence(change RegistryChang
633633
})
634634
}
635635

636-
if packageJsonChanged {
637-
// package.json changes affecting node_modules are handled by comparing dependencies in updateIndexes
638-
return
639-
}
640-
641636
if hasNodeModules {
642637
if _, ok := b.nodeModules.Get(dirPath); !ok {
643638
b.nodeModules.Add(dirPath, newRegistryBucket())
@@ -648,12 +643,15 @@ func (b *registryBuilder) updateBucketAndDirectoryExistence(change RegistryChang
648643
}
649644

650645
var addedNodeModulesDirs, removedNodeModulesDirs []tspath.Path
646+
packageJsonChanged := func(dirName string) bool {
647+
uri := lsconv.FileNameToDocumentURI(tspath.CombinePaths(dirName, "package.json"))
648+
return change.Changed.Has(uri) || change.Deleted.Has(uri) || change.Created.Has(uri)
649+
}
651650
core.DiffMapsFunc(
652651
b.base.directories,
653652
neededDirectories,
654653
func(dir *directory, dirName string) bool {
655-
packageJsonUri := lsconv.FileNameToDocumentURI(tspath.CombinePaths(dirName, "package.json"))
656-
return !change.Changed.Has(packageJsonUri) && !change.Deleted.Has(packageJsonUri) && !change.Created.Has(packageJsonUri)
654+
return !packageJsonChanged(dirName) && dir.hasNodeModules == b.host.FS().DirectoryExists(tspath.CombinePaths(dirName, "node_modules"))
657655
},
658656
func(dirPath tspath.Path, dirName string) {
659657
// Need and don't have
@@ -679,13 +677,13 @@ func (b *registryBuilder) updateBucketAndDirectoryExistence(change RegistryChang
679677
}
680678
},
681679
func(dirPath tspath.Path, dir *directory, dirName string) {
682-
// package.json may have changed
683-
updateDirectory(dirPath, dirName, true)
680+
updateDirectory(dirPath, dirName, packageJsonChanged(dirName))
684681
if logger != nil {
685682
logger.Logf("Changed directory: %s", dirPath)
686683
}
687684
},
688685
)
686+
689687
if logger != nil {
690688
for _, dirPath := range addedNodeModulesDirs {
691689
logger.Logf("Added node_modules bucket: %s", dirPath)
@@ -797,7 +795,6 @@ func (b *registryBuilder) updateIndexes(ctx context.Context, change RegistryChan
797795
packageNames *collections.Set[string]
798796
directoryPackageNames *collections.Set[string]
799797
discovered []*discoveredPackage
800-
discoverErr error
801798
}
802799

803800
projectPath, _ := b.host.GetDefaultProject(change.RequestedFile)
@@ -890,12 +887,7 @@ func (b *registryBuilder) updateIndexes(ctx context.Context, change RegistryChan
890887
if task.isUpdate {
891888
task.packageNames = task.dirtyPackages
892889
} else {
893-
var err error
894-
task.directoryPackageNames, err = getPackageNamesInNodeModules(tspath.CombinePaths(task.dirName, "node_modules"), b.host.FS())
895-
if err != nil {
896-
task.discoverErr = err
897-
return
898-
}
890+
task.directoryPackageNames = getPackageNamesInNodeModules(tspath.CombinePaths(task.dirName, "node_modules"), b.host.FS())
899891
task.packageNames = core.Coalesce(task.dependencyNames, task.directoryPackageNames)
900892
}
901893
task.discovered = b.discoverBucketPackages(task.packageNames, task.dirName, task.dirPath)
@@ -918,9 +910,6 @@ func (b *registryBuilder) updateIndexes(ctx context.Context, change RegistryChan
918910
// filter to only those whose main extraction failed, then deduplicate by typesRealpath.
919911
var typesFallbackCandidates []*discoveredPackage
920912
for _, task := range nodeModulesTasks {
921-
if task.discoverErr != nil {
922-
continue
923-
}
924913
for _, pkg := range task.discovered {
925914
if pkg.realpath != "" {
926915
if !seen[pkg.realpath] {
@@ -1007,10 +996,6 @@ func (b *registryBuilder) updateIndexes(ctx context.Context, change RegistryChan
1007996
for _, task := range nodeModulesTasks {
1008997
br := &bucketBuildResult{entry: task.entry}
1009998
allResults = append(allResults, br)
1010-
if task.discoverErr != nil {
1011-
br.err = task.discoverErr
1012-
continue
1013-
}
1014999
wg.Go(func() {
10151000
if task.isUpdate {
10161001
b.updateNodeModulesBucket(

internal/ls/autoimport/registry_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,127 @@ export const bar = 2;`,
228228
assert.Equal(t, len(stats.ProjectBuckets), 1)
229229
})
230230

231+
t.Run("deleting node_modules leaves the registry prepared for importing", func(t *testing.T) {
232+
t.Parallel()
233+
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
234+
session := fixture.Session()
235+
sessionUtils := fixture.Utils()
236+
project := fixture.SingleProject()
237+
mainFile := project.File(0)
238+
ctx := context.Background()
239+
240+
preferences := lsutil.NewDefaultUserPreferences()
241+
preferences.IncludeCompletionsForModuleExports = core.TSTrue
242+
preferences.IncludeCompletionsForImportStatements = core.TSTrue
243+
244+
// Build auto-imports once so both buckets are clean and prepared.
245+
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
246+
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
247+
assert.NilError(t, err)
248+
249+
snapshot := session.Snapshot()
250+
defaultProject := snapshot.GetDefaultProject(mainFile.URI())
251+
assert.Assert(t, defaultProject != nil)
252+
projectPath := defaultProject.ConfigFilePath()
253+
assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences))
254+
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 1)
255+
256+
// Simulate the user deleting node_modules: remove the directory from disk
257+
// and notify the session of the deletion, which marks the node_modules
258+
// bucket dirty.
259+
nodeModulesDir := tspath.CombinePaths(project.Root(), "node_modules")
260+
assert.NilError(t, sessionUtils.FS().Remove(nodeModulesDir))
261+
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
262+
{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodeModulesDir)},
263+
})
264+
265+
// Re-preparing auto-imports must succeed and leave the registry prepared.
266+
// Before the fix, the node_modules bucket's rebuild bailed out with
267+
// vfs.ErrNotExist, leaving the stale dirty bucket in place so the registry
268+
// reported "needs rebuild" forever (which crashed the request handler).
269+
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
270+
assert.NilError(t, err)
271+
272+
snapshot = session.Snapshot()
273+
assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences),
274+
"registry should be prepared after node_modules is deleted")
275+
// The node_modules bucket should be removed entirely, not left behind as an
276+
// empty bucket.
277+
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 0)
278+
})
279+
280+
t.Run("deleting node_modules alongside a package.json change removes the bucket", func(t *testing.T) {
281+
t.Parallel()
282+
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
283+
session := fixture.Session()
284+
sessionUtils := fixture.Utils()
285+
project := fixture.SingleProject()
286+
mainFile := project.File(0)
287+
packageJSON := project.PackageJSONFile()
288+
ctx := context.Background()
289+
290+
preferences := lsutil.NewDefaultUserPreferences()
291+
preferences.IncludeCompletionsForModuleExports = core.TSTrue
292+
preferences.IncludeCompletionsForImportStatements = core.TSTrue
293+
294+
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
295+
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
296+
assert.NilError(t, err)
297+
298+
snapshot := session.Snapshot()
299+
defaultProject := snapshot.GetDefaultProject(mainFile.URI())
300+
assert.Assert(t, defaultProject != nil)
301+
projectPath := defaultProject.ConfigFilePath()
302+
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 1)
303+
304+
// In a single changeset, edit package.json AND delete node_modules. The
305+
// package.json change must not prevent the now-missing node_modules bucket
306+
// from being removed.
307+
assert.NilError(t, sessionUtils.FS().WriteFile(packageJSON.FileName(), `{"name": "app", "dependencies": {}}`))
308+
nodeModulesDir := tspath.CombinePaths(project.Root(), "node_modules")
309+
assert.NilError(t, sessionUtils.FS().Remove(nodeModulesDir))
310+
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
311+
{Type: lsproto.FileChangeTypeChanged, Uri: packageJSON.URI()},
312+
{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodeModulesDir)},
313+
})
314+
315+
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
316+
assert.NilError(t, err)
317+
318+
snapshot = session.Snapshot()
319+
assert.Assert(t, snapshot.AutoImportRegistry().IsPreparedForImportingFile(mainFile.FileName(), projectPath, preferences))
320+
assert.Equal(t, len(autoImportStats(t, session).NodeModulesBuckets), 0)
321+
})
322+
323+
t.Run("deleting a package directory inside node_modules invalidates the bucket", func(t *testing.T) {
324+
t.Parallel()
325+
fixture := autoimporttestutil.SetupLifecycleSession(t, lifecycleProjectRoot, 1)
326+
session := fixture.Session()
327+
sessionUtils := fixture.Utils()
328+
project := fixture.SingleProject()
329+
mainFile := project.File(0)
330+
nodePackage := project.NodeModules()[0]
331+
ctx := context.Background()
332+
333+
session.DidOpenFile(ctx, mainFile.URI(), 1, mainFile.Content(), lsproto.LanguageKindTypeScript)
334+
_, err := session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
335+
assert.NilError(t, err)
336+
assert.Assert(t, singleBucket(t, autoImportStats(t, session).NodeModulesBuckets).ExportCount > 0)
337+
338+
// Delete just the package directory, leaving node_modules itself in place. The
339+
// package's files are read transiently by the registry, so they are never tracked
340+
// in diskFiles/diskDirectories; only the directory deletion event is reported, and
341+
// it must survive snapshotfs filtering to invalidate the bucket.
342+
assert.NilError(t, sessionUtils.FS().Remove(nodePackage.Directory))
343+
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
344+
{Type: lsproto.FileChangeTypeDeleted, Uri: lsconv.FileNameToDocumentURI(nodePackage.Directory)},
345+
})
346+
347+
_, err = session.GetCurrentLanguageServiceWithAutoImports(ctx, mainFile.URI())
348+
assert.NilError(t, err)
349+
assert.Equal(t, singleBucket(t, autoImportStats(t, session).NodeModulesBuckets).ExportCount, 0)
350+
})
351+
231352
t.Run("node_modules bucket dependency selection changes with open files", func(t *testing.T) {
232353
t.Parallel()
233354
monorepoRoot := "/home/src/monorepo"

internal/ls/autoimport/util.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ func wordIndices(s string) []int {
8585
return indices
8686
}
8787

88-
func getPackageNamesInNodeModules(nodeModulesDir string, fs vfs.FS) (*collections.Set[string], error) {
88+
func getPackageNamesInNodeModules(nodeModulesDir string, fs vfs.FS) *collections.Set[string] {
8989
packageNames := &collections.Set[string]{}
9090
if tspath.GetBaseFileName(nodeModulesDir) != "node_modules" {
9191
panic("nodeModulesDir is not a node_modules directory")
9292
}
93-
if !fs.DirectoryExists(nodeModulesDir) {
94-
return nil, vfs.ErrNotExist
95-
}
93+
// A missing node_modules directory yields no entries (GetAccessibleEntries returns
94+
// empty), so there's no need to check existence first: a deleted node_modules is
95+
// handled upstream in updateBucketAndDirectoryExistence, which drops the bucket.
9696
entries := fs.GetAccessibleEntries(nodeModulesDir)
9797
for _, baseName := range entries.Directories {
9898
if baseName[0] == '.' {
@@ -112,7 +112,7 @@ func getPackageNamesInNodeModules(nodeModulesDir string, fs vfs.FS) (*collection
112112
}
113113
packageNames.Add(baseName)
114114
}
115-
return packageNames, nil
115+
return packageNames
116116
}
117117

118118
func getDefaultLikeExportNameFromDeclaration(symbol *ast.Symbol) string {

internal/project/snapshotfs.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,10 @@ func (s *snapshotFSBuilder) expandAndFilterWatchEvents(change FileChangeSummary)
554554
path := s.toPath(uri.FileName())
555555
if _, ok := s.diskDirectories.Get(path); ok {
556556
s.collectFilesRecursive(path, &filteredDeleted)
557-
} else if s.isRelevantFileName(uri) {
557+
} else if s.isRelevantFileName(uri) || isNodeModulesPath(path) {
558+
// node_modules deletions must always be preserved for auto-import registry change handlers.
559+
// They won't be in diskDirectories since the registry doesn't use the snapshotFSBuilder for
560+
// its file system, since we don't want to retain files read there.
558561
filteredDeleted.Add(uri)
559562
}
560563
}
@@ -578,6 +581,14 @@ func (s *snapshotFSBuilder) expandAndFilterWatchEvents(change FileChangeSummary)
578581
return change
579582
}
580583

584+
// isNodeModulesPath reports whether path is a node_modules directory itself or
585+
// lives inside one. Used to preserve node_modules watch deletions, whose package
586+
// files are read transiently and therefore never tracked in diskDirectories.
587+
func isNodeModulesPath(path tspath.Path) bool {
588+
s := string(path)
589+
return strings.HasSuffix(s, "/node_modules") || strings.Contains(s, "/node_modules/")
590+
}
591+
581592
// collectFilesRecursive recursively collects all cached file URIs under the
582593
// given directory path using the diskDirectories and diskFiles maps.
583594
func (s *snapshotFSBuilder) collectFilesRecursive(dirPath tspath.Path, files *collections.Set[lsproto.DocumentUri]) {

internal/project/snapshotfs_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
88
"github.com/microsoft/typescript-go/internal/project/dirty"
99
"github.com/microsoft/typescript-go/internal/tspath"
10+
"github.com/microsoft/typescript-go/internal/vfs"
1011
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
1112
"gotest.tools/v3/assert"
1213
)
@@ -1383,3 +1384,99 @@ func TestRealpathAliasLifecycle(t *testing.T) {
13831384
"snapshot1 must not contain alias added in snapshot2")
13841385
})
13851386
}
1387+
1388+
func TestExpandAndFilterWatchEvents(t *testing.T) {
1389+
t.Parallel()
1390+
1391+
toPath := func(fileName string) tspath.Path {
1392+
return tspath.Path(fileName)
1393+
}
1394+
1395+
newBuilder := func(testFS vfs.FS) *snapshotFSBuilder {
1396+
return newSnapshotFSBuilder(
1397+
testFS,
1398+
make(map[tspath.Path]*Overlay),
1399+
make(map[tspath.Path]*Overlay),
1400+
make(map[tspath.Path]*diskFile),
1401+
make(map[tspath.Path]dirty.CloneableMap[tspath.Path, string]),
1402+
nil,
1403+
lsproto.PositionEncodingKindUTF16,
1404+
toPath,
1405+
)
1406+
}
1407+
1408+
t.Run("preserves node_modules directory deletion even when untracked", func(t *testing.T) {
1409+
t.Parallel()
1410+
// node_modules package files are read transiently and never tracked in
1411+
// diskDirectories, so a node_modules directory deletion can neither be
1412+
// expanded nor matched by extension. It must still be preserved.
1413+
builder := newBuilder(vfstest.FromMap(map[string]string{
1414+
"/project/index.ts": "export const x = 1;",
1415+
}, false))
1416+
1417+
change := FileChangeSummary{}
1418+
change.Deleted.Add("file:///project/node_modules")
1419+
1420+
expanded := builder.expandAndFilterWatchEvents(change)
1421+
assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules"),
1422+
"bare node_modules directory deletion should be preserved")
1423+
})
1424+
1425+
t.Run("preserves deletion of a package directory inside node_modules", func(t *testing.T) {
1426+
t.Parallel()
1427+
builder := newBuilder(vfstest.FromMap(map[string]string{
1428+
"/project/index.ts": "export const x = 1;",
1429+
}, false))
1430+
1431+
change := FileChangeSummary{}
1432+
change.Deleted.Add("file:///project/node_modules/@scope/pkg")
1433+
1434+
expanded := builder.expandAndFilterWatchEvents(change)
1435+
assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules/@scope/pkg"),
1436+
"package directory deletion inside node_modules should be preserved")
1437+
})
1438+
1439+
t.Run("drops irrelevant untracked deletion outside node_modules", func(t *testing.T) {
1440+
t.Parallel()
1441+
builder := newBuilder(vfstest.FromMap(map[string]string{
1442+
"/project/index.ts": "export const x = 1;",
1443+
}, false))
1444+
1445+
change := FileChangeSummary{}
1446+
change.Deleted.Add("file:///project/build")
1447+
1448+
expanded := builder.expandAndFilterWatchEvents(change)
1449+
assert.Equal(t, expanded.Deleted.Len(), 0,
1450+
"untracked non-node_modules directory deletion should be dropped")
1451+
})
1452+
1453+
t.Run("expands tracked directory deletion into file deletions", func(t *testing.T) {
1454+
t.Parallel()
1455+
existingDiskFiles := map[tspath.Path]*diskFile{
1456+
tspath.Path("/src/foo.ts"): newDiskFile("/src/foo.ts", "const foo = 1;"),
1457+
}
1458+
existingDirs := map[tspath.Path]dirty.CloneableMap[tspath.Path, string]{
1459+
tspath.Path("/"): {tspath.Path("/src"): "src"},
1460+
tspath.Path("/src"): {tspath.Path("/src/foo.ts"): "foo.ts"},
1461+
}
1462+
builder := newSnapshotFSBuilder(
1463+
vfstest.FromMap(map[string]string{"/src/foo.ts": "const foo = 1;"}, false),
1464+
make(map[tspath.Path]*Overlay),
1465+
make(map[tspath.Path]*Overlay),
1466+
existingDiskFiles,
1467+
existingDirs,
1468+
nil,
1469+
lsproto.PositionEncodingKindUTF16,
1470+
toPath,
1471+
)
1472+
1473+
change := FileChangeSummary{}
1474+
change.Deleted.Add("file:///src")
1475+
1476+
expanded := builder.expandAndFilterWatchEvents(change)
1477+
assert.Assert(t, expanded.Deleted.Has("file:///src/foo.ts"),
1478+
"tracked directory deletion should expand to contained file deletions")
1479+
assert.Assert(t, !expanded.Deleted.Has("file:///src"),
1480+
"the directory URI itself should be replaced by its files")
1481+
})
1482+
}

0 commit comments

Comments
 (0)