diff --git a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx index 385c58994..412876367 100644 --- a/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx +++ b/apps/compliance-portal/src/components/ComplianceFrameworks/ComplianceFrameworksSection.tsx @@ -56,12 +56,14 @@ export function ComplianceFrameworksSection({ trustCenterKey }: ComplianceFramew return ( ( + fallback={( + // The data comes from the preloaded HomePageQuery, so there is no local + // refetch to clear a field error — reload the page to recover. window.location.reload()} /> )} diff --git a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx index f01a45c7f..77bfa3807 100644 --- a/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx +++ b/apps/compliance-portal/src/components/RecentUpdates/RecentUpdatesSection.tsx @@ -56,12 +56,14 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro return ( ( + fallback={( + // The data comes from the preloaded HomePageQuery, so there is no local + // refetch to clear a field error — reload the page to recover. window.location.reload()} /> )} diff --git a/apps/compliance-portal/src/components/errors/ListErrorBoundary.tsx b/apps/compliance-portal/src/components/errors/ListErrorBoundary.tsx new file mode 100644 index 000000000..60cbde989 --- /dev/null +++ b/apps/compliance-portal/src/components/errors/ListErrorBoundary.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary"; +import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError"; +import { type ReactNode, useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface ListErrorBoundaryProps { + // Refetch the list from the network, calling `done` once the request settles. + // The caller owns the refetch (it holds the refetchable fragment); this keeps + // that function above the boundary so it survives the child's error. + onRetry: (done: () => void) => void; + children: ReactNode; +} + +// Contains a list/section field error to an inline fallback with a working +// retry. The boundary only resets *after* the caller's refetch settles (via the +// `done` callback bumping its key), so remounting reads the refreshed store +// instead of racing the in-flight request back into the same error. See +// contrib/claude/error-handling.md. +export function ListErrorBoundary({ onRetry, children }: ListErrorBoundaryProps) { + const { t } = useTranslation(); + const [resetToken, setResetToken] = useState(0); + + return ( + + onRetry(() => setResetToken(token => token + 1))} + /> + + )} + > + {children} + + ); +} diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx index 68a29cb9a..f0d491712 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx @@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next"; import type { PreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"; +import { ListErrorBoundary } from "#/components/errors/ListErrorBoundary"; import { PageHeader } from "#/components/PageHeader/PageHeader"; import type { SubprocessorsPage_query$key } from "./__generated__/SubprocessorsPage_query.graphql"; @@ -111,15 +112,24 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) { aria-busy={isRefetching} className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`} > - {groups.length === 0 - ? - : groups.map(group => ( - - ))} + startTransition(() => { + refetch( + toQueryVariables({ query, category, country }), + { fetchPolicy: "network-only", onComplete: done }, + ); + })} + > + {groups.length === 0 + ? + : groups.map(group => ( + + ))} + diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorListItem.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorListItem.tsx index f4b581311..ddc533b88 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorListItem.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorListItem.tsx @@ -31,7 +31,7 @@ import type { SubprocessorListItem_subprocessor$key } from "./__generated__/Subp import { subprocessorListItem } from "./variants"; const subprocessorListItemFragment = graphql` - fragment SubprocessorListItem_subprocessor on Subprocessor { + fragment SubprocessorListItem_subprocessor on Subprocessor @throwOnFieldError { name description websiteUrl diff --git a/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx b/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx index 9a079c4e7..97a5ebacd 100644 --- a/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx +++ b/apps/compliance-portal/src/pages/updates/UpdatesPage.tsx @@ -19,11 +19,12 @@ // SOFTWARE. import { Pagination } from "@probo/ui/src/v2/Pagination/Pagination"; -import { useCallback } from "react"; +import { useCallback, useTransition } from "react"; import { useTranslation } from "react-i18next"; import type { PreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"; +import { ListErrorBoundary } from "#/components/errors/ListErrorBoundary"; import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem"; import { PageHeader } from "#/components/PageHeader/PageHeader"; import type { CursorPaginationVariables } from "#/lib/relay/useCursorPagination"; @@ -88,6 +89,13 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) { refetch(variables, { fetchPolicy: "store-or-network" }); }, [refetch]); + const [, startRetry] = useTransition(); + const retryUpdates = useCallback((done: () => void) => { + startRetry(() => { + refetch({ first: UPDATES_PAGE_SIZE }, { fetchPolicy: "network-only", onComplete: done }); + }); + }, [refetch]); + const { updates } = data.currentTrustCenter; const { pageInfo } = updates; const { isPending, goPrevious, goNext } = useCursorPagination(refetchUpdates, pageInfo, UPDATES_PAGE_SIZE); @@ -104,11 +112,13 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) { ? : (
- - {nodes.map(node => ( - - ))} - + + + {nodes.map(node => ( + + ))} + + startTransition(() => { + refetch(variables, { fetchPolicy: "network-only", onComplete: done }); + })} +> + {rows} + +``` + +Because `useRefetchableFragment` throws at its own read site, put +`@throwOnFieldError` on the **item** fragments (so the throw lands below the +boundary), not on the refetchable list fragment (whose read is above it — a +whole-connection failure there is a page-level error via `@required`). + ## Custom errors for node-type mismatches When a page fetches `node(id:)` and the resolved `__typename` is not the type the