fix(e24): the review's seven frontend defects on the Home surface

Shift+W was dead whenever anything was open. The global handler called `setPage` directly,
so the page change unmounted the player (it lives inside the module column) and that
unmount's reconcile reclaimed the entry the navigation had just pushed — the player closed
and the page snapped back. `closeThenNavigate` cannot serve here: a global key does not
know which overlay is open, or whether one is. `navigateOverOverlays` is the same fix one
step shorter — bury the live overlay entries first, so the unmount consumes the marks
instead of issuing `history.back()`.

`q.isError` had no `&& !data` guard, alone among 33 call sites and against the convention
`ui/QueryState` documents: react-query flips status to error on a failed REFETCH while
keeping the payload, and every mutation here invalidates the discover prefix, so one blip
while marking a card watched blanked a fully populated landing page.

The reroll's key rooted at "discover-pick", outside the prefix every mutation invalidates,
so a rerolled hero outlived the page: play it, finish it, and it was still the answer with
a zeroed progress bar. It now sits under the same root. A FAILED reroll also rewound the
hero to the card the page loaded with, several presses back, because a new key per press
means `keepPreviousData` (pending-only) cannot cover an error; the last resolved pick is
held instead.

The bookmark on a rail tile never updated — `VideoCard` reads `video.saved` from this
payload and keeps no optimistic state — so the star stayed empty and the next click
re-sent the same call.

The shelf reorder wrote a captured snapshot back on failure rather than re-asking the
server, which its own comment promised: two overlapping moves each reverted to their own
snapshot and settled on an order neither side held, and since `before` is the whole
payload a rollback also discarded any rail refresh that had landed meanwhile. It now
invalidates the discover PREFIX on settle, which also fixes the sibling budgets keeping
the pre-move order.

The hero/rail de-duplication matched one key, so for a Plex series — the rail row is the
show, the hero is the episode it resolves to — the same series printed twice; it now
matches both keys, as `hero.pick`'s own exclusion already did. And when the hero empties
the resume rail, the rail is hidden rather than announcing "nothing half-finished right
now" under a hero captioned "where you left off".

Ten regression tests, all mutation-checked. Three of them were rewritten after the first
mutation round showed they could not go red: two asserted before the state under test had
settled, one matched a title the Plex tile does not render.
This commit is contained in:
2026-09-08 03:03:45 +02:00
parent 84f4b20e5a
commit fdc1eabbe0
7 changed files with 282 additions and 24 deletions
+54 -18
View File
@@ -371,6 +371,14 @@ export default function Home() {
// forbid. Measured in the browser (2026-09-06); the button's own spinner is the loading signal.
placeholderData: keepPreviousData,
});
// The last pick that actually RESOLVED. Every press is a new query key, so a failed one has no
// data of its own and `keepPreviousData` (pending-only) cannot cover it — without this the hero
// silently rewound to the card the page loaded with, several presses back, while `core.req`'s own
// dialog was explaining the failure. A reroll that fails leaves the answer on screen alone.
const [lastPick, setLastPick] = useState<DiscoverPayload["hero"]>(null);
useEffect(() => {
if (pickQ.data !== undefined) setLastPick(pickQ.data.hero);
}, [pickQ.data]);
const data = q.data;
// Which hero is on screen. The middle branch is the load-bearing one: `keepPreviousData` holds the
@@ -384,12 +392,23 @@ export default function Home() {
? (data?.hero ?? null)
: pickQ.data !== undefined
? pickQ.data.hero
: (data?.hero ?? null);
: (lastPick ?? data?.hero ?? null);
// The hero is the top of the resume rail by construction, so leaving it in the rail below prints
// the same card twice, a hand's width apart. Matched on source+id rather than on the tier alone:
// a rerolled hero can be a surprise pick that happens to be nothing to do with this rail.
//
// Against BOTH of a row's keys, the way `hero.pick`'s own exclusion does. For a Plex series the
// rail row is the SHOW while the hero is the episode that show RESOLVES to, so a single-key match
// never fires and the most common Plex resume case printed the series twice.
const heroKey = hero ? discoverCardKey(hero.card) : null;
const resumeRail = (data?.continue_watching ?? []).filter((c) => discoverCardKey(c) !== heroKey);
const resumeRail = (data?.continue_watching ?? []).filter(
(c) => discoverCardKey(c) !== heroKey && discoverCardKey(c.next ?? c) !== heroKey,
);
// Empty because the hero ATE the rail, not because nothing is half-finished. An empty rail earns
// its empty state by teaching you how to fill it; here there is nothing to teach, and "Nothing
// half-finished right now" under a hero captioned "Where you left off" is the page contradicting
// itself on one screen.
const resumeConsumed = !resumeRail.length && (data?.continue_watching ?? []).length > 0;
const onState = useCallback(
(id: string, status: VideoStatus) => {
@@ -406,6 +425,11 @@ export default function Home() {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
// And the payload the star is READ from. `VideoCard` derives the filled/empty bookmark
// straight from `video.saved` and keeps no optimistic state of its own (the feed instead
// holds a `savedOverrides` map), so without this the icon never changed and a second click
// re-sent the same call — the control looked dead.
qc.invalidateQueries({ queryKey: qk.discover() });
});
},
[qc],
@@ -486,13 +510,19 @@ export default function Home() {
const ids = next.map((c) => c.pin_id).filter((id): id is number => id != null);
void api
.reorderComfortPins(ids)
.catch((e: unknown) => {
qc.setQueryData<DiscoverPayload>(key, before);
notifyActionError(e, t("home.shelf.moveFailed"));
})
.catch((e: unknown) => notifyActionError(e, t("home.shelf.moveFailed")))
.finally(() => {
// The shelf is also the pin menus' source of truth everywhere else in the app.
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
// And the PREFIX, not this budget's key — which is what actually re-asks the server, and
// is what the comment above this callback promises. Restoring the captured snapshot
// instead was wrong twice over: two overlapping moves each reverted to their own
// snapshot, so the shelf settled on an order neither side ever held; and `before` is the
// whole payload, so a rollback also threw away any rail refresh that had landed while the
// request was in flight. On the success path the prefix matters for a second reason — the
// shelf is budget-independent server-side but cached per budget, so without it the next
// chip click renders the pre-move order and a move from there writes it back.
void qc.invalidateQueries({ queryKey: qk.discover() });
});
},
[budget, qc, t],
@@ -523,7 +553,11 @@ export default function Home() {
}, [surpriseRequest, reroll]);
if (q.isPending) return <LoadingState />;
if (q.isError)
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
// blip replaced a fully populated landing page with an error line.
if (q.isError && !data)
return (
<StateMessage tone="error" onRetry={() => void q.refetch()}>
{t("home.error")}
@@ -581,17 +615,19 @@ export default function Home() {
onToggleWatched={onToggleWatched}
onMove={moveInShelf}
/>
<Rail
title={t("home.continue.title")}
ariaLabel={t("home.continue.title")}
cards={resumeRail}
emptyLabel={t("home.continue.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
{!resumeConsumed && (
<Rail
title={t("home.continue.title")}
ariaLabel={t("home.continue.title")}
cards={resumeRail}
emptyLabel={t("home.continue.empty")}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
/>
)}
{player && (
<Suspense fallback={null}>