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
9 changes: 9 additions & 0 deletions .changeset/sour-hounds-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@typescript/vfs": patch
---

Stop retaining a second parsed SourceFile per file in the virtual environment

`createVirtualTypeScriptEnvironment`'s `createFile`/`updateFile` parsed a `SourceFile` and handed it to the virtual compiler host, which kept it in its `sourceFiles` map for the lifetime of the environment. The language service never reads that map — it parses and caches its own `SourceFile`s from `getScriptSnapshot` — so every file written through the environment was held in memory as two full ASTs, and the parse which produced the second one was thrown away.

The environment now writes file contents through a new `writeFileText`/`updateFileText` path which only stores text. In a 40-file benchmark this cut retained heap from 318MB to 209MB and removed 80 redundant parses.
46 changes: 30 additions & 16 deletions packages/typescript-vfs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function createVirtualTypeScriptEnvironment(
): VirtualTypeScriptEnvironment {
const mergedCompilerOpts = { ...defaultCompilerOptions(ts), ...compilerOptions }

const { languageServiceHost, updateFile, deleteFile } = createVirtualLanguageServiceHost(
const { languageServiceHost, updateFileText, deleteFile } = createVirtualLanguageServiceHost(
sys,
rootFiles,
mergedCompilerOpts,
Expand All @@ -78,7 +78,7 @@ export function createVirtualTypeScriptEnvironment(
getSourceFile: fileName => languageService.getProgram()?.getSourceFile(fileName),

createFile: (fileName, content) => {
updateFile(ts.createSourceFile(fileName, content, mergedCompilerOpts.target!, false))
updateFileText(fileName, content)
},
updateFile: (fileName, content, optPrevTextSpan) => {
const prevSourceFile = languageService.getProgram()!.getSourceFile(fileName)
Expand All @@ -93,12 +93,8 @@ export function createVirtualTypeScriptEnvironment(
prevFullContents.slice(0, prevTextSpan.start) +
content +
prevFullContents.slice(prevTextSpan.start + prevTextSpan.length)
const newSourceFile = ts.updateSourceFile(prevSourceFile, newText, {
span: prevTextSpan,
newLength: content.length,
})

updateFile(newSourceFile)
updateFileText(fileName, newText)
},
deleteFile(fileName) {
const sourceFile = languageService.getProgram()!.getSourceFile(fileName)
Expand Down Expand Up @@ -618,6 +614,7 @@ export function createVirtualCompilerHost(sys: System, compilerOptions: Compiler
compilerHost: CompilerHost
updateFile: (sourceFile: SourceFile) => boolean
deleteFile: (sourceFile: SourceFile) => boolean
writeFileText: (fileName: string, text: string) => boolean
}

const vHost: Return = {
Expand Down Expand Up @@ -653,6 +650,16 @@ export function createVirtualCompilerHost(sys: System, compilerOptions: Compiler
sourceFiles.delete(sourceFile.fileName)
sys.deleteFile!(sourceFile.fileName)
return alreadyExists
},
// Writes a file's contents without keeping a parsed SourceFile for it. Callers which parse
// their own SourceFiles (like the language service) would otherwise keep a second AST per
// file alive for the lifetime of the host. Any previously cached AST is dropped so that
// getSourceFile re-parses the new contents.
writeFileText: (fileName, text) => {
const alreadyExists = sourceFiles.has(fileName)
sourceFiles.delete(fileName)
sys.writeFile(fileName, text)
return alreadyExists
}
}
return vHost
Expand All @@ -669,7 +676,7 @@ export function createVirtualLanguageServiceHost(
customTransformers?: CustomTransformers
) {
const fileNames = [...rootFiles]
const { compilerHost, updateFile, deleteFile } = createVirtualCompilerHost(sys, compilerOptions, ts)
const { compilerHost, writeFileText, deleteFile } = createVirtualCompilerHost(sys, compilerOptions, ts)
const fileVersions = new Map<string, string>()
let projectVersion = 0
const languageServiceHost: LanguageServiceHost = {
Expand Down Expand Up @@ -702,19 +709,26 @@ export function createVirtualLanguageServiceHost(
type Return = {
languageServiceHost: LanguageServiceHost
updateFile: (sourceFile: import("typescript").SourceFile) => void
updateFileText: (fileName: string, text: string) => void
deleteFile: (sourceFile: import("typescript").SourceFile) => void
}

// Only the text of a file is stored: the language service reads it back through
// getScriptSnapshot and parses (and caches) its own SourceFile, so holding on to a parsed
// copy here would keep a second AST per file alive for the lifetime of the host.
const updateFileText = (fileName: string, text: string) => {
projectVersion++
fileVersions.set(fileName, projectVersion.toString())
if (!fileNames.includes(fileName)) {
fileNames.push(fileName)
}
writeFileText(fileName, text)
}

const lsHost: Return = {
languageServiceHost,
updateFile: sourceFile => {
projectVersion++
fileVersions.set(sourceFile.fileName, projectVersion.toString())
if (!fileNames.includes(sourceFile.fileName)) {
fileNames.push(sourceFile.fileName)
}
updateFile(sourceFile)
},
updateFile: sourceFile => updateFileText(sourceFile.fileName, sourceFile.text),
updateFileText,
deleteFile: sourceFile => {
projectVersion++
fileVersions.set(sourceFile.fileName, projectVersion.toString())
Expand Down
70 changes: 70 additions & 0 deletions packages/typescript-vfs/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,58 @@ it("emits new files to the fsMap", () => {
expect(Array.from(fsMap.keys())).toContain("index.js")
})

it("creates, updates and re-updates a file through the environment", () => {
const fsMap = createDefaultMapFromNodeModules({})
fsMap.set("index.ts", "const hello = 'hi'")

const system = createSystem(fsMap)
const env = createVirtualTypeScriptEnvironment(system, ["index.ts"], ts, {})

env.createFile("other.ts", "export const n: number = 'no'")
const errors = env.languageService
.getSemanticDiagnostics("other.ts")
.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
expect(errors).toEqual(["Type 'string' is not assignable to type 'number'."])

env.updateFile("other.ts", "export const n: number = 1")
expect(env.getSourceFile("other.ts")!.text).toEqual("export const n: number = 1")
expect(env.languageService.getSemanticDiagnostics("other.ts").length).toBe(0)

// Passing a text span replaces only that range of the previous contents
env.updateFile("other.ts", "5", ts.createTextSpan("export const n: number = ".length, 1))
expect(env.getSourceFile("other.ts")!.text).toEqual("export const n: number = 5")
expect(system.readFile("other.ts")).toEqual("export const n: number = 5")
expect(env.languageService.getSemanticDiagnostics("other.ts").length).toBe(0)
})

// The language service parses and caches a SourceFile for every file it reads through
// getScriptSnapshot, so anything the environment parses on top of that is a second AST which
// is kept alive for as long as the environment is.
it("does not parse SourceFiles of its own when files are created or updated", () => {
const fsMap = createDefaultMapFromNodeModules({})
fsMap.set("index.ts", "const hello = 'hi'")

let parses = 0
const countingTS: typeof ts = new Proxy(ts, {
get: (target: any, key) =>
key === "createSourceFile" || key === "updateSourceFile"
? (...args: any[]) => {
parses++
return target[key](...args)
}
: target[key],
})

const system = createSystem(fsMap)
const env = createVirtualTypeScriptEnvironment(system, ["index.ts"], countingTS, {})

env.createFile("other.ts", "export const n = 1")
env.updateFile("other.ts", "export const n = 2")
expect(env.languageService.getSemanticDiagnostics("other.ts").length).toBe(0)

expect(parses).toEqual(0)
})

it("creates a map from the CDN without cache", async () => {
const fetcher = jest.fn()
fetcher.mockResolvedValue({ text: () => Promise.resolve("// Contents of file") })
Expand Down Expand Up @@ -228,6 +280,24 @@ it("empty file content", async () => {
})
})

it("writes file contents without leaving a stale SourceFile in the compiler host", () => {
const options = { target: ts.ScriptTarget.ES2020 }
const fsMap = createDefaultMapFromNodeModules(options, ts)
fsMap.set("index.ts", "const a = 1")
const system = createSystem(fsMap)
const host = createVirtualCompilerHost(system, options, ts)

// Reading it once caches a parsed SourceFile in the host
expect(host.compilerHost.getSourceFile("index.ts", ts.ScriptTarget.ES2020)!.text).toEqual("const a = 1")

expect(host.writeFileText("index.ts", "const a = 2")).toEqual(true)
expect(system.readFile("index.ts")).toEqual("const a = 2")
expect(host.compilerHost.getSourceFile("index.ts", ts.ScriptTarget.ES2020)!.text).toEqual("const a = 2")

expect(host.writeFileText("new.ts", "const b = 1")).toEqual(false)
expect(system.readFile("new.ts")).toEqual("const b = 1")
})

it("moduleDetection options", async () => {
const options: ts.CompilerOptions = {
module: ts.ModuleKind.AMD,
Expand Down