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:
2026-10-27 13:52:42 +01:00
parent 7b4e007680
commit 0d5c6273f1
50 changed files with 928 additions and 157 deletions
+5
View File
@@ -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 {
+5
View File
@@ -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 };
}
+23 -1
View File
@@ -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
View File
@@ -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();
+16 -2
View File
@@ -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
+7 -2
View File
@@ -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,
+4
View File
@@ -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.
+9
View File
@@ -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 };
+5
View File
@@ -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"
+2 -2
View File
@@ -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();
});
+14 -14
View File
@@ -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;
+40 -2
View File
@@ -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
+33 -6
View File
@@ -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]);
}