fix(CQ-WEB-9): Cache DOM measurement element in LinkAutocomplete
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Replace per-keystroke DOM element creation/removal with a persistent
off-screen mirror element stored in useRef. The mirror and cursor span
are lazily created on first use and reused for all subsequent caret
position measurements, eliminating layout thrashing. Cleanup on
component unmount removes the element from the DOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-06 18:32:50 -06:00
parent 214139f4d5
commit 952eeb7323

View File

@@ -53,6 +53,8 @@ export function LinkAutocomplete({
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const mirrorRef = useRef<HTMLDivElement | null>(null);
const cursorSpanRef = useRef<HTMLSpanElement | null>(null);
/** /**
* Search for knowledge entries matching the query. * Search for knowledge entries matching the query.
@@ -132,15 +134,37 @@ export function LinkAutocomplete({
); );
/** /**
* Calculate dropdown position relative to cursor * Calculate dropdown position relative to cursor.
* Uses a persistent off-screen mirror element (via refs) to avoid
* creating and removing DOM nodes on every keystroke, which causes
* layout thrashing.
*/ */
const calculateDropdownPosition = useCallback( const calculateDropdownPosition = useCallback(
(textarea: HTMLTextAreaElement, cursorIndex: number): { top: number; left: number } => { (textarea: HTMLTextAreaElement, cursorIndex: number): { top: number; left: number } => {
// Create a mirror div to measure text position // Lazily create the mirror element once, then reuse it
const mirror = document.createElement("div"); if (!mirrorRef.current) {
const styles = window.getComputedStyle(textarea); const mirror = document.createElement("div");
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.height = "auto";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.pointerEvents = "none";
document.body.appendChild(mirror);
mirrorRef.current = mirror;
// Copy relevant styles const span = document.createElement("span");
span.textContent = "|";
cursorSpanRef.current = span;
}
const mirror = mirrorRef.current;
const cursorSpan = cursorSpanRef.current;
if (!cursorSpan) {
return { top: 0, left: 0 };
}
// Sync styles from the textarea so measurement is accurate
const styles = window.getComputedStyle(textarea);
const stylesToCopy = [ const stylesToCopy = [
"fontFamily", "fontFamily",
"fontSize", "fontSize",
@@ -161,31 +185,19 @@ export function LinkAutocomplete({
} }
}); });
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.width = `${String(textarea.clientWidth)}px`; mirror.style.width = `${String(textarea.clientWidth)}px`;
mirror.style.height = "auto";
mirror.style.whiteSpace = "pre-wrap";
// Get text up to cursor // Update content: text before cursor + cursor marker span
const textBeforeCursor = textarea.value.substring(0, cursorIndex); const textBeforeCursor = textarea.value.substring(0, cursorIndex);
mirror.textContent = textBeforeCursor; mirror.textContent = textBeforeCursor;
// Create a span for the cursor position
const cursorSpan = document.createElement("span");
cursorSpan.textContent = "|";
mirror.appendChild(cursorSpan); mirror.appendChild(cursorSpan);
document.body.appendChild(mirror);
const textareaRect = textarea.getBoundingClientRect(); const textareaRect = textarea.getBoundingClientRect();
const cursorSpanRect = cursorSpan.getBoundingClientRect(); const cursorSpanRect = cursorSpan.getBoundingClientRect();
const top = cursorSpanRect.top - textareaRect.top + textarea.scrollTop + 20; const top = cursorSpanRect.top - textareaRect.top + textarea.scrollTop + 20;
const left = cursorSpanRect.left - textareaRect.left + textarea.scrollLeft; const left = cursorSpanRect.left - textareaRect.left + textarea.scrollLeft;
document.body.removeChild(mirror);
return { top, left }; return { top, left };
}, },
[] []
@@ -346,7 +358,8 @@ export function LinkAutocomplete({
}, [textareaRef, handleInput, handleKeyDown]); }, [textareaRef, handleInput, handleKeyDown]);
/** /**
* Cleanup timeout and abort in-flight requests on unmount * Cleanup timeout, abort in-flight requests, and remove the
* persistent mirror element on unmount
*/ */
useEffect(() => { useEffect(() => {
return (): void => { return (): void => {
@@ -356,6 +369,11 @@ export function LinkAutocomplete({
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
} }
if (mirrorRef.current) {
document.body.removeChild(mirrorRef.current);
mirrorRef.current = null;
cursorSpanRef.current = null;
}
}; };
}, []); }, []);