fix(views): apply the starred default view on first resolve, for feed AND plex (S2)

The feed applied its starred default via a localStorage mirror
(LS.defaultViewFilters) written by SavedViewsProvider and read by
FeedFiltersProvider on load. That raced the login effect: on a genuinely
fresh browser (no LS.filters and no mirror yet) the feed could seed
DEFAULT_FILTERS and never re-apply the default until a reload. Plex had no
default-application at all.

Replace the mirror with a single path: SavedViewsProvider applies the
starred default via cfg.setFilters on the views query's FIRST resolve, but
only into a genuinely fresh module (no stored filters for the account, and
— feed only — no URL filter override). Applying persists it, so it happens
once and the last-view-wins path takes over, matching the documented
storage semantics. Because it runs off cfg.setFilters it delivers the Plex
port for free. Extract pickDefaultToSeed (pure, unit-tested) and cover the
first-resolve/stored/no-default cases for both modules in a provider test.

Removes the now-dead LS.defaultViewFilters key and its mirror read.
This commit is contained in:
2026-08-23 23:05:24 +02:00
parent 5051809940
commit 9538fb1b7a
7 changed files with 230 additions and 48 deletions
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
//
// S2 — the single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
// api.savedViews is stubbed and driven through a real QueryClient.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const h = vi.hoisted(() => ({
page: "feed" as "feed" | "plex",
setFeedFilters: vi.fn(),
setPlexFilters: vi.fn(),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) }));
vi.mock("./FeedFiltersProvider", () => ({
useFeedFilters: () => ({
tags: [],
channelIds: [],
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
}),
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
}));
vi.mock("./PlexProvider", () => ({
usePlex: () => ({ filters: {} }),
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
}));
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
import { api, type SavedView } from "../lib/api";
import { LS, accountKey } from "../lib/storage";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
const ID = 7;
const view = (over: Partial<SavedView>): SavedView => ({
id: 1,
name: "v",
filters: {},
position: 0,
is_default: false,
...over,
});
function Probe() {
const { loading } = useSavedViews();
return <div data-testid="loading">{String(loading)}</div>;
}
async function mountResolved(views: SavedView[]) {
vi.spyOn(api, "savedViews").mockResolvedValue(views);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<SavedViewsProvider>
<Probe />
</SavedViewsProvider>
</QueryClientProvider>,
);
// Wait for the query to resolve so the first-resolve effect has run.
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
}
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
h.page = "feed";
h.setFeedFilters.mockClear();
h.setPlexFilters.mockClear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
});
it("feed: does NOT seed when the account already has stored filters", async () => {
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("feed: does NOT seed when no view is starred as default", async () => {
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
h.page = "plex";
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
});
it("plex: does NOT seed when plex filters are already stored", async () => {
h.page = "plex";
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).not.toHaveBeenCalled();
});
});