Add layered error boundaries to compliance portal

Introduce global, page, and section-level error handling for the
compliance portal so a failure is contained at the smallest possible
scope instead of blanking the whole page.

Add a portal-local Relay fetch that throws only request-level errors
(and always redirects on UNAUTHENTICATED) while leaving field-level
errors in the response, so Relay surfaces them at the reading component
through @throwOnFieldError and the nearest boundary. Add a NotFoundError
for node __typename mismatches mapped to a not-found page.

Ship reusable v2 kit primitives (ErrorBoundary, ErrorState, InlineError)
matching the Figma global/local/inline designs, wire the bootstrap and
route boundaries, and demonstrate section and row boundaries on the home
page. Update the error-handling and relay guides accordingly.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-09 20:32:30 -04:00
parent 4c57d201a4
commit 9cd73816b0
25 changed files with 1018 additions and 30 deletions

View File

@@ -156,6 +156,93 @@ function PublishButton() {
For Relay mutations, prefer the built-in `onCompleted` / `onError` callbacks (see [`relay.md`](relay.md)) over a manual `try`/`catch`; use `try`/`catch` for non-Relay async work (fetch, parsing, third-party SDKs).
## Relay field errors and fragment-level boundaries
A GraphQL response can be **partial**: `data` is present but one field carries an
error (with a `path`). To contain such a failure to the component that reads the
bad field — instead of collapsing the whole page — two pieces cooperate:
1. **The fetch layer only throws request-level errors.** A request-level error
has **no `path`** (auth, malformed request, transport) and applies to the
whole operation, so it throws and propagates to the nearest boundary.
Field-level errors (those with a `path`) are **left in the response** so Relay
can attribute them to the reading field.
The compliance-portal wires this in its own
[`apps/compliance-portal/src/lib/relay/fetch.ts`](../../apps/compliance-portal/src/lib/relay/fetch.ts)
(it does **not** use `@probo/relay`'s `makeFetchQuery`, which throws for the
whole operation on any known code).
2. **`@throwOnFieldError` on the query/fragment that reads the field.** With the
directive set, a field error throws **at the read site** (`usePreloadedQuery`
for a query, `useFragment` for a fragment). Put it on the **fragment** to
isolate a section/row, and on the **query** to route page-level field errors
to the route boundary.
Because `useFragment` throws in the component body (not in a child), the boundary
must be an **ancestor**. Split the component into a thin wrapper (holds the
`ErrorBoundary`) and a `*Content` child (reads the fragment):
```tsx
export function RecentUpdatesSection({ trustCenterKey }: Props) {
const { t } = useTranslation();
return (
<ErrorBoundary
fallback={(_, reset) => (
<InlineError message={t("errors.inline.message")} onRetry={reset} retryLabel={t("errors.inline.retry")} />
)}
>
<RecentUpdatesSectionContent trustCenterKey={trustCenterKey} />
</ErrorBoundary>
);
}
function RecentUpdatesSectionContent({ trustCenterKey }: Props) {
const data = useFragment(fragment, trustCenterKey); // throws here on a field error
// ...
}
const fragment = graphql`
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError { ... }
`;
```
The portal ships three fallback tiers, all backed by the same `ErrorBoundary`:
| Tier | Placement | Fallback |
|------|-----------|----------|
| Global (bootstrap) | around `RouterProvider` in `App.tsx`, and the root route | `ErrorState` full page (standalone) |
| Page | pathless child route inside the layout | `ErrorState` inside the shell (TopBar/footer survive) |
| Section / row | around a fragment-reading subtree | `InlineError` (vertical for sections, horizontal for rows) with a retry |
`ErrorState` and `InlineError` are presentational v2 kit components (see
[`ui.md`](ui.md)); the app maps the caught error to copy/actions and passes them
in.
## Custom errors for node-type mismatches
When a page fetches `node(id:)` and the resolved `__typename` is not the type the
view expects, throw a dedicated error, not a bare `Error`, so the boundary can
render the correct state (404):
```tsx
// Good — a typed error the boundary maps to the not-found page
import { NotFoundError } from "#/lib/relay/errors";
if (data.node?.__typename !== "MailingListUpdate") {
throw new NotFoundError("Update not found");
}
```
```tsx
// Bad — an untyped error the boundary can only show as a generic failure
if (data.node?.__typename !== "MailingListUpdate") {
throw new Error("Update not found");
}
```
See [`relay.md`](relay.md) (Node type guards).
## Placement guidance
- **Route root** — one boundary so an unhandled failure shows a full-page error instead of a blank screen.

View File

@@ -261,6 +261,52 @@ const logoUrl = organization.logo?.downloadUrl ?? undefined; // logo stays optio
Do **not** reach for `@required` to silence nullability on fields that are *genuinely* optional (an avatar, a logo, a description that may be empty). Those keep their nullable type and get a real empty/fallback state. Likewise, never select a field, mark it `@required(action: THROW)`, and rely on the throw as control flow for an expected-empty case — that is an error path, not a branch. And there is no need to annotate fields the schema already declares non-null (`String!`, `Organization!`).
### Node type guards
A `node(id:)` query resolves to an interface (`Node`), so the page must narrow it
to the concrete type before use. When the `__typename` is not what the view
expects, throw a **typed** error the nearest error boundary can map to the right
state (a not-found page), not a bare `Error`:
```tsx
// Good — NotFoundError is mapped to the 404 state by the boundary
import { NotFoundError } from "#/lib/relay/errors";
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
if (data.node?.__typename !== "MailingListUpdate") {
throw new NotFoundError("Update not found");
}
const update = data.node; // narrowed to MailingListUpdate
```
```tsx
// Bad — an untyped error only renders a generic failure
if (data.node?.__typename !== "MailingListUpdate") {
throw new Error("Update not found");
}
```
This is a client-side invariant, distinct from server field errors (which flow
through `@throwOnFieldError`; see below). See
[`error-handling.md`](error-handling.md).
### Field errors (`@throwOnFieldError`)
To contain a **partial** GraphQL failure (data present, one field errored) to the
component that reads the bad field, annotate the query or fragment with
`@throwOnFieldError`. The field error then throws at the read site
(`usePreloadedQuery` / `useFragment`) and is caught by the nearest `ErrorBoundary`
— on a **fragment** to isolate a section/row, on a **query** for page-level
fields. This only works when the network layer leaves field-level errors in the
response (see the portal fetch in [`error-handling.md`](error-handling.md)).
```graphql
# Good — a field error in this fragment throws at the section's useFragment
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
updates(first: 5) { edges { node { id ...MailingListUpdateListItem_update } } }
}
```
### Refetchable fragments
For lists that support sorting and pagination, use `@refetchable` with `@argumentDefinitions`: