diff --git a/frontend/src/lib/history.dom.test.ts b/frontend/src/lib/history.dom.test.ts index 9a5ab3af..87dd6ed2 100644 --- a/frontend/src/lib/history.dom.test.ts +++ b/frontend/src/lib/history.dom.test.ts @@ -236,6 +236,53 @@ describe("closeOverlaysThenNavigate", () => { } }); + // The Plex player's ⋯ → Settings: the menu closes (its entry goes back) and the panel opens a task + // later, before that back() has landed. A push made then is the entry the landing back() takes, so + // the books said one entry stood that did not — and the panel's own close stepped back over the + // player's entry, closing the player on a click outside the panel. + it("an overlay opened while a back() is in flight pushes its entry only once that back() lands", async () => { + const landing: (() => void)[] = []; + const back = vi.spyOn(window.history, "back").mockImplementation(() => { + landing.push(() => + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })), + ); + }); + const land = () => act(() => landing.splice(0).forEach((l) => l())); + const push = vi.spyOn(window.history, "pushState"); + let panel: { unmount: () => void } | undefined; + try { + const menu = mountLayer(() => {}); + await settle(); + menu.unmount(); + await settle(); + expect(back).toHaveBeenCalledTimes(1); + + push.mockClear(); + panel = mountLayer(() => {}); + await tick(); + expect(push).not.toHaveBeenCalled(); // it would be undone by the landing back() + + land(); + await settle(); + expect(push).toHaveBeenCalledTimes(1); + + // The books are square: closing the panel reclaims its own entry, one back() and no more. + back.mockClear(); + panel.unmount(); + panel = undefined; + await settle(); + expect(back).toHaveBeenCalledTimes(1); + } finally { + // Square the module-global books for the next test, however far this one got. + panel?.unmount(); + await settle(); + land(); + await settle(); + back.mockRestore(); + push.mockRestore(); + } + }); + // No such overlay exists today, but a future "discard your changes?" guard must not have the page // switched out from under it: dropping the command is the honest outcome. it("drops the command when an overlay does not actually close", async () => { diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 35ff60d7..7ece3984 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -261,10 +261,14 @@ let listenerInstalled = false; const syncWaiters = new Set<() => void>(); // Push/pop history entries until their count matches the live overlay count. Runs once per tick. +// A push waits while a back() of ours is still in flight: it would land on the entry just pushed and +// take it, leaving the books one entry above the history — and the next close then stepped back over +// the entry BELOW, the player's own (the Plex player's ⋯ → Settings, then a click outside the panel, +// closed the player). The popstate that lands the last back() reconciles again. function reconcileHistory(): void { reconcileScheduled = false; const want = overlayStack.length; - while (historyDepth < want) { + while (historyDepth < want && pendingProgrammatic === 0) { historyDepth++; window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, ""); } @@ -294,6 +298,8 @@ function ensureOverlayListener(): void { window.addEventListener("popstate", () => { if (pendingProgrammatic > 0) { pendingProgrammatic--; // our own back(): consume and ignore + // The last one landed: an overlay that opened while it was in flight still owes its entry. + if (pendingProgrammatic === 0 && historyDepth < overlayStack.length) scheduleReconcile(); return; } // Genuine user Back: the browser already dropped one overlay entry, so close the topmost