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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user