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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -219,6 +219,48 @@ The portal ships three fallback tiers, all backed by the same `ErrorBoundary`:
|
||||
[`ui.md`](ui.md)); the app maps the caught error to copy/actions and passes them
|
||||
in.
|
||||
|
||||
### Retrying: reset vs refetch vs reload
|
||||
|
||||
A boundary's `reset` alone does **not** clear a Relay field error — at **any**
|
||||
level, not just lists. `reset` only re-renders the subtree; the read hits the
|
||||
**same errored record** still cached in the store and throws again. `reset` is
|
||||
therefore only a real recovery for *transient render errors* (e.g. a non-Relay
|
||||
render crash). To recover a Relay field error you must go back to the network
|
||||
first, then clear the boundary. Pick the mechanism by what owns the data:
|
||||
|
||||
| Context | Recovery |
|
||||
|---------|----------|
|
||||
| Route / page boundary | `window.location.reload()` (or router revalidation) |
|
||||
| Refetchable list/section (`useRefetchableFragment`) | `refetch(..., { fetchPolicy: "network-only" })`, then reset the boundary once it settles |
|
||||
| Section reading a preloaded query (no local refetch) | reload the owning query via the loader's `loadQuery(..., { fetchPolicy: "network-only" })`, or fall back to `window.location.reload()` |
|
||||
| Transient / non-Relay render error | `reset` |
|
||||
|
||||
In all the network cases, reset the boundary **after** the fetch settles (not
|
||||
before), or the remount races the in-flight request straight back into the same
|
||||
error.
|
||||
|
||||
`ListErrorBoundary` encapsulates the refetchable-list case: it owns a reset key
|
||||
and exposes `onRetry(done)`, where the caller (which holds `refetch`, above the
|
||||
boundary) refetches `network-only` and passes the `onComplete` callback as
|
||||
`done`.
|
||||
|
||||
```tsx
|
||||
// The page owns refetch; item fragments carry @throwOnFieldError, so a row's
|
||||
// field error throws below the boundary while refetch survives above it.
|
||||
<ListErrorBoundary
|
||||
onRetry={done => startTransition(() => {
|
||||
refetch(variables, { fetchPolicy: "network-only", onComplete: done });
|
||||
})}
|
||||
>
|
||||
{rows}
|
||||
</ListErrorBoundary>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user