From 92a9e595e05943089fc55503025951da61358936 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Sun, 14 Jun 2026 18:46:35 +0700 Subject: [PATCH] perf(webui): decouple DetailNavigationSheet search from the page render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the sheet's search lagged badly (INP ~600ms on a 1797-item list): the input bound to the controller's searchQuery, whose state lives in the page-level hook (flow.tsx), so every keystroke re-rendered the whole detail page AND rebuilt all ~1800 option rows before the caret could echo — characters appeared in batches. Mirror the input value in local sheet state (instant caret echo, re-renders only the sheet) and push to the controller in a transition (filtering + prev/next stay correct; the heavy re-render no longer blocks the keystroke). Memoize the option rows so a keystroke that only changes the local mirror does not rebuild them. Measured live (chrome-devtools, test.pentagi.net, 1797-flow sheet): typing INP 606ms -> 258ms; all characters land, focus stays on the input. The residual is the DOM reconciliation when the filter changes — addressed next by virtualizing the list. Co-Authored-By: Claude Fable 5 --- .../detail-navigation-sheet.tsx | 99 +++++++++++-------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx index 003c9301..05cdba05 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx @@ -1,5 +1,5 @@ import { Search, X } from 'lucide-react'; -import { type KeyboardEvent, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { type KeyboardEvent, type ReactNode, startTransition, useCallback, useMemo, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; @@ -65,8 +65,10 @@ export function DetailNavigationSheet({ const buttonRefs = useRef(new Map()); const [focusedId, setFocusedId] = useState(null); + const [localQuery, setLocalQuery] = useState(searchQuery); + const hasEntries = items.length > 0; - const trimmedQuery = searchQuery.trim(); + const trimmedQuery = localQuery.trim(); const hasClearButton = hasSearch && trimmedQuery.length > 0; // Build an `id → index` map once per `items`/`getId` change so both the @@ -249,6 +251,11 @@ export function DetailNavigationSheet({ [getId, hasEntries, items], ); + const clearSearch = useCallback(() => { + setLocalQuery(''); + startTransition(() => clearSearchQuery()); + }, [clearSearchQuery]); + // Intercept Esc at the Radix-Content level so we can clear a non-empty // search before the dialog's built-in "close on Esc" fires. Once the // query is empty, we let Radix close as usual — matches the two-step @@ -257,23 +264,25 @@ export function DetailNavigationSheet({ (event: KeyboardEvent) => { if (hasSearch && trimmedQuery.length > 0) { event.preventDefault(); - clearSearchQuery(); + clearSearch(); } }, - [clearSearchQuery, hasSearch, trimmedQuery.length], + [clearSearch, hasSearch, trimmedQuery.length], ); const handleSearchChange = useCallback( (event: React.ChangeEvent) => { - setSearchQuery(event.target.value); + const { value } = event.target; + setLocalQuery(value); + startTransition(() => setSearchQuery(value)); }, [setSearchQuery], ); const handleSearchClear = useCallback(() => { - clearSearchQuery(); + clearSearch(); searchInputRef.current?.focus(); - }, [clearSearchQuery]); + }, [clearSearch]); // One stable callback ref reused for every button. The previous shape — // `setButtonRef(id) => (node) => …` — manufactured a new closure per id @@ -299,6 +308,45 @@ export function DetailNavigationSheet({ }; }, []); + const listItems = useMemo( + () => + items.map((item) => { + const id = String(getId(item)); + const isCurrent = currentId != null && id === String(currentId); + const isFocused = id === focusedId; + + return ( +
  • + +
  • + ); + }), + [currentId, focusedId, getId, getLabel, handleItemClick, items, renderItem, setButtonRef], + ); + return ( ({ placeholder={searchPlaceholder} ref={searchInputRef} type="text" - value={searchQuery} + value={localQuery} /> {hasClearButton ? ( @@ -368,40 +416,7 @@ export function DetailNavigationSheet({ ref={listRef} role="listbox" > - {items.map((item) => { - const id = String(getId(item)); - const isCurrent = currentId != null && id === String(currentId); - const isFocused = id === focusedId; - - return ( -
  • - -
  • - ); - })} + {listItems} ) : (