Skip to content

Commit 8ed30d3

Browse files
fix: Handle fetcher 404s as normal boundary erorrs (#9015)
* fix: Handle fetcher 404s as normal boundary erorrs * Add changeset * Bump threshold Co-authored-by: Ryan Florence <rpflorence@gmail.com>
1 parent b7fadce commit 8ed30d3

File tree

4 files changed

+187
-7
lines changed

4 files changed

+187
-7
lines changed

.changeset/dirty-ladybugs-grow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
fix: Handle fetcher 404s as normal boundary errors

packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,54 @@ function testDomRouter(
18521852
`);
18531853
});
18541854

1855+
it("handles fetcher 404 errors at the correct spot in the route hierarchy", async () => {
1856+
let { container } = render(
1857+
<TestDataRouter
1858+
window={getWindow("/child")}
1859+
hydrationData={{ loaderData: { "0": null } }}
1860+
>
1861+
<Route path="/" element={<Outlet />} errorElement={<p>Not I!</p>}>
1862+
<Route
1863+
path="child"
1864+
element={<Comp />}
1865+
errorElement={<ErrorElement />}
1866+
/>
1867+
</Route>
1868+
</TestDataRouter>
1869+
);
1870+
1871+
function Comp() {
1872+
let fetcher = useFetcher();
1873+
return (
1874+
<button onClick={() => fetcher.load("/not-found")}>load</button>
1875+
);
1876+
}
1877+
1878+
function ErrorElement() {
1879+
let { status, statusText } = useRouteError();
1880+
return <p>contextual error:{`${status} ${statusText}`}</p>;
1881+
}
1882+
1883+
expect(getHtml(container)).toMatchInlineSnapshot(`
1884+
"<div>
1885+
<button>
1886+
load
1887+
</button>
1888+
</div>"
1889+
`);
1890+
1891+
fireEvent.click(screen.getByText("load"));
1892+
await waitFor(() => screen.getByText(/Not Found/));
1893+
expect(getHtml(container)).toMatchInlineSnapshot(`
1894+
"<div>
1895+
<p>
1896+
contextual error:
1897+
404 Not Found
1898+
</p>
1899+
</div>"
1900+
`);
1901+
});
1902+
18551903
it("handles fetcher.load errors at the correct spot in the route hierarchy", async () => {
18561904
let { container } = render(
18571905
<TestDataRouter

packages/router/__tests__/router-test.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function invariant<T>(
106106
): asserts value is T;
107107
export function invariant(value: any, message?: string) {
108108
if (value === false || value === null || typeof value === "undefined") {
109-
console.warn("Test invaeriant failed:", message);
109+
console.warn("Test invariant failed:", message);
110110
throw new Error(message);
111111
}
112112
}
@@ -427,13 +427,27 @@ function setup({
427427
opts?: RouterNavigateOptions
428428
): FetcherHelpers {
429429
let matches = matchRoutes(enhancedRoutes, href);
430-
invariant(matches, `No matches found for fetcher href:${href}`);
431430
invariant(currentRouter, "No currentRouter available");
432431
let search = parsePath(href).search || "";
433432
let hasNakedIndexQuery = new URLSearchParams(search)
434433
.getAll("index")
435434
.some((v) => v === "");
436435

436+
// Let fetcher 404s go right through
437+
if (!matches) {
438+
return {
439+
key,
440+
navigationId,
441+
get fetcher() {
442+
invariant(currentRouter, "No currentRouter available");
443+
return currentRouter.getFetcher(key);
444+
},
445+
loaders: {},
446+
actions: {},
447+
shimLoaderHelper,
448+
};
449+
}
450+
437451
let match =
438452
matches[matches.length - 1].route.index && !hasNakedIndexQuery
439453
? matches.slice(-2)[0]
@@ -560,14 +574,30 @@ function setup({
560574
key: string,
561575
opts: RouterNavigateOptions
562576
): Promise<FetcherHelpers>;
577+
async function fetch(
578+
href: string,
579+
key: string,
580+
routeId: string,
581+
opts: RouterNavigateOptions
582+
): Promise<FetcherHelpers>;
563583
async function fetch(
564584
href: string,
565585
keyOrOpts?: string | RouterNavigateOptions,
586+
routeIdOrOpts?: string | RouterNavigateOptions,
566587
opts?: RouterNavigateOptions
567588
): Promise<FetcherHelpers> {
568589
let navigationId = ++guid;
569590
let key = typeof keyOrOpts === "string" ? keyOrOpts : String(navigationId);
570-
opts = typeof keyOrOpts === "object" ? keyOrOpts : opts;
591+
let routeId =
592+
typeof routeIdOrOpts === "string"
593+
? routeIdOrOpts
594+
: String(enhancedRoutes[0].id);
595+
opts =
596+
typeof keyOrOpts === "object"
597+
? keyOrOpts
598+
: typeof routeIdOrOpts === "object"
599+
? routeIdOrOpts
600+
: opts;
571601
invariant(currentRouter, "No currentRouter available");
572602

573603
// @ts-expect-error
@@ -580,7 +610,7 @@ function setup({
580610
}
581611

582612
let helpers = getFetcherHelpers(key, href, navigationId, opts);
583-
currentRouter.fetch(key, enhancedRoutes[0].id, href, opts);
613+
currentRouter.fetch(key, routeId, href, opts);
584614
return helpers;
585615
}
586616

@@ -5557,6 +5587,93 @@ describe("a router", () => {
55575587
root: new ErrorResponse(400, undefined, ""),
55585588
});
55595589
});
5590+
5591+
it("handles fetcher errors at contextual route boundaries", async () => {
5592+
let t = setup({
5593+
routes: [
5594+
{
5595+
id: "root",
5596+
path: "/",
5597+
errorElement: true,
5598+
children: [
5599+
{
5600+
id: "wit",
5601+
path: "wit",
5602+
loader: true,
5603+
errorElement: true,
5604+
},
5605+
{
5606+
id: "witout",
5607+
path: "witout",
5608+
loader: true,
5609+
},
5610+
{
5611+
id: "error",
5612+
path: "error",
5613+
loader: true,
5614+
},
5615+
],
5616+
},
5617+
],
5618+
});
5619+
5620+
// If the routeId is not an active match, errors bubble to the root
5621+
let A = await t.fetch("/error", "key1", "wit");
5622+
await A.loaders.error.reject(new Error("Kaboom!"));
5623+
expect(t.router.getFetcher("key1")).toBe(IDLE_FETCHER);
5624+
expect(t.router.state.errors).toEqual({
5625+
root: new Error("Kaboom!"),
5626+
});
5627+
5628+
await t.fetch("/not-found", "key2", "wit");
5629+
expect(t.router.getFetcher("key2")).toBe(IDLE_FETCHER);
5630+
expect(t.router.state.errors).toEqual({
5631+
root: new ErrorResponse(404, "Not Found", null),
5632+
});
5633+
5634+
// Navigate to /wit and trigger errors, handled at the wit boundary
5635+
let B = await t.navigate("/wit");
5636+
await B.loaders.wit.resolve("WIT");
5637+
5638+
let C = await t.fetch("/error", "key3", "wit");
5639+
await C.loaders.error.reject(new Error("Kaboom!"));
5640+
expect(t.router.getFetcher("key3")).toBe(IDLE_FETCHER);
5641+
expect(t.router.state.errors).toEqual({
5642+
wit: new Error("Kaboom!"),
5643+
});
5644+
5645+
await t.fetch("/not-found", "key4", "wit", {
5646+
formMethod: "post",
5647+
formData: createFormData({ key: "value" }),
5648+
});
5649+
expect(t.router.getFetcher("key4")).toBe(IDLE_FETCHER);
5650+
expect(t.router.state.errors).toEqual({
5651+
wit: new ErrorResponse(404, "Not Found", null),
5652+
});
5653+
5654+
await t.fetch("/not-found", "key5", "wit");
5655+
expect(t.router.getFetcher("key5")).toBe(IDLE_FETCHER);
5656+
expect(t.router.state.errors).toEqual({
5657+
wit: new ErrorResponse(404, "Not Found", null),
5658+
});
5659+
5660+
// Navigate to /witout and fetch a 404, handled at the root boundary
5661+
let D = await t.navigate("/witout");
5662+
await D.loaders.witout.resolve("WITOUT");
5663+
5664+
let E = await t.fetch("/error", "key6", "witout");
5665+
await E.loaders.error.reject(new Error("Kaboom!"));
5666+
expect(t.router.getFetcher("key6")).toBe(IDLE_FETCHER);
5667+
expect(t.router.state.errors).toEqual({
5668+
root: new Error("Kaboom!"),
5669+
});
5670+
5671+
await t.fetch("/not-found", "key7", "witout");
5672+
expect(t.router.getFetcher("key7")).toBe(IDLE_FETCHER);
5673+
expect(t.router.state.errors).toEqual({
5674+
root: new ErrorResponse(404, "Not Found", null),
5675+
});
5676+
});
55605677
});
55615678

55625679
describe("fetcher error states (Error)", () => {

packages/router/router.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,11 +1092,21 @@ export function createRouter(init: RouterInit): Router {
10921092
);
10931093
}
10941094

1095-
let matches = matchRoutes(dataRoutes, href);
1096-
invariant(matches, `No matches found for fetch url: ${href}`);
1097-
10981095
if (fetchControllers.has(key)) abortFetcher(key);
10991096

1097+
let matches = matchRoutes(dataRoutes, href);
1098+
if (!matches) {
1099+
let boundaryMatch = findNearestBoundary(state.matches, routeId);
1100+
state.fetchers.set(key, IDLE_FETCHER);
1101+
updateState({
1102+
errors: {
1103+
[boundaryMatch.route.id]: new ErrorResponse(404, "Not Found", null),
1104+
},
1105+
fetchers: new Map(state.fetchers),
1106+
});
1107+
return;
1108+
}
1109+
11001110
let match =
11011111
matches[matches.length - 1].route.index &&
11021112
!hasNakedIndexQuery(parsePath(href).search || "")

0 commit comments

Comments
 (0)