From 44582008e9d85d4f114a62e0996cbc6fbfd181d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:26:46 +0200 Subject: [PATCH] fix(settings): make settings read failures foreground and recoverable A family of settings surfaces rendered a failed read as a bare text-destructive line with no way to recover: the security sessions, trusted-devices and activity cards, the coach memory and reminders sections, the about-me section, and the document link/share pickers. Swap each to the shared QueryErrorRow primitive with a retry control. The suite covering the statically renderable members was watched fail first against the bare lines; the two portaled pickers take the same swap. --- .../documents/document-link-picker.tsx | 8 ++- .../settings-read-error-family.test.tsx | 65 +++++++++++++++++++ src/components/settings/about-me-section.tsx | 12 ++-- .../settings/coach-memory-section.tsx | 12 ++-- .../settings/coach-reminders-section.tsx | 12 ++-- .../settings/security-activity-card.tsx | 10 +-- .../settings/security-sessions-card.tsx | 10 +-- .../settings/share-document-picker.tsx | 8 ++- .../settings/trusted-devices-card.tsx | 10 +-- 9 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 src/components/settings/__tests__/settings-read-error-family.test.tsx diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index d1e5ff6e3..aa5c50158 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -132,9 +133,10 @@ export function DocumentLinkPicker({ ))} ) : list.isError ? ( -

- {t("documents.list.loadError")} -

+ list.refetch()} + /> ) : documents.length === 0 ? (

{t("documents.linkPicker.empty")} diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx new file mode 100644 index 000000000..4a3bffd11 --- /dev/null +++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx @@ -0,0 +1,65 @@ +/** + * The bare-destructive read-failure family: several settings cards rendered a + * failed read as a bare `text-destructive` line with no way to recover. Each is + * forced into its query error state and must now render the shared + * `query-error-row` primitive with a retry control — an alert is foreground + * content, and a read failure has to be retryable. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + isError: false, + reset: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SecuritySessionsCard } from "../security-sessions-card"; +import { SecurityActivityCard } from "../security-activity-card"; +import { TrustedDevicesCard } from "../trusted-devices-card"; +import { CoachMemorySection } from "../coach-memory-section"; +import { CoachRemindersSection } from "../coach-reminders-section"; +import { AboutMeSection } from "../about-me-section"; + +function renderCard(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const cases: Array<[string, React.ReactNode]> = [ + ["SecuritySessionsCard", ], + ["SecurityActivityCard", ], + ["TrustedDevicesCard", ], + ["CoachMemorySection", ], + ["CoachRemindersSection", ], + ["AboutMeSection", ], +]; + +describe("settings read-error family — a read failure is foreground and recoverable", () => { + it.each(cases)("%s renders a query-error row with retry", (_name, node) => { + const html = renderCard(node); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + // No bare red line: the alert is the primitive now. + expect(html).not.toContain('

+ query.refetch()} + /> )}

diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index bf382459a..73826576a 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -34,6 +34,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -152,13 +153,10 @@ export function CoachMemorySection({

{t("settings.ai.coachMemory.detail")}

{query.isError && ( -

- {t("settings.ai.coachMemory.loadError")} -

+ query.refetch()} + /> )} {!query.isError && facts.length === 0 ? ( diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index c0e47c204..c4cde2c14 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -22,6 +22,7 @@ import { BellRing, Check, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { formatDateOrRelative } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -165,13 +166,10 @@ export function CoachRemindersSection({

{t("settings.ai.coachReminders.detail")}

{query.isError && ( -

- {t("settings.ai.coachReminders.loadError")} -

+ query.refetch()} + /> )} {!query.isError && reminders.length === 0 ? ( diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 6d4806d4c..1091e9fc7 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -15,6 +15,7 @@ import { ChevronDown, Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -62,7 +63,7 @@ export function SecurityActivityCard({ const [open, setOpen] = useState(false); const regionId = useId(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.securityActivity(), queryFn: () => apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), @@ -102,9 +103,10 @@ export function SecurityActivityCard({ )} {isError && ( -

- {t("settings.security.activityLoadError")} -

+ refetch()} + /> )} {!isLoading && !isError && events.length === 0 && ( diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index d9b2a6821..206a743f1 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -18,6 +18,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiDelete } from "@/lib/api/api-fetch"; @@ -48,7 +49,7 @@ export function SecuritySessionsCard({ const [open, setOpen] = useState(false); const regionId = useId(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.sessions(), queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), enabled: isAuthenticated, @@ -117,9 +118,10 @@ export function SecuritySessionsCard({ )} {isError && ( -

- {t("settings.security.sessionsLoadError")} -

+ refetch()} + /> )} {!isLoading && !isError && sessions.length === 0 && ( diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index 8db9feb08..4320f448f 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; @@ -152,9 +153,10 @@ export function ShareDocumentPicker({ ))} ) : list.isError ? ( -

- {t("documents.list.loadError")} -

+ list.refetch()} + /> ) : documents.length === 0 ? (

{t("settings.sharing.pickerEmpty")} diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 5213faffe..7fa19272f 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -15,6 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -39,7 +40,7 @@ export function TrustedDevicesCard({ const queryClient = useQueryClient(); const [status, setStatus] = useState(null); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.trustedDevices(), queryFn: () => apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"), @@ -102,9 +103,10 @@ export function TrustedDevicesCard({ )} {isError && ( -

- {t("settings.security.trustedDevices.loadError")} -

+ refetch()} + /> )} {devices.length > 0 && (