Skip to content

fix: pass url string to URLPattern.exec() in dynamic route matching - #3901

Open
SisyphusZheng wants to merge 1 commit into
freshframework:mainfrom
SisyphusZheng:fix/urlpattern-exec-string
Open

fix: pass url string to URLPattern.exec() in dynamic route matching#3901
SisyphusZheng wants to merge 1 commit into
freshframework:mainfrom
SisyphusZheng:fix/urlpattern-exec-string

Conversation

@SisyphusZheng

Copy link
Copy Markdown
Contributor

Problem

UrlPatternRouter.match() passes the URL object directly to route.pattern.exec(url) in the dynamic route loop:

https://github.com/freshframework/fresh/blob/86d6cde/packages/fresh/src/router.ts#L158-L169

Deno's URLPattern is a pure JS implementation. When exec() receives an object, webidl.converters["URLPatternInput"] walks and converts the object's properties on every call — and that conversion happens before the internal match cache is consulted, so the cache never helps. Passing a string skips the conversion layer entirely and hits the cache directly.

This is the remaining hot spot of #1931: the static-route Map added back then addressed the static case, but every dynamic-route exec() call still pays the WebIDL conversion cost.

Benchmark

6 route patterns (/, /about, /blog/:slug, /api/v1/users/:id/posts/:postId, /docs/:path*, /greet/:name), round-robin exec(), median of 5 rounds × 100,000 calls:

input Deno 2.9.0 (aarch64-darwin) Node 24.18.0 (native URLPattern)
exec(url) — URL object 4348 ns/call 2376 ns/call
exec(url.href) — string 221 ns/call 1220 ns/call
speedup 19.7x 1.9x
  • Reading url.href costs ~2 ns (URL objects cache their serialization), so the switch itself is free.
  • Per call this saves ~4 µs on Deno; per request the saving scales with the number of dynamic routes checked before a match is found (e.g. ~20 dynamic routes ≈ ~87 µs per request that misses static matching). Infrastructure-level micro-optimization, no behavior change.
  • The win is specific to runtimes with a JS-based URLPattern (Deno). On runtimes with a native implementation the difference is small but points in the same direction — no regression.
benchmark script
const patterns = [
  "/",
  "/about",
  "/blog/:slug",
  "/api/v1/users/:id/posts/:postId",
  "/docs/:path*",
  "/greet/:name",
];
const pats = patterns.map((p) => new URLPattern({ pathname: p }));
const url = new URL("https://fresh.example.com/blog/hello-world");
const href = url.href;

const ROUNDS = 5;
const N = 100000;

function bench(fn) {
  for (let i = 0; i < 10000; i++) fn(i); // warm-up
  const samples = [];
  for (let r = 0; r < ROUNDS; r++) {
    const t0 = performance.now();
    let sink = 0;
    for (let i = 0; i < N; i++) {
      if (fn(i)) sink++;
    }
    samples.push(((performance.now() - t0) * 1e6) / N);
  }
  samples.sort((a, b) => a - b);
  return samples[Math.floor(ROUNDS / 2)]; // median ns/call
}

const execUrl = bench((i) => pats[i % pats.length].exec(url));
const execStr = bench((i) => pats[i % pats.length].exec(href));
console.log("exec(url)     :", execUrl.toFixed(0), "ns/call");
console.log("exec(url.href):", execStr.toFixed(0), "ns/call");
console.log("speedup       :", (execUrl / execStr).toFixed(1) + "x");

Change

One line plus a comment: pass url.href instead of the URL object.

-      const match = route.pattern.exec(url);
+      const match = route.pattern.exec(url.href);

Semantics

UrlPatternRouter only compiles { pathname } patterns — every other component compiles to * — and only match.pathname.groups is consumed afterwards. For pathname-only patterns, exec(url) and exec(url.href) are equivalent: serializing the URL and re-parsing the string yields exactly the component values the object conversion would read.

Verified differentially on Deno 2.9.0 and Node 24.18.0: 10 pathname patterns × 20 URLs (query strings, hashes, percent-encoded paths, mixed-case hosts, ports, userinfo, trailing slashes) = 200 combos per runtime, full match results compared (minus the inputs field, which by spec echoes the original input) — 0 mismatches.

Testing

  • deno lint, deno task check:types, deno fmt (changed file): pass
  • Router unit tests: 16/16 pass, plus the rest of the non-browser unit suite in packages/fresh/src (205 tests total)
  • No behavior change; the full CI matrix will re-verify

Refs #1931


This PR was prepared with AI assistance for benchmarking and analysis; the change itself is the one-liner above.

Deno's URLPattern implementation runs
webidl.converters["URLPatternInput"] when exec() receives a URL object.
That conversion walks the object's properties on every call and happens
before the internal match cache is consulted, so the cache never helps.
Passing the serialized url skips the conversion layer entirely and hits
the cache directly.

Measured with 6 route patterns, median of 5 rounds x 100k calls:

- Deno 2.9.0 (aarch64-darwin): 4348 -> 221 ns/call (~19.7x)
- Node 24.18.0 (native URLPattern): 2376 -> 1220 ns/call (~1.9x)

The win is specific to runtimes with a JS-based URLPattern (Deno); on
runtimes with a native implementation there is no regression. Reading
url.href costs ~2ns because URL objects cache their serialization.

Refs freshframework#1931
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant