microsoft / microsoft/TypeScript

LSP: file contents are not re-read when `didChangeWatchedFiles` reports `Created` (type 1) for a file already in the program

Open
#64,195 0 comments 0 reactions 1 assignee Claimed by @johnfav03 View on GitHub
Needs Investigation
Dominant language
Go
Stars
111k
Forks
14.3k
Avg merge
2d 4h
Merged PRs (30d)
132

Description

### 🔎 Search Terms

```
didChangeWatchedFiles
FileChangeType.Created
type 1 Created event stale contents
language server stale file contents
LSP requires restart types out of sync
atomic save rename language server
has no exported member after the file was fixed
watched files not reloaded
tsc --lsp
```

### 🕗 Version & Regression Information

- I was unable to test this on prior versions because the LSP server (`tsc --lsp`)
is new in TypeScript 7; TypeScript 6.x and earlier have no equivalent
`workspace/didChangeWatchedFiles` handling to compare against, so this is not a
regression from a previous release.

Reproduces on both:

- `typescript@7.0.2` (`latest`)
- `typescript@7.1.0-dev.20260907.1` (`next`)

`every-ts` bisection is not applicable for the same reason — the code path does not
exist in earlier versions.

### ⏯ Playground Link

Not applicable. The bug is in how the language server reacts to file-system change notifications, so it cannot be reproduced in the Playground. A standalone LSP client script that reproduces it in isolation is attached instead (see 💻 Code).

### 💻 Code

Three files:

```jsonc
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*"]
}
```

```ts
// src/main.ts
import { thing } from './helper';

export const used = thing;
```

```ts
// src/helper.ts — starts out without `thing`, so main.ts has an error
export const other = 1;
```

Then, over LSP with `main.ts` open and `helper.ts` **not** open:

1. `initialize` (advertising `workspace.didChangeWatchedFiles.dynamicRegistration`),
`initialized`, then `textDocument/didOpen` for `src/main.ts`.
2. `textDocument/diagnostic` for `main.ts` →
`Module '"./helper"' has no exported member 'thing'.` (expected at this point)
3. Fix `src/helper.ts` on disk to `export const thing = 1;`, written the way an
atomic save does: write `helper.ts.tmp`, then `rename("helper.ts.tmp", "helper.ts")`.
4. Send the change notification with the events a client actually sends for that
rename (VS Code's watcher reports a rename-over as a *create*):

```json
{ "changes": [
{ "uri": ".../src/helper.ts.tmp", "type": 1 },
{ "uri": ".../src/helper.ts", "type": 1 },
{ "uri": ".../src/helper.ts.tmp", "type": 3 } ] }
```

5. `textDocument/diagnostic` for `main.ts` again.

**Attached: `repro-created-event.js`** — performs exactly these steps against
`tsc --lsp --stdio`. Node only, no dependencies, no editor involved:

```
$ node repro-created-event.js
PASS Changed (type 2) only — control
FAIL Created (type 1) only
still reported: Module '"./helper"' has no exported member 'thing'.
FAIL what VS Code sends for an atomic save
still reported: Module '"./helper"' has no exported member 'thing'.
```

[repro-created-event.js](https://github.com/user-attachments/files/31939009/repro-created-event.js)

### 🙁 Actual behavior

After `helper.ts` is fixed on disk, `textDocument/diagnostic` for `main.ts` keeps
returning `Module '"./helper"' has no exported member 'thing'.` The server never
re-reads `helper.ts`, so the program holds the old contents indefinitely — the stale
diagnostic survives further edits to other files and only goes away on a server
restart, or on `textDocument/didOpen` of `helper.ts` (which supplies the contents
explicitly).

The trigger is the change type. For the same on-disk change:

| notification for the changed file | result |
| --- | --- |
| `type: 2` (Changed) only | ✓ error clears (correct) |
| `type: 1` (Created) only | ✗ error persists |
| tmp `create` → target `create` → tmp `delete` (what VS Code sends) | ✗ error persists |

### 🙂 Expected behavior

All three should clear the diagnostic, since the file's contents on disk changed.

A `Created` event for a path that is already in the program should invalidate that
file's cached contents, the same way `Changed` does. A client cannot reliably tell
the two apart: on Linux, `rename()` over an existing path produces `IN_MOVED_TO`,
which is a *create* from the watcher's point of view — VS Code therefore sends
`type: 1` for what is semantically a modification.

This matters in practice because saving atomically (write a temp file, then rename
over the target) is a common pattern — formatters, code generators, editors with
`files.atomicSave`, and AI coding tools all do it. Every such save takes this path,
so the user-visible result is a permanent, wrong `has no exported member` on an
import line, "fixed" only by restarting the language server.

### Additional information about the issue

### Environment

- TypeScript 7.0.2 and 7.1.0-dev.20260907.1, Linux (arm64), Node 24.14.0
- Language server started as `tsc --lsp --stdio`
- Also observed through VS Code 1.127.0 with `js/ts.experimental.useTsgo: true`
and the "TypeScript 7" extension `TypeScriptTeam.native-preview@0.20260708.2`
(whose bundled binary also reports 7.0.2)

### What is *not* the cause

- The watcher globs the server registers are `**/*` (the extension-suffixed globs
from microsoft/typescript-go#2506 are gone), so the event does reach the server —
VS Code's own logs show `handled method 'workspace/didChangeWatchedFiles'` for
these changes.
- The server is robust in every adjacent scenario tested: `didChange`,
`didClose` + `didOpen`, `type: 2` on an unopened file, 20 rapid successive writes,
`delete` + `create` of a previously *unknown* file, and create-then-modify before
notifying. Only "`type: 1` for a path already in the program" fails.
- Not a client-side file-watching problem: reproduced with a plain Node LSP client,
with no editor in the picture.

### Possibly related

- microsoft/typescript-go#2506 — same area (watched-file handling), different cause
(extension-suffixed watch globs and `failedLookupLocations` for *unresolved*
modules). Already fixed: a `Created` event for a path that is **not** yet in the
program does work.
- JetBrains WEB-76338 "Typescript Go LSP often requires restart as types get out of
sync" — same user-visible symptom from a different client, no root cause
identified. Possibly the same bug, since JetBrains also saves atomically.

### Workarounds

- Open the changed file in the editor (`didOpen` re-reads from disk).
- Restart the language server.
- Follow the atomic write with an in-place rewrite of identical bytes (produces
`IN_MODIFY` → `type: 2`). Note the client may coalesce the two events if they are
close together: with VS Code's watcher, a gap of ~600ms or more was needed before
the second event was reported as an update rather than another create.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.