fix(config): stabilize config hook callbacks to prevent action/filter flicker

This commit is contained in:
2026-03-24 20:13:23 +01:00
parent 2ea4a8304f
commit 9e43282bbc
5 changed files with 173 additions and 6 deletions

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import * as configApi from "../../api/config";
import { useActionConfig } from "../useActionConfig";
vi.mock("../../api/config");
describe("useActionConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(configApi.fetchAction).mockResolvedValue({
name: "iptables",
filename: "iptables.conf",
source_file: "/etc/fail2ban/action.d/iptables.conf",
active: false,
used_by_jails: [],
before: null,
after: null,
actionstart: "",
actionstop: "",
actioncheck: "",
actionban: "",
actionunban: "",
actionflush: "",
definition_vars: {},
init_vars: {},
has_local_override: false,
});
vi.mocked(configApi.updateAction).mockResolvedValue(undefined);
});
it("calls fetchAction exactly once for stable name and rerenders", async () => {
const { rerender } = renderHook(
({ name }) => useActionConfig(name),
{ initialProps: { name: "iptables" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
// Rerender with the same action name; fetch should not be called again.
rerender({ name: "iptables" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
});
it("calls fetchAction again when name changes", async () => {
const { rerender } = renderHook(
({ name }) => useActionConfig(name),
{ initialProps: { name: "iptables" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
rerender({ name: "ssh" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import * as configApi from "../../api/config";
import { useFilterConfig } from "../useFilterConfig";
vi.mock("../../api/config");
describe("useFilterConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(configApi.fetchParsedFilter).mockResolvedValue({
name: "sshd",
filename: "sshd.conf",
source_file: "/etc/fail2ban/filter.d/sshd.conf",
active: false,
used_by_jails: [],
before: null,
after: null,
variables: {},
prefregex: null,
failregex: [],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
has_local_override: false,
});
vi.mocked(configApi.updateParsedFilter).mockResolvedValue(undefined);
});
it("calls fetchParsedFilter only once for stable name", async () => {
const { rerender } = renderHook(
({ name }) => useFilterConfig(name),
{ initialProps: { name: "sshd" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
rerender({ name: "sshd" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
});
it("calls fetchParsedFilter again when name changes", async () => {
const { rerender } = renderHook(
({ name }) => useFilterConfig(name),
{ initialProps: { name: "sshd" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
rerender({ name: "apache-auth" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(2);
});
});

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed action config. * React hook for loading and updating a single parsed action config.
*/ */
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem"; import { useConfigItem } from "./useConfigItem";
import { fetchAction, updateAction } from "../api/config"; import { fetchAction, updateAction } from "../api/config";
import type { ActionConfig, ActionConfigUpdate } from "../types/config"; import type { ActionConfig, ActionConfigUpdate } from "../types/config";
@@ -23,12 +24,18 @@ export interface UseActionConfigResult {
* @param name - Action base name (e.g. ``"iptables"``). * @param name - Action base name (e.g. ``"iptables"``).
*/ */
export function useActionConfig(name: string): UseActionConfigResult { export function useActionConfig(name: string): UseActionConfigResult {
const fetchFn = useCallback(() => fetchAction(name), [name]);
const saveFn = useCallback(
(update: ActionConfigUpdate) => updateAction(name, update),
[name],
);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem< const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
ActionConfig, ActionConfig,
ActionConfigUpdate ActionConfigUpdate
>({ >({
fetchFn: () => fetchAction(name), fetchFn,
saveFn: (update) => updateAction(name, update), saveFn,
mergeOnSave: (prev, update) => mergeOnSave: (prev, update) =>
prev prev
? { ? {

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed filter config. * React hook for loading and updating a single parsed filter config.
*/ */
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem"; import { useConfigItem } from "./useConfigItem";
import { fetchParsedFilter, updateParsedFilter } from "../api/config"; import { fetchParsedFilter, updateParsedFilter } from "../api/config";
import type { FilterConfig, FilterConfigUpdate } from "../types/config"; import type { FilterConfig, FilterConfigUpdate } from "../types/config";
@@ -23,12 +24,18 @@ export interface UseFilterConfigResult {
* @param name - Filter base name (e.g. ``"sshd"``). * @param name - Filter base name (e.g. ``"sshd"``).
*/ */
export function useFilterConfig(name: string): UseFilterConfigResult { export function useFilterConfig(name: string): UseFilterConfigResult {
const fetchFn = useCallback(() => fetchParsedFilter(name), [name]);
const saveFn = useCallback(
(update: FilterConfigUpdate) => updateParsedFilter(name, update),
[name],
);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem< const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
FilterConfig, FilterConfig,
FilterConfigUpdate FilterConfigUpdate
>({ >({
fetchFn: () => fetchParsedFilter(name), fetchFn,
saveFn: (update) => updateParsedFilter(name, update), saveFn,
mergeOnSave: (prev, update) => mergeOnSave: (prev, update) =>
prev prev
? { ? {

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed jail.d config file. * React hook for loading and updating a single parsed jail.d config file.
*/ */
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem"; import { useConfigItem } from "./useConfigItem";
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config"; import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config"; import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
@@ -21,12 +22,18 @@ export interface UseJailFileConfigResult {
* @param filename - Filename including extension (e.g. ``"sshd.conf"``). * @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/ */
export function useJailFileConfig(filename: string): UseJailFileConfigResult { export function useJailFileConfig(filename: string): UseJailFileConfigResult {
const fetchFn = useCallback(() => fetchParsedJailFile(filename), [filename]);
const saveFn = useCallback(
(update: JailFileConfigUpdate) => updateParsedJailFile(filename, update),
[filename],
);
const { data, loading, error, refresh, save } = useConfigItem< const { data, loading, error, refresh, save } = useConfigItem<
JailFileConfig, JailFileConfig,
JailFileConfigUpdate JailFileConfigUpdate
>({ >({
fetchFn: () => fetchParsedJailFile(filename), fetchFn,
saveFn: (update) => updateParsedJailFile(filename, update), saveFn,
mergeOnSave: (prev, update) => mergeOnSave: (prev, update) =>
update.jails != null && prev update.jails != null && prev
? { ...prev, jails: { ...prev.jails, ...update.jails } } ? { ...prev, jails: { ...prev.jails, ...update.jails } }