fix: guard AddToCollectionModal against out-of-order load results

Retargeting the modal mid-load could resolve responses out of order, overwriting
the newer target's membership with the older one's (audit FE-2). Route load()
through the tested CancellableLoader so a superseded result is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:31:26 +02:00
parent cd5358dc9b
commit ac5641bcd3
4 changed files with 22 additions and 10 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.128.14" version = "0.128.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.128.14" version = "0.128.15"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.128.14", "version": "0.128.15",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import AdaptiveDialog from './AdaptiveDialog.svelte'; import AdaptiveDialog from './AdaptiveDialog.svelte';
import { CancellableLoader } from '$lib/util/cancellable';
import { import {
addMangaToCollection, addMangaToCollection,
createCollection, createCollection,
@@ -74,19 +75,30 @@
: removePageFromCollection(collectionId, t.id); : removePageFromCollection(collectionId, t.id);
} }
// Reopening for a different target (or an in-flight retarget) fires load()
// again; without this guard the two responses can resolve out of order and
// the first target's membership overwrites the second's (audit FE-2). The
// CancellableLoader drops a superseded call's result — the newer load then
// owns both the state and the spinner.
const loader = new CancellableLoader();
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
const result = await loader.run(async () => {
const [page, ids] = await Promise.all([ const [page, ids] = await Promise.all([
listMyCollections({ limit: 200 }), listMyCollections({ limit: 200 }),
loadContaining(target) loadContaining(target)
]); ]);
collections = page.items; return { items: page.items, ids };
containingIds = new Set(ids); });
if (result === null) return; // superseded — a newer load owns the state
collections = result.items;
containingIds = new Set(result.ids);
loading = false;
} catch (e) { } catch (e) {
error = (e as Error).message; error = (e as Error).message;
} finally {
loading = false; loading = false;
} }
} }