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

@@ -18,15 +18,19 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
import { RouterProvider } from "react-router";
import { BootstrapError } from "#/components/errors/BootstrapError";
import { RelayProvider } from "#/lib/relay/RelayProvider";
import { router } from "#/routes";
export function App() {
return (
<RelayProvider>
<RouterProvider router={router} />
</RelayProvider>
<ErrorBoundary fallback={<BootstrapError />}>
<RelayProvider>
<RouterProvider router={router} />
</RelayProvider>
</ErrorBoundary>
);
}

View File

@@ -36,6 +36,32 @@
"description": "The page you are looking for does not exist or has moved.",
"backHome": "Back to home"
},
"errors": {
"notFound": {
"title": "Page not found",
"description": "The page you're looking for doesn't exist or may have been moved."
},
"forbidden": {
"title": "Access denied",
"description": "You don't have permission to view this page."
},
"serverError": {
"title": "Something went wrong",
"description": "We hit an unexpected error. Please try again in a moment."
},
"generic": {
"title": "Something went wrong",
"description": "We hit an unexpected error. Please try again in a moment."
},
"actions": {
"backToTrustCenter": "Back to trust center",
"tryAgain": "Try again"
},
"inline": {
"message": "Unable to load content",
"retry": "Retry"
}
},
"footer": {
"poweredBy": "Powered by"
},

View File

@@ -36,6 +36,32 @@
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
"backHome": "Retour à l'accueil"
},
"errors": {
"notFound": {
"title": "Page introuvable",
"description": "La page que vous recherchez n'existe pas ou a été déplacée."
},
"forbidden": {
"title": "Accès refusé",
"description": "Vous n'avez pas l'autorisation de consulter cette page."
},
"serverError": {
"title": "Une erreur est survenue",
"description": "Une erreur inattendue s'est produite. Veuillez réessayer dans un instant."
},
"generic": {
"title": "Une erreur est survenue",
"description": "Une erreur inattendue s'est produite. Veuillez réessayer dans un instant."
},
"actions": {
"backToTrustCenter": "Retour au trust center",
"tryAgain": "Réessayer"
},
"inline": {
"message": "Impossible de charger le contenu",
"retry": "Réessayer"
}
},
"footer": {
"poweredBy": "Propulsé par"
},

View File

@@ -18,6 +18,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
@@ -26,8 +28,11 @@ import { HomeSection } from "#/components/HomeSection/HomeSection";
import type { ComplianceFrameworksSection_trustCenter$key } from "./__generated__/ComplianceFrameworksSection_trustCenter.graphql";
import { ComplianceFrameworkListItem } from "./ComplianceFrameworkListItem";
// @throwOnFieldError makes a field error in this fragment throw at the read
// below, where the section's ErrorBoundary contains it. See
// contrib/claude/error-handling.md.
const complianceFrameworksSectionFragment = graphql`
fragment ComplianceFrameworksSection_trustCenter on TrustCenter {
fragment ComplianceFrameworksSection_trustCenter on TrustCenter @throwOnFieldError {
complianceFrameworks(first: 8) {
edges {
node {
@@ -44,9 +49,30 @@ interface ComplianceFrameworksSectionProps {
}
// "Compliance" section: the grid of certification frameworks the trust center
// covers.
// covers. Wraps its data-reading content in a boundary so a load failure
// degrades to an inline error instead of taking down the page.
export function ComplianceFrameworksSection({ trustCenterKey }: ComplianceFrameworksSectionProps) {
const { t } = useTranslation();
return (
<ErrorBoundary
fallback={(_, reset) => (
<HomeSection title={t("home.sections.compliance")}>
<InlineError
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={reset}
/>
</HomeSection>
)}
>
<ComplianceFrameworksSectionContent trustCenterKey={trustCenterKey} />
</ErrorBoundary>
);
}
function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFrameworksSectionProps) {
const { t } = useTranslation();
const data = useFragment(complianceFrameworksSectionFragment, trustCenterKey);
const frameworks = data.complianceFrameworks.edges.map(edge => edge.node);

View File

@@ -29,7 +29,7 @@ import { formatRelativeTime } from "#/lib/datetime/relativeTime";
import type { MailingListUpdateListItem_update$key } from "./__generated__/MailingListUpdateListItem_update.graphql";
const mailingListUpdateListItemFragment = graphql`
fragment MailingListUpdateListItem_update on MailingListUpdate {
fragment MailingListUpdateListItem_update on MailingListUpdate @throwOnFieldError {
id
title
updatedAt

View File

@@ -19,6 +19,8 @@
// SOFTWARE.
import { Link } from "@probo/ui/src/v2/Button/Link";
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
import { InlineError } from "@probo/ui/src/v2/InlineError/InlineError";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
@@ -28,8 +30,10 @@ import { dotPatternStyle } from "#/components/MediaTile/variants";
import type { RecentUpdatesSection_trustCenter$key } from "./__generated__/RecentUpdatesSection_trustCenter.graphql";
// @throwOnFieldError surfaces a field error at the read below so the section
// ErrorBoundary contains it. See contrib/claude/error-handling.md.
const recentUpdatesSectionFragment = graphql`
fragment RecentUpdatesSection_trustCenter on TrustCenter {
fragment RecentUpdatesSection_trustCenter on TrustCenter @throwOnFieldError {
updates(first: 5) {
edges {
node {
@@ -46,9 +50,29 @@ interface RecentUpdatesSectionProps {
}
// "Recent updates" section: the latest mailing-list updates as a list, with a
// link to the full updates page.
// link to the full updates page. A load failure degrades to an inline error.
export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionProps) {
const { t } = useTranslation();
return (
<ErrorBoundary
fallback={(_, reset) => (
<HomeSection title={t("home.sections.recentUpdates")}>
<InlineError
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={reset}
/>
</HomeSection>
)}
>
<RecentUpdatesSectionContent trustCenterKey={trustCenterKey} />
</ErrorBoundary>
);
}
function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionProps) {
const { t } = useTranslation();
const data = useFragment(recentUpdatesSectionFragment, trustCenterKey);
const updates = data.updates.edges.map(edge => edge.node);
@@ -70,7 +94,22 @@ export function RecentUpdatesSection({ trustCenterKey }: RecentUpdatesSectionPro
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
<div className="relative divide-y divide-sand-a2">
{updates.map(update => (
<MailingListUpdateListItem key={update.id} updateKey={update} />
// A single failing row degrades to a compact horizontal inline error.
<ErrorBoundary
key={update.id}
fallback={(_, reset) => (
<div className="px-6 py-4">
<InlineError
layout="horizontal"
message={t("errors.inline.message")}
retryLabel={t("errors.inline.retry")}
onRetry={reset}
/>
</div>
)}
>
<MailingListUpdateListItem updateKey={update} />
</ErrorBoundary>
))}
</div>
</div>

View File

@@ -0,0 +1,45 @@
// 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 { Anchor } from "@probo/ui/src/v2/Button/Anchor";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
import { useTranslation } from "react-i18next";
import { getPathPrefix } from "#/lib/http/pathPrefix";
// Outermost fallback for failures that happen before (or in) the router itself.
// It cannot use the router (no context yet), so navigation is a plain anchor and
// recovery is a hard reload.
export function BootstrapError() {
const { t } = useTranslation();
return (
<ErrorState
fullPage
title={t("errors.generic.title")}
description={t("errors.generic.description")}
actions={(
<>
<Anchor href={getPathPrefix() || "/"} variant="solid" color="neutral" highContrast size={2}>
{t("errors.actions.backToTrustCenter")}
</Anchor>
<Button variant="soft" color="neutral" size={2} onClick={() => window.location.reload()}>
{t("errors.actions.tryAgain")}
</Button>
</>
)}
/>
);
}

View File

@@ -0,0 +1,89 @@
// 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 { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Link } from "@probo/ui/src/v2/Button/Link";
import { ErrorState } from "@probo/ui/src/v2/ErrorState/ErrorState";
import { useTranslation } from "react-i18next";
import { NotFoundError } from "#/lib/relay/errors";
interface ErrorContent {
code?: string;
titleKey: string;
descriptionKey: string;
}
// Map a caught error to the page-level copy. Recognizes the portal error
// classes first, then falls back to the code embedded in generic Error messages
// (thrown request-level by lib/relay/fetch.ts).
function resolveContent(error: unknown): ErrorContent {
const message = error instanceof Error ? error.message : "";
if (error instanceof NotFoundError || message.includes("NOT_FOUND")) {
return { code: "404", titleKey: "errors.notFound.title", descriptionKey: "errors.notFound.description" };
}
if (
error instanceof ForbiddenError
|| error instanceof UnAuthenticatedError
|| message.includes("FORBIDDEN")
|| message.includes("UNAUTHENTICATED")
) {
return { code: "403", titleKey: "errors.forbidden.title", descriptionKey: "errors.forbidden.description" };
}
if (error instanceof InternalServerError || message.includes("INTERNAL_SERVER_ERROR")) {
return { code: "500", titleKey: "errors.serverError.title", descriptionKey: "errors.serverError.description" };
}
return { titleKey: "errors.generic.title", descriptionKey: "errors.generic.description" };
}
interface GlobalErrorProps {
error: unknown;
// When provided, a "Try again" secondary action is shown.
onRetry?: () => void;
// Full viewport (standalone) vs inside the app chrome (in-shell).
fullPage?: boolean;
}
// Page-level error fallback: renders the v2 ErrorState with portal copy and
// actions. Used by the bootstrap boundary and the route boundaries.
export function GlobalError({ error, onRetry, fullPage = false }: GlobalErrorProps) {
const { t } = useTranslation();
const { code, titleKey, descriptionKey } = resolveContent(error);
return (
<ErrorState
fullPage={fullPage}
code={code}
title={t(titleKey)}
description={t(descriptionKey)}
actions={(
<>
<Link to="/" variant="solid" color="neutral" highContrast size={2}>
{t("errors.actions.backToTrustCenter")}
</Link>
{onRetry && (
<Button variant="soft" color="neutral" size={2} onClick={onRetry}>
{t("errors.actions.tryAgain")}
</Button>
)}
</>
)}
/>
);
}

View File

@@ -0,0 +1,30 @@
// 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 { useRouteError } from "react-router";
import { GlobalError } from "./GlobalError";
// Child-route boundary: a page failure is contained to the layout's Outlet, so
// the error renders inside the app chrome (TopBar + footer survive).
export function PageErrorBoundary() {
const error = useRouteError();
return (
<GlobalError
error={error}
onRetry={() => window.location.reload()}
/>
);
}

View File

@@ -0,0 +1,32 @@
// 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 { useRouteError } from "react-router";
import { GlobalError } from "./GlobalError";
// Root route boundary: a failure in the layout (or anything above the page
// boundaries) takes down the whole tree, so it renders a standalone full-page
// error without the app chrome.
export function RootErrorBoundary() {
const error = useRouteError();
return (
<GlobalError
error={error}
fullPage
onRetry={() => window.location.reload()}
/>
);
}

View File

@@ -18,10 +18,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { makeFetchQuery } from "@probo/relay";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { buildEndpoint } from "#/lib/http/endpoint";
import { makeFetchQuery } from "#/lib/relay/fetch";
const store = new Store(new RecordSource(), {
queryCacheExpirationTime: 1 * 60 * 1000,

View File

@@ -0,0 +1,25 @@
// 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.
// Thrown when a fetched `node(id:)` resolves to a type other than the one the
// view expects (e.g. `node.__typename !== "MailingListUpdate"`). Treated as a
// not-found (404) by the error boundaries. Prefer this over a bare
// `throw new Error(...)` so the boundary can render the dedicated 404 state.
export class NotFoundError extends Error {
constructor(message?: string) {
super(message ?? "NOT_FOUND");
this.name = "NotFoundError";
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}

View File

@@ -0,0 +1,123 @@
// 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 { ForbiddenError, InternalServerError, UnAuthenticatedError } from "@probo/relay";
import { type GraphQLError } from "graphql";
import { type FetchFunction, type GraphQLResponse } from "relay-runtime";
// A GraphQL error is "request-level" when it has no `path` — it applies to the
// whole operation (auth, malformed request, transport) rather than a single
// field. Field-level errors carry a `path` and are left in the response so
// Relay can attribute them to the reading field; combined with
// `@throwOnFieldError` on a query/fragment, they surface at the nearest error
// boundary around the component that reads them, instead of collapsing the whole
// operation. See contrib/claude/error-handling.md.
const isRequestLevel = (error: GraphQLError) =>
error.path === undefined || error.path === null || error.path.length === 0;
// The portal fetch only throws for request-level failures. Everything else
// (field-level errors) flows through to Relay untouched.
export const makeFetchQuery = (endpoint: string): FetchFunction => {
return async (request, variables, _, uploadables) => {
const requestInit: RequestInit = {
method: "POST",
credentials: "include",
headers: {},
};
if (uploadables) {
const formData = new FormData();
formData.append(
"operations",
JSON.stringify({
operationName: request.name,
query: request.text,
variables: variables,
}),
);
const uploadableMap: {
[key: string]: string[];
} = {};
const uploadableKeys = Object.keys(uploadables);
uploadableKeys.forEach((key) => {
uploadableMap[key] = [`variables.${key}`];
});
formData.append("map", JSON.stringify(uploadableMap));
uploadableKeys.forEach((key) => {
formData.append(key, uploadables[key]);
});
requestInit.body = formData;
} else {
requestInit.headers = {
"Accept": "application/graphql-response+json; charset=utf-8, application/json; charset=utf-8",
"Content-Type": "application/json",
};
requestInit.body = JSON.stringify({
operationName: request.name,
query: request.text,
variables,
});
}
const response = await fetch(endpoint, requestInit);
if (response.status === 500) {
throw new InternalServerError();
}
const json = (await response.json()) as GraphQLResponse & {
errors?: GraphQLError[];
};
if (json.errors) {
// An unauthenticated session is always a global concern: the backend
// attaches the resolver path even to auth errors, so we must scan every
// error (not just request-level ones) and redirect regardless of where it
// surfaced.
const unauthenticated = json.errors.find(
error => error.extensions?.code === "UNAUTHENTICATED",
);
if (unauthenticated) {
throw new UnAuthenticatedError(unauthenticated.message);
}
// Everything else is only thrown here when it is request-level (no path) —
// a whole-operation failure. Field-level errors (including a FORBIDDEN on
// a single field/section) are left in the response so Relay surfaces them
// at the reading component via @throwOnFieldError, containing the failure
// to that component's boundary instead of the whole page.
const requestErrors = json.errors.filter(isRequestLevel);
const forbidden = requestErrors.find(
error => error.extensions?.code === "FORBIDDEN",
);
if (forbidden) {
throw new ForbiddenError(forbidden.message);
}
const requestError = requestErrors[0];
if (requestError) {
throw new Error(requestError.message);
}
}
return json;
};
};

View File

@@ -32,7 +32,7 @@ import { TrustedBySection } from "#/components/TrustedBy/TrustedBySection";
import type { HomePageQuery } from "./__generated__/HomePageQuery.graphql";
export const homePageQuery = graphql`
query HomePageQuery {
query HomePageQuery @throwOnFieldError {
currentTrustCenter @required(action: THROW) {
organization {
name

View File

@@ -28,13 +28,14 @@ import { graphql, usePreloadedQuery } from "react-relay";
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
import { formatDate } from "#/lib/datetime/formatDate";
import { NotFoundError } from "#/lib/relay/errors";
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
import { updateArticle } from "./_components/variants";
export const updateDetailPageQuery = graphql`
query UpdateDetailPageQuery($updateId: ID!) {
query UpdateDetailPageQuery($updateId: ID!) @throwOnFieldError {
node(id: $updateId) {
__typename
... on MailingListUpdate {
@@ -55,7 +56,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
const data = usePreloadedQuery<UpdateDetailPageQuery>(updateDetailPageQuery, queryRef);
if (data.node?.__typename !== "MailingListUpdate") {
throw new Error("Update not found");
throw new NotFoundError("Update not found");
}
const update = data.node;

View File

@@ -22,6 +22,8 @@ import { lazy } from "@probo/react-lazy";
import { type AppRoute, routeFromAppRoute } from "@probo/routes";
import { createBrowserRouter } from "react-router";
import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
import { getPathPrefix } from "#/lib/http/pathPrefix";
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
@@ -33,25 +35,34 @@ const routes = [
path: "/",
Fallback: MainLayoutSkeleton,
Component: lazy(() => import("#/pages/MainLayoutLoader")),
// A layout failure takes down the shell, so it shows a standalone full page.
ErrorBoundary: RootErrorBoundary,
children: [
{
index: true,
Fallback: HomePageSkeleton,
Component: lazy(() => import("#/pages/HomePageLoader")),
},
{
path: "documents",
Component: lazy(() => import("#/pages/DocumentsPage")),
},
...subprocessorRoutes,
...updateRoutes,
{
path: "requests",
Component: lazy(() => import("#/pages/RequestsPage")),
},
{
path: "*",
Component: lazy(() => import("#/pages/NotFoundPage")),
// Pathless layout route: page failures bubble here and render inside the
// MainLayout Outlet, keeping the TopBar and footer chrome.
ErrorBoundary: PageErrorBoundary,
children: [
{
index: true,
Fallback: HomePageSkeleton,
Component: lazy(() => import("#/pages/HomePageLoader")),
},
{
path: "documents",
Component: lazy(() => import("#/pages/DocumentsPage")),
},
...subprocessorRoutes,
...updateRoutes,
{
path: "requests",
Component: lazy(() => import("#/pages/RequestsPage")),
},
{
path: "*",
Component: lazy(() => import("#/pages/NotFoundPage")),
},
],
},
],
},