diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx
index 68d52777..95c3a5b0 100644
--- a/frontend/src/components/ChannelTags.tsx
+++ b/frontend/src/components/ChannelTags.tsx
@@ -121,8 +121,12 @@ function SuggestedChipsLane({
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
- // reference that has gone. Same shape as TagFilterChips and the second row below.
+ // reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
+ // render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
+ // unmounts under the cursor gets no mouseleave), so without this it springs back the next time
+ // the lane overflows. Convergent — the write falsifies its own condition.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
+ if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
@@ -355,8 +359,10 @@ export function ChannelTagsControl({
});
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
- // anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render.
+ // anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
+ // and the flag reset with it, or the card returns unasked on the next overflow.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
+ if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 7cd41c06..71d60f99 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -86,6 +86,7 @@ export default function Channels() {
setLayout,
setSort,
focusChannel: onFocusChannel,
+ consumeFocus,
} = useChannelsActions();
const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters();
@@ -284,12 +285,19 @@ export default function Channels() {
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
// overwriting whatever the user had since typed.
+ //
+ // `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
+ // every mount, and this page unmounts on every navigation away — and on opening a channel page,
+ // which does not even change `page`. The provider then reports `focusName` as null, so a later
+ // visit leaves the box exactly as the user left it.
const focusChannelNameRef = useRef(focusChannelName);
focusChannelNameRef.current = focusChannelName;
useEffect(() => {
const name = focusChannelNameRef.current;
- if (name) setSearch(name);
- }, [focusChannelToken, setSearch]);
+ if (!name) return;
+ setSearch(name);
+ consumeFocus();
+ }, [focusChannelToken, setSearch, consumeFocus]);
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx
index 72f5fa6a..d6521c39 100644
--- a/frontend/src/components/ChannelsProvider.dom.test.tsx
+++ b/frontend/src/components/ChannelsProvider.dom.test.tsx
@@ -8,8 +8,8 @@
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
import { describe, it, expect, beforeEach, vi } from "vitest";
-import { renderHook, act } from "@testing-library/react";
-import type { ReactNode } from "react";
+import { renderHook, act, render as renderRoot } from "@testing-library/react";
+import { useEffect, useRef, type ReactNode } from "react";
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
@@ -127,3 +127,85 @@ describe("ChannelsProvider — S8 shareable URL", () => {
});
});
});
+
+// --- the focus-a-channel intent is consumed, not masked -------------------------------------------
+// `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page
+// chunk: it unmounts on every navigation away, and on opening a channel page (which does not even
+// change `page`), and its seeding effect runs again on each mount. So the "already applied" marker
+// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
+// from earlier in the session over whatever the user has typed into the search box since.
+describe("ChannelsProvider — the focus-a-channel intent", () => {
+ /** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */
+ function Manager() {
+ const { focusName, focusToken } = useChannels();
+ const { setSearch, consumeFocus } = useChannelsActions();
+ const nameRef = useRef(focusName);
+ nameRef.current = focusName;
+ useEffect(() => {
+ const name = nameRef.current;
+ if (!name) return;
+ setSearch(name);
+ consumeFocus();
+ }, [focusToken, setSearch, consumeFocus]);
+ return null;
+ }
+
+ function Harness({ mounted }: { mounted: boolean }) {
+ return <>{mounted && }>;
+ }
+
+ it("seeds the search box once, and a later visit does not re-seed it", () => {
+ let api!: ReturnType & ReturnType;
+ function Probe() {
+ api = { ...useChannels(), ...useChannelsActions() };
+ return null;
+ }
+ const { rerender } = renderRoot(
+
+
+
+ ,
+ );
+
+ act(() => api.focusChannel("Kurzgesagt"));
+ expect(api.search).toBe("Kurzgesagt");
+
+ // The user looks for something else instead.
+ act(() => api.setSearch("veritasium"));
+
+ // Leave the manager (the page chunk unmounts) and come back.
+ rerender(
+
+
+
+ ,
+ );
+ rerender(
+
+
+
+ ,
+ );
+
+ expect(api.search).toBe("veritasium");
+ });
+
+ it("re-seeds when the channel is focused again — the token is the intent", () => {
+ let api!: ReturnType & ReturnType;
+ function Probe() {
+ api = { ...useChannels(), ...useChannelsActions() };
+ return null;
+ }
+ renderRoot(
+
+
+
+ ,
+ );
+
+ act(() => api.focusChannel("Kurzgesagt"));
+ act(() => api.setSearch("veritasium"));
+ act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
+ expect(api.search).toBe("Kurzgesagt");
+ });
+});
diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx
index a412093b..a4d10373 100644
--- a/frontend/src/components/ChannelsProvider.tsx
+++ b/frontend/src/components/ChannelsProvider.tsx
@@ -183,7 +183,16 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
// one render earlier than the effect used to make it.
- const focusName = page === "channels" ? focusNameRaw : null;
+ //
+ // But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
+ // Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
+ // does not even change `page`), so its seeding effect runs again on each remount. A marker inside
+ // it would reset with it and re-apply an intent from earlier in the session over whatever the user
+ // has since typed into the search box. Keyed on the token rather than the name, because
+ // re-focusing the same channel is a new intent.
+ const [focusApplied, setFocusApplied] = useState(0);
+ const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
+ const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
@@ -216,9 +225,19 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
setLayout,
setSort,
focusChannel,
+ consumeFocus,
goToFullHistory,
}),
- [setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
+ [
+ setSearch,
+ setFilters,
+ setView,
+ setLayout,
+ setSort,
+ focusChannel,
+ consumeFocus,
+ goToFullHistory,
+ ],
);
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx
index 250b0e6a..9137517a 100644
--- a/frontend/src/components/PlayerModal.dom.test.tsx
+++ b/frontend/src/components/PlayerModal.dom.test.tsx
@@ -351,3 +351,53 @@ describe("PlayerModal — the dialog contract", () => {
}
});
});
+
+// --- the watch toggle belongs to the item it describes -------------------------------------------
+// A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the
+// commit that already advanced `active`, and within the finish margin it auto-marks that item
+// watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item
+// as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored.
+describe("PlayerModal — the watch toggle after a queue advance", () => {
+ const A = vid("A", 0);
+ const B = vid("B", 0);
+
+ beforeEach(() => {
+ vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
+ vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
+ video_id: id,
+ position_seconds: 0,
+ }));
+ });
+
+ it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => {
+ const onState = vi.fn();
+ clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000
+ renderModal(
+ {}} onState={onState} />,
+ );
+ await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
+
+ next();
+ await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
+ await settle();
+
+ // A really is marked watched — that part is correct and must keep working.
+ expect(onState.mock.calls).toContainEqual(["A", "watched"]);
+ // B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
+ expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
+ expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
+ });
+
+ it("still reflects a watch toggled on the item you are actually on", async () => {
+ const onState = vi.fn();
+ clock.current = 0;
+ renderModal(
+ {}} onState={onState} />,
+ );
+ await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
+
+ fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
+ expect(onState.mock.calls).toContainEqual(["A", "watched"]);
+ expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
+ });
+});
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 23db65a1..c3a3e8b0 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -234,25 +234,36 @@ export default function PlayerModal({
setLiveData(null);
};
- // Local mirror of watch status so the toggle reacts instantly; changes are
- // propagated to the feed (and server) via onState.
- const [status, setStatus] = useState(active.status);
- const watched = status === "watched";
+ // Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
+ // (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
+ // is ignored rather than displayed — that is the enforcement, and it has to be in code because
+ // one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
+ // after the commit that already advanced `active`, and near the end of a clip it auto-marks that
+ // item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
+ // item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
+ // Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
+ // onState(next, "new") for an item that was already new.
+ const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
+ id: active.id,
+ value: active.status,
+ });
+ const watched = (status.id === active.id ? status.value : active.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
- setStatus(next);
+ setStatus({ id: active.id, value: next });
onState(active.id, next);
};
- // On queue advance (active changes), sync local state to the new item — DURING that render, not
- // one commit later. As an effect, the frame between showed the new episode's chrome over the
- // previous one's watch state and `navigated` flag (currentVideoId still pointed at the old id, so
- // the "you have navigated away" affordance flashed on every advance). React discards this pass
- // instead of committing it, so there is no such frame; the guard falsifies itself immediately.
+ // On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
+ // not one commit later. As an effect, the frame between still had it on the previous id, so the
+ // "you have navigated away" affordance flashed on every advance. React discards this pass instead
+ // of committing it, so there is no such frame; the guard falsifies itself immediately.
+ //
+ // The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
+ // immune to the late writer this block cannot see (see that comment).
const [syncedTo, setSyncedTo] = useState(active.id);
if (syncedTo !== active.id) {
setSyncedTo(active.id);
- setStatus(active.status);
setCurrentVideoId(active.id);
}
diff --git a/frontend/src/components/TagFilterChips.dom.test.tsx b/frontend/src/components/TagFilterChips.dom.test.tsx
new file mode 100644
index 00000000..9782c8d9
--- /dev/null
+++ b/frontend/src/components/TagFilterChips.dom.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+//
+// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
+// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
+// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
+// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
+// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
+// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
+// second re-fit needs no user action at all.
+//
+// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
+// measurement can only ever produce one answer.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (k: string, o?: Record) => (o?.count != null ? `${k}:${o.count}` : k),
+ }),
+ };
+});
+
+const fit = { visible: 1 };
+vi.mock("../lib/useChipOverflowFit", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useChipOverflowFit: () => fit.visible };
+});
+
+import TagFilterChips, { type FilterChip } from "./TagFilterChips";
+
+const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
+const chips = [chip("alpha"), chip("beta"), chip("gamma")];
+
+// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
+// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
+// `hidden: true` query can see it. Presence is the whole question here, not visibility.
+const menu = () => screen.queryByRole("menu", { hidden: true });
+const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
+
+afterEach(() => {
+ cleanup();
+ fit.visible = 1;
+});
+
+describe("TagFilterChips — the overflow menu", () => {
+ it("does not come back on its own once the lane stops overflowing", () => {
+ const { rerender } = render();
+ fireEvent.click(moreBtn()!);
+ expect(menu()).toBeTruthy();
+
+ // The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
+ // nothing is collapsed any more: both trigger and menu go.
+ fit.visible = 3;
+ rerender();
+ expect(menu()).toBeNull();
+ expect(moreBtn()).toBeNull();
+
+ // …and it narrows again, with no user interaction in between. The menu must stay shut, and the
+ // returning trigger must not claim to be expanded.
+ fit.visible = 1;
+ rerender();
+ expect(menu()).toBeNull();
+ expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
+ });
+
+ it("still opens and closes on the trigger", () => {
+ render();
+ fireEvent.click(moreBtn()!);
+ expect(menu()).toBeTruthy();
+ fireEvent.click(moreBtn()!);
+ expect(menu()).toBeNull();
+ });
+});
diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx
index ff764996..d4e9359b 100644
--- a/frontend/src/components/TagFilterChips.tsx
+++ b/frontend/src/components/TagFilterChips.tsx
@@ -62,7 +62,17 @@ export default function TagFilterChips({
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
+ //
+ // Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
+ // a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
+ // and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
+ // a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
+ // toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
+ // would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
+ // flag is reset in RENDER against the same condition that hides it: convergent (the write
+ // falsifies it) and, unlike an effect, gone before the frame is painted.
const [openRaw, setOpen] = useState(false);
+ if (openRaw && hidden === 0) setOpen(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
diff --git a/frontend/src/components/channelsContext.ts b/frontend/src/components/channelsContext.ts
index 5aacab1f..99f49cd0 100644
--- a/frontend/src/components/channelsContext.ts
+++ b/frontend/src/components/channelsContext.ts
@@ -33,6 +33,10 @@ export interface ChannelsActions {
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
+ // Mark the current focus intent as applied. The manager calls this the moment it seeds its name
+ // filter, so the intent cannot be re-applied when the page chunk remounts on a later visit and
+ // overwrite a search the user has typed since.
+ consumeFocus: () => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
@@ -57,6 +61,7 @@ export const ChannelsActionsContext = createContext({
setLayout: () => {},
setSort: () => {},
focusChannel: () => {},
+ consumeFocus: () => {},
goToFullHistory: () => {},
});
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index 17f28506..9de3413d 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -80,7 +80,11 @@ function Thumb({ job }: { job: DownloadJob }) {
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
+ // …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster
+ // that 404'd because its file was still being written should be re-requested when the row comes
+ // back, not written off for the life of the recycled slot. Convergent, so it belongs in render.
const [failedPrimary, setFailedPrimary] = useState(null);
+ if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null);
const src = failedPrimary && failedPrimary === primary ? remote : primary;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
diff --git a/frontend/src/components/ui/Avatar.dom.test.tsx b/frontend/src/components/ui/Avatar.dom.test.tsx
index bf739e8e..74722030 100644
--- a/frontend/src/components/ui/Avatar.dom.test.tsx
+++ b/frontend/src/components/ui/Avatar.dom.test.tsx
@@ -43,6 +43,9 @@ describe("Avatar · a failed image must not follow the component to the next ite
expect(painted[0]).not.toContain(">Z<");
});
+ // CHARACTERIZATION: the old boolean-plus-effect passes this too (the rerender is wrapped in act(),
+ // so its reset effect has run by the time the src is read). The frame-level guard is the test
+ // above, which the old shape fails; the retry guard is the test below, which it also fails.
it("keeps the fallback for the URL that actually failed, not for whatever is current", () => {
const { rerender } = render(
,
@@ -56,6 +59,23 @@ describe("Avatar · a failed image must not follow the component to the next ite
);
});
+ it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => {
+ // The instance is not remounted here on purpose: the player's channel avatar and the Messages
+ // thread header both swap `src` on a live instance. A memory that outlived the reason for it
+ // would pin a throttled channel to its initial for the rest of the session.
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.error(screen.getByRole("img", { name: "avatar" }));
+ expect(screen.getByText("Z")).toBeTruthy();
+
+ rerender();
+ rerender();
+ expect(screen.getByRole("img", { name: "avatar" }).getAttribute("src")).toBe(
+ "https://cdn.test/a.jpg",
+ );
+ });
+
it("falls back when there is no src at all", () => {
render();
expect(screen.queryByRole("img")).toBeNull();
diff --git a/frontend/src/components/ui/Avatar.tsx b/frontend/src/components/ui/Avatar.tsx
index 3dad0c1a..7172df31 100644
--- a/frontend/src/components/ui/Avatar.tsx
+++ b/frontend/src/components/ui/Avatar.tsx
@@ -25,7 +25,16 @@ export default function Avatar({
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
+ //
+ // Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
+ // TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
+ // swaps — the player's channel avatar stepping through a queue, the Messages thread header — would
+ // show the initial for a throttled channel forever, never re-requesting even after the CDN
+ // relented. Written in render because it is convergent (the write falsifies its own condition) and
+ // because an effect would leave one committed frame in which the memory is stale, which is the
+ // frame this whole component is about.
const [failedSrc, setFailedSrc] = useState(null);
+ if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
const failed = !!src && failedSrc === src;
if (src && !failed) {
diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts
index ec2f52e9..164b7ccf 100644
--- a/frontend/src/lib/playerModalHooks.ts
+++ b/frontend/src/lib/playerModalHooks.ts
@@ -486,9 +486,17 @@ export function useYtProgress({
};
// ONE PLAYER PER VIDEO is the invariant, and it is why this dep list is deliberately just the
// id: re-running would destroy and rebuild the YouTube iframe, losing the playing position and
- // every listener attached to it. Nothing in the body reads a render value — the mutable state it
- // needs mid-session (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef,
- // errorStreakRef, playerRef) is all behind refs, which is the enforcement, not this comment.
+ // every listener attached to it.
+ //
+ // This body DOES capture render values — `resumeAt`, `setWatched`, `advanceOnEnd`,
+ // `reapplyStored`, `focusModal`, `qc` — and it must, because each of them is a PER-ITEM
+ // SNAPSHOT: they describe the video this player was built for, and re-reading them mid-session
+ // would be the bug, not the fix. `resumeAt` is the clearest case, since persist() writes
+ // sessionPosRef and so changes it while `activeId` has not moved; the player is already playing
+ // from it and must not be told again. What genuinely has to stay live mid-session is behind refs
+ // (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and
+ // THAT split is the enforcement: a value that is not a snapshot does not belong in this closure.
+ //
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
// object literal, and an effect event may not be passed around as a value.
// eslint-disable-next-line react-hooks/exhaustive-deps