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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user