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
@@ -14,7 +14,7 @@ import { renderHook } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { createRef, type RefObject } from "react";
import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress } from "./progressCache";
import { patchCachedProgress, progressFlushes } from "./progressCache";
import { qk } from "./queryKeys";
import { api, type Video } from "./api";
import type { YTPlayer } from "./youtube";
@@ -113,6 +113,27 @@ describe("useYtProgress — the checkpoint reaches the cache", () => {
expect(cachedPos(qc)).toBe(830);
});
it("hands its closing checkpoint out until it has landed and been patched in", async () => {
// A list refetched on the player's close waits for it: read before it landed, the list held the
// older position and, landing after the patch, put it back.
const qc = new QueryClient();
seedFeed(qc, video());
atPosition(830, 3118);
let land: (v: unknown) => void = () => {};
vi.spyOn(api, "saveProgress").mockReturnValue(new Promise((r) => (land = r)) as never);
const { unmount } = mountPlayer(qc, new Map());
await settle();
unmount();
const flushes = progressFlushes();
expect(flushes).toHaveLength(1);
land({ video_id: VID, position_seconds: 830 });
await flushes[0];
expect(cachedPos(qc)).toBe(830);
await settle();
expect(progressFlushes()).toHaveLength(0);
});
it("mirrors what the SERVER stored, not what the player read", async () => {
const qc = new QueryClient();
seedFeed(qc, video({ position_seconds: 748 }));
+13 -10
View File
@@ -19,7 +19,7 @@ import {
import { useBackToExit } from "./history";
import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import { patchCachedProgress } from "./progressCache";
import { patchCachedProgress, trackProgressFlush } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
@@ -527,15 +527,18 @@ export function useYtProgress({
window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
void Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
// fetched with — and the next checkpoint then persisted that older value over this one.
patchCachedProgress(qc, sessionPos);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
});
// Tracked, so a list refetch on the player's close waits for it (`progressFlushes`).
trackProgressFlush(
Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
// fetched with — and the next checkpoint then persisted that older value over this one.
patchCachedProgress(qc, sessionPos);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
}),
);
const p = playerRef.current;
if (p && typeof p.destroy === "function") {
try {
+13
View File
@@ -25,6 +25,19 @@ const isItemList = (v: unknown): v is ItemList =>
* read: the endpoint clears trivially-early and near-finished checkpoints to 0, and mirroring our
* own reading would re-introduce that disagreement on the client.
*/
// The player's closing flushes still on their way: its last checkpoint and the patch above. A list
// refetched before one lands read the older checkpoint and, landing after the patch, put it back.
const flushes = new Set<Promise<unknown>>();
/** Track a closing flush until it settles (`progressFlushes` hands out the ones still running). */
export function trackProgressFlush(flush: Promise<unknown>): void {
flushes.add(flush);
const settled = () => void flushes.delete(flush);
flush.then(settled, settled);
}
export const progressFlushes = (): Promise<unknown>[] => [...flushes];
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
if (positions.size === 0) return;