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:
2026-08-23 23:05:24 +02:00
parent 5051809940
commit 9538fb1b7a
7 changed files with 230 additions and 48 deletions
@@ -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" });
});
+12 -17
View File
@@ -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();
});
});
+57 -19
View File
@@ -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(
+22 -1
View File
@@ -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);
});
});
+15
View File
@@ -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;
}
-3
View File
@@ -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