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:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user