perf(web): add debounce channel search and skip during IME composition (#5393)

This commit is contained in:
Q.A.zh
2026-06-10 17:18:51 +08:00
committed by GitHub
parent d2576ddcd3
commit 30d3a3a5f7
2 changed files with 162 additions and 17 deletions
+105 -11
View File
@@ -21,6 +21,7 @@ import { useState, type ReactNode } from 'react'
import { type Table } from '@tanstack/react-table'
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useDebounce } from '@/hooks'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -46,6 +47,10 @@ export type DataTableToolbarProps<TData> = {
* Placeholder for the default search input. Defaults to `t('Filter...')`.
*/
searchPlaceholder?: string
/**
* Delay committing the default search input. Defaults to immediate updates.
*/
searchDebounceMs?: number
/**
* Column id to filter on. When provided, the search input filters
* a specific column. When omitted, the search input updates the
@@ -136,6 +141,8 @@ export type DataTableToolbarProps<TData> = {
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const isSearchComposingRef = React.useRef(false)
const lastCommittedSearchValueRef = React.useRef('')
const filters = props.filters ?? []
const hasExpandable = props.expandable != null
@@ -147,26 +154,109 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
!!props.hasAdditionalFilters
const placeholder = props.searchPlaceholder ?? t('Filter...')
const currentSearchValue = props.searchKey
? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
'')
: ((props.table.getState().globalFilter as string | undefined) ?? '')
const [searchValue, setSearchValue] = useState(currentSearchValue)
const [pendingSearchValue, setPendingSearchValue] =
useState(currentSearchValue)
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
const debouncedSearchValue = useDebounce(
pendingSearchValue,
searchDebounceMs
)
React.useEffect(() => {
lastCommittedSearchValueRef.current = currentSearchValue
if (!isSearchComposingRef.current) {
setSearchValue(currentSearchValue)
}
setPendingSearchValue(currentSearchValue)
}, [currentSearchValue])
const commitSearchValue = React.useCallback(
(value: string) => {
if (value === lastCommittedSearchValueRef.current) {
return
}
lastCommittedSearchValueRef.current = value
if (props.searchKey) {
props.table.getColumn(props.searchKey)?.setFilterValue(value)
return
}
props.table.setGlobalFilter(value)
},
[props.searchKey, props.table]
)
React.useEffect(() => {
if (
searchDebounceMs <= 0 ||
isSearchComposingRef.current ||
debouncedSearchValue !== pendingSearchValue
) {
return
}
commitSearchValue(debouncedSearchValue)
}, [
commitSearchValue,
debouncedSearchValue,
pendingSearchValue,
searchDebounceMs,
])
const queueSearchValue = (value: string) => {
setPendingSearchValue(value)
if (searchDebounceMs <= 0) {
commitSearchValue(value)
}
}
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setSearchValue(value)
if (!isSearchComposingRef.current) {
queueSearchValue(value)
}
}
const handleSearchCompositionStart = () => {
isSearchComposingRef.current = true
}
const handleSearchCompositionEnd = (
event: React.CompositionEvent<HTMLInputElement>
) => {
isSearchComposingRef.current = false
const value = event.currentTarget.value
setSearchValue(value)
queueSearchValue(value)
}
const searchInput = props.searchKey ? (
<Input
placeholder={placeholder}
value={
(props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
''
}
onChange={(event) =>
props.table
.getColumn(props.searchKey!)
?.setFilterValue(event.target.value)
}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
) : (
<Input
placeholder={placeholder}
value={props.table.getState().globalFilter ?? ''}
onChange={(event) => props.table.setGlobalFilter(event.target.value)}
value={searchValue}
onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]'
/>
)
@@ -186,6 +276,10 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
})
const handleReset = () => {
isSearchComposingRef.current = false
setSearchValue('')
setPendingSearchValue('')
lastCommittedSearchValueRef.current = ''
props.table.resetColumnFilters()
props.table.setGlobalFilter('')
props.onReset?.()
@@ -16,7 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo, useEffect } from 'react'
import {
useState,
useMemo,
useEffect,
useRef,
type ChangeEvent,
type CompositionEvent,
} from 'react'
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import {
@@ -124,17 +131,26 @@ export function ChannelsTable() {
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
// Local state for immediate input feedback
const isModelFilterComposingRef = useRef(false)
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
const [modelFilterPendingValue, setModelFilterPendingValue] =
useState(modelFilterFromUrl)
const debouncedModelFilter = useDebounce(modelFilterPendingValue, 500)
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
useEffect(() => {
setModelFilterInput(modelFilterFromUrl)
if (!isModelFilterComposingRef.current) {
setModelFilterInput(modelFilterFromUrl)
}
setModelFilterPendingValue(modelFilterFromUrl)
}, [modelFilterFromUrl])
// Update URL when debounced value changes
useEffect(() => {
if (debouncedModelFilter !== modelFilterFromUrl) {
if (
debouncedModelFilter === modelFilterPendingValue &&
debouncedModelFilter !== modelFilterFromUrl
) {
onColumnFiltersChange((prev) => {
const filtered = prev.filter((f) => f.id !== 'model')
return debouncedModelFilter
@@ -142,7 +158,34 @@ export function ChannelsTable() {
: filtered
})
}
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
}, [
debouncedModelFilter,
modelFilterFromUrl,
modelFilterPendingValue,
onColumnFiltersChange,
])
const handleModelFilterChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value
setModelFilterInput(value)
if (!isModelFilterComposingRef.current) {
setModelFilterPendingValue(value)
}
}
const handleModelFilterCompositionStart = () => {
isModelFilterComposingRef.current = true
}
const handleModelFilterCompositionEnd = (
event: CompositionEvent<HTMLInputElement>
) => {
isModelFilterComposingRef.current = false
const value = event.currentTarget.value
setModelFilterInput(value)
setModelFilterPendingValue(value)
}
const modelFilter = modelFilterFromUrl
@@ -385,11 +428,19 @@ export function ChannelsTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
isModelFilterComposingRef.current = false
setModelFilterInput('')
setModelFilterPendingValue('')
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)}
onChange={handleModelFilterChange}
onCompositionStart={handleModelFilterCompositionStart}
onCompositionEnd={handleModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]'
/>
),