refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state

A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for
its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag.
Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width —
handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for
the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds
the registry to the page ids and each definition to its own folder, with no static import of a page.

Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and
shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot
428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag
(lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each
visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell
hooks; App is layout. The registry's three layer suppressions are gone: none is left.
This commit is contained in:
2026-10-23 04:00:58 +02:00
parent e83c5e2d97
commit cde5a98fd2
56 changed files with 890 additions and 685 deletions
@@ -16,7 +16,7 @@ import {
type DraftStep,
type IssueText,
} from "../../lib/issueDraft";
import { moduleDef } from "../../lib/modules";
import { moduleDef } from "../navigation/moduleRegistry";
import type { Page } from "../../lib/urlState";
import { qk } from "../../lib/queryKeys";
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
@@ -4,7 +4,6 @@ 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 { pageTitleKey } from "../../lib/pageMeta";
import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import {
@@ -14,7 +13,7 @@ import {
notifyNav,
rememberNavPosition,
} from "../../lib/history";
import { reachablePage } from "../../lib/modules";
import { pageTitleKey, reachablePage } from "./moduleRegistry";
import {
channelViewFrom,
loadInitialPage,
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
//
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { PAGES } from "../../lib/urlState";
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
// The registry is the one place a page is listed, so it has to hold every page id the address bar
// accepts and nothing else: a page id with no definition would throw in the nav rail at the first
// render, and a definition with no id could never be navigated to.
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
describe("the module registry", () => {
it("defines every page id exactly once", () => {
expect([...REGISTERED_PAGES].sort()).toEqual([...PAGES].sort());
expect(new Set(REGISTERED_PAGES).size).toBe(REGISTERED_PAGES.length);
});
it.each([...PAGES])("takes %s's definition from that module's own folder", (page) => {
// The definition's id names its page, and it lives at modules/<page>/module.ts — so a page is one
// folder, and a copied definition with a stale id cannot pass as another page's.
expect(moduleDef(page).id).toBe(page);
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
expect(source).toContain(`id: "${page}",`);
});
it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => {
// A static import of the page in its definition would put it on the boot path for every
// account; the chunk guard fails the build on that too, this says which module did it.
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
expect(source).not.toMatch(/^import [^;]* from "\.\//m);
});
});
@@ -0,0 +1,99 @@
import type { Me } from "../../lib/api";
import type { Page } from "../../lib/urlState";
import type { ModuleDef } from "../../lib/moduleDef";
import { homeModule } from "../../modules/home";
import { feedModule } from "../../modules/feed";
import { channelsModule } from "../../modules/channels";
import { playlistsModule } from "../../modules/playlists";
import { plexModule } from "../../modules/plex";
import { notificationsModule } from "../../modules/notifications";
import { filesModule } from "../../modules/files";
import { statsModule } from "../../modules/stats";
import { schedulerModule } from "../../modules/scheduler";
import { configModule } from "../../modules/config";
import { usersModule } from "../../modules/users";
import { activityModule } from "../../modules/activity";
import { settingsModule } from "../../modules/settings";
// ── The module registry ───────────────────────────────────────────────────────────────────────
// Every page, as each module defines itself (its folder's module.ts), in one ordered list: the nav
// rail, the palette, the `g` chords, the tab title, the reachable set and the shell's render all read
// from here, so a new or newly gated module shows up everywhere at once. Each definition's page and
// rail are lazy imports, so listing them here pulls no page into the boot chunk (chunkGuard holds it).
//
// Ordered in nav-rail order — this array IS the sequence the rail and the palette list. Home leads: it
// is the landing (see navigationInit.loadInitialPage). The feed takes the YouTube mark its own toolbar
// already uses, so it and Home sit apart by shape and not just by position.
const MODULES: readonly ModuleDef[] = [
homeModule,
feedModule,
channelsModule,
playlistsModule,
plexModule,
notificationsModule,
filesModule,
statsModule,
schedulerModule,
configModule,
usersModule,
activityModule,
];
// Settings is reachable (its own rail button, a `g` chord) but is not a primary module in the ordered
// nav list, so it lives beside the array rather than in it.
const ALL: readonly ModuleDef[] = [...MODULES, settingsModule];
// Every Page has a definition: the registry contract test pins this list against lib/urlState's ids.
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>;
// 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];
}
// The i18n key for each module's SHORT display name, and its rail label.
export const moduleLabelKey = Object.fromEntries(ALL.map((m) => [m.id, m.labelKey])) as Record<
Page,
string
>;
export const moduleRailKey = Object.fromEntries(ALL.map((m) => [m.id, m.railKey])) as Record<
Page,
string
>;
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);
// The ordered list of primary module pages available to THIS user, in nav-rail order. Availability
// is derived from the account via each module's `gate`, so the reachable set changes with the
// account state, not the UI.
export function moduleOrder(me: Me): Page[] {
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
}
/** The page this account is shown for `page`: itself when its gate lets the account in, else the
* feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed
* out, no page renders, so nothing is moved. */
export function reachablePage(page: Page, me: Me | null | undefined): Page {
const gate = moduleDef(page).gate;
return !me || !gate || gate(me) ? page : "feed";
}
// The i18n key for a page's human title (the browser tab): a module's optional `titleKey` is the
// longer, descriptive one (e.g. "Channel manager"), falling back to its short nav `labelKey`.
export function pageTitleKey(page: Page): string {
const def = moduleDef(page);
return def.titleKey ?? def.labelKey;
}
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
export function preloadPages(pages: Page[]): void {
for (const p of pages) {
const def = moduleDef(p);
def.Page.preload().catch(() => {});
def.Rail?.preload().catch(() => {});
}
}
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
//
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
import { describe, expect, it } from "vitest";
import { pageTitleKey } from "./moduleRegistry";
import type { Page } from "../../lib/urlState";
// pageTitleKey derives from the module registry (titleKey ?? labelKey). These are the exact
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
// rename (or a dropped titleKey) can't silently change a tab title.
const EXPECTED: Record<Page, string> = {
home: "home.title",
feed: "header.account.feed",
channels: "header.channelManager",
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
files: "files.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
scheduler: "header.scheduler",
config: "header.configuration",
users: "header.users",
activity: "activity.title",
settings: "settings.title",
};
describe("pageTitleKey", () => {
it("returns the exact title key for every page (registry-derived)", () => {
for (const [page, key] of Object.entries(EXPECTED)) {
expect(pageTitleKey(page as Page)).toBe(key);
}
});
});