From e67f4403efa35705d09592a07a5668cb0af423b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 15 Jul 2026 10:14:15 +0200 Subject: [PATCH] Add inline list boundaries with refetch retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contain field errors on the subprocessors and updates lists to an inline fallback instead of the whole page, and make the retry actually recover. Introduce ListErrorBoundary, which keeps refetch above the boundary and resets only after the network refetch settles (a bare boundary reset re-reads the same errored record and throws again). Wire the subprocessors and updates lists to refetch network-only on retry, and mark the item fragments @throwOnFieldError so a row error lands below the boundary. Fix the home sections, whose reset-only retry could not clear a field error from the preloaded query, to reload the page instead. Generalize the retry guidance in the error-handling guide (reset vs refetch vs reload). Signed-off-by: Émile Ré --- .../ComplianceFrameworksSection.tsx | 6 ++- .../RecentUpdates/RecentUpdatesSection.tsx | 6 ++- .../components/errors/ListErrorBoundary.tsx | 53 +++++++++++++++++++ .../pages/subprocessors/SubprocessorsPage.tsx | 28 ++++++---- .../_components/SubprocessorListItem.tsx | 2 +- .../src/pages/updates/UpdatesPage.tsx | 22 +++++--- contrib/claude/error-handling.md | 42 +++++++++++++++ 7 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 apps/compliance-portal/src/components/errors/ListErrorBoundary.tsx 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