Merge pull request #1076 from SohamBhattacharjee2003/perf/use-deferred-value-search-filters

perf: use useDeferredValue for search/filter transitions
This commit is contained in:
Rohan Verma 2026-04-01 22:16:53 -07:00 committed by GitHub
commit 5690a96e79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 28 additions and 18 deletions

View file

@ -20,7 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
@ -289,15 +289,14 @@ export function InboxSidebarContent({
[activeFilter] [activeFilter]
); );
// Defer non-urgent list updates so the search input stays responsive.
// The deferred snapshot lags one render behind the live value intentionally.
const deferredTabItems = useDeferredValue(activeSource.items);
const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []);
// Two data paths: search mode (API) or default (per-tab data source) // Two data paths: search mode (API) or default (per-tab data source)
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let tabItems: InboxItem[]; const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems;
if (isSearchMode) {
tabItems = searchResponse?.items ?? [];
} else {
tabItems = activeSource.items;
}
let result = tabItems; let result = tabItems;
if (activeFilter !== "all") { if (activeFilter !== "all") {
@ -310,8 +309,8 @@ export function InboxSidebarContent({
return result; return result;
}, [ }, [
isSearchMode, isSearchMode,
searchResponse, deferredSearchItems,
activeSource.items, deferredTabItems,
activeTab, activeTab,
activeFilter, activeFilter,
selectedSource, selectedSource,

View file

@ -4,6 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
useDeferredValue,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
@ -81,6 +82,9 @@ export const DocumentMentionPicker = forwardRef<
// Debounced search value to minimize API calls and prevent race conditions // Debounced search value to minimize API calls and prevent race conditions
const search = externalSearch; const search = externalSearch;
const debouncedSearch = useDebounced(search, DEBOUNCE_MS); const debouncedSearch = useDebounced(search, DEBOUNCE_MS);
// Deferred snapshot of debouncedSearch — client-side filtering uses this so it
// is treated as a non-urgent update, keeping the input responsive.
const deferredSearch = useDeferredValue(debouncedSearch);
const [highlightedIndex, setHighlightedIndex] = useState(0); const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -245,12 +249,14 @@ export const DocumentMentionPicker = forwardRef<
* Client-side filtering for single character searches. * Client-side filtering for single character searches.
* Filters cached documents locally for instant feedback without additional API calls. * Filters cached documents locally for instant feedback without additional API calls.
* Server-side search is reserved for 2+ character queries to leverage database indexing. * Server-side search is reserved for 2+ character queries to leverage database indexing.
* Uses deferredSearch (a deferred snapshot of debouncedSearch) so this memo is treated
* as non-urgent React can interrupt it to keep the input responsive.
*/ */
const clientFilteredDocs = useMemo(() => { const clientFilteredDocs = useMemo(() => {
if (!isSingleCharSearch) return null; if (!isSingleCharSearch) return null;
const searchLower = debouncedSearch.trim().toLowerCase(); const searchLower = deferredSearch.trim().toLowerCase();
return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower)); return accumulatedDocuments.filter((doc) => doc.title.toLowerCase().includes(searchLower));
}, [isSingleCharSearch, debouncedSearch, accumulatedDocuments]); }, [isSingleCharSearch, deferredSearch, accumulatedDocuments]);
// Select data source based on search length: client-filtered for single char, server results for 2+ // Select data source based on search length: client-filtered for single char, server results for 2+
const actualDocuments = isSingleCharSearch ? (clientFilteredDocs ?? []) : accumulatedDocuments; const actualDocuments = isSingleCharSearch ? (clientFilteredDocs ?? []) : accumulatedDocuments;

View file

@ -5,6 +5,7 @@ import { Plus, Zap } from "lucide-react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
useDeferredValue,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
@ -41,15 +42,19 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
const shouldScrollRef = useRef(false); const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
// Defer the search value so filtering is non-urgent and the input stays responsive
const deferredSearch = useDeferredValue(externalSearch);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const list = prompts ?? []; const list = prompts ?? [];
if (!externalSearch) return list; if (!deferredSearch) return list;
return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); return list.filter((a) => a.name.toLowerCase().includes(deferredSearch.toLowerCase()));
}, [prompts, externalSearch]); }, [prompts, deferredSearch]);
const prevSearchRef = useRef(externalSearch); // Reset highlight when the deferred (filtered) search changes
if (prevSearchRef.current !== externalSearch) { const prevSearchRef = useRef(deferredSearch);
prevSearchRef.current = externalSearch; if (prevSearchRef.current !== deferredSearch) {
prevSearchRef.current = deferredSearch;
if (highlightedIndex !== 0) { if (highlightedIndex !== 0) {
setHighlightedIndex(0); setHighlightedIndex(0);
} }