Skip to content

Commit caeebd8

Browse files
bartlomiejuclaude
andauthored
fix: trailing slash mismatch causes 404 for static routes (#3721)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e6b25a0 commit caeebd8

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

packages/fresh/src/router.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,26 @@ export class UrlPatternRouter<T> implements Router<T> {
116116
pattern: null,
117117
};
118118

119-
const staticMatch = this.#statics.get(url.pathname);
119+
let pathname = url.pathname;
120+
let staticMatch = this.#statics.get(pathname);
121+
122+
// Try alternate trailing slash form if no exact match found.
123+
// Routes may be registered with or without trailing slashes,
124+
// and requests may arrive in either form (e.g. when using
125+
// trailingSlashes("always")).
126+
if (staticMatch === undefined && pathname !== "/") {
127+
const alt = pathname.endsWith("/")
128+
? pathname.slice(0, -1)
129+
: pathname + "/";
130+
const altMatch = this.#statics.get(alt);
131+
if (altMatch !== undefined) {
132+
staticMatch = altMatch;
133+
pathname = alt;
134+
}
135+
}
136+
120137
if (staticMatch !== undefined) {
121-
result.pattern = url.pathname;
138+
result.pattern = pathname;
122139

123140
let handlers = staticMatch.byMethod[method];
124141
if (method === "HEAD" && handlers.length === 0) {

packages/fresh/src/router_test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,78 @@ Deno.test("UrlPatternRouter - wrong + correct method", () => {
7070
});
7171
});
7272

73+
Deno.test("UrlPatternRouter - trailing slash matches route without slash", () => {
74+
const router = new UrlPatternRouter();
75+
const A = () => {};
76+
router.add("GET", "/wissen", [A]);
77+
78+
const res = router.match("GET", new URL("/wissen/", "http://localhost"));
79+
expect(res).toEqual({
80+
params: Object.create(null),
81+
handlers: [A],
82+
methodMatch: true,
83+
pattern: "/wissen",
84+
});
85+
});
86+
87+
Deno.test("UrlPatternRouter - no trailing slash matches route with slash", () => {
88+
const router = new UrlPatternRouter();
89+
const A = () => {};
90+
router.add("GET", "/wissen/", [A]);
91+
92+
const res = router.match("GET", new URL("/wissen", "http://localhost"));
93+
expect(res).toEqual({
94+
params: Object.create(null),
95+
handlers: [A],
96+
methodMatch: true,
97+
pattern: "/wissen/",
98+
});
99+
});
100+
101+
Deno.test("UrlPatternRouter - exact match takes priority over trailing slash fallback", () => {
102+
const router = new UrlPatternRouter();
103+
const A = () => {};
104+
const B = () => {};
105+
router.add("GET", "/wissen", [A]);
106+
router.add("GET", "/wissen/", [B]);
107+
108+
const withSlash = router.match(
109+
"GET",
110+
new URL("/wissen/", "http://localhost"),
111+
);
112+
expect(withSlash).toEqual({
113+
params: Object.create(null),
114+
handlers: [B],
115+
methodMatch: true,
116+
pattern: "/wissen/",
117+
});
118+
119+
const withoutSlash = router.match(
120+
"GET",
121+
new URL("/wissen", "http://localhost"),
122+
);
123+
expect(withoutSlash).toEqual({
124+
params: Object.create(null),
125+
handlers: [A],
126+
methodMatch: true,
127+
pattern: "/wissen",
128+
});
129+
});
130+
131+
Deno.test("UrlPatternRouter - root trailing slash does not double-match", () => {
132+
const router = new UrlPatternRouter();
133+
const A = () => {};
134+
router.add("GET", "/", [A]);
135+
136+
const res = router.match("GET", new URL("/", "http://localhost"));
137+
expect(res).toEqual({
138+
params: Object.create(null),
139+
handlers: [A],
140+
methodMatch: true,
141+
pattern: "/",
142+
});
143+
});
144+
73145
Deno.test("UrlPatternRouter - convert patterns automatically", () => {
74146
const router = new UrlPatternRouter();
75147
const A = () => {};

0 commit comments

Comments
 (0)