diff --git a/.changeset/tough-dancers-own.md b/.changeset/tough-dancers-own.md new file mode 100644 index 0000000000..a7134c568d --- /dev/null +++ b/.changeset/tough-dancers-own.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +UNSTABLE: Fix a few bugs with error bubbling in middleware use-cases diff --git a/integration/middleware-test.ts b/integration/middleware-test.ts index d433ea0ac5..b59b01a114 100644 --- a/integration/middleware-test.ts +++ b/integration/middleware-test.ts @@ -2140,7 +2140,7 @@ test.describe("Middleware", () => { "app/routes/_index.tsx": js` import { Link } from 'react-router' export default function Component({ loaderData }) { - return Link + return Link } `, "app/routes/a.tsx": js` @@ -2158,7 +2158,7 @@ test.describe("Middleware", () => { return null; } export default function Component() { - return + return } `, "app/routes/a.b.c.tsx": js` @@ -2192,6 +2192,109 @@ test.describe("Middleware", () => { expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); expect(await page.locator("pre").textContent()).toBe("broken!"); + await app.goto("/"); + await app.clickLink("/a/b/c/d"); + expect(await page.locator("h1").textContent()).toBe("A Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + + appFixture.close(); + }); + + test("bubbles errors on the way down up to the deepest error boundary when loaders aren't revalidating", async ({ + page, + }) => { + let fixture = await createFixture( + { + files: { + "react-router.config.ts": reactRouterConfig({ + middleware: true, + }), + "vite.config.ts": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + build: { manifest: true, minify: false }, + plugins: [reactRouter()], + }); + `, + "app/routes/_index.tsx": js` + import { Link } from 'react-router' + export default function Component({ loaderData }) { + return ( + <> + /a/b +
+ /a/b/c/d + + ); + } + `, + "app/routes/a.tsx": js` + import { Outlet } from 'react-router' + export default function Component() { + return + } + export function ErrorBoundary({ error }) { + return <>

A Error Boundary

{error.message}
+ } + `, + "app/routes/a.b.tsx": js` + import { Link, Outlet } from 'react-router' + export function loader() { + return { message: "DATA" }; + } + export default function Component({ loaderData }) { + return ( + <> +

AB: {loaderData.message}

+ /a/b/c/d + + + ); + } + export function shouldRevalidate() { + return false; + } + `, + "app/routes/a.b.c.tsx": js` + import { Outlet } from 'react-router' + export default function Component() { + return + } + export function ErrorBoundary({ error }) { + return <>

C Error Boundary

{error.message}
+ } + `, + "app/routes/a.b.c.d.tsx": js` + import { Outlet } from 'react-router' + export const unstable_middleware = [() => { throw new Error("broken!") }] + export const loader = () => null; + export default function Component() { + return + } + `, + }, + }, + UNSAFE_ServerMode.Development + ); + + let appFixture = await createAppFixture( + fixture, + UNSAFE_ServerMode.Development + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/a/b"); + await page.waitForSelector("[data-ab]"); + expect(await page.locator("[data-ab]").textContent()).toBe("AB: DATA"); + + await app.clickLink("/a/b/c/d"); + await page.waitForSelector("[data-error-c]"); + expect(await page.locator("h1").textContent()).toBe("C Error Boundary"); + expect(await page.locator("pre").textContent()).toBe("broken!"); + appFixture.close(); }); diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 964e8f02f7..63f5ac9fbc 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -24,6 +24,8 @@ import type { DataRouteMatch } from "../../context"; export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect"); +class SingleFetchNoResultError extends Error {} + export type SingleFetchRedirectResult = { redirect: string; status: number; @@ -466,9 +468,56 @@ async function singleFetchLoaderNavigationStrategy( await resolvePromise; + await bubbleMiddlewareErrors( + singleFetchDfd.promise, + args.matches, + routesParams, + results + ); + return results; } +// If a middleware threw on the way down, we won't have data for our requested +// loaders and they'll resolve to `SingleFetchNoResultError` results. If this +// happens, take the highest error we find in our results (which is a middleware +// error if no loaders ever ran), and assign to these missing routes and let +// the router bubble accordingly +async function bubbleMiddlewareErrors( + singleFetchPromise: Promise, + matches: DataStrategyFunctionArgs["matches"], + routesParams: Set, + results: Record +) { + try { + let middlewareError: unknown; + let fetchedData = await singleFetchPromise; + + if ("routes" in fetchedData) { + for (let match of matches) { + if (match.route.id in fetchedData.routes) { + let routeResult = fetchedData.routes[match.route.id]; + if ("error" in routeResult) { + middlewareError = routeResult.error; + break; + } + } + } + } + + if (middlewareError !== undefined) { + Array.from(routesParams.values()).forEach((routeId) => { + if (results[routeId].result instanceof SingleFetchNoResultError) { + results[routeId].result = middlewareError; + } + }); + } + } catch (e) { + // No-op - this logic is only intended to process successful responses + // If the `.data` failed, the routes will handle those errors themselves + } +} + // Fetcher loader calls are much simpler than navigational loader calls async function singleFetchLoaderFetcherStrategy( args: DataStrategyFunctionArgs, @@ -694,12 +743,16 @@ function unwrapSingleFetchResult( } let routeResult = result.routes[routeId]; - if ("error" in routeResult) { + if (routeResult == null) { + throw new SingleFetchNoResultError( + `No result found for routeId "${routeId}"` + ); + } else if ("error" in routeResult) { throw routeResult.error; } else if ("data" in routeResult) { return routeResult.data; } else { - throw new Error(`No response found for routeId "${routeId}"`); + throw new Error(`Invalid response found for routeId "${routeId}"`); } } diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index ee41ea8e79..3b5c97f9b1 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -3614,19 +3614,26 @@ export function createStaticHandler( dataRoutes, renderedStaticContext, error, - findNearestBoundary(matches!, routeId).route.id + skipLoaderErrorBubbling + ? routeId + : findNearestBoundary(matches, routeId).route.id ) ); } else { // We never even got to the handlers, so we've got no data - // just create an empty context reflecting the error. - // Find the boundary at or above the highest loader. We can't - // render any UI below there since we have no loader data available - let loaderIdx = matches!.findIndex((m) => m.route.loader); - let boundary = - loaderIdx >= 0 - ? findNearestBoundary(matches!, matches![loaderIdx].route.id) - : findNearestBoundary(matches!); + + // Find the boundary at or above the source of the middleware + // error or the highest loader. We can't render any UI below + // the highest loader since we have no loader data available + let boundaryRouteId = skipLoaderErrorBubbling + ? routeId + : findNearestBoundary( + matches, + matches.find( + (m) => m.route.id === routeId || m.route.loader + )?.route.id || routeId + ).route.id; return respond({ matches: matches!, @@ -3635,7 +3642,7 @@ export function createStaticHandler( loaderData: {}, actionData: null, errors: { - [boundary.route.id]: error, + [boundaryRouteId]: error, }, statusCode: isRouteErrorResponse(error) ? error.status : 500, actionHeaders: {},