refactor: update document mention picker and chat share button for improved performance and UX

- Replaced throttling with debouncing in DocumentMentionPicker to reduce request spam and enhance user experience.
- Updated API service methods to support request cancellation using AbortSignal.
- Simplified imports in ChatShareButton by removing unused components.
This commit is contained in:
Anish Sarkar 2026-01-17 21:44:10 +05:30
parent 293de6876a
commit 720c13667e
3 changed files with 55 additions and 48 deletions

View file

@ -1,7 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Lock, Share2, Users } from "lucide-react";
import { Loader2, Lock, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";

View file

@ -1,6 +1,6 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileText } from "lucide-react";
import {
forwardRef,
@ -32,36 +32,27 @@ interface DocumentMentionPickerProps {
const PAGE_SIZE = 20;
const MIN_SEARCH_LENGTH = 2;
const THROTTLE_MS = 200;
const DEBOUNCE_MS = 300;
/**
* Throttle hook - fires immediately, then at most once per interval
* Better than debounce for typeahead: user sees results updating as they type
* Debounce hook - waits until user stops typing before firing
* Better than throttle for search: reduces request spam and prevents race conditions
*/
function useThrottled<T>(value: T, delay = THROTTLE_MS) {
const [throttled, setThrottled] = useState(value);
const lastExecuted = useRef(Date.now());
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
function useDebounced<T>(value: T, delay = DEBOUNCE_MS) {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
const now = Date.now();
const elapsed = now - lastExecuted.current;
if (elapsed >= delay) {
// Enough time has passed, update immediately
lastExecuted.current = now;
setThrottled(value);
} else {
// Schedule update for remaining time
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
lastExecuted.current = Date.now();
setThrottled(value);
}, delay - elapsed);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout - only fires after user stops typing for `delay` ms
timeoutRef.current = setTimeout(() => {
setDebounced(value);
}, delay);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
@ -69,7 +60,7 @@ function useThrottled<T>(value: T, delay = THROTTLE_MS) {
};
}, [value, delay]);
return throttled;
return debounced;
}
export const DocumentMentionPicker = forwardRef<
@ -81,9 +72,10 @@ export const DocumentMentionPicker = forwardRef<
) {
const queryClient = useQueryClient();
// Use external search with throttle (not debounce) for responsive feel
// Use external search with debounce - waits until user stops typing
// Reduces request spam and prevents race conditions with stale results
const search = externalSearch;
const throttledSearch = useThrottled(search, THROTTLE_MS);
const debouncedSearch = useDebounced(search, DEBOUNCE_MS);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -97,8 +89,8 @@ export const DocumentMentionPicker = forwardRef<
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Check if search is long enough
const isSearchValid = throttledSearch.trim().length >= MIN_SEARCH_LENGTH;
const shouldSearch = throttledSearch.trim().length > 0;
const isSearchValid = debouncedSearch.trim().length >= MIN_SEARCH_LENGTH;
const shouldSearch = debouncedSearch.trim().length > 0;
// Prefetch first page when picker mounts - results appear instantly
useEffect(() => {
@ -129,13 +121,15 @@ export const DocumentMentionPicker = forwardRef<
}, [searchSpaceId, queryClient]);
// Reset pagination when search or search space changes
// Don't clear accumulatedDocuments - let new data replace it smoothly (prevents "No documents found" flash)
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset pagination when search/space changes
useEffect(() => {
setAccumulatedDocuments([]);
// Keep previous documents visible while new query is fetching (smooth UX)
// setAccumulatedDocuments([]); // Removed to prevent flash of "No documents found"
setCurrentPage(0);
setHasMore(false);
setHighlightedIndex(0);
}, [throttledSearch, searchSpaceId]);
}, [debouncedSearch, searchSpaceId]);
// Query params for lightweight title search
const titleSearchParams = useMemo(
@ -143,9 +137,9 @@ export const DocumentMentionPicker = forwardRef<
search_space_id: searchSpaceId,
page: 0,
page_size: PAGE_SIZE,
...(isSearchValid ? { title: throttledSearch.trim() } : {}),
...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
}),
[searchSpaceId, throttledSearch, isSearchValid]
[searchSpaceId, debouncedSearch, isSearchValid]
);
const surfsenseDocsQueryParams = useMemo(() => {
@ -154,25 +148,33 @@ export const DocumentMentionPicker = forwardRef<
page_size: PAGE_SIZE,
};
if (isSearchValid) {
params.title = throttledSearch.trim();
params.title = debouncedSearch.trim();
}
return params;
}, [throttledSearch, isSearchValid]);
}, [debouncedSearch, isSearchValid]);
// Use the new lightweight endpoint for document title search
// TanStack Query provides signal for automatic request cancellation
// keepPreviousData: shows old results while fetching new ones (no spinner flicker)
const { data: titleSearchResults, isLoading: isTitleSearchLoading } = useQuery({
queryKey: ["document-titles", titleSearchParams],
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }),
queryFn: ({ signal }) =>
documentsApiService.searchDocumentTitles({ queryParams: titleSearchParams }, signal),
staleTime: 60 * 1000, // 1 minute - shorter for fresher results
enabled: !!searchSpaceId && currentPage === 0 && (!shouldSearch || isSearchValid),
placeholderData: keepPreviousData,
});
// Use query for fetching first page of SurfSense docs
// TanStack Query provides signal for automatic request cancellation
// keepPreviousData: shows old results while fetching new ones (no spinner flicker)
const { data: surfsenseDocs, isLoading: isSurfsenseDocsLoading } = useQuery({
queryKey: ["surfsense-docs-mention", throttledSearch, isSearchValid],
queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }),
queryKey: ["surfsense-docs-mention", debouncedSearch, isSearchValid],
queryFn: ({ signal }) =>
documentsApiService.getSurfsenseDocs({ queryParams: surfsenseDocsQueryParams }, signal),
staleTime: 3 * 60 * 1000,
enabled: !shouldSearch || isSearchValid,
placeholderData: keepPreviousData,
});
// Update accumulated documents when first page loads - combine both sources
@ -213,7 +215,7 @@ export const DocumentMentionPicker = forwardRef<
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
...(isSearchValid ? { title: throttledSearch.trim() } : {}),
...(isSearchValid ? { title: debouncedSearch.trim() } : {}),
};
const response: SearchDocumentTitlesResponse =
await documentsApiService.searchDocumentTitles({ queryParams });
@ -226,7 +228,7 @@ export const DocumentMentionPicker = forwardRef<
} finally {
setIsLoadingMore(false);
}
}, [currentPage, hasMore, isLoadingMore, throttledSearch, searchSpaceId, isSearchValid]);
}, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId, isSearchValid]);
// Infinite scroll handler
const handleScroll = useCallback(
@ -359,8 +361,8 @@ export const DocumentMentionPicker = forwardRef<
{showSearchHint ? (
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
<p className="text-sm text-muted-foreground">
Type {MIN_SEARCH_LENGTH - throttledSearch.trim().length} more character
{MIN_SEARCH_LENGTH - throttledSearch.trim().length > 1 ? "s" : ""} to search
Type {MIN_SEARCH_LENGTH - debouncedSearch.trim().length} more character
{MIN_SEARCH_LENGTH - debouncedSearch.trim().length > 1 ? "s" : ""} to search
</p>
</div>
) : actualLoading ? (
@ -369,7 +371,7 @@ export const DocumentMentionPicker = forwardRef<
</div>
) : actualDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4 text-center px-4">
<FileText className="h-5 w-5 text-muted-foreground/55 mb-1" />
<FileText className="h-5 w-5 text-muted-foreground mb-1" />
<p className="text-sm text-muted-foreground">No documents found</p>
</div>
) : (

View file

@ -166,8 +166,10 @@ class DocumentsApiService {
/**
* Search document titles (lightweight, optimized for mention picker)
* Returns only id, title, document_type - no content or metadata
* @param request - The search request with query params
* @param signal - Optional AbortSignal for request cancellation
*/
searchDocumentTitles = async (request: SearchDocumentTitlesRequest) => {
searchDocumentTitles = async (request: SearchDocumentTitlesRequest, signal?: AbortSignal) => {
const parsedRequest = searchDocumentTitlesRequest.safeParse(request);
if (!parsedRequest.success) {
@ -188,7 +190,8 @@ class DocumentsApiService {
return baseApiService.get(
`/api/v1/documents/search/titles?${queryParams}`,
searchDocumentTitlesResponse
searchDocumentTitlesResponse,
{ signal }
);
};
@ -258,8 +261,10 @@ class DocumentsApiService {
/**
* List all Surfsense documentation documents
* @param request - The request with query params
* @param signal - Optional AbortSignal for request cancellation
*/
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest) => {
getSurfsenseDocs = async (request: GetSurfsenseDocsRequest, signal?: AbortSignal) => {
const parsedRequest = getSurfsenseDocsRequest.safeParse(request);
if (!parsedRequest.success) {
@ -282,7 +287,7 @@ class DocumentsApiService {
const url = `/api/v1/surfsense-docs?${queryParams}`;
return baseApiService.get(url, getSurfsenseDocsResponse);
return baseApiService.get(url, getSurfsenseDocsResponse, { signal });
};
/**