Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions internal/execute/tsctests/watcher_race_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -290,3 +292,118 @@ func TestBuildWatchStopsWhenContextIsCancelled(t *testing.T) {
t.Fatal("build watch did not stop after context cancellation")
}
}

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 = <div />;`,
"/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 = <div />;`)
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.
func TestWatcherUpdateProgramFastPath(t *testing.T) {
t.Parallel()

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)
}
125 changes: 118 additions & 7 deletions internal/execute/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"reflect"
"slices"
"time"

"github.com/microsoft/typescript-go/internal/ast"
Expand Down Expand Up @@ -73,9 +74,11 @@ 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
programReady bool
}

var _ tsc.Watcher = (*Watcher)(nil)
Expand Down Expand Up @@ -130,6 +133,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()
}
Expand Down Expand Up @@ -225,6 +229,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))
Expand All @@ -237,6 +256,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 {
Expand Down Expand Up @@ -282,20 +302,64 @@ 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.config = newConfig
} else {
w.watchSetDirty = false
w.config = newConfig
}
} else if !w.configModified {
w.watchSetDirty = false
}
}

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}

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())
tfs := &trackingvfs.FS{Inner: cached}
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())
}
}
Expand All @@ -307,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()
Expand All @@ -330,6 +395,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()
Expand All @@ -353,6 +419,51 @@ 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
}

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)
}
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()
Expand Down