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
8 changes: 8 additions & 0 deletions .changeset/cache-internal-ts-ast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'svelte2tsx': patch
'svelte-language-server': patch
---

perf: cache internal TypeScript ASTs with incremental reparsing

Use `ts.updateSourceFile()` instead of `ts.createSourceFile()` for repeated svelte2tsx calls on the same file. This reuses unchanged AST subtrees for ~27% faster svelte2tsx transforms on typical single-character edits.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { EncodedSourceMap, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
// @ts-ignore
import { TemplateNode } from 'svelte/types/compiler/interfaces';
import { svelte2tsx, IExportedNames, internalHelpers } from 'svelte2tsx';
import { svelte2tsx, IExportedNames, internalHelpers, Svelte2TsxCache } from 'svelte2tsx';
import ts from 'typescript';
import { Position, Range, TextDocumentContentChangeEvent } from 'vscode-languageserver';
import {
Expand Down Expand Up @@ -186,6 +186,9 @@ export namespace DocumentSnapshot {
}
}

/** Per-file cache for reusing internal TypeScript ASTs across svelte2tsx invocations */
const svelte2tsxCacheMap = new Map<string, Svelte2TsxCache>();
Copy link
Member

@jasonlyu123 jasonlyu123 Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should not have any top-level state. You can put it as a field for GlobalSnapshotsManager. This also needs to be a FileMap for path case-sensitivity.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or a field in Document, so we don't need a map, and it'll be cleared when the document is removed.


/**
* Tries to preprocess the svelte document and convert the contents into better analyzable js/ts(x) content.
*/
Expand All @@ -205,10 +208,17 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
: ts.ScriptKind.JS;

try {
const filePath = document.getFilePath() ?? '';
let cache = svelte2tsxCacheMap.get(filePath);
if (!cache) {
cache = {};
svelte2tsxCacheMap.set(filePath, cache);
}

const tsx = svelte2tsx(text, {
parse: options.parse,
version: options.version,
filename: document.getFilePath() ?? undefined,
filename: filePath || undefined,
isTsFile: scriptKind === ts.ScriptKind.TS,
mode: 'ts',
typingsNamespace: options.typingsNamespace,
Expand All @@ -218,7 +228,8 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
document.config?.compilerOptions?.accessors ??
document.config?.compilerOptions?.customElement,
emitJsDoc: options.emitJsDoc,
rewriteExternalImports: options.rewriteExternalImports
rewriteExternalImports: options.rewriteExternalImports,
cache
});
text = tsx.code;
tsxMap = tsx.map as EncodedSourceMap;
Expand Down
18 changes: 18 additions & 0 deletions packages/svelte2tsx/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import ts from 'typescript';

/**
* Cache for reusing internal TypeScript ASTs across svelte2tsx invocations.
* Pass the same cache object on repeated calls for the same file to enable
* incremental TS reparsing of script contents via `ts.updateSourceFile()`.
*/
export interface Svelte2TsxCache {
instanceScriptAst?: ts.SourceFile;
instanceScriptContent?: string;
moduleScriptAst?: ts.SourceFile;
moduleScriptContent?: string;
}

export interface SvelteCompiledToTsx {
code: string;
map: import("magic-string").SourceMap;
Expand Down Expand Up @@ -95,6 +107,12 @@ export function svelte2tsx(
* from the generated file location.
*/
rewriteExternalImports?: InternalHelpers.RewriteExternalImportsConfig;
/**
* Optional cache for reusing internal TypeScript ASTs across invocations.
* Pass the same object for repeated calls on the same file to enable
* incremental TS reparsing (~2x faster script parsing).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* incremental TS reparsing (~2x faster script parsing).
* incremental TS reparsing.

We shouldn't add this claim here. There are too many variables that affect the number. It doesn't make sense to add it as a documentation.

*/
cache?: Svelte2TsxCache;
Copy link
Member

@jasonlyu123 jasonlyu123 Mar 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need a concrete type here. It can be a Record<string, unknown>. It's a map for svelte2tsx to store the cache. The consumer of the API doesn't need to know what is in the cache.

}
): SvelteCompiledToTsx

Expand Down
1 change: 1 addition & 0 deletions packages/svelte2tsx/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { svelte2tsx } from './svelte2tsx';
export type { Svelte2TsxCache } from './svelte2tsx';
export { emitDts } from './emitDts';
export { internalHelpers } from './helpers';
106 changes: 104 additions & 2 deletions packages/svelte2tsx/src/svelte2tsx/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import MagicString from 'magic-string';
import ts from 'typescript';
import { convertHtmlxToJsx, TemplateProcessResult } from '../htmlxtojsx_v2';
import { parseHtmlx } from '../utils/htmlxparser';
import { addComponentExport } from './addComponentExport';
Expand All @@ -13,6 +14,80 @@ import { parse, VERSION } from 'svelte/compiler';
import { getTopLevelImports } from './utils/tsAst';
import { RewriteExternalImportsOptions } from '../helpers/rewriteExternalImports';

/**
* Cache for reusing internal TypeScript ASTs across svelte2tsx invocations.
* Pass the same cache object on repeated calls for the same file to enable
* incremental TS reparsing of script contents via `ts.updateSourceFile()`.
*/
export interface Svelte2TsxCache {
instanceScriptAst?: ts.SourceFile;
instanceScriptContent?: string;
moduleScriptAst?: ts.SourceFile;
moduleScriptContent?: string;
}

/**
* Compute a ts.TextChangeRange by scanning for common prefix and suffix.
*/
function computeScriptChangeRange(oldText: string, newText: string): ts.TextChangeRange {
let prefixLen = 0;
const minLen = Math.min(oldText.length, newText.length);
while (prefixLen < minLen && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
prefixLen++;
}
let oldSuffix = oldText.length;
let newSuffix = newText.length;
while (
oldSuffix > prefixLen &&
newSuffix > prefixLen &&
oldText.charCodeAt(oldSuffix - 1) === newText.charCodeAt(newSuffix - 1)
) {
oldSuffix--;
newSuffix--;
}
return {
span: { start: prefixLen, length: oldSuffix - prefixLen },
newLength: newSuffix - prefixLen
};
}

/**
* Create or incrementally update a ts.SourceFile for script content.
* Falls back to full parse if cache is unavailable or content is unchanged.
*/
function getOrUpdateSourceFile(
fileName: string,
scriptContent: string,
cache: Svelte2TsxCache | undefined,
cacheKey: 'instanceScript' | 'moduleScript'
): ts.SourceFile {
const astKey = `${cacheKey}Ast` as const;
const contentKey = `${cacheKey}Content` as const;

if (cache?.[astKey] && cache[contentKey] && cache[contentKey] !== scriptContent) {
const changeRange = computeScriptChangeRange(cache[contentKey]!, scriptContent);
const updated = ts.updateSourceFile(cache[astKey]!, scriptContent, changeRange);
cache[astKey] = updated;
cache[contentKey] = scriptContent;
return updated;
}

const ast = ts.createSourceFile(
fileName,
scriptContent,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);

if (cache) {
cache[astKey] = ast;
cache[contentKey] = scriptContent;
}

return ast;
}

function processSvelteTemplate(
str: MagicString,
parse: typeof import('svelte/compiler').parse,
Expand Down Expand Up @@ -55,6 +130,12 @@ export function svelte2tsx(
workspacePath: string;
generatedPath: string;
};
/**
* Optional cache for reusing internal TypeScript ASTs across invocations.
* Pass the same object for repeated calls on the same file to enable
* incremental TS reparsing (~2x faster script parsing).
*/
cache?: Svelte2TsxCache;
} = { parse }
) {
options.mode = options.mode || 'ts';
Expand Down Expand Up @@ -106,7 +187,17 @@ export function svelte2tsx(
let moduleAst: ModuleAst | undefined;

if (moduleScriptTag) {
moduleAst = createModuleAst(str, moduleScriptTag);
const moduleScriptContent = svelte.substring(
moduleScriptTag.content.start,
moduleScriptTag.content.end
);
const moduleScriptAst = getOrUpdateSourceFile(
'component.module.ts.svelte',
moduleScriptContent,
options.cache,
'moduleScript'
);
moduleAst = createModuleAst(str, moduleScriptTag, moduleScriptAst);

if (moduleScriptTag.start != 0) {
//move our module tag to the top
Expand Down Expand Up @@ -143,6 +234,16 @@ export function svelte2tsx(
if (scriptTag.start != instanceScriptTarget) {
str.move(scriptTag.start, scriptTag.end, instanceScriptTarget);
}
const instanceScriptContent = svelte.substring(
scriptTag.content.start,
scriptTag.content.end
);
const instanceScriptAst = getOrUpdateSourceFile(
'component.ts.svelte',
instanceScriptContent,
options.cache,
'instanceScript'
);
const res = processInstanceScriptContent(
str,
scriptTag,
Expand All @@ -155,7 +256,8 @@ export function svelte2tsx(
svelte5Plus,
isRunes,
emitJsDoc,
rewriteExternalImportsOptions
rewriteExternalImportsOptions,
instanceScriptAst
);
uses$$props = uses$$props || res.uses$$props;
uses$$restProps = uses$$restProps || res.uses$$restProps;
Expand Down
19 changes: 11 additions & 8 deletions packages/svelte2tsx/src/svelte2tsx/processInstanceScriptContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,20 @@ export function processInstanceScriptContent(
isSvelte5Plus: boolean,
isRunes: boolean,
emitJsDoc: boolean,
rewriteExternalImports?: RewriteExternalImportsOptions
rewriteExternalImports?: RewriteExternalImportsOptions,
tsAst?: ts.SourceFile
): InstanceScriptProcessResult {
const htmlx = str.original;
const scriptContent = htmlx.substring(script.content.start, script.content.end);
const tsAst = ts.createSourceFile(
'component.ts.svelte',
scriptContent,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
if (!tsAst) {
tsAst = ts.createSourceFile(
'component.ts.svelte',
scriptContent,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
}
const astOffset = script.content.start;
const exportedNames = new ExportedNames(
str,
Expand Down
22 changes: 14 additions & 8 deletions packages/svelte2tsx/src/svelte2tsx/processModuleScriptTag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,22 @@ export interface ModuleAst {
astOffset: number;
}

export function createModuleAst(str: MagicString, script: Node): ModuleAst {
export function createModuleAst(
str: MagicString,
script: Node,
existingAst?: ts.SourceFile
): ModuleAst {
const htmlx = str.original;
const scriptContent = htmlx.substring(script.content.start, script.content.end);
const tsAst = ts.createSourceFile(
'component.module.ts.svelte',
scriptContent,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const tsAst =
existingAst ??
ts.createSourceFile(
'component.module.ts.svelte',
scriptContent,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);

const astOffset = script.content.start;

Expand Down