fix(spa): gate mutation controls on stale result sets during refetch (#221) #222

Merged
timothy merged 3 commits from fix/221-stale-refetch into main 2026-07-11 01:47:37 +02:00
7 changed files with 473 additions and 16 deletions
+10 -2
View File
@@ -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
+54
View File
@@ -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.
+143
View File
@@ -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 />);
+41 -7
View File
@@ -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))}
/>
);
+170
View File
@@ -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();
+47 -7
View File
@@ -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}
/>
);
+8
View File
@@ -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);