All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
141 lines
6.4 KiB
TypeScript
141 lines
6.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// useHistorySubview: a module's active sub-view rides in history.state._sub, with optional address-bar
|
|
// mirroring so a deep sub-view (Plex show/season/info/person/playlist) is bookmarkable. This backfills
|
|
// the E4 deep-review's deferred coverage for the S2 fix — the seed AND the popstate handler must share
|
|
// the SAME URL fallback, so a popstate that lands on a bookmark entry (URL-only, no _sub — e.g. after
|
|
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing
|
|
// it to root. The overlay/back-ladder half of history.ts is covered in history.dom.test.ts.
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { useHistorySubview, type SubviewUrl } from "./history";
|
|
|
|
// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors
|
|
// verbatim to ?v=<view>. decode rejects the empty string (→ null, i.e. not a valid sub-view).
|
|
const url: SubviewUrl<string> = {
|
|
key: "v",
|
|
encode: (v) => (v === "root" ? null : v),
|
|
decode: (raw) => raw || null,
|
|
};
|
|
|
|
const setUrl = (search: string, state: unknown = null) =>
|
|
window.history.replaceState(state, "", search || "/");
|
|
const popTo = (search: string, state: unknown = null) =>
|
|
act(() => {
|
|
window.history.replaceState(state, "", search || "/");
|
|
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
|
});
|
|
|
|
beforeEach(() => setUrl("/"));
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe("useHistorySubview seed", () => {
|
|
it("decodes the ?<key> URL param when the entry has no _sub (fresh bookmark)", () => {
|
|
setUrl("?v=a");
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
expect(result.current.view).toBe("a");
|
|
});
|
|
|
|
it("prefers history.state._sub over the URL param (in-app entry)", () => {
|
|
setUrl("?v=a", { _sub: "b" });
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
expect(result.current.view).toBe("b");
|
|
});
|
|
|
|
it("falls back to root when neither state nor a decodable URL param is present", () => {
|
|
setUrl("/");
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
expect(result.current.view).toBe("root");
|
|
});
|
|
|
|
it("ignores the URL entirely when no SubviewUrl is supplied (state-only mirror)", () => {
|
|
setUrl("?v=a", { _sub: "fromState" });
|
|
const { result } = renderHook(() => useHistorySubview("root"));
|
|
expect(result.current.view).toBe("fromState");
|
|
// A URL param with no url config must NOT leak in.
|
|
setUrl("?v=a", null);
|
|
const { result: r2 } = renderHook(() => useHistorySubview("root"));
|
|
expect(r2.current.view).toBe("root");
|
|
});
|
|
});
|
|
|
|
describe("useHistorySubview open", () => {
|
|
it("pushes state._sub and mirrors the URL via encode", () => {
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("a"));
|
|
expect(result.current.view).toBe("a");
|
|
expect(window.history.state?._sub).toBe("a");
|
|
expect(window.location.search).toBe("?v=a");
|
|
});
|
|
|
|
it("clears the mirrored key when encode returns null (root/ephemeral view)", () => {
|
|
setUrl("?v=a");
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("root"));
|
|
expect(result.current.view).toBe("root");
|
|
expect(window.history.state?._sub).toBe("root");
|
|
expect(window.location.search).toBe(""); // ?v dropped
|
|
});
|
|
|
|
// E24: a cross-module hand-off (Home plays a Plex title) arrives on an entry the page navigation
|
|
// just pushed. Opening the sub-view on TOP of it stranded the user on a module root they never
|
|
// visited — browser-Back from the player landed on the Plex Discover page instead of Home
|
|
// (measured 2026-09-06). `replace` makes the sub-view BE that entry.
|
|
it("replace rewrites the current entry instead of stacking a new one", () => {
|
|
const before = window.history.length;
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("a", { replace: true }));
|
|
expect(result.current.view).toBe("a");
|
|
expect(window.history.state?._sub).toBe("a");
|
|
expect(window.location.search).toBe("?v=a");
|
|
expect(window.history.length).toBe(before); // no new entry to Back through
|
|
});
|
|
|
|
it("replace is idempotent — two opens of the same view are still one entry", () => {
|
|
// Not a nicety: under StrictMode the jumpTo effect body runs TWICE before its `setJumpTo(null)`
|
|
// re-renders, and two pushes of the identical sub-view left a duplicate whose Back visibly did
|
|
// nothing.
|
|
const before = window.history.length;
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => {
|
|
result.current.open("a", { replace: true });
|
|
result.current.open("a", { replace: true });
|
|
});
|
|
expect(window.history.length).toBe(before);
|
|
});
|
|
|
|
it("keeps the entry's own sfScroll id on a replace", () => {
|
|
// It is the SAME entry, so it keeps its scroll identity; PageScroller keys offsets per
|
|
// (entry, view), and the view key changed, so the sub-view still opens at the top.
|
|
setUrl("/", { sfScroll: 7 });
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("a", { replace: true }));
|
|
expect(window.history.state?.sfScroll).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe("useHistorySubview popstate", () => {
|
|
it("restores the sub-view from state._sub on Back/Forward", () => {
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
popTo("?v=a", { _sub: "a" });
|
|
expect(result.current.view).toBe("a");
|
|
});
|
|
|
|
it("restores from the ?<key> URL when the landed entry has no _sub — the S2 bookmark fix", () => {
|
|
// Start in-app at root, then a popstate lands on a URL-only bookmark entry (no _sub). Reading
|
|
// history.state alone would wrongly collapse to root; the shared URL fallback must decode ?v=b.
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
expect(result.current.view).toBe("root");
|
|
popTo("?v=b", null);
|
|
expect(result.current.view).toBe("b");
|
|
});
|
|
|
|
it("collapses to root when a popstate lands on an entry with neither _sub nor a decodable URL", () => {
|
|
setUrl("?v=a", { _sub: "a" });
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
expect(result.current.view).toBe("a");
|
|
popTo("/", null);
|
|
expect(result.current.view).toBe("root");
|
|
});
|
|
});
|