fix(feed): an override yields only to a refetch that set out after its write was answered

Send order let a refetch read before an unanswered mark committed and drop it. A reset's position 0 is written again after a refetch that set out before the server took the reset. The player-session refetch also waits for the player's closing checkpoint, which the player now tracks (progressFlushes); refetched first, the list put the older position back.
This commit is contained in:
2026-10-09 23:46:39 +02:00
parent 8776586ca3
commit 2cff4ad954
7 changed files with 173 additions and 33 deletions
@@ -40,10 +40,14 @@ vi.mock("./VirtualFeed", () => ({
),
}));
// 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 }));
// `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks.
const player = vi.hoisted(() => ({
watchOnUnmount: false,
flush: null as Promise<unknown> | null,
}));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
const { trackProgressFlush } = await import("../lib/progressCache");
return {
default: function Player({
video,
@@ -57,6 +61,7 @@ vi.mock("./PlayerModal", async () => {
useEffect(
() => () => {
if (player.watchOnUnmount) onState(video.id, "watched");
if (player.flush) trackProgressFlush(player.flush);
},
[video.id, onState],
);
@@ -91,6 +96,7 @@ afterEach(() => {
cleanup();
vi.restoreAllMocks();
player.watchOnUnmount = false;
player.flush = null;
});
const FILTERS: FeedFilters = {
@@ -203,6 +209,22 @@ describe("Feed — a video marked watched in the player", () => {
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("waits for the player's closing checkpoint before it refetches", async () => {
// Refetched before that write landed, the list read the older position and, landing after the
// player's patch, put it back: a reopen resumed early.
const { feed } = mount();
let land: (v: unknown) => void = () => {};
player.flush = new Promise((r) => (land = r));
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)));
expect(feed.mock.calls.length).toBe(before);
await act(async () => land(undefined));
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"));