fix(ui): the card's channel name is a real link too; call sites the bug hunt left untested now have tests
Deep-review R6 — the small items: - The video card's channel name was still a <button> (pre-existing): ctrl/middle-click did nothing there while the title above it and the player's channel name honoured them. It renders through ChannelNameLink now, which grew a forwarded ref (the clamp measurement), pass-through attributes, a `tooltip`, and an opt-in `stopPropagation` so no click reaches the card's row. - ChannelNameLink rendered an empty, still-tabbable <a> for a channel with no title yet (a stub first seen through a live search; pre-existing at this site). It falls back to the translated "Channel". - The feed's SortKey→SortOption mapping existed twice in Feed.tsx; one `feedSortOptions` in feedSort.ts serves both modes (DIRECTIONLESS is module-private again). - The backfill refresh's comment claimed a one-page cost. It refetches every loaded page of the grid, and that is the right price: backfilled videos are older, so they land below the head. Tests for call sites that could be deleted with every lane green: the channel page running the backfill refresh (knip had been blinded by the hook test's import), Feed handing the grid the live results in the chosen key AND direction, the card using the link. Plus the alt-click arm of the shared click predicate, and a plex filter-chip test that claimed it made no request while firing a real one. Verified on localdev: the feed's channel names are anchors (same 14 px muted look), a plain click opens the channel in-app. 7 mutations, each red on its assertion.
This commit is contained in:
@@ -37,10 +37,13 @@ const backfillChangedCatalog = (
|
||||
* background job has written a row — which is why the list stayed on the ~19 cards the channel had
|
||||
* at subscribe time while the count above it climbed into the thousands.
|
||||
*
|
||||
* Cost is bounded: the predicate fires only on a tick where the count actually moved, the poll
|
||||
* itself pauses while the tab is unfocused, and a channel grid is normally a single 60-item page
|
||||
* whose newest-first head the (older) backfilled videos don't disturb — so the refetch usually
|
||||
* comes back structurally identical and re-renders nothing. */
|
||||
* What it costs, honestly: an invalidation refetches EVERY loaded page of the grid's infinite
|
||||
* query — one request per page the viewer has scrolled through, on each 4 s tick where the count
|
||||
* moved, for as long as the backfill runs (the poll pauses while the tab is hidden, and stops when
|
||||
* the back-catalogue is in). That is the right price, not an accident: backfilled videos are OLDER
|
||||
* than what the grid opened with, so under the newest-first order they land in the pages BELOW the
|
||||
* head — refreshing only the first page would leave out exactly the rows the backfill wrote. The
|
||||
* head itself comes back structurally identical and re-renders nothing. */
|
||||
export function useBackfillFeedRefresh(detail: ChannelDetail | undefined, qc: QueryClient) {
|
||||
const seen = useRef<ChannelDetail | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
|
||||
// chips can label a sort without re-deriving the mapping.
|
||||
import type { Video } from "./api";
|
||||
import type { SortOption } from "./columnSort";
|
||||
|
||||
export type SortKey =
|
||||
| "date"
|
||||
@@ -24,7 +25,7 @@ export const SORT_KEYS: SortKey[] = [
|
||||
];
|
||||
|
||||
/** Sorts with no meaningful asc/desc flip. */
|
||||
export const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
||||
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
||||
|
||||
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
|
||||
date: { desc: "newest", asc: "oldest" },
|
||||
@@ -58,6 +59,20 @@ export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
||||
return SORT_MAP[key][dir];
|
||||
}
|
||||
|
||||
/** Feed sort keys as options for the shared SortControl. ONE mapping for the library feed and the
|
||||
* live search results, which share the vocabulary: two copies could hand the same key a different
|
||||
* direction default, or lose its directionless flag, depending on which mode you were in. */
|
||||
export function feedSortOptions(keys: SortKey[], label: (k: SortKey) => string): SortOption[] {
|
||||
return keys.map((k) => ({
|
||||
key: k,
|
||||
label: label(k),
|
||||
directionless: DIRECTIONLESS.includes(k),
|
||||
defaultDir: DIRECTIONLESS.includes(k)
|
||||
? undefined
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Live YouTube search results ----------------------------------------------------------------
|
||||
//
|
||||
// The results view had NO sort control at all — the order was the provider's and that was that. It is
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sortYtResults, YT_SORT_KEYS, SORT_KEYS } from "./feedSort";
|
||||
import {
|
||||
feedSortOptions,
|
||||
sortYtResults,
|
||||
SORT_DEFAULT_DIR,
|
||||
YT_SORT_KEYS,
|
||||
SORT_KEYS,
|
||||
} from "./feedSort";
|
||||
import type { Video } from "./api";
|
||||
|
||||
// The live-YouTube results view shipped with no sort control at all: the provider's order was the
|
||||
@@ -111,3 +117,17 @@ describe("sortYtResults", () => {
|
||||
expect(titles(input)).toEqual(titles(RESULTS));
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedSortOptions", () => {
|
||||
// The library feed and the live results used to build their options from two copies of this
|
||||
// mapping; one helper means a key cannot get a different default direction in one of the modes.
|
||||
it("gives directional keys their default direction and marks shuffle/relevance directionless", () => {
|
||||
const opts = feedSortOptions(["relevance", "date", "duration", "shuffle"], (k) => `label:${k}`);
|
||||
expect(opts.map((o) => [o.key, o.label, !!o.directionless, o.defaultDir])).toEqual([
|
||||
["relevance", "label:relevance", true, undefined],
|
||||
["date", "label:date", false, SORT_DEFAULT_DIR.date],
|
||||
["duration", "label:duration", false, SORT_DEFAULT_DIR.duration],
|
||||
["shuffle", "label:shuffle", true, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user