diff --git a/tsc/internal/project/bulkcache_test.go b/tsc/internal/project/bulkcache_test.go index 19451c6473fe1..ff7e89a9de2c1 100644 --- a/tsc/internal/project/bulkcache_test.go +++ b/tsc/internal/project/bulkcache_test.go @@ -116,6 +116,43 @@ func TestBulkCacheInvalidation(t *testing.T) { }) }) + t.Run("large all-created batch reloads cached file", func(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + eventCount int + eventURI lsproto.DocumentUri + }{ + {name: "single parent directory", eventURI: "file:///project/src"}, + {name: "excessive exact file", eventCount: 1001, eventURI: "file:///project/src/helper.ts"}, + {name: "excessive parent directory", eventCount: 1001, eventURI: "file:///project/src"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(baseFiles) + + session.DidOpenFile(context.Background(), "file:///project/src/index.ts", 1, baseFiles["/project/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + ls, err := session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().GetSourceFile("/project/src/helper.ts").Text(), `export const helper = "test";`) + + err = utils.FS().WriteFile("/project/src/helper.ts", `export const helper = "updated";`) + assert.NilError(t, err) + + fileEvents := generateFileEvents(test.eventCount, "file:///project/generated/file%d.ts", lsproto.FileChangeTypeCreated) + fileEvents = append(fileEvents, &lsproto.FileEvent{ + Uri: test.eventURI, + Type: lsproto.FileChangeTypeCreated, + }) + session.DidChangeWatchedFiles(context.Background(), fileEvents) + + ls, err = session.GetLanguageService(context.Background(), "file:///project/src/index.ts") + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().GetSourceFile("/project/src/helper.ts").Text(), `export const helper = "updated";`) + }) + } + }) + t.Run("large number of changes outside node_modules", func(t *testing.T) { t.Parallel() test := func(t *testing.T, fileEvents []*lsproto.FileEvent, expectConfigReload bool) { diff --git a/tsc/internal/project/overlayfs.go b/tsc/internal/project/overlayfs.go index 458b94d0ee831..36c9302be5267 100644 --- a/tsc/internal/project/overlayfs.go +++ b/tsc/internal/project/overlayfs.go @@ -417,13 +417,14 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma // Reduced collection of changes that occurred on a single file type fileEvents struct { - openChange *FileChange - closeChange *FileChange - watchChanged bool - changes []*FileChange - saved bool - created bool - deleted bool + openChange *FileChange + closeChange *FileChange + watchChanged bool + changes []*FileChange + saved bool + created bool + deleted bool + watchObserved bool } fileEventMap := make(map[lsproto.DocumentUri]*fileEvents) @@ -439,6 +440,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma events = &fileEvents{} fileEventMap[uri] = events } + events.watchObserved = events.watchObserved || change.Kind.IsWatchKind() if !result.IncludesWatchChangeOutsideNodeModules && change.Kind.IsWatchKind() && !strings.Contains(string(uri), "/node_modules/") { result.IncludesWatchChangeOutsideNodeModules = true @@ -529,16 +531,8 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma o = nil } - if events.watchChanged { - if o == nil { - result.Changed.Add(uri) - } else if o != nil && !events.saved { - if matchesDiskText, _ := o.computeMatchesDiskText(fs.host); matchesDiskText != o.MatchesDiskText() { - o = newOverlay(o.FileName(), o.Content(), o.Version(), o.kind) - o.matchesDiskText = matchesDiskText - newOverlays[path] = o - } - } + if events.watchChanged && o == nil { + result.Changed.Add(uri) } if len(events.changes) > 0 && o != nil { @@ -567,15 +561,15 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } } - if events.saved { - if o != nil { + if o != nil && (events.watchObserved || events.saved) { + if matchesDiskText, _ := o.computeMatchesDiskText(fs.host); matchesDiskText != o.MatchesDiskText() { o = newOverlay(o.FileName(), o.Content(), o.Version(), o.kind) - o.matchesDiskText = true + o.matchesDiskText = matchesDiskText newOverlays[path] = o - } else if !events.watchChanged { - // File was saved but never opened via didOpen; treat as a disk change. - result.Changed.Add(uri) } + } else if events.saved && o == nil && !events.watchChanged { + // File was saved but never opened via didOpen; treat as a disk change. + result.Changed.Add(uri) } if events.created && o == nil { diff --git a/tsc/internal/project/overlayfs_test.go b/tsc/internal/project/overlayfs_test.go index 042764dda8267..26f1bcff3f339 100644 --- a/tsc/internal/project/overlayfs_test.go +++ b/tsc/internal/project/overlayfs_test.go @@ -146,6 +146,7 @@ func TestProcessChanges(t *testing.T) { }, }) // Then save + assert.NilError(t, fs.host.WriteFile(testURI1.FileName(), "const x = 1;")) result, _ := fs.processChanges([]FileChange{ { Kind: FileChangeKindSave, @@ -228,6 +229,7 @@ func TestProcessChanges(t *testing.T) { assert.Assert(t, !fs.GetFile(testURI1.FileName()).MatchesDiskText()) // Then save + assert.NilError(t, fs.host.WriteFile(testURI1.FileName(), "const x = 1;")) fs.processChanges([]FileChange{ { Kind: FileChangeKindSave, @@ -237,6 +239,7 @@ func TestProcessChanges(t *testing.T) { assert.Assert(t, fs.GetFile(testURI1.FileName()).MatchesDiskText()) // Now process a watch change + assert.NilError(t, fs.host.WriteFile(testURI1.FileName(), "const x = 2;")) fs.processChanges([]FileChange{ { Kind: FileChangeKindWatchChange, @@ -246,6 +249,52 @@ func TestProcessChanges(t *testing.T) { assert.Assert(t, !fs.GetFile(testURI1.FileName()).MatchesDiskText()) }) + t.Run("save and watch create compare overlay with disk in either order", func(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + changes []FileChange + }{ + { + name: "save then create", + changes: []FileChange{ + {Kind: FileChangeKindSave, URI: testURI1}, + {Kind: FileChangeKindWatchCreate, URI: testURI1}, + }, + }, + { + name: "create then save", + changes: []FileChange{ + {Kind: FileChangeKindWatchCreate, URI: testURI1}, + {Kind: FileChangeKindSave, URI: testURI1}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + const overlayContent = "const value = 'overlay';" + fs.processChanges([]FileChange{ + { + Kind: FileChangeKindOpen, + URI: testURI1, + Version: 1, + Content: overlayContent, + LanguageKind: lsproto.LanguageKindTypeScript, + }, + }) + assert.NilError(t, fs.host.WriteFile(testURI1.FileName(), "const value = 'disk';")) + + fs.processChanges(test.changes) + + file := fs.GetFile(testURI1.FileName()) + assert.Assert(t, file != nil) + assert.Equal(t, file.Content(), overlayContent) + assert.Assert(t, !file.MatchesDiskText()) + }) + } + }) + t.Run("save without overlay should not panic", func(t *testing.T) { t.Parallel() fs := createOverlayFS() diff --git a/tsc/internal/project/session_test.go b/tsc/internal/project/session_test.go index 140632689c940..81199e3731803 100644 --- a/tsc/internal/project/session_test.go +++ b/tsc/internal/project/session_test.go @@ -529,6 +529,41 @@ func TestSession(t *testing.T) { snapshot = session.Snapshot() assert.Equal(t, snapshot.GetFile("/home/projects/TS/p1/src/index.ts").MatchesDiskText(), true) }) + + t.Run("created watch event compares overlay with disk in either order", func(t *testing.T) { + t.Parallel() + for _, saveFirst := range []bool{true, false} { + name := "watch event first" + if saveFirst { + name = "save event first" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(defaultFiles) + const uri = lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") + overlayContent := defaultFiles["/home/projects/TS/p1/src/index.ts"].(string) + session.DidOpenFile(context.Background(), uri, 1, overlayContent, lsproto.LanguageKindTypeScript) + assert.NilError(t, utils.FS().WriteFile(uri.FileName(), "const disk = true;")) + + watch := func() { + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{{Type: lsproto.FileChangeTypeCreated, Uri: uri}}) + } + if saveFirst { + session.DidSaveFile(context.Background(), uri) + watch() + } else { + watch() + session.DidSaveFile(context.Background(), uri) + } + + session.WaitForBackgroundTasks() + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, defaultFiles["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + file := session.Snapshot().GetFile(uri.FileName()) + assert.Equal(t, file.Content(), overlayContent) + assert.Assert(t, !file.MatchesDiskText()) + }) + } + }) }) t.Run("Source file sharing", func(t *testing.T) { @@ -661,6 +696,74 @@ func TestSession(t *testing.T) { assert.Check(t, lsAfter.GetProgram() != programBefore) }) + t.Run("create closed program file reloads content (atomic save)", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + session, utils := projecttestutil.Setup(files) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + programBefore := lsBefore.GetProgram() + + err = utils.FS().WriteFile("/home/projects/TS/p1/src/x.ts", `export const x = 2;`) + assert.NilError(t, err) + + // Simulate atomic save / rename where watcher reports Created (type 1) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeCreated, + Uri: "file:///home/projects/TS/p1/src/x.ts", + }, + }) + + lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + assert.Check(t, lsAfter.GetProgram() != programBefore) + srcX := lsAfter.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") + assert.Assert(t, srcX != nil) + assert.Equal(t, srcX.Text(), "export const x = 2;") + }) + + t.Run("atomic save sequence with temp file reloads content", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + session, utils := projecttestutil.Setup(files) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + programBefore := lsBefore.GetProgram() + + err = utils.FS().WriteFile("/home/projects/TS/p1/src/x.ts", `export const x = 42;`) + assert.NilError(t, err) + + // Sequence sent by editors like VS Code on atomic save: tmp create -> target create -> tmp delete + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeCreated, + Uri: "file:///home/projects/TS/p1/src/x.ts.tmp", + }, + { + Type: lsproto.FileChangeTypeCreated, + Uri: "file:///home/projects/TS/p1/src/x.ts", + }, + { + Type: lsproto.FileChangeTypeDeleted, + Uri: "file:///home/projects/TS/p1/src/x.ts.tmp", + }, + }) + + lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + assert.Check(t, lsAfter.GetProgram() != programBefore) + srcX := lsAfter.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") + assert.Assert(t, srcX != nil) + assert.Equal(t, srcX.Text(), "export const x = 42;") + }) + t.Run("change program file not in tsconfig root files", func(t *testing.T) { t.Parallel() for _, workspaceDir := range []string{"/", "/home/projects/TS/p1", "/somewhere/else/entirely"} { diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 4203b49cc47ad..70bcec79efb12 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -145,6 +145,8 @@ func (s *Snapshot) processFileChanges( previousOpenFiles := overlayFileHandles(previousOverlays) openFiles := overlayFileHandles(overlays) if fileChanges.HasExcessiveWatchEvents() { + fileChanges = fs.expandCreatedCacheEntries(fileChanges) + fileChanges = s.fs.expandRealpathAliases(fileChanges) invalidateStart := time.Now() if fileChanges.InvalidateAll { fs.invalidateCache() @@ -178,6 +180,7 @@ func (s *Snapshot) processFileChanges( } _, contentMapperWatchedFiles := s.contentMapperWatchState() fileChanges = fs.expandAndFilterWatchEvents(fileChanges, contentMapperExtensions, contentMapperWatchedFiles, previousOpenFiles, openFiles) + fileChanges = fs.expandCreatedCacheEntries(fileChanges) fileChanges = s.fs.expandRealpathAliases(fileChanges) fileChanges = fs.markDirtyFiles(fileChanges) fileChanges = fs.convertOpenAndCloseToChanges(fileChanges, previousOpenFiles, openFiles) diff --git a/tsc/internal/project/snapshotfs.go b/tsc/internal/project/snapshotfs.go index 3e95fb3709231..65f6aa7ce699e 100644 --- a/tsc/internal/project/snapshotfs.go +++ b/tsc/internal/project/snapshotfs.go @@ -464,31 +464,38 @@ func (s *snapshotFSBuilder) reloadEntryIfNeeded(entry *dirty.SyncMapEntry[tspath } func (s *snapshotFSBuilder) watchChangesOverlapCache(change FileChangeSummary, previousOpenFiles map[tspath.PathKey]FileHandle, openFiles map[tspath.PathKey]FileHandle) bool { - for uri := range change.Changed.Keys() { - path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) - if previousOpenFiles[path] != nil || openFiles[path] != nil { - return true - } - if _, ok := s.cacheFiles.Load(path); ok { - return true - } - if _, ok := s.nodeModulesRealpathAliases.Load(path); ok { - return true + for _, changes := range []collections.Set[lsproto.DocumentUri]{change.Changed, change.Created, change.Deleted} { + for uri := range changes.Keys() { + path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) + if previousOpenFiles[path] != nil || openFiles[path] != nil { + return true + } + if _, ok := s.cacheFiles.Load(path); ok { + return true + } + if _, ok := s.nodeModulesRealpathAliases.Load(path); ok { + return true + } } } - for uri := range change.Deleted.Keys() { + return false +} + +func (s *snapshotFSBuilder) expandCreatedCacheEntries(change FileChangeSummary) FileChangeSummary { + if change.Created.Len() == 0 { + return change + } + createdDirectories := collections.NewSetWithSizeHint[tspath.PathKey](change.Created.Len()) + for uri := range change.Created.Keys() { path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) - if previousOpenFiles[path] != nil || openFiles[path] != nil { - return true - } - if _, ok := s.cacheFiles.Load(path); ok { - return true - } - if _, ok := s.nodeModulesRealpathAliases.Load(path); ok { - return true + if _, ok := s.cacheDirectories.Get(path); ok { + createdDirectories.Add(path) } } - return false + for path := range createdDirectories.Keys() { + s.collectFilesRecursive(path, &change.Created, nil, nil) + } + return change } func (s *snapshotFSBuilder) invalidateCache() { @@ -512,12 +519,23 @@ func (s *snapshotFSBuilder) invalidateNodeModulesCache() { } func (s *snapshotFSBuilder) markDirtyFiles(change FileChangeSummary) FileChangeSummary { - if change.Changed.Len() > 0 { + filterChanges := func(changes collections.Set[lsproto.DocumentUri], created bool) collections.Set[lsproto.DocumentUri] { + if changes.Len() == 0 { + return changes + } var filteredChanged collections.SyncSet[lsproto.DocumentUri] wg := core.NewWorkGroup(false) - for uri := range change.Changed.Keys() { + for uri := range changes.Keys() { path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) - if file := s.fs.GetFileByPath(uri.FileName(), path); file != nil && file.IsOverlay() { + if created { + // Cached Created paths represent replacements of files we already knew about. + // If the disk content is unchanged, registries already contain the path and + // there is no filesystem change to propagate. Open overlays remain authoritative, + // so a Created event cannot change their active contents either. + if s.fs.Overlays()[path] != nil { + continue + } + } else if file := s.fs.GetFileByPath(uri.FileName(), path); file != nil && file.IsOverlay() { filteredChanged.Add(uri) continue } @@ -533,12 +551,16 @@ func (s *snapshotFSBuilder) markDirtyFiles(change FileChangeSummary) FileChangeS }) } wg.RunAndWait() - newChanged := collections.NewSetWithSizeHint[lsproto.DocumentUri](filteredChanged.Size()) + newSet := collections.NewSetWithSizeHint[lsproto.DocumentUri](filteredChanged.Size()) for uri := range filteredChanged.Keys() { - newChanged.Add(uri) + newSet.Add(uri) } - change.Changed = *newChanged + return *newSet } + + change.Changed = filterChanges(change.Changed, false) + change.Created = filterChanges(change.Created, true) + for uri := range change.Deleted.Keys() { path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) if entry, ok := s.cacheFiles.Load(path); ok { @@ -582,7 +604,7 @@ func (s *snapshotFSBuilder) reloadEntryIfContentChanged(entry *dirty.SyncMapEntr return changed } -// expandRealpathAliases adds synthetic URIs to the Changed and Deleted sets for +// expandRealpathAliases adds synthetic URIs to the Changed, Created, and Deleted sets for // files that were accessed through node_modules symlinks. When a watch event arrives // using a realpath, this expands it to include the symlink-based path so that // downstream consumers (markDirtyFiles, markFilesChanged) can find cached entries. @@ -591,31 +613,50 @@ func (s *SnapshotFS) expandRealpathAliases(change FileChangeSummary) FileChangeS return change } - var additionalChanged collections.Set[lsproto.DocumentUri] - for uri := range change.Changed.Keys() { - path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) - if aliases, ok := s.nodeModulesRealpathAliases[path]; ok { - for _, aliasFileName := range aliases.paths { - additionalChanged.Add(lsconv.FileNameToDocumentURI(aliasFileName)) + expandSet := func(set *collections.Set[lsproto.DocumentUri]) { + if set.Len() == 0 { + return + } + var additional collections.Set[lsproto.DocumentUri] + for uri := range set.Keys() { + path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) + if aliases, ok := s.nodeModulesRealpathAliases[path]; ok { + for _, aliasFileName := range aliases.paths { + additional.Add(lsconv.FileNameToDocumentURI(aliasFileName)) + } } } - } - for uri := range additionalChanged.Keys() { - change.Changed.Add(uri) + for uri := range additional.Keys() { + set.Add(uri) + } } - var additionalDeleted collections.Set[lsproto.DocumentUri] - for uri := range change.Deleted.Keys() { - path := s.caseSensitivity.PathKey(uri.FileName().AsPath()) - if aliases, ok := s.nodeModulesRealpathAliases[path]; ok { - for _, aliasFileName := range aliases.paths { - additionalDeleted.Add(lsconv.FileNameToDocumentURI(aliasFileName)) + expandSet(&change.Changed) + expandSet(&change.Created) + expandSet(&change.Deleted) + + if change.Created.Len() > 0 { + createdDirectories := collections.NewSetWithSizeHint[tspath.PathKey](change.Created.Len()) + for uri := range change.Created.Keys() { + createdDirectories.Add(s.caseSensitivity.PathKey(uri.FileName().AsPath())) + } + for realpath, aliases := range s.nodeModulesRealpathAliases { + path := realpath + for { + if createdDirectories.Has(path) { + for _, aliasFileName := range aliases.paths { + change.Created.Add(lsconv.FileNameToDocumentURI(aliasFileName)) + } + break + } + parent := path.Parent() + if parent == path { + break + } + path = parent } } } - for uri := range additionalDeleted.Keys() { - change.Deleted.Add(uri) - } return change } diff --git a/tsc/internal/project/snapshotfs_test.go b/tsc/internal/project/snapshotfs_test.go index ce087bed9d466..0910c865f13bd 100644 --- a/tsc/internal/project/snapshotfs_test.go +++ b/tsc/internal/project/snapshotfs_test.go @@ -2,6 +2,7 @@ package project import ( "slices" + "strconv" "sync" "testing" @@ -1218,6 +1219,55 @@ func TestRealpathAliasLifecycle(t *testing.T) { assert.Assert(t, expanded.Deleted.Has("file:///project/node_modules/mylib/package.json"), "symlink deletion should be added") }) + t.Run("directory delete invalidates cached realpath and alias", func(t *testing.T) { + t.Parallel() + realpath := tspath.PathKey("/packages/mylib/package.json") + aliasPath := tspath.PathKey("/project/node_modules/mylib/package.json") + aliases := map[tspath.PathKey]*realpathAliasSet{ + realpath: { + paths: aliasPaths{ + aliasPath: tspath.RootedFilePath("/project/node_modules/mylib/package.json"), + }, + }, + } + snapshotFS := &SnapshotFS{ + caseSensitivity: tspath.CaseInsensitive, + cacheFiles: map[tspath.PathKey]*cachedFile{ + realpath: newCachedFile(tspath.RootedFilePath("/packages/mylib/package.json"), `{"name":"mylib"}`), + aliasPath: newCachedFile(tspath.RootedFilePath("/project/node_modules/mylib/package.json"), `{"name":"mylib"}`), + }, + cacheDirectories: map[tspath.PathKey]dirty.CloneableMap[tspath.PathKey, string]{ + tspath.PathKey("/packages/mylib"): {realpath: "package.json"}, + }, + nodeModulesRealpathAliases: aliases, + } + builder := newSnapshotFSBuilder( + vfstest.FromMap(map[string]string{}, tspath.CaseInsensitive), + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*Overlay), + snapshotFS.cacheFiles, + snapshotFS.cacheDirectories, + aliases, + lsproto.PositionEncodingKindUTF16, + ) + snapshot := &Snapshot{ + fs: snapshotFS, + ProjectCollection: &ProjectCollection{}, + ConfigFileRegistry: &ConfigFileRegistry{}, + } + change := FileChangeSummary{} + change.Deleted.Add("file:///packages/mylib") + + change = snapshot.processFileChanges(builder, change, nil, nil, nil, nil) + + assert.Assert(t, change.Deleted.Has("file:///packages/mylib/package.json")) + assert.Assert(t, change.Deleted.Has("file:///project/node_modules/mylib/package.json")) + _, realpathCached := builder.cacheFiles.Load(realpath) + _, aliasCached := builder.cacheFiles.Load(aliasPath) + assert.Assert(t, !realpathCached) + assert.Assert(t, !aliasCached) + }) + t.Run("expandRealpathAliases is a no-op with no aliases", func(t *testing.T) { t.Parallel() snapshot := &SnapshotFS{ @@ -1233,6 +1283,57 @@ func TestRealpathAliasLifecycle(t *testing.T) { assert.Assert(t, expanded.Changed.Has("file:///some/file.ts")) }) + t.Run("created cache expansion does not change excessive event classification", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/project/node_modules/pkg": vfstest.Symlink("/packages/pkg"), + "/project/src/index.ts": "export {}", + } + for i := range 1001 { + files["/packages/pkg/file"+strconv.Itoa(i)+".ts"] = "export {}" + } + testFS := vfstest.FromMap(files, tspath.CaseSensitive) + builder1 := newSnapshotFSBuilder( + testFS, + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*cachedFile), + make(map[tspath.PathKey]dirty.CloneableMap[tspath.PathKey, string]), + nil, + lsproto.PositionEncodingKindUTF16, + ) + for i := range 1001 { + assert.Assert(t, builder1.GetFile(tspath.RootedFilePathFromAbsolute("/project/node_modules/pkg/file"+strconv.Itoa(i)+".ts")) != nil) + } + assert.Assert(t, builder1.GetFile("/project/src/index.ts") != nil) + snapshot1, _ := builder1.Finalize() + + assert.NilError(t, testFS.Remove("/packages/pkg")) + assert.NilError(t, testFS.WriteFile("/packages/pkg", "replacement file")) + builder2 := newSnapshotFSBuilder( + testFS, + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*Overlay), + snapshot1.cacheFiles, + snapshot1.cacheDirectories, + snapshot1.nodeModulesRealpathAliases, + lsproto.PositionEncodingKindUTF16, + ) + snapshot := &Snapshot{ + fs: snapshot1, + ProjectCollection: &ProjectCollection{}, + ConfigFileRegistry: &ConfigFileRegistry{}, + } + change := FileChangeSummary{IncludesWatchChangeOutsideNodeModules: true} + change.Created.Add("file:///packages/pkg") + + change = snapshot.processFileChanges(builder2, change, nil, nil, nil, nil) + + assert.Assert(t, change.HasExcessiveWatchEvents()) + _, unrelatedCached := builder2.cacheFiles.Load(tspath.PathKey("/project/src/index.ts")) + assert.Assert(t, unrelatedCached, "the original event batch should use the normal path") + }) + t.Run("markDirtyFiles invalidates symlinked file via realpath event", func(t *testing.T) { t.Parallel() testFS := vfstest.FromMap(map[string]any{ @@ -1291,6 +1392,132 @@ func TestRealpathAliasLifecycle(t *testing.T) { assert.Equal(t, file.Content(), `{"name": "mylib"}`, "content should be updated") }) + t.Run("created realpath events reload cached symlink alias", func(t *testing.T) { + t.Parallel() + testFS := vfstest.FromMap(map[string]any{ + "/project/node_modules/mylib": vfstest.Symlink("/packages/mylib"), + "/packages/mylib/package.json": `{"name": "mylib", "main": "index.js"}`, + }, tspath.CaseInsensitive) + + builder1 := newSnapshotFSBuilder( + testFS, + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*cachedFile), + make(map[tspath.PathKey]dirty.CloneableMap[tspath.PathKey, string]), + nil, + lsproto.PositionEncodingKindUTF16, + ) + fh := builder1.GetFile("/project/node_modules/mylib/package.json") + assert.Assert(t, fh != nil) + snapshot1, _ := builder1.Finalize() + + err := testFS.WriteFile("/packages/mylib/package.json", `{"name": "mylib"}`) + assert.NilError(t, err) + + builder2 := newSnapshotFSBuilder( + testFS, + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*Overlay), + snapshot1.cacheFiles, + snapshot1.cacheDirectories, + snapshot1.nodeModulesRealpathAliases, + lsproto.PositionEncodingKindUTF16, + ) + change := FileChangeSummary{} + change.Created.Add("file:///packages/mylib") + + change = snapshot1.expandRealpathAliases(change) + assert.Assert(t, change.Created.Has("file:///project/node_modules/mylib/package.json")) + change = builder2.markDirtyFiles(change) + assert.Assert(t, change.Created.Has("file:///project/node_modules/mylib/package.json")) + + fh = builder2.GetFile("/project/node_modules/mylib/package.json") + assert.Assert(t, fh != nil) + assert.Equal(t, fh.Content(), `{"name": "mylib"}`) + + snapshot2, _ := builder2.Finalize() + err = testFS.WriteFile("/packages/mylib/package.json", `{"name": "updated"}`) + assert.NilError(t, err) + + builder3 := newSnapshotFSBuilder( + testFS, + make(map[tspath.PathKey]*Overlay), + make(map[tspath.PathKey]*Overlay), + snapshot2.cacheFiles, + snapshot2.cacheDirectories, + snapshot2.nodeModulesRealpathAliases, + lsproto.PositionEncodingKindUTF16, + ) + excessiveChange := FileChangeSummary{IncludesWatchChangeOutsideNodeModules: true} + for i := range 1001 { + excessiveChange.Created.Add(lsproto.DocumentUri("file:///generated/file" + strconv.Itoa(i) + ".ts")) + } + excessiveChange.Created.Add("file:///packages/mylib") + assert.Assert(t, excessiveChange.HasExcessiveWatchEvents()) + excessiveChange = snapshot2.expandRealpathAliases(excessiveChange) + assert.Assert(t, excessiveChange.Created.Has("file:///project/node_modules/mylib/package.json")) + assert.Assert(t, builder3.watchChangesOverlapCache(excessiveChange, nil, nil)) + + builder3.invalidateCache() + fh = builder3.GetFile("/project/node_modules/mylib/package.json") + assert.Assert(t, fh != nil) + assert.Equal(t, fh.Content(), `{"name": "updated"}`) + }) + + t.Run("markDirtyFiles invalidates existing cached file on created event", func(t *testing.T) { + t.Parallel() + testFS := vfstest.FromMap(map[string]string{ + "/project/src/helper.ts": `export const other = 1;`, + }, tspath.CaseSensitive) + + // Build first snapshot — read the file. + builder1 := newSnapshotFSBuilder( + testFS, + nil, + nil, + nil, + nil, + nil, + lsproto.PositionEncodingKindUTF16, + ) + fh := builder1.GetFile("/project/src/helper.ts") + assert.Assert(t, fh != nil) + assert.Equal(t, fh.Content(), `export const other = 1;`) + snapshot1, _ := builder1.Finalize() + + // Modify the file on disk (e.g. via atomic save rename). + err := testFS.WriteFile("/project/src/helper.ts", `export const thing = 1;`) + assert.NilError(t, err) + + // Build second snapshot — simulate Created event. + builder2 := newSnapshotFSBuilder( + testFS, + nil, + nil, + snapshot1.cacheFiles, + snapshot1.cacheDirectories, + snapshot1.nodeModulesRealpathAliases, + lsproto.PositionEncodingKindUTF16, + ) + + change := FileChangeSummary{} + change.Created.Add("file:///project/src/helper.ts") + + change = builder2.markDirtyFiles(change) + assert.Assert(t, change.Created.Has("file:///project/src/helper.ts")) + + // Reading the file should return the updated content. + fh = builder2.GetFile("/project/src/helper.ts") + assert.Assert(t, fh != nil) + assert.Equal(t, fh.Content(), `export const thing = 1;`) + + snapshot2, _ := builder2.Finalize() + file, ok := snapshot2.cacheFiles[tspath.PathKey("/project/src/helper.ts")] + assert.Assert(t, ok) + assert.Equal(t, file.Content(), `export const thing = 1;`) + }) + t.Run("alias clone isolation between snapshots", func(t *testing.T) { t.Parallel() testFS := vfstest.FromMap(map[string]any{