fix: UX polish across web and native — error boundaries, query tuning, and i18n improvements

- Add `RouteError` component for route-level error boundaries (web) with "Try again" and "Dashboard" links; wire it into dashboard, explore, upcoming, people, and settings routes
- Add `await queryClient.cancelQueries()` before every optimistic update in `useTitleActions` to prevent in-flight refetches from overwriting local state
- Cap all `useInfiniteQuery` calls at `maxPages: 10` (web and native) to bound memory growth on scroll-heavy pages
- Replace `ActivityIndicator` with `Spinner` in search, person detail, and list footer components (native)
- Tune native query client defaults: add `staleTime: 60_000` and lower `gcTime` to `300_000` (was 24h)
- Darken `--color-muted-foreground` for better contrast in the native theme
- Improve error message copy in `error-messages.ts` for clarity (TMDB, import, registration errors)
- Update i18n `.po` files across all 12 locales with new strings (route error, improved error messages)
This commit is contained in:
2026-03-22 16:46:39 -04:00
parent 1d67b201a0
commit f2f5e823f4
31 changed files with 1129 additions and 35 deletions
@@ -23,6 +23,7 @@ export default function ExploreScreen() {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
const popularMovies = useQuery(orpc.explore.popular.queryOptions({ input: { type: "movie" } }));
@@ -33,6 +33,7 @@ export default function UpcomingScreen() {
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 10,
}),
);
@@ -3,7 +3,7 @@ import { FlashList } from "@shopify/flash-list";
import { skipToken, useInfiniteQuery } from "@tanstack/react-query";
import { Stack } from "expo-router";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, View } from "react-native";
import { View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { RecentlyViewedList } from "@/components/search/recently-viewed-list";
@@ -28,6 +28,7 @@ export default function SearchScreen() {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
});
@@ -115,7 +116,7 @@ export default function SearchScreen() {
ListFooterComponent={
searchResults.isFetchingNextPage ? (
<View className="items-center py-4">
<ActivityIndicator />
<Spinner />
</View>
) : null
}
+4 -2
View File
@@ -11,7 +11,7 @@ import {
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo } from "react";
import { ActivityIndicator, Pressable, useWindowDimensions, View } from "react-native";
import { Pressable, useWindowDimensions, View } from "react-native";
import Animated, { FadeIn, FadeInDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCSSVariable } from "uniwind";
@@ -23,6 +23,7 @@ import { PosterCard } from "@/components/ui/poster-card";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { SectionHeader } from "@/components/ui/section-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Text } from "@/components/ui/text";
import { useTitleActions } from "@/hooks/use-title-actions";
import { orpc } from "@/lib/orpc";
@@ -75,6 +76,7 @@ export default function PersonDetailScreen() {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
@@ -310,7 +312,7 @@ export default function PersonDetailScreen() {
ListFooterComponent={
isFetchingNextPage ? (
<View className="items-center py-4">
<ActivityIndicator />
<Spinner />
</View>
) : null
}
@@ -60,6 +60,7 @@ export function FilterableTitleRow({
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
});
+1 -1
View File
@@ -19,7 +19,7 @@
--color-secondary: oklch(0.22 0.008 55);
--color-secondary-foreground: oklch(0.85 0.02 80);
--color-muted: oklch(0.22 0.008 55);
--color-muted-foreground: oklch(0.71 0.02 80);
--color-muted-foreground: oklch(0.63 0.02 80);
--color-accent: oklch(0.25 0.01 55);
--color-accent-foreground: oklch(0.93 0.015 80);
--color-destructive: oklch(0.65 0.2 25);
+7 -7
View File
@@ -24,14 +24,14 @@ export function appErrorMessages(
INTEGRATION_NOT_FOUND: t`Integration not found`,
BACKUP_NOT_FOUND: t`Backup not found`,
BACKUP_DELETE_FAILED: t`Failed to delete backup`,
BACKUP_RESTORE_FAILED: t`Restore failed`,
BACKUP_RESTORE_FAILED: t`Backup restoration failed`,
JOB_NOT_FOUND: t`Job not found`,
TMDB_NOT_CONFIGURED: t`TMDB not configured`,
IMPORT_INVALID_FILE: t`Invalid file`,
IMPORT_PAYLOAD_TOO_LARGE: t`Import too large`,
IMPORT_ALREADY_RUNNING: t`Import already running`,
IMPORT_CANNOT_CANCEL: t`Cannot cancel import`,
REGISTRATION_CLOSED: t`Registration closed`,
TMDB_NOT_CONFIGURED: t`TMDB API key is not configured`,
IMPORT_INVALID_FILE: t`Invalid import file`,
IMPORT_PAYLOAD_TOO_LARGE: t`Import payload is too large`,
IMPORT_ALREADY_RUNNING: t`An import is already in progress`,
IMPORT_CANNOT_CANCEL: t`This import cannot be cancelled`,
REGISTRATION_CLOSED: t`Registration is currently closed`,
EXPORT_FAILED: t`Failed to export data`,
};
}
+2 -3
View File
@@ -6,12 +6,11 @@ import { getIsReachable, isNetworkError } from "@/lib/server";
import { toast } from "@/lib/toast";
import { i18n } from "@sofa/i18n";
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: ONE_DAY_MS,
staleTime: 60_000,
gcTime: 300_000,
networkMode: "online",
retry: (failureCount, error) => {
// Don't retry network errors when server is unreachable — the
@@ -15,6 +15,7 @@ export function LibrarySection() {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
@@ -66,6 +66,7 @@ export function FilterableTitleRow({
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
@@ -38,6 +38,7 @@ export function PersonDetailClient({ id }: { id: string }) {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
+43
View File
@@ -0,0 +1,43 @@
import { Trans } from "@lingui/react/macro";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ErrorComponentProps } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router";
export function RouteError({ reset }: ErrorComponentProps) {
return (
<div className="flex flex-col items-center gap-6 py-24 text-center">
<div className="border-destructive/20 bg-destructive/5 text-destructive flex size-14 items-center justify-center rounded-full border">
<IconAlertTriangle className="size-6" strokeWidth={1.5} />
</div>
<div className="space-y-2">
<h1 className="font-display text-2xl tracking-tight sm:text-3xl">
<Trans>Something went wrong</Trans>
</h1>
<p className="text-muted-foreground mx-auto max-w-sm text-sm leading-relaxed">
<Trans>
An unexpected error occurred while loading this page. You can try again or head back to
the dashboard.
</Trans>
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={reset}
className="group bg-primary text-primary-foreground hover:shadow-primary/20 relative inline-flex h-10 items-center justify-center gap-2 overflow-hidden rounded-lg px-5 text-sm font-medium transition-shadow hover:shadow-lg"
>
<span className="relative z-10">
<Trans>Try again</Trans>
</span>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
</button>
<Link
to="/dashboard"
className="border-border hover:border-primary/40 hover:bg-primary/5 inline-flex h-10 items-center justify-center rounded-lg border px-5 text-sm font-medium transition-colors"
>
<Trans>Dashboard</Trans>
</Link>
</div>
</div>
);
}
@@ -52,6 +52,7 @@ export function useTitleActions() {
const catchUp = useCallback(
async (episodeIds: string[]) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prev = getUserInfo();
const newWatchSet = new Set(prev.episodeWatches);
for (const id of episodeIds) newWatchSet.add(id);
@@ -83,6 +84,7 @@ export function useTitleActions() {
const handleStatusChange = useCallback(
async (status: string | null) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({
...old,
@@ -99,11 +101,12 @@ export function useTitleActions() {
toast.error(t`Failed to update status`);
}
},
[getUserInfo, setUserInfo, titleId, updateStatus, t],
[getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, updateStatus, t],
);
const handleRating = useCallback(
async (ratingStars: number) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevRating = getUserInfo().rating;
setUserInfo((old) => ({ ...old, rating: ratingStars }));
try {
@@ -121,10 +124,11 @@ export function useTitleActions() {
toast.error(t`Failed to update rating`);
}
},
[getUserInfo, setUserInfo, titleId, updateRating, t],
[getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, updateRating, t],
);
const handleWatchMovie = useCallback(async () => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({ ...old, status: "completed" }));
try {
@@ -134,10 +138,11 @@ export function useTitleActions() {
setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error(t`Failed to mark as watched`);
}
}, [getUserInfo, setUserInfo, titleId, titleName, watchMovie, t]);
}, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, watchMovie, t]);
const handleWatchEpisode = useCallback(
async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
setWatchingEp(episodeId);
if (isWatched) {
@@ -218,11 +223,23 @@ export function useTitleActions() {
setWatchingEp(null);
},
[getUserInfo, setUserInfo, setWatchingEp, seasons, catchUp, unwatchEp, watchEp, t],
[
getUserInfo,
setUserInfo,
queryClient,
userInfoKey,
setWatchingEp,
seasons,
catchUp,
unwatchEp,
watchEp,
t,
],
);
const handleMarkSeason = useCallback(
async (season: Season) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const watchedSet = new Set(prevWatches);
@@ -284,6 +301,7 @@ export function useTitleActions() {
const handleUnmarkSeason = useCallback(
async (season: Season) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const seasonEpIds = new Set(season.episodes.map((ep) => ep.id));
@@ -307,10 +325,11 @@ export function useTitleActions() {
toast.error(t`Failed to unmark some episodes`);
}
},
[getUserInfo, setUserInfo, unwatchSeason, t],
[getUserInfo, setUserInfo, queryClient, userInfoKey, unwatchSeason, t],
);
const handleMarkAllWatched = useCallback(async () => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevWatches = getUserInfo().episodeWatches;
const prevStatus = getUserInfo().status;
const allEpIds = seasons.flatMap((s) => s.episodes.map((ep) => ep.id));
+1 -1
View File
@@ -35,7 +35,7 @@ function ScrollArea({
className={cn(
"no-scrollbar focus-visible:ring-ring/50 flex-1 rounded-[inherit] outline-none focus-visible:ring-[3px] focus-visible:outline-1 data-has-overflow-x:overscroll-x-contain",
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem]",
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem] rtl:mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] rtl:mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))]",
scrollbarGutter && "data-has-overflow-x:pb-2.5 data-has-overflow-y:pe-2.5",
)}
>
+3
View File
@@ -9,6 +9,7 @@ import { StatsSection } from "@/components/dashboard/stats-section";
import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid";
import { UpcomingSection } from "@/components/dashboard/upcoming-section";
import { WelcomeHeader } from "@/components/dashboard/welcome-header";
import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
@@ -28,12 +29,14 @@ export const Route = createFileRoute("/_app/dashboard")({
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
),
]);
},
head: () => ({ meta: [{ title: "Dashboard — Sofa" }] }),
pendingComponent: DashboardSkeleton,
errorComponent: RouteError,
component: DashboardPage,
});
+4
View File
@@ -7,6 +7,7 @@ import { useMemo } from "react";
import { FilterableTitleRow } from "@/components/explore/filterable-title-row";
import { HeroBanner } from "@/components/explore/hero-banner";
import { TitleRow } from "@/components/explore/title-row";
import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/explore")({
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
),
context.queryClient.ensureQueryData(
@@ -38,6 +40,7 @@ export const Route = createFileRoute("/_app/explore")({
},
head: () => ({ meta: [{ title: "Explore — Sofa" }] }),
pendingComponent: ExploreSkeletons,
errorComponent: RouteError,
component: ExplorePage,
});
@@ -82,6 +85,7 @@ function ExplorePage() {
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
+3
View File
@@ -2,6 +2,7 @@ import { Trans } from "@lingui/react/macro";
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { PersonDetailClient, PersonDetailSkeleton } from "@/components/people/person-detail-client";
import { RouteError } from "@/components/route-error";
import { getAppErrorCode } from "@/lib/error-messages";
import { orpc } from "@/lib/orpc/client";
@@ -19,6 +20,7 @@ export const Route = createFileRoute("/_app/people/$id")({
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
maxPages: 10,
}),
);
return { personName: data.pages[0]?.person.name };
@@ -34,6 +36,7 @@ export const Route = createFileRoute("/_app/people/$id")({
return { meta: [{ title: `${loaderData.personName} — Sofa` }] };
},
pendingComponent: () => <PersonDetailSkeleton />,
errorComponent: RouteError,
notFoundComponent: PersonNotFound,
component: PersonPage,
});
+5 -3
View File
@@ -8,6 +8,7 @@ import {
} from "@tabler/icons-react";
import { createFileRoute } from "@tanstack/react-router";
import { RouteError } from "@/components/route-error";
import { AccountSection } from "@/components/settings/account-section";
import { BackupRestoreSection } from "@/components/settings/backup-restore-section";
import { BackupScheduleSection } from "@/components/settings/backup-schedule-section";
@@ -46,6 +47,7 @@ export const Route = createFileRoute("/_app/settings")({
},
head: () => ({ meta: [{ title: "Settings — Sofa" }] }),
pendingComponent: SettingsSkeleton,
errorComponent: RouteError,
component: SettingsPage,
});
@@ -169,7 +171,7 @@ function SettingsPage() {
<Trans>Security</Trans>
</h2>
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] font-medium">
Admin only
<Trans>Admin only</Trans>
</span>
</div>
<div className="space-y-3">
@@ -192,7 +194,7 @@ function SettingsPage() {
<Trans>Backups</Trans>
</h2>
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] font-medium">
Admin only
<Trans>Admin only</Trans>
</span>
</div>
<div className="space-y-3">
@@ -218,7 +220,7 @@ function SettingsPage() {
<Trans>Danger Zone</Trans>
</h2>
<span className="bg-primary/10 text-primary rounded-md px-1.5 py-0.5 text-[10px] font-medium">
Admin only
<Trans>Admin only</Trans>
</span>
</div>
<Card className="border-s-primary/30 border-s-2">
+4
View File
@@ -4,6 +4,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { UpcomingRow } from "@/components/dashboard/upcoming-item";
import { RouteError } from "@/components/route-error";
import { Skeleton } from "@/components/ui/skeleton";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
@@ -21,11 +22,13 @@ export const Route = createFileRoute("/_app/upcoming")({
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 10,
}),
);
},
head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }),
pendingComponent: UpcomingSkeleton,
errorComponent: RouteError,
component: UpcomingPage,
});
@@ -59,6 +62,7 @@ function UpcomingPage() {
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 10,
}),
);