From 2d02176c9ff4490433bea75ce2dbed34c6b31918 Mon Sep 17 00:00:00 2001 From: Bytechoreographer Date: Wed, 15 Apr 2026 20:53:34 +0800 Subject: [PATCH 1/2] fix(ui): prevent infinite re-render loop in VirtualKeysTable `keys?.keys || []` produces a new array reference on every render when `keys` is undefined (during initial load). The useEffect([keys, filters]) in useFilterLogic treated each new reference as a change, called setFilteredKeys, triggered a re-render, and looped indefinitely. Stabilise the reference with useMemo before passing it to the hook: const keysList = useMemo(() => keys?.keys ?? [], [keys?.keys]); Also add two regression tests in filter_logic.test.tsx that verify the hook does not hang when re-rendered with a new empty-array reference. Co-Authored-By: Claude Sonnet 4 (1M context) --- .../VirtualKeysPage/VirtualKeysTable.tsx | 7 ++- .../key_team_helpers/filter_logic.test.tsx | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index b30d4b6ce5b6..4f6c281f4dda 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 4e3cda123ca5..a679b782aa7f 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(); From e0e8587264c9cba37af3cdde964495a53db4d9f4 Mon Sep 17 00:00:00 2001 From: Bytechoreographer Date: Tue, 23 Jun 2026 21:56:14 +0800 Subject: [PATCH 2/2] style(ui): prettier-format VirtualKeysTable fix to satisfy frontend-lint --- .../VirtualKeysPage/VirtualKeysTable.tsx | 19 +++++++++++++------ .../key_team_helpers/filter_logic.test.tsx | 14 ++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index 6c9c86858ac0..3aa0d5348e5d 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -98,12 +98,19 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo // causes the useEffect in useFilterLogic to fire on every render → infinite loop. const keysList = useMemo(() => keys?.keys ?? [], [keys?.keys]); - const { filters, filteredKeys, filteredTotalCount, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = - useFilterLogic({ - keys: keysList, - teams, - organizations, - }); + const { + filters, + filteredKeys, + filteredTotalCount, + allTeams, + allOrganizations, + handleFilterChange, + handleFilterReset, + } = useFilterLogic({ + keys: keysList, + teams, + organizations, + }); // Defer the transition so the button stays in loading state until the table // has rendered with the new data (mirrors the spend-logs pattern) 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 c6f3e94da96f..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 @@ -44,10 +44,9 @@ describe("useFilterLogic – stability", () => { // 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[] } }, - ); + const { result, rerender } = renderHook(({ keys }) => useFilterLogic({ keys, teams: [], organizations: [] }), { + initialProps: { keys: [] as any[] }, + }); // Simulate the || [] pattern: each rerender gets a brand-new [] literal act(() => { @@ -62,10 +61,9 @@ describe("useFilterLogic – stability", () => { }); 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[] } }, - ); + const { result, rerender } = renderHook(({ keys }) => useFilterLogic({ keys, teams: [], organizations: [] }), { + initialProps: { keys: [] as any[] }, + }); expect(result.current.filteredKeys).toEqual([]);