Fix infinite re-fetch loop in useJailConfigs
The hook was passing an inline onSuccess callback to useListData, which included onSuccess in its internal refresh function's dependency array. This caused refresh to be recreated on each render, which triggered the useEffect, which fired the fetch, which completed and caused a re-render, creating an infinite loop. Wrap onSuccess in useCallback with empty dependencies so it maintains a stable reference across renders. This allows refresh to be stable when its dependencies don't change, breaking the cycle. Add documentation to Refactoring.md explaining the onSuccess stability requirement for useListData callers. Also add tests for useJailConfigs to verify it doesn't trigger infinite refetches with stable onSuccess callback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
88
frontend/src/hooks/__tests__/useJailConfigs.test.ts
Normal file
88
frontend/src/hooks/__tests__/useJailConfigs.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import * as configApi from "../../api/config";
|
||||
import { useJailConfigs } from "../useJailConfigs";
|
||||
|
||||
vi.mock("../../api/config");
|
||||
|
||||
describe("useJailConfigs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls fetchJailConfigs only once on mount", async () => {
|
||||
vi.mocked(configApi.fetchJailConfigs).mockResolvedValue({
|
||||
jails: [
|
||||
{
|
||||
name: "sshd",
|
||||
enabled: true,
|
||||
active: true,
|
||||
backend: "systemd",
|
||||
filename: "/etc/fail2ban/jail.d/sshd.conf",
|
||||
source_file: "/etc/fail2ban/jail.d/sshd.conf",
|
||||
last_activation: "2024-01-01T00:00:00Z",
|
||||
bantime: 600,
|
||||
findtime: 600,
|
||||
maxretry: 5,
|
||||
action: [],
|
||||
logpath: [],
|
||||
port: [],
|
||||
ignoreself: false,
|
||||
filter: "sshd",
|
||||
destemail: "",
|
||||
sendername: "",
|
||||
action_d_files: [],
|
||||
ignoreip: [],
|
||||
ignorecommand: "",
|
||||
banaction: "",
|
||||
banaction_allports: false,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useJailConfigs());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(configApi.fetchJailConfigs).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.jails).toHaveLength(1);
|
||||
expect(result.current.total).toBe(1);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger infinite refetch with stable onSuccess", async () => {
|
||||
vi.mocked(configApi.fetchJailConfigs).mockResolvedValue({
|
||||
jails: [],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(() => useJailConfigs());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const callCountAfterMount = vi.mocked(configApi.fetchJailConfigs).mock.calls.length;
|
||||
|
||||
// Re-render multiple times to ensure no additional fetches are triggered
|
||||
rerender();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
rerender();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const callCountAfterRerender = vi.mocked(configApi.fetchJailConfigs).mock.calls.length;
|
||||
|
||||
// Should still be called only once (on mount)
|
||||
expect(callCountAfterRerender).toBe(callCountAfterMount);
|
||||
});
|
||||
});
|
||||
@@ -30,13 +30,15 @@ export function useJailConfigs(): UseJailConfigsResult {
|
||||
|
||||
const selector = useCallback((response: JailConfigListResponse) => response.jails, []);
|
||||
|
||||
const onSuccess = useCallback((response: JailConfigListResponse) => {
|
||||
setTotal(response.total);
|
||||
}, []);
|
||||
|
||||
const { items: jails, loading, error, refresh } = useListData<JailConfigListResponse, JailConfig>({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to fetch jail configs",
|
||||
onSuccess: (response) => {
|
||||
setTotal(response.total);
|
||||
},
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const updateJail = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user