feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts

`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up
(lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own
files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide,
which features/ may read too; a page is still opened lazily by import(). Its contract test probes
every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red.

Six modules got that face; the shared channel UI (about, tags, tag manager) moved to
features/channel, the filter chips and the saved-views context to features/filters, and the prefs
store's controller type moved next to the store instead of importing it from its own page. The
header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each
naming why: they go when the header gets slots and every rail loads lazily.

`chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the
entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its
import() a silent no-op. Proved on a build with that mutant; today's build has none.
This commit is contained in:
2026-10-22 23:30:27 +02:00
parent 63a0fecc8b
commit d5867bacca
66 changed files with 453 additions and 109 deletions
+13 -8
View File
@@ -73,14 +73,19 @@ only along a declared edge and writes them only through that unit's service; cor
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it `queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
[`backend/app/README.md`](backend/app/README.md) with the conventions. [`backend/app/README.md`](backend/app/README.md) with the conventions.
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and app-free by a contract test) → `shell/moduleRegistry` route-chunks → **`modules/<page>/`** (one folder per page: the page,
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That its rail, its store, its tests) → **`features/<name>/`** (what several pages share: video cards, filters,
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a downloads, the player, navigation) → the **platform layer `components/ui/`** (domain-free primitives: the
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic …) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md). its own test): an import only goes down; another module is reached only through its **`index.ts`** (its
public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
lazily by `import()`; a platform component may not import a domain API slice, and `lib/` nothing above
it, not even a type. A lazily imported page, rail or dialog that a static import drags onto the boot
path fails the build (`chunkGuard.ts`). A new generic component goes in `ui/`; see
[`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
prefs; API through `lib/api/*` slices over `core.req<T>`. prefs; API through `lib/api/*` slices over `core.req<T>`.
+1 -1
View File
@@ -105,7 +105,7 @@ npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier npm run format:check # prettier
npm run knip # unused files/exports/deps npm run knip # unused files/exports/deps
npm run test # vitest (incl. the compiler and gate-register contracts) npm run test # vitest (incl. the compiler and gate-register contracts)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code) npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
``` ```
+40
View File
@@ -0,0 +1,40 @@
import type { Plugin } from "vite";
import { bootLeaks, type ChunkLike } from "./src/lib/chunkLeaks";
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot (the
// entry, the shell and what they import): one static import of it on the boot path — a module's
// index.ts re-exporting it is the easy way — makes its import() a silent no-op. That rule used to be
// a hope; this makes it a build failure, naming each module and the boot chunk it landed in.
const SHELL = "src/shell/App.tsx";
export function chunkGuard(): Plugin {
return {
name: "siftlode-chunk-guard",
apply: "build",
generateBundle(_options, bundle) {
const lazy = new Set(
[...this.getModuleIds()].filter(
(id) => (this.getModuleInfo(id)?.dynamicImporters.length ?? 0) > 0,
),
);
const chunks: ChunkLike[] = Object.values(bundle).flatMap((o) =>
o.type === "chunk"
? [
{
fileName: o.fileName,
isEntry: o.isEntry,
imports: o.imports,
moduleIds: o.moduleIds,
},
]
: [],
);
const leaks = bootLeaks(chunks, lazy, SHELL);
if (leaks.length)
this.error(
`lazily imported, yet loaded at boot: ${leaks.join(", ")}. ` +
"Something on the boot path imports it statically (an index.ts re-export?): import it lazily there too, or not at all.",
);
},
};
}
+85 -1
View File
@@ -7,6 +7,8 @@ import globals from "globals";
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"; import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
import vitest from "@vitest/eslint-plugin"; import vitest from "@vitest/eslint-plugin";
import playwright from "eslint-plugin-playwright"; import playwright from "eslint-plugin-playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the // Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this // hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
@@ -419,8 +421,89 @@ const glyphLink = {
}; };
}, },
}; };
// The app's layers (CLAUDE.md "Architecture shape"), bottom up: lib/ → components/ui/ → features/ →
// modules/ → shell/. An import may go down, never up — with one door: a module's index.ts, its public
// face, which features/ may read too (the module stores are mounted at the root and read app-wide).
// Anything else of ANOTHER module is reached through that face, or lazily by import(), which is a
// chunk boundary and so the way a page is opened. Measured on the import, resolved to its file, so
// every spelling of a path is the same path. lib/ and ui/ keep their own `no-restricted-imports`
// blocks below; this rule adds the folders above them.
const SRC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "src");
const RANK = { lib: 0, ui: 1, features: 2, modules: 3, shell: 4 };
function placeOf(abs) {
const rel = path.relative(SRC_DIR, abs).split(path.sep).join("/");
if (rel.startsWith("..")) return null;
const parts = rel.replace(/\.(tsx?|jsx?)$/, "").split("/");
const [top, unit] = parts;
if (top === "lib") return { layer: "lib" };
if (top === "components") return unit === "ui" ? { layer: "ui" } : { layer: "other" };
if (top === "features" || top === "modules") {
// `modules/plex` and `modules/plex/index` are the unit's public face.
const face = parts.length === 2 || (parts.length === 3 && parts[2] === "index");
return { layer: top, unit, face };
}
if (top === "shell") return { layer: "shell" };
// main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root
return { layer: "other", top: parts.length === 1 ? "root" : top };
}
const layers = {
meta: {
type: "problem",
messages: {
up: "{{from}} sits below {{to}}: it may not import from it (`{{spec}}`). Move the shared piece down, or take it as a prop.",
face: "`{{spec}}` is one of {{unit}}'s own files: reach another unit through its index.ts (its public face), or lazily with import().",
},
},
create(context) {
const file = context.filename;
const from = placeOf(file);
// The dev-only lab and the app-wide tests at the root look at everything on purpose.
const free =
!from ||
(from.layer === "other" && from.top === "design-lab") ||
(from.layer === "other" && from.top === "root" && /\.test(kit)?\.|\.testkit\./.test(file));
const check = (node, spec, lazy) => {
if (free || typeof spec !== "string" || !spec.startsWith(".")) return;
const to = placeOf(path.resolve(path.dirname(file), spec));
if (!to || to.layer === "other") return;
const fromRank = from.layer === "other" ? 5 : RANK[from.layer];
if (
to.layer === "shell" &&
from.layer !== "shell" &&
!(from.layer === "other" && from.top === "root")
)
return context.report({
node,
messageId: "up",
data: { from: from.layer, to: "shell", spec },
});
const door = to.layer === "modules" && (to.face || lazy) && from.layer === "features";
if (RANK[to.layer] > fromRank && !door)
return context.report({
node,
messageId: "up",
data: { from: from.layer, to: to.layer, spec },
});
const sameUnit = to.layer === from.layer && to.unit === from.unit;
if (to.layer === "modules" && !sameUnit && !to.face && !lazy)
context.report({ node, messageId: "face", data: { spec, unit: `modules/${to.unit}` } });
};
return {
ImportDeclaration: (n) => check(n.source, n.source.value, false),
ExportNamedDeclaration: (n) => n.source && check(n.source, n.source.value, false),
ExportAllDeclaration: (n) => check(n.source, n.source.value, false),
ImportExpression: (n) => n.source.type === "Literal" && check(n.source, n.source.value, true),
TSImportType: (n) => {
const lit = n.argument?.literal ?? n.argument;
if (lit?.type === "Literal") check(lit, lit.value, false);
},
};
},
};
const localRules = { const localRules = {
rules: { rules: {
layers,
"glyph-link": glyphLink, "glyph-link": glyphLink,
"sortable-a11y": sortableA11y, "sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText, "jsx-comment-text": jsxCommentText,
@@ -567,6 +650,7 @@ export default tseslint.config(
"local/literal-a11y-text": "error", "local/literal-a11y-text": "error",
"local/glyph-link": "error", "local/glyph-link": "error",
"local/sortable-a11y": "error", "local/sortable-a11y": "error",
"local/layers": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a // tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
// finding is reported once, by one tool, and this config stays about hooks + refresh safety. // finding is reported once, by one tool, and this config stays about hooks + refresh safety.
@@ -761,7 +845,7 @@ export default tseslint.config(
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the // Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and // flat config named only src/**, so `eslint .` skipped them without a word. The same base and
// typed rules as the app; Node globals, since none of it runs in the browser. // typed rules as the app; Node globals, since none of it runs in the browser.
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"], files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts", "chunkGuard.ts"],
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
@@ -32,7 +32,7 @@ vi.mock("./lib/notifications", async (importOriginal) => ({
import PlexPlaylistView from "./modules/plex/PlexPlaylistView"; import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist"; import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd"; import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./modules/channels/TagManager"; import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api"; import { api, HttpError, type PlexCard } from "./lib/api";
beforeAll(() => { beforeAll(() => {
+4 -4
View File
@@ -1,7 +1,7 @@
# `components/ui/` — the platform layer # `components/ui/` — the platform layer
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**. Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
Everything in `components/` above it is a **module component**: it knows about videos, channels, Everything above it — `features/`, `modules/` and `shell/` under `src/` — knows about videos, channels,
playlists, Plex or downloads. playlists, Plex or downloads.
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
@@ -18,9 +18,9 @@ A component belongs here **iff its props and imports name no domain concept**. I
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …), - generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
- i18n, React, and third-party UI libraries. - i18n, React, and third-party UI libraries.
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice
enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the (`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
supply it. The same goes for its words: a primitive translates only from the platform i18n supply it. The same goes for its words: a primitive translates only from the platform i18n
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` — namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s `uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
@@ -51,7 +51,7 @@ const PLAIN: Record<string, [number, string]> = {
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"], "modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"], "modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"], "modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
"modules/channels/TagManager.tsx": [1, "a suggestion in a picker"], "features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
"modules/files/UploadDialog.tsx": [1, "the drop zone"], "modules/files/UploadDialog.tsx": [1, "the drop zone"],
"features/player/VideoEditor.tsx": [2, "timeline glyphs"], "features/player/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"], "shell/Welcome.tsx": [1, "a screenshot (zoom)"],
@@ -37,8 +37,7 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { ErrorModal } from "./downloadModals"; import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { sourceLabel } from "../../modules/files/fileFolders"; import { sourceLabel, useFilesActions } from "../../modules/files";
import { useFilesActions } from "../../modules/files/filesContext";
import { useDownloadActions } from "./useDownloadActions"; import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { setCornerLift } from "../../components/ui/backToTopState"; import { setCornerLift } from "../../components/ui/backToTopState";
@@ -63,7 +63,7 @@ const CTX = {
toggleDefault: vi.fn(async () => {}), toggleDefault: vi.fn(async () => {}),
}; };
vi.mock("../../modules/feed/savedViewsContext", () => ({ vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({ useSavedViews: () => ({
...CTX, ...CTX,
activeViewId: h.activeViewId, activeViewId: h.activeViewId,
@@ -26,9 +26,9 @@ import {
import Modal from "../../components/ui/Modal"; import Modal from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { useSavedViews, asFilters } from "../../modules/feed/savedViewsContext"; import { useSavedViews, asFilters } from "./savedViewsContext";
import { useActiveFilters } from "../../lib/useActiveFilters"; import { useActiveFilters } from "../../lib/useActiveFilters";
import { buildPlexActiveFilters } from "../../modules/plex/usePlexActiveFilters"; import { buildPlexActiveFilters } from "../../modules/plex";
import { useUndoable } from "../../lib/useUndoable"; import { useUndoable } from "../../lib/useUndoable";
import UndoToolbar from "../../components/ui/UndoToolbar"; import UndoToolbar from "../../components/ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y"; import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
}); });
vi.mock("../../modules/feed/savedViewsContext", () => ({ vi.mock("./savedViewsContext", () => ({
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }), useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
})); }));
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Settings2 } from "lucide-react"; import { Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover"; import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { useSavedViews } from "../../modules/feed/savedViewsContext"; import { useSavedViews } from "./savedViewsContext";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { MenuCheck } from "../../components/ui/MenuCheck"; import { MenuCheck } from "../../components/ui/MenuCheck";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Bookmark } from "lucide-react"; import { Bookmark } from "lucide-react";
import SavedViewsPopover from "./SavedViewsPopover"; import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "../../modules/feed/savedViewsContext"; import { useSavedViews } from "./savedViewsContext";
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like // The active personal view, shown as the leading pill before the active-filter chip row. Styled like
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged // the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
@@ -24,8 +24,7 @@ import {
} from "../../lib/notifications"; } from "../../lib/notifications";
import { hintsEnabled, setHintsEnabled } from "../../lib/hints"; import { hintsEnabled, setHintsEnabled } from "../../lib/hints";
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import type { PrefsController } from "../../modules/settings/SettingsPanel"; import { PrefsContext, type PrefsController } from "./prefsContext";
import { PrefsContext } from "./prefsContext";
const PERF_KEY = LS.perfMode; const PERF_KEY = LS.perfMode;
+21 -3
View File
@@ -1,11 +1,29 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { DEFAULT_THEME } from "../../lib/theme"; import { DEFAULT_THEME, type ThemePrefs } from "../../lib/theme";
import { getNotifSettings } from "../../lib/notifications"; import { getNotifSettings, type NotifSettings } from "../../lib/notifications";
import type { PrefsController } from "../../modules/settings/SettingsPanel"; import type { SaveState } from "../../components/ui/DraftSaveBar";
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only // The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
// components (Fast Refresh). // components (Fast Refresh).
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// outcome. PrefsProvider owns the state + the save; this controller is how the page reads/writes it.
// It lives with the store rather than with the page, so the store does not depend on its page.
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
setHints: (v: boolean) => void;
railLabels: boolean;
setRailLabels: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
saveState: SaveState;
}
export const PrefsContext = createContext<PrefsController>({ export const PrefsContext = createContext<PrefsController>({
theme: DEFAULT_THEME, theme: DEFAULT_THEME,
setTheme: () => {}, setTheme: () => {},
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { bootLeaks, type ChunkLike } from "./chunkLeaks";
const APP = "/r/src/shell/App.tsx";
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
({ fileName, moduleIds, imports, isEntry }) satisfies ChunkLike;
describe("a lazily imported module", () => {
const lazy = new Set([APP, "/r/src/modules/plex/PlexBrowse.tsx"]);
it("is a leak in the entry chunk, in a chunk the entry imports, or in the App chunk", () => {
const P = "/r/src/modules/plex/PlexBrowse.tsx";
for (const chunks of [
[chunk("index.js", ["/r/src/main.tsx", P], [], true)],
[chunk("index.js", ["/r/src/main.tsx"], ["shared.js"], true), chunk("shared.js", [P])],
[chunk("index.js", ["/r/src/main.tsx"], [], true), chunk("App.js", [APP, P])],
[
chunk("index.js", ["/r/src/main.tsx"], [], true),
chunk("App.js", [APP], ["s.js"]),
chunk("s.js", [P]),
],
])
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([
`${P} (${chunks[chunks.length - 1]!.fileName})`,
]);
});
it("is not a leak in its own chunk, or in one only another lazy chunk loads — and App itself is lazy by design", () => {
const chunks = [
chunk("index.js", ["/r/src/main.tsx"], ["react.js"], true),
chunk("react.js", ["/r/node_modules/react/index.js"]),
chunk("App.js", [APP], ["react.js"]),
chunk("PlexBrowse.js", ["/r/src/modules/plex/PlexBrowse.tsx"], ["react.js"]),
];
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([]);
});
it("is checked on Windows paths too", () => {
const win = "D:\\r\\src\\modules\\plex\\PlexBrowse.tsx";
const chunks = [chunk("index.js", ["D:\\r\\src\\main.tsx", win], [], true)];
expect(bootLeaks(chunks, new Set([win]), "src/shell/App.tsx")).toHaveLength(1);
});
});
+40
View File
@@ -0,0 +1,40 @@
// Which lazily imported modules a production build loads at boot anyway — the logic behind the
// chunkGuard build plugin (frontend/chunkGuard.ts), kept free of Vite so a unit test can drive it.
//
// A page, a rail, a dialog or a player is imported with import()/lazy() so it is fetched only when it
// is opened. Rollup honours that only while nothing on the boot path imports the same module
// statically: one static import — a re-export from a module's index.ts is the easy way to add one —
// and the module joins a boot chunk, the import() becomes a no-op, and nothing says so. The boot path
// is the entry chunks and what they import, plus the shell (App, itself loaded lazily but fetched
// at boot alongside the identity probe) and what it imports.
export interface ChunkLike {
fileName: string;
isEntry: boolean;
/** The chunks this one imports statically. */
imports: string[];
moduleIds: string[];
}
const posix = (id: string) => id.split("\\").join("/");
/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The
* shell's own module (`shell`, a path under the project, e.g. "src/shell/App.tsx") is exempt: it is
* lazy so that the public pages never load it, and its chunk is the shell. */
export function bootLeaks(chunks: ChunkLike[], lazy: Set<string>, shell: string): string[] {
const byName = new Map(chunks.map((c) => [c.fileName, c]));
const isShell = (id: string) => posix(id).endsWith(`/${shell}`);
const roots = chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell));
const boot = new Set<ChunkLike>();
const visit = (c: ChunkLike | undefined) => {
if (!c || boot.has(c)) return;
boot.add(c);
c.imports.forEach((name) => visit(byName.get(name)));
};
roots.forEach(visit);
const leaks: string[] = [];
for (const c of boot)
for (const id of c.moduleIds)
if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`);
return leaks.sort();
}
+2
View File
@@ -471,6 +471,8 @@ describe("the gate register (docs/gates.md)", () => {
"vitest.config.ts", "vitest.config.ts",
"reactPlugin.ts", "reactPlugin.ts",
"devOnlyGuard.ts", "devOnlyGuard.ts",
"chunkGuard.ts",
"src/lib/chunkLeaks.ts",
"playwright.config.ts", "playwright.config.ts",
"postcss.config.js", "postcss.config.js",
], ],
+4
View File
@@ -35,6 +35,7 @@
"local/glyph-link", "local/glyph-link",
"local/input-primitive", "local/input-primitive",
"local/jsx-comment-text", "local/jsx-comment-text",
"local/layers",
"local/literal-a11y-text", "local/literal-a11y-text",
"local/locale-format", "local/locale-format",
"local/sortable-a11y", "local/sortable-a11y",
@@ -117,6 +118,7 @@
"local/glyph-link", "local/glyph-link",
"local/input-primitive", "local/input-primitive",
"local/jsx-comment-text", "local/jsx-comment-text",
"local/layers",
"local/literal-a11y-text", "local/literal-a11y-text",
"local/locale-format", "local/locale-format",
"local/sortable-a11y", "local/sortable-a11y",
@@ -642,6 +644,7 @@
"local/glyph-link": [2], "local/glyph-link": [2],
"local/input-primitive": [2], "local/input-primitive": [2],
"local/jsx-comment-text": [2], "local/jsx-comment-text": [2],
"local/layers": [2],
"local/literal-a11y-text": [2], "local/literal-a11y-text": [2],
"local/locale-format": [2], "local/locale-format": [2],
"local/sortable-a11y": [2], "local/sortable-a11y": [2],
@@ -1055,6 +1058,7 @@
"local/glyph-link", "local/glyph-link",
"local/input-primitive", "local/input-primitive",
"local/jsx-comment-text", "local/jsx-comment-text",
"local/layers",
"local/literal-a11y-text", "local/literal-a11y-text",
"local/locale-format", "local/locale-format",
"local/sortable-a11y", "local/sortable-a11y",
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { eslint } from "./lintProbe.testkit";
// The layer rule (local/layers in eslint.config.js) is what makes the folders a structure rather
// than a filing habit: lib/ → components/ui/ → features/ → modules/ → shell/, an import only goes
// down, and a module's own files are reached from outside only through its index.ts or lazily. A
// rule that matched nothing would look exactly like a clean codebase, so each edge is probed here.
async function refused(source: string, filePath: string): Promise<string[]> {
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? []).filter((m) => m.ruleId === "local/layers").map((m) => m.message);
}
const imp = (...specs: string[]) =>
specs.map((s, i) => `import x${i} from "${s}";\n`).join("") +
`export default [${specs.map((_, i) => `x${i}`).join(", ")}];\n`;
const IN_FEED = "src/modules/feed/__probe.tsx";
const IN_FEATURE = "src/features/video/__probe.tsx";
describe("a module", () => {
it("reaches another module only through its index.ts", async () => {
expect(await refused(imp("../plex/PlexBrowse"), IN_FEED)).toHaveLength(1);
expect(await refused(imp("../plex", "../plex/index"), IN_FEED)).toEqual([]);
});
it("by every spelling: a type, a re-export, a type-only import", async () => {
const src =
'import type { P } from "../plex/plexContext";\n' +
'export { usePlex } from "../plex/plexContext";\n' +
'export * from "../channels/channelsContext";\n' +
"export type Q = P;\n";
expect(await refused(src, IN_FEED)).toHaveLength(3);
});
it("may open another module's page lazily, and use its own files and anything below", async () => {
const src =
'const P = () => import("../plex/PlexBrowse");\n' +
'import F from "./Feed";\nimport V from "../../features/video/VideoCard";\n' +
'import B from "../../components/ui/Button";\nimport { qk } from "../../lib/queryKeys";\n' +
"export default [P, F, V, B, qk];\n";
expect(await refused(src, IN_FEED)).toEqual([]);
});
it("never imports the shell", async () => {
expect(await refused(imp("../../shell/Header"), IN_FEED)).toHaveLength(1);
});
});
describe("a feature", () => {
it("reads a module only through its face, and never the shell", async () => {
expect(await refused(imp("../../modules/plex"), IN_FEATURE)).toEqual([]);
const up = await refused(imp("../../modules/plex/plexContext", "../../shell/App"), IN_FEATURE);
expect(up).toHaveLength(2);
expect(up.every((m) => m.includes("sits below"))).toBe(true);
});
it("uses other features freely", async () => {
expect(await refused(imp("../player/ClipPanel", "../filters/SearchBar"), IN_FEATURE)).toEqual(
[],
);
});
});
describe("the platform and lib layers", () => {
it("import nothing above them — not even a module's face", async () => {
const ui = await refused(
imp("../../features/video/VideoCard", "../../modules/feed", "../../shell/App"),
"src/components/ui/__layers_probe.tsx",
);
expect(ui).toHaveLength(3);
const lib = await refused(
imp("../features/navigation/navigationContext", "../modules/feed", "../shell/App"),
"src/lib/__layers_probe.ts",
);
expect(lib).toHaveLength(3);
});
});
describe("the shell and the entry", () => {
it("reach a module through its face, or lazily", async () => {
const src =
imp("../modules/feed/Feed", "../modules/feed") +
'export const L = () => import("../modules/feed/Feed");\n';
expect(await refused(src, "src/shell/__layers_probe.tsx")).toHaveLength(1);
});
it("only the root entry mounts the shell", async () => {
expect(await refused(imp("./shell/App"), "src/__layers_probe.tsx")).toEqual([]);
expect(await refused(imp("./modules/feed/Feed"), "src/__layers_probe.tsx")).toHaveLength(1);
});
});
+3
View File
@@ -15,8 +15,11 @@ const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
// file no tsconfig includes, so those names are let into TypeScript's default project here — the // file no tsconfig includes, so those names are let into TypeScript's default project here — the
// probe still runs the real config, typed rules included, rather than a copy with them switched off. // probe still runs the real config, typed rules included, rather than a copy with them switched off.
const PROBE_FILES = [ const PROBE_FILES = [
"src/__*probe.tsx",
"src/*/__*probe.ts", "src/*/__*probe.ts",
"src/*/__*probe.tsx", "src/*/__*probe.tsx",
// a file inside one module or feature (src/modules/feed/…), for the layer rule
"src/*/*/__*probe.tsx",
"src/components/ui/__*probe.tsx", "src/components/ui/__*probe.tsx",
"src/lib/api/__*probe.ts", "src/lib/api/__*probe.ts",
"src/components/Button.tsx", "src/components/Button.tsx",
+5 -8
View File
@@ -7,14 +7,11 @@ import ErrorBoundary from "./shell/ErrorBoundary";
import { ConfirmProvider } from "./components/ui/ConfirmProvider"; import { ConfirmProvider } from "./components/ui/ConfirmProvider";
import BootGate, { BootScreen } from "./shell/BootGate"; import BootGate, { BootScreen } from "./shell/BootGate";
import { NavigationProvider } from "./features/navigation/NavigationProvider"; import { NavigationProvider } from "./features/navigation/NavigationProvider";
import { FeedFiltersProvider } from "./modules/feed/FeedFiltersProvider"; import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
import { SavedViewsProvider } from "./modules/feed/SavedViewsProvider"; import { PlaylistsProvider } from "./modules/playlists";
import { PlaylistsProvider } from "./modules/playlists/PlaylistsProvider"; import { FilesProvider } from "./modules/files";
import { FilesProvider } from "./modules/files/FilesProvider"; import { PlexProvider, PlexViewBridge } from "./modules/plex";
import { PlexProvider } from "./modules/plex/PlexProvider"; import { ChannelsProvider } from "./modules/channels";
import { PlexViewBridge } from "./modules/plex/PlexViewBridge";
import { ChannelsProvider } from "./modules/channels/ChannelsProvider";
import { FeedViewProvider } from "./modules/feed/FeedViewProvider";
import { PrefsProvider } from "./features/prefs/PrefsProvider"; import { PrefsProvider } from "./features/prefs/PrefsProvider";
import { WizardProvider } from "./features/wizard/WizardProvider"; import { WizardProvider } from "./features/wizard/WizardProvider";
import "./i18n"; import "./i18n";
+2
View File
@@ -0,0 +1,2 @@
// The activity page's public face: what another unit may use of it.
export { StatusDot, type DotKey } from "./RunStatusDot";
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import ChannelAboutContent from "./ChannelAbout"; import ChannelAboutContent from "../../features/channel/ChannelAbout";
import { PopoverSurface } from "../../components/ui/AnchoredPopover"; import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
+4 -4
View File
@@ -33,14 +33,14 @@ import { useChannelActions } from "./useChannelActions";
import { useContextMenu } from "../../components/ui/useContextMenu"; import { useContextMenu } from "../../components/ui/useContextMenu";
import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useChannelTagActions } from "../../lib/useChannelTagActions"; import { useChannelTagActions } from "../../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions"; import { useTagSuggestions } from "../../features/channel/useTagSuggestions";
import { useChannelPriority } from "../../lib/useChannelPriority"; import { useChannelPriority } from "../../lib/useChannelPriority";
import { placeByPriority } from "../../lib/channelOrder"; import { placeByPriority } from "../../lib/channelOrder";
import { focusNavItem } from "../../lib/listNav"; import { focusNavItem } from "../../lib/listNav";
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher"; import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
import ColumnSortControl from "../../components/ui/ColumnSortControl"; import ColumnSortControl from "../../components/ui/ColumnSortControl";
import FilterChipGroup from "../../components/ui/FilterChipGroup"; import FilterChipGroup from "../../components/ui/FilterChipGroup";
import TagFilterChips from "../feed/TagFilterChips"; import TagFilterChips from "../../features/filters/TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu"; import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover"; import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "../../components/ui/Pager"; import Pager from "../../components/ui/Pager";
@@ -57,10 +57,10 @@ import { menuGroups } from "../../components/ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems"; import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
import { optionsFromColumns } from "../../lib/sortOptions"; import { optionsFromColumns } from "../../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery"; import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "../../features/channel/TagManager";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigationActions } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { useWizard } from "../../features/wizard/wizardContext"; import { useWizard } from "../../features/wizard/wizardContext";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { useTags } from "../../lib/useTags"; import { useTags } from "../../lib/useTags";
@@ -7,11 +7,11 @@ import type { Column } from "../../components/ui/DataTable";
import { PriorityStepper } from "../../components/ui/PriorityStepper"; import { PriorityStepper } from "../../components/ui/PriorityStepper";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell"; import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags"; import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags";
import { StoredCell } from "./channelCells"; import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync"; import { isFullySynced } from "./channelSync";
import { RelativeTime } from "../../components/ui/RelativeTime"; import { RelativeTime } from "../../components/ui/RelativeTime";
import type { FilterChip } from "../feed/TagFilterChips"; import type { FilterChip } from "../../features/filters/TagFilterChips";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical /** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different * in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
+3
View File
@@ -0,0 +1,3 @@
// The channel manager's public face: its store, mounted at the root and read app-wide.
export { ChannelsProvider } from "./ChannelsProvider";
export { useChannels, useChannelsActions } from "./channelsContext";
@@ -24,7 +24,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
})); }));
vi.mock("./Feed", () => ({ default: () => null })); vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
import ChannelPage from "./ChannelPage"; import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api"; import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api";
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
})); }));
vi.mock("./Feed", () => ({ default: () => null })); vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for. // The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()), ...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
})); }));
vi.mock("./Feed", () => ({ default: () => null })); vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for. // The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()), ...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
@@ -21,7 +21,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
})); }));
vi.mock("./Feed", () => ({ default: () => null })); vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()), ...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>, PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
+2 -2
View File
@@ -26,7 +26,7 @@ import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel"; import { useTabPanel } from "../../components/ui/tabPanel";
import { useScrollFold } from "../../components/ui/scrollFold"; import { useScrollFold } from "../../components/ui/scrollFold";
import Feed from "./Feed"; import Feed from "./Feed";
import ChannelAboutContent from "../channels/ChannelAbout"; import ChannelAboutContent from "../../features/channel/ChannelAbout";
import { PageToolbar } from "../../components/ui/PageShell"; import { PageToolbar } from "../../components/ui/PageShell";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext"; import { useFeedFilters } from "./feedFiltersContext";
@@ -46,7 +46,7 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import type { FeedView } from "../../lib/feedView"; import type { FeedView } from "../../lib/feedView";
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format"; import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
import { ChannelTagsControl } from "../channels/ChannelTags"; import { ChannelTagsControl } from "../../features/channel/ChannelTags";
import { useChannelTagActions } from "../../lib/useChannelTagActions"; import { useChannelTagActions } from "../../lib/useChannelTagActions";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
@@ -28,7 +28,7 @@ vi.mock("./Feed", () => ({
return null; return null;
}, },
})); }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null })); vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()), ...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>, PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
@@ -45,7 +45,7 @@ vi.mock("../plex/plexContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider } from "./SavedViewsProvider"; import { SavedViewsProvider } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext"; import { useSavedViews } from "../../features/filters/savedViewsContext";
import { api, type SavedView } from "../../lib/api"; import { api, type SavedView } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage"; import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -17,13 +17,13 @@ import { LS, ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { useNavigation } from "../../features/navigation/navigationContext"; import { useNavigation } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "../plex/plexContext"; import { usePlex, usePlexActions } from "../plex";
import { import {
asFilters, asFilters,
SavedViewsCtx, SavedViewsCtx,
type SavedViewsContext, type SavedViewsContext,
type ViewSource, type ViewSource,
} from "./savedViewsContext"; } from "../../features/filters/savedViewsContext";
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider // Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
// switches which module it operates on by the active page (both filter providers are global, so it can // switches which module it operates on by the active page (both filter providers are global, so it can
+3 -3
View File
@@ -7,9 +7,9 @@ import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } fr
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import { useActiveFilters } from "../../lib/useActiveFilters"; import { useActiveFilters } from "../../lib/useActiveFilters";
import TagManager from "../channels/TagManager"; import TagManager from "../../features/channel/TagManager";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover"; import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import { useSavedViews } from "./savedViewsContext"; import { useSavedViews } from "../../features/filters/savedViewsContext";
import FilterRail from "../../components/ui/FilterRail"; import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups"; import { type PanelItem } from "../../components/ui/PanelGroups";
import { type MediaSourceKind } from "../../lib/mediaSource"; import { type MediaSourceKind } from "../../lib/mediaSource";
@@ -20,7 +20,7 @@ import YearFilter from "./YearFilter";
import Combobox from "../../components/ui/Combobox"; import Combobox from "../../components/ui/Combobox";
import MatchToggle from "../../components/ui/MatchToggle"; import MatchToggle from "../../components/ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "../channels/channelsContext"; import { useChannelsActions } from "../channels";
import { useTags } from "../../lib/useTags"; import { useTags } from "../../lib/useTags";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
+6
View File
@@ -0,0 +1,6 @@
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
export { FeedFiltersProvider } from "./FeedFiltersProvider";
export { FeedViewProvider } from "./FeedViewProvider";
export { SavedViewsProvider } from "./SavedViewsProvider";
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
export { useFeedView, useFeedViewActions } from "./feedViewContext";
+1 -1
View File
@@ -4,7 +4,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useFeedView, useFeedViewActions } from "./feedViewContext"; import { useFeedView, useFeedViewActions } from "./feedViewContext";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal"; import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "./savedViewsContext"; import { useSavedViews } from "../../features/filters/savedViewsContext";
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so // The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are // that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
@@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()), ...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
})); }));
vi.mock("./DownloadDialog", () => ({ vi.mock("../../features/downloads/DownloadDialog", () => ({
default: ({ source, onClose }: { source: string; onClose: () => void }) => ( default: ({ source, onClose }: { source: string; onClose: () => void }) => (
<div role="dialog" aria-label="download"> <div role="dialog" aria-label="download">
{source} {source}
@@ -20,8 +20,8 @@ vi.mock("./DownloadDialog", () => ({
), ),
})); }));
import DownloadIntake from "./DownloadIntake"; import DownloadIntake from "../../features/downloads/DownloadIntake";
import { LinkBox } from "../../modules/files/LinkBox"; import { LinkBox } from "./LinkBox";
import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake"; import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake";
import { ensureNavId } from "../../lib/history"; import { ensureNavId } from "../../lib/history";
import { pushLayer, removeLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer } from "../../lib/layerStack";
+4
View File
@@ -0,0 +1,4 @@
// The Files page's public face: its store, mounted at the root, and how a folder is named and pictured.
export { FilesProvider } from "./FilesProvider";
export { useFiles, useFilesActions } from "./filesContext";
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
+2 -2
View File
@@ -55,9 +55,9 @@ import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
import { useVideoActions } from "../../features/video/useVideoActions"; import { useVideoActions } from "../../features/video/useVideoActions";
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"; import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards"; import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
import { usePlaylistsActions } from "../playlists/playlistsContext"; import { usePlaylistsActions } from "../playlists";
import { useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigationActions } from "../../features/navigation/navigationContext";
import { usePlexActions } from "../plex/plexContext"; import { usePlexActions } from "../plex";
import { navItemAttrs } from "../../lib/listNav"; import { navItemAttrs } from "../../lib/listNav";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
@@ -47,9 +47,9 @@ import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu"; import { PageMenu } from "../../components/ui/PageMenu";
import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigationActions } from "../../features/navigation/navigationContext";
import { useFilesActions } from "../files/filesContext"; import { useFilesActions } from "../files";
import { useChannelsActions } from "../channels/channelsContext"; import { useChannelsActions } from "../channels";
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "../feed";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { SectionLabel } from "../../components/ui/Headings"; import { SectionLabel } from "../../components/ui/Headings";
import NavGrid from "../../components/ui/NavGrid"; import NavGrid from "../../components/ui/NavGrid";
+3
View File
@@ -0,0 +1,3 @@
// The playlists page's public face: its store, mounted at the root and read app-wide.
export { PlaylistsProvider } from "./PlaylistsProvider";
export { usePlaylists, usePlaylistsActions } from "./playlistsContext";
+1 -1
View File
@@ -18,7 +18,7 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox"; import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover"; import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import PersonalViewsModal from "../../features/filters/PersonalViewsModal"; import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "../feed/savedViewsContext"; import { useSavedViews } from "../../features/filters/savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext"; import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip"; import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
+5
View File
@@ -0,0 +1,5 @@
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
export { PlexProvider } from "./PlexProvider";
export { PlexViewBridge } from "./PlexViewBridge";
export { usePlex, usePlexActions } from "./plexContext";
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
+1 -1
View File
@@ -44,7 +44,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu";
import { MoreButton } from "../../components/ui/MoreButton"; import { MoreButton } from "../../components/ui/MoreButton";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { jobFailureText, jobResultText } from "../../lib/jobResult"; import { jobFailureText, jobResultText } from "../../lib/jobResult";
import { StatusDot, type DotKey } from "../activity/RunStatusDot"; import { StatusDot, type DotKey } from "../activity";
import { useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigationActions } from "../../features/navigation/navigationContext";
import { urlWithParams } from "../../lib/urlState"; import { urlWithParams } from "../../lib/urlState";
+1 -1
View File
@@ -18,7 +18,7 @@ import { compareText } from "../../lib/collate";
import { formatDate } from "../../lib/format"; import { formatDate } from "../../lib/format";
import { navItemAttrs } from "../../lib/listNav"; import { navItemAttrs } from "../../lib/listNav";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { folderIcon, folderLabel, takesUploads } from "../files/fileFolders"; import { folderIcon, folderLabel, takesUploads } from "../files";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { Badge } from "../../components/ui/Badge"; import { Badge } from "../../components/ui/Badge";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
@@ -20,7 +20,7 @@ import { DownloadFormats } from "./ProfileEditor";
import DiagnosticsSettings from "./DiagnosticsSettings"; import DiagnosticsSettings from "./DiagnosticsSettings";
import ApiTokens from "./ApiTokens"; import ApiTokens from "./ApiTokens";
import { type SettingsTabId } from "../../lib/settingsTab"; import { type SettingsTabId } from "../../lib/settingsTab";
import { SCHEMES, type Scheme, type ThemePrefs } from "../../lib/theme"; import { SCHEMES, type Scheme } from "../../lib/theme";
import { api, HttpError, type Me } from "../../lib/api"; import { api, HttpError, type Me } from "../../lib/api";
import { displayName, roleKey } from "../../lib/api/account"; import { displayName, roleKey } from "../../lib/api/account";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -40,7 +40,7 @@ import {
START_ON_HOME_PREF, START_ON_HOME_PREF,
} from "../../features/navigation/navigationInit"; } from "../../features/navigation/navigationInit";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { usePrefs } from "../../features/prefs/prefsContext"; import { usePrefs, type PrefsController } from "../../features/prefs/prefsContext";
import Tabs from "../../components/ui/Tabs"; import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel"; import { useTabPanel } from "../../components/ui/tabPanel";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -49,25 +49,6 @@ import { Slider } from "../../components/ui/NativeControls";
import { Input, Textarea } from "../../components/ui/Input"; import { Input, Textarea } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// outcome. PrefsProvider owns the state + the save; this controller
// is how the panel reads/writes it.
export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
setHints: (v: boolean) => void;
railLabels: boolean;
setRailLabels: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
saveState: PrefsSaveState;
}
type TabId = SettingsTabId; type TabId = SettingsTabId;
const TABS: { id: TabId; icon: typeof Monitor }[] = [ const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor }, { id: "appearance", icon: Monitor },
@@ -138,7 +119,7 @@ export default function SettingsPanel() {
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the // A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
// bottom of the settings card and fades after a success (the state resets to "idle" in App). // bottom of the settings card and fades after a success (the state resets to "idle" in App).
function SaveIndicator({ state }: { state: PrefsSaveState }) { function SaveIndicator({ state }: { state: SaveState }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (state === "idle") return null; if (state === "idle") return null;
return ( return (
+6 -2
View File
@@ -21,8 +21,12 @@ import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref"; import { usePref } from "../lib/usePref";
import { useVerifyEntry } from "../lib/useVerifyEntry"; import { useVerifyEntry } from "../lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext"; import {
import { useFeedView, useFeedViewActions } from "../modules/feed/feedViewContext"; useFeedFilters,
useFeedFiltersActions,
useFeedView,
useFeedViewActions,
} from "../modules/feed";
import { moduleOrder } from "../lib/modules"; import { moduleOrder } from "../lib/modules";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry"; import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
import { lazyWithPreload } from "../lib/lazyWithPreload"; import { lazyWithPreload } from "../lib/lazyWithPreload";
@@ -57,7 +57,7 @@ vi.mock("../modules/playlists/playlistsContext", () => ({
vi.mock("../features/prefs/prefsContext", () => ({ vi.mock("../features/prefs/prefsContext", () => ({
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }), usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
})); }));
vi.mock("../modules/feed/savedViewsContext", () => ({ vi.mock("../features/filters/savedViewsContext", () => ({
useSavedViews: () => ({ views: [], apply: vi.fn() }), useSavedViews: () => ({ views: [], apply: vi.fn() }),
})); }));
const requestSurprise = vi.fn(); const requestSurprise = vi.fn();
+2 -2
View File
@@ -24,9 +24,9 @@ import { requestLinkBox } from "../lib/downloadIntake";
import { openRecorderStart } from "../lib/debugRecorder"; import { openRecorderStart } from "../lib/debugRecorder";
import { useDebugRecording } from "../features/diagnostics/useDebugRecording"; import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
import { useNavigationActions } from "../features/navigation/navigationContext"; import { useNavigationActions } from "../features/navigation/navigationContext";
import { usePlaylistsActions } from "../modules/playlists/playlistsContext"; import { usePlaylistsActions } from "../modules/playlists";
import { usePrefs } from "../features/prefs/prefsContext"; import { usePrefs } from "../features/prefs/prefsContext";
import { useSavedViews } from "../modules/feed/savedViewsContext"; import { useSavedViews } from "../features/filters/savedViewsContext";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is // What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module. // the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
@@ -11,24 +11,24 @@ vi.mock("react-i18next", async (importOriginal) => ({
})); }));
let page = "home"; let page = "home";
vi.mock("../navigation/navigationContext", () => ({ useNavigation: () => ({ page }) })); vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
vi.mock("../../modules/feed/feedFiltersContext", () => ({ vi.mock("../modules/feed/feedFiltersContext", () => ({
useFeedFilters: () => ({ q: "", sort: "newest" }), useFeedFilters: () => ({ q: "", sort: "newest" }),
useFeedFiltersActions: () => ({ setFilters: vi.fn() }), useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
})); }));
vi.mock("../../modules/playlists/playlistsContext", () => ({ vi.mock("../modules/playlists/playlistsContext", () => ({
usePlaylists: () => ({ search: "" }), usePlaylists: () => ({ search: "" }),
usePlaylistsActions: () => ({ setSearch: vi.fn() }), usePlaylistsActions: () => ({ setSearch: vi.fn() }),
})); }));
vi.mock("../../modules/channels/channelsContext", () => ({ vi.mock("../modules/channels/channelsContext", () => ({
useChannels: () => ({ search: "" }), useChannels: () => ({ search: "" }),
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }), useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
})); }));
vi.mock("../../modules/channels/SyncStatus", () => ({ default: () => null })); vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null }));
import Header from "../../shell/Header"; import Header from "./Header";
import { closePalette, usePaletteOpen } from "../../lib/keymap"; import { closePalette, usePaletteOpen } from "../lib/keymap";
import type { Me } from "../../lib/api"; import type { Me } from "../lib/api";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
+9 -4
View File
@@ -3,16 +3,21 @@ import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../lib/brandIcons"; import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api"; import type { Me } from "../lib/api";
import SearchBar from "../features/header/SearchBar"; import SearchBar from "../features/header/SearchBar";
import { useFiles, useFilesActions } from "../modules/files/filesContext"; import { useFiles, useFilesActions } from "../modules/files";
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
import PlexSearchBox from "../modules/plex/PlexSearchBox"; import PlexSearchBox from "../modules/plex/PlexSearchBox";
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
import PlexScopeToggle from "../modules/plex/PlexScopeToggle"; import PlexScopeToggle from "../modules/plex/PlexScopeToggle";
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
import PlexViewToggle from "../modules/plex/PlexViewToggle"; import PlexViewToggle from "../modules/plex/PlexViewToggle";
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
import FeedScopeToggle from "../modules/feed/FeedScopeToggle"; import FeedScopeToggle from "../modules/feed/FeedScopeToggle";
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
import SyncStatus from "../modules/channels/SyncStatus"; import SyncStatus from "../modules/channels/SyncStatus";
import { useNavigation } from "../features/navigation/navigationContext"; import { useNavigation } from "../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "../modules/feed";
import { usePlaylists, usePlaylistsActions } from "../modules/playlists/playlistsContext"; import { usePlaylists, usePlaylistsActions } from "../modules/playlists";
import { useChannels, useChannelsActions } from "../modules/channels/channelsContext"; import { useChannels, useChannelsActions } from "../modules/channels";
import { PaletteLauncher } from "../features/header/PaletteLauncher"; import { PaletteLauncher } from "../features/header/PaletteLauncher";
// The floating top header: a row of glass pills over the content — the module's SearchBar where it // The floating top header: a row of glass pills over the content — the module's SearchBar where it
+3
View File
@@ -2,8 +2,11 @@ import type { ComponentType } from "react";
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload"; import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout"; import type { PanelId, PanelLayout } from "../lib/panelLayout";
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
import Sidebar from "../modules/feed/Sidebar"; import Sidebar from "../modules/feed/Sidebar";
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
import PlaylistsRail from "../modules/playlists/PlaylistsRail"; import PlaylistsRail from "../modules/playlists/PlaylistsRail";
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
import { Feed, FeedPage } from "../modules/feed/modulePages"; import { Feed, FeedPage } from "../modules/feed/modulePages";
// The RENDER half of the module registry: which component (and side rail) each page renders. The // The RENDER half of the module registry: which component (and side rail) each page renders. The
+2
View File
@@ -43,6 +43,8 @@
"vitest.config.ts", "vitest.config.ts",
"reactPlugin.ts", "reactPlugin.ts",
"devOnlyGuard.ts", "devOnlyGuard.ts",
"chunkGuard.ts",
"src/lib/chunkLeaks.ts",
"playwright.config.ts", "playwright.config.ts",
"postcss.config.js" "postcss.config.js"
] ]
+2 -1
View File
@@ -2,6 +2,7 @@ import { ServerResponse } from "node:http";
import { defineConfig, type Plugin, type ProxyOptions } from "vite"; import { defineConfig, type Plugin, type ProxyOptions } from "vite";
import { reactWithCompiler } from "./reactPlugin"; import { reactWithCompiler } from "./reactPlugin";
import { devOnlyGuard } from "./devOnlyGuard"; import { devOnlyGuard } from "./devOnlyGuard";
import { chunkGuard } from "./chunkGuard";
// During `vite dev` (host with Node), proxy API calls to the backend container. // During `vite dev` (host with Node), proxy API calls to the backend container.
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the // Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
@@ -54,7 +55,7 @@ const watchEntry = (): Plugin => ({
}); });
export default defineConfig({ export default defineConfig({
plugins: [reactWithCompiler(), devOnlyGuard(), watchEntry()], plugins: [reactWithCompiler(), devOnlyGuard(), chunkGuard(), watchEntry()],
server: { port: 5173, proxy }, server: { port: 5173, proxy },
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } }, build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
}); });