diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index 299f8a05f714..3aa0d5348e5d 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -94,9 +94,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }); const [expandedAccordions, setExpandedAccordions] = useState>({}); - // Use the filter logic hook - - const keyList = useMemo(() => keys?.keys ?? [], [keys]); + // Stable reference: `|| []` creates a new array literal on every render, which + // causes the useEffect in useFilterLogic to fire on every render → infinite loop. + const keysList = useMemo(() => keys?.keys ?? [], [keys?.keys]); const { filters, @@ -107,7 +107,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo handleFilterChange, handleFilterReset, } = useFilterLogic({ - keys: keyList, + keys: keysList, teams, organizations, }); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx index 232595286873..4a5ff608be2a 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.test.tsx @@ -32,6 +32,50 @@ const makeApiResponse = (overrides: { keys?: any[]; total_count?: number; total_ total_pages: overrides.total_pages ?? 1, }); +describe("useFilterLogic – stability", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(keyListCall).mockResolvedValue(makeApiResponse()); + }); + + it("should not enter an infinite render loop when keys prop is re-rendered with a new empty-array reference", async () => { + // Regression: callers that write `keys?.keys || []` produce a fresh `[]` + // on every render (when keys is undefined/null). The useEffect([keys, filters]) + // must not treat every new-reference empty array as a change that requires + // another setFilteredKeys call, which would re-render the consumer, which + // would produce yet another new `[]`, ad infinitum. + const { result, rerender } = renderHook(({ keys }) => useFilterLogic({ keys, teams: [], organizations: [] }), { + initialProps: { keys: [] as any[] }, + }); + + // Simulate the || [] pattern: each rerender gets a brand-new [] literal + act(() => { + rerender({ keys: [] }); + rerender({ keys: [] }); + rerender({ keys: [] }); + }); + + // If we reach here the hook did not loop. + // filteredKeys should reflect the empty input. + expect(result.current.filteredKeys).toEqual([]); + }); + + it("should update filteredKeys when keys prop changes from empty to populated", async () => { + const { result, rerender } = renderHook(({ keys }) => useFilterLogic({ keys, teams: [], organizations: [] }), { + initialProps: { keys: [] as any[] }, + }); + + expect(result.current.filteredKeys).toEqual([]); + + act(() => { + rerender({ keys: [mockKey as any] }); + }); + + expect(result.current.filteredKeys).toHaveLength(1); + expect(result.current.filteredKeys[0]).toBe(mockKey); + }); +}); + describe("useFilterLogic – filteredTotalCount", () => { beforeEach(() => { vi.clearAllMocks();