fix(feed): an optimistic write outlives only the server answers older than it

A reset wrote a position mask over the cached list, so a replay's saved
position stayed hidden: a reopen resumed at 0 and its first checkpoint wrote
over the progress. The reset now writes 0 into the cache, where the player's
closing patch writes the next one. Overrides carry their write order, and a
landing refetch drops only those written before it set out. The player
session ends after the modal unmounts, so a clip auto-marked watched as it
closes still refetches the list.
This commit is contained in:
2026-10-09 22:01:27 +02:00
parent 942154cf49
commit 568bf93510
4 changed files with 327 additions and 71 deletions
@@ -0,0 +1,177 @@
// @vitest-environment jsdom
//
// The feed's optimistic layer: a card shows what was just written to it until the server's own answer
// lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written
// before it set out), a filter change; never an append of the next page. A mark made while a refetch
// was in flight is newer than its answer, and dropping it put a card marked watched back into an
// Unwatched view. The grid is a stub that shows what Feed hands it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({
items,
onState,
fetchNextPage,
}: {
items: { id: string; status: string }[];
onState: (id: string, status: string) => void;
fetchNextPage: (o: object) => unknown;
}) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
<span>{`${v.id}:${v.status}`}</span>
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
</li>
))}
<button onClick={() => void fetchNextPage({})}>more</button>
</ol>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "all",
};
const video = (id: string) =>
({
id,
title: id,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
// The server never takes the mark in these tests: whatever a list shows as "watched" is the override.
const page = (ids: string[], next: string | null = null) =>
({ items: ids.map(video), next_cursor: next }) as never;
function mount(feed: ReturnType<typeof vi.fn>) {
vi.spyOn(api, "feed").mockImplementation(feed as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const ui = (filters: FeedFilters) => (
<QueryClientProvider client={qc}>
<Feed
filters={filters}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>
);
const r = render(ui(FILTERS));
return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) };
}
const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
describe("Feed — optimistic overrides", () => {
it("give way to a refetch that lands after them", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed);
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v1"])));
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
it("keep a mark made while the refetch was in flight", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed);
await screen.findByText("v1:new");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
fireEvent.click(screen.getByText("watch v1"));
await screen.findByText("v1:watched");
// The answer the server gave before it heard of the mark.
await act(async () => answer(page(["v1"])));
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("survive an append of the next page", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"], "c1"))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
mount(feed);
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
fireEvent.click(screen.getByText("more"));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v2"])));
await screen.findByText("v2:new");
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("are dropped by a change of the filters", async () => {
const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"])));
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
rerender({ ...FILTERS });
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
});
@@ -39,22 +39,36 @@ vi.mock("./VirtualFeed", () => ({
)
),
}));
vi.mock("./PlayerModal", () => ({
default: ({
video,
onState,
onClose,
}: {
video: { id: string };
onState: (id: string, status: string) => void;
onClose: () => void;
}) => (
<div>
<button onClick={() => onState(video.id, "watched")}>mark</button>
<button onClick={onClose}>close</button>
</div>
),
}));
// The player's progress hook marks a clip closed near its end watched from its unmount cleanup:
// `watchOnUnmount` stands in for that.
const player = vi.hoisted(() => ({ watchOnUnmount: false }));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
return {
default: function Player({
video,
onState,
onClose,
}: {
video: { id: string };
onState: (id: string, status: string) => void;
onClose: () => void;
}) {
useEffect(
() => () => {
if (player.watchOnUnmount) onState(video.id, "watched");
},
[video.id, onState],
);
return (
<div>
<button onClick={() => onState(video.id, "watched")}>mark</button>
<button onClick={onClose}>close</button>
</div>
);
},
};
});
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
@@ -76,6 +90,7 @@ beforeAll(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
player.watchOnUnmount = false;
});
const FILTERS: FeedFilters = {
@@ -164,6 +179,30 @@ describe("Feed — a video marked watched in the player", () => {
expect(screen.queryByText("open v1")).toBeNull();
});
it("refetches when the player marks the clip watched as it closes", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
player.watchOnUnmount = true;
const before = feed.mock.calls.length;
fireEvent.click(await screen.findByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("waits for the session's writes to land before it refetches", async () => {
const { feed } = mount();
let land: (v: unknown) => void = () => {};
vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never);
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await act(() => new Promise((r) => setTimeout(r, 30)));
// A refetch now would ask a server that has not taken the mark yet.
expect(feed.mock.calls.length).toBe(before);
await act(async () => land({ ok: true }));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("does not refetch on a close with nothing changed", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
+43 -20
View File
@@ -5,7 +5,7 @@
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
// hands it: each video's position, and a button that fires the reset.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -35,6 +35,7 @@ vi.mock("./VirtualFeed", () => ({
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { patchCachedProgress } from "../lib/progressCache";
beforeAll(() => {
class Stub {
@@ -78,30 +79,52 @@ const halfWatched = {
watch_url: "https://www.youtube.com/watch?v=v1",
} as unknown as Video;
function mount(clear: () => Promise<unknown>) {
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
vi.spyOn(api, "clearState").mockImplementation(clear as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
return qc;
}
describe("Feed — resetting a half-watched video", () => {
it("clears its resume position at once, not only its status", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
// Never settles: what the card shows before the server answers is the optimistic layer alone.
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
mount(() => new Promise(() => {}));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
fireEvent.click(screen.getByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
});
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
// wrote over the 40.
const qc = mount(() => Promise.resolve({ ok: true }));
fireEvent.click(await screen.findByText("reset v1"));
await screen.findByText("v1@0");
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
});
it("puts the position back when the server refuses the reset", async () => {
mount(() => Promise.reject(new Error("down")));
fireEvent.click(await screen.findByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
});
});
+52 -35
View File
@@ -27,6 +27,7 @@ import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { patchCachedProgress } from "../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -102,6 +103,21 @@ function matchesView(status: VideoStatus, show: string): boolean {
}
}
// An optimistic value and its place in this feed's write order. A refetch that lands is the server's
// word only on what was written BEFORE it set out: a mark made while it was in flight is newer than
// its answer, and dropping it put a card marked watched back into an Unwatched view.
type Stamped<T> = { value: T; seq: number };
type Overrides<T> = Record<string, Stamped<T>>;
const writtenAfter = <T,>(o: Overrides<T>, since: number): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => s.seq > since));
// A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o;
const next = { ...o };
delete next[id];
return next;
};
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
@@ -146,11 +162,11 @@ export default function Feed({
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// A reset drops the resume position too, which a status override cannot say: without this the card
// kept its progress bar and its Continue button until the next refetch.
const [resetIds, setResetIds] = useState<Record<string, true>>({});
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
// The write order the stamps above count in, and where it stood when the running refetch set out.
const writeSeq = useRef(0);
const refetchFrom = useRef(0);
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null,
@@ -174,9 +190,14 @@ export default function Feed({
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
// debounced volume write on each pass.
const closePlayer = useCallback(() => {
setActiveVideo(null);
const closePlayer = useCallback(() => setActiveVideo(null), []);
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
// every unmount cleanup before any effect's setup, so here that write is already in the session.
const playerOpen = activeVideo !== null;
useEffect(() => {
const session = playerSession.current;
if (playerOpen || !session.open) return;
session.open = false;
if (!session.changed) return;
session.changed = false;
@@ -184,7 +205,7 @@ export default function Feed({
void qc.invalidateQueries({ queryKey: qk.feed() });
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
});
}, [qc]);
}, [playerOpen, qc]);
const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more";
@@ -263,21 +284,23 @@ export default function Feed({
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
setResetIds({});
}
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
// And only the overrides written before it set out (`Stamped`).
const refetching = query.isFetching && !query.isFetchingNextPage;
const wasRefetching = useRef(false);
useEffect(() => {
const started = !wasRefetching.current && refetching;
const landed = wasRefetching.current && !refetching && query.status === "success";
wasRefetching.current = refetching;
if (started) refetchFrom.current = writeSeq.current;
if (landed) {
setOverrides({});
setSavedOverrides({});
setResetIds({});
const since = refetchFrom.current;
setOverrides((o) => writtenAfter(o, since));
setSavedOverrides((o) => writtenAfter(o, since));
}
}, [refetching, query.status]);
@@ -302,7 +325,8 @@ export default function Feed({
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
const onState = useCallback(
(id: string, status: VideoStatus) => {
setOverrides((o) => ({ ...o, [id]: status }));
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const session = playerSession.current;
if (session.open) {
@@ -367,11 +391,7 @@ export default function Feed({
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
});
setOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
@@ -381,24 +401,21 @@ export default function Feed({
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
setOverrides((o) => ({ ...o, [id]: "new" }));
setResetIds((r) => ({ ...r, [id]: true }));
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
// The position goes into the cached lists, where the player's own closing patch writes the
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
// checkpoint wrote over the progress just made.
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
api
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
});
setResetIds((r) => {
const next = { ...r };
delete next[id];
return next;
});
setOverrides((o) => withoutWrite(o, id, seq));
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
});
},
[qc],
@@ -406,7 +423,8 @@ export default function Feed({
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
setSavedOverrides((o) => ({ ...o, [id]: saved }));
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -419,7 +437,7 @@ export default function Feed({
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
setSavedOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
@@ -436,12 +454,11 @@ export default function Feed({
list
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status } : v;
return status ? { ...v, status: status.value } : v;
})
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
.map((v) => {
const saved = savedOverrides[v.id];
return saved !== undefined ? { ...v, saved } : v;
return saved ? { ...v, saved: saved.value } : v;
});
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));