From 7dd1000a190d1c810fa0d5723770341106742a1b Mon Sep 17 00:00:00 2001
From: Seefs <40468931+seefs001@users.noreply.github.com>
Date: Mon, 10 Aug 2026 12:47:55 +0800
Subject: [PATCH] perf(web): debounce server and large-list searches (#6727)
---
.../keys/components/api-keys-table.tsx | 1 +
.../models/components/deployments-table.tsx | 1 +
.../dialogs/upstream-conflict-dialog.tsx | 8 +++---
.../models/components/models-table.tsx | 1 +
web/src/features/pricing/hooks/use-filters.ts | 7 +++--
.../components/redemptions-table.tsx | 1 +
.../models/channel-selector-dialog.tsx | 8 +++---
.../models/model-ratio-visual-editor.tsx | 1 +
.../models/upstream-ratio-sync-table.tsx | 8 +++---
.../wallet/hooks/use-billing-history.ts | 27 ++++++++++++++-----
10 files changed, 45 insertions(+), 18 deletions(-)
diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx
index 3561df52..2ee49792 100644
--- a/web/src/features/keys/components/api-keys-table.tsx
+++ b/web/src/features/keys/components/api-keys-table.tsx
@@ -307,6 +307,7 @@ export function ApiKeysTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
+ searchDebounceMs: 500,
additionalSearch: (
.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
-import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
+import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import {
Search,
Info,
@@ -48,6 +48,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
+import { useDebounce } from '@/hooks/use-debounce'
import { useIsMobile } from '@/hooks/use-mobile'
import { applyUpstreamOverwrite } from '../../api'
@@ -117,6 +118,7 @@ export function UpstreamConflictDialog({
} = useModels()
const isMobile = useIsMobile()
const [search, setSearch] = useState('')
+ const debouncedSearch = useDebounce(search, 200)
const [isSubmitting, setIsSubmitting] = useState(false)
const [rowSelection, setRowSelection] = useState({})
const [pageSize, setPageSize] = useState(10)
@@ -149,7 +151,7 @@ export function UpstreamConflictDialog({
const totalModels = upstreamConflicts.length
const totalFields = conflictRows.length
- const normalizedSearch = search.trim().toLowerCase()
+ const normalizedSearch = debouncedSearch.trim().toLowerCase()
const { matchingModelNames, visibleRowIds } = useMemo(() => {
if (!normalizedSearch) {
@@ -396,7 +398,7 @@ export function UpstreamConflictDialog({
const payload: SyncOverwritePayload[] = Object.entries(groupedSelections)
.map(([modelName, fields]) => ({
model_name: modelName,
- fields: Array.from(fields),
+ fields: [...fields],
}))
.filter((item) => item.fields.length > 0)
diff --git a/web/src/features/models/components/models-table.tsx b/web/src/features/models/components/models-table.tsx
index 91640ddb..4a9b7b6e 100644
--- a/web/src/features/models/components/models-table.tsx
+++ b/web/src/features/models/components/models-table.tsx
@@ -202,6 +202,7 @@ export function ModelsTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by model name...'),
+ searchDebounceMs: 500,
filters: [
{
columnId: 'status',
diff --git a/web/src/features/pricing/hooks/use-filters.ts b/web/src/features/pricing/hooks/use-filters.ts
index 18415b8a..c055153f 100644
--- a/web/src/features/pricing/hooks/use-filters.ts
+++ b/web/src/features/pricing/hooks/use-filters.ts
@@ -19,6 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
import { useSearch } from '@tanstack/react-router'
import { useMemo, useCallback, useState } from 'react'
+import { useDebounce } from '@/hooks/use-debounce'
+
import {
FILTER_ALL,
SORT_OPTIONS,
@@ -67,6 +69,7 @@ export function useFilters(models: PricingModel[]) {
}))
const searchInput = filterState.search || ''
+ const debouncedSearchInput = useDebounce(searchInput, 200)
const sortBy = filterState.sort || SORT_OPTIONS.NAME
const vendorFilter = filterState.vendor || FILTER_ALL
const groupFilter = filterState.group || FILTER_ALL
@@ -147,7 +150,7 @@ export function useFilters(models: PricingModel[]) {
if (!models || models.length === 0) return []
return filterAndSortModels(models, {
- search: searchInput,
+ search: debouncedSearchInput,
vendor: vendorFilter,
group: groupFilter,
quotaType: quotaTypeFilter,
@@ -157,7 +160,7 @@ export function useFilters(models: PricingModel[]) {
})
}, [
models,
- searchInput,
+ debouncedSearchInput,
vendorFilter,
groupFilter,
quotaTypeFilter,
diff --git a/web/src/features/redemption-codes/components/redemptions-table.tsx b/web/src/features/redemption-codes/components/redemptions-table.tsx
index 391d64cf..850433ee 100644
--- a/web/src/features/redemption-codes/components/redemptions-table.tsx
+++ b/web/src/features/redemption-codes/components/redemptions-table.tsx
@@ -171,6 +171,7 @@ export function RedemptionsTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name or ID...'),
+ searchDebounceMs: 500,
filters: [
{
columnId: 'status',
diff --git a/web/src/features/system-settings/models/channel-selector-dialog.tsx b/web/src/features/system-settings/models/channel-selector-dialog.tsx
index e8b4d233..0be3348a 100644
--- a/web/src/features/system-settings/models/channel-selector-dialog.tsx
+++ b/web/src/features/system-settings/models/channel-selector-dialog.tsx
@@ -39,6 +39,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
+import { useDebounce } from '@/hooks/use-debounce'
import type { UpstreamChannel } from '../types'
import {
@@ -80,6 +81,7 @@ export function ChannelSelectorDialog({
}: ChannelSelectorDialogProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
+ const debouncedSearch = useDebounce(search, 200)
const [rowSelection, setRowSelection] = useState({})
useEffect(() => {
@@ -273,15 +275,15 @@ export function ChannelSelectorDialog({
)
const filteredChannels = useMemo(() => {
- if (!search.trim()) return channels
+ if (!debouncedSearch.trim()) return channels
- const searchLower = search.toLowerCase()
+ const searchLower = debouncedSearch.toLowerCase()
return channels.filter(
(ch) =>
ch.name.toLowerCase().includes(searchLower) ||
ch.base_url.toLowerCase().includes(searchLower)
)
- }, [channels, search])
+ }, [channels, debouncedSearch])
const sortedChannels = useMemo(() => {
return [...filteredChannels].sort((a, b) => {
diff --git a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx
index f50093cf..5013455c 100644
--- a/web/src/features/system-settings/models/model-ratio-visual-editor.tsx
+++ b/web/src/features/system-settings/models/model-ratio-visual-editor.tsx
@@ -682,6 +682,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
('')
const dataSource = useMemo(() => {
@@ -106,8 +108,8 @@ export function UpstreamRatioSyncTable({
const filteredData = useMemo(() => {
let data = dataSource
- if (search.trim()) {
- const lower = search.toLowerCase()
+ if (debouncedSearch.trim()) {
+ const lower = debouncedSearch.toLowerCase()
data = data.filter((row) => row.model.toLowerCase().includes(lower))
}
@@ -116,7 +118,7 @@ export function UpstreamRatioSyncTable({
}
return data
- }, [dataSource, search, ratioTypeFilter])
+ }, [dataSource, debouncedSearch, ratioTypeFilter])
const upstreamNames = useMemo(() => {
const set = new Set()
diff --git a/web/src/features/wallet/hooks/use-billing-history.ts b/web/src/features/wallet/hooks/use-billing-history.ts
index 48aead6a..54f98d5f 100644
--- a/web/src/features/wallet/hooks/use-billing-history.ts
+++ b/web/src/features/wallet/hooks/use-billing-history.ts
@@ -17,10 +17,11 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import i18next from 'i18next'
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { useIsAdmin } from '@/hooks/use-admin'
+import { useDebounce } from '@/hooks/use-debounce'
import {
getUserBillingHistory,
@@ -50,6 +51,8 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
const [page, setPage] = useState(initialPage)
const [pageSize, setPageSize] = useState(initialPageSize)
const [keyword, setKeyword] = useState('')
+ const debouncedKeyword = useDebounce(keyword)
+ const requestIdRef = useRef(0)
const [loading, setLoading] = useState(false)
const [completing, setCompleting] = useState(false)
@@ -57,11 +60,14 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
* Fetch billing history
*/
const fetchBillingHistory = useCallback(async () => {
+ const requestId = ++requestIdRef.current
setLoading(true)
try {
const response = isAdmin
- ? await getAllBillingHistory(page, pageSize, keyword)
- : await getUserBillingHistory(page, pageSize, keyword)
+ ? await getAllBillingHistory(page, pageSize, debouncedKeyword)
+ : await getUserBillingHistory(page, pageSize, debouncedKeyword)
+
+ if (requestId !== requestIdRef.current) return
if (isApiSuccess(response) && response.data) {
setRecords(response.data.items || [])
@@ -74,15 +80,19 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
setTotal(0)
}
} catch (error) {
+ if (requestId !== requestIdRef.current) return
+
// eslint-disable-next-line no-console
console.error('Failed to fetch billing history:', error)
toast.error(i18next.t('Failed to load billing history'))
setRecords([])
setTotal(0)
} finally {
- setLoading(false)
+ if (requestId === requestIdRef.current) {
+ setLoading(false)
+ }
}
- }, [isAdmin, page, pageSize, keyword])
+ }, [debouncedKeyword, isAdmin, page, pageSize])
/**
* Complete a pending order (admin only)
@@ -137,14 +147,17 @@ export function useBillingHistory(options: UseBillingHistoryOptions = {}) {
* Search by keyword
*/
const handleSearch = useCallback((newKeyword: string) => {
+ requestIdRef.current += 1
setKeyword(newKeyword)
setPage(1) // Reset to first page when searching
}, [])
- // Fetch data when dependencies change
+ // Fetch data after the search draft has settled.
useEffect(() => {
+ if (keyword !== debouncedKeyword) return
+
fetchBillingHistory()
- }, [fetchBillingHistory])
+ }, [debouncedKeyword, fetchBillingHistory, keyword])
return {
records,