From 28837180a968cc1266fb1b715a08696abf0eb680 Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:10:53 -0500 Subject: [PATCH 1/5] performance upgrades for watcher --- internal/execute/watcher.go | 99 ++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 9b11c2a4dd..81b96ac4c8 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "reflect" + "slices" "time" "github.com/microsoft/typescript-go/internal/ast" @@ -73,9 +74,10 @@ type Watcher struct { sourceFileCache *collections.SyncMap[tspath.Path, *cachedSourceFile] - wm *watchmanager.WatchManager - seenFiles *collections.Set[tspath.Path] // all build dependencies (for event filtering) - configMtimes map[string]time.Time + wm *watchmanager.WatchManager + seenFiles *collections.Set[tspath.Path] // all build dependencies (for event filtering) + configMtimes map[string]time.Time + watchSetDirty bool } var _ tsc.Watcher = (*Watcher)(nil) @@ -130,6 +132,7 @@ func (w *Watcher) start(ctx context.Context) { } w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Starting_compilation_in_watch_mode)) + w.watchSetDirty = true if err := w.doBuild(); err != nil { w.wm.ForceOverflow() } @@ -225,6 +228,21 @@ func (w *Watcher) DoCycle() { // Filter fswatch events against known dependencies if w.isRelevantChange(changedPaths) { w.evictChangedSourceFiles(changedPaths) + for eventPath := range changedPaths { + if w.sys.FS().DirectoryExists(eventPath) { + w.watchSetDirty = true + break + } + if w.config.ConfigFile != nil && w.config.PossiblyMatchesFileName(eventPath) { + caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() + cwd := w.sys.GetCurrentDirectory() + p := tspath.ToPath(eventPath, cwd, caseSensitive) + if !w.seenFiles.Has(p) { + w.watchSetDirty = true + break + } + } + } } else { if w.wm.DebugLog != nil { fmt.Fprintf(w.wm.DebugLog, "[watch] DoCycle: %d event(s) not relevant to compilation, skipping rebuild\n", len(changedPaths)) @@ -237,6 +255,7 @@ func (w *Watcher) DoCycle() { } else if overflow { // Overflow: evict the entire source file cache to force re-build w.sourceFileCache = &collections.SyncMap[tspath.Path, *cachedSourceFile]{} + w.watchSetDirty = true } else if !hasEvents && !w.configModified { // No events and no config change if w.wm.DebugLog != nil { @@ -282,6 +301,48 @@ func (w *Watcher) isRelevantChange(changedPaths map[string]fswatch.EventKind) bo func (w *Watcher) doBuild() error { if w.configModified { w.sourceFileCache = &collections.SyncMap[tspath.Path, *cachedSourceFile]{} + w.watchSetDirty = true + } + + if w.watchSetDirty { + if w.config.ConfigFile != nil && len(w.config.WildcardDirectories()) > 0 { + newConfig := w.config.ReloadFileNamesOfParsedCommandLine(w.sys.FS()) + if !slices.Equal(w.config.FileNames(), newConfig.FileNames()) { + w.watchSetDirty = true + } + w.config = newConfig + } + } + + if w.program != nil && !w.configModified && !w.watchSetDirty { + cached := cachedvfs.From(w.sys.FS()) + innerHost := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), cached, w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) + host := &watchCompilerHost{CompilerHost: innerHost, cache: w.sourceFileCache} + + if w.tryUpdateProgram(host) { + result := w.compileAndEmit() + cached.DisableAndClearCache() + + w.configMtimes = make(map[string]time.Time, len(w.configFilePaths)) + for _, cfgPath := range w.configFilePaths { + if s := w.sys.FS().Stat(cfgPath); s != nil { + w.configMtimes[cfgPath] = s.ModTime() + } + } + w.configModified = false + + errorCount := len(result.Diagnostics) + if errorCount == 1 { + w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Found_1_error_Watching_for_file_changes)) + } else { + w.reportWatchStatus(ast.NewCompilerDiagnostic(diagnostics.Found_0_errors_Watching_for_file_changes, errorCount)) + } + if w.testing != nil { + w.testing.OnProgram(w.program) + } + return nil + } + cached.DisableAndClearCache() } cached := cachedvfs.From(w.sys.FS()) @@ -289,13 +350,11 @@ func (w *Watcher) doBuild() error { innerHost := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), tfs, w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) host := &watchCompilerHost{CompilerHost: innerHost, cache: w.sourceFileCache} - var wildcardDirs map[string]bool if w.config.ConfigFile != nil { - wildcardDirs = w.config.WildcardDirectories() - for dir := range wildcardDirs { + for dir := range w.config.WildcardDirectories() { tfs.SeenFiles.Add(dir) } - if len(wildcardDirs) > 0 { + if !w.watchSetDirty && len(w.config.WildcardDirectories()) > 0 { w.config = w.config.ReloadFileNamesOfParsedCommandLine(w.sys.FS()) } } @@ -330,6 +389,7 @@ func (w *Watcher) doBuild() error { fmt.Fprintf(w.sys.Writer(), "%v\n", err) return err } + w.watchSetDirty = false w.configModified = false programFiles := w.program.GetProgram().FilesByPath() @@ -353,6 +413,31 @@ func (w *Watcher) doBuild() error { return nil } +func (w *Watcher) tryUpdateProgram(host *watchCompilerHost) bool { + oldProgram := w.program.GetProgram() + + var changedPath tspath.Path + var changedCount int + for path := range oldProgram.FilesByPath() { + if _, ok := w.sourceFileCache.Load(path); !ok { + changedPath = path + changedCount++ + if changedCount > 1 { + return false + } + } + } + if changedCount == 0 { + return false + } + + newProgram, _, reused := oldProgram.UpdateProgram(changedPath, host, nil) + if reused { + w.program = incremental.NewProgram(newProgram, w.program, nil, w.testing != nil) + } + return reused +} + func (w *Watcher) evictChangedSourceFiles(changedPaths map[string]fswatch.EventKind) { caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() cwd := w.sys.GetCurrentDirectory() From b3d2ec39ad01597a5d87d258109028d4c893cff6 Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:44:06 -0500 Subject: [PATCH 2/5] addressed copilot feedback --- .../execute/tsctests/watcher_race_test.go | 44 +++++++++++++++++++ internal/execute/watcher.go | 8 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/internal/execute/tsctests/watcher_race_test.go b/internal/execute/tsctests/watcher_race_test.go index 691b2605b6..91e12ca6f5 100644 --- a/internal/execute/tsctests/watcher_race_test.go +++ b/internal/execute/tsctests/watcher_race_test.go @@ -290,3 +290,47 @@ func TestBuildWatchStopsWhenContextIsCancelled(t *testing.T) { t.Fatal("build watch did not stop after context cancellation") } } + +// TestWatcherUpdateProgramFastPath verifies that the UpdateProgram optimization +// produces correct compilation results for body-only edits and correctly falls +// back to full NewProgram when imports change. +func TestWatcherUpdateProgramFastPath(t *testing.T) { + t.Parallel() + w, sys := createTestWatcher(t) + + _ = sys.fsFromFileMap().WriteFile( + "/home/src/workspaces/project/a.ts", + `const a: number = 2;`, + ) + w.DoCycle() + + _ = sys.fsFromFileMap().WriteFile( + "/home/src/workspaces/project/a.ts", + `const a: number = "not a number";`, + ) + w.DoCycle() + + _ = sys.fsFromFileMap().WriteFile( + "/home/src/workspaces/project/a.ts", + `const a: number = 3;`, + ) + w.DoCycle() + + _ = sys.fsFromFileMap().WriteFile( + "/home/src/workspaces/project/a.ts", + `export const a: number = 3; export const c: number = 4;`, + ) + w.DoCycle() + + _ = sys.fsFromFileMap().WriteFile( + "/home/src/workspaces/project/b.ts", + `import { a, c } from "./a"; export const b = a + c;`, + ) + w.DoCycle() + + _ = sys.fsFromFileMap().WriteFile( + "/home/src/workspaces/project/b.ts", + `import { a, c } from "./a"; export const b = a + c + 1;`, + ) + w.DoCycle() +} diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 81b96ac4c8..677078a507 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -308,9 +308,13 @@ func (w *Watcher) doBuild() error { if w.config.ConfigFile != nil && len(w.config.WildcardDirectories()) > 0 { newConfig := w.config.ReloadFileNamesOfParsedCommandLine(w.sys.FS()) if !slices.Equal(w.config.FileNames(), newConfig.FileNames()) { - w.watchSetDirty = true + w.config = newConfig + } else { + w.watchSetDirty = false + w.config = newConfig } - w.config = newConfig + } else if !w.configModified { + w.watchSetDirty = false } } From efe994985e7fbd03c44a2cabee05c75c277d9a0b Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:35:54 -0500 Subject: [PATCH 3/5] addressed feedback --- .../execute/tsctests/watcher_race_test.go | 91 +++++++++++-------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/internal/execute/tsctests/watcher_race_test.go b/internal/execute/tsctests/watcher_race_test.go index 91e12ca6f5..31208e6e0c 100644 --- a/internal/execute/tsctests/watcher_race_test.go +++ b/internal/execute/tsctests/watcher_race_test.go @@ -3,12 +3,14 @@ package tsctests import ( "context" "fmt" + "strings" "sync" "testing" "time" "github.com/microsoft/typescript-go/internal/execute" "github.com/microsoft/typescript-go/internal/execute/tsc" + "github.com/microsoft/typescript-go/internal/fswatch" "gotest.tools/v3/assert" ) @@ -292,45 +294,58 @@ func TestBuildWatchStopsWhenContextIsCancelled(t *testing.T) { } // TestWatcherUpdateProgramFastPath verifies that the UpdateProgram optimization -// produces correct compilation results for body-only edits and correctly falls -// back to full NewProgram when imports change. +// produces correct compilation results for body-only edits (fast path) and +// correctly falls back to full NewProgram when imports change. func TestWatcherUpdateProgramFastPath(t *testing.T) { t.Parallel() - w, sys := createTestWatcher(t) - _ = sys.fsFromFileMap().WriteFile( - "/home/src/workspaces/project/a.ts", - `const a: number = 2;`, - ) - w.DoCycle() - - _ = sys.fsFromFileMap().WriteFile( - "/home/src/workspaces/project/a.ts", - `const a: number = "not a number";`, - ) - w.DoCycle() - - _ = sys.fsFromFileMap().WriteFile( - "/home/src/workspaces/project/a.ts", - `const a: number = 3;`, - ) - w.DoCycle() - - _ = sys.fsFromFileMap().WriteFile( - "/home/src/workspaces/project/a.ts", - `export const a: number = 3; export const c: number = 4;`, - ) - w.DoCycle() - - _ = sys.fsFromFileMap().WriteFile( - "/home/src/workspaces/project/b.ts", - `import { a, c } from "./a"; export const b = a + c;`, - ) - w.DoCycle() - - _ = sys.fsFromFileMap().WriteFile( - "/home/src/workspaces/project/b.ts", - `import { a, c } from "./a"; export const b = a + c + 1;`, - ) - w.DoCycle() + input := &tscInput{ + files: FileMap{ + "/home/src/workspaces/project/a.ts": `export const a: number = 1;`, + "/home/src/workspaces/project/b.ts": `import { a } from "./a"; export const b = a;`, + "/home/src/workspaces/project/tsconfig.json": `{}`, + }, + commandLineArgs: []string{"--watch"}, + } + sys := newTestSys(input, false) + result := execute.CommandLine(context.Background(), sys, []string{"--watch"}, sys) + if result.Watcher == nil { + t.Fatal("expected Watcher to be non-nil in watch mode") + } + w := result.Watcher.(*execute.Watcher) + + // Helper to write a file, send the event, cycle, and return output + editAndCycle := func(path, content string) string { + sys.currentWrite.Reset() + _ = sys.fsFromFileMap().WriteFile(path, content) + sys.mockWatchBackend.SendEvents([]fswatch.Event{ + {Kind: fswatch.EventUpdate, Path: path}, + }) + w.DoCycle() + return sys.currentWrite.String() + } + + // Body-only edit — should use UpdateProgram fast path, no errors + out := editAndCycle("/home/src/workspaces/project/a.ts", `export const a: number = 2;`) + assert.Assert(t, strings.Contains(out, "Found 0 errors"), "expected 0 errors after body edit, got: %s", out) + + // Introduce a type error via body-only edit — fast path should detect it + out = editAndCycle("/home/src/workspaces/project/a.ts", `export const a: number = "not a number";`) + assert.Assert(t, !strings.Contains(out, "Found 0 errors"), "expected errors after type error, got: %s", out) + + // Fix the type error — fast path should clear it + out = editAndCycle("/home/src/workspaces/project/a.ts", `export const a: number = 3;`) + assert.Assert(t, strings.Contains(out, "Found 0 errors"), "expected 0 errors after fix, got: %s", out) + + // Add a new export — import structure changes, falls back to NewProgram + out = editAndCycle("/home/src/workspaces/project/a.ts", `export const a: number = 3; export const c: number = 4;`) + assert.Assert(t, strings.Contains(out, "Found 0 errors"), "expected 0 errors after export addition, got: %s", out) + + // Import the new export — import change forces full rebuild + out = editAndCycle("/home/src/workspaces/project/b.ts", `import { a, c } from "./a"; export const b = a + c;`) + assert.Assert(t, strings.Contains(out, "Found 0 errors"), "expected 0 errors after import change, got: %s", out) + + // Body edit after import change — should use fast path again, no errors + out = editAndCycle("/home/src/workspaces/project/b.ts", `import { a, c } from "./a"; export const b = a + c + 1;`) + assert.Assert(t, strings.Contains(out, "Found 0 errors"), "expected 0 errors after body edit post-import-change, got: %s", out) } From e887e92ee3b660cae72da74fa5738fad1caaed79 Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:12:03 -0500 Subject: [PATCH 4/5] fixed jsx imports and tryupdateprogram with existing readbuildinfoprogram --- .../execute/tsctests/watcher_race_test.go | 58 +++++++++++++++++++ internal/execute/watcher.go | 24 +++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/execute/tsctests/watcher_race_test.go b/internal/execute/tsctests/watcher_race_test.go index 31208e6e0c..4bbc8e6951 100644 --- a/internal/execute/tsctests/watcher_race_test.go +++ b/internal/execute/tsctests/watcher_race_test.go @@ -293,6 +293,64 @@ func TestBuildWatchStopsWhenContextIsCancelled(t *testing.T) { } } +func TestWatcherStartsFromExistingBuildInfo(t *testing.T) { + t.Parallel() + input := &tscInput{ + files: FileMap{ + "/home/src/workspaces/project/index.ts": `export const x: number = 1;`, + "/home/src/workspaces/project/tsconfig.json": `{"compilerOptions":{"composite":true},"files":["index.ts"]}`, + }, + } + sys := newTestSys(input, false) + + result := execute.CommandLine(context.Background(), sys, []string{"-p", "tsconfig.json", "--pretty", "false"}, sys) + assert.Equal(t, result.Status, tsc.ExitStatusSuccess) + assert.Assert(t, sys.fsFromFileMap().FileExists("/home/src/workspaces/project/tsconfig.tsbuildinfo")) + + sys.clearOutput() + defer func() { + if r := recover(); r != nil { + t.Fatalf("watch startup with existing build info panicked: %v", r) + } + }() + result = execute.CommandLine(context.Background(), sys, []string{"--watch", "--noEmit", "--pretty", "false"}, sys) + assert.Equal(t, result.Status, tsc.ExitStatusSuccess) + assert.Assert(t, result.Watcher != nil) +} + +func TestWatcherRebuildsWhenJsxImportSourcePragmaChanges(t *testing.T) { + t.Parallel() + input := &tscInput{ + files: FileMap{ + "/home/src/workspaces/project/index.tsx": `/** @jsxImportSource foo */ +export const x =
;`, + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions":{"jsx":"react-jsx","module":"esnext","moduleResolution":"bundler","noEmit":true}, + "files":["index.tsx"] + }`, + }, + commandLineArgs: []string{"--watch"}, + } + sys := newTestSys(input, false) + result := execute.CommandLine(context.Background(), sys, []string{"--watch", "--pretty", "false"}, sys) + if result.Watcher == nil { + t.Fatal("expected Watcher to be non-nil in watch mode") + } + w := result.Watcher.(*execute.Watcher) + + sys.currentWrite.Reset() + _ = sys.fsFromFileMap().WriteFile("/home/src/workspaces/project/index.tsx", `/** @jsxImportSource bar */ +export const x = ;`) + sys.mockWatchBackend.SendEvents([]fswatch.Event{ + {Kind: fswatch.EventUpdate, Path: "/home/src/workspaces/project/index.tsx"}, + }) + w.DoCycle() + + out := sys.currentWrite.String() + assert.Assert(t, strings.Contains(out, "bar/jsx-runtime"), "expected updated JSX runtime diagnostic, got: %s", out) + assert.Assert(t, !strings.Contains(out, "foo/jsx-runtime"), "expected stale JSX runtime diagnostic to be gone, got: %s", out) +} + // TestWatcherUpdateProgramFastPath verifies that the UpdateProgram optimization // produces correct compilation results for body-only edits (fast path) and // correctly falls back to full NewProgram when imports change. diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 677078a507..6a447d6422 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -78,6 +78,7 @@ type Watcher struct { seenFiles *collections.Set[tspath.Path] // all build dependencies (for event filtering) configMtimes map[string]time.Time watchSetDirty bool + programReady bool } var _ tsc.Watcher = (*Watcher)(nil) @@ -318,7 +319,7 @@ func (w *Watcher) doBuild() error { } } - if w.program != nil && !w.configModified && !w.watchSetDirty { + if w.program != nil && w.programReady && !w.configModified && !w.watchSetDirty { cached := cachedvfs.From(w.sys.FS()) innerHost := compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), cached, w.sys.DefaultLibraryPath(), w.extendedConfigCache, getTraceFromSys(w.sys, w.config.Locale(), w.testing)) host := &watchCompilerHost{CompilerHost: innerHost, cache: w.sourceFileCache} @@ -370,6 +371,7 @@ func (w *Watcher) doBuild() error { Config: w.config, Host: host, }), w.program, nil, w.testing != nil) + w.programReady = true result := w.compileAndEmit() cached.DisableAndClearCache() @@ -435,6 +437,14 @@ func (w *Watcher) tryUpdateProgram(host *watchCompilerHost) bool { return false } + if oldFile := oldProgram.FilesByPath()[changedPath]; oldFile != nil { + if newFile := host.GetSourceFile(oldFile.ParseOptions()); newFile != nil { + if !equalJSXImplicitImport(oldProgram.Options(), oldFile, newFile) { + return false + } + } + } + newProgram, _, reused := oldProgram.UpdateProgram(changedPath, host, nil) if reused { w.program = incremental.NewProgram(newProgram, w.program, nil, w.testing != nil) @@ -442,6 +452,18 @@ func (w *Watcher) tryUpdateProgram(host *watchCompilerHost) bool { return reused } +func equalJSXImplicitImport(options *core.CompilerOptions, oldFile *ast.SourceFile, newFile *ast.SourceFile) bool { + isJSX := func(file *ast.SourceFile) bool { + return file.ScriptKind == core.ScriptKindJSX || file.ScriptKind == core.ScriptKindTSX + } + if !isJSX(oldFile) && !isJSX(newFile) { + return true + } + oldImport := ast.GetJSXRuntimeImport(ast.GetJSXImplicitImportBase(options, oldFile), options) + newImport := ast.GetJSXRuntimeImport(ast.GetJSXImplicitImportBase(options, newFile), options) + return oldImport == newImport +} + func (w *Watcher) evictChangedSourceFiles(changedPaths map[string]fswatch.EventKind) { caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() cwd := w.sys.GetCurrentDirectory() From b6d087a2dc39dd29420ec0350b5fd15f935be2ad Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:38:19 -0500 Subject: [PATCH 5/5] fixed format --- internal/execute/watcher.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 6a447d6422..fe09d90cb5 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -78,7 +78,7 @@ type Watcher struct { seenFiles *collections.Set[tspath.Path] // all build dependencies (for event filtering) configMtimes map[string]time.Time watchSetDirty bool - programReady bool + programReady bool } var _ tsc.Watcher = (*Watcher)(nil)