fix(terminal): clear search highlights when the find bar closes (#10872)

Closing Ctrl+F left one match highlighted until the window was minimized
and restored.

xterm's DecorationService keys its SortedList on `decoration.marker.line`,
but `SortedList.delete()` only records an index and defers compaction,
while `Marker.dispose()` sets `line = -1` — mutating that same sort key.
After the first disposal the array is no longer sorted, so the binary
search inside `delete()` can miss a decoration that is present. It returns
false, `onDecorationRemoved` never fires, and the decoration stays live and
keeps painting. Repaints don't help; they faithfully re-paint a live
decoration, which is why only a window cycle appeared to fix it.

`clearDecorations()` disposes the active match before the match
highlights, which is exactly the order that trips this.

Patch `delete()` to retry once after compacting pending deletions, on the
miss path only, so the common bulk delete keeps its O(log n) search and
deferred batching. A 3000-trial randomized differential against upstream
semantics shows no behavior change for well-ordered lists.
This commit is contained in:
Neil
2026-07-27 00:10:04 -07:00
committed by GitHub
parent a142c84ede
commit 2b244fa0ea
3 changed files with 274 additions and 39 deletions
File diff suppressed because one or more lines are too long
+24 -24
View File
@@ -19,7 +19,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -46,7 +46,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -206,25 +206,25 @@ importers:
version: 5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9224,39 +9224,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,205 @@
// @vitest-environment happy-dom
import { SearchAddon } from '@xterm/addon-search'
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Regression for STA-2707: search highlights stayed painted after the find bar
* closed, until the window was minimized and restored.
*
* Mechanism, in @xterm/xterm's SortedList (patched in
* config/patches/@xterm__xterm@6.1.0-beta.287.patch):
*
* - DecorationService keys its SortedList on `decoration.marker.line`.
* - `delete()` only records an index and defers the array compaction, while
* `Marker.dispose()` sets `line = -1` — mutating that very sort key.
* - So after the first disposal the array is no longer sorted, and the binary
* search inside `delete()` can miss a decoration that IS present. `delete()`
* returns false, `onDecorationRemoved` never fires, and the decoration keeps
* reporting itself at its cells forever.
*
* `clearDecorations()` disposes the active (top-layer) match first and the
* match highlights after, which is exactly the order that trips this — so
* closing search left one match highlighted. Repaints don't help: the leaked
* decoration is still live, so every repaint faithfully re-paints it.
*
* These tests drive the REAL Terminal + SearchAddon and assert through
* `forEachDecorationAtCell` — the same lookup both renderers use to decide a
* cell's background — so a regression fails here regardless of renderer.
*/
type LeakProbeDecoration = { marker: { line: number } }
type DecorationServiceInternals = {
decorations: Iterable<LeakProbeDecoration>
forEachDecorationAtCell: (
x: number,
line: number,
layer: string | undefined,
callback: (decoration: LeakProbeDecoration) => void
) => void
}
const SEARCH_DECORATIONS = {
matchBackground: '#5c4a00',
matchBorder: '#5c4a00',
matchOverviewRuler: '#ffcc00',
activeMatchBackground: '#c4580e',
activeMatchBorder: '#ffcf6b',
activeMatchColorOverviewRuler: '#ff9900'
} as const
const PROBE_ROWS = 30
const PROBE_COLS = 80
function settle(ms = 20): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
type SearchHarness = {
terminal: Terminal
search: SearchAddon
/** Cells whose background a renderer would still paint from a decoration. */
highlightedCellCount: () => number
/** Closes the find bar exactly the way TerminalSearch does. */
closeSearch: () => void
}
function openTerminalWithSearch(): SearchHarness {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal()
terminal.open(container)
const search = new SearchAddon()
terminal.loadAddon(search)
const decorationService = (
terminal as never as { _core: { _decorationService: DecorationServiceInternals } }
)._core._decorationService
return {
terminal,
search,
highlightedCellCount: () => {
let count = 0
for (let line = 0; line < PROBE_ROWS; line++) {
for (let x = 0; x < PROBE_COLS; x++) {
decorationService.forEachDecorationAtCell(x, line, undefined, () => count++)
}
}
return count
},
closeSearch: () => {
search.clearDecorations()
search.findNext('')
}
}
}
/**
* Content shapes that produce different match/marker layouts. The leak only
* showed up for some of them, so the regression has to sweep rather than pin
* one lucky case.
*/
const CONTENT_SHAPES: readonly (readonly [string, string])[] = [
['matches on two lines', 'needle one\r\nneedle two\r\n'],
['matches on three lines', 'needle one\r\nneedle two\r\nneedle three\r\n'],
['matches on four lines', 'needle a\r\nneedle b\r\nneedle c\r\nneedle d\r\n'],
['matches separated by gaps', 'needle a\r\nplain\r\nneedle b\r\nplain\r\nneedle c\r\n'],
['two matches on one line', 'needle needle\r\nplain\r\n']
]
describe('terminal search decoration cleanup (STA-2707)', () => {
beforeEach(() => {
// happy-dom has no canvas text metrics; xterm measures glyphs on open().
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
it.each(CONTENT_SHAPES)(
'leaves no highlighted cells after closing search (%s)',
async (_name, content) => {
// Sweeping the match-navigation count matters: which decoration is the
// active one decides whether the stale sort key hides a later delete.
for (const navigations of [0, 1, 2, 3]) {
const harness = openTerminalWithSearch()
await write(harness.terminal, content)
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
await settle()
for (let i = 0; i < navigations; i++) {
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
await settle(5)
}
await settle()
expect(
harness.highlightedCellCount(),
`search should highlight matches while open (navigations=${navigations})`
).toBeGreaterThan(0)
harness.closeSearch()
await settle()
expect(
harness.highlightedCellCount(),
`closing search must leave no highlighted cells (navigations=${navigations})`
).toBe(0)
document.body.replaceChildren()
}
}
)
it('leaves no highlighted cells after typing a query character by character', async () => {
const harness = openTerminalWithSearch()
await write(harness.terminal, 'needle one\r\nneedle two\r\nneedle three\r\n')
// The find bar re-searches on every keystroke, so each character disposes
// the previous run's decorations — the same path that leaked.
for (const query of ['n', 'ne', 'nee', 'need', 'needl', 'needle']) {
harness.search.findNext(query, { decorations: SEARCH_DECORATIONS, incremental: true })
await settle(10)
}
await settle()
harness.closeSearch()
await settle()
expect(harness.highlightedCellCount()).toBe(0)
})
it('does not accumulate highlights across repeated open/close cycles', async () => {
const harness = openTerminalWithSearch()
await write(harness.terminal, 'needle one\r\nneedle two\r\nneedle three\r\n')
for (let cycle = 0; cycle < 5; cycle++) {
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
await settle(10)
harness.closeSearch()
await settle(10)
}
expect(harness.highlightedCellCount()).toBe(0)
})
it('keeps highlights painted while search is still open', async () => {
const harness = openTerminalWithSearch()
await write(harness.terminal, 'needle one\r\nneedle two\r\n')
harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS })
await settle()
expect(harness.highlightedCellCount()).toBeGreaterThan(0)
})
})