Skip to content

perf: enable incremental TypeScript reparsing via getChangeRange#2961

Merged
jasonlyu123 merged 8 commits intosveltejs:masterfrom
MathiasWP:perf/incremental-ts-reparsing
Mar 14, 2026
Merged

perf: enable incremental TypeScript reparsing via getChangeRange#2961
jasonlyu123 merged 8 commits intosveltejs:masterfrom
MathiasWP:perf/incremental-ts-reparsing

Conversation

@MathiasWP
Copy link
Contributor

@MathiasWP MathiasWP commented Feb 26, 2026

Summary

Previously, both SvelteDocumentSnapshot and JSOrTSDocumentSnapshot returned undefined from getChangeRange(), which meant TypeScript had to fully reparse every file on each snapshot update. This PR implements getChangeRange() properly, enabling TypeScript's built-in incremental parser.

What changed

  • New computeChangeRange() function — a simple linear scan (using charCodeAt for speed) that finds the minimal diff between old and new text, returning a ts.TextChangeRange with the exact changed span.
  • SvelteDocumentSnapshot.getChangeRange() — now computes the change range between the old snapshot's text and the current text instead of returning undefined.
  • JSOrTSDocumentSnapshot.getChangeRange() — same as above, with an additional optimization: when TS passes the same object reference (which happens because update() mutates the snapshot in-place and TS cached the reference before mutation), it returns a pre-computed change range from the last update() call, avoiding a redundant diff.
  • 31 unit tests covering computeChangeRange correctness and getChangeRange integration for both snapshot types.

How computeChangeRange works

1. Scan forward to find the first differing character (prefix)
2. Scan backward to find the last differing character (suffix)
3. Return the span between them as a TextChangeRange

Why this matters

getChangeRange() is called by TypeScript every time a source file snapshot changes. Previously returning undefined forced a full reparse of the entire file. With a proper change range, TypeScript's incremental parser can:

  1. Reuse unchanged AST nodes — only re-parsing the region that actually changed
  2. Skip reparsing entirely for token-level edits where the AST structure is unchanged

In end-to-end benchmarks (including svelte2tsx, parseHTML, and TS program update), this yields roughly a 5-10% improvement in the full update pipeline.

Test plan

  • All 31 unit tests pass (npm test in packages/language-server)
  • Manual testing in VS Code with Svelte extension on a large project

🤖 Generated with Claude Code

Previously, both SvelteDocumentSnapshot and JSOrTSDocumentSnapshot
returned undefined from getChangeRange(), forcing TypeScript to do a
full reparse on every snapshot update. This adds a computeChangeRange()
function that scans for the minimal diff between old and new text,
enabling TS's incremental parser.

Benchmarks show 2-5x faster reparsing for structural edits and
near-instant reparsing for token-level edits (e.g. typing/renaming).

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

changeset-bot bot commented Feb 26, 2026

🦋 Changeset detected

Latest commit: 8e6ecbb

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

This PR includes changesets to release 1 package
Name Type
svelte-language-server 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

MathiasWP and others added 4 commits February 26, 2026 17:28
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds tests using realistic svelte2tsx output (~150 lines with script,
store subscriptions, template, and component export) and a full
TypeScript service class (~200 lines with decorators, generics, and
multiple methods). Tests cover common edit patterns on these files
and verify that incremental TS reparsing produces correct ASTs.

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

jasonlyu123 commented Feb 27, 2026

I think we could add the change. However, the difference isn't really that big since it's not the part that spends the most time. I tried running a benchmark with our update process, including svelte2tsx, parseHTML and ts program update. The difference is around 5-10% average in 100 iterations. If starting from two 1000-line files and updating both per iteration, it went from 1300ms to 1200ms. So it might not be noticeable in most cases.

For the code changes, I don't think we need the "incremental reparse AST verification" part. We can keep a few of them. Most of the test AI generated in DocumentSnapshot.test.ts is not useful. Could you trim it down? I think the benchmark can also be deleted. It already served the demonstrative purpose. And as mentioned earlier, the benchmark doesn't tell the full picture.

- Remove incremental-reparse-benchmark.ts (served its purpose)
- Trim DocumentSnapshot.test.ts from 152 to 31 tests, keeping only
  essential coverage: basic operations, edge cases, reconstruction
  invariant, snapshot integration, and in-place update tests
- Remove redundant/over-tested sections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MathiasWP
Copy link
Contributor Author

I think we could add the change. However, the difference isn't really that big since it's not the part that spends the most time. I tried running a benchmark with our update process, including svelte2tsx, parseHTML and ts program update. The difference is around 5-10% average in 100 iterations. If starting from two 1000-line files and updating both per iteration, it went from 1300ms to 1200ms. So it might not be noticeable in most cases.

For the code changes, I don't think we need the "incremental reparse AST verification" part. We can keep a few of them. Most of the test AI generated in DocumentSnapshot.test.ts is not useful. Could you trim it down? I think the benchmark can also be deleted. It already served the demonstrative purpose. And as mentioned earlier, the benchmark doesn't tell the full picture.

Thank you for the reply! I do not really have the full grasp of all of the inner workings of this setup (i tried to do some refactorings earlier as well), so i figured it would be worth it to just take a couple of shots with my best AI setup and see if something good comes out of it.

I implemented your feedback, want to re-review?

@MathiasWP
Copy link
Contributor Author

I think we could add the change. However, the difference isn't really that big since it's not the part that spends the most time. I tried running a benchmark with our update process, including svelte2tsx, parseHTML and ts program update.

Also, could you share from your benchmarking what part spends the most time? Or is this a benchmark that is already in the repo that i can run?

@jasonlyu123
Copy link
Member

You can find it here https://github.com/jasonlyu123/language-tools/tree/incremental-benchmark

While setting up this branch, I remembered we actually tried this before in #1192.

- JSOrTSDocumentSnapshot.update() returns new snapshot instead of mutating in-place
- SvelteDocumentSnapshot.getChangeRange returns undefined on scriptKind/parserError changes
- Trim tests to essential coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@MathiasWP
Copy link
Contributor Author

@jasonlyu123 do you want to review the updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jasonlyu123 jasonlyu123 merged commit 95452d7 into sveltejs:master Mar 14, 2026
3 checks passed
@github-actions github-actions bot mentioned this pull request Mar 14, 2026
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