diff --git a/docs/1.x/concepts/islands.md b/docs/1.x/concepts/islands.md index 9d1aa2f7f36..eeb331f0428 100644 --- a/docs/1.x/concepts/islands.md +++ b/docs/1.x/concepts/islands.md @@ -211,7 +211,7 @@ An error occurred during route handling or page rendering. ReferenceError: Event .... ``` -Use the [`IS_BROWSER`](https://deno.land/x/fresh/runtime.ts?doc=&s=IS_BROWSER) +Use the [`IS_BROWSER`](https://jsr.io/@fresh/core) flag as a guard to fix the issue: ```tsx islands/my-island.tsx diff --git a/docs/1.x/concepts/updating.md b/docs/1.x/concepts/updating.md index c935a0d8fc8..ead35bbcc1e 100644 --- a/docs/1.x/concepts/updating.md +++ b/docs/1.x/concepts/updating.md @@ -6,7 +6,7 @@ description: | Fresh consists of multiple pieces which are independently versioned and released. -- Fresh (https://deno.land/x/fresh) +- Fresh (https://jsr.io/@fresh/core) - Preact (https://esm.sh/preact) - preact-render-to-string (https://esm.sh/preact-render-to-string) diff --git a/packages/fresh/src/router.ts b/packages/fresh/src/router.ts index c0bbfa9293a..9c76835ac7e 100644 --- a/packages/fresh/src/router.ts +++ b/packages/fresh/src/router.ts @@ -54,6 +54,40 @@ export const IS_PATTERN = /[*:{}+?()]/; const EMPTY: string[] = []; +/** + * Compare two URLPattern pathnames by specificity (less specific sorts later). + * + * Static segments win over required params win over wildcards. Score per + * segment: static = 0, `:param` / optional groups / regex groups = 1, `*` + * catch-all = 2. Total score is the sum across segments. Ties preserve + * insertion order (Array.prototype.sort is stable in V8). + */ +export function compareDynamicPatternSpecificity( + a: DynamicRouteDef, + b: DynamicRouteDef, +): number { + return patternSpecificityScore(a.pattern.pathname) - + patternSpecificityScore(b.pattern.pathname); +} + +function patternSpecificityScore(pathname: string): number { + let score = 0; + for (let i = 0; i < pathname.length; i++) { + const ch = pathname.charCodeAt(i); + if (ch === 0x2A) { // '*' + score += 2; + } else if ( + ch === 0x3A || // ':' + ch === 0x3F || // '?' (optional marker) + ch === 0x28 || ch === 0x29 || // '(' ')' + ch === 0x7B || ch === 0x7D // '{' '}' + ) { + score += 1; + } + } + return score; +} + export class UrlPatternRouter implements Router { #statics = new Map>(); #dynamics = new Map>(); @@ -89,6 +123,11 @@ export class UrlPatternRouter implements Router { }; this.#dynamics.set(pathname, def); this.#dynamicArr.push(def); + // Keep dynamic routes sorted by specificity so a more-specific + // pattern registered later wins over an earlier wildcard catch-all. + // Without this, app.route("/blog/[...rest]", A) followed by + // app.route("/blog/[id]", B) matches A on /blog/foo and shadows B. + this.#dynamicArr.sort(compareDynamicPatternSpecificity); } byMethod = def.byMethod; diff --git a/packages/fresh/src/router_test.ts b/packages/fresh/src/router_test.ts index 479f2f8439c..0ab449d36d6 100644 --- a/packages/fresh/src/router_test.ts +++ b/packages/fresh/src/router_test.ts @@ -1,5 +1,6 @@ import { expect } from "@std/expect"; import { + compareDynamicPatternSpecificity, IS_PATTERN, mergePath, pathToPattern, @@ -319,3 +320,55 @@ Deno.test("UrlPatternRouter - non-standard method on dynamic route", () => { pattern: "/books/:id", }); }); + +Deno.test("UrlPatternRouter - more specific dynamic route matches over catch-all", () => { + const router = new UrlPatternRouter<() => string>(); + const catchAll = () => "catch-all"; + const specific = () => "specific"; + + // Catch-all registered first — would shadow the specific route without sorting. + router.add("GET", "/blog/*", catchAll); + router.add("GET", "/blog/:id", specific); + + const res = router.match("GET", new URL("/blog/42", "http://localhost")); + expect(res.item).toBe(specific); + expect(res.pattern).toBe("/blog/:id"); +}); + +Deno.test("UrlPatternRouter - dynamic route beats optional route", () => { + const router = new UrlPatternRouter<() => string>(); + const optional = () => "optional"; + const required = () => "required"; + + router.add("GET", "/api{/:opt}?", optional); + router.add("GET", "/api/:id", required); + + // /api/123 should match the required, not the optional + const requiredRes = router.match( + "GET", + new URL("/api/123", "http://localhost"), + ); + expect(requiredRes.item).toBe(required); + expect(requiredRes.pattern).toBe("/api/:id"); + + // /api alone still matches the optional + const optionalRes = router.match("GET", new URL("/api", "http://localhost")); + expect(optionalRes.item).toBe(optional); +}); + +Deno.test("compareDynamicPatternSpecificity - static beats param beats wildcard", () => { + const mk = (pathname: string) => ({ + pattern: new URLPattern({ pathname }), + byMethod: { GET: null, POST: null, PATCH: null, DELETE: null, PUT: null, HEAD: null, OPTIONS: null }, + }); + + const stat = mk("/foo/bar"); + const dyn = mk("/foo/:bar"); + const wild = mk("/foo/*"); + + expect(compareDynamicPatternSpecificity(stat, dyn)).toBeLessThan(0); + expect(compareDynamicPatternSpecificity(dyn, wild)).toBeLessThan(0); + expect(compareDynamicPatternSpecificity(stat, wild)).toBeLessThan(0); + // Tie preserves insertion order (returns 0) + expect(compareDynamicPatternSpecificity(dyn, mk("/baz/:qux"))).toBe(0); +});