Skip to content

perf: cache internal TS ASTs with incremental reparsing#2963

Open
MathiasWP wants to merge 3 commits intosveltejs:masterfrom
MathiasWP:perf/cache-internal-ts-ast
Open

perf: cache internal TS ASTs with incremental reparsing#2963
MathiasWP wants to merge 3 commits intosveltejs:masterfrom
MathiasWP:perf/cache-internal-ts-ast

Conversation

@MathiasWP
Copy link
Contributor

Summary

  • Cache internal ts.SourceFile ASTs across svelte2tsx invocations and use ts.updateSourceFile() for incremental reparsing instead of ts.createSourceFile() on every call
  • The language server maintains a per-file cache that is passed through to svelte2tsx, enabling TypeScript to reuse unchanged AST subtrees
  • Falls back to full parsing when no prior AST is available or content is unchanged

Benchmark results

Isolated svelte2tsx (80 sequential single-char edits, realistic ~100 line component):

Median Savings
Without cache 1.02ms
With cache 0.74ms 27% faster (0.28ms/keystroke)

Full LSP round-trip (type char → hover):

  • Median: 3.1ms — the cache saves ~0.2-0.5ms per request, with the TS language service being the dominant cost

How it works

Inside svelte2tsx, there are two ts.createSourceFile() calls that parse <script> and <script context="module"> contents into TypeScript ASTs. These account for ~18% of total svelte2tsx time.

TypeScript provides ts.updateSourceFile(oldAst, newText, changeRange) which reuses unchanged AST subtrees. For single-character edits (typical typing), only the affected statement/block is reparsed.

The implementation:

  1. Adds a Svelte2TsxCache interface with fields for cached ASTs and their content
  2. Adds computeScriptChangeRange() to compute ts.TextChangeRange by scanning common prefix/suffix
  3. Adds getOrUpdateSourceFile() that uses ts.updateSourceFile() when a cached AST exists, falls back to ts.createSourceFile() otherwise
  4. Threads the cache from the language server through to both instance and module script processing

Test plan

  • All 365 svelte2tsx tests pass
  • All 472 passing language-server tests pass (123 pre-existing failures unchanged)
  • Verified ts.updateSourceFile() preserves parent node references
  • Verified fallback to full parse when cache is empty or content unchanged

🤖 Generated with Claude Code

Use ts.updateSourceFile() instead of ts.createSourceFile() for the
internal script ASTs inside svelte2tsx. When the same cache object is
passed across invocations for a file, only the changed subtree is
reparsed, yielding ~27% faster svelte2tsx transforms on typical
single-character edits (~0.3ms saved per keystroke for a 100-line
component).

The cache is keyed per-file in the language server and falls back to
full parsing when no prior AST is available or content is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

🦋 Changeset detected

Latest commit: b1cc663

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
svelte2tsx Patch
svelte-language-server Patch
typescript-svelte-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Member

@jasonlyu123 jasonlyu123 left a comment

Choose a reason for hiding this comment

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

This will increase memory usage. In my testing, the memory usage is around 5% increase, but it might be a bigger problem in a bigger project. I think it'll be better cache only the files that are opened in the editor (Document.openedByClient). We can save some unnecessary memory allocation cost this way. You also need to remove the cache when the file is closed or deleted. The logic is most suitable in LSAndTSDocResolver.

In svelte-check without the --watch flag. The cache doesn't do anything, so there should be a way to skip it.

I actually also tried this before, but the difference is so small that it is sometimes slower. We'll need a more end-to-end benchmark to check if it's actually faster.

/**
* 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.

}

/** 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.

@MathiasWP
Copy link
Contributor Author

I've noticed that benchmarks i write with Claude doesn't seem to reflect real improvements when in an E2E environment. I asked you earlier about how you benchmarked this, but i'm wondering if it would be possible to create an E2E script where i could run benchmarks to get proper output? It seems like i just end up with incorrect results, which in turn makes the optimisations useless. I really want to help, so if you have an idea on what would be required to create a script that could do a proper E2E test, i could try to set this up? @jasonlyu123

@jasonlyu123
Copy link
Member

To be clear, I was mostly referring to the "improvement ratio". If the improvement target accounts for only a minor portion of the overall time, it might not be significant, even if it's 50x faster. In that case, the "50x faster" claim is just kind of unhelpful.

You can reference service.test.ts to benchmark the full process. The test in the files is in a more isolated virtual file system. The isolation can be a benefit for optimisation that isn't related to file-system IO. Usually, I'll just add a loop in the language-server code to run a task multiple times and see if it makes any difference. Also, the performance problem that we can more confidently identify is found in the flame graph. In that case, the benchmark script might not be necessary.

* Pass the same object for repeated calls on the same file to enable
* incremental TS reparsing (~2x faster script parsing).
*/
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants