fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made under a dialog, a drawer or a sub-view is not undone by the next reload. - A view pushed from the entry a reload under a dialog started from no longer inherits its overlay mark; a reload under two overlays closes onto the page in one step. - The linked player reopens over a live search, gives itself up when its list moves first, and plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve. - The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and Playlists searches end with their page; the feed always names its view and debounces its search. - Search-box keys are declared per module and kept out of the client log below TRACE. - Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the Files sort and viewer added).
This commit is contained in:
@@ -10,6 +10,11 @@ import { LS, setTabRaw } from "./storage";
|
||||
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
||||
// The address-bar key the page mirrors its tab to.
|
||||
export const USERS_TAB_URL_KEY = "utab";
|
||||
|
||||
/** The address-bar delta for the Users tab shown. */
|
||||
export function usersTabParams(tab: string | null): Record<string, string | null> {
|
||||
return { [USERS_TAB_URL_KEY]: tab };
|
||||
}
|
||||
const ADMIN_USERS_ACCESS_TAB = "access";
|
||||
|
||||
export function focusAccessRequestsTab(): void {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// The Configuration page's address-bar key: the config group its tab rail shows. Here, beside the other
|
||||
// pages' keys, so the page's definition can declare it without importing the lazy page.
|
||||
export const CONFIG_TAB_KEY = "cfgtab";
|
||||
|
||||
/** The address-bar delta for the config group shown (null: none named). */
|
||||
export function configTabParams(tab: string | null): Record<string, string | null> {
|
||||
return { [CONFIG_TAB_KEY]: tab };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in
|
||||
// modules/files/filesContext), the file it turns to once (`?item=`), the name search (`?fq=`) and the
|
||||
// sort (`?fsort=`, always written, lib/columnSort's `none` for the list's own order). Here, beside the
|
||||
// other pages' key lists, so the page's definition can declare them without importing its own folder.
|
||||
// other pages' codecs (lib/<id>UrlState), where the URL contract test reads them all.
|
||||
export const FILES_FOLDER_KEY = "folder";
|
||||
export const FILES_ITEM_KEY = "item";
|
||||
export const FILES_SEARCH_KEY = "fq";
|
||||
@@ -21,6 +21,28 @@ export const FILES_KEYS = [
|
||||
|
||||
export type FilesOpenFile = { open: "view" | "play"; id: number };
|
||||
|
||||
/** The address-bar delta for the list (the page's store): its search, folder and the file it turns to
|
||||
* — each null when not named (`null`: the page is not what the address shows). */
|
||||
export function filesListParams(
|
||||
list: { search: string; folder: string | null; item: number | null } | null,
|
||||
): Record<string, string | null> {
|
||||
return {
|
||||
[FILES_SEARCH_KEY]: list?.search || null,
|
||||
[FILES_FOLDER_KEY]: list?.folder ?? null,
|
||||
[FILES_ITEM_KEY]: list?.item != null ? String(list.item) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The address-bar delta for the list's sort (lib/columnSort's encoding, `none` included). */
|
||||
export function filesSortParams(sort: string | null): Record<string, string | null> {
|
||||
return { [FILES_SORT_KEY]: sort };
|
||||
}
|
||||
|
||||
/** What a file open over the list names in its own entry: the viewer's, or the player's. */
|
||||
export function filesOpenParams(file: FilesOpenFile): Record<string, string | null> {
|
||||
return { [file.open === "play" ? FILES_PLAY_KEY : FILES_VIEW_KEY]: String(file.id) };
|
||||
}
|
||||
|
||||
/** The file an address opens over the list (a reload, a bookmark), or null. */
|
||||
export function openFileFrom(params: URLSearchParams): FilesOpenFile | null {
|
||||
for (const [open, key] of [
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A reload under two overlays (a dialog, or a maximised player, over the player) loads on the upper
|
||||
// one's entry; only the player reopens, and takes that entry over. The lower one's entry is still
|
||||
// under it, and nothing reopens there: closing the player steps back over it too, so one close
|
||||
// lands on the page — not on a dead step that looks like it. Its own file: the entry is read as the
|
||||
// module loads, so it is set up before the import.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
describe("an overlay reopened on the entry a reload under two started from", () => {
|
||||
it("closes onto the page under both", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 1, _ovd: 1 }, "", "/?page=feed&watch=v1");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 2, _ovd: 2 }, "", "/?page=feed&watch=v1");
|
||||
const depth = window.history.length;
|
||||
const { useBackToClose } = await import("./history");
|
||||
|
||||
const holder: { unmount?: () => void } = {};
|
||||
const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" }));
|
||||
holder.unmount = r.unmount;
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
r.unmount();
|
||||
await landed;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.state?._ov).toBeUndefined();
|
||||
expect(window.location.search).toBe("?page=feed");
|
||||
});
|
||||
|
||||
it("a pushed overlay entry records how many it stands on", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
||||
const { useBackToClose } = await import("./history");
|
||||
const r = renderHook(() => useBackToClose(() => {}));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.state?._ovd).toBe(1);
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The entry a reload under an open dialog started from carries the dialog's mark, and a dialog that
|
||||
// does not reopen (About, the cheat sheet) leaves it there. A view pushed from that entry (a channel
|
||||
// page, a search, a sub-view) must not inherit the mark: the first overlay over it took the view's
|
||||
// entry over as its own, and closing it closed the view too. Its own file: the entry is read as the
|
||||
// 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 view pushed from the entry a reload under a dialog started from", () => {
|
||||
it("is not taken over by the first overlay over it", async () => {
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
|
||||
window.history.pushState({ sfPage: "feed", _ov: 3 }, "", "/?page=feed");
|
||||
const { rememberNavPosition, useBackToClose } = await import("./history");
|
||||
|
||||
// openChannel's push, as it spreads the entry it leaves.
|
||||
const left = rememberNavPosition();
|
||||
expect(left._ov).toBeUndefined();
|
||||
window.history.pushState({ ...left, _chan: "UC1" }, "", "/?chan=UC1");
|
||||
const depth = window.history.length;
|
||||
|
||||
const holder: { unmount?: () => void } = {};
|
||||
const r = renderHook(() => useBackToClose(() => holder.unmount?.(), { watch: "v1" }));
|
||||
holder.unmount = r.unmount;
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.length).toBe(depth + 1);
|
||||
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
r.unmount();
|
||||
await landed;
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(window.history.state?._chan).toBe("UC1");
|
||||
expect(window.location.search).toBe("?chan=UC1");
|
||||
});
|
||||
});
|
||||
+56
-14
@@ -87,7 +87,10 @@ if (typeof window !== "undefined") {
|
||||
|
||||
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
|
||||
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
|
||||
* must spread this, so the new entry does not inherit a position it was never left from. */
|
||||
* must spread this, so the new entry does not inherit a position it was never left from — nor an
|
||||
* overlay's marks, which belong to the entry being left (an entry a reload under an open dialog
|
||||
* started from carries them, and the first overlay over a view pushed from it took that view's entry
|
||||
* over for its own). */
|
||||
export function rememberNavPosition(): Record<string, unknown> {
|
||||
// A restore still waiting for the last landing's lists belongs to the page being left: left
|
||||
// running, it would focus "list N, item M" of whatever page this push opens.
|
||||
@@ -95,7 +98,8 @@ export function rememberNavPosition(): Record<string, unknown> {
|
||||
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const pos = navPosition();
|
||||
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
|
||||
return st;
|
||||
const { _ov: _overlay, _ovd: _depth, ...view } = st;
|
||||
return view;
|
||||
}
|
||||
|
||||
// --- Is the entry a back() lands on still ours? --------------------------------------------
|
||||
@@ -111,12 +115,12 @@ interface NavigationLike {
|
||||
entries: () => readonly { sameDocument: boolean }[];
|
||||
}
|
||||
|
||||
/** Would one more history.back() still land in this document? */
|
||||
export function backStaysInDocument(): boolean {
|
||||
/** Would `steps` more steps back (one, a history.back()) still land in this document? */
|
||||
export function backStaysInDocument(steps = 1): boolean {
|
||||
const nav = (window as { navigation?: NavigationLike }).navigation;
|
||||
const at = nav?.currentEntry?.index;
|
||||
if (nav == null || at == null) return true;
|
||||
return nav.entries()[at - pendingProgrammatic - 1]?.sameDocument === true;
|
||||
return nav.entries()[at - pendingSteps - steps]?.sameDocument === true;
|
||||
}
|
||||
|
||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||
@@ -292,10 +296,36 @@ const nextOverlayToken = () => ++overlayCounter;
|
||||
let historyDepth = 0;
|
||||
// The entry this document was loaded on, when it was an overlay's (a reload under an open player):
|
||||
// the first overlay to open takes it over rather than pushing another over it — pushed, Back would
|
||||
// close the player onto that same page again, one dead step per reload. Until the first push or Back.
|
||||
// close the player onto that same page again, one dead step per reload. Until the first push or Back;
|
||||
// a view pushed from it does not carry its mark (rememberNavPosition). `_ovd` on an overlay's entry is how many overlay
|
||||
// entries it stood on, its own included: a reload under two (a dialog, or a maximised player, over
|
||||
// the player) leaves the lower one's entry under the one taken over, and closing the taken-over
|
||||
// overlay steps back over those too (`deadBelow`) — one Back closes it, as it opened.
|
||||
let reloadedOverlayEntry: unknown = window.history.state?._ov ?? null;
|
||||
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
|
||||
const reloadedDepth: unknown = window.history.state?._ovd;
|
||||
let deadBelow = 0;
|
||||
// Programmatic history steps back still awaiting their popstate — each must be ignored — and how
|
||||
// many entries they step over in all (a step over dead entries is one traversal of several).
|
||||
let pendingProgrammatic = 0;
|
||||
let pendingSteps = 0;
|
||||
const pendingStepCounts: number[] = [];
|
||||
function stepBack(steps: number): void {
|
||||
pendingProgrammatic++;
|
||||
pendingSteps += steps;
|
||||
pendingStepCounts.push(steps);
|
||||
if (steps === 1) window.history.back();
|
||||
else window.history.go(-steps);
|
||||
}
|
||||
function landedStepBack(): void {
|
||||
pendingProgrammatic--;
|
||||
pendingSteps -= pendingStepCounts.shift() ?? 1;
|
||||
}
|
||||
// The overlay at the bottom of the stack closing: back over its entry and any dead ones under it.
|
||||
function stepsToClose(): number {
|
||||
const steps = historyDepth === 0 ? 1 + deadBelow : 1;
|
||||
if (historyDepth === 0) deadBelow = 0;
|
||||
return steps;
|
||||
}
|
||||
let reconcileScheduled = false;
|
||||
let listenerInstalled = false;
|
||||
// Re-checked after every reconcile — see `afterClosing`.
|
||||
@@ -314,19 +344,27 @@ function reconcileHistory(): void {
|
||||
const here = window.history.state?._ov;
|
||||
if (historyDepth === 1 && here != null && here === reloadedOverlayEntry) {
|
||||
reloadedOverlayEntry = null;
|
||||
if (typeof reloadedDepth === "number" && reloadedDepth > 1) deadBelow = reloadedDepth - 1;
|
||||
continue;
|
||||
}
|
||||
reloadedOverlayEntry = null;
|
||||
window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
|
||||
window.history.pushState(
|
||||
{ ...window.history.state, _ov: nextOverlayToken(), _ovd: historyDepth + deadBelow },
|
||||
"",
|
||||
);
|
||||
}
|
||||
while (historyDepth > want) {
|
||||
historyDepth--;
|
||||
if (backStaysInDocument()) {
|
||||
pendingProgrammatic++;
|
||||
window.history.back();
|
||||
} else {
|
||||
const steps = stepsToClose();
|
||||
if (backStaysInDocument(steps)) stepBack(steps);
|
||||
else if (steps > 1 && backStaysInDocument()) stepBack(1);
|
||||
else {
|
||||
// Nothing of ours below to step back to (see backStaysInDocument): the entry stays, unmarked.
|
||||
const { _ov: _drop, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const {
|
||||
_ov: _drop,
|
||||
_ovd: _depth,
|
||||
...st
|
||||
} = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
window.history.replaceState(st, "");
|
||||
}
|
||||
}
|
||||
@@ -368,7 +406,7 @@ function ensureOverlayListener(): void {
|
||||
window.addEventListener("popstate", () => {
|
||||
reloadedOverlayEntry = null;
|
||||
if (pendingProgrammatic > 0) {
|
||||
pendingProgrammatic--; // our own back(): consume and ignore
|
||||
landedStepBack(); // 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();
|
||||
else nameOverlaysHere();
|
||||
@@ -376,7 +414,11 @@ function ensureOverlayListener(): void {
|
||||
}
|
||||
// Genuine user Back: the browser already dropped one overlay entry, so close the topmost
|
||||
// overlay to match. Its unmount cleanup finds itself already off the stack and only re-syncs.
|
||||
// The last one closing over dead entries (see `deadBelow`) steps over them too.
|
||||
if (historyDepth > 0) historyDepth--;
|
||||
const dead = historyDepth === 0 ? deadBelow : 0;
|
||||
if (historyDepth === 0) deadBelow = 0;
|
||||
if (dead > 0 && backStaysInDocument(dead)) stepBack(dead);
|
||||
const top = overlayStack.pop();
|
||||
if (top) top.cb();
|
||||
scheduleReconcile();
|
||||
|
||||
@@ -10,9 +10,12 @@ import { installClientLog } from "./logHooks";
|
||||
const appCopy = (text: string | null) =>
|
||||
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
|
||||
|
||||
// The pages' search boxes' keys, as main.tsx hands them over from the module definitions.
|
||||
const TYPED = ["fq", "yt"];
|
||||
|
||||
beforeAll(() => {
|
||||
installClientLog(appCopy);
|
||||
installClientLog(appCopy); // a hot reload's second call must not wrap History twice
|
||||
installClientLog(appCopy, TYPED);
|
||||
installClientLog(appCopy, TYPED); // a hot reload's second call must not wrap History twice
|
||||
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
|
||||
clear();
|
||||
});
|
||||
@@ -56,6 +59,17 @@ describe("client log hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a page's own search box out below TRACE too", () => {
|
||||
setLevel("DEBUG");
|
||||
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words&yt=lofi");
|
||||
expect(views()[0]!.attrs!.values).toEqual({ folder: "all" });
|
||||
expect(JSON.stringify(views())).not.toContain("private");
|
||||
|
||||
setLevel("TRACE");
|
||||
window.history.pushState({ sfPage: "files" }, "", "/?folder=all&fq=private+words");
|
||||
expect(views()[1]!.attrs!.values).toEqual({ folder: "all", fq: "private words" });
|
||||
});
|
||||
|
||||
it("records an overlay opening as a view of its own, and Back as a pop", () => {
|
||||
window.history.pushState({ sfPage: "feed", _ov: 1 }, "");
|
||||
// What Back does: the browser restores the previous entry's state (the unwrapped prototype
|
||||
|
||||
@@ -14,7 +14,8 @@ import { enabled, errorAttrs, log } from "./log";
|
||||
import { pathTemplate } from "./http";
|
||||
|
||||
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
|
||||
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG.
|
||||
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG. The
|
||||
// shell's own here; each page's search boxes join them at install (ModuleDef.typedUrlKeys).
|
||||
const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
|
||||
|
||||
type Via = "start" | "push" | "replace" | "pop";
|
||||
@@ -168,9 +169,13 @@ let installed = false;
|
||||
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
|
||||
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
|
||||
* module stays free of the i18n runtime. */
|
||||
export function installClientLog(appCopy: AppCopy = () => null): void {
|
||||
export function installClientLog(
|
||||
appCopy: AppCopy = () => null,
|
||||
typedParams: readonly string[] = [],
|
||||
): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
for (const k of typedParams) TYPED_PARAMS.add(k);
|
||||
|
||||
log.info("app.start", "app loaded", {
|
||||
version: FRONTEND_VERSION,
|
||||
|
||||
@@ -50,6 +50,10 @@ export type ModuleDef = {
|
||||
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
|
||||
// one module and none is a shell key (moduleUrl.contract.test).
|
||||
urlKeys: readonly string[];
|
||||
// Those of them that carry what the user TYPED (a search box's text): the client log keeps their
|
||||
// values out below its most detailed level, as it does the shell's own (lib/logHooks). A new search
|
||||
// box's key is declared here with it (moduleUrl.contract.test holds them to urlKeys).
|
||||
typedUrlKeys?: readonly string[];
|
||||
// Its page plays YouTube videos in the player and reopens it from an address that names one (a
|
||||
// reload, a bookmark — lib/playerUrlState), with its own list as the queue. A page without it gets
|
||||
// the shell's player for such an address: the video alone.
|
||||
|
||||
@@ -20,6 +20,15 @@ export function isStoredSort(s: SortState): boolean {
|
||||
return s?.key === PLAYLIST_STORED_SORT?.key && s?.dir === PLAYLIST_STORED_SORT?.dir;
|
||||
}
|
||||
|
||||
/** The address-bar delta for the page's store: the playlist shown and the rail's search (each null
|
||||
* when not named). */
|
||||
export function playlistsParams(
|
||||
selected: number | null,
|
||||
search: string,
|
||||
): Record<string, string | null> {
|
||||
return { pl: selected != null ? String(selected) : null, plq: search || null };
|
||||
}
|
||||
|
||||
/** The address-bar delta for an open playlist's view; `null` clears it (no playlist open). */
|
||||
export function playlistViewParamUpdates(v: PlaylistView | null): Record<string, string | null> {
|
||||
if (!v) return { plsort: null, plf: null, plgroup: null };
|
||||
|
||||
@@ -6,6 +6,11 @@ import { LS, setTabRaw } from "./storage";
|
||||
// address-bar key the page mirrors its tab to is here too, beside the other pages' keys.
|
||||
export const SETTINGS_TAB_KEY = "stab";
|
||||
|
||||
/** The address-bar delta for the Settings tab shown. */
|
||||
export function settingsTabParams(tab: string | null): Record<string, string | null> {
|
||||
return { [SETTINGS_TAB_KEY]: tab };
|
||||
}
|
||||
|
||||
export type SettingsTabId =
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
|
||||
@@ -127,13 +127,13 @@ describe("a page opened at a path that starts with //", () => {
|
||||
});
|
||||
|
||||
describe("filterParamUpdates", () => {
|
||||
it("maps non-default filters to values and defaults to null (delete)", () => {
|
||||
it("maps non-default filters to values and other defaults to null (delete)", () => {
|
||||
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
|
||||
const upd = filterParamUpdates(f);
|
||||
expect(upd.q).toBe("react");
|
||||
expect(upd.tags).toBe("3,7");
|
||||
expect(upd.show).toBe("watched");
|
||||
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared
|
||||
expect(upd.sort).toBe("newest"); // the view and the order are always named, defaults too
|
||||
expect(upd.scope).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -80,13 +80,19 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
}
|
||||
|
||||
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
|
||||
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
|
||||
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
|
||||
* Feed the result into urlWithParams(). */
|
||||
* value, or null to delete it (so switching a filter back to its default clears the key) — except the
|
||||
* view and the order, always written (defaults too): a feed address then always names its view, so a
|
||||
* bookmark of the default feed opens the default feed, not whatever view the account stored since
|
||||
* (an address naming none falls back to it). Pass `null` to clear ALL filter keys — used when leaving
|
||||
* the feed, where the localStorage copy still holds them. Feed the result into urlWithParams(). */
|
||||
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
|
||||
const p = f ? filtersToParams(f) : new URLSearchParams();
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const k of KEYS) out[k] = p.get(k);
|
||||
if (f) {
|
||||
out.show = f.show || "unwatched";
|
||||
out.sort = f.sort || "newest";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -233,13 +239,6 @@ export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
return hrefHere(params);
|
||||
}
|
||||
|
||||
/** Take `keys` off the current entry's address: what a link named and the page could not show (a file
|
||||
* or a video that is gone, said once) must not stay to be said again on every reload, or shared. */
|
||||
export function dropFromAddress(keys: readonly string[]): void {
|
||||
const updates = Object.fromEntries(keys.map((k) => [k, null]));
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
}
|
||||
|
||||
/** The channel page's address, opened over the page on screen: the shell's own keys (the page, an
|
||||
* entry link not yet read) and the channel — none of the page's keys, none of another channel's view
|
||||
* (the channel page writes its own, lib/channelPageUrlState), and no player (opening a channel closes
|
||||
@@ -269,10 +268,11 @@ export function hrefHere(query: URLSearchParams, hash: string = window.location.
|
||||
return url.pathname.startsWith("//") ? url.href : `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
|
||||
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
|
||||
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
|
||||
/** Take `keys` off the current entry's address, WITHOUT touching the nav/filter params that live
|
||||
* there: TRANSIENT params once read (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||
* ?reset), and what a link named that the page could not show (a file or a video that is gone, said
|
||||
* once) — left, it would be said again on every reload, or shared. Preserves history.state. No-op when
|
||||
* none of the keys are present. */
|
||||
export function clearParams(...keys: string[]): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let changed = false;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// so an inline object literal (new identity every render) only fires when a param actually changes; a
|
||||
// null value clears a key; and every other param on the URL is preserved.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { useUrlMirror } from "./useUrlMirror";
|
||||
|
||||
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
||||
@@ -15,7 +15,10 @@ beforeEach(() => {
|
||||
setUrl("/");
|
||||
spy = vi.spyOn(window.history, "replaceState");
|
||||
});
|
||||
afterEach(() => spy.mockRestore());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
describe("useUrlMirror", () => {
|
||||
it("writes the param onto the current entry on mount", () => {
|
||||
@@ -49,6 +52,41 @@ describe("useUrlMirror", () => {
|
||||
expect(window.location.search).toBe("?page=playlists&pl=5");
|
||||
});
|
||||
|
||||
// An entry above the view's own (a dialog's, a drawer's, a sub-view's) took a change the entry
|
||||
// under it never saw: Back restores that entry's older address, and a reload would bring the old
|
||||
// view back over the one on screen.
|
||||
it("names the view again on the entry Back lands on", async () => {
|
||||
setUrl("?page=plex&pf=");
|
||||
const { rerender } = renderHook((p: Record<string, string | null>) => useUrlMirror(p), {
|
||||
initialProps: { pf: "" } as Record<string, string | null>,
|
||||
});
|
||||
window.history.pushState({ _ov: 1 }, "", window.location.href);
|
||||
rerender({ pf: "genres=Drama" });
|
||||
expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama");
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
window.history.back();
|
||||
await landed;
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
expect(window.history.state).toBeNull();
|
||||
expect(window.location.search).toBe("?page=plex&pf=genres%3DDrama");
|
||||
});
|
||||
|
||||
it("writes nothing on a landing that already names the view", async () => {
|
||||
setUrl("?pl=5");
|
||||
renderHook(() => useUrlMirror({ pl: "5" }));
|
||||
window.history.pushState({ _ov: 1 }, "", window.location.href);
|
||||
spy.mockClear();
|
||||
await act(async () => {
|
||||
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||
window.history.back();
|
||||
await landed;
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves an unconsumed auth token in the fragment alone", () => {
|
||||
// This hook's mount effect is what ran before <App> (and therefore before the auth card) had even
|
||||
// loaded, so whatever it writes to the address bar is what the card later reads. It must not eat
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
import { useEffect, useEffectEvent, useState } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||
@@ -6,17 +6,44 @@ import { urlWithParams } from "./urlState";
|
||||
// value clears that key, and urlWithParams preserves every other param (so providers don't clobber each
|
||||
// other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and
|
||||
// PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place.
|
||||
//
|
||||
// Also whenever Back/Forward makes another entry current: the browser restores that entry's address,
|
||||
// which says what the view was when it was left — a change made since on an entry above it (a dialog's
|
||||
// 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.)
|
||||
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.
|
||||
const dep = JSON.stringify(updates);
|
||||
// The object itself is read through an effect event, so the effect's one reactive input is the
|
||||
// serialized value — which is exactly what the dep list says: an effect event for the identity that
|
||||
// churns, a primitive for the trigger.
|
||||
// The object itself is read through an effect event, so the effect's reactive inputs are the
|
||||
// serialized value and the landing count — which is exactly what the dep list says: an effect event
|
||||
// for the identity that churns, primitives for the triggers.
|
||||
const mirror = useEffectEvent(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
const href = urlWithParams(updates);
|
||||
// Every mirror runs on every landing: one with nothing to change writes nothing (a browser
|
||||
// throttles a page that rewrites its address too often).
|
||||
if (href !== urlWithParams({})) window.history.replaceState(window.history.state, "", href);
|
||||
});
|
||||
// Counted in state, on a task after the popstate dispatch: every listener of it has run by then —
|
||||
// a store that reads its keys back from the landed address among them (Activity) — and so has the
|
||||
// render each one caused (React renders a popstate's updates at once, between listeners). A write
|
||||
// inside the dispatch put the store's old value over the address a later listener was still to
|
||||
// read.
|
||||
const [landings, setLandings] = useState(0);
|
||||
useEffect(() => {
|
||||
let timer = 0;
|
||||
const onPop = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => setLandings((n) => n + 1), 0);
|
||||
};
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPop);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mirror();
|
||||
}, [dep]);
|
||||
}, [dep, landings]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user