Handle fetch fail in client app, fix data binding in location data (#243)

* Improve error handling for server rendered routes

* Fix data binding issue for country/state codes (use alias in sql)
This commit is contained in:
2026-08-23 03:26:48 -04:00
committed by Aaron Po
parent bfd45842fe
commit 31da1d557c
10 changed files with 97 additions and 185 deletions

View File

@@ -0,0 +1,35 @@
import { isRouteErrorResponse } from 'react-router';
const RouteErrorState = ({ error }: { error: unknown }) => {
let title = 'Something went wrong';
let message = 'An unexpected error occurred.';
if (isRouteErrorResponse(error)) {
title = error.status === 503 ? 'Service unavailable' : `Error ${error.status}`;
message =
typeof error.data === 'string' && error.data ? error.data : error.statusText || message;
} else if (import.meta.env.DEV && error instanceof Error) {
message = error.message;
}
return (
<div className="min-h-[50vh] flex items-center justify-center p-4">
<div
role="alert"
className="alert alert-error alert-soft max-w-md flex-col items-start gap-2"
>
<span className="font-semibold">{title}</span>
<span>{message}</span>
<button
type="button"
className="btn btn-sm btn-outline mt-2"
onClick={() => window.location.reload()}
>
Try again
</button>
</div>
</div>
);
};
export default RouteErrorState;

View File

@@ -1,3 +1,5 @@
import { data } from 'react-router';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
interface ApiResponse<T> {
@@ -24,32 +26,49 @@ export interface Brewery {
location: BreweryLocation | null;
}
const fetchApi = async (path: string): Promise<Response> => {
try {
return await fetch(`${API_BASE_URL}${path}`);
} catch {
throw data('The brewery service is unreachable right now. Please try again in a moment.', {
status: 503,
statusText: 'Service Unavailable',
});
}
};
export const getBreweries = async (limit?: number, offset?: number): Promise<Brewery[]> => {
const params = new URLSearchParams();
if (limit !== undefined) params.set('limit', String(limit));
if (offset !== undefined) params.set('offset', String(offset));
const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`);
const res = await fetchApi(`/api/brewery?${params}`);
if (!res.ok) {
throw new Error(`Failed to load breweries (${res.status})`);
throw data(`Failed to load breweries (${res.status}).`, {
status: res.status,
statusText: res.statusText,
});
}
const data: ApiResponse<Brewery[]> = await res.json();
return data.payload;
const body: ApiResponse<Brewery[]> = await res.json();
return body.payload;
};
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`);
const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`);
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Failed to load brewery (${res.status})`);
throw data(`Failed to load brewery (${res.status}).`, {
status: res.status,
statusText: res.statusText,
});
}
const data: ApiResponse<Brewery> = await res.json();
return data.payload;
const body: ApiResponse<Brewery> = await res.json();
return body.payload;
};

View File

@@ -1,3 +1,4 @@
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import BreweryCard from '../components/BreweryCard';
import { getBreweries } from '../breweries.server';
import type { Route } from './+types/breweries';
@@ -37,3 +38,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
};
export default Breweries;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
<RouteErrorState error={error} />
);

View File

@@ -1,11 +1,12 @@
import { data, Link } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getBreweryById } from '../breweries.server';
import type { Route } from './+types/brewery-detail';
export const loader = async ({ params }: Route.LoaderArgs) => {
const brewery = await getBreweryById(params.id);
if (!brewery) {
throw data('Brewery not found.', { status: 404 });
throw data('Brewery not found.', { status: 404, statusText: 'Not Found' });
}
return { brewery };
@@ -57,3 +58,7 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
};
export default BreweryDetail;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
<RouteErrorState error={error} />
);

View File

@@ -1,26 +1,16 @@
import { useState } from 'react';
import {
biergartenThemes,
defaultThemeName,
isBiergartenTheme,
type ThemeName,
themeStorageKey,
} from '../themes';
import { useRouteLoaderData } from 'react-router';
import type { loader as rootLoader } from '../../../root';
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
const applyTheme = (theme: ThemeName) => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(themeStorageKey, theme);
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
};
const ThemeSwitcher = () => {
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
if (typeof window === 'undefined') {
return defaultThemeName;
}
const savedTheme = localStorage.getItem(themeStorageKey);
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
});
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(rootTheme);
const activeTheme =
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];

View File

@@ -8,7 +8,7 @@ export interface ThemeOption {
}
export const defaultThemeName: ThemeName = 'biergarten-lager';
export const themeStorageKey = 'biergarten-theme';
export const themeCookieName = 'biergarten-theme';
export const biergartenThemes: ThemeOption[] = [
{
@@ -36,3 +36,9 @@ export const biergartenThemes: ThemeOption[] = [
export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => {
return biergartenThemes.some((theme) => theme.value === value);
};
export const parseThemeCookie = (cookieHeader: string | null): ThemeName => {
const match = cookieHeader?.match(new RegExp(`(?:^|; )${themeCookieName}=([^;]*)`));
const value = match ? decodeURIComponent(match[1]) : null;
return isBiergartenTheme(value) ? value : defaultThemeName;
};

View File

@@ -5,6 +5,7 @@ import {
Outlet,
Scripts,
ScrollRestoration,
useRouteLoaderData,
} from 'react-router';
import type { Route } from './+types/root';
@@ -12,16 +13,7 @@ import './app.css';
import Navbar from './components/ui/navbar/Navbar';
import ToastProvider from './components/ui/toast/ToastProvider';
import { getOptionalAuth } from './features/auth/auth.server';
import { themeStorageKey } from './features/theme/themes';
const themeInitScript = `
(function () {
try {
var theme = localStorage.getItem(${JSON.stringify(themeStorageKey)});
if (theme) document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
`;
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
@@ -38,18 +30,20 @@ export const links: Route.LinksFunction = () => [
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request);
return { auth };
const theme = parseThemeCookie(request.headers.get('Cookie'));
return { auth, theme };
};
export const Layout = ({ children }: { children: React.ReactNode }) => {
const theme = useRouteLoaderData<typeof loader>('root')?.theme ?? defaultThemeName;
return (
<html lang="en">
<html lang="en" data-theme={theme}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>
{children}