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:
2026-10-23 04:59:10 +02:00
parent cde5a98fd2
commit 6ae7825a14
43 changed files with 653 additions and 289 deletions
@@ -63,7 +63,8 @@ const CTX = {
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./savedViewsContext", () => ({
vi.mock("./savedViewsContext", async (importOriginal) => ({
...(await importOriginal<typeof import("./savedViewsContext")>()),
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
@@ -43,7 +43,7 @@ import { Input } from "../../components/ui/Input";
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
// all mutations flow through SavedViewsProvider.
// all mutations flow through the module's saved-views store (useSavedViewsStore).
function ViewRow({
view,
selected,
@@ -1,8 +1,11 @@
import { createContext, useContext } from "react";
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
// SavedViewsProvider so that file exports only components (Fast Refresh).
// The saved-views contexts + hooks (and the feed blob coercion their consumers share). Each module that
// has saved views (the feed, Plex) owns a store of them (useSavedViewsStore) under its own context, made
// by `createSavedViewsStore`. The shared UI here (popover, chip, modal) reads `useSavedViews()`: the
// views of the module whose page or rail it sits in, which that page or rail hands down with
// `SavedViewsScope`.
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
@@ -41,10 +44,25 @@ export interface SavedViewsContext {
reorder: (nextIds: number[]) => Promise<void>;
}
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
// One module's store: the context its provider fills and the hook its own code (and the shell) reads.
export function createSavedViewsStore(owner: string) {
const Ctx = createContext<SavedViewsContext | null>(null);
Ctx.displayName = `${owner}SavedViews`;
function useModuleSavedViews(): SavedViewsContext {
const ctx = useContext(Ctx);
if (!ctx) throw new Error(`${owner}'s saved views are read outside its store`);
return ctx;
}
return { Provider: Ctx.Provider, useViews: useModuleSavedViews };
}
// The scope the shared UI reads: the views of the module it is rendered for
// (`<SavedViewsScope value={useFeedSavedViews()}>`).
const SavedViewsScopeCtx = createContext<SavedViewsContext | null>(null);
export const SavedViewsScope = SavedViewsScopeCtx.Provider;
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(SavedViewsCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
const ctx = useContext(SavedViewsScopeCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsScope");
return ctx;
}
@@ -0,0 +1,190 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { api, type SavedView } from "../../lib/api";
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
import { notifyActionError } from "../../lib/actionErrors";
import { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
// feed's active view where it was.
// The per-module wiring the generic logic runs on: the live filters, how to apply a view's, a stable
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
export interface SavedViewsConfig {
source: ViewSource;
filters: Record<string, unknown>;
setFilters: (f: Record<string, unknown>) => void;
keyOf: (f: Record<string, unknown>) => string;
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
* stored filters for the active account, and — feed only — no URL filter override. */
canSeedDefault: () => boolean;
/** Whether to ask for the views now (a signed-in account is required on top of this). */
enabled: boolean;
/** Whether the module's page is the one on screen. Its manage modal belongs to that visit: leaving
* the page closes it, so it does not reopen by itself on the way back. */
onScreen: boolean;
}
// True once this account has its own persisted filters for `lsKey` — the signal that the last
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
// report "stored" so a default never seeds blind over an indeterminate state.
export function storedFiltersPresent(lsKey: string): boolean {
const key = ownKey(lsKey);
if (!key) return true;
try {
return localStorage.getItem(key) != null;
} catch {
return true;
}
}
export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const source = cfg.source;
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
// a 401 on the landing page. Enabled once a signed-in account is cached.
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const viewsQuery = useQuery({
queryKey: qk.savedViews(source),
queryFn: () => api.savedViews(source),
enabled: !!me && cfg.enabled,
});
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
const [stickyId, setStickyId] = useState<number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Adjusted during render, not from an effect, so the frame after leaving never commits it open.
if (modalOpen && !cfg.onScreen) setModalOpen(false);
// The ONE default-application path for both modules. On the views query's FIRST resolve, seed the
// starred default view's filters — but only into a genuinely fresh module (no stored filters, no URL
// override; see cfg.canSeedDefault). Applying via cfg.setFilters persists it, so it happens once and
// the normal last-view-wins path takes over, exactly as the storage semantics document. This replaced
// the feed-only localStorage mirror that raced FeedFiltersProvider's login effect (on a fresh browser
// the feed could seed DEFAULT_FILTERS and never re-apply until a reload) — and it gives Plex the
// default it previously never applied at all. One-shot, so a background refetch never re-seeds over
// the filters you're editing. canSeedDefault() reads the account's stored-filters key, which is always
// readable here: BootGate hands the browser's local state to the account (claimLocalData) before
// this store mounts.
const seededRef = useRef(false);
// `cfg` changes on every filter edit, so it is read through an effect event: the seed must fire on
// the views query's first resolve and never again, which `seededRef` enforces. Listing cfg would
// re-arm nothing (the guard still holds) but would re-run the effect on every keystroke.
const latestCfg = useEffectEvent(() => cfg);
useEffect(() => {
const data = viewsQuery.data;
if (!data || seededRef.current) return;
seededRef.current = true;
const c = latestCfg();
const def = pickDefaultToSeed(data, c.canSeedDefault());
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
if (def) c.setFilters(c.asFilters(def.filters));
}, [viewsQuery.data]);
const currentKey = cfg.keyOf(cfg.filters);
const activeView = useMemo(
() =>
preferSticky(
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
stickyId,
),
[views, currentKey, stickyId, cfg],
);
// The sticky id follows whichever view is active — also adjusted during render. It converges by
// construction: preferSticky returns the sticky match when there is one, so the very write below
// makes its own condition false on the next pass.
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
[views, stickyId],
);
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
let ok = true;
try {
await fn();
} catch (e) {
notifyActionError(e, failMsg);
ok = false;
}
refresh();
return ok;
}
return {
source,
views,
loading: viewsQuery.isLoading,
activeViewId: activeView?.id ?? null,
displayView,
isDirty,
modalOpen,
openModal: () => setModalOpen(true),
closeModal: () => setModalOpen(false),
apply: (v) => {
cfg.setFilters(cfg.asFilters(v.filters));
setStickyId(v.id);
},
clearActive: () => setStickyId(null),
create: async (name) => {
const clean = name.trim();
if (!clean) return false;
return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"),
);
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
},
updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation(
() => api.updateView(id, { filters }),
t("views.actionFailed"),
);
return ok ? filters : null;
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
},
reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
}
@@ -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([]);
});
});