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
@@ -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"));