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

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