Skip to content

Commit a11f18b

Browse files
committed
Add proper 404 error for missing loader
1 parent b7683f1 commit a11f18b

File tree

3 files changed

+170
-10
lines changed

3 files changed

+170
-10
lines changed

.changeset/fetcher-404.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+
Ensure proper 404 error on `fetcher.load` call to a route without a `loader`

packages/router/__tests__/router-test.ts

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10279,6 +10279,150 @@ describe("a router", () => {
1027910279
await F.actions.index.resolve("INDEX ACTION");
1028010280
expect(t.router.getFetcher(key).data).toBe("INDEX ACTION");
1028110281
});
10282+
10283+
it("throws a 404 ErrorResponse without ?index and parent route has no loader", async () => {
10284+
let t = setup({
10285+
routes: [
10286+
{
10287+
id: "parent",
10288+
path: "parent",
10289+
children: [
10290+
{
10291+
id: "index",
10292+
index: true,
10293+
loader: true,
10294+
},
10295+
],
10296+
},
10297+
],
10298+
initialEntries: ["/parent"],
10299+
hydrationData: { loaderData: { index: "INDEX" } },
10300+
});
10301+
10302+
let key = "KEY";
10303+
await t.fetch("/parent");
10304+
expect(t.router.state.errors).toMatchInlineSnapshot(`
10305+
{
10306+
"parent": ErrorResponse {
10307+
"data": "Error: No route matches URL "/parent"",
10308+
"error": [Error: No route matches URL "/parent"],
10309+
"internal": true,
10310+
"status": 404,
10311+
"statusText": "Not Found",
10312+
},
10313+
}
10314+
`);
10315+
expect(t.router.getFetcher(key).data).toBe(undefined);
10316+
});
10317+
10318+
it("throws a 404 ErrorResponse with ?index and index route has no loader", async () => {
10319+
let t = setup({
10320+
routes: [
10321+
{
10322+
id: "parent",
10323+
path: "parent",
10324+
loader: true,
10325+
children: [
10326+
{
10327+
id: "index",
10328+
index: true,
10329+
},
10330+
],
10331+
},
10332+
],
10333+
initialEntries: ["/parent"],
10334+
hydrationData: { loaderData: { parent: "PARENT" } },
10335+
});
10336+
10337+
let key = "KEY";
10338+
await t.fetch("/parent?index");
10339+
expect(t.router.state.errors).toMatchInlineSnapshot(`
10340+
{
10341+
"parent": ErrorResponse {
10342+
"data": "Error: No route matches URL "/parent?index"",
10343+
"error": [Error: No route matches URL "/parent?index"],
10344+
"internal": true,
10345+
"status": 404,
10346+
"statusText": "Not Found",
10347+
},
10348+
}
10349+
`);
10350+
expect(t.router.getFetcher(key).data).toBe(undefined);
10351+
});
10352+
10353+
it("throws a 405 ErrorResponse without ?index and parent route has no action", async () => {
10354+
let t = setup({
10355+
routes: [
10356+
{
10357+
id: "parent",
10358+
path: "parent",
10359+
children: [
10360+
{
10361+
id: "index",
10362+
index: true,
10363+
action: true,
10364+
},
10365+
],
10366+
},
10367+
],
10368+
initialEntries: ["/parent"],
10369+
});
10370+
10371+
let key = "KEY";
10372+
await t.fetch("/parent", {
10373+
formMethod: "post",
10374+
formData: createFormData({}),
10375+
});
10376+
expect(t.router.state.errors).toMatchInlineSnapshot(`
10377+
{
10378+
"parent": ErrorResponse {
10379+
"data": "Error: You made a POST request to "/parent" but did not provide an \`action\` for route "parent", so there is no way to handle the request.",
10380+
"error": [Error: You made a POST request to "/parent" but did not provide an \`action\` for route "parent", so there is no way to handle the request.],
10381+
"internal": true,
10382+
"status": 405,
10383+
"statusText": "Method Not Allowed",
10384+
},
10385+
}
10386+
`);
10387+
expect(t.router.getFetcher(key).data).toBe(undefined);
10388+
});
10389+
10390+
it("throws a 405 ErrorResponse with ?index and index route has no action", async () => {
10391+
let t = setup({
10392+
routes: [
10393+
{
10394+
id: "parent",
10395+
path: "parent",
10396+
action: true,
10397+
children: [
10398+
{
10399+
id: "index",
10400+
index: true,
10401+
},
10402+
],
10403+
},
10404+
],
10405+
initialEntries: ["/parent"],
10406+
});
10407+
10408+
let key = "KEY";
10409+
await t.fetch("/parent?index", {
10410+
formMethod: "post",
10411+
formData: createFormData({}),
10412+
});
10413+
expect(t.router.state.errors).toMatchInlineSnapshot(`
10414+
{
10415+
"parent": ErrorResponse {
10416+
"data": "Error: You made a POST request to "/parent?index" but did not provide an \`action\` for route "parent", so there is no way to handle the request.",
10417+
"error": [Error: You made a POST request to "/parent?index" but did not provide an \`action\` for route "parent", so there is no way to handle the request.],
10418+
"internal": true,
10419+
"status": 405,
10420+
"statusText": "Method Not Allowed",
10421+
},
10422+
}
10423+
`);
10424+
expect(t.router.getFetcher(key).data).toBe(undefined);
10425+
});
1028210426
});
1028310427
});
1028410428

@@ -15443,12 +15587,20 @@ describe("a router", () => {
1544315587
expect(currentRouter.state.loaderData).toEqual({
1544415588
root: "ROOT*",
1544515589
});
15446-
// Fetcher should have been revalidated but thrown an errow since the
15590+
// Fetcher should have been revalidated but throw an error since the
1544715591
// loader was removed
1544815592
expect(currentRouter.state.fetchers.get("key")?.data).toBe(undefined);
15449-
expect(currentRouter.state.errors).toEqual({
15450-
root: new Error('Could not find the loader to run on the "foo" route'),
15451-
});
15593+
expect(currentRouter.state.errors).toMatchInlineSnapshot(`
15594+
{
15595+
"root": ErrorResponse {
15596+
"data": "Error: No route matches URL "/foo"",
15597+
"error": [Error: No route matches URL "/foo"],
15598+
"internal": true,
15599+
"status": 404,
15600+
"statusText": "Not Found",
15601+
},
15602+
}
15603+
`);
1545215604
});
1545315605

1545415606
it("should retain existing routes until revalidation completes on route removal (fetch)", async () => {

packages/router/router.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3425,9 +3425,11 @@ async function callLoaderOrAction(
34253425
// previously-lazy-loaded routes
34263426
result = await runHandler(handler);
34273427
} else if (type === "action") {
3428+
let url = new URL(request.url);
3429+
let pathname = url.pathname + url.search;
34283430
throw getInternalRouterError(405, {
34293431
method: request.method,
3430-
pathname: new URL(request.url).pathname,
3432+
pathname,
34313433
routeId: match.route.id,
34323434
});
34333435
} else {
@@ -3436,12 +3438,13 @@ async function callLoaderOrAction(
34363438
return { type: ResultType.data, data: undefined };
34373439
}
34383440
}
3441+
} else if (!handler) {
3442+
let url = new URL(request.url);
3443+
let pathname = url.pathname + url.search;
3444+
throw getInternalRouterError(404, {
3445+
pathname,
3446+
});
34393447
} else {
3440-
invariant<Function>(
3441-
handler,
3442-
`Could not find the ${type} to run on the "${match.route.id}" route`
3443-
);
3444-
34453448
result = await runHandler(handler);
34463449
}
34473450

0 commit comments

Comments
 (0)