From 8a7dc2c8c081dfb175437fedbdf47a380e9a9acc Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 4 Oct 2026 15:58:41 -0400 Subject: [PATCH] fix(web): refresh tracking-dependent queries after mutations Co-Authored-By: Claude Sonnet 5.5 --- .../components/settings/account-section.tsx | 6 +++++- .../components/settings/imports-section.tsx | 6 +++++- apps/web/src/components/title-card.tsx | 7 ++++++- .../components/titles/use-title-actions.ts | 18 ++++++++++++++---- apps/web/src/lib/orpc/invalidate.ts | 19 +++++++++++++++++++ 5 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/orpc/invalidate.ts diff --git a/apps/web/src/components/settings/account-section.tsx b/apps/web/src/components/settings/account-section.tsx index 681b4c2..ab90972 100644 --- a/apps/web/src/components/settings/account-section.tsx +++ b/apps/web/src/components/settings/account-section.tsx @@ -12,7 +12,7 @@ import { IconTrash, IconX, } from "@tabler/icons-react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -44,6 +44,7 @@ import { authClient, signOut } from "@/lib/auth/client"; import { getErrorMessage } from "@/lib/error-messages"; import { useAppForm } from "@/lib/form"; import { client, orpc } from "@/lib/orpc/client"; +import { invalidateTrackingQueries } from "@/lib/orpc/invalidate"; import type { NormalizedImport } from "@sofa/api/schemas"; import { formatDate } from "@sofa/i18n/format"; @@ -379,6 +380,7 @@ interface ImportResult { function SofaImportDialog() { const { t } = useLingui(); + const queryClient = useQueryClient(); const fileInputRef = useRef(null); const importAbortRef = useRef(null); const [open, setOpen] = useState(false); @@ -448,6 +450,7 @@ function SofaImportDialog() { warnings: event.job.warnings, }); setStep("done"); + void invalidateTrackingQueries(queryClient); const importedCount = event.job.importedCount; if (importedCount > 0) { toast.success(t`Imported ${importedCount} items from Sofa export`); @@ -481,6 +484,7 @@ function SofaImportDialog() { warnings: finalJob.warnings, }); setStep("done"); + void invalidateTrackingQueries(queryClient); } else { toast.info(t`Import is still running in the background. Check back later.`); handleClose(); diff --git a/apps/web/src/components/settings/imports-section.tsx b/apps/web/src/components/settings/imports-section.tsx index 33e78a3..43d7695 100644 --- a/apps/web/src/components/settings/imports-section.tsx +++ b/apps/web/src/components/settings/imports-section.tsx @@ -2,7 +2,7 @@ import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import { IconCloudUpload, IconLink } from "@tabler/icons-react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -23,6 +23,7 @@ import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; import { getErrorMessage } from "@/lib/error-messages"; import { client, orpc } from "@/lib/orpc/client"; +import { invalidateTrackingQueries } from "@/lib/orpc/invalidate"; import type { NormalizedImport } from "@sofa/api/schemas"; import { formatList } from "@sofa/i18n/format"; @@ -192,6 +193,7 @@ export function ImportsSection() { function ImportSourceCard({ config }: { config: SourceConfig }) { const { i18n, t } = useLingui(); + const queryClient = useQueryClient(); const { data: systemStatus } = useQuery(orpc.system.status.queryOptions()); const publicApiUrl = systemStatus?.publicApiUrl ?? "https://public-api.sofa.watch"; const fileInputRef = useRef(null); @@ -285,6 +287,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) { warnings: event.job.warnings, }); setStep("done"); + void invalidateTrackingQueries(queryClient); const importedCount = event.job.importedCount; const sourceLabel = config.label; if (importedCount > 0) { @@ -320,6 +323,7 @@ function ImportSourceCard({ config }: { config: SourceConfig }) { warnings: finalJob.warnings, }); setStep("done"); + void invalidateTrackingQueries(queryClient); } else { toast.info(t`Import is still running in the background. Check back later.`); setStep("preview"); diff --git a/apps/web/src/components/title-card.tsx b/apps/web/src/components/title-card.tsx index 778a45d..f351721 100644 --- a/apps/web/src/components/title-card.tsx +++ b/apps/web/src/components/title-card.tsx @@ -10,15 +10,17 @@ import { IconPlus, IconStarFilled, } from "@tabler/icons-react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { type MotionStyle, type MotionValue, motion } from "motion/react"; import { useState } from "react"; +import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useTiltEffect } from "@/hooks/use-tilt-effect"; import { orpc } from "@/lib/orpc/client"; +import { invalidateTrackingQueries } from "@/lib/orpc/invalidate"; import { thumbHashToUrl } from "@/lib/thumbhash"; export function TitleCardSkeleton() { @@ -86,11 +88,14 @@ function useStatusConfig() { function QuickAddButton({ id, userStatus }: { id: string; userStatus?: TitleStatus | null }) { const { t } = useLingui(); const statusConfig = useStatusConfig(); + const queryClient = useQueryClient(); const [optimisticStatus, setOptimisticStatus] = useState(null); const addToWatchlistMutation = useMutation( orpc.tracking.updateStatus.mutationOptions({ onSuccess: () => setOptimisticStatus("in_watchlist"), + onError: () => toast.error(t`Failed to update status`), + onSettled: () => void invalidateTrackingQueries(queryClient), }), ); diff --git a/apps/web/src/components/titles/use-title-actions.ts b/apps/web/src/components/titles/use-title-actions.ts index f67a44c..146a64c 100644 --- a/apps/web/src/components/titles/use-title-actions.ts +++ b/apps/web/src/components/titles/use-title-actions.ts @@ -5,6 +5,7 @@ import { useCallback } from "react"; import { toast } from "sonner"; import { orpc } from "@/lib/orpc/client"; +import { invalidateTrackingQueries } from "@/lib/orpc/invalidate"; import type { Season } from "@sofa/api/schemas"; import { useTitleContext } from "./title-context"; @@ -40,10 +41,19 @@ export function useTitleActions() { [queryClient, userInfoKey], ); - const { mutateAsync: watch } = useMutation(orpc.tracking.watch.mutationOptions()); - const { mutateAsync: unwatch } = useMutation(orpc.tracking.unwatch.mutationOptions()); - const { mutateAsync: updateStatus } = useMutation(orpc.tracking.updateStatus.mutationOptions()); - const { mutateAsync: updateRating } = useMutation(orpc.tracking.rate.mutationOptions()); + const invalidateAfterMutation = { onSettled: () => void invalidateTrackingQueries(queryClient) }; + const { mutateAsync: watch } = useMutation( + orpc.tracking.watch.mutationOptions(invalidateAfterMutation), + ); + const { mutateAsync: unwatch } = useMutation( + orpc.tracking.unwatch.mutationOptions(invalidateAfterMutation), + ); + const { mutateAsync: updateStatus } = useMutation( + orpc.tracking.updateStatus.mutationOptions(invalidateAfterMutation), + ); + const { mutateAsync: updateRating } = useMutation( + orpc.tracking.rate.mutationOptions(invalidateAfterMutation), + ); const catchUp = useCallback( async (episodeIds: string[]) => { diff --git a/apps/web/src/lib/orpc/invalidate.ts b/apps/web/src/lib/orpc/invalidate.ts new file mode 100644 index 0000000..51638e7 --- /dev/null +++ b/apps/web/src/lib/orpc/invalidate.ts @@ -0,0 +1,19 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { orpc } from "./client"; + +/** + * Mark every query whose response depends on the user's tracking state + * (statuses, watches, ratings, library membership) as stale. Active queries + * refetch immediately; inactive ones refetch on next mount. Title details + * (`titles.get`) are deliberately excluded — they don't depend on tracking. + */ +export function invalidateTrackingQueries(queryClient: QueryClient): Promise { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: orpc.tracking.key() }), + queryClient.invalidateQueries({ queryKey: orpc.library.key() }), + queryClient.invalidateQueries({ queryKey: orpc.discover.key() }), + queryClient.invalidateQueries({ queryKey: orpc.people.key() }), + queryClient.invalidateQueries({ queryKey: orpc.titles.similar.key() }), + ]); +}