Harden snippet session against null decorations and selection/placeholder cardinality drift#315206
Merged
Merged
Conversation
…lder cardinality drift Reconciles four telemetry issues (#233164, #233163, #196664, #193172) into clean defensive guards in OneSnippet/SnippetSession: - Capture model into a local in OneSnippet.move() and _hasPlaceholderBeenCollapsed() and null-guard getDecorationRange/_placeholderDecorations.get lookups (covers #233164, #196664). - Treat a missing decoration range on a non-empty placeholder as collapsed in _hasPlaceholderBeenCollapsed. - Add OneSnippet.activePlaceholderCount and gate SnippetSession.merge structural merge + post-merge _move on a cardinality precondition (snippets.length === sum(activePlaceholderCount)). When cursor normalization or external selection changes collapse mirrored placeholder selections, we now skip structural merge instead of crashing on others.shift() (covers #233163, #193172). Text edits still apply. - next()/prev() no-op when _move() returns no selections.
This was referenced May 8, 2026
This was referenced May 8, 2026
Closed
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens snippet navigation and snippet merging in the editor against stale/missing placeholder decorations and against selection/placeholder-count mismatches (e.g., when cursor normalization collapses multiple empty placeholder occurrences into fewer selections), preventing several telemetry-reported crashes in OneSnippet/SnippetSession.
Changes:
- Add null/undefined guards in
OneSnippet.move()and_hasPlaceholderBeenCollapsed()for missing decoration IDs and missing decoration ranges, skipping invalid placeholders instead of dereferencing. - Add
OneSnippet.activePlaceholderCountand use it inSnippetSession.merge()to only perform structural merges when the number of nested snippets matches the number of active placeholder occurrences. - Make
SnippetSession.next()/prev()resilient to_move()returning no selections (no-op instead of indexingnewSelections[0]).
Show a summary per file
| File | Description |
|---|---|
| src/vs/editor/contrib/snippet/browser/snippetSession.ts | Adds defensive guards for missing decorations/ranges, adds merge cardinality precondition, and avoids crashes when navigation yields no selections. |
| src/vs/editor/contrib/snippet/test/browser/snippetSession.test.ts | Adds regression tests covering missing-decoration navigation and merge when placeholder occurrences collapse to the same position. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 0
alexr00
approved these changes
May 8, 2026
lszomoru
pushed a commit
that referenced
this pull request
May 8, 2026
…lder cardinality drift (#315206) Reconciles four telemetry issues (#233164, #233163, #196664, #193172) into clean defensive guards in OneSnippet/SnippetSession: - Capture model into a local in OneSnippet.move() and _hasPlaceholderBeenCollapsed() and null-guard getDecorationRange/_placeholderDecorations.get lookups (covers #233164, #196664). - Treat a missing decoration range on a non-empty placeholder as collapsed in _hasPlaceholderBeenCollapsed. - Add OneSnippet.activePlaceholderCount and gate SnippetSession.merge structural merge + post-merge _move on a cardinality precondition (snippets.length === sum(activePlaceholderCount)). When cursor normalization or external selection changes collapse mirrored placeholder selections, we now skip structural merge instead of crashing on others.shift() (covers #233163, #193172). Text edits still apply. - next()/prev() no-op when _move() returns no selections.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reconciles four telemetry-reported snippet crashes into a single set of defensive guards in
OneSnippet/SnippetSession. The underlying root cause for some of these is broader (lack of editor transactions / re-entrant model edits duringchangeDecorations), which is out of scope here — this change just protects the snippet code without hiding errors or hacking around the model.Closes #233164
Closes #233163
Closes #196664
Closes #193172
Changes
OneSnippet.move()_editor.getModel()into a localmodelonce (instead of repeatedthis._editor.getModel()calls)._placeholderDecorations.get(...)andmodel.getDecorationRange(id)in:null/undefined.OneSnippet._hasPlaceholderBeenCollapsed()modelinto a local.range.isEmpty() && marker.toString().length > 0semantics, but tolerant ofnull).SnippetSession.merge()OneSnippet.activePlaceholderCountgetter._move(undefined)whensnippets.length === sum(activePlaceholderCount). When cursor normalization or external selection changes collapse mirrored placeholder selections (e.g.$1$1at the same position), we now skip the structural merge instead of crashing onothers.shift()returningundefined. Text edits still apply.SnippetSession.next()/prev()_move()returns no selections, instead of crashing onnewSelections[0].Tests
Added two regression tests in
snippetSession.test.ts:'snippets, next does not throw when placeholder decorations are missing'— covers the null-decoration crashes (Cannot read properties of null (reading 'startLineNumber') #233164, Cannot read properties of null (reading 'startLineNumber') #196664). Inserts a snippet, removes its decorations, callssession.next()and asserts it doesn't throw.'snippets, merge does not throw when placeholder occurrences collapse to same position'— covers the merge-collapse crashes (Cannot read properties of undefined (reading 'e') #233163, Cannot read properties of undefined (reading 'e') #193172). Inserts$1$1$0(cursor normalization collapses two empty placeholder cursors into one), then callssession.merge('${1:nested}$0')and asserts it doesn't throw with correct document content afterward.All 40 tests in
snippetSession.test.tsand the surrounding 38 in the snippet test directory pass.Manual repro (merge-collapse class)
User snippets:
{ "test": { "prefix": "test", "body": "$1$1$0" }, "nest": { "prefix": "nest", "body": "${1:nested}$0" } }test, accept the suggestion. Buffer is empty, one cursor at column 1 (the two$1cursors collapsed via normalization).nestand accept the second snippet from the suggestion list.Pre-fix:
TypeError: Cannot read properties of undefined (reading '_offset')logged to the Window output channel; buffer/navigation left in inconsistent state.Post-fix: No crash. Text edit is applied; structural merge is skipped because the cardinality precondition fails.
The null-decoration class (#233164, #196664) is the result of re-entrant model edits during snippet navigation (e.g. format-on-type / extension edits firing inside
changeDecorations) and is not reliably hand-reproducible. Coverage is via the unit test.Notes on rejected approaches
_snippet.replace(placeholder, [])mid-loop. That mutates the snippet model during merge — exactly the kind of hack we want to avoid. The cardinality precondition fixes the same crash without touching the snippet model mid-merge.move()when no valid snippet selection could be produced. That hides errors rather than just preventing the crash; replaced with the simpler "no selections → no-op" guard innext()/prev().