Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Coalesce watch lifecycles before deriving subtree deletions
Expand notification spellings before the existing overlay coalescer,
but defer descendant deletions to matching the coalesced summary.
Keep affected realpath observations independently so canceled events
still refresh bindings. Share the subtree matcher without expanding
projected aliases again.

Use existing compiler path identity for lifecycle coalescing, retaining
the notification spelling. Cover recreation, final deletion, native
normalization variants, insensitive hosts, interleaved saves and edits,
API merges, and canceled-event retargets.
  • Loading branch information
jakebailey committed Sep 9, 2026
commit 3711d85e432ca5ea231cabf2c2630d842c14c7bf
14 changes: 9 additions & 5 deletions tsc/internal/project/overlayfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma

// Reduced collection of changes that occurred on a single file
type fileEvents struct {
uri lsproto.DocumentUri
openChange *FileChange
closeChange *FileChange
watchChanged bool
Expand All @@ -237,22 +238,25 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma
deleted bool
}

fileEventMap := make(map[lsproto.DocumentUri]*fileEvents)
fileEventMap := make(map[tspath.Path]*fileEvents)

for _, change := range changes {
if change.Kind.IsWatchKind() || change.Kind == FileChangeKindSave {
result.hasFileSystemChanges = true
}
uri := change.URI
events, exists := fileEventMap[uri]
path := uri.Path(fs.fs.UseCaseSensitiveFileNames())
events, exists := fileEventMap[path]
if exists {
if events.openChange != nil {
panic("should see no changes after open")
}
} else {
events = &fileEvents{}
fileEventMap[uri] = events
fileEventMap[path] = events
}
// Coalesce compiler-equivalent paths while retaining notification spelling.
events.uri = uri

if !result.IncludesWatchChangeOutsideNodeModules && change.Kind.IsWatchKind() && !strings.Contains(string(uri), "/node_modules/") {
result.IncludesWatchChangeOutsideNodeModules = true
Expand Down Expand Up @@ -309,8 +313,8 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma
}

// Process deduplicated events per file
for uri, events := range fileEventMap {
path := uri.Path(fs.fs.UseCaseSensitiveFileNames())
for path, events := range fileEventMap {
uri := events.uri
o := newOverlays[path]

if events.openChange != nil {
Expand Down
7 changes: 6 additions & 1 deletion tsc/internal/project/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,12 @@ func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto.
URI: change.Uri,
})
}
preview, _, invalidateAll := snapshot.prepareWatchNotifications(fileChanges)
preview, prepared, invalidateAll := snapshot.prepareWatchNotifications(fileChanges)
if prepared != nil {
for _, name := range prepared.affected {
preview = append(preview, FileChange{Kind: FileChangeKindWatchChange, URI: lsconv.FileNameToDocumentURI(name)})
}
}
for _, change := range preview {
kind := change.Kind

Expand Down
13 changes: 10 additions & 3 deletions tsc/internal/project/watchalias.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,19 +233,21 @@ func (s *Snapshot) expandWatchAliases(change FileChangeSummary) FileChangeSummar
}

func (s *Snapshot) matchWatchChanges(change FileChangeSummary) (FileChangeSummary, []string) {
var affected collections.Set[string]
if prepared := change.preparedWatchChanges; prepared != nil {
if prepared.snapshotID != s.id {
panic("watch changes must be prepared for the snapshot being cloned")
}
return change, prepared.affected
for _, name := range prepared.affected {
affected.Add(name)
}
}
if s.watchAliasesError != nil && change.Created.Len()+change.Changed.Len()+change.Deleted.Len() != 0 {
change.InvalidateAll = true
}
if s.watchAliases == nil {
return change, nil
}
var affected collections.Set[string]
expand := func(uris collections.Set[lsproto.DocumentUri], kind fswatch.EventKind) collections.Set[lsproto.DocumentUri] {
if uris.Len() == 0 {
return uris
Expand All @@ -254,7 +256,12 @@ func (s *Snapshot) matchWatchChanges(change FileChangeSummary) (FileChangeSummar
for uri := range uris.Keys() {
events[uri.FileName()] = kind
}
matches := s.watchAliases.Match(events)
var matches watchalias.Matches
if change.preparedWatchChanges != nil {
matches = s.watchAliases.MatchExpanded(events)
} else {
matches = s.watchAliases.Match(events)
}
var result collections.Set[lsproto.DocumentUri]
for name := range matches.Changes {
result.Add(lsconv.FileNameToDocumentURI(name))
Expand Down
37 changes: 37 additions & 0 deletions tsc/internal/project/watchalias_coalescing_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package project

import (
"testing"

"github.com/microsoft/TypeScript/tsc/internal/fswatch"
"github.com/microsoft/TypeScript/tsc/internal/vfs"
"gotest.tools/v3/assert"
)

type lifecycleNativeComparerFS struct {
vfs.FS
comparer fswatch.PathComparer
}

func (fs *lifecycleNativeComparerFS) WatchPathComparer(string) (fswatch.PathComparer, error) {
return fs.comparer, nil
}

func TestWatchDirectoryRecreationNativeSpellings(t *testing.T) {
t.Parallel()
comparer, err := fswatch.PathComparerForPath(t.TempDir())
assert.NilError(t, err)
if comparer.Key("\u00e9") != comparer.Key("e\u0301") {
t.Skip("requires a normalization-insensitive volume")
}
for _, test := range []struct{ name, deleted, created string }{
{"NFC-to-original", "/packages/\u00e9", watchLifecyclePhysical},
{"original-to-NFC", watchLifecyclePhysical, "/packages/\u00e9"},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
fs := &lifecycleNativeComparerFS{FS: watchLifecycleFS(true), comparer: comparer}
checkWatchDirectoryRecreation(t, fs, test.deleted, test.created, nil, false)
})
}
}
189 changes: 189 additions & 0 deletions tsc/internal/project/watchalias_coalescing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package project

import (
"context"
"strings"
"testing"

"github.com/microsoft/TypeScript/tsc/internal/ls/lsconv"
"github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto"
"github.com/microsoft/TypeScript/tsc/internal/vfs"
"github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest"
"gotest.tools/v3/assert"
)

const (
watchLifecycleLogical = "/project/node_modules/pkg/lib"
watchLifecyclePhysical = "/packages/e\u0301"
watchLifecycleMain = `import { value } from "pkg/lib"; export { value };`
watchLifecycleInitial = `export const value: "initial";`
)

func watchLifecycleFS(useCaseSensitiveFileNames bool) vfs.FS {
return vfstest.FromMap(map[string]any{
"/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true,"module":"node16","moduleResolution":"node16"},"files":["main.ts"]}`,
"/project/main.ts": watchLifecycleMain,
watchLifecycleLogical: vfstest.Symlink(watchLifecyclePhysical),
watchLifecyclePhysical + "/index.d.ts": watchLifecycleInitial,
}, useCaseSensitiveFileNames)
}

func checkWatchDirectoryRecreation(t *testing.T, fs vfs.FS, deleted, created string, editor []FileChangeKind, api bool) {
t.Helper()
ctx := context.Background()
session := NewSession(&SessionInit{
BackgroundCtx: ctx, FS: fs, Client: &noopClient{},
Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true},
})
defer session.Close()
const uri = "file:///project/main.ts"
session.DidOpenFile(ctx, uri, 1, watchLifecycleMain, lsproto.LanguageKindTypeScript)
session.WaitForBackgroundTasks()
old := session.Snapshot()
old.ref()
defer old.Deref()
assert.Assert(t, old.GetDefaultProject(uri).Program.GetSourceFile(watchLifecycleLogical+"/index.d.ts") != nil)

func() {
session.snapshotUpdateMu.Lock()
defer session.snapshotUpdateMu.Unlock()
events := []*lsproto.FileEvent{
{Uri: lsconv.FileNameToDocumentURI(deleted), Type: lsproto.FileChangeTypeDeleted},
{Uri: lsconv.FileNameToDocumentURI(created), Type: lsproto.FileChangeTypeCreated},
}
if len(editor) == 0 {
session.DidChangeWatchedFiles(ctx, events)
return
}
session.DidChangeWatchedFiles(ctx, events[:1])
for _, kind := range editor {
if kind == FileChangeKindSave {
saveURI := lsproto.DocumentUri(uri)
if !fs.UseCaseSensitiveFileNames() {
saveURI = lsconv.FileNameToDocumentURI(strings.ToUpper("/project/main.ts"))
}
session.DidSaveFile(ctx, saveURI)
} else {
session.DidChangeFile(ctx, uri, 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{
WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: watchLifecycleMain + "\n"},
}})
}
}
session.DidChangeWatchedFiles(ctx, events[1:])
}()

if api {
var changes FileChangeSummary
changes.Changed.Add(lsconv.FileNameToDocumentURI(watchLifecyclePhysical + "/index.d.ts"))
next, err := session.APIUpdate(ctx, changes, nil)
assert.NilError(t, err)
defer next.Deref()
}
service, err := session.GetLanguageService(ctx, uri)
assert.NilError(t, err)
source := service.GetProgram().GetSourceFile(watchLifecycleLogical + "/index.d.ts")
assert.Assert(t, source != nil, "directory recreation must not tombstone an unchanged declaration")
assert.Equal(t, source.Text(), watchLifecycleInitial)
assert.Equal(t, old.GetDefaultProject(uri).Program.GetSourceFile(watchLifecycleLogical+"/index.d.ts").Text(), watchLifecycleInitial)
if len(editor) != 0 {
overlay := session.Snapshot().fs.overlays[session.toPath("/project/main.ts")]
assert.Equal(t, overlay.Content(), watchLifecycleMain+"\n")
assert.Equal(t, overlay.Version(), int32(2))
assert.Equal(t, overlay.MatchesDiskText(), editor[len(editor)-1] == FileChangeKindSave)
}

assert.NilError(t, fs.WriteFile(watchLifecyclePhysical+"/index.d.ts", `export const value: "updated";`))
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{
Uri: lsconv.FileNameToDocumentURI(watchLifecyclePhysical + "/index.d.ts"), Type: lsproto.FileChangeTypeChanged,
}})
service, err = session.GetLanguageService(ctx, uri)
assert.NilError(t, err)
assert.Equal(t, service.GetProgram().GetSourceFile(watchLifecycleLogical+"/index.d.ts").Text(), `export const value: "updated";`)
}

func TestWatchDirectoryRecreationCoalescesBeforeDeletion(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, deleted, created string
editor []FileChangeKind
caseInsensitive bool
api bool
}{
{name: "physical", deleted: watchLifecyclePhysical, created: watchLifecyclePhysical},
{name: "logical", deleted: watchLifecycleLogical, created: watchLifecycleLogical},
{name: "physical-to-logical", deleted: watchLifecyclePhysical, created: watchLifecycleLogical},
{name: "logical-to-physical", deleted: watchLifecycleLogical, created: watchLifecyclePhysical},
{name: "edit-save", deleted: watchLifecyclePhysical, created: watchLifecycleLogical, editor: []FileChangeKind{FileChangeKindChange, FileChangeKindSave}},
{name: "save-edit", deleted: watchLifecycleLogical, created: watchLifecyclePhysical, editor: []FileChangeKind{FileChangeKindSave, FileChangeKindChange}},
{name: "case-insensitive-spellings", deleted: strings.ToUpper(watchLifecyclePhysical), created: watchLifecyclePhysical, caseInsensitive: true},
{name: "case-insensitive-edit-save", deleted: strings.ToUpper(watchLifecyclePhysical), created: watchLifecyclePhysical, caseInsensitive: true, editor: []FileChangeKind{FileChangeKindChange, FileChangeKindSave}},
{name: "case-insensitive-save-edit", deleted: strings.ToUpper(watchLifecyclePhysical), created: watchLifecyclePhysical, caseInsensitive: true, editor: []FileChangeKind{FileChangeKindSave, FileChangeKindChange}},
{name: "api-merge", deleted: watchLifecyclePhysical, created: watchLifecycleLogical, api: true},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
checkWatchDirectoryRecreation(t, watchLifecycleFS(!test.caseInsensitive), test.deleted, test.created, test.editor, test.api)
})
}
}

func TestWatchDirectoryFinalDeletion(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := watchLifecycleFS(true)
session := NewSession(&SessionInit{
BackgroundCtx: ctx, FS: fs, Client: &noopClient{},
Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true},
})
defer session.Close()
const uri = "file:///project/main.ts"
session.DidOpenFile(ctx, uri, 1, watchLifecycleMain, lsproto.LanguageKindTypeScript)
session.WaitForBackgroundTasks()
assert.NilError(t, fs.Remove(watchLifecyclePhysical+"/index.d.ts"))
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
{Uri: lsconv.FileNameToDocumentURI(watchLifecyclePhysical), Type: lsproto.FileChangeTypeDeleted},
{Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeCreated},
{Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeDeleted},
})
service, err := session.GetLanguageService(ctx, uri)
assert.NilError(t, err)
assert.Assert(t, service.GetProgram().GetSourceFile(watchLifecycleLogical+"/index.d.ts") == nil)
}

func TestWatchDirectoryCanceledEventsRefreshRealpath(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := &countedWatchAliasFS{FS: watchLifecycleFS(true)}
session := NewSession(&SessionInit{
BackgroundCtx: ctx, FS: fs, Client: &noopClient{},
Options: &SessionOptions{CurrentDirectory: "/project", WatchEnabled: true},
})
defer session.Close()
const uri = "file:///project/main.ts"
session.DidOpenFile(ctx, uri, 1, watchLifecycleMain, lsproto.LanguageKindTypeScript)
session.WaitForBackgroundTasks()
const target = "/packages/other/index.d.ts"
fs.FS = vfstest.FromMap(map[string]any{
"/project/tsconfig.json": `{"compilerOptions":{"noLib":true,"types":[],"preserveSymlinks":true,"module":"node16","moduleResolution":"node16"},"files":["main.ts"]}`,
"/project/main.ts": watchLifecycleMain,
watchLifecycleLogical: vfstest.Symlink("/packages/other"),
target: watchLifecycleInitial,
}, true)
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{
{Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeCreated},
{Uri: lsconv.FileNameToDocumentURI(watchLifecycleLogical), Type: lsproto.FileChangeTypeDeleted},
})
next, err := session.APIUpdate(ctx, FileChangeSummary{}, nil)
assert.NilError(t, err)
defer next.Deref()
file := next.fs.diskFiles[session.toPath(watchLifecycleLogical+"/index.d.ts")]
assert.Assert(t, file != nil)
assert.Equal(t, file.realpathName, target, "canceled notifications must still refresh physical observations")
assert.NilError(t, fs.WriteFile(target, `export const value: "retargeted";`))
session.DidChangeWatchedFiles(ctx, []*lsproto.FileEvent{{
Uri: lsconv.FileNameToDocumentURI(target), Type: lsproto.FileChangeTypeChanged,
}})
service, err := session.GetLanguageService(ctx, uri)
assert.NilError(t, err)
assert.Equal(t, service.GetProgram().GetSourceFile(watchLifecycleLogical+"/index.d.ts").Text(), `export const value: "retargeted";`)
}
Loading