fromStart rode in history.state._sub, so reloading the player entry restarted the item from 0 again and the progress checkpoint overwrote its resume point. A sub-view config can now say what the history entry keeps of a view.
173 lines
7.9 KiB
TypeScript
173 lines
7.9 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");
|
|
});
|
|
});
|
|
|
|
// E28 review E1: a one-shot flag the view is OPENED with (the Plex player's `fromStart`, a card's
|
|
// Restart) rode in history.state._sub, so a reload or a Forward onto that entry restarted from 0
|
|
// again — and the player's checkpoint then overwrote the resume point. `entry` says what the
|
|
// history entry keeps of a view; the view itself still opens with the flag.
|
|
describe("useHistorySubview entry", () => {
|
|
type V = { kind: string; once?: boolean };
|
|
const withEntry: SubviewUrl<V> = {
|
|
key: "v",
|
|
encode: () => null,
|
|
decode: () => null,
|
|
entry: (v) => ({ kind: v.kind }),
|
|
};
|
|
|
|
it("opens the view with the flag, but the history entry keeps it without", () => {
|
|
const { result } = renderHook(() => useHistorySubview<V>({ kind: "root" }, withEntry));
|
|
act(() => result.current.open({ kind: "player", once: true }));
|
|
expect(result.current.view).toEqual({ kind: "player", once: true });
|
|
expect(window.history.state._sub).toEqual({ kind: "player" });
|
|
// A reload of that entry reads what the entry kept.
|
|
const { result: reloaded } = renderHook(() =>
|
|
useHistorySubview<V>({ kind: "root" }, withEntry),
|
|
);
|
|
expect(reloaded.current.view).toEqual({ kind: "player" });
|
|
});
|
|
|
|
it("keeps it out of a replaced entry too", () => {
|
|
const { result } = renderHook(() => useHistorySubview<V>({ kind: "root" }, withEntry));
|
|
act(() => result.current.open({ kind: "player", once: true }, { replace: true }));
|
|
expect(window.history.state._sub).toEqual({ kind: "player" });
|
|
});
|
|
});
|