Skip to content

Commit 0fe5d6d

Browse files
authored
Fix middleware error bubbling scenarios (#13538)
1 parent 1678d41 commit 0fe5d6d

File tree

4 files changed

+181
-13
lines changed

4 files changed

+181
-13
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: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import type { DataRouteMatch } from "../../context";
2424

2525
export const SingleFetchRedirectSymbol = Symbol("SingleFetchRedirect");
2626

27+
class SingleFetchNoResultError extends Error {}
28+
2729
export type SingleFetchRedirectResult = {
2830
redirect: string;
2931
status: number;
@@ -466,9 +468,56 @@ async function singleFetchLoaderNavigationStrategy(
466468

467469
await resolvePromise;
468470

471+
await bubbleMiddlewareErrors(
472+
singleFetchDfd.promise,
473+
args.matches,
474+
routesParams,
475+
results
476+
);
477+
469478
return results;
470479
}
471480

481+
// If a middleware threw on the way down, we won't have data for our requested
482+
// loaders and they'll resolve to `SingleFetchNoResultError` results. If this
483+
// happens, take the highest error we find in our results (which is a middleware
484+
// error if no loaders ever ran), and assign to these missing routes and let
485+
// the router bubble accordingly
486+
async function bubbleMiddlewareErrors(
487+
singleFetchPromise: Promise<DecodedSingleFetchResults>,
488+
matches: DataStrategyFunctionArgs["matches"],
489+
routesParams: Set<string>,
490+
results: Record<string, DataStrategyResult>
491+
) {
492+
try {
493+
let middlewareError: unknown;
494+
let fetchedData = await singleFetchPromise;
495+
496+
if ("routes" in fetchedData) {
497+
for (let match of matches) {
498+
if (match.route.id in fetchedData.routes) {
499+
let routeResult = fetchedData.routes[match.route.id];
500+
if ("error" in routeResult) {
501+
middlewareError = routeResult.error;
502+
break;
503+
}
504+
}
505+
}
506+
}
507+
508+
if (middlewareError !== undefined) {
509+
Array.from(routesParams.values()).forEach((routeId) => {
510+
if (results[routeId].result instanceof SingleFetchNoResultError) {
511+
results[routeId].result = middlewareError;
512+
}
513+
});
514+
}
515+
} catch (e) {
516+
// No-op - this logic is only intended to process successful responses
517+
// If the `.data` failed, the routes will handle those errors themselves
518+
}
519+
}
520+
472521
// Fetcher loader calls are much simpler than navigational loader calls
473522
async function singleFetchLoaderFetcherStrategy(
474523
args: DataStrategyFunctionArgs,
@@ -694,12 +743,16 @@ function unwrapSingleFetchResult(
694743
}
695744

696745
let routeResult = result.routes[routeId];
697-
if ("error" in routeResult) {
746+
if (routeResult == null) {
747+
throw new SingleFetchNoResultError(
748+
`No result found for routeId "${routeId}"`
749+
);
750+
} else if ("error" in routeResult) {
698751
throw routeResult.error;
699752
} else if ("data" in routeResult) {
700753
return routeResult.data;
701754
} else {
702-
throw new Error(`No response found for routeId "${routeId}"`);
755+
throw new Error(`Invalid response found for routeId "${routeId}"`);
703756
}
704757
}
705758

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

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3614,19 +3614,26 @@ 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+
// Find the boundary at or above the source of the middleware
3627+
// error or the highest loader. We can't render any UI below
3628+
// the highest loader since we have no loader data available
3629+
let boundaryRouteId = skipLoaderErrorBubbling
3630+
? routeId
3631+
: findNearestBoundary(
3632+
matches,
3633+
matches.find(
3634+
(m) => m.route.id === routeId || m.route.loader
3635+
)?.route.id || routeId
3636+
).route.id;
36303637

36313638
return respond({
36323639
matches: matches!,
@@ -3635,7 +3642,7 @@ export function createStaticHandler(
36353642
loaderData: {},
36363643
actionData: null,
36373644
errors: {
3638-
[boundary.route.id]: error,
3645+
[boundaryRouteId]: error,
36393646
},
36403647
statusCode: isRouteErrorResponse(error) ? error.status : 500,
36413648
actionHeaders: {},

0 commit comments

Comments
 (0)