From 81c92321d6b0c947f1a6f7d46b7f16cad9238898 Mon Sep 17 00:00:00 2001
From: Jake Bailey <5341706+jakebailey@users.noreply.github.com>
Date: Fri, 19 Jun 2026 14:49:17 -0700
Subject: [PATCH 1/3] Fix watch coalescing for FSEvents
Coalesce dense directory watch sets into recursive ancestor watches on
fast-recursive backends such as FSEvents and Windows. This avoids
opening one stream per dependency directory while still surfacing
startup failures.
---
internal/execute/build/orchestrator.go | 2 +-
.../execute/tsctests/mock_watch_backend.go | 11 ++
internal/execute/tsctests/runner.go | 18 +-
internal/execute/tsctests/sys.go | 2 +
internal/execute/tsctests/tscwatch_test.go | 34 ++++
internal/execute/watcher.go | 2 +-
internal/execute/watchmanager/watchbackend.go | 5 +
internal/execute/watchmanager/watchmanager.go | 101 +++++++++++
...esces-many-dependency-directory-watches.js | 163 ++++++++++++++++++
9 files changed, 328 insertions(+), 10 deletions(-)
create mode 100644 testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js
diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go
index 6afdf077022..4a6c4fd91f0 100644
--- a/internal/execute/build/orchestrator.go
+++ b/internal/execute/build/orchestrator.go
@@ -453,7 +453,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool {
}
}
- return o.wm.ResolveDesiredDirs(desiredDirs)
+ return o.wm.CoalesceDesiredDirs(o.wm.ResolveDesiredDirs(desiredDirs), o.comparePathsOptions)
}
func (o *Orchestrator) DoCycle() {
diff --git a/internal/execute/tsctests/mock_watch_backend.go b/internal/execute/tsctests/mock_watch_backend.go
index eca507a0772..ae91a205af0 100644
--- a/internal/execute/tsctests/mock_watch_backend.go
+++ b/internal/execute/tsctests/mock_watch_backend.go
@@ -22,6 +22,8 @@ type MockWatchBackend struct {
mu sync.Mutex
Dirs map[string]*MockWatch
DirectoryExists func(string) bool // if set, WatchDirectory fails for non-existent dirs
+ Fail func(dir string, recursive bool) error
+ FastRecursive bool
}
var _ watchmanager.WatchBackend = (*MockWatchBackend)(nil)
@@ -60,11 +62,20 @@ func (m *MockWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback,
if m.DirectoryExists != nil && !m.DirectoryExists(dir) {
return nil, fmt.Errorf("directory does not exist: %s", dir)
}
+ if m.Fail != nil {
+ if err := m.Fail(dir, recursive); err != nil {
+ return nil, err
+ }
+ }
w := &MockWatch{Path: dir, Callback: fn, Recursive: recursive, Ignore: ignore}
m.Dirs[dir] = w
return w, nil
}
+func (m *MockWatchBackend) HasFastRecursiveBackend() bool {
+ return m.FastRecursive
+}
+
// SendEvents routes events through the registered watch callbacks
// that match each event's path. Directory watches match if the event
// path is a child (or recursive descendant) of the watched directory.
diff --git a/internal/execute/tsctests/runner.go b/internal/execute/tsctests/runner.go
index 38c7e9c2089..3f798c6e046 100644
--- a/internal/execute/tsctests/runner.go
+++ b/internal/execute/tsctests/runner.go
@@ -31,14 +31,16 @@ var noChangeOnlyEdit = []*tscEdit{
}
type tscInput struct {
- subScenario string
- commandLineArgs []string
- files FileMap
- cwd string
- edits []*tscEdit
- env map[string]string
- ignoreCase bool
- windowsStyleRoot string
+ subScenario string
+ commandLineArgs []string
+ files FileMap
+ cwd string
+ edits []*tscEdit
+ env map[string]string
+ ignoreCase bool
+ windowsStyleRoot string
+ watchBackendFail func(dir string, recursive bool) error
+ watchBackendFastRecursive bool
}
func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult {
diff --git a/internal/execute/tsctests/sys.go b/internal/execute/tsctests/sys.go
index 2d75a36d4ca..32d539f864e 100644
--- a/internal/execute/tsctests/sys.go
+++ b/internal/execute/tsctests/sys.go
@@ -135,6 +135,8 @@ func newTestSys(tscInput *tscInput, forIncrementalCorrectness bool) *TestSys {
sys.forIncrementalCorrectness = forIncrementalCorrectness
sys.mockWatchBackend = NewMockWatchBackend()
sys.mockWatchBackend.DirectoryExists = sys.fs.FS.DirectoryExists
+ sys.mockWatchBackend.Fail = tscInput.watchBackendFail
+ sys.mockWatchBackend.FastRecursive = tscInput.watchBackendFastRecursive
sys.fsDiffer = &fsbaselineutil.FSDiffer{
FS: sys.fs.FS.(iovfs.FsWithSys),
DefaultLibs: func() *collections.SyncSet[string] { return sys.fs.defaultLibs },
diff --git a/internal/execute/tsctests/tscwatch_test.go b/internal/execute/tsctests/tscwatch_test.go
index 7650fcea7e9..c2f8fd7e933 100644
--- a/internal/execute/tsctests/tscwatch_test.go
+++ b/internal/execute/tsctests/tscwatch_test.go
@@ -1,6 +1,8 @@
package tsctests
import (
+ "errors"
+ "fmt"
"strings"
"testing"
@@ -9,6 +11,37 @@ import (
func TestWatch(t *testing.T) {
t.Parallel()
+ bunCoalescingTest := func() *tscInput {
+ files := FileMap{}
+ var index strings.Builder
+ var fileNames strings.Builder
+ fileNames.WriteString(`"index.ts"`)
+ for i := range 12 {
+ name := fmt.Sprintf("pkg%d", i)
+ value := fmt.Sprintf("value%d", i)
+ index.WriteString(fmt.Sprintf(`import { %[1]s } from "./node_modules/.bun/%[2]s/index"; %[1]s;`, value, name))
+ index.WriteString("\n")
+ files["/home/src/workspaces/project/node_modules/.bun/"+name+"/index.ts"] = fmt.Sprintf("export const %s = %d;", value, i)
+ fileNames.WriteString(fmt.Sprintf(`, "node_modules/.bun/%s/index.ts"`, name))
+ }
+ files["/home/src/workspaces/project/index.ts"] = index.String()
+ files["/home/src/workspaces/project/tsconfig.json"] = fmt.Sprintf(`{
+ "compilerOptions": {},
+ "files": [%s]
+}`, fileNames.String())
+ return &tscInput{
+ subScenario: "watch coalesces many dependency directory watches",
+ files: files,
+ commandLineArgs: []string{"--watch"},
+ watchBackendFail: func(dir string, recursive bool) error {
+ if strings.Contains(dir, "/node_modules/.bun/pkg") {
+ return errors.New("error starting FSEvents stream")
+ }
+ return nil
+ },
+ watchBackendFastRecursive: true,
+ }
+ }
testCases := []*tscInput{
{
subScenario: "watch with no tsconfig",
@@ -25,6 +58,7 @@ func TestWatch(t *testing.T) {
},
commandLineArgs: []string{"--watch", "--incremental"},
},
+ bunCoalescingTest(),
{
subScenario: "watch skips build when no files change",
files: FileMap{
diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go
index 9b11c2a4dd4..cafe205cd3d 100644
--- a/internal/execute/watcher.go
+++ b/internal/execute/watcher.go
@@ -195,7 +195,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool
}
// Re-resolve in case newly added dirs don't exist
- return w.wm.ResolveDesiredDirs(resolvedDirs)
+ return w.wm.CoalesceDesiredDirs(w.wm.ResolveDesiredDirs(resolvedDirs), opts)
}
func (w *Watcher) reconcileWatches(seenFilePaths []string) error {
diff --git a/internal/execute/watchmanager/watchbackend.go b/internal/execute/watchmanager/watchbackend.go
index 64551574cb7..63d5da40b84 100644
--- a/internal/execute/watchmanager/watchbackend.go
+++ b/internal/execute/watchmanager/watchbackend.go
@@ -11,6 +11,7 @@ import (
// WatchBackend abstracts fswatch.Watcher for testing
type WatchBackend interface {
WatchDirectory(dir string, fn fswatch.WatchCallback, recursive bool, ignore func(string) bool) (io.Closer, error)
+ HasFastRecursiveBackend() bool
}
// CommandLineTestingWithWatchBackend is an optional extension of
@@ -32,6 +33,10 @@ func (b *FSWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, re
return b.Inner.WatchDirectory(dir, fn, opts...)
}
+func (b *FSWatchBackend) HasFastRecursiveBackend() bool {
+ return b.Inner.HasFastRecursiveBackend()
+}
+
func ShouldIgnoreWatchPath(path string) bool {
p := tspath.NormalizeSlashes(path)
return strings.HasSuffix(p, "/.git") ||
diff --git a/internal/execute/watchmanager/watchmanager.go b/internal/execute/watchmanager/watchmanager.go
index b5fe9a89bab..5cdcfdcc64c 100644
--- a/internal/execute/watchmanager/watchmanager.go
+++ b/internal/execute/watchmanager/watchmanager.go
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"io"
+ "maps"
+ "slices"
"sync"
"github.com/microsoft/typescript-go/internal/core"
@@ -42,6 +44,8 @@ type WatchManager struct {
changedOverflow bool
}
+const recursiveCoalesceThreshold = 10
+
func NewWatchManager(warnWriter io.Writer, dirExists func(string) bool) *WatchManager {
return &WatchManager{
watchedDirs: make(map[string]*watchedDir),
@@ -224,6 +228,103 @@ func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[stri
return resolved
}
+func (wm *WatchManager) CoalesceDesiredDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool {
+ if wm.backend == nil || !wm.backend.HasFastRecursiveBackend() || len(desiredDirs) < recursiveCoalesceThreshold {
+ return desiredDirs
+ }
+
+ pruned := removeDirsCoveredByRecursiveWatches(desiredDirs, opts)
+ coalesced := coalesceAncestorDirs(pruned, opts)
+ if len(coalesced) == len(desiredDirs) {
+ return desiredDirs
+ }
+ return coalesced
+}
+
+func removeDirsCoveredByRecursiveWatches(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool {
+ result := make(map[string]bool, len(desiredDirs))
+ for dir, recursive := range desiredDirs {
+ covered := false
+ for parent, parentRecursive := range desiredDirs {
+ if parent == dir || !parentRecursive {
+ continue
+ }
+ if tspath.ContainsPath(parent, dir, opts) {
+ covered = true
+ break
+ }
+ }
+ if !covered {
+ result[dir] = recursive
+ }
+ }
+ return result
+}
+
+func coalesceAncestorDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool {
+ type candidate struct {
+ dir string
+ depth int
+ }
+ counts := make(map[string]int)
+ for dir := range desiredDirs {
+ parent := tspath.GetDirectoryPath(dir)
+ for parent != "" && parent != dir {
+ if CanWatchDirectory(parent) {
+ counts[parent]++
+ }
+ next := tspath.GetDirectoryPath(parent)
+ if next == parent {
+ break
+ }
+ parent = next
+ }
+ }
+
+ candidates := make([]candidate, 0, len(counts))
+ for dir, count := range counts {
+ if count >= recursiveCoalesceThreshold {
+ candidates = append(candidates, candidate{dir: dir, depth: len(tspath.GetPathComponents(dir, opts.CurrentDirectory))})
+ }
+ }
+ slices.SortFunc(candidates, func(a, b candidate) int {
+ if a.depth != b.depth {
+ return b.depth - a.depth
+ }
+ return opts.GetComparer()(a.dir, b.dir)
+ })
+
+ remaining := make(map[string]bool, len(desiredDirs))
+ maps.Copy(remaining, desiredDirs)
+ selected := make(map[string]struct{})
+ for _, candidate := range candidates {
+ var covered []string
+ for dir := range remaining {
+ if tspath.ContainsPath(candidate.dir, dir, opts) {
+ covered = append(covered, dir)
+ }
+ }
+ if len(covered) < recursiveCoalesceThreshold {
+ continue
+ }
+ selected[candidate.dir] = struct{}{}
+ for _, dir := range covered {
+ delete(remaining, dir)
+ }
+ }
+
+ if len(selected) == 0 {
+ return desiredDirs
+ }
+
+ result := make(map[string]bool, len(remaining)+len(selected))
+ maps.Copy(result, remaining)
+ for dir := range selected {
+ result[dir] = true
+ }
+ return result
+}
+
func (wm *WatchManager) ReconcileWatches(desiredDirs map[string]bool) error {
if wm.backend == nil {
return nil
diff --git a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js
new file mode 100644
index 00000000000..5c06ce22afb
--- /dev/null
+++ b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js
@@ -0,0 +1,163 @@
+currentDirectory::/home/src/workspaces/project
+useCaseSensitiveFileNames::true
+Input::
+//// [/home/src/workspaces/project/index.ts] *new*
+import { value0 } from "./node_modules/.bun/pkg0/index"; value0;
+import { value1 } from "./node_modules/.bun/pkg1/index"; value1;
+import { value2 } from "./node_modules/.bun/pkg2/index"; value2;
+import { value3 } from "./node_modules/.bun/pkg3/index"; value3;
+import { value4 } from "./node_modules/.bun/pkg4/index"; value4;
+import { value5 } from "./node_modules/.bun/pkg5/index"; value5;
+import { value6 } from "./node_modules/.bun/pkg6/index"; value6;
+import { value7 } from "./node_modules/.bun/pkg7/index"; value7;
+import { value8 } from "./node_modules/.bun/pkg8/index"; value8;
+import { value9 } from "./node_modules/.bun/pkg9/index"; value9;
+import { value10 } from "./node_modules/.bun/pkg10/index"; value10;
+import { value11 } from "./node_modules/.bun/pkg11/index"; value11;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.ts] *new*
+export const value0 = 0;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.ts] *new*
+export const value1 = 1;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.ts] *new*
+export const value10 = 10;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.ts] *new*
+export const value11 = 11;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.ts] *new*
+export const value2 = 2;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.ts] *new*
+export const value3 = 3;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.ts] *new*
+export const value4 = 4;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.ts] *new*
+export const value5 = 5;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.ts] *new*
+export const value6 = 6;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.ts] *new*
+export const value7 = 7;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.ts] *new*
+export const value8 = 8;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.ts] *new*
+export const value9 = 9;
+//// [/home/src/workspaces/project/tsconfig.json] *new*
+{
+ "compilerOptions": {},
+ "files": ["index.ts", "node_modules/.bun/pkg0/index.ts", "node_modules/.bun/pkg1/index.ts", "node_modules/.bun/pkg2/index.ts", "node_modules/.bun/pkg3/index.ts", "node_modules/.bun/pkg4/index.ts", "node_modules/.bun/pkg5/index.ts", "node_modules/.bun/pkg6/index.ts", "node_modules/.bun/pkg7/index.ts", "node_modules/.bun/pkg8/index.ts", "node_modules/.bun/pkg9/index.ts", "node_modules/.bun/pkg10/index.ts", "node_modules/.bun/pkg11/index.ts"]
+}
+
+tsgo --watch
+ExitStatus:: Success
+Output::
+[2J[3J[H[[90mHH:MM:SS AM[0m] Starting compilation in watch mode...
+
+[[90mHH:MM:SS AM[0m] Found 0 errors. Watching for file changes.
+
+//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib*
+///
+interface Boolean {}
+interface Function {}
+interface CallableFunction {}
+interface NewableFunction {}
+interface IArguments {}
+interface Number { toExponential: any; }
+interface Object {}
+interface RegExp {}
+interface String { charAt: any; }
+interface Array { length: number; [n: number]: T; }
+interface ReadonlyArray {}
+interface SymbolConstructor {
+ (desc?: string | number): symbol;
+ for(name: string): symbol;
+ readonly toStringTag: symbol;
+}
+declare var Symbol: SymbolConstructor;
+interface Symbol {
+ readonly [Symbol.toStringTag]: string;
+}
+declare const console: { log(msg: any): void; };
+//// [/home/src/workspaces/project/index.js] *new*
+import { value0 } from "./node_modules/.bun/pkg0/index";
+value0;
+import { value1 } from "./node_modules/.bun/pkg1/index";
+value1;
+import { value2 } from "./node_modules/.bun/pkg2/index";
+value2;
+import { value3 } from "./node_modules/.bun/pkg3/index";
+value3;
+import { value4 } from "./node_modules/.bun/pkg4/index";
+value4;
+import { value5 } from "./node_modules/.bun/pkg5/index";
+value5;
+import { value6 } from "./node_modules/.bun/pkg6/index";
+value6;
+import { value7 } from "./node_modules/.bun/pkg7/index";
+value7;
+import { value8 } from "./node_modules/.bun/pkg8/index";
+value8;
+import { value9 } from "./node_modules/.bun/pkg9/index";
+value9;
+import { value10 } from "./node_modules/.bun/pkg10/index";
+value10;
+import { value11 } from "./node_modules/.bun/pkg11/index";
+value11;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.js] *new*
+export const value0 = 0;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.js] *new*
+export const value1 = 1;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.js] *new*
+export const value10 = 10;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.js] *new*
+export const value11 = 11;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.js] *new*
+export const value2 = 2;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.js] *new*
+export const value3 = 3;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.js] *new*
+export const value4 = 4;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.js] *new*
+export const value5 = 5;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.js] *new*
+export const value6 = 6;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.js] *new*
+export const value7 = 7;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.js] *new*
+export const value8 = 8;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.js] *new*
+export const value9 = 9;
+
+
+Watch Registrations::
+Directory watches::
+ /home/src/tslibs/TS/Lib
+ /home/src/workspaces/project
+ /home/src/workspaces/project/node_modules
+ /home/src/workspaces/project/node_modules/.bun (recursive)
+tsconfig.json::
+SemanticDiagnostics::
+*refresh* /home/src/tslibs/TS/Lib/lib.es2025.full.d.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg0/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg1/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg2/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg3/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg4/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg5/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg6/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg7/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg8/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg9/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg10/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg11/index.ts
+*refresh* /home/src/workspaces/project/index.ts
+Signatures::
From 479af3765283c6c577f49ffa0947b37950569a7f Mon Sep 17 00:00:00 2001
From: Jake Bailey <5341706+jakebailey@users.noreply.github.com>
Date: Fri, 19 Jun 2026 15:01:03 -0700
Subject: [PATCH 2/3] Move watch coalescing into fswatch
Consolidate dense directory subscriptions inside fswatch for fast
recursive backends. Parent streams now serve later child subscriptions
with per-subscriber filtering, so all fswatch callers benefit without
watch-manager-specific planning.
---
internal/execute/build/orchestrator.go | 2 +-
.../execute/tsctests/mock_watch_backend.go | 11 --
internal/execute/tsctests/runner.go | 18 +-
internal/execute/tsctests/sys.go | 2 -
internal/execute/tsctests/tscwatch_test.go | 34 ----
internal/execute/watcher.go | 2 +-
internal/execute/watchmanager/watchbackend.go | 5 -
internal/execute/watchmanager/watchmanager.go | 101 -----------
internal/fswatch/watcher.go | 106 ++++++++++--
internal/fswatch/watcher_test.go | 102 +++++++++++
...esces-many-dependency-directory-watches.js | 163 ------------------
11 files changed, 202 insertions(+), 344 deletions(-)
delete mode 100644 testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js
diff --git a/internal/execute/build/orchestrator.go b/internal/execute/build/orchestrator.go
index 4a6c4fd91f0..6afdf077022 100644
--- a/internal/execute/build/orchestrator.go
+++ b/internal/execute/build/orchestrator.go
@@ -453,7 +453,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool {
}
}
- return o.wm.CoalesceDesiredDirs(o.wm.ResolveDesiredDirs(desiredDirs), o.comparePathsOptions)
+ return o.wm.ResolveDesiredDirs(desiredDirs)
}
func (o *Orchestrator) DoCycle() {
diff --git a/internal/execute/tsctests/mock_watch_backend.go b/internal/execute/tsctests/mock_watch_backend.go
index ae91a205af0..eca507a0772 100644
--- a/internal/execute/tsctests/mock_watch_backend.go
+++ b/internal/execute/tsctests/mock_watch_backend.go
@@ -22,8 +22,6 @@ type MockWatchBackend struct {
mu sync.Mutex
Dirs map[string]*MockWatch
DirectoryExists func(string) bool // if set, WatchDirectory fails for non-existent dirs
- Fail func(dir string, recursive bool) error
- FastRecursive bool
}
var _ watchmanager.WatchBackend = (*MockWatchBackend)(nil)
@@ -62,20 +60,11 @@ func (m *MockWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback,
if m.DirectoryExists != nil && !m.DirectoryExists(dir) {
return nil, fmt.Errorf("directory does not exist: %s", dir)
}
- if m.Fail != nil {
- if err := m.Fail(dir, recursive); err != nil {
- return nil, err
- }
- }
w := &MockWatch{Path: dir, Callback: fn, Recursive: recursive, Ignore: ignore}
m.Dirs[dir] = w
return w, nil
}
-func (m *MockWatchBackend) HasFastRecursiveBackend() bool {
- return m.FastRecursive
-}
-
// SendEvents routes events through the registered watch callbacks
// that match each event's path. Directory watches match if the event
// path is a child (or recursive descendant) of the watched directory.
diff --git a/internal/execute/tsctests/runner.go b/internal/execute/tsctests/runner.go
index 3f798c6e046..38c7e9c2089 100644
--- a/internal/execute/tsctests/runner.go
+++ b/internal/execute/tsctests/runner.go
@@ -31,16 +31,14 @@ var noChangeOnlyEdit = []*tscEdit{
}
type tscInput struct {
- subScenario string
- commandLineArgs []string
- files FileMap
- cwd string
- edits []*tscEdit
- env map[string]string
- ignoreCase bool
- windowsStyleRoot string
- watchBackendFail func(dir string, recursive bool) error
- watchBackendFastRecursive bool
+ subScenario string
+ commandLineArgs []string
+ files FileMap
+ cwd string
+ edits []*tscEdit
+ env map[string]string
+ ignoreCase bool
+ windowsStyleRoot string
}
func (test *tscInput) executeCommand(sys *TestSys, baselineBuilder *strings.Builder, commandLineArgs []string) tsc.CommandLineResult {
diff --git a/internal/execute/tsctests/sys.go b/internal/execute/tsctests/sys.go
index 32d539f864e..2d75a36d4ca 100644
--- a/internal/execute/tsctests/sys.go
+++ b/internal/execute/tsctests/sys.go
@@ -135,8 +135,6 @@ func newTestSys(tscInput *tscInput, forIncrementalCorrectness bool) *TestSys {
sys.forIncrementalCorrectness = forIncrementalCorrectness
sys.mockWatchBackend = NewMockWatchBackend()
sys.mockWatchBackend.DirectoryExists = sys.fs.FS.DirectoryExists
- sys.mockWatchBackend.Fail = tscInput.watchBackendFail
- sys.mockWatchBackend.FastRecursive = tscInput.watchBackendFastRecursive
sys.fsDiffer = &fsbaselineutil.FSDiffer{
FS: sys.fs.FS.(iovfs.FsWithSys),
DefaultLibs: func() *collections.SyncSet[string] { return sys.fs.defaultLibs },
diff --git a/internal/execute/tsctests/tscwatch_test.go b/internal/execute/tsctests/tscwatch_test.go
index c2f8fd7e933..7650fcea7e9 100644
--- a/internal/execute/tsctests/tscwatch_test.go
+++ b/internal/execute/tsctests/tscwatch_test.go
@@ -1,8 +1,6 @@
package tsctests
import (
- "errors"
- "fmt"
"strings"
"testing"
@@ -11,37 +9,6 @@ import (
func TestWatch(t *testing.T) {
t.Parallel()
- bunCoalescingTest := func() *tscInput {
- files := FileMap{}
- var index strings.Builder
- var fileNames strings.Builder
- fileNames.WriteString(`"index.ts"`)
- for i := range 12 {
- name := fmt.Sprintf("pkg%d", i)
- value := fmt.Sprintf("value%d", i)
- index.WriteString(fmt.Sprintf(`import { %[1]s } from "./node_modules/.bun/%[2]s/index"; %[1]s;`, value, name))
- index.WriteString("\n")
- files["/home/src/workspaces/project/node_modules/.bun/"+name+"/index.ts"] = fmt.Sprintf("export const %s = %d;", value, i)
- fileNames.WriteString(fmt.Sprintf(`, "node_modules/.bun/%s/index.ts"`, name))
- }
- files["/home/src/workspaces/project/index.ts"] = index.String()
- files["/home/src/workspaces/project/tsconfig.json"] = fmt.Sprintf(`{
- "compilerOptions": {},
- "files": [%s]
-}`, fileNames.String())
- return &tscInput{
- subScenario: "watch coalesces many dependency directory watches",
- files: files,
- commandLineArgs: []string{"--watch"},
- watchBackendFail: func(dir string, recursive bool) error {
- if strings.Contains(dir, "/node_modules/.bun/pkg") {
- return errors.New("error starting FSEvents stream")
- }
- return nil
- },
- watchBackendFastRecursive: true,
- }
- }
testCases := []*tscInput{
{
subScenario: "watch with no tsconfig",
@@ -58,7 +25,6 @@ func TestWatch(t *testing.T) {
},
commandLineArgs: []string{"--watch", "--incremental"},
},
- bunCoalescingTest(),
{
subScenario: "watch skips build when no files change",
files: FileMap{
diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go
index cafe205cd3d..9b11c2a4dd4 100644
--- a/internal/execute/watcher.go
+++ b/internal/execute/watcher.go
@@ -195,7 +195,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool
}
// Re-resolve in case newly added dirs don't exist
- return w.wm.CoalesceDesiredDirs(w.wm.ResolveDesiredDirs(resolvedDirs), opts)
+ return w.wm.ResolveDesiredDirs(resolvedDirs)
}
func (w *Watcher) reconcileWatches(seenFilePaths []string) error {
diff --git a/internal/execute/watchmanager/watchbackend.go b/internal/execute/watchmanager/watchbackend.go
index 63d5da40b84..64551574cb7 100644
--- a/internal/execute/watchmanager/watchbackend.go
+++ b/internal/execute/watchmanager/watchbackend.go
@@ -11,7 +11,6 @@ import (
// WatchBackend abstracts fswatch.Watcher for testing
type WatchBackend interface {
WatchDirectory(dir string, fn fswatch.WatchCallback, recursive bool, ignore func(string) bool) (io.Closer, error)
- HasFastRecursiveBackend() bool
}
// CommandLineTestingWithWatchBackend is an optional extension of
@@ -33,10 +32,6 @@ func (b *FSWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, re
return b.Inner.WatchDirectory(dir, fn, opts...)
}
-func (b *FSWatchBackend) HasFastRecursiveBackend() bool {
- return b.Inner.HasFastRecursiveBackend()
-}
-
func ShouldIgnoreWatchPath(path string) bool {
p := tspath.NormalizeSlashes(path)
return strings.HasSuffix(p, "/.git") ||
diff --git a/internal/execute/watchmanager/watchmanager.go b/internal/execute/watchmanager/watchmanager.go
index 5cdcfdcc64c..b5fe9a89bab 100644
--- a/internal/execute/watchmanager/watchmanager.go
+++ b/internal/execute/watchmanager/watchmanager.go
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"io"
- "maps"
- "slices"
"sync"
"github.com/microsoft/typescript-go/internal/core"
@@ -44,8 +42,6 @@ type WatchManager struct {
changedOverflow bool
}
-const recursiveCoalesceThreshold = 10
-
func NewWatchManager(warnWriter io.Writer, dirExists func(string) bool) *WatchManager {
return &WatchManager{
watchedDirs: make(map[string]*watchedDir),
@@ -228,103 +224,6 @@ func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[stri
return resolved
}
-func (wm *WatchManager) CoalesceDesiredDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool {
- if wm.backend == nil || !wm.backend.HasFastRecursiveBackend() || len(desiredDirs) < recursiveCoalesceThreshold {
- return desiredDirs
- }
-
- pruned := removeDirsCoveredByRecursiveWatches(desiredDirs, opts)
- coalesced := coalesceAncestorDirs(pruned, opts)
- if len(coalesced) == len(desiredDirs) {
- return desiredDirs
- }
- return coalesced
-}
-
-func removeDirsCoveredByRecursiveWatches(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool {
- result := make(map[string]bool, len(desiredDirs))
- for dir, recursive := range desiredDirs {
- covered := false
- for parent, parentRecursive := range desiredDirs {
- if parent == dir || !parentRecursive {
- continue
- }
- if tspath.ContainsPath(parent, dir, opts) {
- covered = true
- break
- }
- }
- if !covered {
- result[dir] = recursive
- }
- }
- return result
-}
-
-func coalesceAncestorDirs(desiredDirs map[string]bool, opts tspath.ComparePathsOptions) map[string]bool {
- type candidate struct {
- dir string
- depth int
- }
- counts := make(map[string]int)
- for dir := range desiredDirs {
- parent := tspath.GetDirectoryPath(dir)
- for parent != "" && parent != dir {
- if CanWatchDirectory(parent) {
- counts[parent]++
- }
- next := tspath.GetDirectoryPath(parent)
- if next == parent {
- break
- }
- parent = next
- }
- }
-
- candidates := make([]candidate, 0, len(counts))
- for dir, count := range counts {
- if count >= recursiveCoalesceThreshold {
- candidates = append(candidates, candidate{dir: dir, depth: len(tspath.GetPathComponents(dir, opts.CurrentDirectory))})
- }
- }
- slices.SortFunc(candidates, func(a, b candidate) int {
- if a.depth != b.depth {
- return b.depth - a.depth
- }
- return opts.GetComparer()(a.dir, b.dir)
- })
-
- remaining := make(map[string]bool, len(desiredDirs))
- maps.Copy(remaining, desiredDirs)
- selected := make(map[string]struct{})
- for _, candidate := range candidates {
- var covered []string
- for dir := range remaining {
- if tspath.ContainsPath(candidate.dir, dir, opts) {
- covered = append(covered, dir)
- }
- }
- if len(covered) < recursiveCoalesceThreshold {
- continue
- }
- selected[candidate.dir] = struct{}{}
- for _, dir := range covered {
- delete(remaining, dir)
- }
- }
-
- if len(selected) == 0 {
- return desiredDirs
- }
-
- result := make(map[string]bool, len(remaining)+len(selected))
- maps.Copy(result, remaining)
- for dir := range selected {
- result[dir] = true
- }
- return result
-}
-
func (wm *WatchManager) ReconcileWatches(desiredDirs map[string]bool) error {
if wm.backend == nil {
return nil
diff --git a/internal/fswatch/watcher.go b/internal/fswatch/watcher.go
index e17d5012904..7b394a26af3 100644
--- a/internal/fswatch/watcher.go
+++ b/internal/fswatch/watcher.go
@@ -214,6 +214,8 @@ type watcher struct {
debounce *debounce // lazily created in getOrCreateDirWatch
}
+const recursiveConsolidateThreshold = 10
+
func (w *watcher) Name() string { return w.name }
func (w *watcher) String() string { return w.name }
func (w *watcher) Available() bool { return w.factory != nil }
@@ -259,6 +261,54 @@ func (w *watcher) getImpl() (watcherImpl, error) {
return impl, nil
}
+func (w *watcher) keyForDirWatch(dir string, recursive bool) string {
+ if recursive {
+ return dir + "\x00recursive"
+ }
+ return dir
+}
+
+func (w *watcher) findCoveringRecursiveWatchLocked(dir string) *dirWatch {
+ var best *dirWatch
+ for _, dw := range w.dirWatches {
+ if !dw.recursive || !isInDirectoryOrSelf(dw.dir, dir) {
+ continue
+ }
+ if best == nil || len(dw.dir) > len(best.dir) {
+ best = dw
+ }
+ }
+ return best
+}
+
+func (w *watcher) findConsolidationDirLocked(dir string) string {
+ if !w.HasFastRecursiveBackend() {
+ return ""
+ }
+ parent := filepath.Dir(dir)
+ for parent != dir && parent != "." {
+ if filepath.Dir(parent) == parent {
+ break
+ }
+ count := 1
+ for _, dw := range w.dirWatches {
+ if isInDirectoryOrSelf(parent, dw.dir) {
+ count++
+ }
+ }
+ if count >= recursiveConsolidateThreshold {
+ return parent
+ }
+ next := filepath.Dir(parent)
+ if next == parent {
+ break
+ }
+ dir = parent
+ parent = next
+ }
+ return ""
+}
+
func (w *watcher) getOrCreateDirWatch(dir string, recursive bool) *dirWatch {
w.mu.Lock()
defer w.mu.Unlock()
@@ -268,10 +318,21 @@ func (w *watcher) getOrCreateDirWatch(dir string, recursive bool) *dirWatch {
if w.debounce == nil {
w.debounce = newDebounce()
}
- key := dir
- if recursive {
- key = dir + "\x00recursive"
+
+ if w.HasFastRecursiveBackend() {
+ if dw := w.findCoveringRecursiveWatchLocked(dir); dw != nil {
+ return dw
+ }
+ if consolidationDir := w.findConsolidationDirLocked(dir); consolidationDir != "" {
+ dir = consolidationDir
+ recursive = true
+ if dw := w.findCoveringRecursiveWatchLocked(dir); dw != nil {
+ return dw
+ }
+ }
}
+
+ key := w.keyForDirWatch(dir, recursive)
if dw, ok := w.dirWatches[key]; ok {
return dw
}
@@ -284,10 +345,7 @@ func (w *watcher) getOrCreateDirWatch(dir string, recursive bool) *dirWatch {
func (w *watcher) removeDirWatch(dw *dirWatch) {
w.mu.Lock()
defer w.mu.Unlock()
- key := dw.dir
- if dw.recursive {
- key = dw.dir + "\x00recursive"
- }
+ key := w.keyForDirWatch(dw.dir, dw.recursive)
if existing, ok := w.dirWatches[key]; ok && existing == dw {
delete(w.dirWatches, key)
dw.destroyDebounce()
@@ -313,7 +371,7 @@ func (w *watcher) WatchDirectory(dir string, fn WatchCallback, opts ...WatchOpti
}
dw := w.getOrCreateDirWatch(dir, sopts.recursive)
- id, _ := dw.watch(fn, sopts.ignore)
+ id, _ := dw.watch(dir, sopts.recursive, fn, sopts.ignore)
impl, err := w.getImpl()
if err != nil {
@@ -504,9 +562,11 @@ func (b *watcherBase) handleWatcherError(werr *dirWatchError) {
// ----- dirWatch: per-directory watch state -------------------------
type callback struct {
- id uint64
- fn WatchCallback
- ignore func(path string) bool
+ id uint64
+ dir string
+ recursive bool
+ fn WatchCallback
+ ignore func(path string) bool
}
// dirWatchError associates an error with a specific directory watch.
@@ -584,18 +644,21 @@ func (dw *dirWatch) triggerCallbacks() {
}
events, err := dw.events.drain()
cbs := slices.Clone(dw.callbacks)
- recursive := dw.recursive
dw.mu.Unlock()
for _, cb := range cbs {
cbEvents := events
- if cb.ignore != nil || !recursive {
+ if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir {
filtered := make([]Event, 0, len(events))
for _, e := range events {
if cb.ignore != nil && cb.ignore(e.Path) {
continue
}
- if !recursive && !isDirectChild(dw.dir, e.Path) {
+ if cb.recursive {
+ if !isInDirectoryOrSelf(cb.dir, e.Path) {
+ continue
+ }
+ } else if !isDirectChild(cb.dir, e.Path) {
continue
}
filtered = append(filtered, e)
@@ -608,6 +671,17 @@ func (dw *dirWatch) triggerCallbacks() {
}
}
+func isInDirectoryOrSelf(dir, path string) bool {
+ if path == dir {
+ return true
+ }
+ if !strings.HasPrefix(path, dir) {
+ return false
+ }
+ rest := path[len(dir):]
+ return len(rest) > 0 && (rest[0] == '/' || rest[0] == filepath.Separator)
+}
+
// isDirectChild reports whether path is an immediate child of dir.
// Both paths must be absolute. Returns false for path == dir.
func isDirectChild(dir, path string) bool {
@@ -625,12 +699,12 @@ func isDirectChild(dir, path string) bool {
return len(rest) > 0 && !strings.ContainsRune(rest, '/') && !strings.ContainsRune(rest, filepath.Separator)
}
-func (dw *dirWatch) watch(fn WatchCallback, ignore func(path string) bool) (uint64, bool) {
+func (dw *dirWatch) watch(dir string, recursive bool, fn WatchCallback, ignore func(path string) bool) (uint64, bool) {
dw.mu.Lock()
defer dw.mu.Unlock()
dw.nextCBID++
id := dw.nextCBID
- dw.callbacks = append(dw.callbacks, callback{id: id, fn: fn, ignore: ignore})
+ dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, recursive: recursive, fn: fn, ignore: ignore})
return id, true
}
diff --git a/internal/fswatch/watcher_test.go b/internal/fswatch/watcher_test.go
index 7fe53c65e74..64faff47cf3 100644
--- a/internal/fswatch/watcher_test.go
+++ b/internal/fswatch/watcher_test.go
@@ -1178,6 +1178,108 @@ func TestSubscribeMultipleDifferentDirs(t *testing.T) {
})
}
+type countingWatcherImpl struct {
+ watcherBase
+ subscribed []*dirWatch
+ closed []*dirWatch
+}
+
+func newCountingWatcherImpl() *countingWatcherImpl {
+ impl := &countingWatcherImpl{}
+ impl.watcherBase.init(impl)
+ return impl
+}
+
+func (b *countingWatcherImpl) start() error {
+ b.notifyStarted()
+ return nil
+}
+
+func (b *countingWatcherImpl) subscribe(w *dirWatch) error {
+ b.subscribed = append(b.subscribed, w)
+ return nil
+}
+
+func (b *countingWatcherImpl) closeWatch(w *dirWatch) error {
+ b.closed = append(b.closed, w)
+ return nil
+}
+
+func TestFastRecursiveWatcherConsolidatesSiblingDirectories(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ parent := filepath.Join(root, "node_modules", ".bun")
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ var impl *countingWatcherImpl
+ watcherImpl := &watcher{
+ name: "fsevents",
+ factory: func() watcherImpl {
+ impl = newCountingWatcherImpl()
+ return impl
+ },
+ }
+
+ var subs []Watch
+ for i := range recursiveConsolidateThreshold + 2 {
+ dir := filepath.Join(parent, fmt.Sprintf("pkg%d", i))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ sub, err := watcherImpl.WatchDirectory(dir, func([]Event, error) {})
+ if err != nil {
+ t.Fatal(err)
+ }
+ subs = append(subs, sub)
+ }
+ t.Cleanup(func() {
+ for _, sub := range subs {
+ _ = sub.Close()
+ }
+ })
+
+ if got := len(impl.subscribed); got != recursiveConsolidateThreshold {
+ t.Fatalf("expected %d subscriptions after consolidation, got %d", recursiveConsolidateThreshold, got)
+ }
+ consolidated := impl.subscribed[len(impl.subscribed)-1]
+ if consolidated.dir != parent || !consolidated.recursive {
+ t.Fatalf("expected consolidated recursive watch on %s, got dir=%s recursive=%v", parent, consolidated.dir, consolidated.recursive)
+ }
+
+ watcherImpl.mu.Lock()
+ _, hasPkgWatch := watcherImpl.dirWatches[watcherImpl.keyForDirWatch(filepath.Join(parent, "pkg11"), false)]
+ watcherImpl.mu.Unlock()
+ if hasPkgWatch {
+ t.Fatal("expected later package watch to reuse consolidated parent instead of creating its own stream")
+ }
+}
+
+func TestConsolidatedChildWatchFiltersAgainstRequestedDir(t *testing.T) {
+ t.Parallel()
+
+ parent := filepath.Join(t.TempDir(), "parent")
+ child := filepath.Join(parent, "child")
+ sibling := filepath.Join(parent, "sibling")
+ dw := newDirectWatcher(t, parent)
+
+ var got []Event
+ dw.watch(child, false, func(events []Event, err error) {
+ if err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, events...)
+ }, nil)
+ dw.events.update(filepath.Join(child, "file.ts"))
+ dw.events.update(filepath.Join(child, "nested", "file.ts"))
+ dw.events.update(filepath.Join(sibling, "file.ts"))
+ dw.triggerCallbacks()
+
+ assertEventSequence(t, got, []wantEvent{{EventUpdate, filepath.Join(child, "file.ts")}})
+}
+
// ----- errors ------------------------------------------------------------
func TestSubscribeMissingDirError(t *testing.T) {
diff --git a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js
deleted file mode 100644
index 5c06ce22afb..00000000000
--- a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-coalesces-many-dependency-directory-watches.js
+++ /dev/null
@@ -1,163 +0,0 @@
-currentDirectory::/home/src/workspaces/project
-useCaseSensitiveFileNames::true
-Input::
-//// [/home/src/workspaces/project/index.ts] *new*
-import { value0 } from "./node_modules/.bun/pkg0/index"; value0;
-import { value1 } from "./node_modules/.bun/pkg1/index"; value1;
-import { value2 } from "./node_modules/.bun/pkg2/index"; value2;
-import { value3 } from "./node_modules/.bun/pkg3/index"; value3;
-import { value4 } from "./node_modules/.bun/pkg4/index"; value4;
-import { value5 } from "./node_modules/.bun/pkg5/index"; value5;
-import { value6 } from "./node_modules/.bun/pkg6/index"; value6;
-import { value7 } from "./node_modules/.bun/pkg7/index"; value7;
-import { value8 } from "./node_modules/.bun/pkg8/index"; value8;
-import { value9 } from "./node_modules/.bun/pkg9/index"; value9;
-import { value10 } from "./node_modules/.bun/pkg10/index"; value10;
-import { value11 } from "./node_modules/.bun/pkg11/index"; value11;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.ts] *new*
-export const value0 = 0;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.ts] *new*
-export const value1 = 1;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.ts] *new*
-export const value10 = 10;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.ts] *new*
-export const value11 = 11;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.ts] *new*
-export const value2 = 2;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.ts] *new*
-export const value3 = 3;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.ts] *new*
-export const value4 = 4;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.ts] *new*
-export const value5 = 5;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.ts] *new*
-export const value6 = 6;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.ts] *new*
-export const value7 = 7;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.ts] *new*
-export const value8 = 8;
-//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.ts] *new*
-export const value9 = 9;
-//// [/home/src/workspaces/project/tsconfig.json] *new*
-{
- "compilerOptions": {},
- "files": ["index.ts", "node_modules/.bun/pkg0/index.ts", "node_modules/.bun/pkg1/index.ts", "node_modules/.bun/pkg2/index.ts", "node_modules/.bun/pkg3/index.ts", "node_modules/.bun/pkg4/index.ts", "node_modules/.bun/pkg5/index.ts", "node_modules/.bun/pkg6/index.ts", "node_modules/.bun/pkg7/index.ts", "node_modules/.bun/pkg8/index.ts", "node_modules/.bun/pkg9/index.ts", "node_modules/.bun/pkg10/index.ts", "node_modules/.bun/pkg11/index.ts"]
-}
-
-tsgo --watch
-ExitStatus:: Success
-Output::
-[2J[3J[H[[90mHH:MM:SS AM[0m] Starting compilation in watch mode...
-
-[[90mHH:MM:SS AM[0m] Found 0 errors. Watching for file changes.
-
-//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib*
-///
-interface Boolean {}
-interface Function {}
-interface CallableFunction {}
-interface NewableFunction {}
-interface IArguments {}
-interface Number { toExponential: any; }
-interface Object {}
-interface RegExp {}
-interface String { charAt: any; }
-interface Array { length: number; [n: number]: T; }
-interface ReadonlyArray {}
-interface SymbolConstructor {
- (desc?: string | number): symbol;
- for(name: string): symbol;
- readonly toStringTag: symbol;
-}
-declare var Symbol: SymbolConstructor;
-interface Symbol {
- readonly [Symbol.toStringTag]: string;
-}
-declare const console: { log(msg: any): void; };
-//// [/home/src/workspaces/project/index.js] *new*
-import { value0 } from "./node_modules/.bun/pkg0/index";
-value0;
-import { value1 } from "./node_modules/.bun/pkg1/index";
-value1;
-import { value2 } from "./node_modules/.bun/pkg2/index";
-value2;
-import { value3 } from "./node_modules/.bun/pkg3/index";
-value3;
-import { value4 } from "./node_modules/.bun/pkg4/index";
-value4;
-import { value5 } from "./node_modules/.bun/pkg5/index";
-value5;
-import { value6 } from "./node_modules/.bun/pkg6/index";
-value6;
-import { value7 } from "./node_modules/.bun/pkg7/index";
-value7;
-import { value8 } from "./node_modules/.bun/pkg8/index";
-value8;
-import { value9 } from "./node_modules/.bun/pkg9/index";
-value9;
-import { value10 } from "./node_modules/.bun/pkg10/index";
-value10;
-import { value11 } from "./node_modules/.bun/pkg11/index";
-value11;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.js] *new*
-export const value0 = 0;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.js] *new*
-export const value1 = 1;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.js] *new*
-export const value10 = 10;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.js] *new*
-export const value11 = 11;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.js] *new*
-export const value2 = 2;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.js] *new*
-export const value3 = 3;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.js] *new*
-export const value4 = 4;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.js] *new*
-export const value5 = 5;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.js] *new*
-export const value6 = 6;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.js] *new*
-export const value7 = 7;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.js] *new*
-export const value8 = 8;
-
-//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.js] *new*
-export const value9 = 9;
-
-
-Watch Registrations::
-Directory watches::
- /home/src/tslibs/TS/Lib
- /home/src/workspaces/project
- /home/src/workspaces/project/node_modules
- /home/src/workspaces/project/node_modules/.bun (recursive)
-tsconfig.json::
-SemanticDiagnostics::
-*refresh* /home/src/tslibs/TS/Lib/lib.es2025.full.d.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg0/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg1/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg2/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg3/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg4/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg5/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg6/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg7/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg8/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg9/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg10/index.ts
-*refresh* /home/src/workspaces/project/node_modules/.bun/pkg11/index.ts
-*refresh* /home/src/workspaces/project/index.ts
-Signatures::
From ce359c197661b56b7614a472fc57ad03143f1eca Mon Sep 17 00:00:00 2001
From: Jake Bailey <5341706+jakebailey@users.noreply.github.com>
Date: Fri, 19 Jun 2026 15:10:57 -0700
Subject: [PATCH 3/3] Add watch test for Bun dependencies
Add a tsc watch integration baseline covering many dependency files
under a Bun-style node_modules/.bun layout. The lower-level fswatch
tests assert stream coalescing; this keeps the command-line watch shape
covered end-to-end.
---
internal/execute/tsctests/tscwatch_test.go | 26 +++
...watch-handles-many-bun-dependency-files.js | 175 ++++++++++++++++++
2 files changed, 201 insertions(+)
create mode 100644 testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js
diff --git a/internal/execute/tsctests/tscwatch_test.go b/internal/execute/tsctests/tscwatch_test.go
index 7650fcea7e9..fdf7ec0302e 100644
--- a/internal/execute/tsctests/tscwatch_test.go
+++ b/internal/execute/tsctests/tscwatch_test.go
@@ -1,6 +1,7 @@
package tsctests
import (
+ "fmt"
"strings"
"testing"
@@ -9,6 +10,30 @@ import (
func TestWatch(t *testing.T) {
t.Parallel()
+ bunDependencyTest := func() *tscInput {
+ files := FileMap{}
+ var index strings.Builder
+ var fileNames strings.Builder
+ fileNames.WriteString(`"index.ts"`)
+ for i := range 12 {
+ name := fmt.Sprintf("pkg%d", i)
+ value := fmt.Sprintf("value%d", i)
+ index.WriteString(fmt.Sprintf(`import { %[1]s } from "./node_modules/.bun/%[2]s/index"; %[1]s;`, value, name))
+ index.WriteString("\n")
+ files["/home/src/workspaces/project/node_modules/.bun/"+name+"/index.ts"] = fmt.Sprintf("export const %s = %d;", value, i)
+ fileNames.WriteString(fmt.Sprintf(`, "node_modules/.bun/%s/index.ts"`, name))
+ }
+ files["/home/src/workspaces/project/index.ts"] = index.String()
+ files["/home/src/workspaces/project/tsconfig.json"] = fmt.Sprintf(`{
+ "compilerOptions": {},
+ "files": [%s]
+}`, fileNames.String())
+ return &tscInput{
+ subScenario: "watch handles many bun dependency files",
+ files: files,
+ commandLineArgs: []string{"--watch"},
+ }
+ }
testCases := []*tscInput{
{
subScenario: "watch with no tsconfig",
@@ -25,6 +50,7 @@ func TestWatch(t *testing.T) {
},
commandLineArgs: []string{"--watch", "--incremental"},
},
+ bunDependencyTest(),
{
subScenario: "watch skips build when no files change",
files: FileMap{
diff --git a/testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js
new file mode 100644
index 00000000000..dbd33c9fbaf
--- /dev/null
+++ b/testdata/baselines/reference/tscWatch/commandLineWatch/watch-handles-many-bun-dependency-files.js
@@ -0,0 +1,175 @@
+currentDirectory::/home/src/workspaces/project
+useCaseSensitiveFileNames::true
+Input::
+//// [/home/src/workspaces/project/index.ts] *new*
+import { value0 } from "./node_modules/.bun/pkg0/index"; value0;
+import { value1 } from "./node_modules/.bun/pkg1/index"; value1;
+import { value2 } from "./node_modules/.bun/pkg2/index"; value2;
+import { value3 } from "./node_modules/.bun/pkg3/index"; value3;
+import { value4 } from "./node_modules/.bun/pkg4/index"; value4;
+import { value5 } from "./node_modules/.bun/pkg5/index"; value5;
+import { value6 } from "./node_modules/.bun/pkg6/index"; value6;
+import { value7 } from "./node_modules/.bun/pkg7/index"; value7;
+import { value8 } from "./node_modules/.bun/pkg8/index"; value8;
+import { value9 } from "./node_modules/.bun/pkg9/index"; value9;
+import { value10 } from "./node_modules/.bun/pkg10/index"; value10;
+import { value11 } from "./node_modules/.bun/pkg11/index"; value11;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.ts] *new*
+export const value0 = 0;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.ts] *new*
+export const value1 = 1;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.ts] *new*
+export const value10 = 10;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.ts] *new*
+export const value11 = 11;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.ts] *new*
+export const value2 = 2;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.ts] *new*
+export const value3 = 3;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.ts] *new*
+export const value4 = 4;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.ts] *new*
+export const value5 = 5;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.ts] *new*
+export const value6 = 6;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.ts] *new*
+export const value7 = 7;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.ts] *new*
+export const value8 = 8;
+//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.ts] *new*
+export const value9 = 9;
+//// [/home/src/workspaces/project/tsconfig.json] *new*
+{
+ "compilerOptions": {},
+ "files": ["index.ts", "node_modules/.bun/pkg0/index.ts", "node_modules/.bun/pkg1/index.ts", "node_modules/.bun/pkg2/index.ts", "node_modules/.bun/pkg3/index.ts", "node_modules/.bun/pkg4/index.ts", "node_modules/.bun/pkg5/index.ts", "node_modules/.bun/pkg6/index.ts", "node_modules/.bun/pkg7/index.ts", "node_modules/.bun/pkg8/index.ts", "node_modules/.bun/pkg9/index.ts", "node_modules/.bun/pkg10/index.ts", "node_modules/.bun/pkg11/index.ts"]
+}
+
+tsgo --watch
+ExitStatus:: Success
+Output::
+[2J[3J[H[[90mHH:MM:SS AM[0m] Starting compilation in watch mode...
+
+[[90mHH:MM:SS AM[0m] Found 0 errors. Watching for file changes.
+
+//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib*
+///
+interface Boolean {}
+interface Function {}
+interface CallableFunction {}
+interface NewableFunction {}
+interface IArguments {}
+interface Number { toExponential: any; }
+interface Object {}
+interface RegExp {}
+interface String { charAt: any; }
+interface Array { length: number; [n: number]: T; }
+interface ReadonlyArray {}
+interface SymbolConstructor {
+ (desc?: string | number): symbol;
+ for(name: string): symbol;
+ readonly toStringTag: symbol;
+}
+declare var Symbol: SymbolConstructor;
+interface Symbol {
+ readonly [Symbol.toStringTag]: string;
+}
+declare const console: { log(msg: any): void; };
+//// [/home/src/workspaces/project/index.js] *new*
+import { value0 } from "./node_modules/.bun/pkg0/index";
+value0;
+import { value1 } from "./node_modules/.bun/pkg1/index";
+value1;
+import { value2 } from "./node_modules/.bun/pkg2/index";
+value2;
+import { value3 } from "./node_modules/.bun/pkg3/index";
+value3;
+import { value4 } from "./node_modules/.bun/pkg4/index";
+value4;
+import { value5 } from "./node_modules/.bun/pkg5/index";
+value5;
+import { value6 } from "./node_modules/.bun/pkg6/index";
+value6;
+import { value7 } from "./node_modules/.bun/pkg7/index";
+value7;
+import { value8 } from "./node_modules/.bun/pkg8/index";
+value8;
+import { value9 } from "./node_modules/.bun/pkg9/index";
+value9;
+import { value10 } from "./node_modules/.bun/pkg10/index";
+value10;
+import { value11 } from "./node_modules/.bun/pkg11/index";
+value11;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg0/index.js] *new*
+export const value0 = 0;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg1/index.js] *new*
+export const value1 = 1;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg10/index.js] *new*
+export const value10 = 10;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg11/index.js] *new*
+export const value11 = 11;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg2/index.js] *new*
+export const value2 = 2;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg3/index.js] *new*
+export const value3 = 3;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg4/index.js] *new*
+export const value4 = 4;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg5/index.js] *new*
+export const value5 = 5;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg6/index.js] *new*
+export const value6 = 6;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg7/index.js] *new*
+export const value7 = 7;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg8/index.js] *new*
+export const value8 = 8;
+
+//// [/home/src/workspaces/project/node_modules/.bun/pkg9/index.js] *new*
+export const value9 = 9;
+
+
+Watch Registrations::
+Directory watches::
+ /home/src/tslibs/TS/Lib
+ /home/src/workspaces/project
+ /home/src/workspaces/project/node_modules
+ /home/src/workspaces/project/node_modules/.bun
+ /home/src/workspaces/project/node_modules/.bun/pkg0
+ /home/src/workspaces/project/node_modules/.bun/pkg1
+ /home/src/workspaces/project/node_modules/.bun/pkg10
+ /home/src/workspaces/project/node_modules/.bun/pkg11
+ /home/src/workspaces/project/node_modules/.bun/pkg2
+ /home/src/workspaces/project/node_modules/.bun/pkg3
+ /home/src/workspaces/project/node_modules/.bun/pkg4
+ /home/src/workspaces/project/node_modules/.bun/pkg5
+ /home/src/workspaces/project/node_modules/.bun/pkg6
+ /home/src/workspaces/project/node_modules/.bun/pkg7
+ /home/src/workspaces/project/node_modules/.bun/pkg8
+ /home/src/workspaces/project/node_modules/.bun/pkg9
+tsconfig.json::
+SemanticDiagnostics::
+*refresh* /home/src/tslibs/TS/Lib/lib.es2025.full.d.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg0/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg1/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg2/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg3/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg4/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg5/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg6/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg7/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg8/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg9/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg10/index.ts
+*refresh* /home/src/workspaces/project/node_modules/.bun/pkg11/index.ts
+*refresh* /home/src/workspaces/project/index.ts
+Signatures::