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
+25 -1
View File
@@ -11,7 +11,7 @@
// no reconcile-driven history.back() runs and the shared module state stays clean between tests.
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { closeThenNavigate, useBackToClose, useBackToExit } from "./history";
import { closeThenNavigate, navigateOverOverlays, useBackToClose, useBackToExit } from "./history";
// Let the mount effect's coalesced reconcile microtask settle.
const settle = () =>
@@ -163,3 +163,27 @@ describe("closeThenNavigate", () => {
}
});
});
describe("navigateOverOverlays", () => {
// The global-shortcut half of the same trap `closeThenNavigate` solves. Shift+W does not know an
// overlay is open and cannot close it — it just navigates, and the page change unmounts the player
// (which lives inside the module column). That unmount's reconcile then reclaimed the entry the
// navigation had only just pushed, so the player closed and the page snapped straight back.
it("a navigation made from outside an open overlay survives that overlay's unmount", async () => {
const overlay = renderHook(() => useBackToClose(() => {}));
await settle();
const back = vi.spyOn(window.history, "back");
try {
const navigate = vi.fn(() => window.history.pushState({ sfPage: "home" }, ""));
act(() => navigateOverOverlays(navigate));
expect(navigate).toHaveBeenCalledTimes(1);
overlay.unmount(); // what the page change does to the player
await settle();
// The mark is consumed instead: no back(), so the entry `navigate` pushed is still current.
expect(back).not.toHaveBeenCalled();
} finally {
back.mockRestore();
}
});
});
+20
View File
@@ -254,6 +254,26 @@ export function closeThenNavigate(close: () => void, navigate: () => void): void
close();
}
/** Navigate from OUTSIDE any overlay — a global keyboard shortcut — when one may be open.
*
* `closeThenNavigate` is for a control that lives INSIDE the overlay and can close it, and it waits
* for that overlay's own entry to come back. A global key has neither: it does not know which
* overlay is open, or whether one is at all. It just navigates, and the page change unmounts
* whatever the module column was holding — whose reconcile then reclaims the entry the navigation
* had just pushed. Shift+W over an open player closed the player and snapped back to the page it
* started on, which reads as a dead shortcut.
*
* Same fix as `closeThenNavigate`, one step shorter: bury the live overlay entries FIRST, so a
* later unmount consumes the marks instead of issuing `history.back()`. There is no popstate to
* wait for, because nothing is being closed on purpose. An overlay the navigation does NOT unmount
* (the palette and the cheat sheet live above the page column) keeps its entry buried too — the
* same trade `closeThenNavigate` already makes, and the reason closing the palette afterwards no
* longer pops the page out from under the user. */
export function navigateOverOverlays(navigate: () => void): void {
buriedEntries = historyDepth;
navigate();
}
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
export function useBackToClose(onClose: () => void): void {
+5 -2
View File
@@ -102,9 +102,12 @@ export const qk = {
// anyway. Single-id plex keys stay precisely typed.
// The Home surface (E24). `discover()` with no args is the prefix every mutation invalidates —
// pinning something changes the shelf AND can change the hero, so the two are never invalidated
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails.
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails —
// but it sits UNDER the same root, because a sibling root ("discover-pick") is not matched by
// that prefix and the rerolled hero silently outlived every invalidation on the page: play it,
// finish it, and it was still the answer on screen with a zeroed progress bar.
discover: (...rest: unknown[]) => ["discover", ...rest],
discoverPick: (...rest: unknown[]) => ["discover-pick", ...rest],
discoverPick: (...rest: unknown[]) => ["discover", "pick", ...rest],
comfortPins: () => ["comfort-pins"] as const,
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,