fix(e24): review round 2 — a buried history entry, a stale hero, and a spinner that was not ours

Nine of the round's ten surviving findings. The tenth (a failed shelf reorder leaving an
order the server rejected on screen) is on ROADMAP.md: its obvious remedy reintroduces the
overlapping-move defect the code already documents, so it wants a design, not a review edit.

- `navigateOverOverlays` buried the live overlay entries before knowing whether the
  navigation pushed one. `setPage` no-ops when the page is already current and Home is the
  default landing page, so Shift+W over a player ON Home marked its entry buried under a
  push that never happened: the unmount consumed the mark instead of reclaiming the entry,
  and the next browser Back was a press that did nothing. `setPage` now reports whether it
  navigated, and one `buryUnder` rule serves both entry points — `closeThenNavigate` takes
  the same no-op navigation from a palette command for the page you are already on.
- The hero could answer a new time budget with the previous one's pick. The chip row resets
  seed/seen/press but cannot reset an answer that already resolved, and `keepPreviousData`
  chained through the disabled seed-0 query — so a reroll under "10 perc" showed the film
  picked for "Bármennyi" while it loaded, and kept it if the request failed. `lastPick` now
  carries the server's own budget echo, and the placeholder is scoped to the budget.
- The reroll button spun and swallowed clicks after mark-watched, bookmark, pin and shelf
  moves: the pick key sits under the invalidated `discover` prefix on purpose, so raw
  `isFetching` was true for background refetches. It now means "a press of mine is
  outstanding" — which still covers the first press, where there is no placeholder.
- Failed writes on Home toasted the page's LOAD-failure string over a fully loaded page;
  they get their own key (HU+EN). The empty-state reroll had no `loading`, so the press was
  unacknowledged and a second click skipped a tier. `danger` moved into the shared unpin row
  so the same command is not red on the shelf and neutral in the rail below it.
- Two comments claimed things the code does not do: the `exclude` cap guards no "unbounded
  scan" (it never reaches SQL), and the route test that named the cap sends three keys.

Tests: the burial rule gets its own file (it is the one assertion that lets a reconcile
issue a real programmatic back()); App's keymap wiring gets a source contract, because
dropping it left all 818 tests green; `add_pin`'s IntegrityError arm was reached by nothing.
Every new test mutation-checked red, both edges where the predicate has two.

Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 822, pytest 1005.
This commit is contained in:
2026-09-09 00:12:53 +02:00
parent 3709b71a94
commit e4ffd59ca7
13 changed files with 317 additions and 32 deletions
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
//
// ONE test, ONE file, for the same reason CommandPalette.ordering.dom.test.tsx has its own: this is
// the only assertion in the suite that lets a reconcile issue a REAL programmatic history.back(),
// and history.ts's overlay bookkeeping is module-global. history.dom.test.ts states its own
// invariant — every layer there is closed by a genuine Back, so no programmatic back() runs and the
// shared state stays clean between its tests — and this test would break it.
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { navigateOverOverlays, useBackToClose } from "./history";
const settle = () =>
act(async () => {
await Promise.resolve();
});
describe("navigateOverOverlays · burying a navigation that never happened", () => {
// The round-2 defect. `navigateOverOverlays` marked every live overlay entry buried BEFORE it knew
// whether the navigation pushed anything, and `setPage` early-returns when the requested page is
// already current. Home is the default landing page, so Shift+W pressed while already on Home with
// the player open buried the player's entry under a push that never happened: its unmount then
// consumed the mark instead of reclaiming the entry with history.back(), leaving an orphan. The
// user's next browser Back popped onto an identical `sfPage` with an empty overlay stack — a press
// that changed nothing on screen, one more per play → Shift+W → close cycle.
it("leaves the overlay's entry reclaimable when the navigation reports it pushed nothing", async () => {
const overlay = renderHook(() => useBackToClose(() => {}));
await settle();
const back = vi.spyOn(window.history, "back");
try {
// What `setPage` returns from its early return: asked for the page we are already on.
const navigate = vi.fn(() => false);
act(() => navigateOverOverlays(navigate));
expect(navigate).toHaveBeenCalledTimes(1);
overlay.unmount();
await settle();
// Nothing was pushed over it, so the entry is still ours — reclaimed, not silently orphaned.
expect(back).toHaveBeenCalledTimes(1);
} finally {
back.mockRestore();
}
});
});
+5 -1
View File
@@ -96,7 +96,11 @@ describe("closeThenNavigate", () => {
const order: string[] = [];
closeThenNavigate(
() => order.push("close"),
() => order.push("navigate"),
// Braced: `navigate` reports whether it PUSHED (see `buryUnder`), so a bare `push` returning
// an array length is a type error rather than an accidental "yes".
() => {
order.push("navigate");
},
);
expect(order).toEqual(["close"]);
+27 -8
View File
@@ -164,6 +164,21 @@ let buriedEntries = 0;
let reconcileScheduled = false;
let listenerInstalled = false;
/** Run a navigation and mark the live overlay entries buried under it — but ONLY if it really
* pushed an entry. A navigation that reports `false` pushed nothing, so its overlays are still on
* top and must stay reclaimable: `setPage` early-returns when the requested page is already
* current, and Home is the default landing page, so "Shift+W while already on Home with the player
* open" navigated nowhere yet still buried the player's entry. The player's unmount then consumed
* the mark instead of issuing history.back(), orphaning its browser entry — and the next Back
* popped onto an identical `sfPage` with an empty overlay stack, i.e. a press that did nothing
* (round 2). One rule for both entry points below, because both take the same no-op navigation.
*
* Marking AFTER the call is safe: `navigate()` is synchronous and only `reconcileHistory` (a
* microtask) and the popstate listener ever move `historyDepth`. */
function buryUnder(navigate: () => boolean | void): void {
if (navigate() !== false) buriedEntries = historyDepth;
}
// Push/pop history entries until their count matches the live overlay count. Runs once per tick.
function reconcileHistory(): void {
reconcileScheduled = false;
@@ -221,7 +236,7 @@ function ensureOverlayListener(): void {
*
* Only call this from INSIDE an overlay that uses `useBackToClose`: the popstate we wait for is
* that overlay's own entry being reclaimed, and without one it never arrives. */
export function closeThenNavigate(close: () => void, navigate: () => void): void {
export function closeThenNavigate(close: () => void, navigate: () => boolean | void): void {
let done = false;
// Armed BEFORE the close, so it is a plain const and there is no declaration order to reason about.
// Degrades honestly if no popstate is coming at all — an overlay closed in the same tick it opened
@@ -233,7 +248,6 @@ export function closeThenNavigate(close: () => void, navigate: () => void): void
done = true;
window.removeEventListener("popstate", onPop);
clearTimeout(fallback);
navigate();
// Every overlay entry still counted is now BURIED under the entry the navigation just pushed, so
// a later unmount must NOT reclaim it with history.back() — that pops the navigation instead and
// silently bounces the user back where they started (a palette opened over the player: closing
@@ -241,8 +255,9 @@ export function closeThenNavigate(close: () => void, navigate: () => void): void
// reconcile ate the new page entry). MARK them rather than zeroing the depth: round 1 set
// `historyDepth = 0` outright, which also desynced the counter from overlays the navigation does
// NOT unmount — About/ReleaseNotes live outside the page column — so one Back both navigated and
// closed them (round 2).
buriedEntries = historyDepth;
// closed them (round 2). `buryUnder` is what skips the mark when the command navigated nowhere
// — a palette entry for the page you are already on.
buryUnder(navigate);
};
const onPop = () => {
// The same reconcile can issue MORE than one back(); a pushState made between them would be
@@ -268,10 +283,14 @@ export function closeThenNavigate(close: () => void, navigate: () => void): void
* 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();
* longer pops the page out from under the user.
*
* `navigate` reports whether it actually pushed — see `buryUnder`, which is why `setPage` returns a
* boolean rather than this guessing from `history.length` (a `back()` followed by a `pushState`
* leaves it unchanged) or from the entry's `sfScroll` id (`openChannel` pushes without minting
* one). */
export function navigateOverOverlays(navigate: () => boolean | void): void {
buryUnder(navigate);
}
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via