mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-29 14:36:31 +00:00
perf(webui): decouple DetailNavigationSheet search from the page render
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4cb25ac469
commit
92a9e595e0
@@ -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<T extends { id: string }>({
|
||||
const buttonRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const [focusedId, setFocusedId] = useState<null | string>(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<T extends { id: string }>({
|
||||
[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<T extends { id: string }>({
|
||||
(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<HTMLInputElement>) => {
|
||||
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<T extends { id: string }>({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const listItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const id = String(getId(item));
|
||||
const isCurrent = currentId != null && id === String(currentId);
|
||||
const isFocused = id === focusedId;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="min-w-0"
|
||||
key={id}
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
aria-selected={isCurrent}
|
||||
className={cn(
|
||||
'hover:bg-muted/50 focus-visible:ring-ring flex w-full min-w-0 items-center gap-2 rounded-md px-3 py-2 text-left text-sm focus-visible:ring-2 focus-visible:outline-hidden',
|
||||
isCurrent && 'bg-muted text-foreground font-medium',
|
||||
)}
|
||||
data-item-id={id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onFocus={() => setFocusedId(id)}
|
||||
ref={setButtonRef}
|
||||
role="option"
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
{renderItem ? (
|
||||
renderItem(item, isCurrent)
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate">{getLabel(item)}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}),
|
||||
[currentId, focusedId, getId, getLabel, handleItemClick, items, renderItem, setButtonRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
onOpenChange={onOpenChange}
|
||||
@@ -341,7 +389,7 @@ export function DetailNavigationSheet<T extends { id: string }>({
|
||||
placeholder={searchPlaceholder}
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
value={localQuery}
|
||||
/>
|
||||
{hasClearButton ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
@@ -368,40 +416,7 @@ export function DetailNavigationSheet<T extends { id: string }>({
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const id = String(getId(item));
|
||||
const isCurrent = currentId != null && id === String(currentId);
|
||||
const isFocused = id === focusedId;
|
||||
|
||||
return (
|
||||
<li
|
||||
className="min-w-0"
|
||||
key={id}
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
aria-selected={isCurrent}
|
||||
className={cn(
|
||||
'hover:bg-muted/50 focus-visible:ring-ring flex w-full min-w-0 items-center gap-2 rounded-md px-3 py-2 text-left text-sm focus-visible:ring-2 focus-visible:outline-hidden',
|
||||
isCurrent && 'bg-muted text-foreground font-medium',
|
||||
)}
|
||||
data-item-id={id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onFocus={() => setFocusedId(id)}
|
||||
ref={setButtonRef}
|
||||
role="option"
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
{renderItem ? (
|
||||
renderItem(item, isCurrent)
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate">{getLabel(item)}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{listItems}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user