diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index b30d4b6ce5b..4f6c281f4dd 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -94,11 +94,14 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }); const [expandedAccordions, setExpandedAccordions] = useState>({}); - // Use the filter logic hook + // 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]); + // Use the filter logic hook const { filters, filteredKeys, filteredTotalCount, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = useFilterLogic({ - keys: keys?.keys || [], + 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 4e3cda123ca..a679b782aa7 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,52 @@ 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();