Add inline list boundaries with refetch retry

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-15 10:14:15 +02:00
parent acf710d80c
commit e67f4403ef
7 changed files with 139 additions and 20 deletions

View File

@@ -56,12 +56,14 @@ export function ComplianceFrameworksSection({ trustCenterKey }: ComplianceFramew
return (
<ErrorBoundary
fallback={(_, reset) => (
fallback={(
// The data comes from the preloaded HomePageQuery, so there is no local
// refetch to clear a field error — reload the page to recover.
<HomeSection title={t("home.sections.compliance")}>
<InlineError
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={reset}
onRetry={() => window.location.reload()}
/>
</HomeSection>
)}

View File

@@ -56,12 +56,14 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
return (
<ErrorBoundary
fallback={(_, reset) => (
fallback={(
// The data comes from the preloaded HomePageQuery, so there is no local
// refetch to clear a field error — reload the page to recover.
<HomeSection title={t("home.sections.recentUpdates")}>
<InlineError
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={reset}
onRetry={() => window.location.reload()}
/>
</HomeSection>
)}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<ErrorBoundary
key={resetToken}
fallback={(
<div className="py-8">
<InlineError
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={() => onRetry(() => setResetToken(token => token + 1))}
/>
</div>
)}
>
{children}
</ErrorBoundary>
);
}

View File

@@ -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
? <SubprocessorsEmpty />
: groups.map(group => (
<SubprocessorCategorySection
key={group.category}
category={group.category}
subprocessors={group.nodes}
/>
))}
<ListErrorBoundary
onRetry={done => startTransition(() => {
refetch(
toQueryVariables({ query, category, country }),
{ fetchPolicy: "network-only", onComplete: done },
);
})}
>
{groups.length === 0
? <SubprocessorsEmpty />
: groups.map(group => (
<SubprocessorCategorySection
key={group.category}
category={group.category}
subprocessors={group.nodes}
/>
))}
</ListErrorBoundary>
</div>
</div>
</>

View File

@@ -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

View File

@@ -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) {
? <UpdatesEmpty />
: (
<div className="flex flex-col gap-8">
<UpdatesList busy={isPending}>
{nodes.map(node => (
<MailingListUpdateListItem key={node.id} updateKey={node} />
))}
</UpdatesList>
<ListErrorBoundary onRetry={retryUpdates}>
<UpdatesList busy={isPending}>
{nodes.map(node => (
<MailingListUpdateListItem key={node.id} updateKey={node} />
))}
</UpdatesList>
</ListErrorBoundary>
<Pagination
hasPrevious={pageInfo.hasPreviousPage}
hasNext={pageInfo.hasNextPage}