make query memoized

This commit is contained in:
Nate Kelley 2025-10-01 16:33:21 -06:00
parent ffde4ef59c
commit 9a9b5fb674
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
8 changed files with 294 additions and 23 deletions

View File

@ -1,7 +1,13 @@
import type { SearchTextResponse } from '@buster/server-shared/search';
import { keepPreviousData, type UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
keepPreviousData,
type UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { useState } from 'react';
import type { ApiError } from '@/api/errors';
import { searchQueryKeys } from '@/api/query_keys/search';
import { getSearchResultInfinite, searchQueryKeys } from '@/api/query_keys/search';
import { search } from './requests';
export const useSearch = <T = SearchTextResponse>(
@ -16,3 +22,27 @@ export const useSearch = <T = SearchTextResponse>(
placeholderData: keepPreviousData,
});
};
export const useSearchInfinite = (
params: Pick<Parameters<typeof search>[0], 'page_size' | 'assetTypes'> = {
page_size: 5,
assetTypes: ['chat'],
}
) => {
const [searchQuery, setSearchQuery] = useState<string>('');
const queryResult = useInfiniteQuery({
queryKey: ['search', 'results', 'infinite', params] as const,
staleTime: 1000 * 30, // 30 seconds,
queryFn: ({ pageParam = 1 }) => search({ query: searchQuery, page: pageParam, ...params }),
getNextPageParam: (lastPage) => {
if (!lastPage.pagination.has_more) {
return undefined;
}
return lastPage.pagination.page + 1;
},
initialPageParam: 1,
});
return { ...queryResult, setSearchQuery, searchQuery };
};

View File

@ -0,0 +1 @@
export { useInfiniteScroll } from './useInfiniteScroll';

View File

@ -0,0 +1,130 @@
import type { SearchPaginatedResponse } from '@buster/server-shared';
import type {
InfiniteData,
QueryKey,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
} from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import type { ApiError } from '@/api/errors';
/**
* Configuration for infinite scroll behavior
*/
const InfiniteScrollConfigSchema = {
/**
* Distance in pixels from the bottom to trigger next page fetch
* @default 100
*/
scrollThreshold: 100,
} as const;
type InfiniteScrollConfig = {
scrollThreshold?: number;
};
/**
* Hook options extending react-query's infinite query options
*/
type UseInfiniteScrollOptions<TData, TError = ApiError> = Omit<
UseInfiniteQueryOptions<
SearchPaginatedResponse<TData>,
TError,
InfiniteData<SearchPaginatedResponse<TData>>,
QueryKey,
number
>,
'getNextPageParam' | 'initialPageParam'
> & {
/**
* Infinite scroll configuration
*/
scrollConfig?: InfiniteScrollConfig;
};
/**
* Return type for useInfiniteScroll hook
*/
type UseInfiniteScrollResult<TData, TError = ApiError> = UseInfiniteQueryResult<
InfiniteData<SearchPaginatedResponse<TData>>,
TError
> & {
/**
* Ref to attach to the scrollable container element
*/
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
/**
* Flattened array of all results from all pages
*/
allResults: TData[];
};
/**
* Reusable infinite scroll hook that combines react-query's useInfiniteQuery
* with automatic scroll detection and pagination.
*
* @example
* ```tsx
* const { scrollContainerRef, allResults, isLoading } = useInfiniteScroll({
* queryKey: ['search', searchQuery],
* queryFn: ({ pageParam = 1 }) => searchApi({ query: searchQuery, page: pageParam }),
* });
*
* return (
* <div ref={scrollContainerRef} style={{ height: '500px', overflow: 'auto' }}>
* {allResults.map(item => <Item key={item.id} data={item} />)}
* {isLoading && <div>Loading...</div>}
* </div>
* );
* ```
*/
export function useInfiniteScroll<TData, TError = ApiError>(
options: UseInfiniteScrollOptions<TData, TError>
): UseInfiniteScrollResult<TData, TError> {
const { scrollConfig, ...queryOptions } = options;
const scrollThreshold =
scrollConfig?.scrollThreshold ?? InfiniteScrollConfigSchema.scrollThreshold;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const queryResult = useInfiniteQuery({
...queryOptions,
getNextPageParam: (lastPage) => {
if (!lastPage.pagination.has_more) {
return undefined;
}
return lastPage.pagination.page + 1;
},
initialPageParam: 1,
});
const { fetchNextPage, hasNextPage, isFetchingNextPage } = queryResult;
// Combine all pages into a single array of results
const allResults = queryResult.data?.pages.flatMap((page) => page.data) ?? [];
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// Trigger when user is within scrollThreshold pixels of the bottom
if (scrollHeight - scrollTop - clientHeight < scrollThreshold) {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage, scrollThreshold]);
return {
...queryResult,
scrollContainerRef,
allResults,
};
}

View File

@ -1,10 +1,24 @@
import type { SearchTextRequest, SearchTextResponse } from '@buster/server-shared';
import { queryOptions } from '@tanstack/react-query';
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
import type { search } from '../buster_rest/search';
export const getSearchResult = (params: SearchTextRequest) =>
queryOptions<SearchTextResponse>({
queryKey: ['search', 'results', params] as const,
staleTime: 1000 * 15, // 15 seconds,
staleTime: 1000 * 30, // 30 seconds,
});
export const getSearchResultInfinite = () =>
infiniteQueryOptions<SearchTextResponse>({
queryKey: ['search', 'results', 'infinite'] as const,
staleTime: 1000 * 30, // 30 seconds,
getNextPageParam: (lastPage) => {
if (!lastPage.pagination.has_more) {
return undefined;
}
return lastPage.pagination.page + 1;
},
initialPageParam: 1,
});
export const searchQueryKeys = {

View File

@ -23,8 +23,8 @@ export const BusterChatInput: React.FC<{
}
});
const onSubmit: BusterChatInputProps['onSubmit'] = useMemoizedFn((d) => {
onStartNewChat({ prompt: d.transformedValue, mode: d.mode });
const onSubmit: BusterChatInputProps['onSubmit'] = useMemoizedFn(({ transformedValue, mode }) => {
return onStartNewChat({ prompt: transformedValue, mode });
});
return (

View File

@ -87,7 +87,8 @@ export const BusterChatInputBase: React.FC<BusterChatInputProps> = React.memo(
return [shortcutsMentionsSuggestions];
}, [shortcutsMentionsSuggestions]);
const onSubmitPreflight = (valueProp?: ReturnType<MentionInputSuggestionsRef['getValue']>) => {
const onSubmitPreflight = useMemoizedFn(
(valueProp?: ReturnType<MentionInputSuggestionsRef['getValue']>) => {
if (submitting) {
console.warn('Input is submitting');
return;
@ -106,7 +107,8 @@ export const BusterChatInputBase: React.FC<BusterChatInputProps> = React.memo(
}
onSubmit({ ...value, mode });
};
}
);
const onSuggestionItemClick = useMemoizedFn((d: MentionInputSuggestionsOnSelectParams) => {
if (d.addValueToInput) {

View File

@ -30,6 +30,7 @@ import { Route as EmbedDashboardDashboardIdRouteImport } from './routes/embed/da
import { Route as EmbedChatChatIdRouteImport } from './routes/embed/chat.$chatId'
import { Route as AppSettingsRestricted_layoutRouteImport } from './routes/app/_settings/_restricted_layout'
import { Route as AppSettingsPermissionsRouteImport } from './routes/app/_settings/_permissions'
import { Route as AppAppTestPaginationRouteImport } from './routes/app/_app/test-pagination'
import { Route as AppAppNewUserRouteImport } from './routes/app/_app/new-user'
import { Route as AppAppHomeRouteImport } from './routes/app/_app/home'
import { Route as AppAppAssetRouteImport } from './routes/app/_app/_asset'
@ -292,6 +293,11 @@ const AppSettingsPermissionsRoute = AppSettingsPermissionsRouteImport.update({
id: '/_permissions',
getParentRoute: () => AppSettingsRoute,
} as any)
const AppAppTestPaginationRoute = AppAppTestPaginationRouteImport.update({
id: '/test-pagination',
path: '/test-pagination',
getParentRoute: () => AppAppRoute,
} as any)
const AppAppNewUserRoute = AppAppNewUserRouteImport.update({
id: '/new-user',
path: '/new-user',
@ -1188,6 +1194,7 @@ export interface FileRoutesByFullPath {
'/app/': typeof AppIndexRoute
'/app/home': typeof AppAppHomeRouteWithChildren
'/app/new-user': typeof AppAppNewUserRouteWithChildren
'/app/test-pagination': typeof AppAppTestPaginationRoute
'/embed/chat/$chatId': typeof EmbedChatChatIdRouteWithChildren
'/embed/dashboard/$dashboardId': typeof EmbedDashboardDashboardIdRoute
'/embed/metric/$metricId': typeof EmbedMetricMetricIdRoute
@ -1320,6 +1327,7 @@ export interface FileRoutesByTo {
'/auth/logout': typeof AuthLogoutRoute
'/auth/reset-password': typeof AuthResetPasswordRoute
'/info/getting-started': typeof InfoGettingStartedRoute
'/app/test-pagination': typeof AppAppTestPaginationRoute
'/embed/dashboard/$dashboardId': typeof EmbedDashboardDashboardIdRoute
'/embed/metric/$metricId': typeof EmbedMetricMetricIdRoute
'/embed/report/$reportId': typeof EmbedReportReportIdRoute
@ -1436,6 +1444,7 @@ export interface FileRoutesById {
'/app/_app/_asset': typeof AppAppAssetRouteWithChildren
'/app/_app/home': typeof AppAppHomeRouteWithChildren
'/app/_app/new-user': typeof AppAppNewUserRouteWithChildren
'/app/_app/test-pagination': typeof AppAppTestPaginationRoute
'/app/_settings/_permissions': typeof AppSettingsPermissionsRouteWithChildren
'/app/_settings/_restricted_layout': typeof AppSettingsRestricted_layoutRouteWithChildren
'/embed/chat/$chatId': typeof EmbedChatChatIdRouteWithChildren
@ -1591,6 +1600,7 @@ export interface FileRouteTypes {
| '/app/'
| '/app/home'
| '/app/new-user'
| '/app/test-pagination'
| '/embed/chat/$chatId'
| '/embed/dashboard/$dashboardId'
| '/embed/metric/$metricId'
@ -1723,6 +1733,7 @@ export interface FileRouteTypes {
| '/auth/logout'
| '/auth/reset-password'
| '/info/getting-started'
| '/app/test-pagination'
| '/embed/dashboard/$dashboardId'
| '/embed/metric/$metricId'
| '/embed/report/$reportId'
@ -1838,6 +1849,7 @@ export interface FileRouteTypes {
| '/app/_app/_asset'
| '/app/_app/home'
| '/app/_app/new-user'
| '/app/_app/test-pagination'
| '/app/_settings/_permissions'
| '/app/_settings/_restricted_layout'
| '/embed/chat/$chatId'
@ -2141,6 +2153,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsPermissionsRouteImport
parentRoute: typeof AppSettingsRoute
}
'/app/_app/test-pagination': {
id: '/app/_app/test-pagination'
path: '/test-pagination'
fullPath: '/app/test-pagination'
preLoaderRoute: typeof AppAppTestPaginationRouteImport
parentRoute: typeof AppAppRoute
}
'/app/_app/new-user': {
id: '/app/_app/new-user'
path: '/new-user'
@ -3610,6 +3629,7 @@ interface AppAppRouteChildren {
AppAppAssetRoute: typeof AppAppAssetRouteWithChildren
AppAppHomeRoute: typeof AppAppHomeRouteWithChildren
AppAppNewUserRoute: typeof AppAppNewUserRouteWithChildren
AppAppTestPaginationRoute: typeof AppAppTestPaginationRoute
AppAppDatasetsDatasetIdRoute: typeof AppAppDatasetsDatasetIdRouteWithChildren
AppAppChatsIndexRoute: typeof AppAppChatsIndexRoute
AppAppCollectionsIndexRoute: typeof AppAppCollectionsIndexRoute
@ -3624,6 +3644,7 @@ const AppAppRouteChildren: AppAppRouteChildren = {
AppAppAssetRoute: AppAppAssetRouteWithChildren,
AppAppHomeRoute: AppAppHomeRouteWithChildren,
AppAppNewUserRoute: AppAppNewUserRouteWithChildren,
AppAppTestPaginationRoute: AppAppTestPaginationRoute,
AppAppDatasetsDatasetIdRoute: AppAppDatasetsDatasetIdRouteWithChildren,
AppAppChatsIndexRoute: AppAppChatsIndexRoute,
AppAppCollectionsIndexRoute: AppAppCollectionsIndexRoute,

View File

@ -0,0 +1,73 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useRef } from 'react';
import { search, useSearchInfinite } from '@/api/buster_rest/search';
export const Route = createFileRoute('/app/_app/test-pagination')({
component: RouteComponent,
});
function RouteComponent() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useSearchInfinite();
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Combine all pages into a single array of results
const allResults = data?.pages.flatMap((page) => page.data) ?? [];
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// Trigger when user is within 100px of the bottom
if (scrollHeight - scrollTop - clientHeight < 100) {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div
className="p-4 max-h-[500px] overflow-y-auto border border-red-500"
ref={scrollContainerRef}
>
<h1 className="text-2xl font-bold mb-4">Search Results</h1>
{isLoading && <div>Loading...</div>}
{allResults.length > 0 && (
<div className="space-y-2 ">
{allResults.map((result, index) => (
<div key={`${result.assetId}-${index}`} className="p-4 border rounded">
{JSON.stringify(result)}
</div>
))}
{isFetchingNextPage && (
<div className="p-4 text-center text-gray-500">Loading more...</div>
)}
</div>
)}
{hasNextPage && (
<button
type="button"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-300"
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
{!hasNextPage && allResults.length > 0 && (
<div className="mt-4 text-gray-500">No more results</div>
)}
</div>
);
}