mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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 />
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user