perf: Improve performance of in-page search (#12649)

* 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.

* Skip redundant search highlight rebuilds, lower debounce to 100ms

The highlight plugin rebuilt every match's DOM range via domAtPos on
every editor view update while a search was active, forcing synchronous
layout on cursor moves, selection changes, and collaboration cursors.

Track the built ranges and, when the result set is unchanged, only
rebuild when they are actually stale — a referenced node has detached or
some matches were not yet resolved to ranges. isConnected checks are
cheap property reads with no layout, versus domAtPos which forces
reflow, so this is strictly less work than before and skips entirely in
the common case where all matches are resolved and connected.

Also lower the find debounce from 250ms to 100ms for snappier feedback.

* Shorten highlight rebuild comment

* PR feedback

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-06-09 22:46:59 -04:00
committed by GitHub
parent 7106263f88
commit b3d4563730
2 changed files with 79 additions and 16 deletions
+37 -5
View File
@@ -1,3 +1,4 @@
import { debounce } from "es-toolkit/compat";
import {
CaretDownIcon,
CaretUpIcon,
@@ -211,9 +212,31 @@ 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.
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 +266,7 @@ export default function FindAndReplace({
}
}
},
[editor.commands, selectInputText]
[debouncedFind, editor.commands, selectInputText]
);
const handleReplace = React.useCallback(
@@ -274,13 +297,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(
@@ -331,6 +354,9 @@ export default function FindAndReplace({
} else {
onClose();
setShowReplace(false);
// Cancel any pending debounced find so it can't reactivate highlights
// after the search has been cleared.
debouncedFind.cancel();
editor.commands.clearSearch();
}
// oxlint-disable-next-line react-hooks/exhaustive-deps
@@ -346,7 +372,10 @@ export default function FindAndReplace({
>
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.prevSearchMatch()}
onClick={() => {
debouncedFind.flush();
editor.commands.prevSearchMatch();
}}
aria-label={t("Previous match")}
>
<CaretUpIcon />
@@ -355,7 +384,10 @@ export default function FindAndReplace({
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
<ButtonLarge
disabled={disabled}
onClick={() => editor.commands.nextSearchMatch()}
onClick={() => {
debouncedFind.flush();
editor.commands.nextSearchMatch();
}}
aria-label={t("Next match")}
>
<CaretDownIcon />
+42 -11
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 {
@@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
continue;
}
// 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)) {
// Check if already exists in results, possible because we search
// over `deburr(text) + text`
const key = `${from}:${to}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
this.results.push({ from, to, type });
}
@@ -483,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
}
this.highlightRanges = allRanges;
CSS.highlights.set("search-results", new Highlight(...allRanges));
if (currentRanges.length) {
CSS.highlights.set(
@@ -495,6 +503,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
}
private clearHighlights() {
this.highlightRanges = [];
if (!supportsHighlightAPI) {
return;
}
@@ -503,6 +512,25 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
this.currentHighlightRange = undefined;
}
/**
* Determine whether the highlight ranges need to be rebuilt against the live
* DOM. The CSS Custom Highlight API holds static ranges that detach when the
* editor re-renders its DOM without changing the doc, so highlights are stale
* when a built range's nodes have disconnected, or when some matches have not
* yet been resolved to ranges (e.g. inside a node view that mounts later).
*
* @returns whether the highlights should be rebuilt.
*/
private highlightsStale() {
if (this.highlightRanges.length < this.results.length) {
return true;
}
return this.highlightRanges.some(
(range) =>
!range.startContainer.isConnected || !range.endContainer.isConnected
);
}
private handleEscape = () => {
const params = new URLSearchParams(window.location.search);
if (params.has("q")) {
@@ -536,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
private currentHighlightRange?: StaticRange;
private highlightRanges: StaticRange[] = [];
get allowInReadOnly() {
return true;
}
@@ -604,16 +634,17 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
return {
update: (view) => {
const generation = pluginKey.getState(view.state) as number;
// Rebuild highlights when the results change (generation bump) or,
// while a search is active, on any view update. The CSS Custom
// Highlight API relies on static DOM ranges that become detached
// when the editor re-renders its DOM — e.g. content settling after
// sync when navigating from search results, collaboration cursors,
// or node views mounting — none of which bump the generation. This
// keeps the highlights tracking the live DOM, as decorations do.
if (generation !== lastGeneration || this.searchTerm) {
// The results changed (search ran, doc changed, fold toggled), so
// always rebuild.
if (generation !== lastGeneration) {
lastGeneration = generation;
this.updateHighlights();
return;
}
// Results unchanged: only rebuild when the static highlight ranges
// have detached from a DOM re-render that didn't bump the generation.
if (this.searchTerm && this.highlightsStale()) {
this.updateHighlights();
}
},
destroy: () => {