Leaving the Plex player while one of its layers was up (the info panel when the film ended, the countdown under the back arrow) landed as a user Back: the overlay listener spent it closing the top layer and the player stayed. useHistorySubview's open/back now go through closeOverlaysThenNavigate, like setPage. The RECLOSE comment no longer cites the shared Plex entry.
249 lines
11 KiB
TypeScript
249 lines
11 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 coverage a review found missing — 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, waitFor } from "@testing-library/react";
|
|
import { useBackToClose, 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.restoreAllMocks());
|
|
|
|
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
|
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
|
|
function stubNavigation(below: boolean[]) {
|
|
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
|
|
entries.push({ sameDocument: true });
|
|
Object.defineProperty(window, "navigation", {
|
|
configurable: true,
|
|
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
|
|
});
|
|
}
|
|
afterEach(() => {
|
|
Reflect.deleteProperty(window, "navigation");
|
|
});
|
|
|
|
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
|
|
});
|
|
|
|
// 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 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");
|
|
});
|
|
});
|
|
|
|
// 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" });
|
|
});
|
|
});
|
|
|
|
// Chrome keeps 50 entries a tab; when full, a new entry drops the oldest "skippable" one — one the page
|
|
// left by a pushState with no user gesture — and that can be this page's own entry under the
|
|
// sub-view. back() then left the document for an older one (measured 2026-10-07: its restored Plex
|
|
// player started playing on its own).
|
|
describe("useHistorySubview back", () => {
|
|
it("goes back when the entry below is this document's", () => {
|
|
stubNavigation([true]);
|
|
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("a"));
|
|
act(() => result.current.back());
|
|
expect(back).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("goes back as before where the browser has no Navigation API", () => {
|
|
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("a"));
|
|
act(() => result.current.back());
|
|
expect(back).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// A sub-view left while an overlay of its own is still up — the Plex player's stats readout, a
|
|
// panel, the shortcuts sheet: the step back used to land as a USER Back, so the overlay listener
|
|
// spent it closing the top overlay and the sub-view stayed where it was.
|
|
it("closes the overlays standing over it first, then steps back out of the sub-view", async () => {
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("player"));
|
|
const closed = vi.fn();
|
|
const holder: { unmount?: () => void } = {};
|
|
const layer = renderHook(() =>
|
|
useBackToClose(() => {
|
|
closed();
|
|
holder.unmount?.();
|
|
}),
|
|
);
|
|
holder.unmount = layer.unmount;
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(window.history.state?._ov).toBeDefined();
|
|
act(() => result.current.back());
|
|
await waitFor(() => expect(result.current.view).toBe("root"));
|
|
expect(closed).toHaveBeenCalledTimes(1);
|
|
expect(window.history.state?._ov).toBeUndefined();
|
|
});
|
|
|
|
it("returns to the root in place when the entry below is another document's", () => {
|
|
const { result } = renderHook(() => useHistorySubview("root", url));
|
|
act(() => result.current.open("a"));
|
|
stubNavigation([false]);
|
|
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
|
const before = window.history.length;
|
|
act(() => result.current.back());
|
|
expect(back).not.toHaveBeenCalled();
|
|
expect(result.current.view).toBe("root");
|
|
expect(window.history.state?._sub).toBe("root");
|
|
expect(window.location.search).toBe("");
|
|
expect(window.history.length).toBe(before);
|
|
});
|
|
});
|