|
| 1 | +--- |
| 2 | +title: Selectively rethrow tRPC errors in widgets inside an ErrorBoundary |
| 3 | +date: 2026-04-30 |
| 4 | +module: packages/widgets/src/app |
| 5 | +problem_type: best_practice |
| 6 | +component: tooling |
| 7 | +severity: medium |
| 8 | +applies_when: |
| 9 | + - "Widget uses tRPC + react-query inside a React ErrorBoundary" |
| 10 | + - "One specific TRPCError code is a normal/expected condition, not a real failure" |
| 11 | + - "Other error codes from the same query should still surface as the widget's error UI" |
| 12 | + - "Replacing useSuspenseQuery with useQuery to gain access to query.error before render" |
| 13 | +tags: |
| 14 | + - trpc |
| 15 | + - react-query |
| 16 | + - error-boundary |
| 17 | + - use-suspense-query |
| 18 | + - use-query |
| 19 | + - widget-pattern |
| 20 | + - graceful-degradation |
| 21 | +--- |
| 22 | + |
| 23 | +# Selectively rethrow tRPC errors in widgets inside an ErrorBoundary |
| 24 | + |
| 25 | +## Context |
| 26 | + |
| 27 | +The fork adds path-only hrefs (`/cockpit/`) so app cards work across multiple origins (mDNS, VPN FQDN, raw LAN IP). The browser resolves them against whatever origin the user is currently on. The server cannot follow that href to ping the app — synthesising an absolute URL from `X-Forwarded-Host` would be a header-spoofing / SSRF surface — so the ping router throws `TRPCError({code: "CONFLICT"})` when no explicit `pingUrl` is configured. That is a *valid* config, not a failure. |
| 28 | + |
| 29 | +The friction: Homarr's ping indicator was originally written with `useSuspenseQuery`, and the widget sits inside a parent React `ErrorBoundary`. Every thrown tRPC error — including this expected CONFLICT — escaped Suspense and replaced the entire app card with a loud "Try again" error panel. Genuine misconfig (FORBIDDEN, NOT_FOUND) deserves that treatment; an intentionally non-pingable app does not. |
| 30 | + |
| 31 | +## Guidance |
| 32 | + |
| 33 | +When a tRPC query lives inside an outer error boundary and *some* of its error codes represent expected, normal configuration rather than faults, switch the call site from `useSuspenseQuery` to `useQuery` and discriminate on `error.data.code`: |
| 34 | + |
| 35 | +1. **Replace `useSuspenseQuery` with `useQuery`** and disable retries (`retry: false`) so the expected error doesn't trigger backoff churn. |
| 36 | +2. **Inspect `query.error.data?.code`** — render an in-place degraded UI for the known-good code(s), `throw query.error` for everything else so the outer boundary still catches genuine faults. |
| 37 | +3. **Move the loading state inside the component.** Since you're no longer suspending, drop the parent `<Suspense fallback>` and render your own placeholder when `query.data` is undefined. |
| 38 | +4. **Prefer derivation over `useState` + `useEffect`** when merging query data with an override stream (e.g. a websocket subscription): `const result = override ?? query.data ?? null`. This avoids the one-render lag described in tradeoffs. |
| 39 | + |
| 40 | +The discriminator pattern: |
| 41 | + |
| 42 | +```tsx |
| 43 | +if (query.error) { |
| 44 | + if (query.error.data?.code === "CONFLICT") { |
| 45 | + return <DegradedView tooltip={query.error.message} />; |
| 46 | + } |
| 47 | + throw query.error; // FORBIDDEN, NOT_FOUND, INTERNAL_SERVER_ERROR → boundary |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +## Why This Matters |
| 52 | + |
| 53 | +- **Preserves the safety net.** The error boundary still catches genuinely broken state — auth failures, missing resources, server crashes — exactly as it did before. We narrow what's swallowed, we don't disable the boundary. |
| 54 | +- **Turns expected config into normal UI.** A path-only href without `pingUrl` is a deliberate deployment choice, not a fault. Treating it as one is the bug; rendering a calm indeterminate dot is the fix. |
| 55 | +- **Keeps the failure mode legible.** A typed code check (`error.data.code === "CONFLICT"`) is greppable, fails loudly if the server changes the code, and survives message rewording. Matching on `error.message` substrings would not. |
| 56 | +- **Avoids worse alternatives.** Server-side "never throw, return null" loses the typed discriminator at the boundary and forces every consumer to recheck. An error-boundary reset button forces user interaction for a non-error. |
| 57 | + |
| 58 | +## When to Apply |
| 59 | + |
| 60 | +Apply this pattern when **all** of: |
| 61 | + |
| 62 | +- The query runs inside a React `ErrorBoundary` (directly or via a parent widget framework). |
| 63 | +- At least one tRPC error code returned by the procedure represents *expected, valid* runtime state (config-driven, not a fault). |
| 64 | +- The component can render a meaningful degraded UI for that case. |
| 65 | + |
| 66 | +Do **not** apply when: |
| 67 | + |
| 68 | +- *Every* error from the procedure is genuinely a fault — `useSuspenseQuery` + boundary is simpler and correct. |
| 69 | +- The component relies on React 18 streaming SSR for above-the-fold / SEO-critical content. `useQuery` is client-first; you lose the streaming integration. Dashboard widgets behind auth don't care; public landing-page content does. |
| 70 | +- You'd be tempted to catch *all* errors generically. That defeats the boundary. Discriminate on a specific known code. |
| 71 | + |
| 72 | +Alternatives considered and why they're worse: |
| 73 | + |
| 74 | +- **Server returns `null` instead of throwing CONFLICT.** Loses the typed signal; every client must re-derive "is this a real null or a config-degraded null". Erodes the procedure's contract. |
| 75 | +- **Error boundary with reset on CONFLICT.** Forces the user to click through a non-error. Also fragile: the boundary has to introspect the error to decide whether to auto-reset, which is the same discriminator logic in a worse place. |
| 76 | +- **Try/catch around `useSuspenseQuery`.** Doesn't work — Suspense throws promises during render; you cannot catch the eventual error synchronously at the call site. |
| 77 | + |
| 78 | +## Examples |
| 79 | + |
| 80 | +Before — `packages/widgets/src/app/ping/ping-indicator.tsx`: |
| 81 | + |
| 82 | +```tsx |
| 83 | +const [ping] = clientApi.widget.app.ping.useSuspenseQuery( |
| 84 | + { id: appId }, |
| 85 | + { refetchOnMount: false, refetchOnWindowFocus: false }, |
| 86 | +); |
| 87 | +const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"]>(ping); |
| 88 | +``` |
| 89 | + |
| 90 | +…wrapped at the call site in `packages/widgets/src/app/component.tsx` with `<Suspense fallback={<PingDot icon={IconLoader} … />}>`. |
| 91 | + |
| 92 | +After: |
| 93 | + |
| 94 | +```tsx |
| 95 | +const query = clientApi.widget.app.ping.useQuery( |
| 96 | + { id: appId }, |
| 97 | + { |
| 98 | + refetchOnMount: false, |
| 99 | + refetchOnWindowFocus: false, |
| 100 | + retry: false, |
| 101 | + }, |
| 102 | +); |
| 103 | + |
| 104 | +const [pingResult, setPingResult] = useState<RouterOutputs["widget"]["app"]["ping"] | null>( |
| 105 | + query.data ?? null, |
| 106 | +); |
| 107 | + |
| 108 | +useEffect(() => { |
| 109 | + if (query.data) setPingResult(query.data); |
| 110 | +}, [query.data]); |
| 111 | + |
| 112 | +clientApi.widget.app.updatedPing.useSubscription( |
| 113 | + { id: appId }, |
| 114 | + { onData(data) { setPingResult(data); } }, |
| 115 | +); |
| 116 | + |
| 117 | +// Apps without a server-pingable URL (e.g. path-only href without an explicit |
| 118 | +// pingUrl) yield a CONFLICT. Render an indeterminate dot for that case so the |
| 119 | +// card stays usable. Other tRPC errors (FORBIDDEN, NOT_FOUND) still bubble to |
| 120 | +// the widget error boundary as before. |
| 121 | +if (query.error) { |
| 122 | + if (query.error.data?.code === "CONFLICT") { |
| 123 | + return <PingDot icon={IconLoader} color="blue" tooltip={query.error.message} />; |
| 124 | + } |
| 125 | + throw query.error; |
| 126 | +} |
| 127 | + |
| 128 | +if (!pingResult) { |
| 129 | + return <PingDot icon={IconLoader} color="blue" tooltip="Pinging…" />; |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +In `component.tsx`, the `<Suspense>` wrapper around `<PingIndicator>` is removed along with the now-unused `IconLoader` / `useI18n` / `PingDot` imports — loading state lives inside `PingIndicator` now. |
| 134 | + |
| 135 | +A cleaner variant that avoids the one-render lag (see tradeoffs): |
| 136 | + |
| 137 | +```tsx |
| 138 | +const query = clientApi.widget.app.ping.useQuery(/* … */); |
| 139 | +const [override, setOverride] = useState<RouterOutputs["widget"]["app"]["ping"] | null>(null); |
| 140 | + |
| 141 | +clientApi.widget.app.updatedPing.useSubscription( |
| 142 | + { id: appId }, |
| 143 | + { onData: setOverride }, |
| 144 | +); |
| 145 | + |
| 146 | +const pingResult = override ?? query.data ?? null; |
| 147 | +// no useEffect needed; render is a pure derivation |
| 148 | +``` |
| 149 | + |
| 150 | +## Tradeoffs |
| 151 | + |
| 152 | +- **One-render visual lag on initial data.** With `useState(query.data ?? null)` + `useEffect`, the first render after `query.data` resolves shows the loading placeholder; the synced state lands one render later. Mitigation: derive `pingResult = override ?? query.data ?? null` instead of using `useState` + `useEffect`. The shipped code uses the useEffect form for symmetry with the subscription override; the derived form is preferable in new code. |
| 153 | +- **Lost streaming-SSR integration.** `useSuspenseQuery` participates in React 18 streaming SSR; `useQuery` is client-first and renders the loading placeholder on the server. Irrelevant for authenticated dashboard widgets, material for public SEO-critical content. |
| 154 | +- **`data` becomes nullable.** Consumers must handle `query.data === undefined` (loading) and the discriminated error case explicitly. The Suspense version made `data` non-null by construction; this version trades that ergonomic guarantee for the ability to keep rendering on expected errors. |
0 commit comments