fix(spa): gate mutation controls on stale result sets during refetch (#221) #222
@@ -64,9 +64,9 @@ redirect).
|
||||
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
|
||||
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
|
||||
> | Channels-numbers / Logs | PARITY-OK / minors | #213 |
|
||||
> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10) | — |
|
||||
> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10; mutation controls + Add-all now gated during refetch — #221) | — |
|
||||
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
|
||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10) | — |
|
||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
|
||||
> | Schedules editors | **disproven — moved to Section 3** | **#207** |
|
||||
> | Media sources | **disproven — moved to Section 3** | **#202** |
|
||||
>
|
||||
@@ -192,6 +192,14 @@ query-wide Add All via `GET /api/search/all-items`, Save As Smart Collection) an
|
||||
pages + child grids, per-show Quick/Deep scan gated to Plex/Jellyfin/Emby, per-episode Media
|
||||
Info + Troubleshoot entries; `POST /api/playlists/{id}/items` added). Known accepted deviations
|
||||
(select-mode toggle, per-card target superset) recorded in `docs/decisions.md`.
|
||||
CLOSED 2026-07-10: **#221** (adversarial-reviewer#18 follow-up to #208/#209) — those PRs added
|
||||
mutation actions to two screens whose fetch model keeps the previous result set rendered during a
|
||||
refetch. On Search and Media browse the per-card Add-to menu, Select/select-mode, selection action
|
||||
bar, Add-all, and Save-as-smart-collection are now **gated while a refetch is in flight** (query on
|
||||
Search; kind/query/page on Media browse), with a visible "Refreshing…" cue and dimmed grid; card
|
||||
navigation stays live. `SearchScreen.addAll` also binds its completion to the requesting query so a
|
||||
late `GET /api/search/all-items` can no longer open a bulk-add dialog scoped to the previous query.
|
||||
See `docs/spa-conventions.md` §3a for the pattern.
|
||||
|
||||
## Section 4 — Blazor home / escape hatch
|
||||
|
||||
|
||||
@@ -60,6 +60,47 @@ fetches from the API:
|
||||
`eslint-plugin-react-hooks` in `web/eslint.config.js` — a synchronous `setState` in an effect body
|
||||
will fail `npm run lint`.
|
||||
|
||||
## 3a. "Keep results visible during refetch" ⇒ gate mutations + show a refreshing cue
|
||||
|
||||
Some grid screens deliberately keep the **previous** successful result set rendered while a refetch
|
||||
is in flight (no full-screen loading state on a query/kind/page change), so the grid doesn't flash
|
||||
empty. `SearchScreen.tsx` and `MediaBrowseScreen.tsx` do this. If such a screen also carries
|
||||
**mutation surfaces** (per-card Add-to menu, Select/select-mode, a selection action bar, "Add all",
|
||||
"Save as smart collection"), those surfaces would otherwise stay live over a **stale** result set —
|
||||
an add/select action then targets the about-to-be-replaced items, or (worse) a query-wide "Add all"
|
||||
bulk request resolves against the previous query. This was issue #221 (adversarial-reviewer#18).
|
||||
|
||||
Convention — when a screen keeps stale results visible during a refetch:
|
||||
|
||||
- **Key the success state to the request params that produced it.** Store the identifying params on
|
||||
the `status: 'success'` variant (`SearchScreen`: the `query`; `MediaBrowseScreen`: a
|
||||
`kind|query|page` `key`), set in the seq-guarded `.then`. Derive
|
||||
`const refreshing = state.status === 'success' && state.<key> !== <current params>;` in render.
|
||||
Prefer this over a synchronously-set `refreshing` flag: setting state synchronously from the load
|
||||
path trips the `react-hooks` "no set-state-in-effect" rule (§3).
|
||||
**Invariant, not a guarantee**: the derivation is only self-correcting when *every* value the
|
||||
current params can take will actually trigger a fetch. If `load()` early-returns for some param
|
||||
value (e.g. `SearchScreen`'s blank-query guard), `state` never updates for that value and a stale
|
||||
`status: 'success'` variant lingers — so the comparison must exclude params that suppress
|
||||
fetching, or gate the whole flag on the same condition that gates the fetch (`SearchScreen`:
|
||||
`const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();` —
|
||||
fixed post-review in #222 after the naive derivation got stuck `true` once the query was cleared
|
||||
to empty, see PR discussion for #221).
|
||||
- **While `refreshing`:** show a visible cue (a `role="status"` "Refreshing…" row with `<Spinner>`
|
||||
plus the `.ctv-media-grid-dim` opacity class on the grid) and **disable every mutation surface** —
|
||||
per-card Add-to menu (withhold the `actions` node), select toggle + in-grid selection
|
||||
(`const canSelect = selectMode && !refreshing;` gates `onToggleSelect`), the selection action bar,
|
||||
"Add all", "Save as smart collection". Card navigation (`onOpen`) **may** stay live — but only
|
||||
outside select mode: while `selectMode && refreshing`, `MediaPosterCard` falls back to `onOpen`
|
||||
whenever `onToggleSelect` is undefined, so both props must be withheld together or a mid-select
|
||||
click navigates away instead of no-op'ing. The select-mode toggle itself should only be disabled
|
||||
while refreshing when *entering* select mode (`refreshing && !selectMode`) — exiting only clears
|
||||
selection, not a mutation, so it must stay available.
|
||||
- **Bind async bulk completions to their request params, not just mount.** A whole-query/whole-set
|
||||
request (e.g. `getSearchAllItems`) must, on resolve, check that its snapshotted params are still
|
||||
current (compare against a ref that always holds the committed value — `SearchScreen` reuses
|
||||
`lastQueryRef`) and **discard** otherwise. Checking only `activeRef` (mounted) is insufficient.
|
||||
|
||||
## 4. API client modules
|
||||
|
||||
One file per domain in `web/src/api/`, e.g. `logs.ts`, `blocks.ts`, `playouts.ts`. Pattern (see
|
||||
@@ -145,3 +186,16 @@ npm run build # tsc -b && vite build
|
||||
Also run `npm run check:api` if you touched anything OpenAPI-relevant (see `api-conventions.md` §5)
|
||||
— it regenerates `src/api/generated/v1.d.ts` and fails the build if it's out of sync with what's
|
||||
committed.
|
||||
|
||||
## 8. Review checklist — temporal semantics
|
||||
|
||||
- **For every effect / timer / async completion, ask: *when* does it fire (mount, dependency change,
|
||||
unmount, StrictMode double-invoke) and *which* render/request does it still own?** A debounce timer
|
||||
fires on mount too (§3, the `lastQueryRef` no-change guard exists precisely for that); a `.then`
|
||||
can resolve after the params it was launched for have moved on (§3a, the `refreshing` gate and the
|
||||
Add-all query binding exist for that). A guard that only checks "still mounted" (`activeRef`) does
|
||||
not answer "still current".
|
||||
- **For any "make X consistent with Y" change, re-validate the exemplar Y's temporal behavior before
|
||||
copying it.** #221 came from copying a fetch model that keeps stale results visible onto screens
|
||||
that had gained mutation surfaces — the exemplar was safe read-only, the copy was not. Copying a
|
||||
pattern copies its *assumptions*; confirm they still hold in the new context.
|
||||
|
||||
@@ -130,6 +130,149 @@ describe('MediaBrowseScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('gates old-kind cards and shows a refreshing cue during a kind-change refetch (issue #221)', async () => {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const d = deferred<Response>();
|
||||
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
render(<MediaBrowseScreen />);
|
||||
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
|
||||
|
||||
// Change kind: the refetch for the new kind is held pending while old-kind cards stay rendered.
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(2));
|
||||
|
||||
// Refreshing window: cue visible, mutation surfaces gated, old-kind cards still visible but inert.
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
|
||||
|
||||
// New-kind results resolve: controls re-enable, cue gone.
|
||||
browseDeferreds[1].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).not.toBeInTheDocument());
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const d = deferred<Response>();
|
||||
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
render(<MediaBrowseScreen />);
|
||||
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
|
||||
const pushState = vi.spyOn(window.history, 'pushState');
|
||||
fireEvent.click(screen.getByText('Blade Runner'));
|
||||
|
||||
// Neither selection nor navigation fires — the card is fully inert during select+refresh.
|
||||
expect(screen.queryByText(/selected/)).toBeNull();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select toggle: entering select mode is blocked while refreshing, exiting (Done) is allowed (#222 review)', async () => {
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const browseDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const d = deferred<Response>();
|
||||
browseDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
render(<MediaBrowseScreen />);
|
||||
browseDeferreds[0].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
|
||||
// Not in select mode; trigger a refetch — entering select mode must be blocked.
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'shows' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeDisabled();
|
||||
|
||||
// Settle, enter select mode, then trigger another refetch — exiting (Done) must stay enabled.
|
||||
browseDeferreds[1].resolve({ page: items, totalCount: items.length });
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
expect(screen.getByRole('button', { name: 'Done' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'movies' } });
|
||||
await waitFor(() => expect(browseDeferreds.length).toBe(3));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeInTheDocument());
|
||||
|
||||
const doneToggle = screen.getByRole('button', { name: 'Done' });
|
||||
expect(doneToggle).not.toBeDisabled();
|
||||
fireEvent.click(doneToggle);
|
||||
expect(screen.getByRole('button', { name: 'Select' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('selects every loaded item with Select all on page', async () => {
|
||||
mockFetch();
|
||||
render(<MediaBrowseScreen />);
|
||||
|
||||
@@ -55,10 +55,17 @@ function kindFromSlug(slug: string | null): MediaKind {
|
||||
}
|
||||
|
||||
type BrowseState =
|
||||
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number }
|
||||
// `key` records the request params (kind + query + page) that produced this result set. When it
|
||||
// no longer matches the current params, the visible items are stale (a refetch is in flight) —
|
||||
// see `refreshing` below.
|
||||
| { items: LibraryBrowseItem[]; error: null; status: 'success'; totalCount: number; key: string }
|
||||
| { items: []; error: string; status: 'error'; totalCount: 0 }
|
||||
| { items: []; error: null; status: 'loading'; totalCount: 0 };
|
||||
|
||||
function browseKeyOf(mediaType: LibraryBrowseMediaType, query: string, pageNum: number): string {
|
||||
return `${mediaType}\u0000${query}\u0000${pageNum}`;
|
||||
}
|
||||
|
||||
type Notice = { tone: 'ok' | 'error'; message: string };
|
||||
|
||||
export function MediaBrowseScreen() {
|
||||
@@ -108,10 +115,11 @@ export function MediaBrowseScreen() {
|
||||
|
||||
const load = useCallback(() => {
|
||||
const id = ++seqRef.current;
|
||||
const key = browseKeyOf(kind.mediaType, query, pageNum);
|
||||
getLibraryBrowseItems({ mediaType: kind.mediaType, query: query || undefined, pageNum, pageSize: PAGE_SIZE })
|
||||
.then((paged) => {
|
||||
if (activeRef.current && id === seqRef.current) {
|
||||
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
|
||||
setState({ items: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0, key });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -185,6 +193,13 @@ export function MediaBrowseScreen() {
|
||||
const selectedItems = Array.from(selected.values());
|
||||
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1;
|
||||
|
||||
// A refetch is in flight when the currently-rendered items were produced by different params than
|
||||
// the current kind/query/page (items stay visible during refetch — see `load`). While refreshing we
|
||||
// keep items visible but gate every mutation surface and show a refreshing cue, so no add/select
|
||||
// action is scoped to the stale, about-to-be-replaced result set (issue #221).
|
||||
const refreshing = state.status === 'success' && state.key !== browseKeyOf(kind.mediaType, query, pageNum);
|
||||
const canSelect = selectMode && !refreshing;
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -213,6 +228,9 @@ export function MediaBrowseScreen() {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
// Entering select mode while refreshing is blocked (it would target a stale result set),
|
||||
// but exiting is always allowed — it only clears selection, not a mutation.
|
||||
disabled={refreshing && !selectMode}
|
||||
onClick={toggleSelectMode}
|
||||
size="sm"
|
||||
startIcon={<ListChecks aria-hidden="true" size={14} />}
|
||||
@@ -233,11 +251,12 @@ export function MediaBrowseScreen() {
|
||||
{selected.size > 0 && (
|
||||
<div className="ctv-channels-actionbar">
|
||||
<span className="ctv-channels-selected">{selected.size} selected</span>
|
||||
<Button onClick={selectAllOnPage} size="sm" variant="secondary">
|
||||
<Button disabled={refreshing} onClick={selectAllOnPage} size="sm" variant="secondary">
|
||||
Select all on page
|
||||
</Button>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => setBulkDialog('collection')}
|
||||
size="sm"
|
||||
startIcon={<FolderPlus aria-hidden="true" size={14} />}
|
||||
@@ -246,6 +265,7 @@ export function MediaBrowseScreen() {
|
||||
Add to collection
|
||||
</Button>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => setBulkDialog('playlist')}
|
||||
size="sm"
|
||||
startIcon={<ListVideo aria-hidden="true" size={14} />}
|
||||
@@ -274,6 +294,13 @@ export function MediaBrowseScreen() {
|
||||
open={bulkDialog === 'playlist'}
|
||||
/>
|
||||
|
||||
{refreshing && (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={14} />
|
||||
<span>Refreshing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
@@ -298,20 +325,27 @@ export function MediaBrowseScreen() {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="ctv-media-grid">
|
||||
<div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
|
||||
{state.items.map((item) => {
|
||||
const detailPath = mediaDetailPath(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
// While refreshing, the per-card Add-to menu is withheld (stale result set);
|
||||
// card navigation stays live.
|
||||
actions={
|
||||
selectMode ? undefined : (
|
||||
selectMode || refreshing ? undefined : (
|
||||
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
|
||||
)
|
||||
}
|
||||
item={item}
|
||||
key={`${item.mediaType}-${item.id}`}
|
||||
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={selectMode ? toggleSelect : undefined}
|
||||
// While `selectMode && refreshing`, the card must be fully inert: neither
|
||||
// `onOpen` (which MediaPosterCard falls back to when `onToggleSelect` is
|
||||
// undefined) nor `onToggleSelect` may fire, or a mid-select click would navigate
|
||||
// away instead of no-op'ing. Outside select mode, `onOpen` stays live during a
|
||||
// refresh (intended).
|
||||
onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={canSelect ? toggleSelect : undefined}
|
||||
selected={selected.has(itemKey(item))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -97,6 +97,46 @@ function renderWithQuery() {
|
||||
return render(<SearchScreen />);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// A fetch mock whose /api/search (results) and /api/search/all-items responses are held pending
|
||||
// until the test resolves them, so we can observe the in-flight "refreshing" window (issue #221).
|
||||
function mockControlledSearch() {
|
||||
const searchDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
const allItemsDeferreds: Array<{ resolve: (body: unknown) => void }> = [];
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (url.startsWith('/api/search/all-items')) {
|
||||
const d = deferred<Response>();
|
||||
allItemsDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url.startsWith('/api/search')) {
|
||||
const d = deferred<Response>();
|
||||
searchDeferreds.push({ resolve: (body) => d.resolve(jsonResponse(body)) });
|
||||
return d.promise;
|
||||
}
|
||||
if (url === '/api/collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/smart-collections') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
return { searchDeferreds, allItemsDeferreds };
|
||||
}
|
||||
|
||||
describe('SearchScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
@@ -178,6 +218,136 @@ describe('SearchScreen', () => {
|
||||
expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [7, 8] });
|
||||
});
|
||||
|
||||
it('gates mutation controls and shows a refreshing cue while a new query loads (issue #221)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// Baseline: mutation surfaces are live for the current result set.
|
||||
expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Add all to collection/ })).not.toBeDisabled();
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
|
||||
|
||||
// Change the query: the debounced commit fires a refetch, which we hold pending.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
|
||||
// Refreshing window: cue visible, every mutation surface gated, cards still visible.
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
expect(screen.getByText('Blade Runner')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /^Select/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Add all to collection/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Add all to playlist/ })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /Save as smart collection/ })).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: 'Add to…' })).toBeNull();
|
||||
|
||||
// Replacement resolves: controls re-enable, cue gone.
|
||||
ctl.searchDeferreds[1].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
|
||||
expect(screen.getByRole('button', { name: /^Select/ })).not.toBeDisabled();
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('discards an Add-all result when the query changed before it resolved (issue #221)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// Kick off Add all (all-items request in flight, dialog not yet open).
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
|
||||
await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(1));
|
||||
|
||||
// Change the query before all-items resolves, then settle the new query's results.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
ctl.searchDeferreds[1].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
|
||||
|
||||
// The stale all-items result now resolves — it must NOT open a dialog for the old query.
|
||||
ctl.allItemsDeferreds[0].resolve(allItems);
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
// A fresh Add all for the current query still works.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ }));
|
||||
await waitFor(() => expect(ctl.allItemsDeferreds.length).toBe(2));
|
||||
ctl.allItemsDeferreds[1].resolve(allItems);
|
||||
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show a stuck refreshing cue after the query is cleared to empty (#222 review)', async () => {
|
||||
mockApi();
|
||||
renderWithQuery();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// `load()` early-returns for a blank query, so `state` never updates once cleared — the
|
||||
// `refreshing` derivation must be gated on `hasQuery`, or the cue gets stuck forever over the
|
||||
// "Type a query…" empty card.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: '' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Type a query to search across every media kind.')).toBeTruthy()
|
||||
);
|
||||
expect(screen.queryByText('Refreshing…')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a select-mode card inert (no select, no navigate) while refreshing (#222 review)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
|
||||
const pushState = vi.spyOn(window.history, 'pushState');
|
||||
fireEvent.click(screen.getByText('Blade Runner'));
|
||||
|
||||
// Neither selection nor navigation fires — the card is fully inert during select+refresh.
|
||||
expect(screen.queryByText('1 selected')).toBeNull();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('select toggle: entering select mode is blocked while refreshing, exiting is allowed (#222 review)', async () => {
|
||||
const ctl = mockControlledSearch();
|
||||
renderWithQuery();
|
||||
|
||||
ctl.searchDeferreds[0].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy());
|
||||
|
||||
// Not in select mode; trigger a refetch — entering select mode must be blocked.
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'alien' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(2));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
const enterToggle = screen.getByRole('button', { name: /^Select/ });
|
||||
expect(enterToggle).toBeDisabled();
|
||||
expect(enterToggle.className).toContain('ctv-button-secondary');
|
||||
|
||||
// Settle, enter select mode, then trigger another refetch — exiting must stay enabled.
|
||||
ctl.searchDeferreds[1].resolve(searchResults());
|
||||
await waitFor(() => expect(screen.queryByText('Refreshing…')).toBeNull());
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Select/ }));
|
||||
expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-primary');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Search movies/), { target: { value: 'aliens' } });
|
||||
await waitFor(() => expect(ctl.searchDeferreds.length).toBe(3));
|
||||
await waitFor(() => expect(screen.getByText('Refreshing…')).toBeTruthy());
|
||||
|
||||
const exitToggle = screen.getByRole('button', { name: /^Select/ });
|
||||
expect(exitToggle).not.toBeDisabled();
|
||||
fireEvent.click(exitToggle);
|
||||
expect(screen.getByRole('button', { name: /^Select/ }).className).toContain('ctv-button-secondary');
|
||||
});
|
||||
|
||||
it('saves the current query as a new smart collection', async () => {
|
||||
const { calls } = mockApi();
|
||||
renderWithQuery();
|
||||
|
||||
@@ -44,7 +44,9 @@ const GROUPS: GroupDef[] = [
|
||||
];
|
||||
|
||||
type SearchState =
|
||||
| { results: SearchResults; error: null; status: 'success' }
|
||||
// `query` records which query produced this result set. When it no longer matches the current
|
||||
// committed query, the visible cards are stale (a refetch is in flight) — see `refreshing` below.
|
||||
| { results: SearchResults; error: null; status: 'success'; query: string }
|
||||
| { results: null; error: string; status: 'error' }
|
||||
| { results: null; error: null; status: 'loading' };
|
||||
|
||||
@@ -116,7 +118,7 @@ export function SearchScreen() {
|
||||
getSearchResults({ query: trimmed, pageSize: PAGE_SIZE })
|
||||
.then((results) => {
|
||||
if (activeRef.current && id === seqRef.current) {
|
||||
setState({ results, error: null, status: 'success' });
|
||||
setState({ results, error: null, status: 'success', query: trimmed });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -172,6 +174,13 @@ export function SearchScreen() {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// Bind the all-items result to the query that requested it. `lastQueryRef` always holds the
|
||||
// current committed query (see the debounce effect); if it moved on, this bulk-add would be
|
||||
// scoped to the PREVIOUS query's entire result set — discard it (issue #221).
|
||||
if (lastQueryRef.current !== trimmed) {
|
||||
setPendingAll(null);
|
||||
return;
|
||||
}
|
||||
setPendingAll(null);
|
||||
setDialog({ kind, items: { requestOverride: toAddItemsRequestFromSearch(result) } });
|
||||
})
|
||||
@@ -202,6 +211,17 @@ export function SearchScreen() {
|
||||
const hasResults = hasQuery && state.status === 'success' && totalMatches > 0;
|
||||
const selectionCount = selected.size;
|
||||
|
||||
// A refetch is in flight when the currently-rendered success result set was produced by a query
|
||||
// other than the current committed one (results stay visible during refetch — see `load`). While
|
||||
// refreshing we keep cards visible but gate every mutation surface and show a refreshing cue, so
|
||||
// no add/select action is scoped to the stale, about-to-be-replaced result set (issue #221).
|
||||
// Gated on `hasQuery`: `load()` early-returns for a blank query, so `state` keeps the LAST
|
||||
// non-empty query's success variant forever once the query is cleared — without this gate,
|
||||
// `refreshing` would get stuck true over the "Type a query to search…" empty card (#222 review).
|
||||
const refreshing = hasQuery && state.status === 'success' && state.query !== query.trim();
|
||||
// Enter select mode only for the live result set.
|
||||
const canSelect = selectMode && !refreshing;
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -215,6 +235,9 @@ export function SearchScreen() {
|
||||
{hasResults && (
|
||||
<>
|
||||
<Button
|
||||
// Entering select mode while refreshing is blocked (it would target a stale result
|
||||
// set), but exiting is always allowed — it only clears selection, not a mutation.
|
||||
disabled={refreshing && !selectMode}
|
||||
onClick={toggleSelectMode}
|
||||
size="sm"
|
||||
startIcon={<CheckSquare aria-hidden="true" size={14} />}
|
||||
@@ -223,7 +246,7 @@ export function SearchScreen() {
|
||||
Select
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pendingAll !== null}
|
||||
disabled={pendingAll !== null || refreshing}
|
||||
loading={pendingAll === 'collection'}
|
||||
onClick={() => addAll('collection')}
|
||||
size="sm"
|
||||
@@ -233,7 +256,7 @@ export function SearchScreen() {
|
||||
Add all to collection
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pendingAll !== null}
|
||||
disabled={pendingAll !== null || refreshing}
|
||||
loading={pendingAll === 'playlist'}
|
||||
onClick={() => addAll('playlist')}
|
||||
size="sm"
|
||||
@@ -243,6 +266,7 @@ export function SearchScreen() {
|
||||
Add all to playlist
|
||||
</Button>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => setDialog({ kind: 'save-smart' })}
|
||||
size="sm"
|
||||
startIcon={<Save aria-hidden="true" size={14} />}
|
||||
@@ -263,6 +287,7 @@ export function SearchScreen() {
|
||||
<span className="ctv-search-selectbar-count">{selectionCount} selected</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => addSelection('collection')}
|
||||
size="sm"
|
||||
startIcon={<FolderPlus aria-hidden="true" size={14} />}
|
||||
@@ -271,6 +296,7 @@ export function SearchScreen() {
|
||||
Add to collection
|
||||
</Button>
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={() => addSelection('playlist')}
|
||||
size="sm"
|
||||
startIcon={<ListVideo aria-hidden="true" size={14} />}
|
||||
@@ -289,6 +315,13 @@ export function SearchScreen() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refreshing && (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={14} />
|
||||
<span>Refreshing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasQuery && (
|
||||
<Card>
|
||||
<div className="ctv-collections-empty">Type a query to search across every media kind.</div>
|
||||
@@ -344,21 +377,28 @@ export function SearchScreen() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ctv-media-grid">
|
||||
<div className={`ctv-media-grid${refreshing ? ' ctv-media-grid-dim' : ''}`}>
|
||||
{data.items.map((item) => {
|
||||
const key = itemKey(item);
|
||||
const detailPath = mediaDetailPath(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
// While refreshing, the per-card Add-to menu is withheld (stale result set);
|
||||
// card navigation stays live.
|
||||
actions={
|
||||
selectMode ? undefined : (
|
||||
selectMode || refreshing ? undefined : (
|
||||
<AddToMenu compact items={[item]} onDone={(message) => setNotice({ tone: 'ok', message })} />
|
||||
)
|
||||
}
|
||||
item={item}
|
||||
key={key}
|
||||
// While `selectMode && refreshing`, the card must be fully inert: neither
|
||||
// `onOpen` (which MediaPosterCard falls back to when `onToggleSelect` is
|
||||
// undefined) nor `onToggleSelect` may fire, or a mid-select click would
|
||||
// navigate away instead of no-op'ing. Outside select mode, `onOpen` stays
|
||||
// live during a refresh (intended).
|
||||
onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={selectMode ? toggleSelect : undefined}
|
||||
onToggleSelect={canSelect ? toggleSelect : undefined}
|
||||
selected={selectMode ? selected.has(key) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2509,6 +2509,14 @@ body {
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
/* Dim the stale result set while a refetch is in flight (search/media browse — issue #221). The
|
||||
per-card mutation menu is withheld in the same state; navigation stays live so no pointer-events
|
||||
change here. */
|
||||
.ctv-media-grid-dim {
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--dur-fast, 120ms) var(--ease-standard, ease);
|
||||
}
|
||||
|
||||
.ctv-media-card {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
Reference in New Issue
Block a user