microsoft / microsoft/TypeScript
API returns a stale SourceFile after the latest snapshot is disposed
@andrewbranch is already working on this.
Since Aug 19, 2026.
- Dominant language
- Go
- Stars
- 111k
- Forks
- 14.3k
- Avg merge
- 2d 4h
- Merged PRs (30d)
- 132
Description
## Summary
When an API client queries a source file, disposes the latest snapshot, edits the open file, and calls `updateSnapshot` again, the new snapshot can return the cached `SourceFile` from the disposed snapshot.
In VS Code this presents as follows: the first edit is reflected in the first API snapshot, but after that snapshot is disposed, a snapshot created for the second edit still contains the text from the first edit.
Observed with `@typescript/native` version `7.1.0-dev.20260818.1` using an API created through `API.fromLSPConnection`.
The caller follows this pattern:
```ts
const snapshot = await api.updateSnapshot({ openFiles: [{ uri }] });
try {
const project = await snapshot.getDefaultProjectForFile({ uri });
const sourceFile = await project?.program.getSourceFile({ uri });
// sourceFile.text can contain the previous edit here
} finally {
await snapshot.dispose();
}
```
## Steps to reproduce
1. Start the native preview language server and create an API with `API.fromLSPConnection`.
2. Edit an open TypeScript file.
3. Call `updateSnapshot`, fetch its `SourceFile` (populating the client-side source-file cache), and dispose the snapshot.
4. Edit the same file again and let the LSP `didChange` notification complete.
5. Call `updateSnapshot` again and fetch the same `SourceFile`.
A direct API regression test can use the equivalent lifecycle:
```ts
const firstSnapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" });
const firstSourceFile = await firstSnapshot
.getProject("/tsconfig.json")!
.program.getSourceFile("/src/foo.ts");
assert.ok(firstSourceFile);
await firstSnapshot.dispose();
fs.writeFile!("/src/foo.ts", `export const foo = "changed";`);
const secondSnapshot = await api.updateSnapshot({
fileChanges: { changed: ["/src/foo.ts"] },
});
const secondSourceFile = await secondSnapshot
.getProject("/tsconfig.json")!
.program.getSourceFile("/src/foo.ts");
assert.equal(secondSourceFile?.text, `export const foo = "changed";`);
```
## Expected behavior
The second snapshot returns a `SourceFile` containing the second edit.
## Actual behavior
The second snapshot can return the cached `SourceFile` containing the first edit.
## Likely cause
The server and JS client appear to have different lifetime assumptions for the latest disposed snapshot:
- `Session.releaseSnapshot` deletes the latest snapshot from `s.snapshots` when its API refcount reaches zero, while `s.latestSnapshot` still points to that handle.
- On the next `handleUpdateSnapshot`, `prevSD := s.snapshots[s.latestSnapshot]` is therefore `nil`, so the response has no `SnapshotChanges` diff.
- The JS API intentionally retains cache references for a disposed latest snapshot. On the next update it calls `SourceFileCache.retainForSnapshot` before releasing that snapshot's cache references.
- With `data.changes === undefined`, the cache treats every previously fetched source file as unchanged and retains it for the new snapshot. `getSourceFile` then returns it through `getRetained` without asking the server for the new content.
Relevant code paths:
- `internal/api/session.go`: `releaseSnapshot` and `handleUpdateSnapshot`
- `_packages/native-preview/src/api/async/api.ts`: `API.updateSnapshot`
- `_packages/native-preview/src/api/sourceFileCache.ts`: `retainForSnapshot` and `getRetained`
Either the server needs to retain a diff base independently of the client-visible snapshot lifetime, or the client must not carry cache entries forward when its previous latest snapshot was disposed and no reliable diff is available.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.