├── .gitignore ├── public └── favicon.ico ├── app ├── utils │ └── responses.ts ├── entry.client.tsx ├── routes │ ├── api.widget.ts │ └── _index.tsx ├── components │ ├── widget.tsx │ └── ComponentErrorBoundary.tsx ├── root.tsx └── entry.server.tsx ├── vite.config.ts ├── tsconfig.json ├── package.json ├── README.md └── .eslintrc.cjs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiliman/remix-component-errorboundary/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /app/utils/responses.ts: -------------------------------------------------------------------------------- 1 | import { json } from "@remix-run/node"; 2 | 3 | export function error(e: unknown) { 4 | if (e instanceof Error) { 5 | const { message, stack } = e as Error; 6 | return json({ $$error: { message, stack } }, { status: 500 }); 7 | } 8 | return json({ $$error: { message: "Unknown error", e } }, { status: 500 }); 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | remix({ 8 | future: { 9 | v3_fetcherPersist: true, 10 | v3_relativeSplatPath: true, 11 | v3_throwAbortReason: true, 12 | }, 13 | }), 14 | tsconfigPaths(), 15 | ], 16 | }); 17 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/routes/api.widget.ts: -------------------------------------------------------------------------------- 1 | import { LoaderFunctionArgs, json } from "@remix-run/node"; 2 | import { error } from "~/utils/responses"; 3 | 4 | export const loader = async ({ request }: LoaderFunctionArgs) => { 5 | try { 6 | const url = new URL(request.url); 7 | if (url.searchParams.has("error")) { 8 | throw new Error("💣 This is an error in the widget"); 9 | } 10 | return json({ items: range(Date.now(), Date.now() + 10) }); 11 | } catch (e) { 12 | // don't return an actual Error, but a json object that represents the error 13 | // this way Remix won't render the route error boundary and our component 14 | // error boundary can handle it 15 | return error(e); 16 | } 17 | }; 18 | 19 | function range(start: number, end: number) { 20 | return Array.from({ length: end - start + 1 }, (_, i) => i + start); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"] 27 | }, 28 | 29 | // Vite takes care of building everything, not tsc. 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; 2 | import { Link, json } from "@remix-run/react"; 3 | import Widget from "~/components/widget"; 4 | 5 | export const meta: MetaFunction = () => { 6 | return [ 7 | { title: "New Remix App" }, 8 | { name: "description", content: "Welcome to Remix!" }, 9 | ]; 10 | }; 11 | 12 | export function loader({ request }: LoaderFunctionArgs) { 13 | const url = new URL(request.url); 14 | if (url.searchParams.has("error")) { 15 | throw new Error("This is an error"); 16 | } 17 | return json({ message: "Hello, world!" }); 18 | } 19 | 20 | export default function Index() { 21 | return ( 22 |
23 |

Welcome to Remix

24 | 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/components/widget.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { loader as widgetLoader } from "~/routes/api.widget"; 3 | import { 4 | ComponentErrorBoundary, 5 | useComponentFetcher, 6 | } from "./ComponentErrorBoundary"; 7 | 8 | export default function Component() { 9 | return ( 10 |
11 |

Widget

12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export function Widget() { 20 | const fetcher = useComponentFetcher(); 21 | useEffect( 22 | () => { 23 | if (fetcher.state === "idle" && !fetcher.data) { 24 | fetcher.load("/api/widget"); 25 | } 26 | }, 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | [fetcher.state, fetcher.data] 29 | ); 30 | 31 | return ( 32 |
33 | 34 | 37 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | Meta, 4 | Outlet, 5 | Scripts, 6 | ScrollRestoration, 7 | isRouteErrorResponse, 8 | useRouteError, 9 | } from "@remix-run/react"; 10 | import { isErrorResponse } from "@remix-run/react/dist/data"; 11 | 12 | export function Layout({ children }: { children: React.ReactNode }) { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export default function App() { 31 | return ; 32 | } 33 | 34 | export function ErrorBoundary() { 35 | const error = useRouteError(); 36 | if (isRouteErrorResponse(error)) { 37 | return ( 38 |
39 |

40 | {error.status} {error.statusText} 41 |

42 |
{error.data}
43 |
44 | ); 45 | } 46 | if (error instanceof Error) { 47 | return ( 48 |
49 |

Oh no, an error occurred!

50 |
{error.message}
51 |
52 | ); 53 | } 54 | return null; 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-component-errorboundary", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "remix-serve ./build/server/index.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@remix-run/node": "^2.9.2", 15 | "@remix-run/react": "^2.9.2", 16 | "@remix-run/serve": "^2.9.2", 17 | "isbot": "^4.1.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-error-boundary": "^4.0.13" 21 | }, 22 | "devDependencies": { 23 | "@remix-run/dev": "^2.9.2", 24 | "@types/react": "^18.2.20", 25 | "@types/react-dom": "^18.2.7", 26 | "@typescript-eslint/eslint-plugin": "^6.7.4", 27 | "@typescript-eslint/parser": "^6.7.4", 28 | "eslint": "^8.38.0", 29 | "eslint-import-resolver-typescript": "^3.6.1", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-jsx-a11y": "^6.7.1", 32 | "eslint-plugin-react": "^7.33.2", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "typescript": "^5.1.6", 35 | "vite": "^5.1.0", 36 | "vite-tsconfig-paths": "^4.2.1" 37 | }, 38 | "engines": { 39 | "node": ">=20.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Component Error Boundary 2 | 3 | This example includes a new `` component that you can wrap around any non-critical component so that when an error occurs, it will not result in the default route ``, which may replace the entire page. 4 | 5 | ## Example 6 | 7 | ⚡️ StackBlitz https://stackblitz.com/github/kiliman/remix-component-errorboundary 8 | 9 | ## Usage 10 | 11 | Wrap your existing component with ``. You can also provide your own `fallbackRenderer` or use the default. 12 | 13 | ```ts 14 | 15 | 16 | 17 | 18 |

💣 Oops! {error.message}

} 20 | > 21 | 22 |
23 | ``` 24 | 25 | In your component, replace `useFetcher` with `useComponentFetcher`. This ensures it correctly handles error responses and triggers the error boundary. The 26 | `useComponetFetcher` hook also supports type inference. It removes the error response 27 | from the return type. 28 | 29 | ```ts 30 | export function Component({ items }: { items?: number[] }) { 31 | const fetcher = useComponentFetcher(); 32 | items = fetcher.data?.items ?? items ?? []; 33 | ``` 34 | 35 | Finally, in your `loader` or `action`, do not throw errors or let them escape the function. You will need to wrap in `try/catch` and instead return an error object using the `error` helper function. 36 | 37 | ```ts 38 | import { error } from "~/utils/responses"; 39 | export async function loader() { 40 | try { 41 | // do stuff 42 | return json(data); 43 | } catch (e) { 44 | // catch errors and return using helper 45 | return error(e); 46 | } 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /app/components/ComponentErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import type { TypedResponse } from "@remix-run/node"; 2 | import { useFetcher } from "@remix-run/react"; 3 | import { ErrorBoundary } from "react-error-boundary"; 4 | 5 | type ComponentErrorBoundaryProps = { 6 | children: React.ReactNode; 7 | fallbackRender?: (props: { error: Error }) => React.ReactNode; 8 | }; 9 | 10 | export function DefaultErrorBoundaryFallback({ error }: { error: Error }) { 11 | return ( 12 |
13 |

Something went wrong

14 |
{error.message}
15 |
16 | ); 17 | } 18 | 19 | export function ComponentErrorBoundary({ 20 | children, 21 | fallbackRender, 22 | }: ComponentErrorBoundaryProps) { 23 | function defaultFallbackRender({ error }: { error: Error }) { 24 | return ; 25 | } 26 | 27 | return ( 28 | 29 | {children} 30 | 31 | ); 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | type ExcludeErrorResponse = T extends TypedResponse<{ $$error: any }> 36 | ? never 37 | : T; 38 | type UnwrapPromise = T extends Promise ? U : T; 39 | 40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 41 | export function useComponentFetcher Promise>() { 42 | type Unwrapped = UnwrapPromise>; 43 | type Excluded = ExcludeErrorResponse; 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | type Fetcher = (args: any) => Promise; 46 | 47 | // we want to infer the loader return type and exclude the $$error response 48 | const fetcher = useFetcher(); 49 | // ensure if we get an $errror response, we throw it so it can be handled 50 | // by the component error boundary 51 | handleComponentError(fetcher.data); 52 | 53 | return fetcher; 54 | } 55 | 56 | function handleComponentError(fetcherData: T) { 57 | if ( 58 | fetcherData && 59 | typeof fetcherData === "object" && 60 | "$$error" in fetcherData 61 | ) { 62 | throw fetcherData?.$$error; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: [ 70 | "plugin:@typescript-eslint/recommended", 71 | "plugin:import/recommended", 72 | "plugin:import/typescript", 73 | ], 74 | }, 75 | 76 | // Node 77 | { 78 | files: [".eslintrc.cjs"], 79 | env: { 80 | node: true, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext 39 | ); 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false; 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true; 59 | const body = new PassThrough(); 60 | const stream = createReadableStreamFromReadable(body); 61 | 62 | responseHeaders.set("Content-Type", "text/html"); 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }) 69 | ); 70 | 71 | pipe(body); 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error); 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500; 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error); 83 | } 84 | }, 85 | } 86 | ); 87 | 88 | setTimeout(abort, ABORT_DELAY); 89 | }); 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false; 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true; 109 | const body = new PassThrough(); 110 | const stream = createReadableStreamFromReadable(body); 111 | 112 | responseHeaders.set("Content-Type", "text/html"); 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }) 119 | ); 120 | 121 | pipe(body); 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error); 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500; 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error); 133 | } 134 | }, 135 | } 136 | ); 137 | 138 | setTimeout(abort, ABORT_DELAY); 139 | }); 140 | } 141 | --------------------------------------------------------------------------------