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:
2026-04-22 21:15:23 +02:00
parent 3e3578f4d8
commit 0f261e31c2
6 changed files with 116 additions and 105 deletions

View 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);
});
});

View File

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