Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions src/app/(app)/index.error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
jest.mock('react-native-mmkv', () => ({
createMMKV: jest.fn(() => ({
set: jest.fn(),
getString: jest.fn(),
getAllKeys: jest.fn().mockReturnValue([]),
remove: jest.fn(),
})),
}));

jest.mock('react-native-flash-message', () => ({
showMessage: jest.fn(),
}));

// FlashList → FlatList passthrough so list items actually render in tests.
// (The old '@shopify/flash-list/jestSetup' mocked it to a v1-only export that
// no longer exists in v2, i.e. undefined.)
jest.mock('@shopify/flash-list', () => {
const React: typeof import('react') = jest.requireActual('react');
const { FlatList } = jest.requireActual('react-native');
const FlashList = (props: React.ComponentProps<typeof FlatList>) => <FlatList {...props} />;
return { FlashList };
});

jest.mock('expo-router', () => ({
Link: 'Link',
useLocalSearchParams: jest.fn().mockReturnValue({}),
useRouter: () => ({ push: jest.fn() }),
}));

jest.mock('@react-native-menu/menu', () => {
const React: typeof import('react') = jest.requireActual('react');
const MenuView = ({ children }: { children?: React.ReactNode }) => children ?? null;
return { MenuView };
});

jest.mock('@/lib/hooks/use-download-settings', () => ({
useDownloadSettings: () => ({ downloadPath: 'videos' }),
}));

// Per-test state of the videos query.
const mockQueryState: {
data: { pages: { data: unknown[] }[] } | undefined;
isError: boolean;
error: unknown;
} = {
data: undefined,
isError: false,
error: null,
};

jest.mock('@/api/video-queries', () => ({
useVideos: () => ({
data: mockQueryState.data,
isPending: false,
isError: mockQueryState.isError,
error: mockQueryState.error,
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
refetch: jest.fn(),
isRefetching: false,
}),
}));

import { cleanup, render, screen } from '@/lib/test-utils';
import { showMessage } from 'react-native-flash-message';

import Home from './index';

const sampleVideo = {
id: 'v1',
slug: 'v-1',
title: 'Loaded Video',
thumbnail: '',
duration: '1:00',
views: '1',
rating: '100%',
};

beforeEach(() => {
jest.clearAllMocks();
mockQueryState.data = undefined;
mockQueryState.isError = false;
mockQueryState.error = null;
});

afterEach(cleanup);

describe('Home screen — fetch error resilience', () => {
it('keeps the loaded feed and toasts when a page fetch fails', async () => {
// Pages 1-6 loaded; page 7 came back 502 (data is retained by the query).
mockQueryState.data = { pages: [{ data: [sampleVideo] }] };
mockQueryState.isError = true;
const error = new Error(
'Failed to fetch https://rule34video.com/latest-updates/7/: 502'
) as Error & { status?: number };
error.status = 502;
mockQueryState.error = error;

render(<Home />);

// The feed item is still on screen; the full error page is not.
expect(await screen.findByText('Loaded Video')).toBeTruthy();
expect(screen.queryByText('Error loading videos')).toBeNull();
// The classified toast fired (en locale in tests).
expect(showMessage).toHaveBeenCalledTimes(1);
const call = (showMessage as jest.Mock).mock.calls[0][0];
expect(call.message).toContain('502');
expect(call.position).toBe('center');
});

it('shows the full error screen (no toast) when the feed never loaded', () => {
mockQueryState.data = undefined;
mockQueryState.isError = true;
mockQueryState.error = new Error('Failed to fetch: 502');

render(<Home />);

expect(screen.getByText('Error loading videos')).toBeTruthy();
expect(screen.queryByText('Loaded Video')).toBeNull();
expect(showMessage).not.toHaveBeenCalled();
});
});
8 changes: 7 additions & 1 deletion src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SafeAreaView, Text } from '@/components/ui';
import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import type { VideoListItem } from '@/lib/r34/types';

export default function Home(): React.ReactElement {
Expand All @@ -18,6 +19,7 @@ export default function Home(): React.ReactElement {
data,
isPending,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
Expand All @@ -33,12 +35,16 @@ export default function Home(): React.ReactElement {
return flattenUniquePages(data?.pages, (item) => item.id);
}, [data]);

// Keep the loaded feed on fetch errors (e.g. a 502 on page 7) and say what
// went wrong via toast; the full error screen is only for an empty feed.
useFetchErrorToast(isError, error, videos.length > 0);

const renderItem = React.useCallback(
({ item }: { item: VideoListItem }) => <VideoTile item={item} />,
[]
);

if (isError) {
if (isError && videos.length === 0) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Text className="text-lg text-neutral-900 dark:text-neutral-100">Error loading videos</Text>
Expand Down
7 changes: 6 additions & 1 deletion src/app/(app)/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { VideoTile } from '@/components/video-tile';
import { useTranslate } from '@/lib';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import { useSearchHistory } from '@/lib/hooks/use-search-history';
import type { Post } from '@/lib/r34/extractor';
import { useTagStore } from '@/lib/stores/tag-store';
Expand Down Expand Up @@ -49,14 +50,18 @@ export default function SearchScreen() {
const { getHistory, addHistory, removeHistory, clearHistory } = useSearchHistory();
const { favoriteTags } = useTagStore();

const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
const { data, isPending, isError, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSearch(submittedQuery);
const numColumns = useColumns();

const posts = React.useMemo(() => {
return flattenUniquePages(data?.pages, (item) => item.id);
}, [data]);

// Search errors used to fail silently — surface them via toast while
// keeping whatever results are already on screen.
useFetchErrorToast(isError, error, posts.length > 0);

const [history, setHistory] = React.useState<string[]>(getHistory());

const handleSubmit = () => {
Expand Down
18 changes: 15 additions & 3 deletions src/app/author/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui';
import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import type { VideoListItem } from '@/lib/r34/types';
import { useFollowingStore } from '@/lib/stores/following-store';

Expand All @@ -17,19 +18,30 @@ export default function AuthorPage(): React.ReactElement {
const followed = isFollowing(id);
const numColumns = useColumns();

const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
useMemberVideos({ variables: { memberId: id } });
const {
data,
isPending,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useMemberVideos({ variables: { memberId: id } });

const videos = React.useMemo(() => {
return flattenUniquePages(data?.pages, (item) => item.id);
}, [data]);

// Keep loaded videos on fetch errors; toast says what went wrong.
useFetchErrorToast(isError, error, videos.length > 0);

const renderItem = React.useCallback(
({ item }: { item: VideoListItem }) => <VideoTile item={item} />,
[]
);

if (isError) {
if (isError && videos.length === 0) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
Expand Down
8 changes: 6 additions & 2 deletions src/app/category/[name].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,30 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui';
import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import type { VideoListItem } from '@/lib/r34/types';

export default function CategoryPage(): React.ReactElement {
const { name } = useLocalSearchParams<{ name: string }>();
const numColumns = useColumns();

const { data, isPending, isError, refetch, isRefetching } = useVideos({
const { data, isPending, isError, error, refetch, isRefetching } = useVideos({
variables: { category: name },
});

const videos = React.useMemo(() => {
return flattenUniquePages(data?.pages, (item) => item.id);
}, [data]);

// Keep loaded videos on fetch errors; toast says what went wrong.
useFetchErrorToast(isError, error, videos.length > 0);

const renderItem = React.useCallback(
({ item }: { item: VideoListItem }) => <VideoTile item={item} />,
[]
);

if (isError) {
if (isError && videos.length === 0) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
Expand Down
7 changes: 6 additions & 1 deletion src/app/model/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui';
import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import type { VideoListItem } from '@/lib/r34/types';

export default function ModelPage(): React.ReactElement {
Expand All @@ -18,6 +19,7 @@ export default function ModelPage(): React.ReactElement {
data,
isPending,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
Expand All @@ -31,6 +33,9 @@ export default function ModelPage(): React.ReactElement {
return flattenUniquePages(data?.pages, (item) => item.id);
}, [data]);

// Keep loaded videos on fetch errors; toast says what went wrong.
useFetchErrorToast(isError, error, videos.length > 0);

const renderItem = React.useCallback(
({ item }: { item: VideoListItem }) => <VideoTile item={item} />,
[]
Expand All @@ -50,7 +55,7 @@ export default function ModelPage(): React.ReactElement {
[slug, videos.length]
);

if (isError) {
if (isError && videos.length === 0) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
Expand Down
18 changes: 15 additions & 3 deletions src/app/tag/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FocusAwareStatusBar, SafeAreaView, Text } from '@/components/ui';
import { VideoTile } from '@/components/video-tile';
import { flattenUniquePages } from '@/lib/flatten-pages';
import { useColumns } from '@/lib/hooks/use-columns';
import { useFetchErrorToast } from '@/lib/hooks/use-fetch-error-toast';
import type { Post } from '@/lib/r34/extractor';
import { useTagStore } from '@/lib/stores/tag-store';

Expand All @@ -17,16 +18,27 @@ export default function TagPage(): React.ReactElement {
const favorited = isFavorite(name ?? id);
const numColumns = useColumns();

const { data, isPending, isError, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } =
useTagVideos(id);
const {
data,
isPending,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTagVideos(id);

const posts = React.useMemo(() => {
return flattenUniquePages(data?.pages, (item) => item.id);
}, [data]);

// Keep loaded posts on fetch errors; toast says what went wrong.
useFetchErrorToast(isError, error, posts.length > 0);

const renderItem = React.useCallback(({ item }: { item: Post }) => <VideoTile item={item} />, []);

if (isError) {
if (isError && posts.length === 0) {
return (
<SafeAreaView className="flex-1 items-center justify-center bg-white dark:bg-neutral-900">
<Stack.Screen options={{ title: 'Error' }} />
Expand Down
17 changes: 17 additions & 0 deletions src/lib/hooks/use-fetch-error-toast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from 'react';

import { showFetchErrorToast } from '@/lib/r34/fetch-error';

/**
* Toasts a classified fetch error while the screen keeps its already-loaded
* content. `enabled` should reflect "content is on screen" — when there is
* nothing to keep, the screen renders its full error state instead and the
* toast would be noise. Call before any early returns (it's a hook).
*/
export function useFetchErrorToast(isError: boolean, error: unknown, enabled: boolean): void {
React.useEffect(() => {
if (isError && enabled) {
showFetchErrorToast(error);
}
}, [isError, error, enabled]);
}
Loading