fix(views): seed the default view only after the tab-account pins
The S2 seed effect ran on the saved-views query's first resolve, but that query is ungated and — since SavedViewsProvider mounts above <App>, 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.
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
// 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.
|
// api.savedViews is stubbed and driven through a real QueryClient.
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
const h = vi.hoisted(() => ({
|
const h = vi.hoisted(() => ({
|
||||||
@@ -43,7 +43,7 @@ vi.mock("./PlexProvider", () => ({
|
|||||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||||
|
|
||||||
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
|
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 { LS, accountKey } from "../lib/storage";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
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" });
|
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 <App>, 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 () => {
|
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
|||||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
|
import { useActiveAccountId } from "../lib/activeAccount";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useNavigation } from "./NavigationProvider";
|
import { useNavigation } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
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
|
// 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
|
// 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.
|
// 60s background refetch never re-seeds over the filters you're editing.
|
||||||
|
//
|
||||||
|
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, 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<Set<ViewSource>>(new Set());
|
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const data = viewsQuery.data;
|
const data = viewsQuery.data;
|
||||||
if (!data || seededRef.current.has(source)) return;
|
if (!data || accountId == null || seededRef.current.has(source)) return;
|
||||||
seededRef.current.add(source);
|
seededRef.current.add(source);
|
||||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||||
if (!def) return;
|
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
|
// 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.
|
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewsQuery.data, source]);
|
}, [viewsQuery.data, source, accountId]);
|
||||||
|
|
||||||
const currentKey = cfg.keyOf(cfg.filters);
|
const currentKey = cfg.keyOf(cfg.filters);
|
||||||
const activeView = useMemo(
|
const activeView = useMemo(
|
||||||
|
|||||||
Reference in New Issue
Block a user