feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's
The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared key literal in the app.
This commit is contained in:
@@ -167,6 +167,19 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
expect(window.location.search).toBe("?page=plex&keep=1");
|
||||
});
|
||||
|
||||
it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => {
|
||||
// Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key
|
||||
// missing from a hand-kept list rode along onto the next page's entry.
|
||||
window.history.replaceState(
|
||||
{ sfPage: "files" },
|
||||
"",
|
||||
"/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1",
|
||||
);
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(window.location.search).toBe("?page=plex&keep=1");
|
||||
});
|
||||
|
||||
it("pushes nothing when it is already that page", () => {
|
||||
const { result } = render();
|
||||
act(() => result.current.setPage("plex"));
|
||||
@@ -195,6 +208,25 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · a link carrying another page's keys", () => {
|
||||
it("opens with only its own page's keys: what the page does not show, its address does not say", () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1",
|
||||
);
|
||||
render();
|
||||
expect(window.location.search).toBe("?page=activity&at=logs&keep=1");
|
||||
});
|
||||
|
||||
it("keeps the channel page open over any page", () => {
|
||||
window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all");
|
||||
const { result } = render();
|
||||
expect(window.location.search).toBe("?page=stats&chan=UC1");
|
||||
expect(result.current.channelView?.id).toBe("UC1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · Back", () => {
|
||||
// A tab that had a since-removed module open, reloaded onto the new build: its older history
|
||||
// entries still name that page, and Back lands on one of them.
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLatest } from "../../lib/useLatest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
|
||||
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
||||
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
||||
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||
import {
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
notifyNav,
|
||||
rememberNavPosition,
|
||||
} from "../../lib/history";
|
||||
import { pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||
import {
|
||||
channelViewFrom,
|
||||
loadInitialPage,
|
||||
@@ -37,21 +36,13 @@ import {
|
||||
//
|
||||
// The contexts, their value types and the hooks live in navigationContext.ts.
|
||||
|
||||
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
|
||||
/** The address bar of `page`'s root: the page (feed implicit) with every module's own keys cleared — a
|
||||
* page switch resets to that module's root, and its store mirrors what it holds back in. */
|
||||
function pageRootParams(page: Page): Record<string, string | null> {
|
||||
return {
|
||||
page: page === "feed" ? null : page,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
||||
...Object.fromEntries(moduleUrlKeys().map((k) => [k, null])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,12 +107,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
||||
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
||||
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
||||
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
|
||||
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
|
||||
// their page. The item it was opened from is still focused: the state setters above have not
|
||||
// re-rendered anything yet.
|
||||
// root, and the address bar reflects the page (feed is implicit) while clearing `chan` and
|
||||
// every module's own keys (pageRootParams) — a page switch resets to that module's root, and
|
||||
// each module's store mirrors its state back in on its page. The item it was opened from is
|
||||
// still focused: the state setters above have not re-rendered anything yet.
|
||||
rememberNavPosition();
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
@@ -244,7 +233,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// part of the page that replaced it (an `at` tab).
|
||||
const linked = new URLSearchParams(window.location.search).get("page");
|
||||
const remembered = getAccountRaw(PAGE_KEY);
|
||||
// A link can carry another page's keys (hand-edited, or written by an older build): the page
|
||||
// opens without them, so its address names only what it shows.
|
||||
const url = urlWithParams({
|
||||
...Object.fromEntries(foreignUrlKeys(mountPage).map((k) => [k, null])),
|
||||
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
|
||||
page: mountPage === "feed" ? null : mountPage,
|
||||
});
|
||||
|
||||
@@ -48,6 +48,17 @@ export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id);
|
||||
|
||||
const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
|
||||
|
||||
// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and
|
||||
// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys
|
||||
// are cleared the day it declares them.
|
||||
const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
|
||||
export function moduleUrlKeys(): readonly string[] {
|
||||
return ALL_URL_KEYS;
|
||||
}
|
||||
export function foreignUrlKeys(page: Page): readonly string[] {
|
||||
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
|
||||
}
|
||||
|
||||
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
||||
export function moduleDef(id: Page): ModuleDef {
|
||||
return BY_ID[id];
|
||||
|
||||
Reference in New Issue
Block a user