LSP: file contents are not re-read when `didChangeWatchedFiles` reports `Created` (type 1) for a file already in the program
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 equivalentworkspace/didChangeWatchedFileshandling to compare against, so this is not a regression from a previous release.
Reproduces on both:
[email protected](latest)[email protected](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:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*"]
}
// src/main.ts
import { thing } from './helper';
export const used = thing;
// 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:
initialize(advertisingworkspace.didChangeWatchedFiles.dynamicRegistration),initialized, thentextDocument/didOpenforsrc/main.ts.textDocument/diagnosticformain.ts→Module '"./helper"' has no exported member 'thing'.(expected at this point)Fix
src/helper.tson disk toexport const thing = 1;, written the way an atomic save does: writehelper.ts.tmp, thenrename("helper.ts.tmp", "helper.ts").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):
{ "changes": [ { "uri": ".../src/helper.ts.tmp", "type": 1 }, { "uri": ".../src/helper.ts", "type": 1 }, { "uri": ".../src/helper.ts.tmp", "type": 3 } ] }textDocument/diagnosticformain.tsagain.
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 <path-to-tsc>
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'.
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: trueand the "TypeScript 7" extension[email protected](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 showhandled method 'workspace/didChangeWatchedFiles'for these changes. - The server is robust in every adjacent scenario tested:
didChange,didClose+didOpen,type: 2on an unopened file, 20 rapid successive writes,delete+createof a previously unknown file, and create-then-modify before notifying. Only "type: 1for 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
failedLookupLocationsfor unresolved modules). Already fixed: aCreatedevent 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 (
didOpenre-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.
Source: microsoft/TypeScript