From f2242e683acaac6a431c29026f5a260cf6d9634c Mon Sep 17 00:00:00 2001 From: Generalsimus Date: Mon, 21 Sep 2026 02:19:50 +0400 Subject: [PATCH 1/4] Watch project directories that are close to the filesystem root ResolveDesiredDirs ran CanWatchDirectory on every desired directory, including the ones the project itself declares (wildcard include directories, the tsconfig directory, the cwd). CanWatchDirectory needs at least five path components, so a project in /app, /srv/app or /home/user/project was silently dropped and `tsc --watch` never rebuilt. Only apply the check when falling back to an ancestor of a directory that does not exist, which is what it is meant to guard against (/, /home, ...). A directory that exists and was asked for is watched at any depth, like tsc 6.0 watches every program file. --- .../execute/watchmanager/watchmanager.go | 5 +- .../execute/watchmanager/watchmanager_test.go | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tsc/internal/execute/watchmanager/watchmanager.go b/tsc/internal/execute/watchmanager/watchmanager.go index f037d93e2f863..0a0dfd81fbccc 100644 --- a/tsc/internal/execute/watchmanager/watchmanager.go +++ b/tsc/internal/execute/watchmanager/watchmanager.go @@ -205,7 +205,10 @@ func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[stri watchDir = parent watchRecursive = false // ancestor fallbacks are always non-recursive } - if !wm.dirExists(watchDir) || !CanWatchDirectory(watchDir) { + // CanWatchDirectory only guards against falling back to an ancestor that is too generic to watch + // (/, /home, ...). A directory that exists and was asked for is watched at any depth, otherwise a + // project that lives near the filesystem root (say /app or /srv/app) would never be watched. + if !wm.dirExists(watchDir) || (watchDir != dir && !CanWatchDirectory(watchDir)) { if wm.DebugLog != nil { fmt.Fprintf(wm.DebugLog, "[watch] no watchable ancestor for %s\n", dir) } diff --git a/tsc/internal/execute/watchmanager/watchmanager_test.go b/tsc/internal/execute/watchmanager/watchmanager_test.go index 633afde563c34..d4c47ba8c3920 100644 --- a/tsc/internal/execute/watchmanager/watchmanager_test.go +++ b/tsc/internal/execute/watchmanager/watchmanager_test.go @@ -1,6 +1,7 @@ package watchmanager import ( + "io" "testing" "github.com/microsoft/TypeScript/tsc/internal/tspath" @@ -135,3 +136,50 @@ func TestDirWatchSetDirs(t *testing.T) { assert.Equal(t, dirs["/repo/a"], false) assert.Equal(t, dirs["/repo/b"], true) } + +// TestResolveDesiredDirsShallowProject verifies that a directory that exists and was asked for is watched at any +// depth. A project close to the filesystem root (/app, /srv/app, a Docker WORKDIR) must not be silently ignored. +func TestResolveDesiredDirsShallowProject(t *testing.T) { + t.Parallel() + + existing := map[string]bool{ + "/": true, "/app": true, "/app/src": true, "/srv": true, "/srv/app": true, + "/home": true, "/home/user": true, "/home/user/project": true, + } + wm := NewWatchManager(io.Discard, func(dir string) bool { return existing[dir] }) + + resolved := wm.ResolveDesiredDirs(map[string]bool{ + "/app": true, + "/app/src": false, + "/srv/app": true, + "/home/user/project": true, + }) + + assert.DeepEqual(t, resolved, map[string]bool{ + "/app": true, + "/app/src": false, + "/srv/app": true, + "/home/user/project": true, + }) +} + +// TestResolveDesiredDirsAncestorFallback verifies that the depth check still guards the fallback to an ancestor, +// so a missing directory never turns into a watch on something too generic like /, /home or /home/user. +func TestResolveDesiredDirsAncestorFallback(t *testing.T) { + t.Parallel() + + existing := map[string]bool{ + "/": true, "/app": true, "/home": true, "/home/user": true, + "/repo": true, "/repo/a": true, "/repo/a/b": true, "/repo/a/b/c": true, + } + wm := NewWatchManager(io.Discard, func(dir string) bool { return existing[dir] }) + + resolved := wm.ResolveDesiredDirs(map[string]bool{ + "/app/missing": true, // ancestor /app is too shallow + "/home/user/missing": true, // ancestor /home/user is too shallow + "/repo/a/b/c/missing/deep": true, // ancestor /repo/a/b/c is deep enough, and is never recursive + "/nothing/exists/anywhere/": true, // no existing ancestor except / + }) + + assert.DeepEqual(t, resolved, map[string]bool{"/repo/a/b/c": false}) +} From 439773ee62990828f1f1760d2d348d81901950b9 Mon Sep 17 00:00:00 2001 From: Generalsimus Date: Wed, 23 Sep 2026 23:17:35 +0400 Subject: [PATCH 2/4] Watch program files at any depth, keep the depth check for lookups The first commit only exempted directories the project declares. A file that is part of the program but only reached through an import (say /shared/s.ts imported from /app, or package.json "imports") still went through CanWatchDirectory in the seen-files loop of tsc --watch and in the tsc -b orchestrator, so editing it never rebuilt. tsc 6.0 watched every program source file at any depth and only applied canWatchDirectoryOrFile to lookup locations (failed resolutions, affecting package.json files, @types roots). Do the same: directories of program files are watched at any depth, lookups keep the check so they never add a watch on / or /home. Embedded libs (bundled:///libs) are skipped. --- tsc/internal/execute/build/orchestrator.go | 18 +++-- .../execute/tsctests/watch_shallow_test.go | 76 +++++++++++++++++++ tsc/internal/execute/watcher.go | 10 ++- .../execute/watchmanager/watchbackend.go | 9 +++ 4 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 tsc/internal/execute/tsctests/watch_shallow_test.go diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index a5ca48c30c234..6b5a5dc37823b 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -507,10 +507,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { // Input file directories not already covered for _, fileName := range task.resolved.FileNames() { absPath := tspath.GetNormalizedAbsolutePath(fileName, o.opts.Sys.GetCurrentDirectory()) - dir := tspath.GetDirectoryPath(absPath) - if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { - desiredDirs.Set(dir, false) - } + o.addProgramFileWatchDir(desiredDirs, tspath.GetDirectoryPath(absPath)) for _, mapper := range task.resolved.ContentMappers() { if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue @@ -549,10 +546,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { if roots.Has(fp) { continue } - dir := tspath.GetDirectoryPath(absPath) - if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { - desiredDirs.Set(dir, false) - } + o.addProgramFileWatchDir(desiredDirs, tspath.GetDirectoryPath(absPath)) } for packageJson := range bi.buildInfo.GetPackageJsons(buildInfoDir) { o.addPackageJsonWatchDirs(desiredDirs, packageJson) @@ -575,6 +569,14 @@ func (o *Orchestrator) addWatchDir(desiredDirs *watchmanager.DirWatchSet, dir st } } +// addProgramFileWatchDir watches the directory of a program file at any depth, unlike addWatchDir, which guards lookup +// locations against watching something as generic as / or /home. +func (o *Orchestrator) addProgramFileWatchDir(desiredDirs *watchmanager.DirWatchSet, dir string) { + if !desiredDirs.Covered(dir) && watchmanager.CanWatchProgramFileDirectory(dir) { + desiredDirs.Set(dir, false) + } +} + func (o *Orchestrator) addPackageJsonWatchDirs(desiredDirs *watchmanager.DirWatchSet, packageJson string) { dir := tspath.GetDirectoryPath(packageJson) dirs := []string{dir} diff --git a/tsc/internal/execute/tsctests/watch_shallow_test.go b/tsc/internal/execute/tsctests/watch_shallow_test.go new file mode 100644 index 0000000000000..6c0696e069948 --- /dev/null +++ b/tsc/internal/execute/tsctests/watch_shallow_test.go @@ -0,0 +1,76 @@ +package tsctests + +import ( + "context" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/execute" + "github.com/microsoft/TypeScript/tsc/internal/fswatch" + "gotest.tools/v3/assert" +) + +// shallowProjectFiles is a project close to the filesystem root (a common Docker WORKDIR) that imports a file from a +// sibling directory which is not part of "include", plus a bare import whose failed lookups walk up to /node_modules. +func shallowProjectFiles(compilerOptions string) FileMap { + return FileMap{ + "/app/tsconfig.json": `{"compilerOptions":{` + compilerOptions + `"rootDir":"..","outDir":"out","noLib":true},"include":["*.ts"]}`, + "/app/index.ts": `import { s } from "../shared/s"; +// @ts-ignore +import "missing-package"; +export const x = s;`, + "/shared/s.ts": `export const s = 1;`, + } +} + +func assertShallowProjectWatches(t *testing.T, sys *TestSys) { + t.Helper() + dirs := sys.mockWatchBackend.Dirs + assert.Assert(t, dirs["/app"] != nil, "the tsconfig directory /app must be watched") + assert.Assert(t, dirs["/shared"] != nil, "the directory of the imported program file /shared/s.ts must be watched") + // Failed lookups of "missing-package" reach /node_modules. They must not turn into a watch on /. + assert.Assert(t, dirs["/"] == nil, "/ must never be watched") +} + +func editShallowProjectFiles(t *testing.T, sys *TestSys, w interface{ DoCycle() }) { + t.Helper() + fs := sys.fsFromFileMap() + + sys.writeFileNoError("/shared/s.ts", `export const s = 2;`) + sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/shared/s.ts"}}) + w.DoCycle() + out, _ := fs.ReadFile("/app/out/shared/s.js") + assert.Assert(t, strings.Contains(out, "s = 2"), "editing /shared/s.ts must rebuild, got:\n%s", out) + + sys.writeFileNoError("/app/index.ts", `import { s } from "../shared/s"; export const y = s;`) + sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/app/index.ts"}}) + w.DoCycle() + out, _ = fs.ReadFile("/app/out/app/index.js") + assert.Assert(t, strings.Contains(out, "y = "), "editing /app/index.ts must rebuild, got:\n%s", out) +} + +// TestWatchShallowProjectWithImportedFile verifies that tsc --watch rebuilds a project that lives close to the +// filesystem root, both for its own files and for a file it imports from outside "include". +func TestWatchShallowProjectWithImportedFile(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{files: shallowProjectFiles(""), cwd: "/app"}, false) + result := execute.CommandLine(context.Background(), sys, []string{"--watch"}, sys) + assert.Assert(t, result.Watcher != nil) + + assertShallowProjectWatches(t, sys) + editShallowProjectFiles(t, sys, result.Watcher) +} + +// TestBuildWatchShallowProjectWithImportedFile is the tsc -b --watch variant of +// TestWatchShallowProjectWithImportedFile. +func TestBuildWatchShallowProjectWithImportedFile(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{files: shallowProjectFiles(`"composite":true,`), cwd: "/app"}, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + result := execute.CommandLine(ctx, sys, []string{"--build", "--watch"}, sys) + assert.Assert(t, result.Watcher != nil) + + assertShallowProjectWatches(t, sys) + editShallowProjectFiles(t, sys, result.Watcher) +} diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index 3a3b9405340c9..fdcd0f93d9054 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -252,9 +252,17 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool for dir, recursive := range resolvedDirs { coverage.Set(dir, recursive) } + programFiles := w.program.GetProgram().FilesByPath() + caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() for _, filePath := range seenFilePaths { dir := tspath.GetDirectoryPath(filePath) - if !coverage.Covered(dir) && watchmanager.CanWatchDirectory(dir) { + if coverage.Covered(dir) { + continue + } + // Seen files mix program files with lookup locations. Only lookups keep the depth check, so an imported + // file outside the tsconfig directory (say /shared next to /app) is still watched. + _, isProgramFile := programFiles[tspath.ToPath(filePath, cwd, caseSensitive)] + if (isProgramFile && watchmanager.CanWatchProgramFileDirectory(dir)) || watchmanager.CanWatchDirectory(dir) { coverage.Set(dir, false) } } diff --git a/tsc/internal/execute/watchmanager/watchbackend.go b/tsc/internal/execute/watchmanager/watchbackend.go index c158f93d00292..38a82ef6cac40 100644 --- a/tsc/internal/execute/watchmanager/watchbackend.go +++ b/tsc/internal/execute/watchmanager/watchbackend.go @@ -4,6 +4,7 @@ import ( "io" "strings" + "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/tspath" ) @@ -77,6 +78,14 @@ func ShouldIgnoreWatchPath(path string) bool { strings.Contains(p, "/.#") } +// CanWatchProgramFileDirectory reports whether the directory of a file that is part of the program can be watched. +// Program files are watched at any depth, like tsc 6.0 watched every source file, so a project near the +// filesystem root (/app, /srv/app, a Docker WORKDIR) still rebuilds. Only virtual locations such as the +// embedded libs are skipped. Lookup locations (package.json, failed resolutions) use CanWatchDirectory instead. +func CanWatchProgramFileDirectory(dir string) bool { + return !bundled.IsBundled(dir) +} + func CanWatchDirectory(dir string) bool { components := tspath.GetPathComponents(dir, "") length := len(components) From 555734a3fd3c594ebb48deed8a0f29fb8086ef1f Mon Sep 17 00:00:00 2001 From: Generalsimus Date: Thu, 24 Sep 2026 14:34:12 +0400 Subject: [PATCH 3/4] Skip non-disk directories in ResolveDesiredDirs instead of a bundled check CanWatchProgramFileDirectory only existed to keep the embedded libs (bundled:///libs) out of the watch set. Before this PR, CanWatchDirectory dropped that path by accident, since it has only two path components. fswatch rejects it as well, but with an error that fails the whole batch. Drop the wrapper and skip any directory that is not a rooted disk path in ResolveDesiredDirs, which every desired directory of tsc --watch and tsc -b --watch already goes through. --- tsc/internal/execute/build/orchestrator.go | 2 +- tsc/internal/execute/watcher.go | 2 +- tsc/internal/execute/watchmanager/watchbackend.go | 9 --------- tsc/internal/execute/watchmanager/watchmanager.go | 7 +++++++ .../execute/watchmanager/watchmanager_test.go | 15 +++++++++++++++ 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index 6b5a5dc37823b..f90f3d3de7826 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -572,7 +572,7 @@ func (o *Orchestrator) addWatchDir(desiredDirs *watchmanager.DirWatchSet, dir st // addProgramFileWatchDir watches the directory of a program file at any depth, unlike addWatchDir, which guards lookup // locations against watching something as generic as / or /home. func (o *Orchestrator) addProgramFileWatchDir(desiredDirs *watchmanager.DirWatchSet, dir string) { - if !desiredDirs.Covered(dir) && watchmanager.CanWatchProgramFileDirectory(dir) { + if !desiredDirs.Covered(dir) { desiredDirs.Set(dir, false) } } diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index fdcd0f93d9054..6c9962b7ff0bf 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -262,7 +262,7 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool // Seen files mix program files with lookup locations. Only lookups keep the depth check, so an imported // file outside the tsconfig directory (say /shared next to /app) is still watched. _, isProgramFile := programFiles[tspath.ToPath(filePath, cwd, caseSensitive)] - if (isProgramFile && watchmanager.CanWatchProgramFileDirectory(dir)) || watchmanager.CanWatchDirectory(dir) { + if isProgramFile || watchmanager.CanWatchDirectory(dir) { coverage.Set(dir, false) } } diff --git a/tsc/internal/execute/watchmanager/watchbackend.go b/tsc/internal/execute/watchmanager/watchbackend.go index 38a82ef6cac40..c158f93d00292 100644 --- a/tsc/internal/execute/watchmanager/watchbackend.go +++ b/tsc/internal/execute/watchmanager/watchbackend.go @@ -4,7 +4,6 @@ import ( "io" "strings" - "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/tspath" ) @@ -78,14 +77,6 @@ func ShouldIgnoreWatchPath(path string) bool { strings.Contains(p, "/.#") } -// CanWatchProgramFileDirectory reports whether the directory of a file that is part of the program can be watched. -// Program files are watched at any depth, like tsc 6.0 watched every source file, so a project near the -// filesystem root (/app, /srv/app, a Docker WORKDIR) still rebuilds. Only virtual locations such as the -// embedded libs are skipped. Lookup locations (package.json, failed resolutions) use CanWatchDirectory instead. -func CanWatchProgramFileDirectory(dir string) bool { - return !bundled.IsBundled(dir) -} - func CanWatchDirectory(dir string) bool { components := tspath.GetPathComponents(dir, "") length := len(components) diff --git a/tsc/internal/execute/watchmanager/watchmanager.go b/tsc/internal/execute/watchmanager/watchmanager.go index 0a0dfd81fbccc..dd6490e9d6a68 100644 --- a/tsc/internal/execute/watchmanager/watchmanager.go +++ b/tsc/internal/execute/watchmanager/watchmanager.go @@ -195,6 +195,13 @@ func (wm *WatchManager) createDirWatchRequest(dir string, entry *watchedDir) Wat func (wm *WatchManager) ResolveDesiredDirs(desiredDirs map[string]bool) map[string]bool { resolved := make(map[string]bool, len(desiredDirs)) for dir, recursive := range desiredDirs { + // Only directories on disk can be watched. The embedded libs (bundled:///libs) exist in the FS but not on disk. + if !tspath.IsRootedDiskPath(dir) { + if wm.DebugLog != nil { + fmt.Fprintf(wm.DebugLog, "[watch] not a disk path: %s\n", dir) + } + continue + } watchDir := dir watchRecursive := recursive for !wm.dirExists(watchDir) { diff --git a/tsc/internal/execute/watchmanager/watchmanager_test.go b/tsc/internal/execute/watchmanager/watchmanager_test.go index d4c47ba8c3920..bbc17792290e6 100644 --- a/tsc/internal/execute/watchmanager/watchmanager_test.go +++ b/tsc/internal/execute/watchmanager/watchmanager_test.go @@ -183,3 +183,18 @@ func TestResolveDesiredDirsAncestorFallback(t *testing.T) { assert.DeepEqual(t, resolved, map[string]bool{"/repo/a/b/c": false}) } + +// TestResolveDesiredDirsSkipsNonDiskPaths verifies that a directory that is not on disk, such as the embedded libs +// (bundled:///libs), is never watched, even though the wrapped FS reports that it exists. +func TestResolveDesiredDirsSkipsNonDiskPaths(t *testing.T) { + t.Parallel() + + wm := NewWatchManager(io.Discard, func(dir string) bool { return true }) + + resolved := wm.ResolveDesiredDirs(map[string]bool{ + "bundled:///libs": false, + "/app": true, + }) + + assert.DeepEqual(t, resolved, map[string]bool{"/app": true}) +} From cb6290d30e6660c8966d19819477b52c90f837af Mon Sep 17 00:00:00 2001 From: Generalsimus Date: Fri, 25 Sep 2026 01:15:38 +0400 Subject: [PATCH 4/4] Keep watching a shallow root file's directory while the file is missing A root file listed in "files" is not in FilesByPath while it does not exist, so the seen-files loop in tsc --watch treated it as a lookup and applied the depth check. For /shared/root.ts next to /app, deleting the file closed the watch on /shared, and recreating it never rebuilt. Treat root files like program files there. tsc -b --watch already watches root file directories at any depth, and tsc 6.0 kept watching missing root files through missingFilesMap. The shallow tests now also check that a watch is still open, since the mock backend keeps closed watches in Dirs. --- .../execute/tsctests/watch_shallow_test.go | 66 +++++++++++++++++-- tsc/internal/execute/watcher.go | 11 +++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/tsc/internal/execute/tsctests/watch_shallow_test.go b/tsc/internal/execute/tsctests/watch_shallow_test.go index 6c0696e069948..79dc968eeb2cb 100644 --- a/tsc/internal/execute/tsctests/watch_shallow_test.go +++ b/tsc/internal/execute/tsctests/watch_shallow_test.go @@ -23,13 +23,18 @@ export const x = s;`, } } +// isWatched reports whether dir has an open watch. The mock keeps closed watches in Dirs, so a nil check is not enough. +func isWatched(sys *TestSys, dir string) bool { + w := sys.mockWatchBackend.Dirs[dir] + return w != nil && !w.Closed +} + func assertShallowProjectWatches(t *testing.T, sys *TestSys) { t.Helper() - dirs := sys.mockWatchBackend.Dirs - assert.Assert(t, dirs["/app"] != nil, "the tsconfig directory /app must be watched") - assert.Assert(t, dirs["/shared"] != nil, "the directory of the imported program file /shared/s.ts must be watched") + assert.Assert(t, isWatched(sys, "/app"), "the tsconfig directory /app must be watched") + assert.Assert(t, isWatched(sys, "/shared"), "the directory of the imported program file /shared/s.ts must be watched") // Failed lookups of "missing-package" reach /node_modules. They must not turn into a watch on /. - assert.Assert(t, dirs["/"] == nil, "/ must never be watched") + assert.Assert(t, !isWatched(sys, "/"), "/ must never be watched") } func editShallowProjectFiles(t *testing.T, sys *TestSys, w interface{ DoCycle() }) { @@ -74,3 +79,56 @@ func TestBuildWatchShallowProjectWithImportedFile(t *testing.T) { assertShallowProjectWatches(t, sys) editShallowProjectFiles(t, sys, result.Watcher) } + +// shallowRootFileProjectFiles is a project in /app whose "files" list a root file in the sibling directory /shared. +func shallowRootFileProjectFiles(compilerOptions string) FileMap { + return FileMap{ + "/app/tsconfig.json": `{"compilerOptions":{` + compilerOptions + `"rootDir":"..","outDir":"out","noLib":true},"files":["index.ts","../shared/root.ts"]}`, + "/app/index.ts": `export const x = 1;`, + "/shared/root.ts": `export const r = 1;`, + } +} + +// deleteAndRecreateShallowRootFile deletes /shared/root.ts and writes it back. While the file is missing it is not +// part of the program, but it is still a root file, so /shared must stay watched for the rebuild on recreation. +func deleteAndRecreateShallowRootFile(t *testing.T, sys *TestSys, w interface{ DoCycle() }) { + t.Helper() + fs := sys.fsFromFileMap() + assert.Assert(t, isWatched(sys, "/shared"), "the directory of the root file /shared/root.ts must be watched") + + sys.removeNoError("/shared/root.ts") + sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventDelete, Path: "/shared/root.ts"}}) + w.DoCycle() + assert.Assert(t, isWatched(sys, "/shared"), "/shared must stay watched while the root file /shared/root.ts is missing") + assert.Assert(t, !isWatched(sys, "/"), "/ must never be watched") + + sys.writeFileNoError("/shared/root.ts", `export const r = 2;`) + sys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: "/shared/root.ts"}}) + w.DoCycle() + out, _ := fs.ReadFile("/app/out/shared/root.js") + assert.Assert(t, strings.Contains(out, "r = 2"), "recreating /shared/root.ts must rebuild, got:\n%s", out) +} + +// TestWatchShallowProjectRecreatedRootFile verifies that tsc --watch rebuilds when a root file near the filesystem +// root is deleted and created again. +func TestWatchShallowProjectRecreatedRootFile(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{files: shallowRootFileProjectFiles(""), cwd: "/app"}, false) + result := execute.CommandLine(context.Background(), sys, []string{"--watch"}, sys) + assert.Assert(t, result.Watcher != nil) + + deleteAndRecreateShallowRootFile(t, sys, result.Watcher) +} + +// TestBuildWatchShallowProjectRecreatedRootFile is the tsc -b --watch variant of +// TestWatchShallowProjectRecreatedRootFile. +func TestBuildWatchShallowProjectRecreatedRootFile(t *testing.T) { + t.Parallel() + sys := newTestSys(&tscInput{files: shallowRootFileProjectFiles(`"composite":true,`), cwd: "/app"}, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + result := execute.CommandLine(ctx, sys, []string{"--build", "--watch"}, sys) + assert.Assert(t, result.Watcher != nil) + + deleteAndRecreateShallowRootFile(t, sys, result.Watcher) +} diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index 6c9962b7ff0bf..ca5d5f8b54795 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -254,15 +254,20 @@ func (w *Watcher) computeDesiredWatches(seenFilePaths []string) map[string]bool } programFiles := w.program.GetProgram().FilesByPath() caseSensitive := w.sys.FS().UseCaseSensitiveFileNames() + rootFiles := collections.NewSetFromItems(core.Map(w.config.FileNames(), func(fileName string) tspath.Path { + return tspath.ToPath(fileName, cwd, caseSensitive) + })...) for _, filePath := range seenFilePaths { dir := tspath.GetDirectoryPath(filePath) if coverage.Covered(dir) { continue } // Seen files mix program files with lookup locations. Only lookups keep the depth check, so an imported - // file outside the tsconfig directory (say /shared next to /app) is still watched. - _, isProgramFile := programFiles[tspath.ToPath(filePath, cwd, caseSensitive)] - if isProgramFile || watchmanager.CanWatchDirectory(dir) { + // file outside the tsconfig directory (say /shared next to /app) is still watched. A root file is not in + // the program while it is missing, but its directory stays watched so that recreating it rebuilds. + p := tspath.ToPath(filePath, cwd, caseSensitive) + _, isProgramFile := programFiles[p] + if isProgramFile || rootFiles.Has(p) || watchmanager.CanWatchDirectory(dir) { coverage.Set(dir, false) } }