From 8414e620ec1302cb8cd731f254c9ecf54da866d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 28 Mar 2026 09:49:11 +0100 Subject: [PATCH 1/2] fix: trailing slash mismatch causes 404 for static routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router did exact-match on url.pathname for static routes, so a route registered as `/wissen` would never match a request for `/wissen/`. This broke `trailingSlashes("always")` — it redirected to the trailing slash URL, but the router couldn't find the route. Now the router tries the alternate trailing slash form when the exact match fails. Routes registered as `/foo` match requests for `/foo/` and vice versa. Closes #3644 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/router.ts | 20 +++++++++++++-- packages/fresh/src/router_test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/fresh/src/router.ts b/packages/fresh/src/router.ts index b731f7b9ce2..f26736c9373 100644 --- a/packages/fresh/src/router.ts +++ b/packages/fresh/src/router.ts @@ -116,9 +116,25 @@ export class UrlPatternRouter implements Router { pattern: null, }; - const staticMatch = this.#statics.get(url.pathname); + let pathname = url.pathname; + let staticMatch = this.#statics.get(pathname); + + // Try alternate trailing slash form if no exact match found. + // Routes are registered without trailing slashes, but requests + // may arrive with them (e.g. when using trailingSlashes("always")). + if (staticMatch === undefined && pathname !== "/") { + const alt = pathname.endsWith("/") + ? pathname.slice(0, -1) + : pathname + "/"; + const altMatch = this.#statics.get(alt); + if (altMatch !== undefined) { + staticMatch = altMatch; + pathname = alt; + } + } + if (staticMatch !== undefined) { - result.pattern = url.pathname; + result.pattern = pathname; let handlers = staticMatch.byMethod[method]; if (method === "HEAD" && handlers.length === 0) { diff --git a/packages/fresh/src/router_test.ts b/packages/fresh/src/router_test.ts index ae082a43b6a..ae674e2e6ea 100644 --- a/packages/fresh/src/router_test.ts +++ b/packages/fresh/src/router_test.ts @@ -70,6 +70,48 @@ Deno.test("UrlPatternRouter - wrong + correct method", () => { }); }); +Deno.test("UrlPatternRouter - trailing slash matches route without slash", () => { + const router = new UrlPatternRouter(); + const A = () => {}; + router.add("GET", "/wissen", [A]); + + const res = router.match("GET", new URL("/wissen/", "http://localhost")); + expect(res).toEqual({ + params: Object.create(null), + handlers: [A], + methodMatch: true, + pattern: "/wissen", + }); +}); + +Deno.test("UrlPatternRouter - no trailing slash matches route with slash", () => { + const router = new UrlPatternRouter(); + const A = () => {}; + router.add("GET", "/wissen/", [A]); + + const res = router.match("GET", new URL("/wissen", "http://localhost")); + expect(res).toEqual({ + params: Object.create(null), + handlers: [A], + methodMatch: true, + pattern: "/wissen/", + }); +}); + +Deno.test("UrlPatternRouter - root trailing slash does not double-match", () => { + const router = new UrlPatternRouter(); + const A = () => {}; + router.add("GET", "/", [A]); + + const res = router.match("GET", new URL("/", "http://localhost")); + expect(res).toEqual({ + params: Object.create(null), + handlers: [A], + methodMatch: true, + pattern: "/", + }); +}); + Deno.test("UrlPatternRouter - convert patterns automatically", () => { const router = new UrlPatternRouter(); const A = () => {}; From c2f541174fe870488a44bd56e587fb24df78d050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 20:59:33 +0200 Subject: [PATCH 2/2] fix: clarify comment and add exact-match priority test - Update comment to reflect that routes can be registered with or without trailing slashes, not just without. - Add test asserting exact match takes priority when both /path and /path/ are registered as separate routes. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/router.ts | 5 +++-- packages/fresh/src/router_test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/fresh/src/router.ts b/packages/fresh/src/router.ts index f26736c9373..d32a1fb68e6 100644 --- a/packages/fresh/src/router.ts +++ b/packages/fresh/src/router.ts @@ -120,8 +120,9 @@ export class UrlPatternRouter implements Router { let staticMatch = this.#statics.get(pathname); // Try alternate trailing slash form if no exact match found. - // Routes are registered without trailing slashes, but requests - // may arrive with them (e.g. when using trailingSlashes("always")). + // Routes may be registered with or without trailing slashes, + // and requests may arrive in either form (e.g. when using + // trailingSlashes("always")). if (staticMatch === undefined && pathname !== "/") { const alt = pathname.endsWith("/") ? pathname.slice(0, -1) diff --git a/packages/fresh/src/router_test.ts b/packages/fresh/src/router_test.ts index ae674e2e6ea..1776754173b 100644 --- a/packages/fresh/src/router_test.ts +++ b/packages/fresh/src/router_test.ts @@ -98,6 +98,36 @@ Deno.test("UrlPatternRouter - no trailing slash matches route with slash", () => }); }); +Deno.test("UrlPatternRouter - exact match takes priority over trailing slash fallback", () => { + const router = new UrlPatternRouter(); + const A = () => {}; + const B = () => {}; + router.add("GET", "/wissen", [A]); + router.add("GET", "/wissen/", [B]); + + const withSlash = router.match( + "GET", + new URL("/wissen/", "http://localhost"), + ); + expect(withSlash).toEqual({ + params: Object.create(null), + handlers: [B], + methodMatch: true, + pattern: "/wissen/", + }); + + const withoutSlash = router.match( + "GET", + new URL("/wissen", "http://localhost"), + ); + expect(withoutSlash).toEqual({ + params: Object.create(null), + handlers: [A], + methodMatch: true, + pattern: "/wissen", + }); +}); + Deno.test("UrlPatternRouter - root trailing slash does not double-match", () => { const router = new UrlPatternRouter(); const A = () => {};