From f676cea237ac075110bce031774d3ace77305071 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 21 Mar 2026 16:02:59 -0400 Subject: [PATCH] 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 --- apps/native/src/app/(tabs)/(search)/index.tsx | 5 +++ apps/native/src/app/title/[id].tsx | 33 +++++++++++++++++- .../search/recently-viewed-list.tsx | 5 +++ apps/web/src/components/title-card.tsx | 4 +-- apps/web/src/routes/_app/people.$id.tsx | 34 ++++++++++++------- apps/web/src/routes/_app/titles.$id.tsx | 28 +++++++++------ 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/apps/native/src/app/(tabs)/(search)/index.tsx b/apps/native/src/app/(tabs)/(search)/index.tsx index 358929f..e287090 100644 --- a/apps/native/src/app/(tabs)/(search)/index.tsx +++ b/apps/native/src/app/(tabs)/(search)/index.tsx @@ -66,6 +66,10 @@ export default function SearchScreen() { ); const keyExtractor = useCallback((item: SearchResultItem) => `${item.type}-${item.id}`, []); + const getItemType = useCallback( + (item: SearchResultItem) => (item.type === "person" ? "person" : "title"), + [], + ); return ( @@ -98,6 +102,7 @@ export default function SearchScreen() { @@ -240,6 +243,34 @@ export default function TitleDetailScreen() { ); } + if (detail.isError || !title) { + return ( + + + + + Something went wrong + + + Could not load title details + + + void detail.refetch()}> + + Try again + + + back()}> + + Go back + + + + + + ); + } + const year = (title.releaseDate ?? title.firstAirDate)?.slice(0, 4); return ( diff --git a/apps/native/src/components/search/recently-viewed-list.tsx b/apps/native/src/components/search/recently-viewed-list.tsx index fd6bef7..68ef957 100644 --- a/apps/native/src/components/search/recently-viewed-list.tsx +++ b/apps/native/src/components/search/recently-viewed-list.tsx @@ -46,6 +46,10 @@ export function RecentlyViewedList() { ); const keyExtractor = useCallback((item: RecentlyViewedItem) => item.id, []); + const getItemType = useCallback( + (item: RecentlyViewedItem) => (item.type === "person" ? "person" : "title"), + [], + ); const listHeaderComponent = useMemo( () => ( @@ -89,6 +93,7 @@ export function RecentlyViewedList() { { - if (userStatus) { - setAddedStatus(userStatus); - } + setAddedStatus(userStatus ?? null); }, [userStatus]); const quickAddMutation = useMutation( diff --git a/apps/web/src/routes/_app/people.$id.tsx b/apps/web/src/routes/_app/people.$id.tsx index dc9d32a..34e9c9a 100644 --- a/apps/web/src/routes/_app/people.$id.tsx +++ b/apps/web/src/routes/_app/people.$id.tsx @@ -1,25 +1,33 @@ 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 { getAppErrorCode } from "@/lib/error-messages"; import { orpc } from "@/lib/orpc/client"; export const Route = createFileRoute("/_app/people/$id")({ staleTime: 60_000, loader: async ({ params, context }) => { - const data = await context.queryClient.ensureInfiniteQueryData( - orpc.people.detail.infiniteOptions({ - input: (pageParam: number) => ({ - id: params.id, - page: pageParam, - limit: 20, + try { + const data = await context.queryClient.ensureInfiniteQueryData( + orpc.people.detail.infiniteOptions({ + input: (pageParam: number) => ({ + id: params.id, + page: pageParam, + limit: 20, + }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, }), - initialPageParam: 1, - getNextPageParam: (lastPage) => - lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, - }), - ); - return { personName: data.pages[0]?.person.name }; + ); + return { personName: data.pages[0]?.person.name }; + } catch (error) { + if (getAppErrorCode(error) === "PERSON_NOT_FOUND") { + throw notFound(); + } + throw error; + } }, head: ({ loaderData }) => { if (!loaderData?.personName) return {}; diff --git a/apps/web/src/routes/_app/titles.$id.tsx b/apps/web/src/routes/_app/titles.$id.tsx index 31a0fba..a7b343c 100644 --- a/apps/web/src/routes/_app/titles.$id.tsx +++ b/apps/web/src/routes/_app/titles.$id.tsx @@ -1,5 +1,5 @@ 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 { 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 { TitleTheme } from "@/components/titles/title-theme"; import { Skeleton } from "@/components/ui/skeleton"; +import { getAppErrorCode } from "@/lib/error-messages"; import { orpc } from "@/lib/orpc/client"; import { getThemeCssProperties } from "@/lib/theme"; export const Route = createFileRoute("/_app/titles/$id")({ staleTime: 60_000, loader: async ({ params, context }) => { - const [titleResult, userInfo] = await Promise.all([ - context.queryClient.ensureQueryData( - orpc.titles.detail.queryOptions({ input: { id: params.id } }), - ), - context.queryClient - .ensureQueryData(orpc.titles.userInfo.queryOptions({ input: { id: params.id } })) - .catch(() => null), - ]); - return { ...titleResult, userInfo }; + try { + const [titleResult, userInfo] = await Promise.all([ + context.queryClient.ensureQueryData( + orpc.titles.detail.queryOptions({ input: { id: params.id } }), + ), + context.queryClient + .ensureQueryData(orpc.titles.userInfo.queryOptions({ input: { id: params.id } })) + .catch(() => null), + ]); + return { ...titleResult, userInfo }; + } catch (error) { + if (getAppErrorCode(error) === "TITLE_NOT_FOUND") { + throw notFound(); + } + throw error; + } }, head: ({ loaderData }) => { if (!loaderData) return {};