Fix promise cancellation in 5 components with AbortController refs

Add AbortController refs and abort signal checks to prevent race conditions
and memory leaks when components unmount or new requests are initiated.

Components fixed:
- JailsTab.tsx: validation handler with AbortController pattern
- JailInfoSection.tsx: handle function with useCallback wrapper
- RawConfigSection.tsx: fetch handler with abort checks
- ConfFilesTab.tsx: file fetch handler with abort signal verification
- IgnoreListSection.tsx: three handlers (add, remove, toggle) with callbacks

All handlers now:
1. Abort previous requests before initiating new ones
2. Create and store new AbortController instances
3. Check abort status before state updates in .then()/.catch()
4. Include cleanup effects that abort on unmount

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-01 17:43:47 +02:00
parent c988b4b8b6
commit 96a21ffb70
15 changed files with 291 additions and 73 deletions

View File

@@ -1,40 +1,3 @@
## [IMPORTANT] Provider ordering fragility (Frontend)
**Where found**
- `frontend/src/App.tsx` — 10-level deep provider nesting
- `frontend/src/providers/PROVIDER_ORDER.md` — documents order, no compile-time enforcement
**Why this is needed**
Provider order (ThemeProvider → AppContents → FluentProvider → ...) enforced only at runtime. Accidental reorder caught only after deploy.
**Goal**
Add compile-time validation of provider ordering.
**What to do**
1. Create provider composition utility enforcing order
2. Use TypeScript discriminated unions
3. Add ESLint rule to check provider wrapping
**Possible traps and issues**
- TypeScript doesn't easily enforce ordering
- May be overkill — improve runtime error messages instead
**Docs changes needed**
- Update `Docs/Architekture.md` § 3.2 (Providers)
**Doc references**
- `Docs/Architekture.md` § 3.2 (Providers)
- `frontend/src/providers/PROVIDER_ORDER.md`
---
## [IMPORTANT] Promise cancellation not checked in .then()/.catch() chains ## [IMPORTANT] Promise cancellation not checked in .then()/.catch() chains
**Where found** **Where found**

View File

@@ -1046,6 +1046,64 @@ function useBans(hours: number): UseBansResult {
export default useBans; export default useBans;
``` ```
### Promise Cancellation in Callbacks
When performing async operations in `useCallback` (form submissions, button clicks, etc.), always check if the operation was cancelled before updating state. This prevents React warnings and memory leaks when components unmount or users navigate away.
**Problem with naked `.then()` chains:**
```tsx
const handleSubmit = useCallback((values: FormValues) => {
setSaving(true);
saveData(values)
.then(() => {
setSaving(false); // ❌ Updates state even if component unmounted
setDialogOpen(false);
})
.catch((err) => {
setSaving(false); // ❌ Updates state on unmounted component
setError(err.message);
});
}, []);
```
**Solution: Use AbortController to track and cancel operations:**
```tsx
const submitControllerRef = useRef<AbortController | null>(null);
const handleSubmit = useCallback((values: FormValues) => {
// Abort any previous in-flight request
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSaving(true);
saveData(values)
.then(() => {
if (controller.signal.aborted) return; // ✅ Check before state updates
setSaving(false);
setDialogOpen(false);
})
.catch((err) => {
if (controller.signal.aborted) return; // ✅ Check before state updates
setSaving(false);
setError(err.message);
});
}, []);
// Clean up on unmount
useEffect(() => {
return () => {
submitControllerRef.current?.abort(); // ✅ Abort in cleanup
};
}, []);
```
**Key rules:**
- Always create a **local `const`** variable to capture the controller — never read `ref.current` inside `.then()` callbacks (race condition risk).
- Check `controller.signal.aborted` **in every `.then()` and `.catch()` block** before calling `setState`.
- Abort any previous operation before starting a new one to avoid stale callbacks.
- Clean up with a `useEffect` return function on component unmount.
### AbortController in Hooks ### AbortController in Hooks
When using `AbortController` for fetch cancellation in hooks with mutable refs: When using `AbortController` for fetch cancellation in hooks with mutable refs:

View File

@@ -27,6 +27,7 @@ export function BlocklistScheduleSection({ onRunImport, runImportRunning, import
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null); const [saveMsg, setSaveMsg] = useState<string | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
const config = info?.config ?? { const config = info?.config ?? {
frequency: "daily" as ScheduleFrequency, frequency: "daily" as ScheduleFrequency,
@@ -44,18 +45,25 @@ export function BlocklistScheduleSection({ onRunImport, runImportRunning, import
saveTimeoutRef.current = null; saveTimeoutRef.current = null;
} }
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSaving(true); setSaving(true);
saveSchedule(draft) saveSchedule(draft)
.then(() => { .then(() => {
if (controller.signal.aborted) return;
refresh(); refresh();
setSaveMsg("Schedule saved."); setSaveMsg("Schedule saved.");
setSaving(false); setSaving(false);
saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = setTimeout(() => {
if (controller.signal.aborted) return;
setSaveMsg(null); setSaveMsg(null);
saveTimeoutRef.current = null; saveTimeoutRef.current = null;
}, 3000); }, 3000);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setSaveMsg(err instanceof Error ? err.message : "Failed to save schedule"); setSaveMsg(err instanceof Error ? err.message : "Failed to save schedule");
setSaving(false); setSaving(false);
}); });
@@ -72,6 +80,7 @@ export function BlocklistScheduleSection({ onRunImport, runImportRunning, import
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
submitControllerRef.current?.abort();
}; };
}, []); }, []);

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
MessageBar, MessageBar,
@@ -55,6 +55,7 @@ export function BlocklistSourcesSection({ onRunImport, runImportRunning }: Sourc
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewSourceItem, setPreviewSourceItem] = useState<BlocklistSource | null>(null); const [previewSourceItem, setPreviewSourceItem] = useState<BlocklistSource | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
const openAdd = useCallback((): void => { const openAdd = useCallback((): void => {
setDialogMode("add"); setDialogMode("add");
@@ -74,18 +75,26 @@ export function BlocklistSourcesSection({ onRunImport, runImportRunning }: Sourc
const handleSubmit = useCallback( const handleSubmit = useCallback(
(values: SourceFormValues): void => { (values: SourceFormValues): void => {
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
const op = const op =
dialogMode === "add" dialogMode === "add"
? createSource({ name: values.name, url: values.url, enabled: values.enabled }) ? createSource({ name: values.name, url: values.url, enabled: values.enabled })
: updateSource(editingId ?? -1, { name: values.name, url: values.url, enabled: values.enabled }); : updateSource(editingId ?? -1, { name: values.name, url: values.url, enabled: values.enabled });
op op
.then(() => { .then(() => {
if (controller.signal.aborted) return;
setSaving(false); setSaving(false);
setDialogOpen(false); setDialogOpen(false);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setSaving(false); setSaving(false);
setSaveError(err instanceof Error ? err.message : "Failed to save source"); setSaveError(err instanceof Error ? err.message : "Failed to save source");
}); });
@@ -112,6 +121,12 @@ export function BlocklistSourcesSection({ onRunImport, runImportRunning }: Sourc
setPreviewOpen(true); setPreviewOpen(true);
}, []); }, []);
useEffect(() => {
return (): void => {
submitControllerRef.current?.abort();
};
}, []);
return ( return (
<div className={sectionStyles.section}> <div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}> <div className={sectionStyles.sectionHeader}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Dialog, Dialog,
@@ -45,23 +45,37 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi
const [data, setData] = useState<PreviewResponse | null>(null); const [data, setData] = useState<PreviewResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchControllerRef = useRef<AbortController | null>(null);
const handleOpen = useCallback((): void => { const handleOpen = useCallback((): void => {
if (!source) return; if (!source) return;
fetchControllerRef.current?.abort();
const controller = new AbortController();
fetchControllerRef.current = controller;
setData(null); setData(null);
setError(null); setError(null);
setLoading(true); setLoading(true);
fetchPreview(source.id) fetchPreview(source.id)
.then((result) => { .then((result) => {
if (controller.signal.aborted) return;
setData(result); setData(result);
setLoading(false); setLoading(false);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch preview"); setError(err instanceof Error ? err.message : "Failed to fetch preview");
setLoading(false); setLoading(false);
}); });
}, [source, fetchPreview]); }, [source, fetchPreview]);
useEffect(() => {
return (): void => {
fetchControllerRef.current?.abort();
};
}, []);
return ( return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) onClose(); }}> <Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) onClose(); }}>
<DialogSurface onAnimationEnd={open ? handleOpen : undefined}> <DialogSurface onAnimationEnd={open ? handleOpen : undefined}>

View File

@@ -5,7 +5,7 @@
* ``POST /api/config/jails/{jail_name}/action`` on confirmation. * ``POST /api/config/jails/{jail_name}/action`` on confirmation.
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -110,6 +110,7 @@ function AssignActionDialogInner({
const [reload, setReload] = useState(false); const [reload, setReload] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
const activeJails = jails.filter((j) => j.enabled); const activeJails = jails.filter((j) => j.enabled);
@@ -135,23 +136,37 @@ function AssignActionDialogInner({
if (!actionName || !selectedJail || submitting) return; if (!actionName || !selectedJail || submitting) return;
const req: AssignActionRequest = { action_name: actionName }; const req: AssignActionRequest = { action_name: actionName };
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
onAssign(selectedJail, req, reload) onAssign(selectedJail, req, reload)
.then(() => { .then(() => {
if (controller.signal.aborted) return;
onAssigned(); onAssigned();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setError( setError(
err instanceof ApiError ? err.message : "Failed to assign action.", err instanceof ApiError ? err.message : "Failed to assign action.",
); );
}) })
.finally(() => { .finally(() => {
if (controller.signal.aborted) return;
setSubmitting(false); setSubmitting(false);
}); });
}, [actionName, selectedJail, reload, submitting, onAssigned, onAssign]); }, [actionName, selectedJail, reload, submitting, onAssigned, onAssign]);
useEffect(() => {
return (): void => {
submitControllerRef.current?.abort();
};
}, []);
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
return ( return (

View File

@@ -5,7 +5,7 @@
* ``POST /api/config/jails/{jail_name}/filter`` on confirmation. * ``POST /api/config/jails/{jail_name}/filter`` on confirmation.
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -110,6 +110,7 @@ function AssignFilterDialogInner({
const [reload, setReload] = useState(false); const [reload, setReload] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
const activeJails = jails.filter((j) => j.enabled); const activeJails = jails.filter((j) => j.enabled);
@@ -135,23 +136,37 @@ function AssignFilterDialogInner({
if (!filterName || !selectedJail || submitting) return; if (!filterName || !selectedJail || submitting) return;
const req: AssignFilterRequest = { filter_name: filterName }; const req: AssignFilterRequest = { filter_name: filterName };
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
onAssign(selectedJail, req, reload) onAssign(selectedJail, req, reload)
.then(() => { .then(() => {
if (controller.signal.aborted) return;
onAssigned(); onAssigned();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setError( setError(
err instanceof ApiError ? err.message : "Failed to assign filter.", err instanceof ApiError ? err.message : "Failed to assign filter.",
); );
}) })
.finally(() => { .finally(() => {
if (controller.signal.aborted) return;
setSubmitting(false); setSubmitting(false);
}); });
}, [filterName, selectedJail, reload, submitting, onAssigned, onAssign]); }, [filterName, selectedJail, reload, submitting, onAssigned, onAssign]);
useEffect(() => {
return (): void => {
submitControllerRef.current?.abort();
};
}, []);
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
return ( return (

View File

@@ -6,7 +6,7 @@
* be reused for both filter and action files. * be reused for both filter and action files.
*/ */
import { useCallback, useEffect, useReducer } from "react"; import { useCallback, useEffect, useReducer, useRef } from "react";
import { import {
Accordion, Accordion,
AccordionHeader, AccordionHeader,
@@ -160,6 +160,7 @@ export function ConfFilesTab({
newContent, newContent,
creating, creating,
} = state; } = state;
const fetchFileControllerRef = useRef<AbortController | null>(null);
const loadFiles = useCallback(async (signal?: AbortSignal) => { const loadFiles = useCallback(async (signal?: AbortSignal) => {
dispatch({ type: "setLoading", value: true }); dispatch({ type: "setLoading", value: true });
@@ -202,8 +203,13 @@ export function ConfFilesTab({
dispatch({ type: "setOpenItems", value: next }); dispatch({ type: "setOpenItems", value: next });
for (const name of newlyOpened) { for (const name of newlyOpened) {
if (!Object.prototype.hasOwnProperty.call(contents, name)) { if (!Object.prototype.hasOwnProperty.call(contents, name)) {
fetchFileControllerRef.current?.abort();
const controller = new AbortController();
fetchFileControllerRef.current = controller;
void fetchFile(name) void fetchFile(name)
.then((c) => { .then((c) => {
if (controller.signal.aborted) return;
dispatch({ dispatch({
type: "setContent", type: "setContent",
name, name,
@@ -216,6 +222,7 @@ export function ConfFilesTab({
}); });
}) })
.catch(() => { .catch(() => {
if (controller.signal.aborted) return;
dispatch({ dispatch({
type: "setContent", type: "setContent",
name, name,
@@ -233,6 +240,12 @@ export function ConfFilesTab({
[openItems, contents, fetchFile], [openItems, contents, fetchFile],
); );
useEffect(() => {
return (): void => {
fetchFileControllerRef.current?.abort();
};
}, []);
const handleSave = useCallback( const handleSave = useCallback(
async (name: string) => { async (name: string) => {
dispatch({ type: "setSaving", value: name }); dispatch({ type: "setSaving", value: name });

View File

@@ -5,7 +5,7 @@
* ``POST /api/config/actions`` on confirmation. * ``POST /api/config/actions`` on confirmation.
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Dialog, Dialog,
@@ -73,6 +73,7 @@ export function CreateActionDialog({
const [actionunban, setActionunban] = useState(""); const [actionunban, setActionunban] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
// Reset form when the dialog opens. // Reset form when the dialog opens.
useEffect(() => { useEffect(() => {
@@ -99,23 +100,36 @@ export function CreateActionDialog({
actionunban: actionunban.trim() || null, actionunban: actionunban.trim() || null,
}; };
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
onCreateAction(req) onCreateAction(req)
.then((action) => { .then((action) => {
if (controller.signal.aborted) return;
onCreate(action); onCreate(action);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setError( setError(
err instanceof ApiError ? err.message : "Failed to create action.", err instanceof ApiError ? err.message : "Failed to create action.",
); );
}) })
.finally(() => { .finally(() => {
if (controller.signal.aborted) return;
setSubmitting(false); setSubmitting(false);
}); });
}, [name, actionban, actionunban, submitting, onCreate, onCreateAction]); }, [name, actionban, actionunban, submitting, onCreate, onCreateAction]);
useEffect(() => {
return (): void => {
submitControllerRef.current?.abort();
};
}, []);
const canConfirm = name.trim() !== "" && !submitting; const canConfirm = name.trim() !== "" && !submitting;
return ( return (

View File

@@ -5,7 +5,7 @@
* calls ``POST /api/config/filters`` on confirmation. * calls ``POST /api/config/filters`` on confirmation.
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Dialog, Dialog,
@@ -82,6 +82,7 @@ export function CreateFilterDialog({
const [ignoreregex, setIgnoreregex] = useState(""); const [ignoreregex, setIgnoreregex] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
// Reset form when the dialog opens. // Reset form when the dialog opens.
useEffect(() => { useEffect(() => {
@@ -108,23 +109,36 @@ export function CreateFilterDialog({
ignoreregex: splitLines(ignoreregex), ignoreregex: splitLines(ignoreregex),
}; };
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
onCreateFilter(req) onCreateFilter(req)
.then((filter) => { .then((filter) => {
if (controller.signal.aborted) return;
onCreate(filter); onCreate(filter);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setError( setError(
err instanceof ApiError ? err.message : "Failed to create filter.", err instanceof ApiError ? err.message : "Failed to create filter.",
); );
}) })
.finally(() => { .finally(() => {
if (controller.signal.aborted) return;
setSubmitting(false); setSubmitting(false);
}); });
}, [name, failregex, ignoreregex, submitting, onCreate, onCreateFilter]); }, [name, failregex, ignoreregex, submitting, onCreate, onCreateFilter]);
useEffect(() => {
return (): void => {
submitControllerRef.current?.abort();
};
}, []);
const canConfirm = name.trim() !== "" && !submitting; const canConfirm = name.trim() !== "" && !submitting;
return ( return (

View File

@@ -5,7 +5,7 @@
* confirmation, seeding the file with a minimal comment header. * confirmation, seeding the file with a minimal comment header.
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Button, Button,
Dialog, Dialog,
@@ -63,6 +63,7 @@ export function CreateJailDialog({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submitControllerRef = useRef<AbortController | null>(null);
// Reset form when the dialog opens. // Reset form when the dialog opens.
useEffect(() => { useEffect(() => {
@@ -86,23 +87,36 @@ export function CreateJailDialog({
content: `# ${trimmedName}\n`, content: `# ${trimmedName}\n`,
}; };
submitControllerRef.current?.abort();
const controller = new AbortController();
submitControllerRef.current = controller;
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
onCreateJail(req) onCreateJail(req)
.then(() => { .then(() => {
if (controller.signal.aborted) return;
onCreated(); onCreated();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setError( setError(
err instanceof ApiError ? err.message : "Failed to create jail config.", err instanceof ApiError ? err.message : "Failed to create jail config.",
); );
}) })
.finally(() => { .finally(() => {
if (controller.signal.aborted) return;
setSubmitting(false); setSubmitting(false);
}); });
}, [name, submitting, onCreated, onCreateJail]); }, [name, submitting, onCreated, onCreateJail]);
useEffect(() => {
return (): void => {
submitControllerRef.current?.abort();
};
}, []);
const canConfirm = name.trim() !== "" && !submitting; const canConfirm = name.trim() !== "" && !submitting;
return ( return (

View File

@@ -725,19 +725,37 @@ function InactiveJailDetail({
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null); const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const validateControllerRef = useRef<AbortController | null>(null);
const handleValidate = useCallback((): void => { const handleValidate = useCallback((): void => {
validateControllerRef.current?.abort();
const controller = new AbortController();
validateControllerRef.current = controller;
setValidating(true); setValidating(true);
setValidationResult(null); setValidationResult(null);
setValidationError(null); setValidationError(null);
onValidate() onValidate()
.then((result) => { setValidationResult(result); }) .then((result) => {
if (controller.signal.aborted) return;
setValidationResult(result);
})
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, createStringErrorAdapter(setValidationError), "Validation request failed."); handleFetchError(err, createStringErrorAdapter(setValidationError), "Validation request failed.");
}) })
.finally(() => { setValidating(false); }); .finally(() => {
if (controller.signal.aborted) return;
setValidating(false);
});
}, [onValidate]); }, [onValidate]);
useEffect(() => {
return (): void => {
validateControllerRef.current?.abort();
};
}, []);
const blockingIssues: JailValidationIssue[] = const blockingIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field !== "logpath") ?? []; validationResult?.issues.filter((i) => i.field !== "logpath") ?? [];
const advisoryIssues: JailValidationIssue[] = const advisoryIssues: JailValidationIssue[] =

View File

@@ -6,7 +6,7 @@
* Feedback is shown via an {@link AutoSaveIndicator}-style message bar. * Feedback is shown via an {@link AutoSaveIndicator}-style message bar.
*/ */
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Accordion, Accordion,
AccordionHeader, AccordionHeader,
@@ -97,6 +97,7 @@ export function RawConfigSection({
/** Whether the section has been expanded at least once. */ /** Whether the section has been expanded at least once. */
const loadedRef = useRef(false); const loadedRef = useRef(false);
const fetchControllerRef = useRef<AbortController | null>(null);
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Handlers // Handlers
@@ -109,16 +110,22 @@ export function RawConfigSection({
if (!isOpen || loadedRef.current) return; if (!isOpen || loadedRef.current) return;
loadedRef.current = true; loadedRef.current = true;
fetchControllerRef.current?.abort();
const controller = new AbortController();
fetchControllerRef.current = controller;
setFetchLoading(true); setFetchLoading(true);
setFetchError(null); setFetchError(null);
fetchContent() fetchContent()
.then((text) => { .then((text) => {
if (controller.signal.aborted) return;
setContent(text); setContent(text);
setLocalText(text); setLocalText(text);
setFetchLoading(false); setFetchLoading(false);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setFetchError( setFetchError(
err instanceof Error ? err.message : "Failed to load raw content.", err instanceof Error ? err.message : "Failed to load raw content.",
); );
@@ -128,6 +135,12 @@ export function RawConfigSection({
[fetchContent], [fetchContent],
); );
useEffect(() => {
return (): void => {
fetchControllerRef.current?.abort();
};
}, []);
const handleSave = useCallback(async (): Promise<void> => { const handleSave = useCallback(async (): Promise<void> => {
setSaveStatus("saving"); setSaveStatus("saving");
setSaveError(null); setSaveError(null);

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -35,25 +35,54 @@ export function IgnoreListSection({
const sectionStyles = useCommonSectionStyles(); const sectionStyles = useCommonSectionStyles();
const [inputVal, setInputVal] = useState(""); const [inputVal, setInputVal] = useState("");
const [opError, setOpError] = useState<string | null>(null); const [opError, setOpError] = useState<string | null>(null);
const opControllerRef = useRef<AbortController | null>(null);
const handleAdd = (): void => { const handleAdd = useCallback((): void => {
if (!inputVal.trim()) return; if (!inputVal.trim()) return;
opControllerRef.current?.abort();
const controller = new AbortController();
opControllerRef.current = controller;
setOpError(null); setOpError(null);
onAdd(inputVal.trim()) onAdd(inputVal.trim())
.then(() => { .then(() => {
if (controller.signal.aborted) return;
setInputVal(""); setInputVal("");
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (controller.signal.aborted) return;
setOpError(err instanceof Error ? err.message : String(err)); setOpError(err instanceof Error ? err.message : String(err));
}); });
}; }, [inputVal, onAdd]);
const handleRemove = useCallback((ip: string): void => {
opControllerRef.current?.abort();
const controller = new AbortController();
opControllerRef.current = controller;
const handleRemove = (ip: string): void => {
setOpError(null); setOpError(null);
onRemove(ip).catch((err: unknown) => { onRemove(ip).catch((err: unknown) => {
if (controller.signal.aborted) return;
setOpError(err instanceof Error ? err.message : String(err)); setOpError(err instanceof Error ? err.message : String(err));
}); });
}; }, [onRemove]);
const handleToggleIgnoreSelf = useCallback((checked: boolean): void => {
opControllerRef.current?.abort();
const controller = new AbortController();
opControllerRef.current = controller;
onToggleIgnoreSelf(checked).catch((err: unknown) => {
if (controller.signal.aborted) return;
setOpError(err instanceof Error ? err.message : String(err));
});
}, [onToggleIgnoreSelf]);
useEffect(() => {
return (): void => {
opControllerRef.current?.abort();
};
}, []);
return ( return (
<div className={sectionStyles.section}> <div className={sectionStyles.section}>
@@ -70,9 +99,7 @@ export function IgnoreListSection({
label="Ignore self — exclude this server's own IP addresses from banning" label="Ignore self — exclude this server's own IP addresses from banning"
checked={ignoreSelf} checked={ignoreSelf}
onChange={(_e, data): void => { onChange={(_e, data): void => {
onToggleIgnoreSelf(data.checked).catch((err: unknown) => { handleToggleIgnoreSelf(data.checked);
setOpError(err instanceof Error ? err.message : String(err));
});
}} }}
/> />

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Badge, Badge,
@@ -32,24 +32,40 @@ export function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, o
const sectionStyles = useCommonSectionStyles(); const sectionStyles = useCommonSectionStyles();
const navigate = useNavigate(); const navigate = useNavigate();
const [ctrlError, setCtrlError] = useState<string | null>(null); const [ctrlError, setCtrlError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const handle = const handle = useCallback(
(fn: () => Promise<unknown>, postNavigate = false) => (fn: () => Promise<unknown>, postNavigate = false): (() => void) => {
(): void => { return (): void => {
setCtrlError(null); controllerRef.current?.abort();
fn() const controller = new AbortController();
.then(() => { controllerRef.current = controller;
if (postNavigate) {
navigate("/jails"); setCtrlError(null);
} else { fn()
onRefresh(); .then(() => {
} if (controller.signal.aborted) return;
}) if (postNavigate) {
.catch((err: unknown) => { navigate("/jails");
const msg = err instanceof Error ? err.message : String(err); } else {
setCtrlError(msg); onRefresh();
}); }
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setCtrlError(msg);
});
};
},
[navigate, onRefresh]
);
useEffect(() => {
return (): void => {
controllerRef.current?.abort();
}; };
}, []);
return ( return (
<div className={sectionStyles.section}> <div className={sectionStyles.section}>