Skip to content

Commit

Permalink
feat(react-server): client error boundary for server error (#211)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Mar 22, 2024
1 parent 42bb714 commit 6d4cbfe
Show file tree
Hide file tree
Showing 27 changed files with 473 additions and 83 deletions.
35 changes: 31 additions & 4 deletions packages/react-server/examples/basic/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,39 @@ test("navigation", async ({ page }) => {
await checkClientState();
});

test("404", async ({ page }) => {
checkNoError(page);

test("error", async ({ page }) => {
const res = await page.goto("/test/not-found");
expect(res?.status()).toBe(404);
await page.getByText("Not Found: /test/not-found").click();

await page.getByText("hydrated: true").click();
await page.getByText(`server error: {"status":404}`).click();

const checkClientState = await setupCheckClientState(page);

await page.getByRole("link", { name: "/test/error" }).click();
await page.getByRole("link", { name: "Server 500" }).click();
await page.getByText('server error: {"status":500}').click();

await page.getByRole("link", { name: "/test/error" }).click();
await page.getByRole("link", { name: "Server Custom" }).click();
await page
.getByText('server error: {"status":403,"customMessage":"hello"}')
.click();

await page.getByRole("link", { name: "/test/error" }).click();
await page.getByRole("link", { name: "Browser" }).click();
await page.getByText("server error: (N/A)").click();

await page.getByRole("link", { name: "/test/other" }).click();
await page.getByRole("heading", { name: "Other Page" }).click();

await checkClientState();
});

test("DefaultRootErrorPage", async ({ page }) => {
const res = await page.goto("/not-found");
expect(res?.status()).toBe(404);
await page.getByText("404 Not Found").click();
});

test("rsc hmr @dev", async ({ page }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import type { ErrorRouteProps } from "@hiogawa/react-server/server";

export default function ErrorPage(props: ErrorRouteProps) {
return <div>{props.serverError?.pokemonError || "Unknown error"}</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Link } from "@hiogawa/react-server/client";
import { type LayoutRouteProps } from "@hiogawa/react-server/server";

export default async function Layout(props: LayoutRouteProps) {
return (
<div className="flex flex-col items-center gap-4 p-4">
<Link href="/demo/waku_02" className="antd-btn antd-btn-default px-2">
Home
</Link>
{props.children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,41 @@
import { Link } from "@hiogawa/react-server/client";
import type { PageRouteProps } from "@hiogawa/react-server/server";
import { type PageRouteProps, createError } from "@hiogawa/react-server/server";
import { tinyassert } from "@hiogawa/utils";
import { fetchPokemons } from "../_utils";

// extend server error to include detail
declare module "@hiogawa/react-server/server" {
interface ReactServerErrorContext {
pokemonError?: string;
}
}

export default async function Page(props: PageRouteProps) {
const pokemons = await fetchPokemons();
tinyassert("pokemon" in props.match.params);

const slug = props.match.params["pokemon"];
const e = pokemons.find((e) => e.slug === slug);
if (!e) {
throw createError({ status: 404, pokemonError: `Not found : ${slug}` });
}

return (
<div className="flex flex-col items-center gap-4 p-4">
<Link href="/demo/waku_02" className="antd-btn antd-btn-default px-2">
Home
</Link>

{/* TODO: not found error convention? */}
{!e && <>Not Found : {slug}</>}

{e && (
<div className="flex flex-col items-center">
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${e.id}.png`}
alt={e.slug}
className="w-50 aspect-square"
/>
<div className="flex flex-col items-center gap-0.5 text-lg">
<span>{e.name.english}</span>
<span>{e.name.japanese}</span>
<span>Types: {e.type.join(", ")}</span>
{Object.entries(e.base).map(([k, v]) => (
<div key={k}>
{k}: {v}
</div>
))}
<div className="flex flex-col items-center">
<img
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${e.id}.png`}
alt={e.slug}
className="w-50 aspect-square"
/>
<div className="flex flex-col items-center gap-0.5 text-lg">
<span>{e.name.english}</span>
<span>{e.name.japanese}</span>
<span>Types: {e.type.join(", ")}</span>
{Object.entries(e.base).map(([k, v]) => (
<div key={k}>
{k}: {v}
</div>
</div>
)}
))}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { __global } from "@hiogawa/react-server";

// TODO: server action + redirect
export function SearchInput() {
return (
<form
onSubmit={(e) => {
e.preventDefault();
const q = e.currentTarget["q"].value;
if (typeof q === "string") {
__global.history.push(`/demo/waku_02/${q.toLowerCase()}`);
}
}}
>
<input name="q" className="antd-input px-2" placeholder="Search..." />
</form>
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SearchInput } from "./_client";

export default async function Layout(props: React.PropsWithChildren) {
return (
<div className="flex flex-col items-center gap-2">
Expand All @@ -11,6 +13,7 @@ export default async function Layout(props: React.PropsWithChildren) {
Waku
</a>
</h2>
<SearchInput />
{props.children}
</div>
);
Expand Down
15 changes: 15 additions & 0 deletions packages/react-server/examples/basic/src/routes/test/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";

import type { ErrorRouteProps } from "@hiogawa/react-server/server";

export default function ErrorPage(props: ErrorRouteProps) {
return (
<div className="flex flex-col gap-2">
<h4>ErrorPage</h4>
<div>
server error:{" "}
{props.serverError ? JSON.stringify(props.serverError) : "(N/A)"}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import React from "react";

export function ClinetPage() {
React.useEffect(() => {
throw new Error("boom!");
}, []);
return <div>Error on Effect</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ClinetPage } from "./_client";

export default function Page() {
return <ClinetPage />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Link } from "@hiogawa/react-server/client";

export default async function Page() {
return (
<div className="flex gap-2 p-2">
<Link
className="antd-btn antd-btn-default px-2"
href="/test/error/server?500"
>
Server 500
</Link>
<Link
className="antd-btn antd-btn-default px-2"
href="/test/error/server?custom"
>
Server Custom
</Link>
<Link
className="antd-btn antd-btn-default px-2"
href="/test/error/browser"
>
Browser
</Link>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type PageRouteProps, createError } from "@hiogawa/react-server/server";

declare module "@hiogawa/react-server/server" {
interface ReactServerErrorContext {
customMessage?: string;
}
}

export default function Page(props: PageRouteProps) {
const url = new URL(props.request.url);
if (url.searchParams.has("custom")) {
throw createError({ status: 403, customMessage: "hello" });
}
throw new Error("boom!");
}
10 changes: 7 additions & 3 deletions packages/react-server/examples/basic/src/routes/test/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { LayoutRouteProps } from "@hiogawa/react-server/server";
import { NavMenu } from "../../components/nav-menu";
import { Hydrated } from "./_client";

export default async function Layout(props: React.PropsWithChildren) {
export default async function Layout(props: LayoutRouteProps) {
return (
<div className="flex flex-col gap-2">
<h2 className="text-lg">Test</h2>
Expand All @@ -14,11 +15,14 @@ export default async function Layout(props: React.PropsWithChildren) {
"/test/deps",
"/test/head",
"/test/css",
"/test/error",
"/test/not-found",
]}
/>
<input className="antd-input w-sm px-2" placeholder="test-input" />
<Hydrated />
<div className="flex items-center gap-2 w-sm text-sm">
<input className="antd-input px-2" placeholder="test-input" />
<Hydrated />
</div>
{props.children}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hiogawa/react-server",
"version": "0.1.0-pre.8",
"version": "0.1.0-pre.9",
"license": "MIT",
"type": "module",
"exports": {
Expand Down
4 changes: 4 additions & 0 deletions packages/react-server/src/client-internal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"use client";

export { createServerReference } from "./lib/shared";
export {
ErrorBoundary,
DefaultRootErrorPage,
} from "./lib/components/error-boundary";
28 changes: 22 additions & 6 deletions packages/react-server/src/entry/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,21 @@ export async function start() {
return React.use(rsc);
}

reactDomClient.hydrateRoot(
document,
<React.StrictMode>
<Root />
</React.StrictMode>,
);
// full client render on SSR error
if (document.documentElement.dataset["noHydate"]) {
reactDomClient.createRoot(document).render(
<React.StrictMode>
<Root />
</React.StrictMode>,
);
} else {
reactDomClient.hydrateRoot(
document,
<React.StrictMode>
<Root />
</React.StrictMode>,
);
}

// custom event for RSC reload
if (import.meta.hot) {
Expand All @@ -87,3 +96,10 @@ export async function start() {
});
}
}

declare module "react-dom/client" {
// TODO: full document CSR works fine?
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_EXPERIMENTAL_CREATE_ROOT_CONTAINERS {
Document: Document;
}
}
Loading

0 comments on commit 6d4cbfe

Please sign in to comment.