Fix editor find freezing on long documents

In-document search (Ctrl+F) blocked the UI for several seconds while
typing in long documents. Two compounding causes:

- The find command ran a full-document search and highlight rebuild on
  every keystroke. Debounce it so typing stays responsive; the input
  value still updates immediately and pending searches are flushed when
  navigating between matches.

- search() de-duplicated matches with an O(n) scan of all prior results
  per match, making a common term that matches many times quadratic.
  Track seen positions in a Set for constant-time lookups.
This commit is contained in:
Claude
2026-06-09 15:46:30 +00:00
parent 3c2e9a9723
commit 46d407b615
2 changed files with 35 additions and 4 deletions
+27 -3
View File
@@ -1,3 +1,4 @@
import { debounce } from "es-toolkit/compat";
import {
CaretDownIcon,
CaretUpIcon,
@@ -211,9 +212,32 @@ export default function FindAndReplace({
});
}, [caseSensitive, editor.commands, searchTerm]);
// Searching the document on every keystroke is expensive in long documents
// it traverses the entire doc and rebuilds highlights so debounce it to keep
// typing in the input responsive. The input value itself updates immediately.
const debouncedFind = React.useMemo(
() =>
debounce(
(attrs: {
text: string;
caseSensitive: boolean;
regexEnabled: boolean;
}) => {
editor.commands.find(attrs);
},
250
),
[editor.commands]
);
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
function nextPrevious() {
// Ensure any pending debounced search has run so navigation acts on the
// results for the text currently in the input.
debouncedFind.flush();
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
@@ -243,7 +267,7 @@ export default function FindAndReplace({
}
}
},
[editor.commands, selectInputText]
[debouncedFind, editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -274,13 +298,13 @@ export default function FindAndReplace({
ev.stopPropagation();
setSearchTerm(ev.currentTarget.value);
editor.commands.find({
debouncedFind({
text: ev.currentTarget.value,
caseSensitive,
regexEnabled,
});
},
[caseSensitive, editor.commands, regexEnabled]
[caseSensitive, debouncedFind, regexEnabled]
);
const handleReplaceKeyDown = React.useCallback(
+8 -1
View File
@@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
});
// Tracks already-seen match positions so duplicate matches (possible because
// we search the deburred text concatenated with the original) can be skipped
// in constant time rather than rescanning the entire results array.
const seen = new Set<string>();
mergedTextNodes.forEach((node) => {
const { text = "", pos, type } = node;
try {
@@ -407,9 +412,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
// Check if already exists in results, possible due to duplicated
// search string on L257
if (this.results.some((r) => r.from === from && r.to === to)) {
const key = `${from}:${to}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
this.results.push({ from, to, type });
}