refactor(ui): migrate callback debounce sites to react-pacer with regression tests (#33043)

* refactor(ui): standardize debounce waits behind shared DEBOUNCE_WAIT_MS constant

* build(ui): bump @tanstack/react-pacer from 0.2.0 to 0.22.1

* refactor(ui): migrate straightforward value debounces to react-pacer

* refactor(ui): migrate callback debounce sites to react-pacer with regression tests

* chore(ui): restore trailing newline in eslint-suppressions.json

* test(ui): mock all pacer debounce hooks in VirtualKeysTable test

* fix(ui): update merged debounce tests for OldTeams to Teams rename
This commit is contained in:
ryan-crabbe-berri 2026-07-13 15:38:34 -07:00 committed by GitHub
parent 07d2a03dbf
commit 539bc30e04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 708 additions and 131 deletions

View file

@ -1934,11 +1934,6 @@
"count": 1
}
},
"src/components/common_components/user_search_modal.tsx": {
"react-hooks/use-memo": {
"count": 1
}
},
"src/components/constants.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -2104,9 +2099,6 @@
"src/components/molecules/filter.tsx": {
"no-nested-ternary": {
"count": 2
},
"react-hooks/use-memo": {
"count": 1
}
},
"src/components/molecules/models/columns.test.tsx": {
@ -2168,9 +2160,6 @@
},
"react-hooks/set-state-in-effect": {
"count": 4
},
"react-hooks/use-memo": {
"count": 1
}
},
"src/components/organization/organization_view.tsx": {

View file

@ -51,7 +51,6 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.23",
"@types/node": "20.19.37",
"@types/react": "18.2.48",
"@types/react-copy-to-clipboard": "5.0.7",
@ -4109,13 +4108,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",

View file

@ -67,7 +67,6 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/lodash": "4.17.23",
"@types/node": "20.19.37",
"@types/react": "18.2.48",
"@types/react-copy-to-clipboard": "5.0.7",
@ -95,7 +94,6 @@
"js-yaml": "4.2.0",
"glob": "13.0.0",
"minimatch": "10.2.4",
"lodash": "4.18.1",
"ws": "8.21.0",
"braces": "3.0.3",
"axios": "1.13.6",

View file

@ -1,6 +1,6 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import AgentCardDiscovery from "./agent_card_discovery";
@ -243,6 +243,54 @@ describe("AgentCardDiscovery", () => {
expect(selection.selected_card.capabilities.streaming).toBe(false);
});
it("does not fire discovery before the debounce wait and fires once with the last URL", async () => {
mockDiscover.mockResolvedValue({
url: "https://last.example.com",
agent_card: sampleCard,
});
renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />);
const input = screen.getByPlaceholderText("https://upstream-agent.example.com");
act(() => {
fireEvent.change(input, { target: { value: "https://first.example.com" } });
});
act(() => {
vi.advanceTimersByTime(399);
});
expect(mockDiscover).not.toHaveBeenCalled();
act(() => {
fireEvent.change(input, { target: { value: "https://last.example.com" } });
});
act(() => {
vi.advanceTimersByTime(399);
});
expect(mockDiscover).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockDiscover).toHaveBeenCalledWith("tok", "https://last.example.com", undefined);
});
it("fires no discovery when unmounted mid-wait", () => {
const { unmount } = renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} />);
const input = screen.getByPlaceholderText("https://upstream-agent.example.com");
act(() => {
fireEvent.change(input, { target: { value: "https://first.example.com" } });
});
act(() => {
vi.advanceTimersByTime(200);
});
unmount();
act(() => {
vi.advanceTimersByTime(2000);
});
expect(mockDiscover).not.toHaveBeenCalled();
});
it("blocks discover when no access token is provided", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderWithProviders(<AgentCardDiscovery accessToken={null} onApply={vi.fn()} />);

View file

@ -11,6 +11,7 @@ import {
SearchOutlined,
} from "@ant-design/icons";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
import {
ALLOWED_CAPABILITY_KEYS,
@ -22,6 +23,8 @@ import {
const { Text, Paragraph } = Typography;
const { Panel } = Collapse;
const DISCOVERY_DEBOUNCE_WAIT_MS = 400;
export interface DiscoveredAgentCardSelection {
/** Full upstream card the proxy fetched, unmodified. */
raw_card: DiscoveredAgentCard;
@ -171,6 +174,14 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]);
const debouncedDiscover = useDebouncedCallback(
() => {
if (!accessToken || !effectiveUrl.trim()) return;
void handleDiscover();
},
{ wait: DISCOVERY_DEBOUNCE_WAIT_MS },
);
// Auto-discover when the URL (or parent plan) becomes available. Debounce
// is applied uniformly so rapid changes from a watched parent form (e.g.
// typing into a LangGraph api_base / assistant_id field) don't fire one
@ -186,11 +197,8 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
return;
}
const timer = window.setTimeout(() => {
void handleDiscover();
}, 400);
return () => window.clearTimeout(timer);
}, [accessToken, effectiveUrl, handleDiscover]);
debouncedDiscover();
}, [accessToken, effectiveUrl, handleDiscover, debouncedDiscover]);
const toggleSkill = (id: string, checked: boolean) => {
setSelectedSkillIds((prev) => {

View file

@ -1,5 +1,5 @@
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChatHistory } from "./useChatHistory";
describe("useChatHistory", () => {
@ -499,6 +499,80 @@ describe("useChatHistory", () => {
});
});
describe("debounced chatHistory persistence", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("should not write chatHistory to sessionStorage before the debounce wait elapses", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const { result } = renderHook(() => useChatHistory({ simplified: false }));
act(() => {
result.current.updateTextUI("user", "hello");
});
act(() => {
vi.advanceTimersByTime(499);
});
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
setItemSpy.mockRestore();
});
it("should write chatHistory exactly once with the last value after the wait", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const { result } = renderHook(() => useChatHistory({ simplified: false }));
act(() => {
result.current.updateTextUI("user", "h");
});
act(() => {
vi.advanceTimersByTime(300);
});
act(() => {
result.current.updateTextUI("user", "i");
});
act(() => {
vi.advanceTimersByTime(499);
});
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
act(() => {
vi.advanceTimersByTime(1);
});
const writes = setItemSpy.mock.calls.filter(([key]) => key === "chatHistory");
expect(writes).toHaveLength(1);
expect(JSON.parse(writes[0][1])).toEqual([{ role: "user", content: "hi" }]);
setItemSpy.mockRestore();
});
it("should not write chatHistory when unmounted mid-wait", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const { result, unmount } = renderHook(() => useChatHistory({ simplified: false }));
act(() => {
result.current.updateTextUI("user", "hello");
});
unmount();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(setItemSpy.mock.calls.filter(([key]) => key === "chatHistory")).toHaveLength(0);
setItemSpy.mockRestore();
});
});
describe("simplified mode session isolation", () => {
it("should not hydrate messageTraceId from sessionStorage in simplified mode", () => {
sessionStorage.setItem("messageTraceId", "trace-from-playground");

View file

@ -1,9 +1,12 @@
import React, { useState, useEffect } from "react";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { MessageType, A2ATaskMetadata } from "@/components/chat_ui/types";
import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
import { MCPEvent } from "@/components/mcp_tools/types";
import { truncateString } from "@/utils/textUtils";
const CHAT_HISTORY_PERSIST_WAIT_MS = 500;
export interface UseChatHistoryReturn {
// State
chatHistory: MessageType[];
@ -64,20 +67,20 @@ export function useChatHistory({ simplified }: { simplified: boolean }): UseChat
return saved ? JSON.parse(saved) : true; // Default to API session management
});
// Debounced chatHistory persistence
useEffect(() => {
if (simplified) return; // Do not persist chat history in simplified (embedded) mode
// When chatHistory is empty (e.g. after clearChatHistory removed the key),
// don't re-write an empty array back into sessionStorage.
if (chatHistory.length === 0) return;
const handler = setTimeout(() => {
sessionStorage.setItem("chatHistory", JSON.stringify(chatHistory));
}, 500); // Debounce by 500ms
const persistDebouncer = useDebouncer(
(history: MessageType[]) => {
sessionStorage.setItem("chatHistory", JSON.stringify(history));
},
{ wait: CHAT_HISTORY_PERSIST_WAIT_MS },
);
return () => {
clearTimeout(handler);
};
}, [chatHistory, simplified]);
useEffect(() => {
if (simplified || chatHistory.length === 0) {
persistDebouncer.cancel();
return;
}
persistDebouncer.maybeExecute(chatHistory);
}, [chatHistory, simplified, persistDebouncer]);
// messageTraceId/responsesSessionId/useApiSessionManagement persistence
useEffect(() => {

View file

@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
import Teams from "./Teams";
@ -1142,3 +1142,68 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
});
});
});
describe("Teams - search debounce", () => {
const emptyTeamList = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 };
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(teamListCall).mockResolvedValue(emptyTeamList);
mockUseOrganizations.mockReturnValue({ data: [] });
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
const typeSearch = (value: string) => {
fireEvent.change(screen.getByPlaceholderText("Search teams by name or ID..."), { target: { value } });
};
it("fires a single search request with the last value only after the wait elapses", async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await act(async () => {});
vi.mocked(teamListCall).mockClear();
act(() => {
typeSearch("a");
typeSearch("ab");
typeSearch("abc");
});
expect(teamListCall).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(299);
});
expect(teamListCall).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(teamListCall).toHaveBeenCalledTimes(1);
expect(teamListCall).toHaveBeenCalledWith("test-token", 1, 10, expect.objectContaining({ search: "abc" }));
});
it("does not fire the search request when unmounted mid-wait", async () => {
const { unmount } = renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
await act(async () => {});
vi.mocked(teamListCall).mockClear();
act(() => {
typeSearch("abc");
});
unmount();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(teamListCall).not.toHaveBeenCalled();
});
});

View file

@ -29,7 +29,9 @@ import {
import type { ColumnsType } from "antd/es/table";
import type { SorterResult } from "antd/es/table/interface";
import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import OrganizationDropdown from "./common_components/OrganizationDropdown";
@ -178,7 +180,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
sort_by: "created_at",
sort_order: "desc",
});
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isSearching, setIsSearching] = useState(false);
const fetchTeamsV2 = async (
@ -218,6 +219,19 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}
};
const searchDebouncer = useDebouncer(
async (value: string) => {
try {
setFilters((prev) => ({ ...prev, search: value }));
setCurrentPage(1);
await fetchTeamsV2({ page: 1, search: value });
} finally {
setIsSearching(false);
}
},
{ wait: DEBOUNCE_WAIT_MS },
);
useEffect(() => {
fetchTeamsV2();
}, [accessToken]);
@ -596,17 +610,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
};
const handleSearchChange = (value: string) => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
setIsSearching(true);
searchDebounceRef.current = setTimeout(async () => {
try {
setFilters((prev) => ({ ...prev, search: value }));
setCurrentPage(1);
await fetchTeamsV2({ page: 1, search: value });
} finally {
setIsSearching(false);
}
}, 300);
searchDebouncer.maybeExecute(value);
};
const handleFilterChange = async (key: keyof FilterState, value: string) => {
@ -630,7 +635,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
};
const handleFilterReset = () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebouncer.cancel();
setIsSearching(false);
const resetFilters: FilterState = {
search: "",

View file

@ -16,6 +16,8 @@ vi.mock("@tanstack/react-pacer/debouncer", async () => {
const [value, setValue] = React.useState(initial);
return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }];
},
useDebouncedCallback: (fn: (...args: unknown[]) => void) => fn,
useDebouncer: (fn: (...args: unknown[]) => void) => ({ maybeExecute: fn, cancel: vi.fn(), flush: vi.fn() }),
};
});

View file

@ -56,4 +56,23 @@ describe("FilterInput", () => {
expect(input.value).toBe("a");
});
it("should not call onChange when unmounted mid-debounce", () => {
const onChange = vi.fn();
const { unmount } = render(<FilterInput value="" onChange={onChange} placeholder="Search..." />);
const input = screen.getByPlaceholderText("Search...");
act(() => {
fireEvent.change(input, { target: { value: "test" } });
});
unmount();
act(() => {
vi.advanceTimersByTime(300);
});
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -1,8 +1,9 @@
import { cx } from "@/lib/cva.config";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Input } from "antd";
import debounce from "lodash/debounce";
import { LucideIcon } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useEffect, useState } from "react";
interface FilterInputProps {
placeholder?: string;
@ -13,8 +14,6 @@ interface FilterInputProps {
style?: React.CSSProperties;
}
const DEBOUNCE_DELAY = 300;
export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, onChange, icon: Icon, className }) => {
const [localValue, setLocalValue] = useState(value);
@ -22,22 +21,13 @@ export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, on
setLocalValue(value);
}, [value]);
const debouncedOnChange = useMemo(() => debounce((val: string) => onChange(val), DEBOUNCE_DELAY), [onChange]);
const debouncedOnChange = useDebouncedCallback((val: string) => onChange(val), { wait: DEBOUNCE_WAIT_MS });
useEffect(() => {
return () => {
debouncedOnChange.cancel();
};
}, [debouncedOnChange]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue);
},
[debouncedOnChange],
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue);
};
return (
<Input

View file

@ -0,0 +1,92 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelSelector from "./ModelSelector";
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
}));
const openCustomModelInput = () => {
const selector = document.querySelector(".ant-select-selector");
expect(selector).toBeTruthy();
act(() => {
fireEvent.mouseDown(selector!);
});
act(() => {
fireEvent.click(screen.getByText("Enter custom model"));
});
return screen.getByPlaceholderText("Enter custom model name");
};
describe("ModelSelector custom model debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
act(() => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
});
it("does not call onChange before the debounce wait elapses", () => {
const onChange = vi.fn();
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
act(() => {
fireEvent.change(input, { target: { value: "gpt-4o" } });
});
expect(onChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(499);
});
expect(onChange).not.toHaveBeenCalled();
});
it("calls onChange exactly once with the last typed value after the wait", () => {
const onChange = vi.fn();
render(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
act(() => {
fireEvent.change(input, { target: { value: "g" } });
fireEvent.change(input, { target: { value: "gp" } });
fireEvent.change(input, { target: { value: "gpt-5.2" } });
});
expect(onChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(500);
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("gpt-5.2");
});
it("does not call onChange when unmounted mid-wait", () => {
const onChange = vi.fn();
const { unmount } = render(<ModelSelector accessToken="test-token" onChange={onChange} />);
const input = openCustomModelInput();
act(() => {
fireEvent.change(input, { target: { value: "gpt-4o" } });
});
unmount();
act(() => {
vi.advanceTimersByTime(500);
});
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -1,9 +1,12 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import { TextInput, Text } from "@tremor/react";
import { Select } from "antd";
import { RobotOutlined } from "@ant-design/icons";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
const MODEL_SELECT_DEBOUNCE_MS = 500;
interface ModelSelectorProps {
accessToken: string;
value?: string;
@ -30,7 +33,6 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
const [selectedModel, setSelectedModel] = useState<string | undefined>(value);
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const customModelTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setSelectedModel(value);
@ -67,19 +69,13 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
}
};
const handleCustomModelChange = (value: string) => {
// Using setTimeout to create a simple debounce effect
if (customModelTimeout.current) {
clearTimeout(customModelTimeout.current);
}
customModelTimeout.current = setTimeout(() => {
const debouncedSelect = useDebouncedCallback(
(value: string) => {
setSelectedModel(value);
if (onChange) {
onChange(value);
}
}, 500); // 500ms delay after typing stops
};
onChange?.(value);
},
{ wait: MODEL_SELECT_DEBOUNCE_MS },
);
return (
<div>
@ -109,7 +105,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
<TextInput
className="mt-2"
placeholder="Enter custom model name"
onValueChange={handleCustomModelChange}
onValueChange={debouncedSelect}
disabled={disabled}
/>
)}

View file

@ -0,0 +1,97 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./RouterSettingsAccordion";
vi.mock("../networking", () => ({
getRouterSettingsCall: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
}));
vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
FallbackSelectionForm: () => null,
}));
vi.mock("@tremor/react", () => ({
TabGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Tab: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanels: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabPanel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../router_settings/RouterSettingsForm", () => ({
default: ({
value,
onChange,
}: {
value: RouterSettingsFormValue;
onChange: (value: RouterSettingsFormValue) => void;
}) => (
<div>
<button onClick={() => onChange({ ...value, selectedStrategy: "least-busy" })}>set-least-busy</button>
<button onClick={() => onChange({ ...value, selectedStrategy: "usage-based-routing" })}>set-usage-based</button>
</div>
),
}));
describe("RouterSettingsAccordion", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
const flushInitialPropagation = async (onChange: ReturnType<typeof vi.fn>) => {
await act(async () => {
vi.advanceTimersByTime(100);
});
onChange.mockClear();
};
it("debounces propagation and calls onChange once with the last value", async () => {
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
await flushInitialPropagation(onChange);
fireEvent.click(screen.getByText("set-least-busy"));
act(() => {
vi.advanceTimersByTime(50);
});
fireEvent.click(screen.getByText("set-usage-based"));
expect(onChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(99);
});
expect(onChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(1);
});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0].router_settings.routing_strategy).toBe("usage-based-routing");
});
it("does not call onChange when unmounted mid-wait", async () => {
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
const { unmount } = render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
await flushInitialPropagation(onChange);
fireEvent.click(screen.getByText("set-least-busy"));
unmount();
act(() => {
vi.advanceTimersByTime(500);
});
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { getRouterSettingsCall } from "../networking";
import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks";
@ -35,6 +36,8 @@ export interface RouterSettingsAccordionRef {
getValue: () => RouterSettingsAccordionValue;
}
const PROPAGATE_WAIT_MS = 100;
const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSettingsAccordionProps>(
({ accessToken, value, onChange, modelData }, ref) => {
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
@ -304,21 +307,26 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
};
};
// Update parent when form values change (with debounce to avoid infinite loops)
useEffect(() => {
if (!onChange) {
return;
}
const timeoutId = setTimeout(() => {
const debouncedPropagate = useDebouncedCallback(
() => {
if (!onChange) {
return;
}
isInternalUpdateRef.current = true;
const finalRouterSettings = buildRouterSettings();
onChange({
router_settings: finalRouterSettings,
});
}, 100);
},
{ wait: PROPAGATE_WAIT_MS },
);
return () => clearTimeout(timeoutId);
// Update parent when form values change (with debounce to avoid infinite loops)
useEffect(() => {
if (!onChange) {
return;
}
debouncedPropagate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formValue, fallbacks]);

View file

@ -0,0 +1,68 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import UserSearchModal from "./user_search_modal";
import { userFilterUICall } from "@/components/networking";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
vi.mock("@/components/networking", () => ({
userFilterUICall: vi.fn().mockResolvedValue([]),
}));
const renderModal = () =>
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={vi.fn()} accessToken="sk-test" />);
const getEmailSearchInput = () => within(screen.getByTestId("member-email-search")).getByRole("combobox");
describe("UserSearchModal", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(userFilterUICall).mockClear();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it("debounces the user search and fires exactly once with the last typed value", async () => {
renderModal();
const input = getEmailSearchInput();
act(() => {
fireEvent.change(input, { target: { value: "a" } });
fireEvent.change(input, { target: { value: "ab" } });
fireEvent.change(input, { target: { value: "abc" } });
});
act(() => {
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS - 1);
});
expect(userFilterUICall).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
await Promise.resolve();
});
expect(userFilterUICall).toHaveBeenCalledTimes(1);
const params = vi.mocked(userFilterUICall).mock.calls[0][1];
expect(params.get("user_email")).toBe("abc");
});
it("does not fire the search when unmounted mid-wait", () => {
const { unmount } = renderModal();
const input = getEmailSearchInput();
act(() => {
fireEvent.change(input, { target: { value: "abc" } });
});
unmount();
act(() => {
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS * 2);
});
expect(userFilterUICall).not.toHaveBeenCalled();
});
});

View file

@ -1,8 +1,9 @@
import { useState, useCallback } from "react";
import { useState } from "react";
import { Modal, Form, Button, Select, Tooltip } from "antd";
import { UserAddOutlined } from "@ant-design/icons";
import debounce from "lodash/debounce";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { userFilterUICall } from "@/components/networking";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
interface User {
user_id: string;
user_email: string;
@ -93,9 +94,9 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
}
};
const debouncedSearch = useCallback(
debounce((text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName), 300),
[],
const debouncedSearch = useDebouncedCallback(
(text: string, fieldName: "user_email" | "user_id") => fetchUsers(text, fieldName),
{ wait: DEBOUNCE_WAIT_MS },
);
const handleSearch = (value: string, fieldName: "user_email" | "user_id"): void => {

View file

@ -547,6 +547,44 @@ describe("FilterComponent", () => {
});
});
it("cancels a pending debounced search when the component unmounts mid-type", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
const { unmount } = renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
await user.click(screen.getByRole("button", { name: "Filters" }));
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
vi.clearAllMocks();
const modelLabel = screen.getByText("Model");
const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox");
await user.click(modelSelect);
await user.type(modelSelect, "test");
expect(mockSearchFn).not.toHaveBeenCalled();
unmount();
await new Promise((resolve) => setTimeout(resolve, 400));
expect(mockSearchFn).not.toHaveBeenCalled();
});
it("should reset all filter values when reset button is clicked", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(

View file

@ -1,6 +1,7 @@
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { FilterIcon } from "@heroicons/react/outline";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Button, Input, Select } from "antd";
import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useState } from "react";
export interface FilterOptionCustomComponentProps {
@ -54,8 +55,8 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
[key: string]: boolean;
}>({});
const debouncedSearch = useCallback(
debounce(async (value: string, option: FilterOption) => {
const debouncedSearch = useDebouncedCallback(
async (value: string, option: FilterOption) => {
if (!option.isSearchable || !option.searchFn) return;
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true }));
@ -68,8 +69,8 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
} finally {
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false }));
}
}, 300),
[],
},
{ wait: DEBOUNCE_WAIT_MS },
);
// Load initial options for searchable filters

View file

@ -1,7 +1,8 @@
import { act, fireEvent, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { Team } from "../key_team_helpers/key_list";
import { userFilterUICall } from "../networking";
import CreateKey from "./create_key_button";
const { formMock, setFieldsValueMock, radioGroupValueRef, formStateRef, mockKeyCreateCall, teamDropdownTeamsRef } =
@ -134,14 +135,16 @@ vi.mock("antd", () => {
const Select = ({
children,
onChange,
onSearch,
options,
...props
}: {
children?: any;
onChange?: (value: string) => void;
onSearch?: (value: string) => void;
options?: Array<{ value: string; label: string }>;
}) =>
React.createElement(
}) => {
const select = React.createElement(
"select",
{
...props,
@ -151,6 +154,21 @@ vi.mock("antd", () => {
options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)),
);
if (!onSearch) {
return select;
}
return React.createElement(
React.Fragment,
null,
React.createElement("input", {
"data-testid": "select-search-input",
onChange: (event: React.ChangeEvent<HTMLInputElement>) => onSearch(event.target.value),
}),
select,
);
};
Select.Option = ({ children, ...props }: { children?: any }) => React.createElement("option", props, children);
const Input = (props: any) => React.createElement("input", props);
@ -641,6 +659,80 @@ describe("CreateKey", () => {
});
});
describe("user search debounce", () => {
const mockUserFilterUICall = vi.mocked(userFilterUICall);
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
const renderUserSearch = () => {
const view = renderWithProviders(
<CreateKey {...defaultProps} autoOpenCreate={true} prefillData={{ owned_by: "another_user" }} />,
);
return { input: screen.getByTestId("select-search-input"), unmount: view.unmount };
};
it("should not fire the search before the wait elapses", () => {
const { input } = renderUserSearch();
act(() => {
fireEvent.change(input, { target: { value: "alice" } });
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(299);
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
});
it("should fire exactly one search carrying the last value after the wait", async () => {
const { input } = renderUserSearch();
act(() => {
fireEvent.change(input, { target: { value: "a" } });
vi.advanceTimersByTime(100);
fireEvent.change(input, { target: { value: "al" } });
vi.advanceTimersByTime(100);
fireEvent.change(input, { target: { value: "alice" } });
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(mockUserFilterUICall).toHaveBeenCalledTimes(1);
const params = mockUserFilterUICall.mock.calls[0][1] as URLSearchParams;
expect(params.get("user_email")).toBe("alice");
});
it("should fire nothing when unmounted mid-wait", () => {
const { input, unmount } = renderUserSearch();
act(() => {
fireEvent.change(input, { target: { value: "alice" } });
});
unmount();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(mockUserFilterUICall).not.toHaveBeenCalled();
});
});
describe("tags dropdown", () => {
it("should populate tags dropdown with options from useTags hook", async () => {
renderWithProviders(<CreateKey {...defaultProps} />);

View file

@ -10,8 +10,9 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
import { Accordion, AccordionBody, AccordionHeader, Button, Col, Grid, Text, TextInput, Title } from "@tremor/react";
import { Button as Button2, Form, Input, Modal, Radio, Select, Switch, Tag, Tooltip, Typography } from "antd";
import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useState } from "react";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import React, { useEffect, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import { mapDisplayToInternalNames } from "../callback_info_helpers";
@ -688,14 +689,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
}
};
const debouncedSearch = useCallback(
debounce((text: string) => fetchUsers(text), 300),
[accessToken],
);
const handleUserSearch = (value: string): void => {
debouncedSearch(value);
};
const handleUserSearch = useDebouncedCallback((text: string) => fetchUsers(text), { wait: DEBOUNCE_WAIT_MS });
const handleUserSelect = (_value: string, option: UserOption): void => {
const selectedUser = option.user;

View file

@ -1,5 +1,7 @@
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
@ -16,15 +18,6 @@ export interface PaginatedResponse {
total_is_capped?: boolean;
}
function useDebouncedValue<T>(value: T, delayMs: number): [T, React.Dispatch<React.SetStateAction<T>>] {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return [debounced, setDebounced];
}
/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */
export const FILTER_KEYS = {
TEAM_ID: "Team ID",
@ -112,7 +105,11 @@ export function useLogFilterLogic({
sortOrder?: "asc" | "desc";
currentPage?: number;
}) {
const [debouncedFilters, setDebouncedFilters] = useDebouncedValue(filters, 300);
const [debouncedFilters, setDebouncedFilters] = useState(filters);
const debouncer = useDebouncer(setDebouncedFilters, { wait: DEBOUNCE_WAIT_MS });
useEffect(() => {
debouncer.maybeExecute(filters);
}, [filters, debouncer]);
// Live values for dropdown keys, debounced for text keys.
const effectiveFilters = useMemo(() => {