diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json
index 776a910cf..a0f36a870 100644
--- a/frontend/src/locales/bg_BG/rom.json
+++ b/frontend/src/locales/bg_BG/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Редактирай запис",
"edit-screenshot": "Редактирай екранна снимка",
"edit-state": "Редактирай бърз запис",
+ "exclude-failed": "{n} изтрити игри не можаха да бъдат изключени от сканиранията",
"favorite": "Любим",
"favorites-updated-n": "{n} елемент е обновен | {n} елемента са обновени",
"file": "Файл",
diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json
index bfb0c9fe3..2a7530ddd 100644
--- a/frontend/src/locales/cs_CZ/rom.json
+++ b/frontend/src/locales/cs_CZ/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Upravit uloženou pozici",
"edit-screenshot": "Upravit screenshot",
"edit-state": "Upravit stav",
+ "exclude-failed": "{n} smazaných her se nepodařilo vyloučit ze skenování",
"favorite": "Oblíbené",
"favorites-updated-n": "{n} položka aktualizována | {n} položek aktualizováno",
"file": "Soubor",
diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json
index 3f8110310..dd212c715 100644
--- a/frontend/src/locales/de_DE/rom.json
+++ b/frontend/src/locales/de_DE/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Spielstand bearbeiten",
"edit-screenshot": "Screenshot bearbeiten",
"edit-state": "Zustand bearbeiten",
+ "exclude-failed": "{n} gelöschte Spiele konnten nicht von Scans ausgeschlossen werden",
"favorite": "Favorit",
"favorites-updated-n": "{n} Element aktualisiert | {n} Elemente aktualisiert",
"file": "Datei",
diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json
index 404b856a0..3cae3697d 100644
--- a/frontend/src/locales/en_GB/rom.json
+++ b/frontend/src/locales/en_GB/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Edit save",
"edit-screenshot": "Edit screenshot",
"edit-state": "Edit state",
+ "exclude-failed": "Couldn't exclude {n} deleted games from scans",
"favorite": "Favourite",
"favorites-updated-n": "{n} item updated | {n} items updated",
"file": "File",
diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json
index cfd4293eb..c97e01767 100644
--- a/frontend/src/locales/en_US/rom.json
+++ b/frontend/src/locales/en_US/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Edit save",
"edit-screenshot": "Edit screenshot",
"edit-state": "Edit state",
+ "exclude-failed": "Couldn't exclude {n} deleted games from scans",
"favorite": "Favorite",
"favorites-updated-n": "{n} item updated | {n} items updated",
"file": "File",
diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json
index a47b716bb..5251d09c8 100644
--- a/frontend/src/locales/es_ES/rom.json
+++ b/frontend/src/locales/es_ES/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Editar partida",
"edit-screenshot": "Editar captura",
"edit-state": "Editar estado",
+ "exclude-failed": "No se pudieron excluir {n} juegos eliminados de los escaneos",
"favorite": "Favorito",
"favorites-updated-n": "{n} elemento actualizado | {n} elementos actualizados",
"file": "Archivo",
diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json
index a5fb65dbe..1ff9c6218 100644
--- a/frontend/src/locales/fr_FR/rom.json
+++ b/frontend/src/locales/fr_FR/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Modifier la sauvegarde",
"edit-screenshot": "Modifier la capture d'écran",
"edit-state": "Modifier l'état",
+ "exclude-failed": "Impossible d'exclure {n} jeux supprimés des analyses",
"favorite": "Favori",
"favorites-updated-n": "{n} élément mis à jour | {n} éléments mis à jour",
"file": "Fichier",
diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json
index da72e9da8..d2b8c0405 100644
--- a/frontend/src/locales/hu_HU/rom.json
+++ b/frontend/src/locales/hu_HU/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Mentés szerkesztése",
"edit-screenshot": "Képernyőkép szerkesztése",
"edit-state": "Állás szerkesztése",
+ "exclude-failed": "{n} törölt játékot nem sikerült kizárni a keresésekből",
"favorite": "Kedvenc",
"favorites-updated-n": "{n} elem frissítve | {n} elem frissítve",
"file": "Fájl",
diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json
index 01dbf6631..b770c2f1a 100644
--- a/frontend/src/locales/it_IT/rom.json
+++ b/frontend/src/locales/it_IT/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Modifica salvataggio",
"edit-screenshot": "Modifica screenshot",
"edit-state": "Modifica stato",
+ "exclude-failed": "Impossibile escludere {n} giochi eliminati dalle scansioni",
"favorite": "Preferito",
"favorites-updated-n": "{n} elemento aggiornato | {n} elementi aggiornati",
"file": "File",
diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json
index 574bcf917..2f5720994 100644
--- a/frontend/src/locales/ja_JP/rom.json
+++ b/frontend/src/locales/ja_JP/rom.json
@@ -152,6 +152,7 @@
"edit-save": "セーブを編集",
"edit-screenshot": "スクリーンショットを編集",
"edit-state": "ステートセーブを編集",
+ "exclude-failed": "削除した{n}件のゲームをスキャン対象から除外できませんでした",
"favorite": "お気に入り",
"favorites-updated-n": "{n} 件を更新しました",
"file": "ファイル",
diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json
index 588b57ec4..5244df987 100644
--- a/frontend/src/locales/ko_KR/rom.json
+++ b/frontend/src/locales/ko_KR/rom.json
@@ -152,6 +152,7 @@
"edit-save": "세이브 편집",
"edit-screenshot": "스크린샷 편집",
"edit-state": "상태 편집",
+ "exclude-failed": "삭제된 게임 {n}개를 스캔에서 제외하지 못했습니다",
"favorite": "즐겨찾기",
"favorites-updated-n": "{n}개 항목을 업데이트했습니다",
"file": "파일",
diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json
index bdfaf2f0c..414eb5cf3 100644
--- a/frontend/src/locales/pl_PL/rom.json
+++ b/frontend/src/locales/pl_PL/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Edytuj zapis",
"edit-screenshot": "Edytuj zrzut ekranu",
"edit-state": "Edytuj stan",
+ "exclude-failed": "Nie udało się wykluczyć {n} usuniętych gier ze skanowania",
"favorite": "Ulubione",
"favorites-updated-n": "Zaktualizowano {n} element | Zaktualizowano {n} elementów",
"file": "Plik",
diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json
index c3b2b035c..b78265fe5 100644
--- a/frontend/src/locales/pt_BR/rom.json
+++ b/frontend/src/locales/pt_BR/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Editar save",
"edit-screenshot": "Editar captura de tela",
"edit-state": "Editar state",
+ "exclude-failed": "Não foi possível excluir {n} jogos removidos das verificações",
"favorite": "Favorito",
"favorites-updated-n": "{n} item atualizado | {n} itens atualizados",
"file": "Arquivo",
diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json
index 7e699ddc5..811fd98b4 100644
--- a/frontend/src/locales/ro_RO/rom.json
+++ b/frontend/src/locales/ro_RO/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Editează salvarea",
"edit-screenshot": "Editează captura de ecran",
"edit-state": "Editează starea",
+ "exclude-failed": "Nu s-au putut exclude {n} jocuri șterse din scanări",
"favorite": "Favorit",
"favorites-updated-n": "{n} element actualizat | {n} elemente actualizate",
"file": "Fișier",
diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json
index ba582ef66..501a53244 100644
--- a/frontend/src/locales/ru_RU/rom.json
+++ b/frontend/src/locales/ru_RU/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Изменить сохранение",
"edit-screenshot": "Изменить скриншот",
"edit-state": "Изменить состояние",
+ "exclude-failed": "Не удалось исключить {n} удалённых игр из сканирования",
"favorite": "В избранное",
"favorites-updated-n": "Обновлён {n} элемент | Обновлено {n} элементов",
"file": "Файл",
diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json
index d9074add4..b532a20d4 100644
--- a/frontend/src/locales/tr_TR/rom.json
+++ b/frontend/src/locales/tr_TR/rom.json
@@ -152,6 +152,7 @@
"edit-save": "Kaydı düzenle",
"edit-screenshot": "Ekran görüntüsünü düzenle",
"edit-state": "Durum kaydını düzenle",
+ "exclude-failed": "Silinen {n} oyun taramalardan hariç tutulamadı",
"favorite": "Favori",
"favorites-updated-n": "{n} öge güncellendi",
"file": "Dosya",
diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json
index bc5bc042d..afe33db45 100644
--- a/frontend/src/locales/zh_CN/rom.json
+++ b/frontend/src/locales/zh_CN/rom.json
@@ -152,6 +152,7 @@
"edit-save": "编辑存档",
"edit-screenshot": "编辑截图",
"edit-state": "编辑状态",
+ "exclude-failed": "无法将 {n} 个已删除的游戏排除在扫描之外",
"favorite": "收藏",
"favorites-updated-n": "已更新 {n} 个项目",
"file": "文件",
diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json
index 7619139dc..b3f95fe10 100644
--- a/frontend/src/locales/zh_TW/rom.json
+++ b/frontend/src/locales/zh_TW/rom.json
@@ -152,6 +152,7 @@
"edit-save": "編輯存檔",
"edit-screenshot": "編輯螢幕截圖",
"edit-state": "編輯即時存檔",
+ "exclude-failed": "無法將 {n} 個已刪除的遊戲排除在掃描之外",
"favorite": "喜好",
"favorites-updated-n": "已更新 {n} 個項目",
"file": "檔案",
diff --git a/frontend/src/v2/components/Dialogs/DeleteRomDialog.test.ts b/frontend/src/v2/components/Dialogs/DeleteRomDialog.test.ts
index fd30c64ba..356cad8c2 100644
--- a/frontend/src/v2/components/Dialogs/DeleteRomDialog.test.ts
+++ b/frontend/src/v2/components/Dialogs/DeleteRomDialog.test.ts
@@ -2,13 +2,14 @@ import { flushPromises, mount } from "@vue/test-utils";
import mitt, { type Emitter } from "mitt";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { reactive } from "vue";
+import { defineComponent, reactive } from "vue";
import storeRoms from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { makeDetailedRom, makeRom } from "@/utils/rom.fixtures";
import DeleteRomDialog from "./DeleteRomDialog.vue";
-const { deleteRoms, push, snackbarError } = vi.hoisted(() => ({
+const { addExclusion, deleteRoms, push, snackbarError } = vi.hoisted(() => ({
+ addExclusion: vi.fn(),
deleteRoms: vi.fn(),
push: vi.fn(),
snackbarError: vi.fn(),
@@ -29,7 +30,7 @@ vi.mock("vue-router", async (importOriginal) => ({
vi.mock("@/plugins/router", () => ({ ROUTES: { PLATFORM: "platform" } }));
vi.mock("@/services/api/rom", () => ({ default: { deleteRoms } }));
vi.mock("@/services/api/config", () => ({
- default: { addExclusion: vi.fn() },
+ default: { addExclusion },
}));
vi.mock("@/stores/config", () => ({
default: () => ({ addExclusion: vi.fn() }),
@@ -43,19 +44,35 @@ vi.mock("@/v2/composables/useSnackbar", () => ({
const RDialog = {
props: { modelValue: { type: Boolean, default: false } },
- template: `
`,
+ template: `
`,
};
const RBtn = {
emits: ["click"],
template: ``,
};
+const RCheckbox = {
+ emits: ["update:modelValue"],
+ template: ``,
+};
+// happy-dom lays nothing out, so a windowed list would render no rows.
+const WholeList = defineComponent({
+ props: { items: { type: Array, default: () => [] } },
+ template: ``,
+});
+const stubs = {
+ RDialog,
+ RBtn,
+ RCheckbox,
+ RIcon: true,
+ RVirtualScroller: WholeList,
+};
async function deleteShownGame() {
const emitter: Emitter = mitt();
const wrapper = mount(DeleteRomDialog, {
global: {
provide: { emitter },
- stubs: { RDialog, RBtn, RCheckbox: true, RIcon: true },
+ stubs,
},
});
emitter.emit("showDeleteRomDialog", [
@@ -106,3 +123,64 @@ describe("DeleteRomDialog", () => {
expect(storeRoms().getDetailedRom(6)).toBeNull();
});
});
+
+describe("DeleteRomDialog with a large selection", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.clearAllMocks();
+ deleteRoms.mockResolvedValue({
+ data: { failed_ids: [], successful_items: 120 },
+ });
+ });
+
+ async function openWith(count: number) {
+ const emitter: Emitter = mitt();
+ const wrapper = mount(DeleteRomDialog, {
+ global: {
+ provide: { emitter },
+ stubs,
+ },
+ });
+ emitter.emit(
+ "showDeleteRomDialog",
+ Array.from({ length: count }, (_, i) =>
+ makeRom({ id: i + 1, platform_id: 1 }),
+ ),
+ );
+ await flushPromises();
+ return wrapper;
+ }
+
+ it("hands every selected game to the virtual scroller", async () => {
+ const wrapper = await openWith(120);
+
+ expect(wrapper.findComponent(WholeList).props("items")).toHaveLength(120);
+ });
+
+ it("waits for the exclusions and reports the ones that failed", async () => {
+ addExclusion
+ .mockResolvedValueOnce(undefined)
+ .mockRejectedValueOnce(new Error("config not writable"));
+ const wrapper = await openWith(2);
+
+ await wrapper.find('input[type="checkbox"]').trigger("change");
+ await wrapper.findAll("button").at(-1)?.trigger("click");
+ await flushPromises();
+
+ expect(addExclusion).toHaveBeenCalledTimes(2);
+ expect(snackbarError).toHaveBeenCalledWith(
+ "rom.exclude-failed",
+ expect.anything(),
+ );
+ });
+
+ it("deletes every selected game", async () => {
+ const wrapper = await openWith(120);
+
+ await wrapper.findAll("button").at(-1)?.trigger("click");
+ await flushPromises();
+
+ const sent = deleteRoms.mock.calls[0][0] as { roms: { id: number }[] };
+ expect(sent.roms).toHaveLength(120);
+ });
+});
diff --git a/frontend/src/v2/components/Dialogs/DeleteRomDialog.vue b/frontend/src/v2/components/Dialogs/DeleteRomDialog.vue
index c69ea9cf6..ede0477ac 100644
--- a/frontend/src/v2/components/Dialogs/DeleteRomDialog.vue
+++ b/frontend/src/v2/components/Dialogs/DeleteRomDialog.vue
@@ -2,7 +2,8 @@
// DeleteRomDialog: single or multi-ROM delete flow. Each row has a
// "also remove file from disk" checkbox; a global "exclude on delete" flag
// adds deleted filenames to the scan exclusion list so they don't re-appear.
-import { RBtn, RCheckbox, RDialog, RIcon } from "@v2/lib";
+// Rows are virtualised so a whole-library selection doesn't mount every cover.
+import { RBtn, RCheckbox, RDialog, RIcon, RVirtualScroller } from "@v2/lib";
import type { Emitter } from "mitt";
import { computed, inject, onBeforeUnmount, ref } from "vue";
import { useI18n } from "vue-i18n";
@@ -16,9 +17,14 @@ import type { Events } from "@/types/emitter";
import { useRomSync } from "@/v2/composables/useRomSync";
import { romIdFromRoute } from "@/v2/composables/useRouteRom";
import { useSnackbar } from "@/v2/composables/useSnackbar";
+import { settleWithLimit } from "@/v2/utils/settleWithLimit";
defineOptions({ inheritAttrs: false });
+// A 66px row plus the 6px gap below it; `.r-v2-del-rom__row` pins the row.
+const ROW_HEIGHT_PX = 72;
+const LIST_MAX_HEIGHT_PX = 360;
+
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
@@ -32,6 +38,12 @@ const deleting = ref(false);
const emitter = inject>("emitter");
const snackbar = useSnackbar();
const configStore = storeConfig();
+const listHeight = computed(() =>
+ Math.min(roms.value.length * ROW_HEIGHT_PX, LIST_MAX_HEIGHT_PX),
+);
+const fsIds = computed(() => new Set(romsToDeleteFromFs.value));
+const rowHeight = () => ROW_HEIGHT_PX;
+const rowKey = (rom: unknown) => (rom as SimpleRom).id;
const openHandler = (romsToDelete: SimpleRom[]) => {
roms.value = romsToDelete;
@@ -102,15 +114,30 @@ async function deleteRoms() {
{ icon: "mdi-check-bold" },
);
if (exclude) {
- for (const rom of deletedRoms) {
- const type = rom.has_simple_single_file
+ const exclusionType = (rom: SimpleRom) =>
+ rom.has_simple_single_file
? "EXCLUDED_SINGLE_FILES"
: "EXCLUDED_MULTI_FILES";
+ // One at a time: each call rewrites the config file server-side.
+ const results = await settleWithLimit(deletedRoms, 1, (rom) =>
configApi.addExclusion({
exclusionValue: rom.fs_name,
- exclusionType: type,
+ exclusionType: exclusionType(rom),
+ }),
+ );
+ let failed = 0;
+ results.forEach((result, i) => {
+ const rom = deletedRoms[i];
+ if (result.status === "fulfilled") {
+ configStore.addExclusion(exclusionType(rom), rom.fs_name);
+ } else {
+ failed++;
+ }
+ });
+ if (failed > 0) {
+ snackbar.error(t("rom.exclude-failed", { n: failed }), {
+ icon: "mdi-close-circle",
});
- configStore.addExclusion(type, rom.fs_name);
}
}
romsStore.resetSelection();
@@ -200,50 +227,55 @@ function closeDialog() {
-
- -
-
-
![]()
-
-
-
-
-
-
@@ -308,18 +340,9 @@ function closeDialog() {
background: var(--r-color-surface);
}
-.r-v2-del-rom__list {
- list-style: none;
- padding: 0;
- margin: 0;
- display: flex;
- flex-direction: column;
- gap: 6px;
- max-height: 360px;
- overflow-y: auto;
-}
-
.r-v2-del-rom__row {
+ box-sizing: border-box;
+ height: 66px;
display: grid;
grid-template-columns: 36px 1fr auto;
align-items: center;
diff --git a/frontend/src/v2/components/Gallery/SelectionBar.test.ts b/frontend/src/v2/components/Gallery/SelectionBar.test.ts
index 111de3285..290a597ba 100644
--- a/frontend/src/v2/components/Gallery/SelectionBar.test.ts
+++ b/frontend/src/v2/components/Gallery/SelectionBar.test.ts
@@ -19,6 +19,7 @@ const {
selectAll,
snackbarError,
snackbarSuccess,
+ updateUserRomProps,
} = vi.hoisted(() => ({
addRomsToCollection: vi.fn(),
createCollection: vi.fn(),
@@ -27,6 +28,7 @@ const {
selectAll: vi.fn(),
snackbarError: vi.fn(),
snackbarSuccess: vi.fn(),
+ updateUserRomProps: vi.fn(),
}));
// The whole-result behavior is covered by the composable's own tests;
@@ -65,6 +67,13 @@ vi.mock("@/services/api/collection", () => ({
},
}));
+vi.mock("@/services/api/rom", async (importOriginal) => {
+ const actual = await importOriginal
();
+ return {
+ default: { ...actual.default, updateUserRomProps },
+ };
+});
+
vi.mock("@/v2/composables/useSnackbar", () => ({
useSnackbar: () => ({
success: snackbarSuccess,
@@ -100,7 +109,10 @@ function select(...roms: SimpleRom[]) {
roms.forEach((r, i) => selection.toggle(r, i));
}
-function mountBar(props: { hideDownload?: boolean } = {}) {
+function mountBar(
+ props: { hideDownload?: boolean } = {},
+ extraStubs: Record = {},
+) {
return mount(SelectionBar, {
props,
global: {
@@ -120,6 +132,7 @@ function mountBar(props: { hideDownload?: boolean } = {}) {
RMenuItem: true,
RIcon: true,
RDivider: true,
+ ...extraStubs,
},
},
});
@@ -337,3 +350,41 @@ describe("SelectionBar select all", () => {
).toBe(true);
});
});
+
+describe("SelectionBar bulk status", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.clearAllMocks();
+ });
+
+ it("ignores a status choice while the previous batch is running", async () => {
+ let finish: () => void = () => {};
+ updateUserRomProps.mockImplementation(
+ () => new Promise((resolve) => (finish = resolve)),
+ );
+ select(rom(1));
+ const wrapper = mountBar(
+ {},
+ {
+ RMenu: { template: "
" },
+ RMenuItem: {
+ props: { disabled: Boolean },
+ emits: ["click"],
+ template:
+ "",
+ },
+ },
+ );
+ const [first, second] = wrapper.findAll(".status-item");
+
+ await first.trigger("click");
+ await second.trigger("click");
+ expect(updateUserRomProps).toHaveBeenCalledTimes(1);
+ expect(second.attributes("disabled")).toBeDefined();
+
+ finish();
+ await flushPromises();
+ await second.trigger("click");
+ expect(updateUserRomProps).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/frontend/src/v2/components/Gallery/SelectionBar.vue b/frontend/src/v2/components/Gallery/SelectionBar.vue
index 384e06918..3fd649ff1 100644
--- a/frontend/src/v2/components/Gallery/SelectionBar.vue
+++ b/frontend/src/v2/components/Gallery/SelectionBar.vue
@@ -70,6 +70,7 @@ import {
type StatusFlagKey,
VISIBILITY_FLAG_KEYS,
} from "@/v2/utils/romStatus";
+import { settleWithLimit } from "@/v2/utils/settleWithLimit";
import {
FILLET_PX,
NOTCH_RADIUS_PX,
@@ -188,7 +189,9 @@ async function bulkFavorite() {
// ROM is missing it) sets it on all. The per-rom `updateUserRomProps` is
// the only endpoint (no bulk variant), so we fan out one request per ROM,
// optimistically write the store, and revert only the ROMs whose request
-// failed.
+// failed. The fan-out is bounded so a whole-library selection doesn't queue
+// thousands of requests on a single-worker backend.
+const STATUS_CONCURRENCY = 4;
//
// `enumAllActive` / `flagAllActive` mirror `allFavorited`: keyed by
// status so each menu row can paint its active state and decide its
@@ -218,10 +221,24 @@ const hasAnyStatus = computed(() => {
);
});
+// One batch at a time, so a slower earlier batch can't land after a newer choice.
+const applyingStatus = ref(false);
+
async function applyStatus(data: Partial) {
const roms = selection.roms;
- if (roms.length === 0) return;
+ if (roms.length === 0 || applyingStatus.value) return;
+ applyingStatus.value = true;
+ try {
+ await applyStatusTo(roms, data);
+ } finally {
+ applyingStatus.value = false;
+ }
+}
+async function applyStatusTo(
+ roms: typeof selection.roms,
+ data: Partial,
+) {
const before = new Map();
for (const rom of roms) {
if (!rom.rom_user) continue;
@@ -230,8 +247,8 @@ async function applyStatus(data: Partial) {
syncCachedRom(rom);
}
- const results = await Promise.allSettled(
- roms.map((rom) => romApi.updateUserRomProps({ romId: rom.id, data })),
+ const results = await settleWithLimit(roms, STATUS_CONCURRENCY, (rom) =>
+ romApi.updateUserRomProps({ romId: rom.id, data }),
);
const failed = roms.filter((_, i) => results[i].status === "rejected");
for (const rom of failed) {
@@ -469,6 +486,7 @@ function clear() {
:key="key"
:icon="STATUS_ICONS[key]"
:variant="enumAllActive[key] ? 'active' : 'default'"
+ :disabled="applyingStatus"
@click="toggleEnumStatus(key)"
>
{{ t(romStatusMap[key].i18nKey) }}
@@ -485,6 +503,7 @@ function clear() {
:icon="STATUS_ICONS[key]"
:text-color="flagAllActive[key] ? 'brand-primary' : undefined"
:icon-color="flagAllActive[key] ? 'brand-primary' : undefined"
+ :disabled="applyingStatus"
@click="toggleFlagStatus(key)"
>
{{ t(romStatusMap[key].i18nKey) }}
@@ -503,6 +522,7 @@ function clear() {
:icon="STATUS_ICONS[key]"
:text-color="flagAllActive[key] ? 'brand-primary' : undefined"
:icon-color="flagAllActive[key] ? 'brand-primary' : undefined"
+ :disabled="applyingStatus"
@click="toggleFlagStatus(key)"
>
{{ t(romStatusMap[key].i18nKey) }}
@@ -516,6 +536,7 @@ function clear() {
{{ t("rom.clear-all") }}
diff --git a/frontend/src/v2/utils/settleWithLimit.test.ts b/frontend/src/v2/utils/settleWithLimit.test.ts
new file mode 100644
index 000000000..2c132fd23
--- /dev/null
+++ b/frontend/src/v2/utils/settleWithLimit.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { settleWithLimit } from "./settleWithLimit";
+
+describe("settleWithLimit", () => {
+ it("never runs more than the limit at once", async () => {
+ let inFlight = 0;
+ let peak = 0;
+ const task = async (n: number) => {
+ inFlight++;
+ peak = Math.max(peak, inFlight);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ inFlight--;
+ return n * 2;
+ };
+
+ const results = await settleWithLimit([1, 2, 3, 4, 5, 6, 7], 3, task);
+
+ expect(peak).toBe(3);
+ expect(results).toEqual(
+ [2, 4, 6, 8, 10, 12, 14].map((value) => ({
+ status: "fulfilled",
+ value,
+ })),
+ );
+ });
+
+ it("keeps each rejection at its item's index", async () => {
+ const results = await settleWithLimit([1, 2, 3], 2, async (n) => {
+ if (n === 2) throw new Error("boom");
+ return n;
+ });
+
+ expect(results.map((r) => r.status)).toEqual([
+ "fulfilled",
+ "rejected",
+ "fulfilled",
+ ]);
+ });
+
+ it("resolves an empty list", async () => {
+ expect(await settleWithLimit([], 4, async () => 1)).toEqual([]);
+ });
+});
diff --git a/frontend/src/v2/utils/settleWithLimit.ts b/frontend/src/v2/utils/settleWithLimit.ts
new file mode 100644
index 000000000..6a808b21c
--- /dev/null
+++ b/frontend/src/v2/utils/settleWithLimit.ts
@@ -0,0 +1,24 @@
+/** `Promise.allSettled` over `items`, with at most `limit` tasks in flight. */
+export async function settleWithLimit(
+ items: readonly T[],
+ limit: number,
+ task: (item: T) => Promise,
+): Promise[]> {
+ const results = new Array>(items.length);
+ let next = 0;
+
+ async function worker() {
+ while (next < items.length) {
+ const i = next++;
+ try {
+ results[i] = { status: "fulfilled", value: await task(items[i]) };
+ } catch (reason) {
+ results[i] = { status: "rejected", reason };
+ }
+ }
+ }
+
+ const workers = Math.max(1, Math.min(limit, items.length));
+ await Promise.all(Array.from({ length: workers }, worker));
+ return results;
+}