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
@@ -29,6 +29,7 @@ vi.mock("./VirtualFeed", () => ({
<li key={v.id}>
<span>{`${v.id}:${v.status}`}</span>
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
</li>
))}
<button onClick={() => void fetchNextPage({})}>more</button>
@@ -87,10 +88,11 @@ const video = (id: string) =>
const page = (ids: string[], next: string | null = null) =>
({ items: ids.map(video), next_cursor: next }) as never;
function mount(feed: ReturnType<typeof vi.fn>) {
function mount(feed: ReturnType<typeof vi.fn>, setState?: () => Promise<unknown>) {
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);
if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never);
else 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) => (
@@ -149,6 +151,44 @@ describe("Feed — optimistic overrides", () => {
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("keep a mark sent before the refetch set out but not answered by then", async () => {
// The server may read the list before it commits the mark: that answer is older than the mark.
let answer: (v: unknown) => void = () => {};
let commit: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed, () => new Promise((r) => (commit = r)));
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 settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
await act(async () => commit({ ok: true }));
});
it("are taken back by a failed write only while no later write stands", async () => {
// Marked watched, then hidden before the first answer came back: the refused mark must not take
// the hide with it and put the card back in the view.
const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = [];
mount(
vi.fn().mockResolvedValue(page(["v1", "v2"])),
() => new Promise((resolve, reject) => writes.push({ resolve, reject })),
);
fireEvent.click(await screen.findByText("watch v1"));
fireEvent.click(await screen.findByText("hide v1"));
await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull());
await act(async () => writes[0]?.reject(new Error("refused")));
await settle();
expect(screen.queryByText(/^v1:/)).toBeNull();
await act(async () => writes[1]?.reject(new Error("refused")));
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
it("survive an append of the next page", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi