fix(accounts): stabilize extreme toggle bursts
This commit is contained in:
+38
-34
@@ -41,7 +41,7 @@ import {
|
|||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||||
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
|
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 { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||||
@@ -1803,6 +1803,7 @@ export function App(): ReactElement {
|
|||||||
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
const [accountDialogSearch, setAccountDialogSearch] = useState("");
|
||||||
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
|
const [accountDialogServiceFilter, setAccountDialogServiceFilter] = useState("all");
|
||||||
const [pendingAccountToggles, setPendingAccountToggles] = useState<Record<string, { enabled: boolean; sequence: number }>>({});
|
const [pendingAccountToggles, setPendingAccountToggles] = useState<Record<string, { enabled: boolean; sequence: number }>>({});
|
||||||
|
const pendingAccountTogglesRef = useRef<Record<string, { enabled: boolean; sequence: number }>>({});
|
||||||
const accountToggleQueueRef = useRef(createAccountToggleQueue());
|
const accountToggleQueueRef = useRef(createAccountToggleQueue());
|
||||||
const accountToggleSequenceRef = useRef(0);
|
const accountToggleSequenceRef = useRef(0);
|
||||||
const settingsMutationSequenceRef = useRef(0);
|
const settingsMutationSequenceRef = useRef(0);
|
||||||
@@ -3184,10 +3185,11 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const requestAccountToggle = (row: AccountTableRow, enabled: boolean): void => {
|
const requestAccountToggle = (row: AccountTableRow, enabled: boolean): void => {
|
||||||
const sequence = ++accountToggleSequenceRef.current;
|
const sequence = ++accountToggleSequenceRef.current;
|
||||||
|
const requestedEnabled = resolveAccountToggleIntentEnabled(pendingAccountTogglesRef.current[row.rowKey]?.enabled, enabled);
|
||||||
const subject = row.toggleKind === "dl" && row.dlKey
|
const subject = row.toggleKind === "dl" && row.dlKey
|
||||||
? `${row.entry.serviceLabel} ${row.dlKey.label}`
|
? `${row.entry.serviceLabel} ${row.dlKey.label}`
|
||||||
: row.entry.serviceLabel;
|
: row.entry.serviceLabel;
|
||||||
const check = enabled
|
const check = requestedEnabled
|
||||||
? row.toggleKind === "rd" && row.accountId
|
? row.toggleKind === "rd" && row.accountId
|
||||||
? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "realdebrid-api" | "realdebrid-web", accountId: row.accountId || undefined })
|
? () => window.rd.checkAccountCredentials({ kind: row.entry.kind as "realdebrid-api" | "realdebrid-web", accountId: row.accountId || undefined })
|
||||||
: row.toggleKind === "mega" && row.accountId
|
: row.toggleKind === "mega" && row.accountId
|
||||||
@@ -3198,14 +3200,16 @@ export function App(): ReactElement {
|
|||||||
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
|
? () => window.rd.checkAccountCredentials({ kind: "deepbrid-api", accountId: "svc-deepbrid" })
|
||||||
: undefined
|
: undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
setPendingAccountToggles((current) => ({
|
const nextPending = {
|
||||||
...current,
|
...pendingAccountTogglesRef.current,
|
||||||
[row.rowKey]: { enabled, sequence }
|
[row.rowKey]: { enabled: requestedEnabled, sequence }
|
||||||
}));
|
};
|
||||||
|
pendingAccountTogglesRef.current = nextPending;
|
||||||
|
setPendingAccountToggles(nextPending);
|
||||||
void enqueueAccountToggleIntent(accountToggleQueueRef.current, {
|
void enqueueAccountToggleIntent(accountToggleQueueRef.current, {
|
||||||
key: row.rowKey,
|
key: row.rowKey,
|
||||||
target: getAccountToggleTarget(row),
|
target: getAccountToggleTarget(row),
|
||||||
enabled,
|
enabled: requestedEnabled,
|
||||||
check
|
check
|
||||||
}, {
|
}, {
|
||||||
getSettings: () => persistedSettingsRef.current,
|
getSettings: () => persistedSettingsRef.current,
|
||||||
@@ -3218,17 +3222,16 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (result.status === "superseded") return;
|
if (result.status === "superseded") return;
|
||||||
setPendingAccountToggles((current) => {
|
if (pendingAccountTogglesRef.current[row.rowKey]?.sequence !== sequence) return;
|
||||||
if (current[row.rowKey]?.sequence !== sequence) return current;
|
const settledPending = { ...pendingAccountTogglesRef.current };
|
||||||
const next = { ...current };
|
delete settledPending[row.rowKey];
|
||||||
delete next[row.rowKey];
|
pendingAccountTogglesRef.current = settledPending;
|
||||||
return next;
|
setPendingAccountToggles(settledPending);
|
||||||
});
|
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200);
|
showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(`${subject} ${enabled ? "aktiviert" : "deaktiviert"}`, 2200);
|
showToast(`${subject} ${requestedEnabled ? "aktiviert" : "deaktiviert"}`, 2200);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4543,21 +4546,20 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onImportBackup = async (): Promise<void> => {
|
const onImportBackup = async (): Promise<void> => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
const result = await runLocalBackupImport(window.rd, askBackupPassphrase);
|
await runQueuedSettingsMutation(async () => {
|
||||||
if (result.restored) {
|
const result = await runLocalBackupImport(window.rd, askBackupPassphrase);
|
||||||
showToast(result.message, 4000);
|
if (result.restored) {
|
||||||
// A settings-only import applies live without a relaunch, so the editable
|
showToast(result.message, 4000);
|
||||||
// settings form would otherwise keep showing the old values. Pull the
|
if (!result.relaunch) {
|
||||||
// fresh settings and re-seed the draft so the UI reflects the import.
|
const fresh = await window.rd.getSnapshot();
|
||||||
if (!result.relaunch) {
|
applyPersistedSettings(fresh.settings, false);
|
||||||
const fresh = await window.rd.getSnapshot();
|
}
|
||||||
applyPersistedSettings(fresh.settings, false);
|
} else if (result.message !== "Abgebrochen") {
|
||||||
}
|
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
||||||
} else if (result.message !== "Abgebrochen") {
|
}
|
||||||
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
});
|
||||||
}
|
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
|
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
|
||||||
});
|
});
|
||||||
@@ -4585,11 +4587,13 @@ export function App(): ReactElement {
|
|||||||
if (!key) return;
|
if (!key) return;
|
||||||
setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current);
|
setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current);
|
||||||
try {
|
try {
|
||||||
const result = await window.rd.importOnlineBackup(key);
|
await runQueuedSettingsMutation(async () => {
|
||||||
const fresh = await window.rd.getSnapshot();
|
const result = await window.rd.importOnlineBackup(key);
|
||||||
applyPersistedSettings(fresh.settings, false);
|
const fresh = await window.rd.getSnapshot();
|
||||||
setOnlineBackupDialog(null);
|
applyPersistedSettings(fresh.settings, false);
|
||||||
showToast(result.message, 4000);
|
setOnlineBackupDialog(null);
|
||||||
|
showToast(result.message, 4000);
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current);
|
setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export interface AccountToggleQueue {
|
|||||||
enqueue<T>(key: string, task: (isCurrent: () => boolean) => Promise<T>): Promise<AccountToggleQueueResult<T>>;
|
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 {
|
export function createAccountToggleQueue(): AccountToggleQueue {
|
||||||
let tail = Promise.resolve();
|
let tail = Promise.resolve();
|
||||||
const versions = new Map<string, number>();
|
const versions = new Map<string, number>();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
matchesAccountModeFilter,
|
matchesAccountModeFilter,
|
||||||
mergeAccountToggleSettings,
|
mergeAccountToggleSettings,
|
||||||
pruneAccountRowSelection,
|
pruneAccountRowSelection,
|
||||||
|
resolveAccountToggleIntentEnabled,
|
||||||
resolveAccountUsername,
|
resolveAccountUsername,
|
||||||
resolveVisibleAccountKind,
|
resolveVisibleAccountKind,
|
||||||
sortAccountServices
|
sortAccountServices
|
||||||
@@ -143,6 +144,12 @@ describe("account usernames", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("account toggle bursts", () => {
|
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 () => {
|
it("serializes different account intents without dropping the second task", async () => {
|
||||||
const queue = createAccountToggleQueue();
|
const queue = createAccountToggleQueue();
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
|
|||||||
@@ -1356,6 +1356,16 @@ describe("account workspace", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("settings App integration", () => {
|
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", () => {
|
it("projects provider order entries with logos and explicit login modes", () => {
|
||||||
const settings = createRendererSettings({
|
const settings = createRendererSettings({
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
Reference in New Issue
Block a user