@@ -109,7 +105,7 @@ const ModelSelector: React.FC
= ({
)}
diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx
new file mode 100644
index 00000000000..a70b7602e5b
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx
@@ -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 }) => {children}
,
+ TabList: ({ children }: { children: ReactNode }) => {children}
,
+ Tab: ({ children }: { children: ReactNode }) => {children}
,
+ TabPanels: ({ children }: { children: ReactNode }) => {children}
,
+ TabPanel: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+vi.mock("../router_settings/RouterSettingsForm", () => ({
+ default: ({
+ value,
+ onChange,
+ }: {
+ value: RouterSettingsFormValue;
+ onChange: (value: RouterSettingsFormValue) => void;
+ }) => (
+
+
+
+
+ ),
+}));
+
+describe("RouterSettingsAccordion", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ const flushInitialPropagation = async (onChange: ReturnType) => {
+ 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();
+ 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();
+ await flushInitialPropagation(onChange);
+
+ fireEvent.click(screen.getByText("set-least-busy"));
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx
index 0aa274b5749..08b917e302f 100644
--- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx
@@ -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(
({ accessToken, value, onChange, modelData }, ref) => {
const [formValue, setFormValue] = useState({
@@ -304,21 +307,26 @@ const RouterSettingsAccordion = forwardRef {
- 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]);
diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx
new file mode 100644
index 00000000000..72b0e10e5d6
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx
@@ -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();
+
+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();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx
index 866d7cbec7f..fafafd8e5d5 100644
--- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx
@@ -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 = ({
}
};
- 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 => {
diff --git a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx
index d956cd93168..66c671b12d4 100644
--- a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx
+++ b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx
index 45ad3a6b9ca..8218de41a19 100644
--- a/ui/litellm-dashboard/src/components/molecules/filter.tsx
+++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx
@@ -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 = ({
[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 = ({
} finally {
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false }));
}
- }, 300),
- [],
+ },
+ { wait: DEBOUNCE_WAIT_MS },
);
// Load initial options for searchable filters
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx
index 0fe8adb70e1..f84b95b8d4d 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx
@@ -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) => 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(
+ ,
+ );
+ 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();
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
index ef2ddab70ed..4bfd869f17a 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
@@ -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 = ({ 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;
diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
index 1d699042f05..caa9d8d9361 100644
--- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx
@@ -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(value: T, delayMs: number): [T, React.Dispatch>] {
- 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(() => {