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:
2026-10-25 20:48:14 +01:00
parent 4aff19a9a4
commit b30c60d1a5
24 changed files with 246 additions and 28 deletions
+2
View File
@@ -1,6 +1,7 @@
import { ScrollText } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const activityModule: ModuleDef = {
@@ -12,4 +13,5 @@ export const activityModule: ModuleDef = {
system: true,
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Activity")),
urlKeys: ACTIVITY_KEYS,
};
+2
View File
@@ -1,6 +1,7 @@
import { Tv } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { CHANNELS_KEYS } from "../../lib/channelsUrlState";
import { ChannelsProvider } from "./ChannelsProvider";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
@@ -13,4 +14,5 @@ export const channelsModule: ModuleDef = {
Page: lazyWithPreload(() => import("./Channels")),
maxWidth: "96rem",
Store: ChannelsProvider,
urlKeys: CHANNELS_KEYS,
};
+1
View File
@@ -12,4 +12,5 @@ export const configModule: ModuleDef = {
system: true,
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./ConfigPanel")),
urlKeys: [],
};
+2
View File
@@ -1,6 +1,7 @@
import { YouTubeIcon } from "../../lib/brandIcons";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { FEED_KEYS } from "../../lib/urlState";
import { FeedStore } from "./FeedStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -15,6 +16,7 @@ export const feedModule: ModuleDef = {
Rail,
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore,
urlKeys: FEED_KEYS,
};
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
+5 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import {
ALL_FILES,
@@ -33,14 +34,14 @@ import {
function initialView(): FilesView {
return (
parseView(new URLSearchParams(window.location.search).get("folder")) ??
parseView(new URLSearchParams(window.location.search).get(FILES_FOLDER_KEY)) ??
parseView(getAccountRaw(LS.filesView)) ??
ALL_FILES
);
}
function initialFocus(): FilesFocus | null {
const id = parseItem(new URLSearchParams(window.location.search).get("item"));
const id = parseItem(new URLSearchParams(window.location.search).get(FILES_ITEM_KEY));
return id === null ? null : { id, token: 1 };
}
@@ -87,8 +88,8 @@ export function FilesProvider({ children }: { children: ReactNode }) {
const arrived = page === "files" ? arrivedRaw : null;
useUrlMirror({
folder: page === "files" ? viewParam(view) : null,
item: focus ? String(focus.id) : null,
[FILES_FOLDER_KEY]: page === "files" ? viewParam(view) : null,
[FILES_ITEM_KEY]: focus ? String(focus.id) : null,
});
const state = useMemo<FilesState>(
+2
View File
@@ -1,6 +1,7 @@
import { FolderOpen } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { FILES_KEYS } from "../../lib/filesUrlState";
import { FilesProvider } from "./FilesProvider";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -16,4 +17,5 @@ export const filesModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
maxWidth: "80rem",
Store: FilesProvider,
urlKeys: FILES_KEYS,
};
+3
View File
@@ -11,4 +11,7 @@ export const homeModule: ModuleDef = {
icon: Home,
Page: lazyWithPreload(() => import("./Home")),
maxWidth: "80rem",
// Nothing of its own: the question is an answer, not a setting — the same question draws other
// cards once what you have watched moves on.
urlKeys: [],
};
@@ -11,4 +11,5 @@ export const notificationsModule: ModuleDef = {
hasBadge: true,
Page: lazyWithPreload(() => import("./NotificationsPanel")),
maxWidth: "48rem",
urlKeys: [],
};
+2
View File
@@ -16,4 +16,6 @@ export const playlistsModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
maxWidth: "64rem",
Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: ["pl"],
};
+2
View File
@@ -1,6 +1,7 @@
import { Clapperboard } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef";
import { PLEX_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState";
import { PlexStore } from "./PlexStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -17,4 +18,5 @@ export const plexModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
maxWidth: "100rem",
Store: PlexStore,
urlKeys: [...PLEX_KEYS, PLEX_SUB_KEY],
};
+1
View File
@@ -12,4 +12,5 @@ export const schedulerModule: ModuleDef = {
system: true,
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Scheduler")),
urlKeys: [],
};
+1
View File
@@ -10,4 +10,5 @@ export const settingsModule: ModuleDef = {
titleKey: "settings.title",
icon: Settings,
Page: lazyWithPreload(() => import("./SettingsPanel")),
urlKeys: [],
};
+1
View File
@@ -10,4 +10,5 @@ export const statsModule: ModuleDef = {
titleKey: "header.usageStats",
icon: BarChart3,
Page: lazyWithPreload(() => import("./Stats")),
urlKeys: [],
};
+1
View File
@@ -13,4 +13,5 @@ export const usersModule: ModuleDef = {
gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./AdminUsers")),
maxWidth: "48rem",
urlKeys: [],
};