Skip to content
This repository was archived by the owner on Jul 6, 2025. It is now read-only.

Commit b8abca2

Browse files
committed
Improve error handling
1 parent ed82da4 commit b8abca2

File tree

9 files changed

+324
-162
lines changed

9 files changed

+324
-162
lines changed

framework/react/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const RouterContext = createContext<RouterContextProps>({
1010

1111
export type DataContextProps = {
1212
dataUrl: string;
13-
dataCache: Map<string, { data?: unknown; dataCacheTtl?: number; dataExpires?: number }>;
13+
dataCache: Map<string, { data?: unknown; dataCacheTtl?: number; dataExpires?: number; error?: Error }>;
1414
ssrHeadCollection?: string[];
1515
};
1616

framework/react/data.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,32 @@
11
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
2+
import { FetchError } from "../../lib/helpers.ts";
23
import { DataContext } from "./context.ts";
34

45
export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
6+
57
export type UpdateStrategy<T> = "none" | "replace" | {
68
optimisticUpdate?: (data: T) => T;
7-
onFailure?: (error: FetchError) => void;
9+
onFailure?: (error: Error) => void;
810
replace?: boolean;
911
};
1012

11-
class FetchError extends Error {
12-
constructor(public method: HttpMethod, public status: number, public message: string) {
13-
super(message);
14-
}
15-
}
16-
1713
export const useData = <T = unknown>(): {
1814
data: T;
1915
isMutating?: HttpMethod;
2016
mutation: typeof mutation;
2117
reload: (signal?: AbortSignal) => Promise<void>;
2218
} => {
2319
const { dataUrl, dataCache } = useContext(DataContext);
24-
const [data, setData] = useState(() => dataCache.get(dataUrl)?.data as T);
20+
const [data, setData] = useState(() => {
21+
const cached = dataCache.get(dataUrl);
22+
if (cached) {
23+
if (cached.error) {
24+
throw cached.error;
25+
}
26+
return cached.data as T;
27+
}
28+
throw new Error("Data not found");
29+
});
2530
const [isMutating, setIsMutating] = useState<HttpMethod>();
2631
const action = useCallback(async (method: HttpMethod, fetcher: Promise<Response>, update: UpdateStrategy<T>) => {
2732
const updateIsObject = update && typeof update === "object" && update !== null;
@@ -47,8 +52,7 @@ export const useData = <T = unknown>(): {
4752
if (optimistic && rollbackData !== undefined) {
4853
setData(rollbackData);
4954
if (update.onFailure) {
50-
const error = new FetchError(method, res.status, res.statusText);
51-
update.onFailure(error);
55+
update.onFailure(await FetchError.fromResponse(res));
5256
}
5357
} else {
5458
setIsMutating(undefined);
@@ -76,12 +80,11 @@ export const useData = <T = unknown>(): {
7680
const dataCacheTtl = dataCache.get(dataUrl)?.dataCacheTtl;
7781
dataCache.set(dataUrl, { data, dataCacheTtl, dataExpires: Date.now() + (dataCacheTtl || 1) * 1000 });
7882
setData(data);
79-
} catch (err) {
83+
} catch (_) {
8084
if (optimistic && rollbackData !== undefined) {
8185
setData(rollbackData);
8286
if (update.onFailure) {
83-
const error = new FetchError(method, res.status, err.message);
84-
update.onFailure(error);
87+
update.onFailure(new FetchError(500, {}, "Data must be valid JSON"));
8588
}
8689
} else {
8790
setIsMutating(undefined);
@@ -97,14 +100,14 @@ export const useData = <T = unknown>(): {
97100
try {
98101
const res = await fetch(dataUrl, { headers: { "Accept": "application/json" }, signal, redirect: "manual" });
99102
if (res.status >= 400) {
100-
throw new FetchError("get", res.status, await res.text());
103+
throw await FetchError.fromResponse(res);
101104
}
102105
if (res.status >= 300) {
103106
const redirectUrl = res.headers.get("Location");
104107
if (redirectUrl) {
105108
location.href = redirectUrl;
106109
}
107-
return;
110+
throw new FetchError(500, {}, "Missing the `Location` header");
108111
}
109112
if (res.ok) {
110113
const data = await res.json();
@@ -114,10 +117,10 @@ export const useData = <T = unknown>(): {
114117
dataCache.set(dataUrl, { data, dataExpires });
115118
setData(data);
116119
} else {
117-
throw new FetchError("get", res.status, await res.text());
120+
throw new FetchError(500, {}, "Data must be valid JSON");
118121
}
119-
} catch (err) {
120-
throw new FetchError("get", 0, err.message);
122+
} catch (error) {
123+
throw error;
121124
}
122125
}, [dataUrl]);
123126
const mutation = useMemo(() => {

framework/react/router.ts

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { FC, ReactElement, ReactNode } from "react";
2-
import { createElement, useCallback, useContext, useEffect, useMemo, useState } from "react";
3-
import { matchRoutes } from "../../lib/helpers.ts";
2+
import { Component, createElement, useContext, useEffect, useMemo, useState } from "react";
3+
import { FetchError } from "../../lib/helpers.ts";
4+
import type { Route, RouteMeta, RouteModule } from "../../lib/route.ts";
5+
import { matchRoutes } from "../../lib/route.ts";
46
import { URLPatternCompat } from "../../lib/urlpattern.ts";
5-
import type { RenderModule, Route, RouteMeta, SSRContext } from "../../server/types.ts";
7+
import type { SSRContext } from "../../server/types.ts";
68
import events from "../core/events.ts";
79
import { redirect } from "../core/redirect.ts";
810
import { DataContext, ForwardPropsContext, RouterContext } from "./context.ts";
@@ -13,22 +15,29 @@ export type RouterProps = {
1315

1416
export const Router: FC<RouterProps> = ({ ssrContext }) => {
1517
const [url, setUrl] = useState(() => ssrContext?.url || new URL(window.location.href));
16-
const [modules, setModules] = useState(() => ssrContext?.modules || loadSSRModulesFromTag());
18+
const [modules, setModules] = useState(() => ssrContext?.routeModules || loadSSRModulesFromTag());
1719
const dataCache = useMemo(() => {
18-
const cache = new Map<string, { data?: unknown; dataCacheTtl?: number; dataExpires?: number }>();
19-
modules.forEach(({ url, data, dataCacheTtl }) => {
20+
const cache = new Map<
21+
string,
22+
{ error?: Error; data?: unknown; dataCacheTtl?: number; dataExpires?: number }
23+
>();
24+
modules.forEach(({ url, data, dataCacheTtl, error }) => {
2025
cache.set(url.pathname + url.search, {
26+
error,
2127
data,
2228
dataCacheTtl,
2329
dataExpires: Date.now() + (dataCacheTtl || 1) * 1000,
2430
});
2531
});
2632
return cache;
2733
}, []);
28-
const createDataDriver = useCallback((modules: RenderModule[]): ReactElement => {
34+
const createRouteEl = (modules: RouteModule[]): ReactElement => {
35+
const ErrorBoundaryHandler: undefined | FC<{ error: Error }> = ssrContext?.errorBoundaryModule?.defaultExport ||
36+
// deno-lint-ignore no-explicit-any
37+
(window as any).__ERROR_BOUNDARY_HANDLER;
2938
const currentModule = modules[0];
3039
const dataUrl = currentModule.url.pathname + currentModule.url.search;
31-
return createElement(
40+
const el = createElement(
3241
DataContext.Provider,
3342
{
3443
value: {
@@ -41,22 +50,22 @@ export const Router: FC<RouterProps> = ({ ssrContext }) => {
4150
? createElement(
4251
currentModule.defaultExport as FC,
4352
null,
44-
modules.length > 1 ? createDataDriver(modules.slice(1)) : undefined,
53+
modules.length > 1 ? createRouteEl(modules.slice(1)) : undefined,
4554
)
4655
: createElement(Err, {
4756
status: 400,
4857
statusText: "missing default export as a valid React component",
4958
}),
5059
);
51-
}, []);
52-
const dataDirver = useMemo<ReactElement | null>(
60+
if (ErrorBoundaryHandler) {
61+
return createElement(ErrorBoundary, { Handler: ErrorBoundaryHandler }, el);
62+
}
63+
return el;
64+
};
65+
const routeEl = useMemo<ReactElement | null>(
5366
() =>
54-
modules.length > 0
55-
? createDataDriver(modules)
56-
: createElement(Err, { status: 404, statusText: "page not found" }),
57-
[
58-
modules,
59-
],
67+
modules.length > 0 ? createRouteEl(modules) : createElement(Err, { status: 404, statusText: "page not found" }),
68+
[modules],
6069
);
6170

6271
useEffect(() => {
@@ -82,15 +91,23 @@ export const Router: FC<RouterProps> = ({ ssrContext }) => {
8291
return {};
8392
}
8493
if (res.status >= 400) {
85-
const message = await res.text();
86-
console.warn(`prefetchData: ${res.status} ${message}`);
94+
const error = await FetchError.fromResponse(res);
95+
dataCache.set(dataUrl, {
96+
error,
97+
dataExpires: Date.now() + 1000,
98+
});
8799
return {};
88100
}
89101
if (res.status >= 300) {
90102
const redirectUrl = res.headers.get("Location");
91103
if (redirectUrl) {
92104
location.href = redirectUrl;
93105
}
106+
const error = new FetchError(500, {}, "Missing the `Location` header");
107+
dataCache.set(dataUrl, {
108+
error,
109+
dataExpires: Date.now() + 1000,
110+
});
94111
return {};
95112
}
96113
const data = await res.json();
@@ -121,7 +138,7 @@ export const Router: FC<RouterProps> = ({ ssrContext }) => {
121138
const matches = matchRoutes(url, routes);
122139
const modules = await Promise.all(matches.map(async ([ret, meta]) => {
123140
const { filename } = meta;
124-
const rmod: RenderModule = {
141+
const rmod: RouteModule = {
125142
url: new URL(ret.pathname.input + url.search, url.href),
126143
filename,
127144
};
@@ -161,9 +178,28 @@ export const Router: FC<RouterProps> = ({ ssrContext }) => {
161178
};
162179
}, []);
163180

164-
return createElement(RouterContext.Provider, { value: { url } }, dataDirver);
181+
return createElement(RouterContext.Provider, { value: { url } }, routeEl);
165182
};
166183

184+
class ErrorBoundary extends Component<{ Handler: FC<{ error: Error }> }, { error: Error | null }> {
185+
constructor(props: { Handler: FC<{ error: Error }> }) {
186+
super(props);
187+
this.state = { error: null };
188+
}
189+
190+
static getDerivedStateFromError(error: Error) {
191+
return { error };
192+
}
193+
194+
render() {
195+
if (this.state.error) {
196+
return createElement(this.props.Handler, { error: this.state.error });
197+
}
198+
199+
return this.props.children;
200+
}
201+
}
202+
167203
function Err({ status, statusText }: { status: number; statusText: string }) {
168204
return createElement(
169205
"div",
@@ -218,7 +254,7 @@ function loadRoutesFromTag(): Route[] {
218254
return [];
219255
}
220256

221-
function loadSSRModulesFromTag(): RenderModule[] {
257+
function loadSSRModulesFromTag(): RouteModule[] {
222258
const ROUTE_MODULES = getRouteModules();
223259
const el = window.document?.getElementById("ssr-modules");
224260
if (el) {

lib/helpers.ts

Lines changed: 19 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,30 @@
1-
import type { Route, RouteMeta } from "../server/types.ts";
2-
import { createStaticURLPatternResult, type URLPatternResult } from "./urlpattern.ts";
31
import util from "./util.ts";
42

53
export const builtinModuleExts = ["tsx", "ts", "mts", "jsx", "js", "mjs"];
64

7-
/** match routes against the given url */
8-
export function matchRoutes(url: URL, routes: Route[]): [ret: URLPatternResult, route: RouteMeta][] {
9-
let { pathname } = url;
10-
if (pathname !== "/") {
11-
pathname = util.trimSuffix(url.pathname, "/");
5+
export class FetchError extends Error {
6+
constructor(
7+
public status: number,
8+
public details: Record<string, unknown>,
9+
message: string,
10+
opts?: ErrorOptions,
11+
) {
12+
super(message, opts);
1213
}
13-
const matches: [ret: URLPatternResult, route: RouteMeta][] = [];
14-
if (routes.length > 0) {
15-
routes.forEach(([pattern, meta]) => {
16-
const ret = pattern.exec({ host: url.host, pathname });
17-
if (ret) {
18-
matches.push([ret, meta]);
19-
// find the nesting index of the route
20-
if (meta.nesting && meta.pattern.pathname !== "/_app") {
21-
for (const [p, m] of routes) {
22-
const [_, name] = util.splitBy(m.pattern.pathname, "/", true);
23-
if (!name.startsWith(":")) {
24-
const ret = p.exec({ host: url.host, pathname: pathname + "/index" });
25-
if (ret) {
26-
matches.push([ret, m]);
27-
break;
28-
}
29-
}
30-
}
31-
}
32-
} else if (meta.nesting) {
33-
const parts = util.splitPath(pathname);
34-
for (let i = parts.length - 1; i > 0; i--) {
35-
const pathname = "/" + parts.slice(0, i).join("/");
36-
const ret = pattern.exec({ host: url.host, pathname });
37-
if (ret) {
38-
matches.push([ret, meta]);
39-
break;
40-
}
41-
}
42-
}
43-
});
44-
if (matches.filter(([_, meta]) => !meta.nesting).length === 0) {
45-
for (const [_, meta] of routes) {
46-
if (meta.pattern.pathname === "/_404") {
47-
matches.push([createStaticURLPatternResult(url.host, "/_404"), meta]);
48-
break;
49-
}
50-
}
51-
}
52-
if (matches.length > 0) {
53-
for (const [_, meta] of routes) {
54-
if (meta.pattern.pathname === "/_app") {
55-
matches.unshift([createStaticURLPatternResult(url.host, "/_app"), meta]);
56-
break;
57-
}
14+
15+
static async fromResponse(res: Response): Promise<FetchError> {
16+
let message = res.statusText;
17+
let details: Record<string, unknown> = {};
18+
if (res.headers.get("content-type")?.startsWith("application/json")) {
19+
details = await res.json();
20+
if (typeof details.message === "string") {
21+
message = details.message;
5822
}
23+
} else {
24+
message = await res.text();
5925
}
26+
return new FetchError(res.status, details, message);
6027
}
61-
return matches;
6228
}
6329

6430
/**

0 commit comments

Comments
 (0)