Fix Discover catalog dropdown going empty on Movie/Series switch

discover.catalogs held whatever type the last fetch returned, so
switching content type raced the async refetch: the auto-select
effect could latch onto a stale catalog key from the previous type,
which then matched nothing once the real catalogs arrived. Filter
catalogs by the current contentType at the source so a mismatched
entry can never be selected.

Adds a Vitest + RTL harness (none existed) to drive the real
DiscoverScreen through the exact race condition.
This commit is contained in:
KhooLy 2026-07-11 02:44:16 +03:00
parent 353f82de26
commit 18c99a66e2
6 changed files with 1354 additions and 5 deletions

1227
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"test": "vitest run",
"typecheck": "node scripts/gen-core-methods.mjs && tsc --noEmit",
"build": "node scripts/gen-core-methods.mjs && tsc && vite build",
"check": "npm run build && cd src-tauri && cargo check",
@ -29,11 +30,16 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"esbuild": "^0.28.1",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.1.1"
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}

View file

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DiscoverScreen } from './DiscoverScreen';
import type { AppState, DiscoverCatalog } from '../core/types';
const MOVIE_CATALOGS: DiscoverCatalog[] = [
{ key: 'cinemeta-movie-top', label: 'Cinemeta: Popular', type: 'movie', extras: [] },
];
const SERIES_CATALOGS: DiscoverCatalog[] = [
{ key: 'cinemeta-series-top', label: 'Cinemeta: Popular', type: 'series', extras: [] },
];
function baseState(): AppState {
return {
navigation: { route: 'discover' },
home: {} as AppState['home'],
detail: {} as AppState['detail'],
search: {} as AppState['search'],
player: {} as AppState['player'],
library: {} as AppState['library'],
discover: { catalogs: MOVIE_CATALOGS, results: [], isLoading: false, catalogsLoading: false },
addons: {
installed: [{
manifest: { catalogs: [{ type: 'movie' }, { type: 'series' }] },
}] as unknown as AppState['addons']['installed'],
},
settings: {},
} as unknown as AppState;
}
function Harness({ catalogFetchDelayMs }: { catalogFetchDelayMs: number }) {
const [state, setState] = useState<AppState>(baseState());
const onDispatch = (actionJson: string) => {
const action = JSON.parse(actionJson) as { type: string; contentType?: string };
if (action.type === 'discoverCatalogFiltersRequested') {
const forType = action.contentType;
setTimeout(() => {
setState((prev) => ({
...prev,
discover: {
...prev.discover,
catalogs: forType === 'series' ? SERIES_CATALOGS : MOVIE_CATALOGS,
},
}));
}, catalogFetchDelayMs);
}
};
return (
<DiscoverScreen
state={state}
onDispatch={onDispatch}
onNavigateDetail={() => {}}
onBack={() => {}}
/>
);
}
function openPopoverMenu() {
return document.body.querySelector('.ui-popover') as HTMLElement;
}
async function switchToSeries(user: ReturnType<typeof userEvent.setup>) {
const typeDropdown = screen.getByText('Movie').closest('button')!;
await user.click(typeDropdown);
const menu = openPopoverMenu();
await user.click(within(menu).getByText('Series'));
}
describe('DiscoverScreen: switching content type', () => {
it('shows the series catalog once the async catalog fetch resolves, never leaving the dropdown stuck empty', async () => {
const user = userEvent.setup();
render(<Harness catalogFetchDelayMs={50} />);
await switchToSeries(user);
await waitFor(() => {
expect(screen.getByText('Cinemeta: Popular')).toBeInTheDocument();
});
const catalogDropdown = screen.getByText('Cinemeta: Popular').closest('button')!;
await user.click(catalogDropdown);
const menu = openPopoverMenu();
expect(within(menu).getByText('Cinemeta: Popular')).toBeInTheDocument();
});
it('never lets a stale movie catalog key survive the switch to Series (the actual bug)', async () => {
const user = userEvent.setup();
render(<Harness catalogFetchDelayMs={10} />);
await switchToSeries(user);
await waitFor(() => {
expect(screen.queryByText('No content found')).not.toBeInTheDocument();
});
expect(screen.getByText('Cinemeta: Popular')).toBeInTheDocument();
});
});

View file

@ -44,7 +44,7 @@ function DiscoverScreenInner({ state, onDispatch, onNavigateDetail, initialGenre
const isGridScrollingRef = useRef(false);
const scrollIdleTimerRef = useRef<number | null>(null);
const hoveredMetaRef = useRef<Meta | null>(null);
const catalogs = (discover.catalogs ?? []) as DiscoverCatalog[];
const catalogs = ((discover.catalogs ?? []) as DiscoverCatalog[]).filter((catalog) => catalog.type === contentType);
const selectedCatalog = catalogs.find((catalog) => catalog.key === selectedCatalogKey) ?? null;
const selectedExtra = selectedCatalog?.extras?.[0] ?? null;
const key = cacheKey(selectedCatalog?.key ?? null, selectedExtra?.name ?? null, extraValue);

9
src/test/setup.ts Normal file
View file

@ -0,0 +1,9 @@
import '@testing-library/jest-dom/vitest';
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverStub;

11
vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});