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