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
@@ -24,7 +24,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
import ChannelPage from "./ChannelPage";
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 }),
}));
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.
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
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.
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
@@ -21,7 +21,7 @@ vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
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) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
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 { useScrollFold } from "../../components/ui/scrollFold";
import Feed from "./Feed";
import ChannelAboutContent from "../channels/ChannelAbout";
import ChannelAboutContent from "../../features/channel/ChannelAbout";
import { PageToolbar } from "../../components/ui/PageShell";
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
@@ -46,7 +46,7 @@ import {
} from "../../lib/api";
import type { FeedView } from "../../lib/feedView";
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
import { ChannelTagsControl } from "../channels/ChannelTags";
import { ChannelTagsControl } from "../../features/channel/ChannelTags";
import { useChannelTagActions } from "../../lib/useChannelTagActions";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
@@ -28,7 +28,7 @@ vi.mock("./Feed", () => ({
return null;
},
}));
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
@@ -45,7 +45,7 @@ vi.mock("../plex/plexContext", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import { api, type SavedView } from "../../lib/api";
import { claimLocalData, LS } from "../../lib/storage";
import { qk } from "../../lib/queryKeys";
@@ -17,13 +17,13 @@ import { LS, ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext";
import { useNavigation } from "../../features/navigation/navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "../plex/plexContext";
import { usePlex, usePlexActions } from "../plex";
import {
asFilters,
SavedViewsCtx,
type SavedViewsContext,
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
// 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 { useDebounced } from "../../lib/useDebounced";
import { useActiveFilters } from "../../lib/useActiveFilters";
import TagManager from "../channels/TagManager";
import TagManager from "../../features/channel/TagManager";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import { useSavedViews } from "./savedViewsContext";
import { useSavedViews } from "../../features/filters/savedViewsContext";
import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups";
import { type MediaSourceKind } from "../../lib/mediaSource";
@@ -20,7 +20,7 @@ import YearFilter from "./YearFilter";
import Combobox from "../../components/ui/Combobox";
import MatchToggle from "../../components/ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "../channels/channelsContext";
import { useChannelsActions } from "../channels";
import { useTags } from "../../lib/useTags";
import { compareText } from "../../lib/collate";
@@ -1,102 +0,0 @@
// @vitest-environment jsdom
//
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
// second re-fit needs no user action at all.
//
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
}),
};
});
const fit = { visible: 1 };
vi.mock("../../lib/useChipOverflowFit", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/useChipOverflowFit")>();
return { ...actual, useChipOverflowFit: () => fit.visible };
});
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
cleanup();
fit.visible = 1;
});
describe("TagFilterChips — the overflow menu", () => {
it("does not come back on its own once the lane stops overflowing", () => {
const { rerender } = render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
// nothing is collapsed any more: both trigger and menu go.
fit.visible = 3;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toBeNull();
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
// returning trigger must not claim to be expanded.
fit.visible = 1;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("does not come back when the chip set SHRINKS under the open menu", () => {
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
// unclamped never fires, and the flag survives the one window it exists to close.
//
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
fit.visible = 3;
const { rerender } = render(<TagFilterChips chips={five} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
expect(menu()).toBeNull();
// …and now the layout effect lands: two long names, only one of them fits.
fit.visible = 1;
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("still opens and closes on the trigger", () => {
render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
fireEvent.click(moreBtn()!);
expect(menu()).toBeNull();
});
});
@@ -1,147 +0,0 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { Chip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { SURFACE } from "../../components/ui/surfaces";
import { ChipLabelPending } from "../../components/ui/Skeleton";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
export interface FilterChip {
key: string;
label: string;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
onToggle: () => void;
}
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
}
function TagChip({ chip }: { chip: FilterChip }) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<ChipText chip={chip} />
</Chip>
);
}
export default function TagFilterChips({
chips,
className,
}: {
chips: FilterChip[];
className?: string;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: chips.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, chips.length - visible);
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
//
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
// flag is reset in RENDER against the same condition that hides it: convergent (the write
// falsifies it) and, unlike an effect, gone before the frame is painted.
const [openRaw, setOpen] = useState(false);
if (openRaw && hidden === 0) setOpen(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
read each chip's right edge, the rem gap and the pill width off real boxes. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
>
{chips.map((c, i) => (
<span
key={c.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<span className={chipClass()}>
<ChipText chip={c} />
</span>
</span>
))}
<span ref={pillRef} className={chipClass()}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
</div>
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
);
}
+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 { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
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
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
@@ -1,50 +0,0 @@
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).
// 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).
export const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw),
untagged: coerceUntagged(raw),
});
export type ViewSource = "feed" | "plex";
export interface SavedViewsContext {
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
source: ViewSource;
views: SavedView[];
loading: boolean;
activeViewId: number | null;
displayView: SavedView | null;
isDirty: boolean;
modalOpen: boolean;
openModal: () => void;
closeModal: () => void;
apply: (v: SavedView) => void;
clearActive: () => void;
create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
/** Overwrite a view's captured filters (used by the modal's removable chips). */
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
toggleDefault: (v: SavedView) => Promise<void>;
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(SavedViewsCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
return ctx;
}