diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 46e79adb3..aaca7f03e 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -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 }) => { diff --git a/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/error.tsx b/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/error.tsx new file mode 100644 index 000000000..1feac9d3d --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import type { ErrorRouteProps } from "@hiogawa/react-server/server"; + +export default function ErrorPage(props: ErrorRouteProps) { + return
{props.serverError?.pokemonError || "Unknown error"}
; +} diff --git a/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/layout.tsx b/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/layout.tsx new file mode 100644 index 000000000..f3a45cd0e --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/layout.tsx @@ -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 ( +
+ + Home + + {props.children} +
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/page.tsx b/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/page.tsx index ae215941c..1b5cf8d49 100644 --- a/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/page.tsx +++ b/packages/react-server/examples/basic/src/routes/demo/waku_02/[pokemon]/page.tsx @@ -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 ( -
- - Home - - - {/* TODO: not found error convention? */} - {!e && <>Not Found : {slug}} - - {e && ( -
- {e.slug} -
- {e.name.english} - {e.name.japanese} - Types: {e.type.join(", ")} - {Object.entries(e.base).map(([k, v]) => ( -
- {k}: {v} -
- ))} +
+ {e.slug} +
+ {e.name.english} + {e.name.japanese} + Types: {e.type.join(", ")} + {Object.entries(e.base).map(([k, v]) => ( +
+ {k}: {v}
-
- )} + ))} +
); } diff --git a/packages/react-server/examples/basic/src/routes/demo/waku_02/_client.tsx b/packages/react-server/examples/basic/src/routes/demo/waku_02/_client.tsx new file mode 100644 index 000000000..e26c4d21f --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/demo/waku_02/_client.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { __global } from "@hiogawa/react-server"; + +// TODO: server action + redirect +export function SearchInput() { + return ( +
{ + e.preventDefault(); + const q = e.currentTarget["q"].value; + if (typeof q === "string") { + __global.history.push(`/demo/waku_02/${q.toLowerCase()}`); + } + }} + > + +
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/demo/waku_02/layout.tsx b/packages/react-server/examples/basic/src/routes/demo/waku_02/layout.tsx index 29c3fd1b8..45756d56c 100644 --- a/packages/react-server/examples/basic/src/routes/demo/waku_02/layout.tsx +++ b/packages/react-server/examples/basic/src/routes/demo/waku_02/layout.tsx @@ -1,3 +1,5 @@ +import { SearchInput } from "./_client"; + export default async function Layout(props: React.PropsWithChildren) { return (
@@ -11,6 +13,7 @@ export default async function Layout(props: React.PropsWithChildren) { Waku + {props.children}
); diff --git a/packages/react-server/examples/basic/src/routes/test/error.tsx b/packages/react-server/examples/basic/src/routes/test/error.tsx new file mode 100644 index 000000000..6538d8f1b --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/error.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { ErrorRouteProps } from "@hiogawa/react-server/server"; + +export default function ErrorPage(props: ErrorRouteProps) { + return ( +
+

ErrorPage

+
+ server error:{" "} + {props.serverError ? JSON.stringify(props.serverError) : "(N/A)"} +
+
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/test/error/browser/_client.tsx b/packages/react-server/examples/basic/src/routes/test/error/browser/_client.tsx new file mode 100644 index 000000000..896a95f8e --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/error/browser/_client.tsx @@ -0,0 +1,10 @@ +"use client"; + +import React from "react"; + +export function ClinetPage() { + React.useEffect(() => { + throw new Error("boom!"); + }, []); + return
Error on Effect
; +} diff --git a/packages/react-server/examples/basic/src/routes/test/error/browser/page.tsx b/packages/react-server/examples/basic/src/routes/test/error/browser/page.tsx new file mode 100644 index 000000000..7d562a747 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/error/browser/page.tsx @@ -0,0 +1,5 @@ +import { ClinetPage } from "./_client"; + +export default function Page() { + return ; +} diff --git a/packages/react-server/examples/basic/src/routes/test/error/page.tsx b/packages/react-server/examples/basic/src/routes/test/error/page.tsx new file mode 100644 index 000000000..1e006a6b6 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/error/page.tsx @@ -0,0 +1,26 @@ +import { Link } from "@hiogawa/react-server/client"; + +export default async function Page() { + return ( +
+ + Server 500 + + + Server Custom + + + Browser + +
+ ); +} diff --git a/packages/react-server/examples/basic/src/routes/test/error/server/page.tsx b/packages/react-server/examples/basic/src/routes/test/error/server/page.tsx new file mode 100644 index 000000000..391eae254 --- /dev/null +++ b/packages/react-server/examples/basic/src/routes/test/error/server/page.tsx @@ -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!"); +} diff --git a/packages/react-server/examples/basic/src/routes/test/layout.tsx b/packages/react-server/examples/basic/src/routes/test/layout.tsx index de3f272e4..26bc099d2 100644 --- a/packages/react-server/examples/basic/src/routes/test/layout.tsx +++ b/packages/react-server/examples/basic/src/routes/test/layout.tsx @@ -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 (

Test

@@ -14,11 +15,14 @@ export default async function Layout(props: React.PropsWithChildren) { "/test/deps", "/test/head", "/test/css", + "/test/error", "/test/not-found", ]} /> - - +
+ + +
{props.children}
); diff --git a/packages/react-server/package.json b/packages/react-server/package.json index 3aa34bdd4..1598cd02e 100644 --- a/packages/react-server/package.json +++ b/packages/react-server/package.json @@ -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": { diff --git a/packages/react-server/src/client-internal.ts b/packages/react-server/src/client-internal.ts index 6805e3c83..9d9792c86 100644 --- a/packages/react-server/src/client-internal.ts +++ b/packages/react-server/src/client-internal.ts @@ -1,3 +1,7 @@ "use client"; export { createServerReference } from "./lib/shared"; +export { + ErrorBoundary, + DefaultRootErrorPage, +} from "./lib/components/error-boundary"; diff --git a/packages/react-server/src/entry/browser.tsx b/packages/react-server/src/entry/browser.tsx index 84b4d9348..2960675aa 100644 --- a/packages/react-server/src/entry/browser.tsx +++ b/packages/react-server/src/entry/browser.tsx @@ -72,12 +72,21 @@ export async function start() { return React.use(rsc); } - reactDomClient.hydrateRoot( - document, - - - , - ); + // full client render on SSR error + if (document.documentElement.dataset["noHydate"]) { + reactDomClient.createRoot(document).render( + + + , + ); + } else { + reactDomClient.hydrateRoot( + document, + + + , + ); + } // custom event for RSC reload if (import.meta.hot) { @@ -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; + } +} diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index f912c03b7..f7306b21c 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -1,5 +1,7 @@ import { objectMapKeys } from "@hiogawa/utils"; import reactServerDomServer from "react-server-dom-webpack/server.edge"; +import { debug } from "../lib/debug"; +import { ReactServerDigestError, createError } from "../lib/error"; import { __global } from "../lib/global"; import { generateRouteTree, matchRoute, renderMatchRoute } from "../lib/router"; import { createBundlerConfig } from "../lib/rsc"; @@ -18,7 +20,6 @@ export type ReactServerHandlerResult = | Response | { stream: ReadableStream; - status: number; }; export const handler: ReactServerHandler = async ({ request }) => { @@ -36,7 +37,7 @@ export const handler: ReactServerHandler = async ({ request }) => { const rscOnlyRequest = unwrapRscRequest(request); // rsc - const { stream, status } = render({ + const { stream } = await render({ request: rscOnlyRequest ?? request, }); if (rscOnlyRequest) { @@ -47,20 +48,33 @@ export const handler: ReactServerHandler = async ({ request }) => { }); } - return { stream, status }; + return { stream }; }; // // render RSC // -function render({ request }: { request: Request }) { - const result = router.run(request); +async function render({ request }: { request: Request }) { + const result = await router.run(request); const stream = reactServerDomServer.renderToReadableStream( result.node, createBundlerConfig(), + { + onError(error, errorInfo) { + debug.rsc("[reactServerDomServer.renderToReadableStream]", { + error, + errorInfo, + }); + const serverError = + error instanceof ReactServerDigestError + ? error + : createError({ status: 500 }); + return serverError.digest; + }, + }, ); - return { stream, status: result.match.notFound ? 404 : 200 }; + return { stream }; } // @@ -72,7 +86,7 @@ const router = createRouter(); function createRouter() { // for now hard code /src/routes as convention const glob = import.meta.glob( - "/src/routes/**/(page|layout).(js|jsx|ts|tsx)", + "/src/routes/**/(page|layout|error).(js|jsx|ts|tsx)", { eager: true, }, @@ -81,13 +95,10 @@ function createRouter() { objectMapKeys(glob, (_v, k) => k.slice("/src/routes".length)), ); - function run(request: Request) { + async function run(request: Request) { const url = new URL(request.url); const match = matchRoute(url.pathname, tree); - const node = renderMatchRoute( - { request, match }, -
Not Found: {url.pathname}
, - ); + const node = renderMatchRoute({ request, match }); return { node, match }; } diff --git a/packages/react-server/src/entry/server.tsx b/packages/react-server/src/entry/server.tsx index 695a787bd..b1496f455 100644 --- a/packages/react-server/src/entry/server.tsx +++ b/packages/react-server/src/entry/server.tsx @@ -1,6 +1,8 @@ import { splitFirst } from "@hiogawa/utils"; import reactDomServer from "react-dom/server.edge"; import { injectRSCPayload } from "rsc-html-stream/server"; +import { debug } from "../lib/debug"; +import { getErrorContext, getStatusText } from "../lib/error"; import { __global } from "../lib/global"; import { createModuleMap, @@ -19,9 +21,9 @@ export async function handler(request: Request): Promise { } // ssr rsc - const htmlStream = await renderHtml(result.stream); - return new Response(htmlStream, { - status: result.status, + const ssrResult = await renderHtml(result.stream); + return new Response(ssrResult.htmlStream, { + status: ssrResult.status, headers: { "content-type": "text/html", }, @@ -40,9 +42,7 @@ export async function importReactServer(): Promise< } } -export async function renderHtml( - rscStream: ReadableStream, -): Promise { +export async function renderHtml(rscStream: ReadableStream) { await initDomWebpackSsr(); const { default: reactServerDomClient } = await import( @@ -55,6 +55,7 @@ export async function renderHtml( // (see src/lib/ssr.tsx for details) const renderId = Math.random().toString(36).slice(2); + // TODO: Reac.use promise? const rscNode = await reactServerDomClient.createFromReadableStream( rscStream1, { @@ -73,16 +74,49 @@ export async function renderHtml( } const assets = (await import("virtual:ssr-assets" as string)).default; - const ssrStream = await reactDomServer.renderToReadableStream(rscNode, { - bootstrapModules: assets.bootstrapModules, - }); + // two pass SSR to re-render on error + let ssrStream: ReadableStream; + let status = 200; + try { + ssrStream = await reactDomServer.renderToReadableStream(rscNode, { + bootstrapModules: assets.bootstrapModules, + onError(error, errorInfo) { + // TODO: should handle SSR error which is not RSC error? + debug.ssr("renderToReadableStream", { error, errorInfo }); + }, + }); + } catch (e) { + // render empty as error fallback and + // let browser render full CSR instead of hydration + // which will reply client error boudnary from RSC error + // TODO: proper two-pass SSR with error route tracking? + // TODO: meta tag system + status = getErrorContext(e)?.status ?? 500; + const errorRoot = ( + + + + + + + + + ); + ssrStream = await reactDomServer.renderToReadableStream(errorRoot, { + bootstrapModules: assets.bootstrapModules, + }); + } - return ssrStream + const htmlStream = ssrStream .pipeThrough(invalidateImportCacheOnFinish(renderId)) .pipeThrough(new TextDecoderStream()) .pipeThrough(injectToHead(assets.head)) .pipeThrough(new TextEncoderStream()) .pipeThrough(injectRSCPayload(rscStream2)); + + return { htmlStream, status }; } function injectToHead(data: string) { diff --git a/packages/react-server/src/internal.ts b/packages/react-server/src/internal.ts new file mode 100644 index 000000000..702de4ee8 --- /dev/null +++ b/packages/react-server/src/internal.ts @@ -0,0 +1,2 @@ +// expose for quick experimentation +export { __global } from "./lib/global"; diff --git a/packages/react-server/src/lib/components/error-boundary.tsx b/packages/react-server/src/lib/components/error-boundary.tsx new file mode 100644 index 000000000..6acb1d13f --- /dev/null +++ b/packages/react-server/src/lib/components/error-boundary.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React from "react"; +import { getErrorContext, getStatusText } from "../error"; +import type { ErrorRouteProps } from "../router"; + +// cf. +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary + +interface Props { + children?: React.ReactNode; + errorComponent: React.FC; + url: string; +} + +interface State { + error: Error | null; + url: string; +} + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { error: null, url: props.url }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + // automatically reset on url change + static getDerivedStateFromProps(props: Props, state: State): State { + return { + ...state, + url: props.url, + error: props.url === state.url ? state.error : null, + }; + } + + reset = () => { + this.setState({ error: null }); + }; + + override render() { + const error = this.state.error; + if (error) { + const Component = this.props.errorComponent; + return ( + + ); + } + return this.props.children; + } +} + +export function DefaultRootErrorPage(props: ErrorRouteProps) { + const status = props.serverError?.status; + return ( + + + {status ? ( +
+ {status} {getStatusText(status)} +
+ ) : ( +
Unexpected Error
+ )} + + + ); +} diff --git a/packages/react-server/src/lib/csr.tsx b/packages/react-server/src/lib/csr.tsx index d72ddc7ba..ec6ecf86f 100644 --- a/packages/react-server/src/lib/csr.tsx +++ b/packages/react-server/src/lib/csr.tsx @@ -37,6 +37,7 @@ export function initDomWebpackCsr() { // import { type RouterHistory, createBrowserHistory } from "@tanstack/history"; +import { __global } from ".."; // TODO: client context instead of global? // TODO: create a wrapper to do `callServer` before actual client url update? @@ -44,4 +45,5 @@ export let __history: RouterHistory; export function initHistory() { __history = createBrowserHistory(); + __global.history = __history; } diff --git a/packages/react-server/src/lib/debug.ts b/packages/react-server/src/lib/debug.ts index 360bbf58a..534d5807e 100644 --- a/packages/react-server/src/lib/debug.ts +++ b/packages/react-server/src/lib/debug.ts @@ -35,4 +35,9 @@ type Debug = ((...args: unknown[]) => void) & { [k in K]: (...args: unknown[]) => void; }; -export const debug = createDebug("react-server", ["plugin", "ssr", "browser"]); +export const debug = createDebug("react-server", [ + "plugin", + "ssr", + "rsc", + "browser", +]); diff --git a/packages/react-server/src/lib/error.tsx b/packages/react-server/src/lib/error.tsx new file mode 100644 index 000000000..bdadc4cf7 --- /dev/null +++ b/packages/react-server/src/lib/error.tsx @@ -0,0 +1,49 @@ +import { debug } from "./debug"; + +// TODO: accomodate redirection error convention? +// TODO: custom (de)serialization? +export interface ReactServerErrorContext { + status: number; +} + +export class ReactServerDigestError extends Error { + constructor(public digest: string) { + super("ReactServerError"); + } +} + +export function createError(ctx: ReactServerErrorContext) { + const digest = `__REACT_SERVER_ERROR__:${JSON.stringify(ctx)}`; + return new ReactServerDigestError(digest); +} + +export function getErrorContext( + error: unknown, +): ReactServerErrorContext | undefined { + if ( + error instanceof Error && + "digest" in error && + typeof error.digest === "string" + ) { + const m = error.digest.match(/^__REACT_SERVER_ERROR__:(.*)$/); + if (m && m[1]) { + try { + return JSON.parse(m[1]); + } catch (e) { + debug("[getErrorContext]", e); + } + } + } + return; +} + +const STATUS_TEXT_MAP = new Map([ + [400, "Bad Request"], + [403, "Forbidden"], + [404, "Not Found"], + [500, "Internal Server Error"], +]); + +export function getStatusText(status: number) { + return STATUS_TEXT_MAP.get(status) ?? "Unknown Server Error"; +} diff --git a/packages/react-server/src/lib/global.ts b/packages/react-server/src/lib/global.ts index cd3aa7d17..070ce3155 100644 --- a/packages/react-server/src/lib/global.ts +++ b/packages/react-server/src/lib/global.ts @@ -1,3 +1,4 @@ +import type { RouterHistory } from "@tanstack/history"; import type { ViteDevServer } from "vite"; import type { CallServerCallback } from "./types"; @@ -8,6 +9,9 @@ export const __global: { server: ViteDevServer; reactServer: ViteDevServer; }; + history: RouterHistory; callServer: CallServerCallback; + // see "virtual:self-reference-workaround" + clientInternal: typeof import("../client-internal"); serverInternal: typeof import("../server-internal"); } = ((globalThis as any).__REACT_SERVER_GLOBAL ??= {}); diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index 176c904ed..8ac5f83bc 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -1,8 +1,7 @@ import { objectHas, tinyassert } from "@hiogawa/utils"; -import type React from "react"; - -// cf. similar to vite-glob-routes -// https://github.com/hi-ogawa/vite-plugins/blob/c2d22f9436ef868fc413f05f243323686a7aa143/packages/vite-glob-routes/src/react-router/route-utils.ts#L15-L22 +import React from "react"; +import { type ReactServerErrorContext, createError } from "./error"; +import { __global } from "./global"; // cf. https://nextjs.org/docs/app/building-your-application/routing#file-conventions interface RouteEntry { @@ -12,6 +11,10 @@ interface RouteEntry { layout?: { default: React.FC; }; + error?: { + // TODO: warn if no "use client" + default: React.FC; + }; } type RouteTreeNode = TreeNode; @@ -21,7 +24,7 @@ type RouteTreeNode = TreeNode; export function generateRouteTree(globEntries: Record) { const entries: Record = {}; for (const [k, v] of Object.entries(globEntries)) { - const m = k.match(/^(.*)\/(page|layout)\.\w*$/); + const m = k.match(/^(.*)\/(page|layout|error)\.\w*$/); tinyassert(m && 1 in m && 2 in m); tinyassert(objectHas(v, "default"), `no deafult export found in '${k}'`); ((entries[m[1]] ??= {}) as any)[m[2]] = v; @@ -70,15 +73,13 @@ export function matchRoute( } // TODO: separate react code in a different file -export function renderMatchRoute( - props: RouteProps, - fallback: React.ReactNode, -): React.ReactNode { +export function renderMatchRoute(props: RouteProps) { + const { ErrorBoundary, DefaultRootErrorPage } = __global.clientInternal; + const nodes = [...props.match.nodes].reverse(); - let acc: React.ReactNode = fallback; + let acc: React.ReactNode = ; if (!props.match.notFound) { - // TODO: assert? const Page = nodes[0]?.value?.page?.default; if (Page) { acc = ; @@ -86,15 +87,37 @@ export function renderMatchRoute( } for (const node of nodes) { + const ErrorPage = node.value?.error?.default; + if (ErrorPage) { + // TODO: can we remove extra
? + acc = ( + +
{acc}
+
+ ); + } const Layout = node.value?.layout?.default; if (Layout) { acc = {acc}; } } + acc = ( + + {acc} + + ); + return acc; } +const ThrowNotFound: React.FC = () => { + throw createError({ status: 404 }); +}; + interface RouteProps { request: Request; match: MatchRouteResult; @@ -102,6 +125,12 @@ interface RouteProps { export interface PageRouteProps extends RouteProps {} +export interface ErrorRouteProps { + error: Error; + serverError?: ReactServerErrorContext; + reset: () => void; +} + export interface LayoutRouteProps extends React.PropsWithChildren {} function matchChild(input: string, node: RouteTreeNode) { @@ -124,7 +153,8 @@ function matchChild(input: string, node: RouteTreeNode) { } // -// general tree structure utils +// general tree utils copied from vite-glob-routes +// https://github.com/hi-ogawa/vite-plugins/blob/c2d22f9436ef868fc413f05f243323686a7aa143/packages/vite-glob-routes/src/react-router/route-utils.ts#L15-L22 // type TreeNode = { diff --git a/packages/react-server/src/lib/types-env.d.ts b/packages/react-server/src/lib/types-env.d.ts index 1a4668840..d03fa7e98 100644 --- a/packages/react-server/src/lib/types-env.d.ts +++ b/packages/react-server/src/lib/types-env.d.ts @@ -7,7 +7,9 @@ declare module "react-server-dom-webpack/server.edge" { export function renderToReadableStream( node: React.ReactNode, bundlerConfig: import("./types").BundlerConfig, - opitons?: {}, + opitons?: { + onError: import("react-dom/server").RenderToReadableStreamOptions["onError"]; + }, ): ReadableStream; export function decodeReply(body: string | FormData): Promise; diff --git a/packages/react-server/src/plugin/index.ts b/packages/react-server/src/plugin/index.ts index ff53c293d..03046741a 100644 --- a/packages/react-server/src/plugin/index.ts +++ b/packages/react-server/src/plugin/index.ts @@ -149,18 +149,24 @@ export function vitePluginReactServer(options?: { // TODO: workaround Vite self-reference import via global (Try Vite 5.2). // https://github.com/vitejs/vite/pull/16068 - createVirtualPlugin("self-reference-workaround", () => { + createVirtualPlugin("self-reference-workaround1", () => { return /* js */ ` import * as serverInternal from "@hiogawa/react-server/server-internal"; - globalThis.__REACT_SERVER_GLOBAL ??= {}; - globalThis.__REACT_SERVER_GLOBAL.serverInternal = serverInternal; + Object.assign(globalThis.__REACT_SERVER_GLOBAL ??= {}, { serverInternal }); + `; + }), + createVirtualPlugin("self-reference-workaround2", () => { + return /* js */ ` + import * as clientInternal from "@hiogawa/react-server/client-internal"; + Object.assign(globalThis.__REACT_SERVER_GLOBAL ??= {}, { clientInternal }); `; }), createVirtualPlugin( ENTRY_REACT_SERVER_WRAPPER.slice("virtual:".length), () => { return /* js */ ` - import "virtual:self-reference-workaround"; + import "virtual:self-reference-workaround1"; + import "virtual:self-reference-workaround2"; export * from "${ENTRY_REACT_SERVER}"; `; }, diff --git a/packages/react-server/src/server.ts b/packages/react-server/src/server.ts index 7aecf678f..27c937cea 100644 --- a/packages/react-server/src/server.ts +++ b/packages/react-server/src/server.ts @@ -1,2 +1,7 @@ // TODO: export type from root? -export type { PageRouteProps, LayoutRouteProps } from "./lib/router"; +export type { + PageRouteProps, + LayoutRouteProps, + ErrorRouteProps, +} from "./lib/router"; +export { createError, type ReactServerErrorContext } from "./lib/error";