mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-07-21 08:15:23 -04:00
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:
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+13
@@ -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', () => {
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user