refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved views; channels, files, playlists their provider), and ModuleStores mounts them at the root in registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's read side is in reach and that no store's static imports leave its own module, so the order cannot matter. Saved views were one provider that switched between the feed and Plex by the active page and reset on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the shared chip, popover and modal read the views their page or rail hands down. A feed view applied and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a module's manage modal still closes when its page is left. The feed's URL-override check applies only on the feed page, where its filter params live. The Channels store's tab type moves from the page into the store's context.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MODULE_STORES } from "./moduleRegistry";
|
||||
|
||||
// Every module's store, mounted once at the root for the whole session, composed from the registry
|
||||
// instead of nested by hand: a new module's store is its definition's `Store` and nothing here. They sit
|
||||
// at the root and not inside their pages because they are read from outside them (the palette, the
|
||||
// notifications, the download tray steering Files) and hold in-memory state — search terms, the Plex
|
||||
// jump target, the Files focus — that has to outlive a visit to another page.
|
||||
export function ModuleStores({ children }: { children: ReactNode }) {
|
||||
return MODULE_STORES.reduceRight<ReactNode>((inner, Store) => <Store>{inner}</Store>, children);
|
||||
}
|
||||
@@ -29,8 +29,10 @@ describe("the module registry", () => {
|
||||
|
||||
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.
|
||||
// account; the chunk guard fails the build on that too, this says which module did it. The one
|
||||
// static import a definition may make of its own folder is its store (a `…Provider` / `…Store`
|
||||
// file): that is root-mounted, on the boot path by design.
|
||||
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
||||
expect(source).not.toMatch(/^import [^;]* from "\.\//m);
|
||||
expect(source).not.toMatch(/^import [^;]* from "\.\/(?!\w+(?:Provider|Store)")/m);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,3 +97,6 @@ export function preloadPages(pages: Page[]): void {
|
||||
def.Rail?.preload().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// The modules' stores (lib/moduleDef `Store`), in registry order: ModuleStores mounts them at the root.
|
||||
export const MODULE_STORES = ALL.flatMap((m) => (m.Store ? [m.Store] : []));
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The modules' stores are mounted at the root by ModuleStores, composed from the registry in its order
|
||||
// (lib/moduleDef `Store`). Two things make that safe, and this pins both: every store a reader needs is
|
||||
// in the composition (a missing one throws in its first reader — the palette, the notifications, a
|
||||
// rail), and no store reads another module's store, so the order they happen to nest in can never
|
||||
// leave one outside a provider it depends on.
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ModuleStores } from "./ModuleStores";
|
||||
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
|
||||
import { useFeedFilters, useFeedSavedViews, useFeedView } from "../../modules/feed";
|
||||
import { usePlex, usePlexSavedViews } from "../../modules/plex";
|
||||
import { useChannelsActions } from "../../modules/channels";
|
||||
import { useFilesActions } from "../../modules/files";
|
||||
import { usePlaylistsActions } from "../../modules/playlists";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
// Each store's read side, as the app's readers reach it (the module faces).
|
||||
function Readers() {
|
||||
useFeedFilters();
|
||||
useFeedView();
|
||||
useFeedSavedViews();
|
||||
usePlex();
|
||||
usePlexSavedViews();
|
||||
useChannelsActions();
|
||||
useFilesActions();
|
||||
usePlaylistsActions();
|
||||
return <p>all stores in reach</p>;
|
||||
}
|
||||
|
||||
// The static (value) imports of a source file, resolved to files under src/. Type-only imports and
|
||||
// lazy `import()` are not edges a store's render depends on.
|
||||
function staticImports(file: string): string[] {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const out: string[] = [];
|
||||
for (const m of text.matchAll(/^import (?!type )[^;]*? from "(\.[^"]+)";/gms)) {
|
||||
const base = resolve(dirname(file), m[1]!);
|
||||
const path = [base + ".ts", base + ".tsx", base + "/index.ts", base + "/index.tsx", base].find(
|
||||
(p) => /\.tsx?$/.test(p) && existsSync(p),
|
||||
);
|
||||
if (path) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every file a module's definition statically pulls in — its store and what the store imports.
|
||||
function closure(start: string): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const todo = [start];
|
||||
while (todo.length) {
|
||||
const f = todo.pop()!;
|
||||
if (seen.has(f)) continue;
|
||||
seen.add(f);
|
||||
todo.push(...staticImports(f));
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("the module stores", () => {
|
||||
it("are all in reach under ModuleStores", () => {
|
||||
const qc = new QueryClient();
|
||||
const { getByText } = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ModuleStores>
|
||||
<Readers />
|
||||
</ModuleStores>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(getByText("all stores in reach")).toBeTruthy();
|
||||
});
|
||||
|
||||
const withStore = REGISTERED_PAGES.filter((p) => moduleDef(p).Store);
|
||||
it.each(withStore)("%s's store reads no other module", (page) => {
|
||||
const own = resolve(SRC, "modules", page).split("\\").join("/") + "/";
|
||||
const others = [...closure(resolve(SRC, `modules/${page}/module.ts`))]
|
||||
.map((f) => f.split("\\").join("/"))
|
||||
.filter((f) => f.includes("/src/modules/") && !f.startsWith(own));
|
||||
expect(others).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user