From eac92263ba1a513f03d969f59ca227b9223be01a Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sun, 1 Sep 2024 23:29:14 -0400 Subject: [PATCH] Add react.d.ts and error handling documentation --- deno.jsonc | 21 ++- deno.lock | 71 ++++----- docs/error-handling.md | 322 +++++++++++++++++++++++++++++++++++++++- docs/getting-started.md | 29 +++- react.d.ts | 5 + 5 files changed, 382 insertions(+), 66 deletions(-) create mode 100644 react.d.ts diff --git a/deno.jsonc b/deno.jsonc index a8b6498..c384c24 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -24,7 +24,8 @@ "**/*.test.tsx", "test-utils.tsx", "example", - "coverage" + "coverage", + "node_modules" ] }, "tasks": { @@ -58,17 +59,13 @@ "jsxImportSourceTypes": "@types/react" }, "nodeModulesDir": true, - "lint": { - "exclude": [ - "coverage", - "example/public/build", - "example/routes/_main.ts", - "example/routes/_main.tsx" - ] - }, - "fmt": { - "exclude": ["coverage", "example/public/build"] - }, + "exclude": [ + "coverage", + "node_modules", + "example/public/build", + "example/routes/_main.ts", + "example/routes/_main.tsx" + ], "imports": { "@udibo/http-error": "jsr:@udibo/http-error@0", "@udibo/react-app": "./mod.tsx", diff --git a/deno.lock b/deno.lock index 4c2a2f9..8505c98 100644 --- a/deno.lock +++ b/deno.lock @@ -6,11 +6,10 @@ "jsr:@oak/oak@16": "jsr:@oak/oak@16.1.0", "jsr:@std/assert@0.223": "jsr:@std/assert@0.223.0", "jsr:@std/assert@0.226": "jsr:@std/assert@0.226.0", - "jsr:@std/assert@1": "jsr:@std/assert@1.0.2", "jsr:@std/assert@^0.213.1": "jsr:@std/assert@0.213.1", "jsr:@std/assert@^0.223.0": "jsr:@std/assert@0.223.0", "jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0", - "jsr:@std/async@1": "jsr:@std/async@1.0.2", + "jsr:@std/async@1": "jsr:@std/async@1.0.4", "jsr:@std/bytes@0.223": "jsr:@std/bytes@0.223.0", "jsr:@std/bytes@0.224": "jsr:@std/bytes@0.224.0", "jsr:@std/bytes@^0.223.0": "jsr:@std/bytes@0.223.0", @@ -19,32 +18,31 @@ "jsr:@std/encoding@0.213": "jsr:@std/encoding@0.213.1", "jsr:@std/encoding@1.0.0-rc.2": "jsr:@std/encoding@1.0.0-rc.2", "jsr:@std/encoding@^0.223.0": "jsr:@std/encoding@0.223.0", - "jsr:@std/fmt@^1.0.0-rc.1": "jsr:@std/fmt@1.0.0", - "jsr:@std/fs@1": "jsr:@std/fs@1.0.1", - "jsr:@std/fs@^1.0.0-rc.5": "jsr:@std/fs@1.0.1", + "jsr:@std/fmt@^1.0.1": "jsr:@std/fmt@1.0.1", + "jsr:@std/fs@1": "jsr:@std/fs@1.0.2", + "jsr:@std/fs@^1.0.2": "jsr:@std/fs@1.0.2", "jsr:@std/http@0": "jsr:@std/http@0.224.5", "jsr:@std/http@0.223": "jsr:@std/http@0.223.0", "jsr:@std/http@0.224": "jsr:@std/http@0.224.5", - "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", "jsr:@std/io@0.223": "jsr:@std/io@0.223.0", - "jsr:@std/io@^0.224.3": "jsr:@std/io@0.224.4", - "jsr:@std/json@^0.213.1": "jsr:@std/json@0.213.1", + "jsr:@std/io@^0.224.6": "jsr:@std/io@0.224.6", "jsr:@std/jsonc@0.213": "jsr:@std/jsonc@0.213.1", - "jsr:@std/log@0": "jsr:@std/log@0.224.5", + "jsr:@std/log@0": "jsr:@std/log@0.224.6", "jsr:@std/media-types@0.223": "jsr:@std/media-types@0.223.0", "jsr:@std/media-types@0.224": "jsr:@std/media-types@0.224.1", "jsr:@std/path@0.213": "jsr:@std/path@0.213.1", "jsr:@std/path@0.223": "jsr:@std/path@0.223.0", - "jsr:@std/path@1": "jsr:@std/path@1.0.2", - "jsr:@std/path@^1.0.2": "jsr:@std/path@1.0.2", - "jsr:@std/testing@1": "jsr:@std/testing@1.0.0", + "jsr:@std/path@1": "jsr:@std/path@1.0.3", + "jsr:@std/path@^1.0.3": "jsr:@std/path@1.0.3", "jsr:@udibo/http-error@0": "jsr:@udibo/http-error@0.8.2", "npm:@testing-library/react@16": "npm:@testing-library/react@16.0.0_@testing-library+dom@10.4.0_@types+react@18.3.3_react@18.3.1_react-dom@18.3.1__react@18.3.1", "npm:@types/node": "npm:@types/node@18.16.19", "npm:@types/react@18": "npm:@types/react@18.3.3", + "npm:@types/react@^18.3": "npm:@types/react@18.3.3", "npm:esbuild@0.23": "npm:esbuild@0.23.0", "npm:global-jsdom@24": "npm:global-jsdom@24.0.0_jsdom@24.1.1", "npm:path-to-regexp@6.2.1": "npm:path-to-regexp@6.2.1", + "npm:react": "npm:react@18.3.1", "npm:react-dom@18": "npm:react-dom@18.3.1_react@18.3.1", "npm:react-dom@18.3.1": "npm:react-dom@18.3.1_react@18.3.1", "npm:react-error-boundary@4": "npm:react-error-boundary@4.0.13_react@18.3.1", @@ -54,6 +52,7 @@ "npm:react-router-dom@6.26.0": "npm:react-router-dom@6.26.0_react@18.3.1_react-dom@18.3.1__react@18.3.1", "npm:react@18": "npm:react@18.3.1", "npm:react@18.3.1": "npm:react@18.3.1", + "npm:react@^18.3": "npm:react@18.3.1", "npm:serialize-javascript@6": "npm:serialize-javascript@6.0.2" }, "jsr": { @@ -101,14 +100,8 @@ "@std/assert@0.226.0": { "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" }, - "@std/assert@1.0.2": { - "integrity": "ccacec332958126deaceb5c63ff8b4eaf9f5ed0eac9feccf124110435e59e49c", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/async@1.0.2": { - "integrity": "36e7f0f922c843b45df546857d269f01ed4d0406aced2a6639eac325b2435e43" + "@std/async@1.0.4": { + "integrity": "373f5168a01b46ecaabc785d4e0f9ef18a010ab867af069fb905d93a9129ae5b" }, "@std/bytes@0.223.0": { "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" @@ -138,13 +131,13 @@ "@std/encoding@1.0.0-rc.2": { "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" }, - "@std/fmt@1.0.0": { - "integrity": "8a95c9fdbb61559418ccbc0f536080cf43341655e1444f9d375a66886ceaaa3d" + "@std/fmt@1.0.1": { + "integrity": "ef76c37faa7720faa8c20fd8cc74583f9b1e356dfd630c8714baa716a45856ab" }, - "@std/fs@1.0.1": { - "integrity": "d6914ca2c21abe591f733b31dbe6331e446815e513e2451b3b9e472daddfefcb", + "@std/fs@1.0.2": { + "integrity": "af57555c7a224a6f147d5cced5404692974f7a628ced8eda67e0d22d92d474ec", "dependencies": [ - "jsr:@std/path@^1.0.2" + "jsr:@std/path@^1.0.3" ] }, "@std/http@0.223.0": { @@ -160,34 +153,27 @@ "jsr:@std/encoding@1.0.0-rc.2" ] }, - "@std/internal@1.0.1": { - "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" - }, "@std/io@0.223.0": { "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", "dependencies": [ "jsr:@std/bytes@^0.223.0" ] }, - "@std/io@0.224.4": { - "integrity": "bce1151765e4e70e376039fd72c71672b4d4aae363878a5ee3e58361b81197ec" - }, - "@std/json@0.213.1": { - "integrity": "f572b1de605d07c4a5602445dac54bfc51b1fb87a3710a17aed2608bfca54e68" + "@std/io@0.224.6": { + "integrity": "eefe034a370be34daf066c8634dd645635d099bb21eccf110f0bdc28d9040891" }, "@std/jsonc@0.213.1": { "integrity": "5578f21aa583b7eb7317eed077ffcde47b294f1056bdbb9aacec407758637bfe", "dependencies": [ - "jsr:@std/assert@^0.213.1", - "jsr:@std/json" + "jsr:@std/assert@^0.213.1" ] }, - "@std/log@0.224.5": { - "integrity": "4612a45189438441bbd923a4cad1cce5c44c6c4a039195a3e8d831ce38894eee", + "@std/log@0.224.6": { + "integrity": "c34e7b69fe84b2152ce2b0a1b5e211b26e6a1ce6a6b68a217b9342fd13ed95a4", "dependencies": [ "jsr:@std/fmt", - "jsr:@std/fs@^1.0.0-rc.5", - "jsr:@std/io@^0.224.3" + "jsr:@std/fs@^1.0.2", + "jsr:@std/io@^0.224.6" ] }, "@std/media-types@0.223.0": { @@ -208,11 +194,8 @@ "jsr:@std/assert@^0.223.0" ] }, - "@std/path@1.0.2": { - "integrity": "a452174603f8c620bd278a380c596437a9eef50c891c64b85812f735245d9ec7" - }, - "@std/testing@1.0.0": { - "integrity": "27cfc06392c69c2acffe54e6d0bcb5f961cf193f519255372bd4fff1481bfef8" + "@std/path@1.0.3": { + "integrity": "cd89d014ce7eb3742f2147b990f6753ee51d95276bfc211bc50c860c1bc7df6f" }, "@udibo/http-error@0.8.2": { "integrity": "db400c3d169f308b64b7349875b164562f947eb08a9079d3a1da41f53e034907", diff --git a/docs/error-handling.md b/docs/error-handling.md index d560616..22450ee 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -2,14 +2,230 @@ - [Error handling](#error-handling) - [UI](#ui) + - [Adding error boundaries](#adding-error-boundaries) + - [Exporting an ErrorFallback component](#exporting-an-errorfallback-component) + - [Manually adding an error boundary](#manually-adding-an-error-boundary) + - [SSR](#ssr) - [API](#api) + - [Default Error Handling](#default-error-handling) + - [Creating and Throwing HttpErrors](#creating-and-throwing-httperrors) + - [Controlling Error Exposure](#controlling-error-exposure) + - [Adding Additional Error Data](#adding-additional-error-data) + - [Overriding Default Error Handling](#overriding-default-error-handling) ## UI -For UI routes, error handling typically involves using the included error -handling components and hooks. This allows you to catch and display errors in a -user-friendly manner, preventing the entire application from crashing due to a -single component error. +For UI routes, error handling typically involves using error boundaries to catch +and display errors in a user-friendly manner, preventing the entire application +from crashing due to a single component error. + +### Adding error boundaries + +There are two main ways to add error handling to UI routes, either by exporting +an `ErrorFallback` component from the route file or by manually adding an error +boundary to your component. + +#### Exporting an ErrorFallback component + +The simplest way to add error handling to a UI route is to export an +`ErrorFallback` component from the route file. The framework will automatically +wrap the route's default export with an error boundary using this fallback. + +Example: + +```tsx +import { FallbackProps, HttpError } from "@udibo/react-app"; + +export default function BlogPost() { + // ... component logic +} + +export function ErrorFallback({ error }: FallbackProps) { + return ( +
+

Error

+

{error.message}

+
+ ); +} + +// Optionally, you can specify a custom boundary name +export const boundary = "BlogPostErrorBoundary"; +``` + +In this example, if an error occurs within the `BlogPost` component, the +`ErrorFallback` component will be rendered instead. + +#### Manually adding an error boundary + +For more control over error handling, you can manually add an error boundary to +your component using the `ErrorBoundary` component or the `withErrorBoundary` +higher-order component. + +Using `ErrorBoundary`: + +```tsx +import { DefaultErrorFallback, ErrorBoundary } from "@udibo/react-app"; + +export default function Blog() { + return ( + + {/* Blog content */} + + ); +} +``` + +Using `withErrorBoundary`: + +```tsx +import { DefaultErrorFallback, withErrorBoundary } from "@udibo/react-app"; + +function Blog() { + // ... component logic +} + +export default withErrorBoundary(Blog, { + FallbackComponent: DefaultErrorFallback, + boundary: "BlogErrorBoundary", +}); +``` + +### SSR + +In the browser, any errors that occur within a route will be caught by the +nearest error boundary. When rendering on the server, the errors will have a +boundary key added to them to indicate which error boundary they should be +associated with during rendering. If a route throws an error, it will default to +the nearest route's error boundary. + +In the following example, any errors thrown in the route will automatically have +the boundary key set to the boundary for that route. If the route path is +`/blog/:id` and the UI route file doesn't export a boundary constant, any errors +thrown will have the `/blog/:id` boundary added to them. If the UI route file +does export a boundary constant, that will be used instead of the default. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id"); + } + + state.app.initialState.posts = { + [id]: getPost(id), + }; + await state.app.render(); + }); +``` + +Routes can have as many error boundaries as you need. If you want an error on +the server to be caught by a specific boundary when doing server-side rendering, +you'll need the error to be thrown with the boundary key set to the name of the +boundary you want to catch the error. This can be done automatically by using +the `errorBoundary` middleware. The following example shows how to use the +`errorBoundary` middleware to catch errors in a route. Now instead of the errors +having the routes boundary key added to them, it will have the +`BlogErrorBoundary` boundary key added to them instead. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .use(errorBoundary("BlogErrorBoundary")) + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id"); + } + + state.app.initialState.posts = { + [id]: getPost(id), + }; + await state.app.render(); + }); +``` + +Alternatively, you can throw an `HttpError` with the boundary key set to the +name of the boundary you want to catch the error. In the following example, the +invalid id error will be caught by the `BlogErrorBoundary` boundary instead of +the default boundary. Any other errors will still be caught by the route's +boundary. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id", { boundary: "BlogErrorBoundary" }); + } + + state.app.initialState.posts = { + [id]: getPost(id), + }; + await state.app.render(); + }); +``` + +If the error could come from somewhere else that doesn't throw an HttpError with +a boundary, you can catch and re-throw it as an HttpError with the correct +boundary like shown in the following example. It is doing the same thing as the +error boundary middleware, but only applying to the code within the try +statement. Any errors thrown within there will have their boundary set to +`BlogErrorBoundary`. Any errors thrown outside of there will have their boundary +set to the route's boundary. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id"); + } + + try { + state.app.initialState.posts = { + [id]: getPost(id), + }; + } catch (cause) { + const error = HttpError.from<{ boundary?: string }>(cause); + if (isDevelopment()) error.expose = true; + error.data.boundary = "BlogErrorBoundary"; + throw error; + } + await state.app.render(); + }); +``` ## API @@ -17,3 +233,101 @@ In API routes, error handling involves catching and properly formatting errors, setting appropriate HTTP status codes, and potentially logging errors for debugging purposes. The framework provides utilities to streamline this process and ensure consistent error responses across your API. + +### Default Error Handling + +By default, errors thrown in API routes are caught and handled automatically. +The response body is set to an `ErrorResponse` object representing the error. +This object typically includes: + +- `status`: The HTTP status code +- `message`: A description of the error +- `data`: Additional error details (if provided) + +These errors are also logged as API route errors, which can be useful for +debugging and monitoring purposes. + +### Creating and Throwing HttpErrors + +The framework provides an `HttpError` class that you can use to create and throw +custom errors in your API routes. Here's how you can use it: + +```typescript +import { HttpError } from "@udibo/react-app"; + +// ... + +if (someErrorCondition) { + throw new HttpError(400, "Invalid input"); +} +``` + +#### Controlling Error Exposure + +You can use the `expose` property to control whether the error message is +exposed to the client: + +```typescript +throw new HttpError(400, "Invalid input", { expose: false }); +``` + +When `expose` is set to `false`, the client will receive a generic error message +instead of the specific one you provided. This is useful for hiding sensitive +information or internal error details from users. HTTP errors with a status code +of 500 or greater are not exposed to the client by default, they will only be +exposed if you explicitly set `expose` to `true`. The inverse is true for HTTP +errors with a status code between 400 and 499, they will be exposed to the +client by default, but you can set `expose` to `false` to hide them. + +Non-HTTP errors will be converted into an HttpError with a status code of 500, +with the original error being set as the cause. The cause will be logged but not +exposed to the client. + +#### Adding Additional Error Data + +You can add extra context to your errors by including additional data: + +```typescript +throw new HttpError(400, "Form validation failed", { + field: "email", + reason: "Invalid format", +}); +``` + +This additional data will be included in the `ErrorResponse` object sent to the +client. It's important to note that this data is shared with the client, so be +careful not to include any sensitive information. + +### Overriding Default Error Handling + +If you need more control over error handling, you can override the default +behavior by adding custom middleware to the root of your API routes. Here's an +example of how to do this: + +```ts +import { Router } from "@udibo/react-app/server"; +import { ErrorResponse, HttpError } from "@udibo/react-app"; +import * as log from "@std/log"; + +export default new Router() + .use(async ({ request, response }, next) => { + try { + await next(); + } catch (cause) { + const error = HttpError.from(cause); + log.error("API Error", error); + + response.status = error.status; + const extname = path.extname(request.url.pathname); + if (error.status !== 404 || extname === "") { + response.body = new ErrorResponse(error); + } + } + }); +``` + +This middleware catches any errors thrown in subsequent middleware or route +handlers. It converts the error to an `HttpError`, sets the appropriate status +code, and formats the response body. You can customize this further to fit your +specific error handling needs, such as integrating with error tracking services +or applying different handling logic based on the error type. diff --git a/docs/getting-started.md b/docs/getting-started.md index 8056cf3..9987ca3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,6 +14,7 @@ application using the Udibo React App framework. - [routes/main.ts](#routesmaints) - [routes/main.tsx](#routesmaintsx) - [routes/index.tsx](#routesindextsx) + - [react.d.ts](#reactdts) - [Optional files](#optional-files) - [build.ts](#buildts) - [dev.ts](#devts) @@ -79,6 +80,8 @@ explains their purpose. - [routes/main.tsx](#routesmaintsx): A wrapper around the client side of the application. - [routes/index.tsx](#routesindextsx): The homepage for the application. +- [react.d.ts](#reactdts): Type definitions for React to enable autocompletion + and type checking. ### deno.jsonc @@ -132,12 +135,13 @@ Node.js-like environment. "jsxImportSourceTypes": "@types/react" }, "nodeModulesDir": true, - "lint": { - "exclude": ["public/build", "routes/_main.ts", "routes/_main.tsx"] - }, - "fmt": { - "exclude": ["public/build"] - }, + "exclude": [ + "coverage", + "node_modules", + "public/build", + "routes/_main.ts", + "routes/_main.tsx" + ], "imports": { "/": "./", "./": "./", @@ -344,6 +348,19 @@ export default function Index() { } ``` +### react.d.ts + +This file is required for Deno's LSP to recognize the types for React and to +provide autocompletions. + +```ts +declare module "react" { + // @ts-types="@types/react" + import React from "npm:react@18"; + export = React; +} +``` + ## Optional files - [build.ts](#buildts): Builds the application with your own build options. diff --git a/react.d.ts b/react.d.ts new file mode 100644 index 0000000..614f142 --- /dev/null +++ b/react.d.ts @@ -0,0 +1,5 @@ +declare module "react" { + // @ts-types="@types/react" + import React from "npm:react@18"; + export = React; +}