fix(accounts): stabilize extreme toggle bursts

This commit is contained in:
Sucukdeluxe
2026-08-25 06:05:05 +02:00
parent ffc0f918ac
commit 77236f2f08
4 changed files with 59 additions and 34 deletions
+21 -17
View File
@@ -41,7 +41,7 @@ import {
} from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
@@ -1803,6 +1803,7 @@ export function App(): ReactElement {
const [accountDialogSearch, setAccountDialogSearch] = useState("");
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
const [pendingAccountToggles, setPendingAccountToggles] = useState<Record<string, { enabled: boolean; sequence: number }>>({});
const pendingAccountTogglesRef = useRef<Record<string, { enabled: boolean; sequence: number }>>({});
const accountToggleQueueRef = useRef(createAccountToggleQueue());
const accountToggleSequenceRef = useRef(0);
const settingsMutationSequenceRef = useRef(0);
@@ -3184,10 +3185,11 @@ export function App(): ReactElement {
const requestAccountToggle = (row: AccountTableRow, enabled: boolean): void => {
const sequence = ++accountToggleSequenceRef.current;
const requestedEnabled = resolveAccountToggleIntentEnabled(pendingAccountTogglesRef.current[row.rowKey]?.enabled, enabled);
const subject = row.toggleKind === "dl" && row.dlKey
? `${row.entry.serviceLabel} ${row.dlKey.label}`
: row.entry.serviceLabel;
const check = enabled
const check = requestedEnabled
? row.toggleKind === "rd" && row.accountId
? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "realdebrid-api" | "realdebrid-web", accountId: row.accountId || undefined })
: row.toggleKind === "mega" && row.accountId
@@ -3198,14 +3200,16 @@ export function App(): ReactElement {
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
: undefined
: undefined;
setPendingAccountToggles((current) => ({
...current,
[row.rowKey]: { enabled, sequence }
}));
const nextPending = {
...pendingAccountTogglesRef.current,
[row.rowKey]: { enabled: requestedEnabled, sequence }
};
pendingAccountTogglesRef.current = nextPending;
setPendingAccountToggles(nextPending);
void enqueueAccountToggleIntent(accountToggleQueueRef.current, {
key: row.rowKey,
target: getAccountToggleTarget(row),
enabled,
enabled: requestedEnabled,
check
}, {
getSettings: () => persistedSettingsRef.current,
@@ -3218,17 +3222,16 @@ export function App(): ReactElement {
}
}).then((result) => {
if (result.status === "superseded") return;
setPendingAccountToggles((current) => {
if (current[row.rowKey]?.sequence !== sequence) return current;
const next = { ...current };
delete next[row.rowKey];
return next;
});
if (pendingAccountTogglesRef.current[row.rowKey]?.sequence !== sequence) return;
const settledPending = { ...pendingAccountTogglesRef.current };
delete settledPending[row.rowKey];
pendingAccountTogglesRef.current = settledPending;
setPendingAccountToggles(settledPending);
if (result.status === "failed") {
showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200);
return;
}
showToast(`${subject} ${enabled ? "aktiviert" : "deaktiviert"}`, 2200);
showToast(`${subject} ${requestedEnabled ? "aktiviert" : "deaktiviert"}`, 2200);
});
};
@@ -4545,12 +4548,10 @@ export function App(): ReactElement {
const onImportBackup = async (): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
await runQueuedSettingsMutation(async () => {
const result = await runLocalBackupImport(window.rd, askBackupPassphrase);
if (result.restored) {
showToast(result.message, 4000);
// A settings-only import applies live without a relaunch, so the editable
// settings form would otherwise keep showing the old values. Pull the
// fresh settings and re-seed the draft so the UI reflects the import.
if (!result.relaunch) {
const fresh = await window.rd.getSnapshot();
applyPersistedSettings(fresh.settings, false);
@@ -4558,6 +4559,7 @@ export function App(): ReactElement {
} else if (result.message !== "Abgebrochen") {
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
}
});
}, (error) => {
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
});
@@ -4585,11 +4587,13 @@ export function App(): ReactElement {
if (!key) return;
setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current);
try {
await runQueuedSettingsMutation(async () => {
const result = await window.rd.importOnlineBackup(key);
const fresh = await window.rd.getSnapshot();
applyPersistedSettings(fresh.settings, false);
setOnlineBackupDialog(null);
showToast(result.message, 4000);
});
} catch {
setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current);
}
+4
View File
@@ -15,6 +15,10 @@ export interface AccountToggleQueue {
enqueue<T>(key: string, task: (isCurrent: () => boolean) => Promise<T>): Promise<AccountToggleQueueResult<T>>;
}
export function resolveAccountToggleIntentEnabled(pendingEnabled: boolean | undefined, eventEnabled: boolean): boolean {
return pendingEnabled === undefined ? eventEnabled : !pendingEnabled;
}
export function createAccountToggleQueue(): AccountToggleQueue {
let tail = Promise.resolve();
const versions = new Map<string, number>();
+7
View File
@@ -13,6 +13,7 @@ import {
matchesAccountModeFilter,
mergeAccountToggleSettings,
pruneAccountRowSelection,
resolveAccountToggleIntentEnabled,
resolveAccountUsername,
resolveVisibleAccountKind,
sortAccountServices
@@ -143,6 +144,12 @@ describe("account usernames", () => {
});
describe("account toggle bursts", () => {
it("toggles from the synchronous pending intent when the controlled DOM value lags", () => {
expect(resolveAccountToggleIntentEnabled(undefined, false)).toBe(false);
expect(resolveAccountToggleIntentEnabled(false, false)).toBe(true);
expect(resolveAccountToggleIntentEnabled(true, true)).toBe(false);
});
it("serializes different account intents without dropping the second task", async () => {
const queue = createAccountToggleQueue();
const events: string[] = [];
+10
View File
@@ -1356,6 +1356,16 @@ describe("account workspace", () => {
});
describe("settings App integration", () => {
it("serializes local and online backup imports with pending account mutations", () => {
const localImport = sourceBlock(appSource, "const onImportBackup", "const onCreateOnlineBackup");
const onlineImport = sourceBlock(appSource, "const onImportOnlineBackup", "const onCopyOnlineBackupKey");
expect(localImport).toContain("runQueuedSettingsMutation");
expect(onlineImport).toContain("runQueuedSettingsMutation");
expect(localImport.indexOf("runQueuedSettingsMutation")).toBeLessThan(localImport.indexOf("runLocalBackupImport"));
expect(onlineImport.indexOf("runQueuedSettingsMutation")).toBeLessThan(onlineImport.indexOf("window.rd.importOnlineBackup"));
});
it("projects provider order entries with logos and explicit login modes", () => {
const settings = createRendererSettings({
...defaultSettings(),