fix: throw notFound() for missing titles/people and improve error handling

- Web: wrap title and person detail loaders in try/catch; throw `notFound()` when the API returns `TITLE_NOT_FOUND` or `PERSON_NOT_FOUND` so TanStack Router renders the 404 component instead of crashing
- Web: fix `QuickAddButton` to reset local status to `null` (not skip the update) when `userStatus` becomes undefined, so removing a title from the library reflects immediately
- Native: add a generic error state to the title detail screen with a "Try again" / "Go back" recovery UI, distinct from the existing "title not found" state
- Native: pass `getItemType` to `FlashList` in the search screen and recently-viewed list to enable correct item recycling across person and title cell types
This commit is contained in:
2026-03-21 16:02:59 -04:00
parent 37c35488fd
commit f676cea237
6 changed files with 82 additions and 27 deletions
@@ -66,6 +66,10 @@ export default function SearchScreen() {
); );
const keyExtractor = useCallback((item: SearchResultItem) => `${item.type}-${item.id}`, []); const keyExtractor = useCallback((item: SearchResultItem) => `${item.type}-${item.id}`, []);
const getItemType = useCallback(
(item: SearchResultItem) => (item.type === "person" ? "person" : "title"),
[],
);
return ( return (
<View collapsable={false} className="bg-background flex-1"> <View collapsable={false} className="bg-background flex-1">
@@ -98,6 +102,7 @@ export default function SearchScreen() {
<FlashList <FlashList
data={allResults} data={allResults}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem} renderItem={renderItem}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
+32 -1
View File
@@ -2,6 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { useHeaderHeight } from "@react-navigation/elements"; import { useHeaderHeight } from "@react-navigation/elements";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { import {
IconAlertTriangle,
IconBrandAppstore, IconBrandAppstore,
IconBrandGooglePlay, IconBrandGooglePlay,
IconCheck, IconCheck,
@@ -47,6 +48,7 @@ import { StarRating } from "@/components/ui/star-rating";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { useTitleActions } from "@/hooks/use-title-actions"; import { useTitleActions } from "@/hooks/use-title-actions";
import { useTitleTheme } from "@/hooks/use-title-theme"; import { useTitleTheme } from "@/hooks/use-title-theme";
import { getAppErrorCode } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc"; import { orpc } from "@/lib/orpc";
import { addRecentlyViewed } from "@/lib/recently-viewed"; import { addRecentlyViewed } from "@/lib/recently-viewed";
@@ -118,6 +120,7 @@ export default function TitleDetailScreen() {
}); });
const title = detail.data?.title; const title = detail.data?.title;
const detailErrorCode = getAppErrorCode(detail.error);
const palette = title?.colorPalette ?? null; const palette = title?.colorPalette ?? null;
useTitleTheme(palette); useTitleTheme(palette);
const providerIcon = process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay; const providerIcon = process.env.EXPO_OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay;
@@ -222,7 +225,7 @@ export default function TitleDetailScreen() {
); );
} }
if (!title) { if (detail.isError && detailErrorCode === "TITLE_NOT_FOUND") {
return ( return (
<ModalLayout> <ModalLayout>
<View className="flex-1 items-center justify-center px-6"> <View className="flex-1 items-center justify-center px-6">
@@ -240,6 +243,34 @@ export default function TitleDetailScreen() {
); );
} }
if (detail.isError || !title) {
return (
<ModalLayout>
<View className="flex-1 items-center justify-center px-6">
<IconAlertTriangle size={48} color={mutedForeground} />
<Text className="font-display text-foreground mt-3 text-xl">
<Trans>Something went wrong</Trans>
</Text>
<Text className="text-muted-foreground mt-1 text-center text-sm">
<Trans>Could not load title details</Trans>
</Text>
<View className="mt-4 flex-row items-center gap-4">
<Pressable onPress={() => void detail.refetch()}>
<Text className="text-primary">
<Trans>Try again</Trans>
</Text>
</Pressable>
<Pressable onPress={() => back()}>
<Text className="text-primary">
<Trans>Go back</Trans>
</Text>
</Pressable>
</View>
</View>
</ModalLayout>
);
}
const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4); const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4);
return ( return (
@@ -46,6 +46,10 @@ export function RecentlyViewedList() {
); );
const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []); const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []);
const getItemType = useCallback(
(item: RecentlyViewedItem) => (item.type === "person" ? "person" : "title"),
[],
);
const listHeaderComponent = useMemo( const listHeaderComponent = useMemo(
() => ( () => (
@@ -89,6 +93,7 @@ export function RecentlyViewedList() {
<FlashList <FlashList
data={items} data={items}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem} renderItem={renderItem}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
+1 -3
View File
@@ -89,9 +89,7 @@ function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStat
// Sync local state when prop changes (e.g. after navigation or SWR revalidation) // Sync local state when prop changes (e.g. after navigation or SWR revalidation)
useEffect(() => { useEffect(() => {
if (userStatus) { setAddedStatus(userStatus ?? null);
setAddedStatus(userStatus);
}
}, [userStatus]); }, [userStatus]);
const quickAddMutation = useMutation( const quickAddMutation = useMutation(
+21 -13
View File
@@ -1,25 +1,33 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { PersonDetailClient, PersonDetailSkeleton } from "@/components/people/person-detail-client"; import { PersonDetailClient, PersonDetailSkeleton } from "@/components/people/person-detail-client";
import { getAppErrorCode } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
export const Route = createFileRoute("/_app/people/$id")({ export const Route = createFileRoute("/_app/people/$id")({
staleTime: 60_000, staleTime: 60_000,
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const data = await context.queryClient.ensureInfiniteQueryData( try {
orpc.people.detail.infiniteOptions({ const data = await context.queryClient.ensureInfiniteQueryData(
input: (pageParam: number) => ({ orpc.people.detail.infiniteOptions({
id: params.id, input: (pageParam: number) => ({
page: pageParam, id: params.id,
limit: 20, page: pageParam,
limit: 20,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}), }),
initialPageParam: 1, );
getNextPageParam: (lastPage) => return { personName: data.pages[0]?.person.name };
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, } catch (error) {
}), if (getAppErrorCode(error) === "PERSON_NOT_FOUND") {
); throw notFound();
return { personName: data.pages[0]?.person.name }; }
throw error;
}
}, },
head: ({ loaderData }) => { head: ({ loaderData }) => {
if (!loaderData?.personName) return {}; if (!loaderData?.personName) return {};
+18 -10
View File
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { TitleActions } from "@/components/titles/title-actions"; import { TitleActions } from "@/components/titles/title-actions";
import { TitleAvailability } from "@/components/titles/title-availability"; import { TitleAvailability } from "@/components/titles/title-availability";
@@ -11,21 +11,29 @@ import { TitleRecommendations } from "@/components/titles/title-recommendations"
import { TitleSeasons } from "@/components/titles/title-seasons"; import { TitleSeasons } from "@/components/titles/title-seasons";
import { TitleTheme } from "@/components/titles/title-theme"; import { TitleTheme } from "@/components/titles/title-theme";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { getAppErrorCode } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc/client"; import { orpc } from "@/lib/orpc/client";
import { getThemeCssProperties } from "@/lib/theme"; import { getThemeCssProperties } from "@/lib/theme";
export const Route = createFileRoute("/_app/titles/$id")({ export const Route = createFileRoute("/_app/titles/$id")({
staleTime: 60_000, staleTime: 60_000,
loader: async ({ params, context }) => { loader: async ({ params, context }) => {
const [titleResult, userInfo] = await Promise.all([ try {
context.queryClient.ensureQueryData( const [titleResult, userInfo] = await Promise.all([
orpc.titles.detail.queryOptions({ input: { id: params.id } }), context.queryClient.ensureQueryData(
), orpc.titles.detail.queryOptions({ input: { id: params.id } }),
context.queryClient ),
.ensureQueryData(orpc.titles.userInfo.queryOptions({ input: { id: params.id } })) context.queryClient
.catch(() => null), .ensureQueryData(orpc.titles.userInfo.queryOptions({ input: { id: params.id } }))
]); .catch(() => null),
return { ...titleResult, userInfo }; ]);
return { ...titleResult, userInfo };
} catch (error) {
if (getAppErrorCode(error) === "TITLE_NOT_FOUND") {
throw notFound();
}
throw error;
}
}, },
head: ({ loaderData }) => { head: ({ loaderData }) => {
if (!loaderData) return {}; if (!loaderData) return {};