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:
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user