Skip to content

Commit 46446e6

Browse files
committed
Fix middleware error bubbling scenarios
1 parent 2c87a07 commit 46446e6

File tree

4 files changed

+180
-19
lines changed

4 files changed

+180
-19
lines changed

.changeset/tough-dancers-own.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router": patch
3+
---
4+
5+
UNSTABLE: Fix a few bugs with error bubbling in middleware use-cases

integration/middleware-test.ts

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2140,7 +2140,7 @@ test.describe("Middleware", () => {
21402140
"app/routes/_index.tsx": js`
21412141
import { Link } from 'react-router'
21422142
export default function Component({ loaderData }) {
2143-
return <Link to="/a/b">Link</Link>
2143+
return <Link to="/a/b/c/d">Link</Link>
21442144
}
21452145
`,
21462146
"app/routes/a.tsx": js`
@@ -2158,7 +2158,7 @@ test.describe("Middleware", () => {
21582158
return null;
21592159
}
21602160
export default function Component() {
2161-
return <Outlet/>
2161+
return <Outlet />
21622162
}
21632163
`,
21642164
"app/routes/a.b.c.tsx": js`
@@ -2192,6 +2192,109 @@ test.describe("Middleware", () => {
21922192
expect(await page.locator("h1").textContent()).toBe("A Error Boundary");
21932193
expect(await page.locator("pre").textContent()).toBe("broken!");
21942194

2195+
await app.goto("/");
2196+
await app.clickLink("/a/b/c/d");
2197+
expect(await page.locator("h1").textContent()).toBe("A Error Boundary");
2198+
expect(await page.locator("pre").textContent()).toBe("broken!");
2199+
2200+
appFixture.close();
2201+
});
2202+
2203+
test("bubbles errors on the way down up to the deepest error boundary when loaders aren't revalidating", async ({
2204+
page,
2205+
}) => {
2206+
let fixture = await createFixture(
2207+
{
2208+
files: {
2209+
"react-router.config.ts": reactRouterConfig({
2210+
middleware: true,
2211+
}),
2212+
"vite.config.ts": js`
2213+
import { defineConfig } from "vite";
2214+
import { reactRouter } from "@react-router/dev/vite";
2215+
2216+
export default defineConfig({
2217+
build: { manifest: true, minify: false },
2218+
plugins: [reactRouter()],
2219+
});
2220+
`,
2221+
"app/routes/_index.tsx": js`
2222+
import { Link } from 'react-router'
2223+
export default function Component({ loaderData }) {
2224+
return (
2225+
<>
2226+
<Link to="/a/b">/a/b</Link>
2227+
<br/>
2228+
<Link to="/a/b/c/d">/a/b/c/d</Link>
2229+
</>
2230+
);
2231+
}
2232+
`,
2233+
"app/routes/a.tsx": js`
2234+
import { Outlet } from 'react-router'
2235+
export default function Component() {
2236+
return <Outlet/>
2237+
}
2238+
export function ErrorBoundary({ error }) {
2239+
return <><h1 data-error-a>A Error Boundary</h1><pre>{error.message}</pre></>
2240+
}
2241+
`,
2242+
"app/routes/a.b.tsx": js`
2243+
import { Link, Outlet } from 'react-router'
2244+
export function loader() {
2245+
return { message: "DATA" };
2246+
}
2247+
export default function Component({ loaderData }) {
2248+
return (
2249+
<>
2250+
<h2 data-ab>AB: {loaderData.message}</h2>
2251+
<Link to="/a/b/c/d">/a/b/c/d</Link>
2252+
<Outlet/>
2253+
</>
2254+
);
2255+
}
2256+
export function shouldRevalidate() {
2257+
return false;
2258+
}
2259+
`,
2260+
"app/routes/a.b.c.tsx": js`
2261+
import { Outlet } from 'react-router'
2262+
export default function Component() {
2263+
return <Outlet/>
2264+
}
2265+
export function ErrorBoundary({ error }) {
2266+
return <><h1 data-error-c>C Error Boundary</h1><pre>{error.message}</pre></>
2267+
}
2268+
`,
2269+
"app/routes/a.b.c.d.tsx": js`
2270+
import { Outlet } from 'react-router'
2271+
export const unstable_middleware = [() => { throw new Error("broken!") }]
2272+
export const loader = () => null;
2273+
export default function Component() {
2274+
return <Outlet/>
2275+
}
2276+
`,
2277+
},
2278+
},
2279+
UNSAFE_ServerMode.Development
2280+
);
2281+
2282+
let appFixture = await createAppFixture(
2283+
fixture,
2284+
UNSAFE_ServerMode.Development
2285+
);
2286+
2287+
let app = new PlaywrightFixture(appFixture, page);
2288+
await app.goto("/");
2289+
await app.clickLink("/a/b");
2290+
await page.waitForSelector("[data-ab]");
2291+
expect(await page.locator("[data-ab]").textContent()).toBe("AB: DATA");
2292+
2293+
await app.clickLink("/a/b/c/d");
2294+
await page.waitForSelector("[data-error-c]");
2295+
expect(await page.locator("h1").textContent()).toBe("C Error Boundary");
2296+
expect(await page.locator("pre").textContent()).toBe("broken!");
2297+
21952298
appFixture.close();
21962299
});
21972300

packages/react-router/lib/dom/ssr/single-fetch.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,20 @@ async function singleFetchLoaderNavigationStrategy(
466466

467467
await resolvePromise;
468468

469+
// Capture any middleware errors from routes that weren't actually fetching
470+
// data, these will be bubbled by the router in `processRouteLoaderData`
471+
let fetchedData = await singleFetchDfd.promise;
472+
if ("routes" in fetchedData) {
473+
Object.entries(fetchedData.routes).forEach(([routeId, result]) => {
474+
if ("error" in result && results[routeId]?.type !== "error") {
475+
results[routeId] = {
476+
type: "error",
477+
result: result.error,
478+
};
479+
}
480+
});
481+
}
482+
469483
return results;
470484
}
471485

@@ -694,12 +708,14 @@ function unwrapSingleFetchResult(
694708
}
695709

696710
let routeResult = result.routes[routeId];
697-
if ("error" in routeResult) {
711+
if (routeResult == null) {
712+
throw new Error(`No result found for routeId "${routeId}"`);
713+
} else if ("error" in routeResult) {
698714
throw routeResult.error;
699715
} else if ("data" in routeResult) {
700716
return routeResult.data;
701717
} else {
702-
throw new Error(`No response found for routeId "${routeId}"`);
718+
throw new Error(`Invalid response found for routeId "${routeId}"`);
703719
}
704720
}
705721

packages/react-router/lib/router/router.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3614,28 +3614,50 @@ export function createStaticHandler(
36143614
dataRoutes,
36153615
renderedStaticContext,
36163616
error,
3617-
findNearestBoundary(matches!, routeId).route.id
3617+
skipLoaderErrorBubbling
3618+
? routeId
3619+
: findNearestBoundary(matches, routeId).route.id
36183620
)
36193621
);
36203622
} else {
36213623
// We never even got to the handlers, so we've got no data -
36223624
// just create an empty context reflecting the error.
3623-
// Find the boundary at or above the highest loader. We can't
3624-
// render any UI below there since we have no loader data available
3625-
let loaderIdx = matches!.findIndex((m) => m.route.loader);
3626-
let boundary =
3627-
loaderIdx >= 0
3628-
? findNearestBoundary(matches!, matches![loaderIdx].route.id)
3629-
: findNearestBoundary(matches!);
3625+
3626+
let boundaryRouteId = skipLoaderErrorBubbling
3627+
? routeId
3628+
: // Find the boundary at or above the source of the middleware
3629+
// error or the highest loader. We can't render any UI below
3630+
// the highest loader since we have no loader data available
3631+
findNearestBoundary(
3632+
matches,
3633+
matches.find(
3634+
(m) => m.route.id === routeId || m.route.loader
3635+
)?.route.id || routeId
3636+
).route.id;
3637+
3638+
// If we errored in the top-down middleware, stub in `undefined`
3639+
// for all loaders the front end is expecting results for
3640+
let loaderData: RouterState["loaderData"] = {};
3641+
if (!isMutationMethod(request.method)) {
3642+
matches
3643+
.filter((m) =>
3644+
filterMatchesToLoad
3645+
? filterMatchesToLoad(m)
3646+
: m.route.loader
3647+
)
3648+
.forEach((m) => {
3649+
loaderData[m.route.id] = undefined;
3650+
});
3651+
}
36303652

36313653
return respond({
36323654
matches: matches!,
36333655
location,
36343656
basename,
3635-
loaderData: {},
3657+
loaderData,
36363658
actionData: null,
36373659
errors: {
3638-
[boundary.route.id]: error,
3660+
[boundaryRouteId]: error,
36393661
},
36403662
statusCode: isRouteErrorResponse(error) ? error.status : 500,
36413663
actionHeaders: {},
@@ -4219,6 +4241,7 @@ export function createStaticHandler(
42194241
matches,
42204242
results,
42214243
pendingActionResult,
4244+
undefined,
42224245
true,
42234246
skipLoaderErrorBubbling
42244247
);
@@ -5987,6 +6010,7 @@ function processRouteLoaderData(
59876010
matches: AgnosticDataRouteMatch[],
59886011
results: Record<string, DataResult>,
59896012
pendingActionResult: PendingActionResult | undefined,
6013+
currentLoaderData?: RouterState["loaderData"],
59906014
isStaticHandler = false,
59916015
skipLoaderErrorBubbling = false
59926016
): {
@@ -6032,10 +6056,22 @@ function processRouteLoaderData(
60326056
if (skipLoaderErrorBubbling) {
60336057
errors[id] = error;
60346058
} else {
6035-
// Look upwards from the matched route for the closest ancestor error
6036-
// boundary, defaulting to the root match. Prefer higher error values
6037-
// if lower errors bubble to the same boundary
6038-
let boundaryMatch = findNearestBoundary(matches, id);
6059+
// Bubble the error to the proper error boundary by looking upwards from
6060+
// the highest route that defines a `loader` but doesn't currently have
6061+
// any `loaderData`. This situation can happen if a middleware throws
6062+
// on the way down and thus a loader never executes for a given route.
6063+
// If such a route doesn't exist, then we just look upwards from the
6064+
// throwing route. Prefer higher error values if lower errors bubble to
6065+
// the same boundary
6066+
let highestLoaderRouteWithoutData = currentLoaderData
6067+
? matches.find(
6068+
(m) => m.route.loader && !(m.route.id in currentLoaderData)
6069+
)
6070+
: undefined;
6071+
let boundaryMatch = findNearestBoundary(
6072+
matches,
6073+
highestLoaderRouteWithoutData?.route.id || id
6074+
);
60396075
if (errors[boundaryMatch.route.id] == null) {
60406076
errors[boundaryMatch.route.id] = error;
60416077
}
@@ -6102,7 +6138,8 @@ function processLoaderData(
61026138
let { loaderData, errors } = processRouteLoaderData(
61036139
matches,
61046140
results,
6105-
pendingActionResult
6141+
pendingActionResult,
6142+
state.loaderData
61066143
);
61076144

61086145
// Process results from our revalidating fetchers

0 commit comments

Comments
 (0)