Install
openclaw skills install remix-v2-error-boundaries-reviewReviews Remix v2 error-handling code for the unified ErrorBoundary, isRouteErrorResponse narrowing, throw-vs-return, root boundary scaffolding, and v1 holdovers (CatchBoundary, useCatch). Use when reviewing routes that throw or define ErrorBoundary in a Remix v2 codebase.
openclaw skills install remix-v2-error-boundaries-reviewTargets TypeScript route modules importing from @remix-run/*. No sibling
knowledge skill exists for this topic; the canonical mental model is
summarized inline below and expanded in references/.
Remix v2 unified v1's CatchBoundary + ErrorBoundary into a single
ErrorBoundary route-module export. The framework calls it for both
thrown Responses (e.g. throw new Response(...), throw json(...))
and thrown runtime errors (loader/action/render exceptions). Inside
the boundary you read the value with the useRouteError() hook, then
narrow in this order:
isRouteErrorResponse(error) → it was a thrown Response; read
error.status, error.statusText, error.data.error instanceof Error → real runtime error; read error.message.The boundary takes no props. CatchBoundary, useCatch, and the
future.v2_errorBoundary flag are all gone — finding any of them is a
v1 holdover. Errors render the nearest ErrorBoundary and bubble to
the root if none exists; the root boundary remounts the whole document,
so it must render <Meta />, <Links />, and <Scripts />. Only
thrown loader/action results reach the boundary — a return json(...)
with a 4xx status is a successful loader, not an error. Server-side
runtime errors also flow through an optional entry.server.tsx
handleError export (thrown Responses do not).
| Issue Type | Reference |
|---|---|
Missing route ErrorBoundary, props-on-boundary, narrowing-only instanceof Error, narrowing-only isRouteErrorResponse | references/boundary-shape.md |
Return-instead-of-throw 4xx/5xx, swallowing error.data, throwing strings, missing handleError | references/throw-response.md |
Missing root boundary, root boundary without <Meta />/<Links />/<Scripts />, useLoaderData() in root boundary | references/root-boundary.md |
CatchBoundary export, useCatch import, v2_errorBoundary future flag | references/v1-holdovers.md |
ErrorBoundary declared export function ErrorBoundary() with no propsuseRouteError(), not useCatch() and not a propisRouteErrorResponse(error) first, then error instanceof Error, then fallbackerror.data rendered defensively (typed/narrowed before going into JSX)throw (not return) for Response / jsonErrorBoundary (don't tear down parents for a widget failure)app/root.tsx exports an ErrorBoundary that renders <Meta />, <Links />, and <Scripts />useRouteLoaderData("root") (not useLoaderData()) when reading root dataCatchBoundary export anywhere; no useCatch import; no future.v2_errorBoundary in remix.config.jsentry.server.tsx exports handleError and pipes runtime errors to an error reporterhandleError does not assume thrown Responses flow through it (they don't)Response/json/Error instances — never plain strings or POJOsThese are correct Remix v2 usage and must not be reported as issues:
ErrorBoundary that intentionally inherits from a parent — Boundaries cascade up. A child route may omit ErrorBoundary so the parent (or root) renders the fallback. Only flag if the route handles user-distinct error UX and a parent boundary cannot.throw new Response(...) or throw json(...) from a loader/action — The canonical way to signal 404/401/403/etc. This is not "using exceptions for control flow"; it is documented v2 contract.isRouteErrorResponse(error) — Acceptable when the route demonstrably only throws Responses and has no render-time crash risk. Severity is ADVISORY at most; suggest adding an instanceof Error branch for defense-in-depth, do not flag as a bug.ErrorBoundary that does not call useRouteError() — Valid when the boundary renders a static "Something went wrong" fallback intentionally (e.g. marketing pages that don't want to surface error detail).ErrorBoundary calling useRouteLoaderData("root") and getting undefined — Documented defensive pattern (root loader may have thrown). Do not flag the undefined handling as "dead code."handleError returning early on request.signal.aborted — Documented noise filter, not a swallowed error.handleError not handling thrown Responses — By framework contract handleError only fires for runtime errors. The absence of Response handling is correct, not a gap.ErrorBoundary returning a bare fragment (no <html> / <body>) — Only the root boundary owns the document. Nested boundaries render inside parent layouts and must not include document tags.Use these defaults unless the codebase has documented a different scale:
| Pattern | Default severity |
|---|---|
CatchBoundary export or useCatch import in v2 codebase | BLOCKER (build-breaking or dead code) |
Root ErrorBoundary missing <Scripts /> | BLOCKER (dead-end error page) |
ErrorBoundary with ({ error }) v1 prop signature | WARN (silent runtime undefined) |
return json(...) for 4xx instead of throw | WARN (boundary never fires) |
Missing instanceof Error branch on a route with render-crash risk | WARN |
Missing instanceof Error branch on a Response-only route | ADVISORY |
useLoaderData() (vs useRouteLoaderData) in root boundary | WARN (latent loop) |
Missing handleError in entry.server.tsx | ADVISORY (observability gap, not a bug) |
Run in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.
Location evidence — Pass: Each issue lists the repo path to
the route module (or app/root.tsx, or app/entry.server.tsx) and
either a line range or a short verbatim quote from the file you read
(not from memory or diff-only guesswork). "The root boundary is
wrong" without a path to app/root.tsx is not reportable.
Exemption check — Pass: For each issue, you can state in one
line why it is not covered by Valid Patterns (Do NOT Flag).
In particular: confirm a missing ErrorBoundary is not a deliberate
cascade to a parent boundary; confirm an isRouteErrorResponse-only
narrowing is not on a route that demonstrably only throws Responses
(downgrade to ADVISORY in that case).
v1-vs-v2 marker check — Pass: Before writing the finding,
grep the route module (and the repo at large for cross-cutting
issues) for: CatchBoundary, useCatch, v2_errorBoundary,
ErrorBoundary({ error, ErrorBoundary({error. If any of these
appear, the finding is a v1 holdover (load references/v1-holdovers.md)
and must be labeled as such — not as a generic "missing error
handling" issue. If none appear, the code is v2-shape and the
finding is about v2 correctness.
Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
ErrorBoundary at the right level — local where the recovery UI
matters, parent/root where cascade is intentional?ErrorBoundary call useRouteError() (not useCatch(),
not props) and narrow isRouteErrorResponse first?throw (not return) so the
boundary actually fires?app/root.tsx export an ErrorBoundary with <Meta />,
<Links />, and <Scripts />, and use useRouteLoaderData("root")
defensively?CatchBoundary, useCatch,
v2_errorBoundary, ({ error }) prop signature)?handleError present in entry.server.tsx for runtime-error
observability, with the correct contract (no Response handling)?ErrorBoundary export shape, hook usage, or narrowing → references/boundary-shape.mdResponse / json patterns, handleError, or return-vs-throw → references/throw-response.mdapp/root.tsx boundary scaffolding → references/root-boundary.mdCatchBoundary, useCatch, v2_errorBoundary) → references/v1-holdovers.mdentry.server / handleError docs: https://remix.run/docs/en/main/file-conventions/entry.server