perf(web): add debounce channel search and skip during IME composition (#5393)
This commit is contained in:
+105
-11
@@ -21,6 +21,7 @@ import { useState, type ReactNode } from 'react'
|
|||||||
import { type Table } from '@tanstack/react-table'
|
import { type Table } from '@tanstack/react-table'
|
||||||
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useDebounce } from '@/hooks'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -46,6 +47,10 @@ export type DataTableToolbarProps<TData> = {
|
|||||||
* Placeholder for the default search input. Defaults to `t('Filter...')`.
|
* Placeholder for the default search input. Defaults to `t('Filter...')`.
|
||||||
*/
|
*/
|
||||||
searchPlaceholder?: string
|
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
|
* Column id to filter on. When provided, the search input filters
|
||||||
* a specific column. When omitted, the search input updates the
|
* 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>) {
|
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const isSearchComposingRef = React.useRef(false)
|
||||||
|
const lastCommittedSearchValueRef = React.useRef('')
|
||||||
|
|
||||||
const filters = props.filters ?? []
|
const filters = props.filters ?? []
|
||||||
const hasExpandable = props.expandable != null
|
const hasExpandable = props.expandable != null
|
||||||
@@ -147,26 +154,109 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
|||||||
!!props.hasAdditionalFilters
|
!!props.hasAdditionalFilters
|
||||||
|
|
||||||
const placeholder = props.searchPlaceholder ?? t('Filter...')
|
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 ? (
|
const searchInput = props.searchKey ? (
|
||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={
|
value={searchValue}
|
||||||
(props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
|
onChange={handleSearchChange}
|
||||||
''
|
onCompositionStart={handleSearchCompositionStart}
|
||||||
}
|
onCompositionEnd={handleSearchCompositionEnd}
|
||||||
onChange={(event) =>
|
|
||||||
props.table
|
|
||||||
.getColumn(props.searchKey!)
|
|
||||||
?.setFilterValue(event.target.value)
|
|
||||||
}
|
|
||||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={props.table.getState().globalFilter ?? ''}
|
value={searchValue}
|
||||||
onChange={(event) => props.table.setGlobalFilter(event.target.value)}
|
onChange={handleSearchChange}
|
||||||
|
onCompositionStart={handleSearchCompositionStart}
|
||||||
|
onCompositionEnd={handleSearchCompositionEnd}
|
||||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -186,6 +276,10 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
isSearchComposingRef.current = false
|
||||||
|
setSearchValue('')
|
||||||
|
setPendingSearchValue('')
|
||||||
|
lastCommittedSearchValueRef.current = ''
|
||||||
props.table.resetColumnFilters()
|
props.table.resetColumnFilters()
|
||||||
props.table.setGlobalFilter('')
|
props.table.setGlobalFilter('')
|
||||||
props.onReset?.()
|
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
|
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 { useQuery } from '@tanstack/react-query'
|
||||||
import { getRouteApi } from '@tanstack/react-router'
|
import { getRouteApi } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
@@ -124,17 +131,26 @@ export function ChannelsTable() {
|
|||||||
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
|
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
|
||||||
|
|
||||||
// Local state for immediate input feedback
|
// Local state for immediate input feedback
|
||||||
|
const isModelFilterComposingRef = useRef(false)
|
||||||
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
|
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)
|
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setModelFilterInput(modelFilterFromUrl)
|
if (!isModelFilterComposingRef.current) {
|
||||||
|
setModelFilterInput(modelFilterFromUrl)
|
||||||
|
}
|
||||||
|
setModelFilterPendingValue(modelFilterFromUrl)
|
||||||
}, [modelFilterFromUrl])
|
}, [modelFilterFromUrl])
|
||||||
|
|
||||||
// Update URL when debounced value changes
|
// Update URL when debounced value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedModelFilter !== modelFilterFromUrl) {
|
if (
|
||||||
|
debouncedModelFilter === modelFilterPendingValue &&
|
||||||
|
debouncedModelFilter !== modelFilterFromUrl
|
||||||
|
) {
|
||||||
onColumnFiltersChange((prev) => {
|
onColumnFiltersChange((prev) => {
|
||||||
const filtered = prev.filter((f) => f.id !== 'model')
|
const filtered = prev.filter((f) => f.id !== 'model')
|
||||||
return debouncedModelFilter
|
return debouncedModelFilter
|
||||||
@@ -142,7 +158,34 @@ export function ChannelsTable() {
|
|||||||
: filtered
|
: 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
|
const modelFilter = modelFilterFromUrl
|
||||||
|
|
||||||
@@ -385,11 +428,19 @@ export function ChannelsTable() {
|
|||||||
applyHeaderSize
|
applyHeaderSize
|
||||||
toolbarProps={{
|
toolbarProps={{
|
||||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||||
|
searchDebounceMs: 500,
|
||||||
|
onReset: () => {
|
||||||
|
isModelFilterComposingRef.current = false
|
||||||
|
setModelFilterInput('')
|
||||||
|
setModelFilterPendingValue('')
|
||||||
|
},
|
||||||
additionalSearch: (
|
additionalSearch: (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('Filter by model...')}
|
placeholder={t('Filter by model...')}
|
||||||
value={modelFilterInput}
|
value={modelFilterInput}
|
||||||
onChange={(e) => setModelFilterInput(e.target.value)}
|
onChange={handleModelFilterChange}
|
||||||
|
onCompositionStart={handleModelFilterCompositionStart}
|
||||||
|
onCompositionEnd={handleModelFilterCompositionEnd}
|
||||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user