Files
siftlode/frontend/src/lib/history.subview.dom.test.ts
T
peter 7f17f488ab fix(nav): a sub-view step closes the overlays standing over it first
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.
2026-10-08 01:16:30 +02:00

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);
});
});