fix(history): an overlay opened while a back() is in flight waits to push its entry

The Plex player's ⋯ → Settings closed the menu (its entry went back) and opened the panel a task
later, before that back() had landed. The panel's push was the entry the landing back() took, so
the books counted one entry more than the history held, and closing the panel by a click outside
it stepped back over the player's own entry: the player closed.

A push now waits while a back() of ours is in flight; the popstate that lands the last one
reconciles again.
This commit is contained in:
2026-10-14 19:43:51 +02:00
parent c335d54929
commit 759a7536b9
2 changed files with 54 additions and 1 deletions
+47
View File
@@ -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 () => {
+7 -1
View File
@@ -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