// @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) { const holder: { unmount?: () => void } = {}; const r = renderHook( (url: Record) => 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"); }); });