From b3d456373072b8548796d9275153d72088f39c71 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 9 Jun 2026 22:46:59 -0400 Subject: [PATCH] perf: Improve performance of in-page search (#12649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- app/editor/components/FindAndReplace.tsx | 42 ++++++++++++++++--- app/editor/extensions/FindAndReplace.tsx | 53 +++++++++++++++++++----- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/app/editor/components/FindAndReplace.tsx b/app/editor/components/FindAndReplace.tsx index f3a19102a0..34665d4292 100644 --- a/app/editor/components/FindAndReplace.tsx +++ b/app/editor/components/FindAndReplace.tsx @@ -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) => { 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({ > editor.commands.prevSearchMatch()} + onClick={() => { + debouncedFind.flush(); + editor.commands.prevSearchMatch(); + }} aria-label={t("Previous match")} > @@ -355,7 +384,10 @@ export default function FindAndReplace({ editor.commands.nextSearchMatch()} + onClick={() => { + debouncedFind.flush(); + editor.commands.nextSearchMatch(); + }} aria-label={t("Next match")} > diff --git a/app/editor/extensions/FindAndReplace.tsx b/app/editor/extensions/FindAndReplace.tsx index 01ae8505b9..cf30df77a5 100644 --- a/app/editor/extensions/FindAndReplace.tsx +++ b/app/editor/extensions/FindAndReplace.tsx @@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension(); + mergedTextNodes.forEach((node) => { const { text = "", pos, type } = node; try { @@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension 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 + !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 { 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: () => {