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:
@@ -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 }));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user