Backfills the E4 deep-review's deferred coverage for the subview history primitive. The keeper case is the S2 bookmark fix: a popstate that lands on a URL-only entry (no history.state._sub) must restore the sub-view from the ?v param via the SAME fallback the mount seed uses, not collapse to root. Covers: seed from URL vs state._sub vs root, the no-SubviewUrl state-only path, open() push+mirror + encode→null key-clear, and popstate restore from state / from URL (the fix) / collapse-to-root. 9 tests, gate green.
105 lines
4.5 KiB
TypeScript
105 lines
4.5 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
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|