fix(nav): a sub-view step closes the overlays standing over it first

Leaving the Plex player while one of its layers was up (the info panel when
the film ended, the countdown under the back arrow) landed as a user Back:
the overlay listener spent it closing the top layer and the player stayed.
useHistorySubview's open/back now go through closeOverlaysThenNavigate,
like setPage. The RECLOSE comment no longer cites the shared Plex entry.
This commit is contained in:
2026-10-08 01:16:30 +02:00
parent d1704a5519
commit 7f17f488ab
3 changed files with 52 additions and 14 deletions
+3 -3
View File
@@ -177,9 +177,9 @@ describe("closeOverlaysThenNavigate", () => {
} }
}); });
// An overlay's Back callback is a Back STEP: the Plex player keeps its panels on one entry and peels // An overlay's Back callback is a Back STEP, and the contract lets one entry hold several. When the
// one per call. Called once, the entry stayed and the navigation was dropped 1.5 s later — `i`, `d`, // Plex player kept its panels on one entry, a single call left the entry up and the navigation was
// Shift+W in the player closed the info panel and nothing else. // dropped 1.5 s later — `i`, `d`, Shift+W in the player closed the info panel and nothing else.
it("asks again, a render apart, when one call peels only a step off the overlay", async () => { it("asks again, a render apart, when one call peels only a step off the overlay", async () => {
const back = stubBack(); const back = stubBack();
try { try {
+27 -2
View File
@@ -7,8 +7,8 @@
// an overlay's programmatic history.back()) restores the sub-view from the URL instead of collapsing // 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. // 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 { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react"; import { renderHook, act, waitFor } from "@testing-library/react";
import { useHistorySubview, type SubviewUrl } from "./history"; import { useBackToClose, useHistorySubview, type SubviewUrl } from "./history";
// A minimal string-view mirror: "root" is the un-mirrored root (encode → null), anything else mirrors // 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). // verbatim to ?v=<view>. decode rejects the empty string (→ null, i.e. not a valid sub-view).
@@ -207,6 +207,31 @@ describe("useHistorySubview back", () => {
expect(back).toHaveBeenCalledTimes(1); expect(back).toHaveBeenCalledTimes(1);
}); });
// A sub-view left while an overlay of its own is still up — the Plex player's stats readout, a
// panel, the shortcuts sheet: the step back used to land as a USER Back, so the overlay listener
// spent it closing the top overlay and the sub-view stayed where it was.
it("closes the overlays standing over it first, then steps back out of the sub-view", async () => {
const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("player"));
const closed = vi.fn();
const holder: { unmount?: () => void } = {};
const layer = renderHook(() =>
useBackToClose(() => {
closed();
holder.unmount?.();
}),
);
holder.unmount = layer.unmount;
await act(async () => {
await Promise.resolve();
});
expect(window.history.state?._ov).toBeDefined();
act(() => result.current.back());
await waitFor(() => expect(result.current.view).toBe("root"));
expect(closed).toHaveBeenCalledTimes(1);
expect(window.history.state?._ov).toBeUndefined();
});
it("returns to the root in place when the entry below is another document's", () => { it("returns to the root in place when the entry below is another document's", () => {
const { result } = renderHook(() => useHistorySubview("root", url)); const { result } = renderHook(() => useHistorySubview("root", url));
act(() => result.current.open("a")); act(() => result.current.open("a"));
+22 -9
View File
@@ -174,7 +174,12 @@ export function useHistorySubview<T>(
// re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a // re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a
// module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only // module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only
// module functions and the setter, so there is nothing left to churn on. // module functions and the setter, so there is nothing left to churn on.
const open = useCallback( // Both move the module to another view, so both are navigations: they close the overlays standing
// over it first (`closeOverlaysThenNavigate`, synchronous when none is open). A step taken under an
// open overlay landed as a USER Back — the overlay listener spent it closing the top overlay, so
// leaving the Plex player with its stats readout up only closed the readout — and a push or a
// replace on top of an overlay's entry is undone when that entry is reclaimed.
const go = useCallback(
(next: T, opts?: { replace?: boolean }) => { (next: T, opts?: { replace?: boolean }) => {
setView(next); setView(next);
const kept = url?.entry ? url.entry(next) : next; const kept = url?.entry ? url.entry(next) : next;
@@ -198,13 +203,21 @@ export function useHistorySubview<T>(
}, },
[url], [url],
); );
const open = useCallback(
(next: T, opts?: { replace?: boolean }) => closeOverlaysThenNavigate(() => go(next, opts)),
[go],
);
// With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes // With no entry of this document below (see backStaysInDocument), the sub-view's own entry becomes
// the root view in place — a step up the module, not out of the app. // the root view in place — a step up the module, not out of the app.
const rootRef = useLatest(root); const rootRef = useLatest(root);
const back = useCallback(() => { const back = useCallback(
if (backStaysInDocument()) window.history.back(); () =>
else open(rootRef.current, { replace: true }); closeOverlaysThenNavigate(() => {
}, [open, rootRef]); if (backStaysInDocument()) window.history.back();
else go(rootRef.current, { replace: true });
}),
[go, rootRef],
);
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves" // Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
// can now write `[sub]` and mean it, instead of re-running on every parent render. // can now write `[sub]` and mean it, instead of re-running on every parent render.
return useMemo(() => ({ view, open, back }), [view, open, back]); return useMemo(() => ({ view, open, back }), [view, open, back]);
@@ -302,10 +315,10 @@ function historyInSync(): boolean {
// render (measured back()→popstate: 1–13 ms in Chrome); nothing in the app refuses a close today, // render (measured back()→popstate: 1–13 ms in Chrome); nothing in the app refuses a close today,
// but a future "discard your changes?" guard must not be navigated out from under. // but a future "discard your changes?" guard must not be navigated out from under.
const CLOSE_TIMEOUT_MS = 1500; const CLOSE_TIMEOUT_MS = 1500;
// An overlay's Back callback is a Back STEP, and one entry can hold several steps: the Plex player's // An overlay's Back callback is a Back STEP: the contract lets one entry hold several, peeled one per
// panels share a single entry and Back peels one per press. Navigating over it asks again, a render // call. Every overlay today closes in one call (the Plex player gives each layer its own entry), but a
// apart (the callback reads what the last one closed), until the entry is gone or the timeout drops // navigation must not be dropped by one that does not, so it asks again, a render apart (the callback
// the command. // reads what the last one closed), until the entry is gone or the timeout drops the command.
const RECLOSE_MS = 50; const RECLOSE_MS = 50;
/** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up. /** Close `targets` (via `close`), then `run` once they have unmounted AND the history has caught up.