fix(detail): resync reaction toggle across manga navigation
The detail page component is reused across /manga/A -> /manga/B navigations (clicking a similar or recommendation card), so ReactionButtons' locally-held `current` state kept the previous manga's reaction until interacted with — a visibly wrong thumb on the new page. Re-seed `current` from the loader-supplied prop via an effect keyed on mangaId. Also fetch the homepage's two independent /me/* shelves concurrently (Promise.allSettled) instead of in series. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.109.0"
|
version = "0.109.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.109.0"
|
version = "0.109.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.109.0",
|
"version": "0.109.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.109.0",
|
"version": "0.109.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.109.0",
|
"version": "0.109.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -18,6 +18,16 @@
|
|||||||
let current = $state<Reaction | null>(initial);
|
let current = $state<Reaction | null>(initial);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
|
// The detail page reuses this component across manga -> manga navigation
|
||||||
|
// (similar / recommendation cards), so `current` must re-seed when the
|
||||||
|
// loader hands us a new manga's reaction. `mangaId` is referenced so the
|
||||||
|
// effect also re-runs when navigating between two never-reacted mangas
|
||||||
|
// (both `initial === null`).
|
||||||
|
$effect(() => {
|
||||||
|
mangaId;
|
||||||
|
current = initial;
|
||||||
|
});
|
||||||
|
|
||||||
// Click the active reaction to clear it; click the other to switch.
|
// Click the active reaction to clear it; click the other to switch.
|
||||||
async function apply(next: Reaction) {
|
async function apply(next: Reaction) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ describe('ReactionButtons', () => {
|
|||||||
expect(pressed('reaction-like')).toBe('false');
|
expect(pressed('reaction-like')).toBe('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resyncs when navigated to a different manga', async () => {
|
||||||
|
// The detail page component is reused across /manga/A -> /manga/B
|
||||||
|
// navigations (e.g. clicking a similar or recommendation card), so
|
||||||
|
// new props must overwrite the locally-held reaction state.
|
||||||
|
const { rerender } = render(ReactionButtons, {
|
||||||
|
props: { mangaId: 'm1', initial: 'like' }
|
||||||
|
});
|
||||||
|
expect(pressed('reaction-like')).toBe('true');
|
||||||
|
|
||||||
|
await rerender({ mangaId: 'm2', initial: 'dislike' });
|
||||||
|
expect(pressed('reaction-dislike')).toBe('true');
|
||||||
|
expect(pressed('reaction-like')).toBe('false');
|
||||||
|
|
||||||
|
await rerender({ mangaId: 'm3', initial: null });
|
||||||
|
expect(pressed('reaction-like')).toBe('false');
|
||||||
|
expect(pressed('reaction-dislike')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls back on failure', async () => {
|
it('rolls back on failure', async () => {
|
||||||
setReaction.mockRejectedValue(new Error('boom'));
|
setReaction.mockRejectedValue(new Error('boom'));
|
||||||
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
|
render(ReactionButtons, { props: { mangaId: 'm1', initial: null } });
|
||||||
|
|||||||
@@ -311,24 +311,24 @@
|
|||||||
}
|
}
|
||||||
await hydrateFromUrl();
|
await hydrateFromUrl();
|
||||||
await load();
|
await load();
|
||||||
// Fetch the "Continue reading" shelf after the catalogue so the
|
// Fetch the personal shelves after the catalogue so the public browse
|
||||||
// public browse path stays unauthenticated and unblocked. Returns
|
// path stays unauthenticated and unblocked. Both are independent
|
||||||
// empty for guests (401 swallowed), which hides the shelf.
|
// `/me/*` calls, so fire them concurrently rather than in series.
|
||||||
try {
|
// Each is isolated: a failure (or 401 for guests) hides its own shelf
|
||||||
const progress = await listMyReadProgressOrEmpty();
|
// without touching the catalogue or the other shelf.
|
||||||
|
const [progressResult, recsResult] = await Promise.allSettled([
|
||||||
|
listMyReadProgressOrEmpty(),
|
||||||
|
listMyRecommendations()
|
||||||
|
]);
|
||||||
|
if (progressResult.status === 'fulfilled') {
|
||||||
// Drop finished series (read to the end, nothing new) — a
|
// Drop finished series (read to the end, nothing new) — a
|
||||||
// "Continue reading" shelf is for what's still in progress.
|
// "Continue reading" shelf is for what's still in progress.
|
||||||
continueEntries = progress.items.filter((e) => !isCaughtUp(e));
|
continueEntries = progressResult.value.items.filter((e) => !isCaughtUp(e));
|
||||||
} catch {
|
|
||||||
// Never let a history hiccup break the catalogue — leave the
|
|
||||||
// shelf hidden.
|
|
||||||
}
|
}
|
||||||
try {
|
if (recsResult.status === 'fulfilled') {
|
||||||
// Personal "Recommended for you" feed (empty for guests / no
|
// Personal "Recommended for you" feed (empty for guests / no
|
||||||
// taste signals yet → shelf hidden).
|
// taste signals yet → shelf hidden).
|
||||||
recommendations = await listMyRecommendations();
|
recommendations = recsResult.value;
|
||||||
} catch {
|
|
||||||
// Non-critical — leave the shelf hidden on failure.
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user