From e6423d3b7405533a031973cd7e58658f2d90efd2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 15 Jun 2026 11:07:09 -0700 Subject: [PATCH] fix(ui): stop Virtual Keys page from infinite render loop (#30397) The keys-filter effect called setFilteredKeys with a fresh array on every [keys, filters] change, and the caller passed keys?.keys || [] which mints a new array identity each render when the list is empty or loading. That unstable reference re-fired the effect every render, looping until React's max update depth. The effect now bails out when the filtered result is unchanged (matching the sibling teams/orgs effects), and the caller memoizes the array it passes in. --- .../VirtualKeysPage/VirtualKeysTable.tsx | 4 +++- .../key_team_helpers/filter_logic.test.tsx | 17 +++++++++++++++++ .../key_team_helpers/filter_logic.tsx | 4 +++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index 295638545c9..299f8a05f71 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -96,6 +96,8 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo // Use the filter logic hook + const keyList = useMemo(() => keys?.keys ?? [], [keys]); + const { filters, filteredKeys, @@ -105,7 +107,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo handleFilterChange, handleFilterReset, } = useFilterLogic({ - keys: keys?.keys || [], + keys: keyList, 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 24891ac6615..23259528687 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 @@ -149,6 +149,23 @@ describe("useFilterLogic – filteredTotalCount", () => { expect(result.current.filteredTotalCount).toBeNull(); }); + it("should not enter an infinite update loop when keys is a fresh array reference on every render", () => { + const sourceKeys = [mockKey]; + let renderCount = 0; + + const { result } = renderHook(() => { + renderCount += 1; + const value = useFilterLogic({ keys: [...sourceKeys], teams: [], organizations: [] }); + if (renderCount > 25) { + throw new Error(`useFilterLogic re-rendered ${renderCount} times; setFilteredKeys is looping`); + } + return value; + }); + + expect(result.current.filteredKeys).toEqual([mockKey]); + expect(renderCount).toBeLessThanOrEqual(25); + }); + it("should not trigger a debounced search when skipDebounce is true", async () => { const { result } = renderHook(() => useFilterLogic(defaultProps)); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx index cd55477208c..e31a6fbee38 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx @@ -99,7 +99,9 @@ export function useFilterLogic({ result = result.filter((key) => (key.organization_id ?? key.org_id) === filters["Organization ID"]); } - setFilteredKeys(result); + setFilteredKeys((prev) => + prev.length === result.length && prev.every((key, index) => key === result[index]) ? prev : result, + ); }, [keys, filters]); // Fetch all data for filters when component mounts