The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark rebuilds the queue from the playlist's own order while the item is still in it. Found in the browser and fixed: - after a reload the shell's player opened a second one over Home and the feed: it read the catalog row the page had fetched (a disabled query still reads the shared cache); it now waits for ready. - every reload under an open overlay left a dead history step: the first overlay to open takes over the entry the document was loaded on. - the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked feed opened as Home: the feed now names itself (page=feed) like every page. - a linked file or video that is gone was said, but stayed in the address and was said again on every reload: its keys are taken off. The overlay address is written in one place (the entry is named once it is current), and the player keys only the codec reads are no longer exported.
94 lines
3.9 KiB
TypeScript
94 lines
3.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// An overlay that is part of the view (a player) names itself in its own history entry's address, so a
|
|
// reload or a bookmark opens it — and the entry under it never carries those keys, so Back lands on the
|
|
// page as it was. Driven through jsdom's real history: its back()/forward() land their popstate a
|
|
// task later, like a browser's.
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { act, renderHook } from "@testing-library/react";
|
|
import { useBackToClose } from "./history";
|
|
|
|
const settle = () =>
|
|
act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
// Step through the history the way the browser does, and wait for its popstate to land.
|
|
const step = (go: () => void) =>
|
|
act(async () => {
|
|
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
|
|
go();
|
|
await landed;
|
|
await Promise.resolve();
|
|
});
|
|
const search = () => new URLSearchParams(window.location.search);
|
|
|
|
/** A player-like overlay whose close really unmounts it, naming `url` in its entry. */
|
|
function mountPlayer(initial: Record<string, string | null>) {
|
|
const holder: { unmount?: () => void } = {};
|
|
const r = renderHook(
|
|
(url: Record<string, string | null>) => useBackToClose(() => holder.unmount?.(), url),
|
|
{ initialProps: initial },
|
|
);
|
|
holder.unmount = r.unmount;
|
|
return r;
|
|
}
|
|
|
|
afterEach(() => {
|
|
window.history.replaceState(null, "", "/");
|
|
});
|
|
|
|
describe("an overlay named in its entry's address", () => {
|
|
it("is named in its own entry, and Back lands on the address under it without it", async () => {
|
|
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
|
const depth = window.history.length;
|
|
mountPlayer({ watch: "v1", wtab: null });
|
|
await settle();
|
|
expect(window.history.length).toBe(depth + 1);
|
|
expect(search().get("watch")).toBe("v1");
|
|
expect(search().get("q")).toBe("music");
|
|
// What a page reads as Back lands (a host mounting there reopens a player its address names):
|
|
// the entry under the player never carried it, so there is nothing to clear first.
|
|
let landedOn = "";
|
|
window.addEventListener("popstate", () => (landedOn = window.location.search), { once: true });
|
|
await step(() => window.history.back());
|
|
expect(landedOn).toBe("?q=music");
|
|
expect(search().has("watch")).toBe(false);
|
|
expect(search().get("q")).toBe("music");
|
|
});
|
|
|
|
it("follows what it names as it changes", async () => {
|
|
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
|
const r = mountPlayer({ watch: "v1", wtab: null });
|
|
await settle();
|
|
r.rerender({ watch: "v2", wtab: "related" });
|
|
expect(search().get("watch")).toBe("v2");
|
|
expect(search().get("wtab")).toBe("related");
|
|
r.rerender({ watch: "v2", wtab: null });
|
|
expect(search().has("wtab")).toBe(false);
|
|
await step(() => r.unmount());
|
|
expect(search().has("watch")).toBe(false);
|
|
expect(search().get("q")).toBe("music");
|
|
});
|
|
|
|
it("closed by its own control, leaves the entry under it as it was", async () => {
|
|
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music");
|
|
const r = mountPlayer({ watch: "v1" });
|
|
await settle();
|
|
await step(() => r.unmount());
|
|
expect(window.location.search).toBe("?q=music");
|
|
});
|
|
|
|
it("an entry that still names it after it closed is cleared once it is the current one", async () => {
|
|
// A reload under an open player: the entry the new document starts from names the player.
|
|
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&watch=v1");
|
|
const r = mountPlayer({ watch: "v1" });
|
|
await settle();
|
|
await step(() => r.unmount());
|
|
expect(search().has("watch")).toBe(false);
|
|
// Forward onto the closed player's entry: nothing is open there, so neither is it named.
|
|
await step(() => window.history.forward());
|
|
expect(search().has("watch")).toBe(false);
|
|
expect(search().get("q")).toBe("music");
|
|
});
|
|
});
|