fix: add tags optimistically on the detail page

submitTag now shows the chip immediately with the typed name and reconciles
it with the server's (normalized) ref on success, rolling back on error —
matching the already-optimistic tag removal instead of blocking on the
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 22:05:23 +02:00
parent cee1e73f98
commit f1e66141f4
5 changed files with 107 additions and 10 deletions

2
backend/Cargo.lock generated
View File

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

View File

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

View File

@@ -0,0 +1,88 @@
import { test, expect, type Page } from './fixtures';
// Adding a tag on the detail page should feel instant: the chip appears
// optimistically while the request is in flight, and is reconciled with the
// server's (normalized) tag on success.
const mangaId = 'e1111111-1111-1111-1111-111111111111';
async function mockDetail(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) })
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({ status: 404, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })
);
// Autocomplete suggestions endpoint — keep it empty/fast.
await page.route('**/api/v1/tags*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
);
await page.route(`**/api/v1/mangas/${mangaId}`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: mangaId, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null,
cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0
})
})
);
}
test('adds a tag optimistically before the request resolves', async ({ page }) => {
await mockDetail(page);
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(`**/api/v1/mangas/${mangaId}/tags`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'tag1', name: 'action', added_by: 'u1' })
});
});
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await page.getByTestId('tag-input').fill('action');
await page.getByTestId('tag-input').press('Enter');
// Optimistic: the chip shows while the POST is still gated.
await expect(page.getByTestId('manga-tags')).toContainText('action');
release();
// Still present after the server confirms (reconciled to the real tag).
await expect(page.getByTestId('manga-tags')).toContainText('action');
await expect(page.getByTestId('tag-chip-tag1')).toBeVisible();
});

View File

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

View File

@@ -257,18 +257,27 @@
async function submitTag(name: string) {
const trimmed = name.trim();
if (!trimmed || !session.user || tagAddBusy) return;
const snapshot = tags;
// Show the chip immediately with the typed name; reconcile with the
// server ref (which may normalize the name/id) on success. Drop any
// existing same-name chip so a re-add doesn't duplicate.
const tempId = `optimistic-${crypto.randomUUID()}`;
tags = [
...tags.filter((t) => t.name.toLowerCase() !== trimmed.toLowerCase()),
{ id: tempId, name: trimmed, added_by: session.user.id }
];
tagDraft = '';
suggestions = [];
suggestHighlight = -1;
tagAddBusy = true;
tagError = null;
try {
const attached = await attachTag(manga.id, trimmed);
// If the tag was already attached by someone else, the
// server returns 200 + the existing ref — replace any
// matching entry to keep local state coherent.
tags = [...tags.filter((t) => t.id !== attached.id), attached];
tagDraft = '';
suggestions = [];
suggestHighlight = -1;
// Replace the placeholder (and any pre-existing entry the server
// may have matched) with the authoritative ref.
tags = [...tags.filter((t) => t.id !== tempId && t.id !== attached.id), attached];
} catch (e) {
tags = snapshot;
tagError = (e as Error).message;
} finally {
tagAddBusy = false;