Repository navigation
[API] Redesign client-side snapshot state model #64154
Description
Context
The API currently exposes several overlapping ways to describe state changes:
api.updateSnapshot({ fileChanges })applies caller-reported changes to the API client's latest snapshot.api.createProgram(..., oldProgram, fileChanges)derives a program from the snapshot that ownsoldProgram.- Add optional VFS parameters to updateSnapshot #64115 adds
snapshot.update({ fileSystem }), which layers a virtual filesystem over a snapshot. - The LSP-connected API adopts the language server's canonical state through an empty
updateSnapshot()call.
These operations grew from different requirements and do not form one coherent state-transition model. In particular:
- A standalone API client does not inherently have a meaningful "latest" snapshot. Only an LSP session has canonical current state. Currently, if you call
api.createProgramwithout passing anoldProgram, the behavior implicitly depends on what's in the "latest snapshot." It looks totally unexpected that you could get different results at different times because of intermediate calls. fileChangesdescribes out-of-band mutations rather than performing a state change. It is redundant for in-band virtual filesystem updates that you can do with Add optional VFS parameters to updateSnapshot #64115 and awkward as the default way to obtain TS 6-style incremental behavior.createProgramtaking anoldProgramas the source for the base snapshot to clone from means you can't have a sequence like create a program (S1), perform a file system update (S2), then update the program from S1 while keeping the file system changes from S2. On the other hand, allowingcreateProgramto be based off of an arbitrary snapshot while also taking an arbitraryoldProgrammeans the server doesn't know the relationship between the base snapshot and the snapshot that producedoldProgram, sooldProgramcould never be reused.createProgramcurrently consumes the one inferred-project slot, preventing a snapshot from containing multiple independently API-created programs. (Having multiple programs in a single snapshot can be desirable if they overlap in source files, since binder symbols have referential equality within the same snapshot.)
Redesign
Clarifying the initial state and removing "latest" state
First, I want to replace api.updateSnapshot with two different methods that make it a little more clear what you're getting:
// Only available in LSP-connected API!
// Gets the canonical state of the LSP server:
const s = api.getLatestSnapshot();
// Maybe even name it `getLatestLSPSnapshot()`?
// Available in both LSP mode and standalone mode:
const s = api.getEmptySnapshot();The API client will have no concept of a "latest snapshot"; you can get the LSP server's latest snapshot if you're connected to the LSP.
Updating a snapshot
Any snapshot can be updated with changes passed to snapshot.update(), and everything you can do to clone a snapshot with changes should be expressible through that API, including creating/updating/removing a program, opening/closing a tsconfig project, applying virtual file system overlays, etc.:
const s0 = api.getEmptySnapshot();
const s1 = s0.update({
fileSystem: /* ... */,
createPrograms: [{ rootFiles, options }],
openProjects: [pathToTsconfig],
});getEmptySnapshot (and getLatestSnapshot) can also take a set of initial changes to incorporate (there's no reason to round-trip to get an empty snapshot if you're going to add to it in another request right afterwards). Maybe we should name that method getInitialSnapshot or something, since it's confusing to request an empty snapshot with stuff in it:
const s0 = api.getEmptySnapshot().update({ /* ... */ });
// better written as:
const s0 = api.getInitialSnapshot({ /* ... */ });Or perhaps api.createSnapshot() encodes the meaning well enough whether or not it has arguments:
const s0 = api.createSnapshot(); // empty, not a ton of reason to do this
const s0 = api.createSnapshot({ createPrograms: [/* ... */] });File system updates shouldn't automatically refresh affected projects/programs; we need to allow the API client to specify what it needs:
const s0 = api.getInitialSnapshot({ createPrograms: [{ rootFiles, options }] });
// No program update performed, but program is marked dirty if affected
const s1 = s0.update({ fileSystem: /* ... */ });
let program = s1.programs[0];
program.dirty; // true
// Updates the program if dirty
const s2 = s1.update({ ensurePrograms: [program.id] });
program = s2.programs[0];
program.dirty; // false(We may want to have createProgram pass a program id and look it up from a map rather than autogenerating one and accessing programs in an array.)
This solves the problem of combining efficient updates of existing programs with other snapshot changes without introducing the possibility of grafting a program from one snapshot into an unrelated one with unpredictable results. (It loses the ability to “move” a tsconfig project’s program into an isolated snapshot, but that was sort of a side effect of the current design rather than a use case we were designing for.)
Convenience APIs
One motivation of #63950 was to hide snapshot management for API users who only need to create one program, or multiple disconnected programs, and never need to update them over time. api.createProgram should still exist, but it can be sugar over the lower-level snapshot update API:
const program = api.createProgram({ rootFiles, options });
// Sugar for:
const program = api.getInitialSnapshot({
createPrograms: [{ rootFiles, options }]
}).programs[0];I'm undecided whether we should expose a way to update that program without going back out to the snapshot.update API. I think it would be reasonable to say that if you want to do anything that evolves a non-initial snapshot, you should get comfortable using snapshot.update.
Architectural changes
We need to change a few things on the server side to make this happen.
- The
api.Sessionreally doesn't need to run its snapshot updating work throughproject.Sessionunless it's in LSP-connection mode. That will remove the concept of a latest/canonical snapshot from the API server when running in standalone mode. We should extract the parts ofproject.Sessionthat own global caches, i.e. the interface thatsnapshot.Clonerelies on, into an object that's separate from theproject.Session's ownership of a single latest state and reactions to updating that state. In standalone mode, theapi.Sessioncan fulfill that interface or own an object implementing that interface rather than owning a fullproject.Session. - We need to use a different storage mechanism for API-created programs than the single inferred project of
project.ProjectCollection. Probably a separate map of projects, leaving both configured and inferred projects alone. (This is needed both to support multiple API-created program in the same snapshot as well as adding a single API-created program to an LSP snapshot where there may already be an inferred project.)
These are both moderately sized refactors, but I think they'll actually simplify the mental model, and everything else should fall cleanly out of these moves.
Activity
DanielRosenwasser commentedon Sep 3, 2026
I am fine with this - if we have a broader concept (e.g. a daemon mode down the line), we can have them mean the same thing. Bikeshed:
getLanguageServerSnapshot()orgetLatestLanguageServerSnapshot()?I do like the name but I feel like it could be misinterpreted as "get the current LSP snapshot". Maybe it doesn't matter.
DanielRosenwasser commentedon Sep 3, 2026
So does
dirtybasically mean "we applied some changes on top of the last snapshot, but the program is out of date with respect to what's on disk"?DanielRosenwasser commentedon Sep 3, 2026
It would be slightly annoying, but maybe working with snapshots are a necessary detail for this stuff anyway. We could also make
createProgramreturn a[Program, Snapshot]pair, but a user could always build that abstraction too.mrazauskas commentedon Sep 8, 2026
An option to set implied/enforced compiler options programmatically is what could be added to
api.createSnapshot(). For instance, I would like all programs to be created with{ skipLibCheck: false }enforced; otherwise, type errors in declaration files are not reported. In Strada, I was usingProjectService, and it hadproject.setCompilerOptions().As a workaround, now I create a virtual TSConfig file that extends users’
tsconfig.json. The downside is that the name of the virtual file becomes visible in error messages.By the way,
tscworks similarly. It readstsconfig.json, but allows overrides via flags. The overrides are missing in the API; perhaps this fits with the redesign?