From 9538fb1b7a67d1ee2aff0b40fdc836d7c44a6027 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 23:05:24 +0200 Subject: [PATCH] fix(views): apply the starred default view on first resolve, for feed AND plex (S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../FeedFiltersProvider.dom.test.ts | 8 -- .../src/components/FeedFiltersProvider.tsx | 29 ++-- .../SavedViewsProvider.dom.test.tsx | 124 ++++++++++++++++++ .../src/components/SavedViewsProvider.tsx | 76 ++++++++--- frontend/src/lib/savedViews.test.ts | 23 +++- frontend/src/lib/savedViews.ts | 15 +++ frontend/src/lib/storage.ts | 3 - 7 files changed, 230 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/SavedViewsProvider.dom.test.tsx diff --git a/frontend/src/components/FeedFiltersProvider.dom.test.ts b/frontend/src/components/FeedFiltersProvider.dom.test.ts index 2d54581c..b55edddc 100644 --- a/frontend/src/components/FeedFiltersProvider.dom.test.ts +++ b/frontend/src/components/FeedFiltersProvider.dom.test.ts @@ -19,14 +19,6 @@ describe("loadAccountFilters — legacy tagMode migration on seed", () => { expect(f.q).toBe("hi"); // other fields preserved through the merge }); - it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => { - localStorage.setItem( - accountKey(LS.defaultViewFilters, ID)!, - JSON.stringify({ tagMode: "and" }), - ); - expect(loadAccountFilters(ID).tagModes.topic).toBe("and"); - }); - it("leaves tagModes at defaults when nothing is stored", () => { expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" }); }); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 2a0d9ad3..8ef6d8ea 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -51,16 +51,16 @@ const DEFAULT_FILTERS: FeedFilters = { const FILTERS_KEY = LS.filters; -// This account's own persisted filters (per-account keys — never another account's). A chosen -// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When -// the account isn't known yet (first load, before the tab is pinned), start from defaults; the -// post-login effect loads the real ones once the id arrives. +// This account's own persisted filters (per-account keys — never another account's). When the +// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login +// effect loads the real ones once the id arrives. export function loadAccountFilters(id: number | null): FeedFilters { // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you - // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The - // starred default view only SEEDS a fresh account that has never stored filters: it drives the - // very first load, after which that state persists like any other. (Re-apply the default view - // from the sidebar to return to it.) + // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred + // default view seeds a fresh account that has never stored filters, but that is applied ONCE by + // SavedViewsProvider on its views query's first resolve (which then persists here like any edit) — + // not read from a mirror on this synchronous path, which used to race that resolve. So here we only + // ever read the account's own stored filters, else fall back to DEFAULT_FILTERS. const fKey = accountKey(FILTERS_KEY, id); let hasStored = false; try { @@ -68,19 +68,14 @@ export function loadAccountFilters(id: number | null): FeedFilters { } catch { /* localStorage may be unavailable */ } - // Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path, - // matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes - // MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects - // DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. + // Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the + // saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the + // RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES + // (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. if (hasStored && fKey) { const raw = readJSON>(fKey, {}); return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) }; } - const dvKey = accountKey(LS.defaultViewFilters, id); - const def = dvKey ? readJSON | null>(dvKey, null) : null; - if (def) { - return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) }; - } return { ...DEFAULT_FILTERS }; } diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx new file mode 100644 index 00000000..45172c89 --- /dev/null +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -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(); + 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 => ({ + id: 1, + name: "v", + filters: {}, + position: 0, + is_default: false, + ...over, +}); + +function Probe() { + const { loading } = useSavedViews(); + return
{String(loading)}
; +} + +async function mountResolved(views: SavedView[]) { + vi.spyOn(api, "savedViews").mockResolvedValue(views); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + // 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(); + }); +}); diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index c02db1a1..046d7d73 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -1,4 +1,12 @@ -import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; @@ -12,10 +20,10 @@ import { type PlexFilters, type SavedView, } from "../lib/api"; -import { filtersToParams } from "../lib/urlState"; -import { preferSticky } from "../lib/savedViews"; +import { filtersToParams, hasFilterParams } from "../lib/urlState"; +import { pickDefaultToSeed, preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; -import { accountKey, LS, writeJSON } from "../lib/storage"; +import { accountKey, LS } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -46,20 +54,35 @@ interface ViewConfig { keyOf: (f: Record) => string; asFilters: (raw: Record) => Record; stripForSave: (f: Record) => Record; - /** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */ - syncDefaultMirror?: (views: SavedView[]) => void; + /** Whether this module is a genuinely fresh start the starred default may seed on first load: no + * stored filters for the active account, and — feed only — no URL filter override. */ + canSeedDefault: () => boolean; } const feedKeyOf = (f: Record) => filtersToParams(asFilters(f as unknown as FeedFilters)).toString(); const plexKeyOf = (f: Record) => plexFiltersSignature(coercePlexFilters(f)); -// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load). -function syncFeedDefaultMirror(views: SavedView[]): void { - const key = accountKey(LS.defaultViewFilters, getActiveAccount()); - if (!key) return; - const def = views.find((v) => v.is_default); - writeJSON(key, def ? def.filters : null); +// True once this account has its own persisted filters for `lsKey` — the signal that the last +// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable → +// report "stored" so a default never seeds blind over an indeterminate state. +function storedFiltersPresent(lsKey: string): boolean { + const key = accountKey(lsKey, getActiveAccount()); + if (!key) return true; + try { + return localStorage.getItem(key) != null; + } catch { + return true; + } +} +// Feed also yields to a URL that carries filter params (a shared/bookmarked view). +function feedCanSeedDefault(): boolean { + if (hasFilterParams(new URLSearchParams(window.location.search))) return false; + return !storedFiltersPresent(LS.filters); +} +// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key. +function plexCanSeedDefault(): boolean { + return !storedFiltersPresent(LS.plexFilters); } export interface SavedViewsContext { @@ -105,6 +128,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { keyOf: plexKeyOf, asFilters: (raw) => coercePlexFilters(raw) as unknown as Record, stripForSave: (f) => f, // Plex has no transient field to drop + canSeedDefault: plexCanSeedDefault, }; } return { @@ -119,7 +143,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { const { seed: _seed, ...rest } = f; return rest; }, - syncDefaultMirror: syncFeedDefaultMirror, + canSeedDefault: feedCanSeedDefault, }; }, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]); @@ -140,13 +164,27 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { setModalOpen(false); }, [source]); - // Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the - // mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes - // on every live filter edit, which would fire this synchronous localStorage write far beyond its intent. - const syncMirror = cfg.syncDefaultMirror; + // The ONE default-application path for both modules. On the views query's FIRST resolve for a + // source, seed the starred default view's filters — but only into a genuinely fresh module (no + // stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists + // it, so it happens once and the normal last-view-wins path takes over, exactly as the storage + // semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's + // login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a + // reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a + // 60s background refetch never re-seeds over the filters you're editing. + const seededRef = useRef>(new Set()); useEffect(() => { - if (viewsQuery.data) syncMirror?.(viewsQuery.data); - }, [viewsQuery.data, syncMirror]); + const data = viewsQuery.data; + if (!data || seededRef.current.has(source)) return; + seededRef.current.add(source); + const def = pickDefaultToSeed(data, cfg.canSeedDefault()); + if (!def) return; + cfg.setFilters(cfg.asFilters(def.filters)); + setStickyId(def.id); + // cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg + // (which re-memoizes on every filter edit) is deliberately kept out of the deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewsQuery.data, source]); const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo( diff --git a/frontend/src/lib/savedViews.test.ts b/frontend/src/lib/savedViews.test.ts index 3ebe93d5..c1cce3c6 100644 --- a/frontend/src/lib/savedViews.test.ts +++ b/frontend/src/lib/savedViews.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { preferSticky } from "./savedViews"; +import { pickDefaultToSeed, preferSticky } from "./savedViews"; const v = (id: number) => ({ id, name: `v${id}` }); @@ -24,3 +24,24 @@ describe("preferSticky", () => { expect(preferSticky([v(1), v(2)], null)?.id).toBe(1); }); }); + +describe("pickDefaultToSeed", () => { + const dv = (id: number, is_default = false) => ({ id, name: `v${id}`, is_default }); + + it("returns the starred default when the module is a fresh start", () => { + expect(pickDefaultToSeed([dv(1), dv(2, true)], true)?.id).toBe(2); + }); + + it("returns null when the module already has stored filters / a URL override (canSeed=false)", () => { + expect(pickDefaultToSeed([dv(1), dv(2, true)], false)).toBeNull(); + }); + + it("returns null when there is no starred default, even on a fresh start", () => { + expect(pickDefaultToSeed([dv(1), dv(2)], true)).toBeNull(); + expect(pickDefaultToSeed([], true)).toBeNull(); + }); + + it("picks the first default deterministically if several are somehow flagged", () => { + expect(pickDefaultToSeed([dv(1, true), dv(2, true)], true)?.id).toBe(1); + }); +}); diff --git a/frontend/src/lib/savedViews.ts b/frontend/src/lib/savedViews.ts index cce4e46a..0902fa5b 100644 --- a/frontend/src/lib/savedViews.ts +++ b/frontend/src/lib/savedViews.ts @@ -10,3 +10,18 @@ export function preferSticky( ): T | null { return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null; } + +/** The starred default view to seed a module's filters with on first load, or null to leave them. + * Seeds ONLY when the module is a genuinely fresh start (`canSeed`: no stored filters for this + * account and — feed only — no URL filter override); otherwise the last-session/URL/manual filters + * win, exactly as they do after any edit. First `is_default` wins if somehow several are flagged, so + * the choice is deterministic. This is the single default-application path for BOTH the feed and Plex + * (SavedViewsProvider drives it on the views query's first resolve) — it replaced the feed-only + * localStorage mirror that raced the login effect and left Plex with no default at all. */ +export function pickDefaultToSeed( + views: T[], + canSeed: boolean, +): T | null { + if (!canSeed) return null; + return views.find((v) => v.is_default) ?? null; +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index de66b8b8..6610d308 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -14,9 +14,6 @@ export const LS = { hints: "siftlode.hints", lang: "siftlode.lang", filters: "siftlode.filters", - // Mirror of the default saved view's filters, so it can be applied synchronously on load - // (before the async saved-views query resolves). Kept in sync by SavedViewsWidget. - defaultViewFilters: "siftlode.defaultViewFilters", page: "siftlode.page", perfMode: "siftlode.perfMode", // The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an