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:
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
}
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
|
||||
if (def) {
|
||||
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, unknown>) => string;
|
||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** 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<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => 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<string, unknown>,
|
||||
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<Set<ViewSource>>(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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,3 +10,18 @@ export function preferSticky<T extends { id: number }>(
|
||||
): 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<T extends { is_default: boolean }>(
|
||||
views: T[],
|
||||
canSeed: boolean,
|
||||
): T | null {
|
||||
if (!canSeed) return null;
|
||||
return views.find((v) => v.is_default) ?? null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user