Skip to content

Cut evaluation plugin overhead and debounce diagnostics - #166

Open
jdesrosiers wants to merge 7 commits into
mainfrom
annotation-performance
Open

jdesrosiers wants to merge 7 commits into
mainfrom
annotation-performance

Conversation

@jdesrosiers

Copy link
Copy Markdown
Collaborator

The language server's evaluation plugin adds a lot to every validation. With a 150KB GitHub workflow, it added about 260ms on top of hyperjump's own ~50ms. These changes cut the plugin's own overhead from about 260ms to about 90ms and stop sending diagnostics while someone is typing.

  • Index incomplete locations by their parent. The object keywords looked at every incomplete location in the document each time they were evaluated, and every array has one. Now they only look at the ones in the object they apply to.
  • Combine oneOf completions without a Pact pipeline. A pipeline ran for every location under every oneOf. Now one pass groups the sets by location.
  • Mark conditional annotations while appending them. Every anyOf/oneOf alternative copied its subschema's whole annotation record just to mark annotations at one location as value annotations.
  • Store subschema tracking state on the context. SubschemaTracker set a WeakMap entry for every keyword. Its state is now a property under a symbol that's unique to the tracker, so trackers still don't share state. This relies on hyperjump creating a fresh context for each keyword, which the WeakMap version also relied on.
  • Filter annotations by kind with a loop. Deprecated diagnostics asks for annotations at every node, and the Pact pipeline dominated that walk.
  • Use the pointers walkNodes builds for document colors. getPointerForNode searches arrays for the node's index, so colors were quadratic in array size.
  • Wait for edits to pause before sending diagnostics. Diagnostics for an edit are sent once the document hasn't changed for 500ms, like VS Code's JSON language server. A document is validated without waiting when it's opened. A change or close cancels a send that's waiting or in progress. The delay is a buildServer option so tests don't wait for it.

Measured with a generated 154KB GitHub workflow against the SchemaStore schema (medians):

Before After
Validation ~310 ms ~144 ms
Annotations at every node (deprecated diagnostics, colors) ~26 ms ~3 ms
hyperjump alone, JSE output ~53 ms ~53 ms

Most of what's left is copying completions and annotations up through each schema, which keeps the plugin simple. Recording a tree during evaluation and flattening it when requested took validation to ~200 ms before the last two improvements, but it was left out because it makes the plugin harder to follow. The JSE output plugin is a large part of the remaining time. It computes Instance.uri three times per keyword, and computing it once saves about 20 ms. That fix belongs in @hyperjump/json-schema-errors.

Testing

The debounce has new tests for opening a document, changes in quick succession, and closing before the delay is over. The other changes aren't intended to change behavior. All 540 existing tests pass.

🤖 Generated with Claude Code

jdesrosiers and others added 7 commits October 20, 2026 21:46
DeprecatedDiagnosticsProvider gets the location and value annotations of
every node in the document, and most of that time was the overhead of
the Pact pipeline that filtered them. A plain loop took the walk over a
150KB GitHub workflow from about 18ms to 2ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The object keywords looked at every incomplete location in the document
each time they were evaluated, and every array has one. Incomplete
locations are now grouped by their parent's pointer so a keyword only
looks at the ones in the object it applies to. Validating a 150KB GitHub
workflow went from about 103ms to 84ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getPointerForNode searches each array for the node's index, so getting
the pointer of every string in a document with large arrays was
quadratic. walkNodes already builds the pointers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SubschemaTracker kept its state in WeakMaps keyed by context, and
setting a WeakMap entry for every keyword was a large part of the
plugin's time. The state is now a property of the context under a
symbol that's unique to the tracker, so trackers still don't share
state. Validating a 150KB GitHub workflow and getting its annotations
went from about 57ms to 46ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every change to a document validated it against its schema and sent
diagnostics, even while someone was typing faster than the results could
be read. Diagnostics for an edit are now sent once the document hasn't
changed for 500ms, like VS Code's JSON language server. Unlike VS Code,
a document is validated without waiting when it's opened. A change or
close cancels a send that's waiting as well as one that's in progress.
The delay is an option of buildServer so tests don't have to wait for
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The completions of a oneOf were combined with a Pact pipeline for every
location under it, and its overhead was a large part of evaluating
schemas that use oneOf a lot. The sets are now grouped by location in
one pass. Validating a 150KB GitHub workflow went from about 209ms to
163ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Annotations from a conditional subschema were marked as value
annotations by copying the subschema's whole record, which has an entry
for every location under it, for every anyOf and oneOf alternative.
They're now marked while they're appended. Validating a 150KB GitHub
workflow went from about 163ms to 144ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant