fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)

- useUrlMirror: a change before the user has done anything since the load is the page settling (the
  rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload
  mark stays, and the player that reopens takes its entry over — one Back closes it, as before.
- useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the
  address names under that module's key (the card's push kept the season's `v` beside its `m`).
- Playlists: the rail's default selection and the page's "the rail moved there" read one helper.
- moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census
  walks type arguments of any depth and refuses an aliased import.
- A stale comment on the nav press over a live search.
This commit is contained in:
2026-10-28 01:39:26 +01:00
parent 99a33e7fd1
commit 5686759bca
10 changed files with 137 additions and 29 deletions
+10 -1
View File
@@ -51,7 +51,7 @@ describe("useHistorySubview seed", () => {
});
it("prefers history.state._sub over the URL param (in-app entry)", () => {
setUrl("?v=a", { _sub: "b" });
setUrl("?v=a", { _sub: "b", _subOf: "v" });
const { result } = renderHook(() => useHistorySubview("root", url));
expect(result.current.view).toBe("b");
});
@@ -161,6 +161,15 @@ describe("useHistorySubview on an entry another module's sub-view stood on", ()
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
});
it("an older build's entry whose address names both: the _sub goes to the one it is the view of", () => {
// The card's push kept the season's `v` beside its own `m`.
setUrl("?v=season&m=register", { _sub: "register" });
expect(renderHook(() => useHistorySubview("root", url)).result.current.view).toBe("season");
expect(renderHook(() => useHistorySubview("signin", other)).result.current.view).toBe(
"register",
);
});
it("reads its own from an entry it rewrote in place, which no address names", () => {
const first = renderHook(() => useHistorySubview("root"));
act(() => first.result.current.open("x", { replace: true }));
+6 -2
View File
@@ -164,12 +164,16 @@ export function useHistorySubview<T>(
// The entry's _sub is this module's only when it says so (`_subOf`): one entry can carry another
// module's — the sign-in card mounts on the entry a Plex season stood on when signed out there, and
// took that season for one of its modes (a card with no button for it). An entry an older build
// wrote says nothing: its _sub is this module's when its address names one of this module's views.
// wrote says nothing: its _sub is this module's when it is the view the address names under this
// module's key. The key alone is not enough: the card's push kept the season's `v` beside its `m`.
const readView = (): T => {
const st = window.history.state;
const raw = url ? new URLSearchParams(window.location.search).get(url.key) : null;
const decoded = url && raw != null ? url.decode(raw) : null;
const owned = st?._subOf === undefined ? decoded != null : st._subOf === subOwner(url);
const owned =
st?._subOf === undefined
? decoded != null && st?._sub != null && url?.encode(st._sub as T) === raw
: st._subOf === subOwner(url);
const fromState = owned ? (st?._sub as T | undefined) : undefined;
if (fromState != null) return fromState;
if (decoded != null) return decoded;
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// A reload under an overlay that reopens (the player) loads on the overlay's entry, its mark on it.
// A page that settles before the overlay is back (the rail steps off a playlist deleted since, before
// the player's row has loaded) has not been sent to another view: nothing the user did moved it. The
// mark stays, and the reopened overlay takes its entry over rather than pushing one more — one more
// was a Back that went nowhere. Its own file: the entry is read as the history module loads, so it is
// set up before the import.
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
describe("a page settling on the entry a reload under an overlay started from", () => {
it("before the user has done anything, leaves the entry to the overlay that reopens", async () => {
Object.defineProperty(navigator, "userActivation", {
configurable: true,
value: { hasBeenActive: false },
});
window.history.replaceState({ sfPage: "playlists" }, "", "/?page=playlists&pl=7");
window.history.pushState(
{ sfPage: "playlists", _ov: 1, _ovd: 1 },
"",
"/?page=playlists&pl=7&watch=Z",
);
const { useUrlMirror } = await import("./useUrlMirror");
const { useBackToClose } = await import("./history");
const mirror = renderHook(({ pl }) => useUrlMirror({ pl }), { initialProps: { pl: "7" } });
mirror.rerender({ pl: "1" });
expect(new URLSearchParams(window.location.search).get("pl")).toBe("1");
const depth = window.history.length;
renderHook(() => useBackToClose(() => {}));
await act(async () => {
await Promise.resolve();
});
expect(window.history.length).toBe(depth);
});
});
+10 -2
View File
@@ -13,6 +13,12 @@ import { urlWithParams } from "./urlState";
// or a drawer's, a sub-view's) is not in it, and a reload would bring the old view back over the one
// on screen. (One landed on by an overlay's step back that another step will leave is written too,
// and harmlessly: the next landing writes the entry that stays.)
// Whether a click or a key press has reached the page since it loaded. A browser without the API
// counts as yes: every change is then another view, as before it existed.
const userHasActed = (): boolean =>
(navigator as { userActivation?: { hasBeenActive: boolean } }).userActivation?.hasBeenActive ??
true;
export function useUrlMirror(updates: Record<string, string | null>): void {
// The caller typically passes an inline object literal (new identity every render); key on its
// serialized value so the effect only fires when a param actually changes, not every render.
@@ -23,11 +29,13 @@ export function useUrlMirror(updates: Record<string, string | null>): void {
// A change of what this mirror writes, after its first write, is another view on the same entry
// (replaceView: it drops the marks a reload under an overlay left). Its first write on a mount and
// its write on a landing name the view the entry already shows, so the marks stay: an overlay that
// reopens after a reload still finds its entry.
// reopens after a reload still finds its entry. So does a change before the user has done anything
// since the load: that is the page settling (the rail stepping off a playlist deleted since), and
// the address under the entry names the same screen, settled again on the way back.
const written = useRef<string | null>(null);
const mirror = useEffectEvent(() => {
const href = urlWithParams(updates);
const changed = written.current != null && written.current !== dep;
const changed = written.current != null && written.current !== dep && userHasActed();
written.current = dep;
// Every mirror runs on every landing: one with nothing to change writes nothing (a browser
// throttles a page that rewrites its address too often).