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) <noreply@anthropic.com>
This commit is contained in:
Bytechoreographer 2026-04-15 20:53:34 +08:00
parent b8f7d61400
commit 2d02176c9f
2 changed files with 51 additions and 2 deletions

View file

@ -94,11 +94,14 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
});
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
// 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,
});

View file

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