From 852b227ea2dc67bd442f27095426db8d00a85a81 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 24 Aug 2026 22:09:39 +0200 Subject: [PATCH] fix(views): seed the default view only after the tab-account pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S2 seed effect ran on the saved-views query's first resolve, but that query is ungated and — since SavedViewsProvider mounts above , which pins the tab-account only after /api/me — resolves before the account is known on a fresh tab. It then evaluated canSeedDefault() against a null account (which reports 'stored, do not seed') and burned its one-shot guard, so the starred default was never applied until a reload: exactly the fresh-tab miss S2 set out to fix. Gate the effect on the reactive account id so it defers instead of consuming the guard, and re-fires when the account pins. Covers feed + plex. Adds a dom test for the defer-then-seed-on-pin path the old test masked by pre-pinning the account. --- .../SavedViewsProvider.dom.test.tsx | 19 +++++++++++++++++-- .../src/components/SavedViewsProvider.tsx | 13 +++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx index 45172c89..ad23dae0 100644 --- a/frontend/src/components/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -7,7 +7,7 @@ // 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 { render, screen, waitFor, cleanup, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ @@ -43,7 +43,7 @@ vi.mock("./PlexProvider", () => ({ vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider"; -import { api, type SavedView } from "../lib/api"; +import { api, setActiveAccount, type SavedView } from "../lib/api"; import { LS, accountKey } from "../lib/storage"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; @@ -97,6 +97,21 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => { expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" }); }); + it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => { + // The provider mounts above , which pins the tab-account only after /api/me; the ungated + // views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its + // one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload). + sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); + await mountResolved([ + view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }), + ]); + expect(h.setFeedFilters).not.toHaveBeenCalled(); + // App pins the account; the reactive id re-fires the effect and the deferred seed lands. + act(() => setActiveAccount(ID)); + await waitFor(() => 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" } })]); diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 046d7d73..c17b9dd3 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -24,6 +24,7 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState"; import { pickDefaultToSeed, preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS } from "../lib/storage"; +import { useActiveAccountId } from "../lib/activeAccount"; import { useConfirm } from "./ConfirmProvider"; import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -172,10 +173,18 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { // 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. + // + // WAIT FOR THE ACCOUNT PIN. This provider mounts above , which pins the tab-account only after + // /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null. + // canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored") + // with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never + // re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the + // pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real. + const accountId = useActiveAccountId(); const seededRef = useRef>(new Set()); useEffect(() => { const data = viewsQuery.data; - if (!data || seededRef.current.has(source)) return; + if (!data || accountId == null || seededRef.current.has(source)) return; seededRef.current.add(source); const def = pickDefaultToSeed(data, cfg.canSeedDefault()); if (!def) return; @@ -184,7 +193,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { // 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]); + }, [viewsQuery.data, source, accountId]); const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo(