commit message
{"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"}
This commit is contained in:
@@ -94,7 +94,11 @@ describe("GlobalSearch", () => {
|
||||
mocks.useQuery.mockReset();
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -145,7 +149,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
mocks.useQuery.mockReset();
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
@@ -159,7 +167,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
});
|
||||
|
||||
it("renders the section with libraryHadHits=true when library returned results", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: {
|
||||
page: 1,
|
||||
@@ -186,7 +198,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
});
|
||||
|
||||
it("renders the section with libraryHadHits=false when library returned 0 results", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useQuery.mockReturnValue({
|
||||
data: { total: 0, has_more: false, items: [] },
|
||||
isFetching: false,
|
||||
@@ -216,7 +232,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
});
|
||||
|
||||
it("does not call useRequestSearch with enabled=true when discoveryEnabled is false", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
|
||||
|
||||
const call = mocks.useRequestSearch.mock.calls[mocks.useRequestSearch.mock.calls.length - 1];
|
||||
@@ -224,14 +244,22 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
});
|
||||
|
||||
it("does not mount RequestToAddSection when discovery is disabled", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
const markup = renderSearchMarkup({ defaultOpen: true, initialQuery: "Dune" });
|
||||
|
||||
expect(markup).not.toContain('data-testid="request-section"');
|
||||
});
|
||||
|
||||
it("suppresses 'No matches' when library is empty and TMDB is still loading", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useQuery.mockReturnValue({
|
||||
data: { total: 0, has_more: false, items: [] },
|
||||
isFetching: false,
|
||||
@@ -248,7 +276,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
});
|
||||
|
||||
it("suppresses 'No matches' when library is empty and TMDB has missing results", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useQuery.mockReturnValue({
|
||||
data: { total: 0, has_more: false, items: [] },
|
||||
isFetching: false,
|
||||
@@ -278,7 +310,11 @@ describe("GlobalSearch + RequestToAddSection wiring", () => {
|
||||
});
|
||||
|
||||
it("still shows 'No matches' when both library and TMDB are empty", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useQuery.mockReturnValue({
|
||||
data: { total: 0, has_more: false, items: [] },
|
||||
isFetching: false,
|
||||
|
||||
@@ -111,9 +111,15 @@ export function GlobalSearch({
|
||||
});
|
||||
const tmdbMissingCount =
|
||||
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
|
||||
// Cap at DIALOG_LIMIT (4) — RequestToAddSection slices results to that many rows.
|
||||
const tmdbVisibleCount = Math.min(tmdbMissingCount, 4);
|
||||
const tmdbStillLoading =
|
||||
canRequest.discoveryEnabled && tmdbDebouncedQuery.length > 1 && tmdbQuery.isLoading;
|
||||
const tmdbWillRender = canRequest.discoveryEnabled && tmdbMissingCount > 0;
|
||||
// Hide empty state while the TMDB debounce trails the library debounce; otherwise
|
||||
// the user sees "No matches" flash between t=200ms and t=400ms after typing.
|
||||
const tmdbDebounceCatchingUp =
|
||||
canRequest.discoveryEnabled && tmdbDebouncedQuery !== debouncedQuery;
|
||||
|
||||
const searchState = useMemo(
|
||||
() => createCatalogSearchState("query", { q: debouncedQuery || undefined }),
|
||||
@@ -195,7 +201,9 @@ export function GlobalSearch({
|
||||
items.length === 0 &&
|
||||
!previewQuery.isError &&
|
||||
!tmdbStillLoading &&
|
||||
!tmdbWillRender;
|
||||
!tmdbWillRender &&
|
||||
!canRequest.isResolving &&
|
||||
!tmdbDebounceCatchingUp;
|
||||
const showError = previewQuery.isError;
|
||||
|
||||
return (
|
||||
@@ -252,34 +260,33 @@ export function GlobalSearch({
|
||||
</form>
|
||||
{showResultsPanel && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
role="listbox"
|
||||
className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2"
|
||||
>
|
||||
{showLoading && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{showError && (
|
||||
<div className="text-destructive px-3 py-4 text-center text-sm">
|
||||
Could not load results. Press Enter to open the search page.
|
||||
</div>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<GlobalSearchResultRow
|
||||
key={item.content_id}
|
||||
item={item}
|
||||
index={i}
|
||||
isSelected={i === selectedIndex}
|
||||
onPick={handlePickItem}
|
||||
/>
|
||||
))}
|
||||
<div className="max-h-[min(22rem,55vh)] overflow-y-auto overscroll-contain px-2 py-2">
|
||||
<div role="listbox">
|
||||
{showLoading && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{showError && (
|
||||
<div className="text-destructive px-3 py-4 text-center text-sm">
|
||||
Could not load results. Press Enter to open the search page.
|
||||
</div>
|
||||
)}
|
||||
{showEmpty && (
|
||||
<div className="text-muted-foreground px-3 py-6 text-center text-sm">
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<GlobalSearchResultRow
|
||||
key={item.content_id}
|
||||
item={item}
|
||||
index={i}
|
||||
isSelected={i === selectedIndex}
|
||||
onPick={handlePickItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{tmdbDebouncedQuery.length > 1 && canRequest.discoveryEnabled && (
|
||||
<RequestToAddSection
|
||||
variant="dialog"
|
||||
@@ -289,7 +296,9 @@ export function GlobalSearch({
|
||||
)}
|
||||
</div>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{items.length} results found
|
||||
{tmdbVisibleCount > 0
|
||||
? `${items.length} library results, ${tmdbVisibleCount} request suggestions`
|
||||
: `${items.length} results found`}
|
||||
</div>
|
||||
<div className="text-muted-foreground border-t px-3 py-2 text-center text-xs">
|
||||
{total > PREVIEW_LIMIT ? (
|
||||
|
||||
@@ -24,7 +24,9 @@ describe("RequestPosterCard (discover variant)", () => {
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(markup).toContain("Request");
|
||||
// Must render an actual <button> with the "Request" label, not just any "Request"
|
||||
// substring (the /requests/... URL would match a naive includes check).
|
||||
expect(markup).toMatch(/<button[^>]*>[\s\S]*?Request[\s\S]*?<\/button>/);
|
||||
});
|
||||
|
||||
it("does not render the hover Request button when onRequest is omitted", () => {
|
||||
@@ -34,6 +36,8 @@ describe("RequestPosterCard (discover variant)", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain("rounded-full bg-white");
|
||||
// The discover variant only contains one <button> (the hover Request action);
|
||||
// its absence is the strongest signal that the button was suppressed.
|
||||
expect(markup).not.toContain("<button");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCanRequest: vi.fn(),
|
||||
useRequestSearch: vi.fn(),
|
||||
useCreateMediaRequest: vi.fn(),
|
||||
useDebounce: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -16,6 +17,7 @@ vi.mock("@/hooks/useCanRequest", () => ({
|
||||
|
||||
vi.mock("@/hooks/queries/useRequests", () => ({
|
||||
useRequestSearch: (...args: unknown[]) => mocks.useRequestSearch(...args),
|
||||
useCreateMediaRequest: () => mocks.useCreateMediaRequest(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useDebounce", () => ({
|
||||
@@ -59,12 +61,20 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useDebounce.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useDebounce.mockImplementation((v: unknown) => v);
|
||||
});
|
||||
|
||||
it("renders nothing when discovery is disabled", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
|
||||
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
@@ -72,7 +82,11 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
});
|
||||
|
||||
it("passes enabled=false to useRequestSearch when discovery is disabled so no network call fires", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
|
||||
|
||||
render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
@@ -85,7 +99,11 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
});
|
||||
|
||||
it("passes enabled=true to useRequestSearch when discovery is enabled", () => {
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: { page: 1, total_pages: 1, total_results: 0, results: [] },
|
||||
isLoading: false,
|
||||
@@ -123,6 +141,10 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
});
|
||||
|
||||
it("filters out results already available in the library", () => {
|
||||
// missingResult has tmdb_id 1, availableResult has tmdb_id 2. The DialogRow
|
||||
// renders item.title only as text content (never as a `title=` attribute), so
|
||||
// a substring check on `title="Dune"` would pass even with the filter removed.
|
||||
// Check the link target instead — it's a precise, filter-driven signal.
|
||||
mocks.useRequestSearch.mockReturnValue({
|
||||
data: {
|
||||
page: 1,
|
||||
@@ -134,8 +156,8 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
isError: false,
|
||||
});
|
||||
const markup = render(<RequestToAddSection variant="dialog" query="dune" libraryHadHits />);
|
||||
expect(markup).toContain("Dune: Prophecy");
|
||||
expect(markup).not.toContain('title="Dune"');
|
||||
expect(markup).toContain("/requests/movie/1");
|
||||
expect(markup).not.toContain("/requests/movie/2");
|
||||
});
|
||||
|
||||
it("renders nothing when TMDB returned an error", () => {
|
||||
@@ -179,7 +201,9 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
missingResult({
|
||||
tmdb_id: 7,
|
||||
title: "Quota Capped Movie",
|
||||
request: { requestable: false, reason: "quota_exhausted" },
|
||||
// formatRequestReason recognises "quota_exceeded" (not "quota_exhausted");
|
||||
// assert on the produced label so a regression in that mapping is caught.
|
||||
request: { requestable: false, reason: "quota_exceeded" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -191,7 +215,8 @@ describe("RequestToAddSection (dialog variant)", () => {
|
||||
|
||||
expect(markup).toContain("Quota Capped Movie");
|
||||
expect(markup).not.toContain("bg-amber-400/15");
|
||||
expect(markup).toMatch(/title="[^"]+"/);
|
||||
expect(markup).toContain("Limit reached");
|
||||
expect(markup).toContain('title="Limit reached"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,7 +224,17 @@ describe("RequestToAddSection (grid variant)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useCanRequest.mockReset();
|
||||
mocks.useRequestSearch.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mocks.useCreateMediaRequest.mockReset();
|
||||
mocks.useCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mocks.useCreateMediaRequest.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
variables: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a card per result with the Request to Add header when library had hits", () => {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Film, Tv } from "lucide-react";
|
||||
import { Film, Sparkles, Tv } from "lucide-react";
|
||||
import { useCanRequest } from "@/hooks/useCanRequest";
|
||||
import { useRequestSearch } from "@/hooks/queries/useRequests";
|
||||
import { useCreateMediaRequest, useRequestSearch } from "@/hooks/queries/useRequests";
|
||||
import type { RequestMediaResult } from "@/api/types";
|
||||
import { formatRequestReason, tmdbImageURL } from "@/lib/mediaRequests";
|
||||
import {
|
||||
formatRequestReason,
|
||||
requestInputFromMediaResult,
|
||||
tmdbImageURL,
|
||||
} from "@/lib/mediaRequests";
|
||||
import { cn } from "@/lib/utils";
|
||||
import RequestPosterCard from "./RequestPosterCard";
|
||||
|
||||
function cardKey(item: Pick<RequestMediaResult, "media_type" | "tmdb_id">): string {
|
||||
return `${item.media_type}-${item.tmdb_id}`;
|
||||
}
|
||||
|
||||
const DIALOG_LIMIT = 4;
|
||||
const GRID_LIMIT = 20;
|
||||
|
||||
@@ -135,24 +144,76 @@ function GridVariant({
|
||||
items: RequestMediaResult[];
|
||||
libraryHadHits: boolean;
|
||||
}) {
|
||||
const count = items.length;
|
||||
const createRequest = useCreateMediaRequest();
|
||||
// Track each in-flight card key independently; the shared `useMutation`
|
||||
// observer overwrites its `variables` on every `mutate` call, so rapid
|
||||
// clicks on different cards would otherwise trample each other's spinner.
|
||||
const [pendingKeys, setPendingKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const submitCard = (item: RequestMediaResult) => {
|
||||
const key = cardKey(item);
|
||||
setPendingKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
createRequest.mutate(requestInputFromMediaResult(item), {
|
||||
onSettled: () => {
|
||||
setPendingKeys((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-amber-300/85">
|
||||
<div className="h-px flex-1 bg-amber-400/20" />
|
||||
<h2 className="text-[11px] font-semibold tracking-[0.12em] uppercase">
|
||||
{libraryHadHits ? "Request to Add" : "Not in your library, but you can request"}
|
||||
</h2>
|
||||
<div className="h-px flex-1 bg-amber-400/20" />
|
||||
</div>
|
||||
<section
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-[28px] border border-amber-400/[0.14]",
|
||||
"bg-[radial-gradient(120%_60%_at_50%_0%,rgba(245,158,11,0.07)_0%,rgba(245,158,11,0.015)_45%,transparent_75%)]",
|
||||
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04),0_28px_60px_-44px_rgba(0,0,0,0.7)]",
|
||||
"px-4 pt-7 pb-7 sm:px-7 sm:pt-8",
|
||||
libraryHadHits && "mt-12! sm:mt-16!",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-16 top-0 h-px bg-gradient-to-r from-transparent via-amber-300/45 to-transparent"
|
||||
/>
|
||||
|
||||
<header className="mb-7 flex flex-wrap items-end justify-between gap-x-5 gap-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-200/85">
|
||||
<Sparkles className="h-3.5 w-3.5" strokeWidth={2.2} aria-hidden />
|
||||
<span className="text-[10px] font-semibold tracking-[0.24em] uppercase">
|
||||
{libraryHadHits ? "Discover · Outside your library" : "Outside your library"}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-display text-foreground text-[clamp(1.25rem,1.6vw,1.55rem)] leading-tight font-semibold tracking-tight">
|
||||
{libraryHadHits ? "Request to Add" : "Not in your library, but you can request"}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 self-end rounded-full border border-amber-400/15 bg-amber-400/[0.06] px-2.5 py-1 text-[11px] font-medium tracking-wide text-amber-100/75 tabular-nums">
|
||||
{count} {count === 1 ? "result" : "results"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item) => (
|
||||
<RequestPosterCard
|
||||
key={`${item.media_type}-${item.tmdb_id}`}
|
||||
variant="discover"
|
||||
item={item}
|
||||
fluid
|
||||
/>
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const key = cardKey(item);
|
||||
return (
|
||||
<RequestPosterCard
|
||||
key={key}
|
||||
variant="discover"
|
||||
item={item}
|
||||
isSubmitting={pendingKeys.has(key)}
|
||||
onRequest={() => submitCard(item)}
|
||||
fluid
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -166,6 +166,10 @@ export function useRequestSearch(
|
||||
) {
|
||||
const normalizedQuery = query.trim();
|
||||
const { profile } = useCurrentProfile();
|
||||
// Use a sentinel viewerKey when there is no profile so the cache key is stable,
|
||||
// but suppress the actual fetch — see the `enabled` gate below. This prevents
|
||||
// any anonymous request results from being written into a bucket that could
|
||||
// later be read by a different viewer.
|
||||
const viewerKey = profile?.id ?? "anon";
|
||||
const enabledOverride = options.enabled ?? true;
|
||||
|
||||
@@ -179,7 +183,7 @@ export function useRequestSearch(
|
||||
});
|
||||
return api<RequestMediaPage>(`/requests/search?${params}`, { signal });
|
||||
},
|
||||
enabled: enabledOverride && normalizedQuery.length > 1,
|
||||
enabled: enabledOverride && normalizedQuery.length > 1 && Boolean(profile?.id),
|
||||
staleTime: REQUEST_SEARCH_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ function render(child: ReactNode) {
|
||||
|
||||
describe("useCanRequest", () => {
|
||||
it("returns discoveryEnabled=false when requests_enabled is false", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: false } });
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({
|
||||
data: { requests_enabled: false },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
@@ -43,11 +46,18 @@ describe("useCanRequest", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns discoveryEnabled=false when there is no profile", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } });
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({
|
||||
data: { requests_enabled: true },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: null });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
@@ -59,11 +69,18 @@ describe("useCanRequest", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns discoveryEnabled=true when requests are enabled and there is a profile", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({ data: { requests_enabled: true } });
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({
|
||||
data: { requests_enabled: true },
|
||||
isLoading: false,
|
||||
});
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
@@ -75,11 +92,15 @@ describe("useCanRequest", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns discoveryEnabled=false while the feature status is still loading", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined });
|
||||
it("returns isResolving=true and discoveryEnabled=false while the feature status is still loading", () => {
|
||||
mocks.useRequestFeatureStatus.mockReturnValue({ data: undefined, isLoading: true });
|
||||
mocks.useCurrentProfile.mockReturnValue({ profile: { id: "p1" } });
|
||||
|
||||
let captured: ReturnType<typeof useCanRequest> | null = null;
|
||||
@@ -91,6 +112,10 @@ describe("useCanRequest", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(captured).toEqual({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
expect(captured).toEqual({
|
||||
discoveryEnabled: false,
|
||||
isResolving: true,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,12 @@ import { useCurrentProfile } from "@/hooks/useCurrentProfile";
|
||||
|
||||
export interface CanRequestState {
|
||||
discoveryEnabled: boolean;
|
||||
/**
|
||||
* True while we cannot yet decide if discovery is on — feature status
|
||||
* is still loading. Consumers should suppress empty-state UI in this
|
||||
* window to avoid a flash before the request section appears.
|
||||
*/
|
||||
isResolving: boolean;
|
||||
submitDisabledReason: string | null;
|
||||
}
|
||||
|
||||
@@ -10,9 +16,11 @@ export function useCanRequest(): CanRequestState {
|
||||
const status = useRequestFeatureStatus();
|
||||
const { profile } = useCurrentProfile();
|
||||
const discoveryEnabled = Boolean(status.data?.requests_enabled) && Boolean(profile?.id);
|
||||
const isResolving = status.isLoading;
|
||||
|
||||
return {
|
||||
discoveryEnabled,
|
||||
isResolving,
|
||||
submitDisabledReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,7 +189,11 @@ describe("Catalog page", () => {
|
||||
mockItemGrid.mockReset();
|
||||
mockUseCanRequest.mockReset();
|
||||
mockUseRequestSearch.mockReset();
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: false, submitDisabledReason: null });
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseRequestSearch.mockReturnValue({ data: undefined, isLoading: false, isError: false });
|
||||
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
@@ -325,7 +329,11 @@ describe("Catalog page", () => {
|
||||
});
|
||||
|
||||
it("renders the request grid variant when source=query and library has results", () => {
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseRequestSearch.mockReturnValue({
|
||||
data: {
|
||||
page: 1,
|
||||
@@ -357,7 +365,11 @@ describe("Catalog page", () => {
|
||||
});
|
||||
|
||||
it("renders the request grid variant with libraryHadHits=false when library has 0 hits", () => {
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
@@ -392,7 +404,11 @@ describe("Catalog page", () => {
|
||||
|
||||
it("does not render the request section when source is not query", () => {
|
||||
appInitialEntries = ["/catalog?source=favorites"];
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: "Favorites", totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
@@ -428,8 +444,12 @@ describe("Catalog page", () => {
|
||||
expect(call?.[3]).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("keeps ItemGrid in a loading state when library is empty and TMDB is still loading", () => {
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
it("hides ItemGrid when library is empty and TMDB is still loading (request section will rescue)", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
@@ -442,11 +462,17 @@ describe("Catalog page", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-loading="true"');
|
||||
// Previously this case forced ItemGrid into loading=true, rendering 24 fake
|
||||
// skeletons forever above the section. Now ItemGrid is hidden entirely.
|
||||
expect(markup).not.toContain('data-kind="item-grid"');
|
||||
});
|
||||
|
||||
it("keeps ItemGrid in a loading state when library is empty and TMDB has missing results", () => {
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
it("hides ItemGrid when library is empty and TMDB has missing results", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
@@ -476,11 +502,38 @@ describe("Catalog page", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-loading="true"');
|
||||
expect(markup).not.toContain('data-kind="item-grid"');
|
||||
expect(markup).toContain('data-testid="request-section"');
|
||||
});
|
||||
|
||||
it("hides ItemGrid when library is empty and discovery feature status is still resolving", () => {
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: false,
|
||||
isResolving: true,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Avoids the empty-state flash before the feature status resolves and TMDB
|
||||
// either rescues with results or confirms there are none.
|
||||
expect(markup).not.toContain('data-kind="item-grid"');
|
||||
});
|
||||
|
||||
it("renders the normal ItemGrid empty state when both library and TMDB are empty", () => {
|
||||
mockUseCanRequest.mockReturnValue({ discoveryEnabled: true, submitDisabledReason: null });
|
||||
mockUseCanRequest.mockReturnValue({
|
||||
discoveryEnabled: true,
|
||||
isResolving: false,
|
||||
submitDisabledReason: null,
|
||||
});
|
||||
mockUseCatalogWindow.mockReturnValue({
|
||||
data: { title: 'Results for "heat"', totalItems: 0, pages: new Map() },
|
||||
isLoading: false,
|
||||
|
||||
+29
-20
@@ -11,6 +11,7 @@ import { useCatalogWindow } from "@/hooks/queries/catalog";
|
||||
import { useRemoveHistory } from "@/hooks/queries/history";
|
||||
import { useRequestSearch } from "@/hooks/queries/useRequests";
|
||||
import { useCanRequest } from "@/hooks/useCanRequest";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
@@ -106,18 +107,24 @@ function CatalogResults({
|
||||
});
|
||||
const canRequest = useCanRequest();
|
||||
const isQuerySource = state.source === "query" && Boolean(state.q);
|
||||
const tmdbQuery = useRequestSearch("all", state.q ?? "", 1, {
|
||||
// Add a 200ms TMDB debounce on top of SearchBar's 200ms input debounce so the
|
||||
// TMDB plugin isn't hit at the same cadence as the local library query.
|
||||
const tmdbDebouncedQ = useDebounce(state.q ?? "", 200);
|
||||
const tmdbQuery = useRequestSearch("all", tmdbDebouncedQ, 1, {
|
||||
enabled: canRequest.discoveryEnabled && isQuerySource,
|
||||
});
|
||||
const tmdbMissingCount =
|
||||
tmdbQuery.data?.results?.filter((result) => result.availability !== "available").length ?? 0;
|
||||
const libraryEmpty = (catalogQuery.data?.totalItems ?? 0) === 0;
|
||||
const tmdbPendingForEmptyLibrary =
|
||||
isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbQuery.isLoading;
|
||||
const tmdbWillRenderForEmptyLibrary =
|
||||
isQuerySource && canRequest.discoveryEnabled && libraryEmpty && tmdbMissingCount > 0;
|
||||
const itemGridLoading =
|
||||
catalogQuery.isLoading || tmdbPendingForEmptyLibrary || tmdbWillRenderForEmptyLibrary;
|
||||
const libraryHasResults = (catalogQuery.data?.totalItems ?? 0) > 0;
|
||||
const libraryEmpty = !catalogQuery.isLoading && !libraryHasResults;
|
||||
// When the library is empty and the request section will (or might) render,
|
||||
// hide ItemGrid entirely. The previous approach pinned ItemGrid's `loading`
|
||||
// prop to true, which renders 24 skeleton tiles forever above the section.
|
||||
const tmdbMayRescueLibrary =
|
||||
isQuerySource &&
|
||||
libraryEmpty &&
|
||||
(canRequest.isResolving ||
|
||||
(canRequest.discoveryEnabled && (tmdbQuery.isLoading || tmdbMissingCount > 0)));
|
||||
const loadedHistoryItems = useMemo(() => {
|
||||
if (!isHistorySource) {
|
||||
return [] as BrowseItem[];
|
||||
@@ -266,22 +273,24 @@ function CatalogResults({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ItemGrid
|
||||
totalItems={catalogQuery.data?.totalItems ?? 0}
|
||||
pages={catalogQuery.data?.pages ?? new Map()}
|
||||
pageSize={limit}
|
||||
loading={itemGridLoading}
|
||||
onVisibleRangeChange={handleVisibleRangeChange}
|
||||
selectionMode={isHistorySource && selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleHistorySelection}
|
||||
/>
|
||||
{tmdbMayRescueLibrary ? null : (
|
||||
<ItemGrid
|
||||
totalItems={catalogQuery.data?.totalItems ?? 0}
|
||||
pages={catalogQuery.data?.pages ?? new Map()}
|
||||
pageSize={limit}
|
||||
loading={catalogQuery.isLoading}
|
||||
onVisibleRangeChange={handleVisibleRangeChange}
|
||||
selectionMode={isHistorySource && selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleHistorySelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuerySource && canRequest.discoveryEnabled ? (
|
||||
<RequestToAddSection
|
||||
variant="grid"
|
||||
query={state.q!}
|
||||
libraryHadHits={(catalogQuery.data?.totalItems ?? 0) > 0}
|
||||
query={tmdbDebouncedQ}
|
||||
libraryHadHits={libraryHasResults}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user