Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/1.x/concepts/islands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/1.x/concepts/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
39 changes: 39 additions & 0 deletions packages/fresh/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>,
b: DynamicRouteDef<unknown>,
): 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<T> implements Router<T> {
#statics = new Map<string, StaticRouteDef<T>>();
#dynamics = new Map<string, DynamicRouteDef<T>>();
Expand Down Expand Up @@ -89,6 +123,11 @@ export class UrlPatternRouter<T> implements Router<T> {
};
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;
Expand Down
53 changes: 53 additions & 0 deletions packages/fresh/src/router_test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from "@std/expect";
import {
compareDynamicPatternSpecificity,
IS_PATTERN,
mergePath,
pathToPattern,
Expand Down Expand Up @@ -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);
});