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.
This commit is contained in:
ryan-crabbe-berri 2026-06-15 11:07:09 -07:00 committed by GitHub
parent 2655d1dd5e
commit e6423d3b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 23 additions and 2 deletions

View file

@ -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,
});

View file

@ -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));

View file

@ -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