Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-server): client error boundary for server error #211

Merged
merged 41 commits into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
0a99c1c
chore: tweak NavMenu
hi-ogawa Mar 20, 2024
896b4dd
wip: error page
hi-ogawa Mar 20, 2024
f404ae8
Merge branch 'main' into feat-react-server-error-page
hi-ogawa Mar 20, 2024
a799f57
chore: tweak demo
hi-ogawa Mar 20, 2024
242cdcc
Merge remote-tracking branch 'origin/feat-react-server-error-page' in…
hi-ogawa Mar 20, 2024
c4facf5
chore: setup demo
hi-ogawa Mar 20, 2024
b641d47
wip: client error boundary
hi-ogawa Mar 20, 2024
0352c1f
wip
hi-ogawa Mar 20, 2024
cc38a39
chore: todo
hi-ogawa Mar 21, 2024
08fea24
Merge branch 'main' into feat-react-server-error-page
hi-ogawa Mar 21, 2024
8702d79
wip: two-pass SSR for error re-rendering
hi-ogawa Mar 21, 2024
89fbfad
wip: full CSR fallback
hi-ogawa Mar 21, 2024
5a2bd00
chore: cleanup
hi-ogawa Mar 21, 2024
ca4d259
wip: auto error boundary reset on url change
hi-ogawa Mar 21, 2024
57d4d7f
wip: status code
hi-ogawa Mar 21, 2024
42343ee
feat: handle status code
hi-ogawa Mar 21, 2024
62c5e19
refactor: unused
hi-ogawa Mar 21, 2024
8125760
test: e2e
hi-ogawa Mar 21, 2024
783e986
test: no only
hi-ogawa Mar 21, 2024
3c3813f
fix: handle RSC render error digest
hi-ogawa Mar 21, 2024
307e72f
test: e2e browser error
hi-ogawa Mar 21, 2024
89d1e8c
refactor: move code
hi-ogawa Mar 21, 2024
640858a
chore: comment
hi-ogawa Mar 21, 2024
6df5a00
test: refactor checkNoError
hi-ogawa Mar 21, 2024
675755e
wip: error page convention
hi-ogawa Mar 21, 2024
4b391fd
chore: comment
hi-ogawa Mar 21, 2024
787e69e
chore: tweak pokemon demo
hi-ogawa Mar 21, 2024
79fc37f
chore: noscript to show status on server error
hi-ogawa Mar 21, 2024
832aa24
feat: add DefaultRootErrorPage
hi-ogawa Mar 22, 2024
9912dd4
test: e2e DefaultRootErrorPage
hi-ogawa Mar 22, 2024
a2276f6
fix: workaround self-reference import via "entry-react-server-wrapper"
hi-ogawa Mar 22, 2024
253b2a7
refactor: refactor ENTRY_CLIENT_WRAPPER
hi-ogawa Mar 22, 2024
b3abadd
chore: indent
hi-ogawa Mar 22, 2024
9d5e53c
Merge branch 'main' into feat-react-server-error-page
hi-ogawa Mar 22, 2024
c02d051
chore: unused
hi-ogawa Mar 22, 2024
70ebcea
chore: tweak pokemon demo
hi-ogawa Mar 22, 2024
1abf3fa
demo: extend ReactServerErrorContext
hi-ogawa Mar 22, 2024
851563b
chore: comment
hi-ogawa Mar 22, 2024
c10e7f6
chore: tweak
hi-ogawa Mar 22, 2024
baf2952
e2e: test custom error
hi-ogawa Mar 22, 2024
47610fc
chore: release
hi-ogawa Mar 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading