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:
@@ -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**
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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[] =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user