refactor(frontend): migrate filter/search debounce to use-debounce npm

After landing the imperative-timer fix and extracting `useDebouncedCallback`
into our own hook, the use-site count climbed past three and the case for
a single, battle-tested contract beat the case for in-tree code. Drops
both our `useDebouncedValue` and `useDebouncedCallback` and routes every
caller through `use-debounce@10.1.1` (1M+ weekly, ~3KB gzip).

API mapping at each use-site:
- value debounce: `useDebouncedValue(v, ms)` → `const [v] = useDebounce(v, ms)`
- callback debounce: `useDebouncedCallback(fn, ms)` → identical signature,
  same `.cancel()`/`.isPending()` semantics we relied on, plus `.flush()`
  and leading/trailing/maxWait options we now get for free.

Files touched (7 use-sites): data-table.tsx, input-search.tsx,
use-table-state.ts, use-table-query-filter.ts, knowledges-provider.tsx,
use-flow-files-search.ts, use-detail-navigation.ts.

pnpm + Vite gotcha: pnpm hoists into `.pnpm/<name>@<ver>/`, so any
transitive `import React from 'react'` from a third-party package can
resolve to its own physical copy. With one path through our code and
another through use-debounce, the runtime crashed at `useRef` with
"Cannot read properties of null (reading 'useRef')". Added
`resolve.dedupe: ['react', 'react-dom']` in vite.config.ts so the
bundle ships exactly one React.

Tests:
- new input-search.test.tsx (10 tests) covers collapsed/expanded
  presentation, expand-on-click + rAF focus, burst-typing coalesce,
  Esc-clear mid-debounce, two-stage Esc semantics, external-source-wins
  race, Ctrl+F / Cmd+F global hotkey expansion.
- data-table.test.tsx +5 tests: deep-link initial filterValue,
  6-keystroke burst coalesce, unmount-mid-debounce cleanup, two
  independent DataTables on one page (settings/prompts shape),
  back-button-style external clear, three-round type/clear stress.
- full suite: 541/541 passing, lint 0 errors.

Manual smoke via Chrome DevTools MCP on every page that mounts a
filter input — /flows, /templates, /settings/api-tokens,
/settings/prompts (both tables independent), /knowledges (filter
+ semantic search hitting the server through ?qs=). All scenarios
collapse to a single `[{v="", u=""}]` transition; no URL re-emit
after X, no leftover timer after Esc, no cross-table leakage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-23 08:28:27 +07:00
parent e451d23aab
commit 17451e75dd
15 changed files with 438 additions and 243 deletions
+1
View File
@@ -90,6 +90,7 @@
"tailwind-merge": "^3.6.0",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"use-debounce": "^10.1.1",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
+13
View File
@@ -224,6 +224,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
use-debounce:
specifier: ^10.1.1
version: 10.1.1(react@19.2.6)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -5708,6 +5711,12 @@ packages:
'@types/react':
optional: true
use-debounce@10.1.1:
resolution: {integrity: sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==}
engines: {node: '>= 16.0.0'}
peerDependencies:
react: '*'
use-isomorphic-layout-effect@1.2.1:
resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
peerDependencies:
@@ -12025,6 +12034,10 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
use-debounce@10.1.1(react@19.2.6):
dependencies:
react: 19.2.6
use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.6):
dependencies:
react: 19.2.6
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDebounce } from 'use-debounce';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { useTableQueryFilterReader } from '@/hooks/use-table-query-filter';
import { mergeHrefWithSearchParams } from '@/lib/url-params';
@@ -222,7 +222,7 @@ export function useDetailNavigation<T extends { id: string }>({
const [internalSearchQuery, setInternalSearchQuery] = useState(defaultSearchQuery ?? '');
const isSearchControlled = searchQuery !== undefined;
const activeSearchQuery = isSearchControlled ? searchQuery : internalSearchQuery;
const debouncedSearchQuery = useDebouncedValue(activeSearchQuery, searchDebounceMs ?? DEFAULT_SEARCH_DEBOUNCE_MS);
const [debouncedSearchQuery] = useDebounce(activeSearchQuery, searchDebounceMs ?? DEFAULT_SEARCH_DEBOUNCE_MS);
const setSearchQuery = useCallback(
(next: string) => {
@@ -293,6 +293,194 @@ describe('DataTable — controlled filter projection', () => {
await waitFor(() => expect(emitted.at(-1)).toBe('seed'));
expect((input as HTMLInputElement).value).toBe('seed');
});
it('mounts with a deep-linked filterValue and shows the X clear button immediately', () => {
// Deep links land on the page with `?q=alpha` already in the URL —
// the parent passes that value down at first render, the input should
// be populated, and the trailing X should be available right away.
const emitted: string[] = [];
render(<FilterHost emitted={emitted} initialValue="alpha" />, { wrapper: Wrapper });
const input = screen.getByPlaceholderText('Filter...') as HTMLInputElement;
expect(input.value).toBe('alpha');
// No emission happened — we accepted the prop, we didn't echo it back.
expect(emitted).toEqual([]);
// X button is rendered because the input is non-empty.
const inputGroup = input.closest('[data-slot="input-group"]');
expect(within(inputGroup as HTMLElement).getByRole('button')).toBeInTheDocument();
});
it('coalesces burst typing into a single emit with the last-typed value', async () => {
// We type six characters back-to-back. The 150ms debounce should
// collapse the burst to a single outbound call — sequential prefix
// commits would defeat the debounce and hammer the URL writer.
const emitted: string[] = [];
const user = userEvent.setup();
render(<FilterHost emitted={emitted} />, { wrapper: Wrapper });
const input = screen.getByPlaceholderText('Filter...');
await user.type(input, 'foobar');
await waitFor(() => expect(emitted.at(-1)).toBe('foobar'));
// No intermediate prefixes — only the final value lands.
expect(emitted).toEqual(['foobar']);
});
it('does not write to the upstream on unmount with a pending debounce', async () => {
// If the user types and immediately navigates away, the in-flight
// timer should be cancelled by `useDebouncedCallback` so the dead
// component can't push a stale value into the parent (which by then
// may belong to a different page).
const emitted: string[] = [];
const user = userEvent.setup();
const { unmount } = render(<FilterHost emitted={emitted} />, { wrapper: Wrapper });
await user.type(screen.getByPlaceholderText('Filter...'), 'gone', { delay: 5 });
// Unmount before the debounce settles.
unmount();
// Give any leftover timer time to misfire.
await new Promise((resolve) => setTimeout(resolve, 400));
// Nothing made it upstream — the timer was cancelled by the hook's
// unmount cleanup.
expect(emitted).toEqual([]);
});
it('keeps two DataTables on one page independent (settings/prompts shape)', async () => {
// `/settings/prompts` mounts an Agent table and a Tool table on the
// same route. Typing in one input must not leak into the other —
// both DataTableFilter instances own private state, with separate
// `lastEmittedReference`s and timers.
const emittedAgent: string[] = [];
const emittedTool: string[] = [];
const user = userEvent.setup();
const TwoTablesHost = () => {
const [agent, setAgent] = useState('');
const [tool, setTool] = useState('');
return (
<>
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterPlaceholder="Agent filter..."
filterValue={agent}
onFilterChange={(next) => {
emittedAgent.push(next);
setAgent(next);
}}
/>
<DataTable<Row>
columns={COLUMNS}
data={ROWS}
filterColumn="name"
filterPlaceholder="Tool filter..."
filterValue={tool}
onFilterChange={(next) => {
emittedTool.push(next);
setTool(next);
}}
/>
</>
);
};
render(<TwoTablesHost />, { wrapper: Wrapper });
const agentInput = screen.getByPlaceholderText('Agent filter...');
const toolInput = screen.getByPlaceholderText('Tool filter...');
await user.type(agentInput, 'agX');
await waitFor(() => expect(emittedAgent.at(-1)).toBe('agX'));
// Tool table never saw anything.
expect(emittedTool).toEqual([]);
expect((toolInput as HTMLInputElement).value).toBe('');
// Now type in the tool input; agent must stay frozen.
await user.type(toolInput, 'toY');
await waitFor(() => expect(emittedTool.at(-1)).toBe('toY'));
expect((agentInput as HTMLInputElement).value).toBe('agX');
expect(emittedAgent).toEqual(['agX']);
});
it('accepts a back-button-style external clear without re-emitting the old value', async () => {
// History pop: user typed `'foo'`, then hit back. The router resets
// `filterValue` to `''`. The input should snap to `''` without
// re-emitting `'foo'` from a stale timer or sync effect.
const emitted: string[] = [];
const user = userEvent.setup();
let setExternal: ((next: string) => void) | undefined;
render(
<FilterHost
emitted={emitted}
onSetValueRef={(setter) => {
setExternal = setter;
}}
/>,
{ wrapper: Wrapper },
);
const input = screen.getByPlaceholderText('Filter...');
await user.type(input, 'foo');
await waitFor(() => expect(emitted.at(-1)).toBe('foo'));
// Simulate the browser back button clearing `?q=`. The external
// setter bypasses `onFilterChange` (which is the realistic shape —
// a real router pop doesn't echo through our handler), so we measure
// the absence of further emits rather than a `''` arrival.
const indexOfFoo = emitted.lastIndexOf('foo');
expect(setExternal).toBeDefined();
act(() => setExternal!(''));
await new Promise((resolve) => setTimeout(resolve, 400));
// The input cleared; no leftover timer pushed `'foo'` back upstream.
expect((input as HTMLInputElement).value).toBe('');
expect(emitted.slice(indexOfFoo + 1)).not.toContain('foo');
});
it('survives rapid alternation between typing and X clicks', async () => {
// Stress: type, clear, type, clear — three rounds back to back.
// Each round must end with a clean `''` upstream and an empty input.
const emitted: string[] = [];
const user = userEvent.setup();
render(<FilterHost emitted={emitted} />, { wrapper: Wrapper });
const input = screen.getByPlaceholderText('Filter...');
for (const round of ['one', 'two', 'three']) {
await user.type(input, round);
await waitFor(() => expect(emitted.at(-1)).toBe(round));
const inputGroup = input.closest('[data-slot="input-group"]');
const clearButton = within(inputGroup as HTMLElement).getByRole('button');
await user.click(clearButton);
await waitFor(() => expect((input as HTMLInputElement).value).toBe(''));
}
await new Promise((resolve) => setTimeout(resolve, 400));
// Final state: input empty, last emit is the clear from round three.
expect((input as HTMLInputElement).value).toBe('');
expect(emitted.at(-1)).toBe('');
// Every round's value made it through cleanly.
expect(emitted).toContain('one');
expect(emitted).toContain('two');
expect(emitted).toContain('three');
});
});
describe('DataTable — uncontrolled filter is still routed through the same input', () => {
+1 -1
View File
@@ -41,6 +41,7 @@ import {
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { Button } from '@/components/ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu';
@@ -54,7 +55,6 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
import { useEffectAfterMount } from '@/hooks/use-effect-after-mount';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { usePageStorageKeys } from '@/hooks/use-page-storage-keys';
@@ -0,0 +1,215 @@
import type { ReactNode } from 'react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TooltipProvider } from '@/components/ui/tooltip';
import { InputSearch } from './input-search';
// Collapsed state shows a single `<button aria-label="Search docs">` (the
// expand trigger). Expanded state replaces that button with a non-interactive
// `<Search>` icon and exposes the controlled `<input>`. The presence of the
// trigger button is therefore the canonical "is it collapsed" signal — the
// input itself always lives in the DOM (just behind a `width: 0` motion box).
const queryTrigger = () => screen.queryByRole('button', { name: 'Search docs' });
const getInput = () => screen.getByPlaceholderText('Search…') as HTMLInputElement;
// Real-time `sleep` — comfortably past the 150ms debounce.
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const PAST_DEBOUNCE_MS = 250;
const Wrapper = ({ children }: { children: ReactNode }) => <TooltipProvider>{children}</TooltipProvider>;
beforeEach(() => {
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string) => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: false,
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
}),
writable: true,
});
}
});
interface SearchHostProps {
emitted: string[];
initialQuery?: string;
onSetExternalRef?: (setter: (next: string) => void) => void;
}
// Mirrors /knowledges: a fresh inline `onSearchChange` per render plus an
// imperative setter for tests that simulate an external source.
const SearchHost = ({ emitted, initialQuery = '', onSetExternalRef }: SearchHostProps) => {
const [value, setValue] = useState(initialQuery);
if (onSetExternalRef) {
onSetExternalRef(setValue);
}
return (
<InputSearch
ariaLabel="Search docs"
onSearchChange={(next) => {
emitted.push(next);
setValue(next);
}}
placeholder="Search…"
searchQuery={value}
/>
);
};
describe('InputSearch — collapsed/expanded presentation', () => {
it('mounts collapsed (trigger present) when initial query is empty', () => {
render(<SearchHost emitted={[]} />, { wrapper: Wrapper });
expect(queryTrigger()).toBeInTheDocument();
});
it('mounts expanded (trigger absent, input populated) when deep-linked', () => {
render(<SearchHost emitted={[]} initialQuery="jwt" />, { wrapper: Wrapper });
expect(queryTrigger()).not.toBeInTheDocument();
expect(getInput().value).toBe('jwt');
});
it('expands on trigger click and focuses the input on the next animation frame', async () => {
const user = userEvent.setup();
render(<SearchHost emitted={[]} />, { wrapper: Wrapper });
await user.click(queryTrigger()!);
await waitFor(() => expect(queryTrigger()).not.toBeInTheDocument());
await waitFor(() => expect(document.activeElement).toBe(getInput()));
});
});
describe('InputSearch — typing and outbound emit', () => {
it('coalesces a burst of keystrokes into a single emit with the latest value', async () => {
const emitted: string[] = [];
const user = userEvent.setup();
render(<SearchHost emitted={emitted} initialQuery="x" />, { wrapper: Wrapper });
await user.clear(getInput());
await sleep(PAST_DEBOUNCE_MS);
emitted.length = 0;
await user.type(getInput(), 'abc');
await sleep(PAST_DEBOUNCE_MS);
expect(emitted).toEqual(['abc']);
});
it('drops a pending typed emit when Esc clears mid-debounce', async () => {
const emitted: string[] = [];
const user = userEvent.setup();
render(<SearchHost emitted={emitted} />, { wrapper: Wrapper });
await user.click(queryTrigger()!);
await user.type(getInput(), 'ab');
await user.keyboard('{Escape}');
await sleep(PAST_DEBOUNCE_MS);
expect(emitted.some((value) => value.length > 0)).toBe(false);
expect(getInput().value).toBe('');
});
});
describe('InputSearch — Escape semantics', () => {
it('first Esc clears a non-empty query but keeps the input expanded', async () => {
const emitted: string[] = [];
const user = userEvent.setup();
render(<SearchHost emitted={emitted} initialQuery="jwt" />, { wrapper: Wrapper });
const input = getInput();
input.focus();
await user.keyboard('{Escape}');
await sleep(50);
expect(input.value).toBe('');
expect(emitted.at(-1)).toBe('');
expect(document.activeElement).toBe(input);
expect(queryTrigger()).not.toBeInTheDocument();
});
it('second Esc on an already-empty query collapses the input', async () => {
const emitted: string[] = [];
const user = userEvent.setup();
render(<SearchHost emitted={emitted} initialQuery="jwt" />, { wrapper: Wrapper });
const input = getInput();
input.focus();
await user.keyboard('{Escape}');
await sleep(50);
await user.keyboard('{Escape}');
await sleep(50);
expect(queryTrigger()).toBeInTheDocument();
});
});
describe('InputSearch — external source-of-truth wins', () => {
it('snaps to an external value mid-debounce and drops the in-flight typed emit', async () => {
const emitted: string[] = [];
let setExternal: ((next: string) => void) | undefined;
const user = userEvent.setup();
render(
<SearchHost
emitted={emitted}
initialQuery=""
onSetExternalRef={(setter) => {
setExternal = setter;
}}
/>,
{ wrapper: Wrapper },
);
await user.click(queryTrigger()!);
await user.type(getInput(), 'ab');
// External source mutates the prop while the debounce is still pending.
expect(setExternal).toBeDefined();
act(() => setExternal!('external'));
await sleep(PAST_DEBOUNCE_MS);
expect(emitted).not.toContain('ab');
expect(getInput().value).toBe('external');
});
});
describe('InputSearch — global hotkey expansion', () => {
it('Ctrl+F expands the collapsed input', () => {
render(<SearchHost emitted={[]} />, { wrapper: Wrapper });
expect(queryTrigger()).toBeInTheDocument();
act(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 'f' }));
});
expect(queryTrigger()).not.toBeInTheDocument();
});
it('Cmd+F (metaKey) also expands', () => {
render(<SearchHost emitted={[]} />, { wrapper: Wrapper });
act(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'f', metaKey: true }));
});
expect(queryTrigger()).not.toBeInTheDocument();
});
});
+1 -1
View File
@@ -1,11 +1,11 @@
import { Search } from 'lucide-react';
import { motion } from 'motion/react';
import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
import { cn } from '@/lib/utils';
import { isMac } from '@/lib/utils/platform';
@@ -1,10 +1,9 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { useForm, type UseFormReturn } from 'react-hook-form';
import { useDebounce } from 'use-debounce';
import { z } from 'zod';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { SEARCH_DEBOUNCE_MS } from './flow-files-constants';
const flowFilesSearchFormSchema = z.object({
@@ -34,7 +33,7 @@ export function useFlowFilesSearch(): UseFlowFilesSearchResult {
});
const rawQuery = form.watch('search');
const debouncedQuery = useDebouncedValue(rawQuery, SEARCH_DEBOUNCE_MS);
const [debouncedQuery] = useDebounce(rawQuery, SEARCH_DEBOUNCE_MS);
const resetSearch = useCallback(() => {
form.reset({ search: '' });
@@ -1,111 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDebouncedCallback } from './use-debounced-callback';
describe('useDebouncedCallback', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('defers invocation until delayMs of silence', () => {
const fn = vi.fn();
const { result } = renderHook(() => useDebouncedCallback(fn, 100));
act(() => {
result.current('a');
result.current('b');
result.current('c');
});
expect(fn).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(100);
});
// Only the latest call lands; intermediate ones are dropped.
expect(fn).toHaveBeenCalledExactlyOnceWith('c');
});
it('cancel() drops the pending dispatch', () => {
const fn = vi.fn();
const { result } = renderHook(() => useDebouncedCallback(fn, 100));
act(() => {
result.current('x');
result.current.cancel();
vi.advanceTimersByTime(500);
});
expect(fn).not.toHaveBeenCalled();
});
it('isPending() flips with the timer', () => {
const fn = vi.fn();
const { result } = renderHook(() => useDebouncedCallback(fn, 100));
expect(result.current.isPending()).toBe(false);
act(() => {
result.current('a');
});
expect(result.current.isPending()).toBe(true);
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current.isPending()).toBe(false);
});
it('reads the latest `fn` closure at fire time, not at schedule time', () => {
// Mirrors the production use case: a stale fn would close over an
// old prop. The debounced callback identity stays stable across
// renders, but the dispatched fn must be the freshest one.
const calls: string[] = [];
const { rerender, result } = renderHook(({ tag }: { tag: string }) => useDebouncedCallback(() => calls.push(tag), 100), {
initialProps: { tag: 'old' },
});
const initialDebounced = result.current;
act(() => {
result.current();
});
rerender({ tag: 'new' });
// Same callback identity across renders — caller can pass it to
// memoised children or list it in effect deps without churn.
expect(result.current).toBe(initialDebounced);
act(() => {
vi.advanceTimersByTime(100);
});
expect(calls).toEqual(['new']);
});
it('cancels pending dispatch on unmount so dead components never fire', () => {
const fn = vi.fn();
const { result, unmount } = renderHook(() => useDebouncedCallback(fn, 100));
act(() => {
result.current('a');
});
unmount();
act(() => {
vi.advanceTimersByTime(500);
});
expect(fn).not.toHaveBeenCalled();
});
});
@@ -1,95 +0,0 @@
import { useEffect, useState } from 'react';
import { useLatestRef } from './use-latest-ref';
/**
* A function that defers invoking `fn` until `delayMs` have elapsed since the
* last call. The returned function is stable across renders and exposes
* imperative escape hatches for callers that need to override the schedule.
*/
export interface DebouncedCallback<Args extends unknown[]> {
(...args: Args): void;
/**
* Drop any pending call. After this, the next invocation starts a fresh
* timer. Idempotent — safe to call from `handleClear`, route transitions,
* external-sync effects, or anywhere else that needs to override the
* schedule.
*/
cancel: () => void;
/**
* Whether a call is currently scheduled but not yet dispatched. Useful
* for tests; rarely needed in product code.
*/
isPending: () => boolean;
}
/**
* Same idea as `lodash.debounce` / `use-debounce`, but small and
* dependency-free: returns a stable callback that defers invoking `fn` until
* `delayMs` have elapsed since the most recent call. New calls reset the
* timer; the most recent arguments are the ones eventually dispatched.
*
* Why a hook (instead of inlining `setTimeout`):
* - `cancel()` is a single, synchronous call site — usable from `handleClear`,
* external-sync effects, or anywhere else that needs to override the
* pending dispatch. There is no opportunity for a stale timer to fire
* after the cancel, which was the entire class of races in our previous
* filter implementation.
* - The returned function identity is stable across renders, so it can be
* passed to memoised children and listed in effect deps without churn.
* - `fn` is captured in a latest-ref, so the dispatched call always sees the
* most recent closure (props/state at fire time, not at schedule time).
* This is the standard React idiom for stashing an event-like handler.
* - Unmount cleanup is automatic — `useEffect` cancels any pending timer
* so dispatched functions can't land in a torn-down tree.
*
* Tradeoff vs `useDeferredValue`: `useDeferredValue` is React's preferred
* tool for deferring *rendering* work (a memoised list re-renders later
* while the input stays snappy). It does not defer side-effects. Reach for
* `useDebouncedCallback` whenever you need to throttle imperative work that
* leaves React's render tree — URL writes, network requests, persistence —
* and `useDeferredValue` for the render pipeline itself.
*
* Implementation note: the entire API object is built once inside a
* `useState` lazy initialiser, with the timer handle living in a *closure
* variable* (`timerId`) rather than a React `useRef`. This keeps the
* returned `debounced` reference stable forever and side-steps the React
* Compiler rule that flags reading refs during render — there are no refs
* to read, only a private variable closed over by the three methods.
*/
export function useDebouncedCallback<Args extends unknown[]>(
fn: (...args: Args) => void,
delayMs: number,
): DebouncedCallback<Args> {
const fnReference = useLatestRef(fn);
const delayReference = useLatestRef(delayMs);
const [debounced] = useState<DebouncedCallback<Args>>(() => {
let timerId: null | number = null;
const cancel = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
const isPending = () => timerId !== null;
const invoker = (...args: Args) => {
cancel();
timerId = window.setTimeout(() => {
timerId = null;
fnReference.current(...args);
}, delayReference.current);
};
return Object.assign(invoker, { cancel, isPending });
});
// Cancel any pending dispatch when the host component unmounts so it
// can't fire into a torn-down tree.
useEffect(() => debounced.cancel, [debounced]);
return debounced;
}
-24
View File
@@ -1,24 +0,0 @@
import { useEffect, useState } from 'react';
/**
* Returns a debounced version of `value` that updates only after `delayMs` of inactivity.
*
* The hook owns its timer entirely: every change to `value` schedules a new update and
* cancels the previous one. When the component unmounts, the pending timer is cleared,
* so callers do not need to manage cancellation themselves.
*/
export function useDebouncedValue<Value>(value: Value, delayMs: number): Value {
const [debouncedValue, setDebouncedValue] = useState<Value>(value);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
window.clearTimeout(timeoutId);
};
}, [value, delayMs]);
return debouncedValue;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDebounce } from 'use-debounce';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { URL_PARAMS } from '@/lib/url-params';
interface UseTableQueryFilterReaderOptions {
@@ -34,7 +34,7 @@ export function useTableQueryFilterReader({
}: UseTableQueryFilterReaderOptions = {}): UseTableQueryFilterReaderResult {
const [searchParams] = useSearchParams();
const filter = searchParams.get(paramName) ?? '';
const debouncedFilter = useDebouncedValue(filter, debounceMs);
const [debouncedFilter] = useDebounce(filter, debounceMs);
return useMemo(() => ({ debouncedFilter, filter }), [debouncedFilter, filter]);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDebounce } from 'use-debounce';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { URL_PARAMS } from '@/lib/url-params';
interface SetPageOptions {
@@ -95,7 +95,7 @@ export function useTableState(options: UseTableStateOptions = {}): UseTableState
const [searchParams, setSearchParams] = useSearchParams();
const filter = searchParams.get(filterParamName) ?? '';
const debouncedFilter = useDebouncedValue(filter, debounceMs);
const [debouncedFilter] = useDebounce(filter, debounceMs);
const pageIndex = useMemo(() => {
const raw = searchParams.get(pageParamName);
@@ -1,6 +1,7 @@
import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { useDebounce } from 'use-debounce';
import type {
CreateKnowledgeDocumentInput,
@@ -18,7 +19,6 @@ import {
useSearchKnowledgeQuery,
useUpdateKnowledgeDocumentMutation,
} from '@/graphql/types';
import { useDebouncedValue } from '@/hooks/use-debounced-value';
import { useLatestRef } from '@/hooks/use-latest-ref';
import { Log } from '@/lib/log';
import { URL_PARAMS } from '@/lib/url-params';
@@ -71,7 +71,8 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
// provider sees one canonical "is the user actively searching" flag.
const [searchParams] = useSearchParams();
const rawSemanticQuery = searchParams.get(URL_PARAMS.SEARCH) ?? '';
const debouncedSemanticQuery = useDebouncedValue(rawSemanticQuery, SEARCH_DEBOUNCE_MS).trim();
const [debouncedSemanticQueryRaw] = useDebounce(rawSemanticQuery, SEARCH_DEBOUNCE_MS);
const debouncedSemanticQuery = debouncedSemanticQueryRaw.trim();
const inSearchMode = debouncedSemanticQuery.length > 0;
// Override the client's default `nextFetchPolicy: 'cache-first'`: since
+8
View File
@@ -117,6 +117,14 @@ export default defineConfig(({ mode }) => {
alias: {
'@': path.resolve(__dirname, './src'),
},
// pnpm hoists each package into its own `.pnpm/<name>@<version>/`
// folder, which means transitive deps that import `react` resolve
// it through their own private path. Without dedupe, two physical
// copies of React end up in the bundle and any hook called from
// inside the second copy throws "Cannot read properties of null
// (reading 'useRef')" — that's what bit us when `use-debounce`
// was added.
dedupe: ['react', 'react-dom'],
},
server: serverConfig,
};