mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
Reference in New Issue
Block a user