app: search Other tab distinguishes pending channels from none

useSearchChannels initialised channels to [] and loading to false, and
only set loading inside its mount effect, so the Other tab's first
render painted "No Channels" with a count of 0 before the search had
run.  A failed search was rendered the same way, since the hook had no
catch and the API helper returns undefined on a non-OK response.

Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] = no channels; loading
  is true from the first render because the search starts on mount.
* Changing tags resets to pending, and only the newest request may
  write state, so a slow response for previous tags is dropped.
* The tab shows a loader while pending, an error on failure, and
  "No Channels" only once the server confirmed there are none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-09-26 11:28:19 -06:00
parent 811793d767
commit 770e80fa3e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 148 additions and 9 deletions

View file

@ -0,0 +1,56 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {OtherSearchView} from './Search';
import {searchChannels} from '../api';
// What the search page's Other tab shows for each state of useSearchChannels. "No Channels"
// must only appear once the server has confirmed there are none.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
searchChannels: jest.fn(),
}));
const NO_CHANNELS = /No Channels/;
beforeEach(() => searchChannels.mockReset());
test('pending: shows a loader, not "No Channels"', () => {
searchChannels.mockReturnValue(new Promise(() => {
}));
const {container} = render(<OtherSearchView loading={false}/>);
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
});
test('failed: shows an error, not "No Channels"', async () => {
searchChannels.mockResolvedValue(undefined);
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(/Could not fetch the channels/)).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
} finally {
spy.mockRestore();
}
});
test('empty: shows "No Channels"', async () => {
searchChannels.mockResolvedValue({channels: []});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
});
test('loaded: lists the channels', async () => {
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText('Cooking')).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
});

View file

@ -6,7 +6,7 @@ import {ShortcutHint} from "./ShortcutHint";
import {ZimSearchView} from "./Zim";
import {MapSearchView} from "./MapSearchView";
import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions} from "../api";
import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import {ErrorMessage, filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
@ -541,14 +541,17 @@ function SearchChannelPreview({channel}) {
</div>
}
function OtherSearchView({loading}) {
// `channels` follows useSearchChannels: null = pending, undefined = fetch failed, [] = none.
export function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
const [activeValue, setActiveValue] = React.useState('channels');
const activeTags = searchParams.getAll('tag');
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
if (loading || channelsLoading) {
if (loading || channelsLoading || channels === null) {
return <Panel><Loading/></Panel>
} else if (channels === undefined) {
return <Panel><ErrorMessage>Could not fetch the channels.</ErrorMessage></Panel>
}
return <Accordion value={activeValue} onChange={setActiveValue}>

View file

@ -2079,16 +2079,31 @@ export const useCalcQuery = () => {
export const useSearchChannels = (defaultTagNames) => {
const [tagNames, setTagNames] = useState(defaultTagNames || []);
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(false);
// null = pending, undefined = fetch failed, [] = no channels. A search starts on mount, so the
// first render is already loading; consumers must never see [] for a search that has not run.
const [channels, setChannels] = useState(null);
const [loading, setLoading] = useState(true);
// Only the newest request may write state; a slow response for previous tags is dropped.
const requestGen = useRef(0);
const localSearchChannels = async () => {
const gen = ++requestGen.current;
setChannels(null);
setLoading(true);
try {
const {channels: newChannels} = await searchChannels(tagNames);
setChannels(newChannels);
if (gen === requestGen.current) {
setChannels(newChannels || []);
}
} catch (e) {
console.error(e);
if (gen === requestGen.current) {
setChannels(undefined);
}
} finally {
setLoading(false);
if (gen === requestGen.current) {
setLoading(false);
}
}
};

View file

@ -1,8 +1,8 @@
import React from 'react';
import {act, renderHook} from '@testing-library/react';
import {render, screen} from '../test-utils';
import {usePages, useDriveTemperature, useDriveHealth, useVideoExtras} from './customHooks';
import {getVideoComments, getVideoDescription} from '../api';
import {usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useVideoExtras} from './customHooks';
import {getVideoComments, getVideoDescription, searchChannels} from '../api';
import {QueryContext, StatusContext} from '../contexts/contexts';
import {Paginator} from '../components/Common';
@ -38,6 +38,7 @@ jest.mock('../api', () => ({
...jest.requireActual('../api'),
getVideoComments: jest.fn(),
getVideoDescription: jest.fn(),
searchChannels: jest.fn(),
}));
// Mock Media so Paginator renders both mobile + tablet variants synchronously.
@ -441,3 +442,67 @@ describe('useVideoExtras', () => {
expect(result.current.description).toBe('edited');
});
});
describe('useSearchChannels', () => {
// State contract: null = pending, undefined = fetch failed, [] = no channels. The Other tab
// shows "No Channels" only for the last of those; `loading` is true from the first render.
beforeEach(() => searchChannels.mockReset());
test('is loading with no channels on the first render', () => {
searchChannels.mockReturnValue(new Promise(() => {
}));
const {result} = renderHook(() => useSearchChannels(['a']));
expect(result.current.loading).toBe(true);
expect(result.current.channels).toBeNull();
});
test('resolves to the channels and stops loading', async () => {
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'One'}]});
const {result} = renderHook(() => useSearchChannels(['a']));
await act(async () => {
});
expect(searchChannels).toHaveBeenCalledWith(['a']);
expect(result.current.channels).toEqual([{id: 1, name: 'One'}]);
expect(result.current.loading).toBe(false);
});
test('a failed fetch yields undefined, not "no channels"', async () => {
// The api helper returns undefined on a non-OK response, which the hook cannot destructure.
searchChannels.mockResolvedValue(undefined);
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
const {result} = renderHook(() => useSearchChannels(['a']));
await act(async () => {
});
expect(result.current.channels).toBeUndefined();
expect(result.current.loading).toBe(false);
} finally {
spy.mockRestore();
}
});
test('changing tags resets to pending, and a slow response for the old tags is dropped', async () => {
let resolveOld;
searchChannels.mockReturnValueOnce(new Promise(res => resolveOld = res));
const {result} = renderHook(() => useSearchChannels(['old']));
expect(result.current.loading).toBe(true);
searchChannels.mockResolvedValue({channels: [{id: 2, name: 'New'}]});
act(() => {
result.current.setTagNames(['new']);
});
expect(result.current.channels).toBeNull();
expect(result.current.loading).toBe(true);
await act(async () => {
});
expect(result.current.channels).toEqual([{id: 2, name: 'New'}]);
expect(result.current.loading).toBe(false);
await act(async () => {
resolveOld({channels: [{id: 1, name: 'Old'}]});
});
expect(result.current.channels).toEqual([{id: 2, name: 'New'}]);
expect(result.current.loading).toBe(false);
});
});