v0.11.0
⚠️ Migration guide
Most apps can upgrade with no changes. Route patterns now follow URLPattern more closely, so check the sections below that apply to how you use rou3.
A - ends a param name (#222, #230)
Param names are now [A-Za-z_]\w*, as in URLPattern. A - is no longer part of the name, so :year-:month is two params again.
| Pattern | Path | v0.10 | v0.11 |
|---|---|---|---|
/api/:test-id |
/api/abc |
{ "test-id": "abc" } |
no match |
/api/:test-id |
/api/abc-id |
{ "test-id": "abc-id" } |
{ test: "abc" } |
/blog/:year-:month |
/blog/2024-05 |
{ "year-": "2024-0", month: "5" } |
{ year: "2024", month: "05" } |
/blog/:id{-:title}? |
/blog/1-hi |
{ "id-": "1-h", title: "i" } |
{ id: "1", title: "hi" } |
- A
{or}also ends a name:/:a{b}?is the paramafollowed by an optionalb(it used to be one param namedab). InferRouteParamsreads names the same way, so the types change too (/blog/:year-:monthgives{ year, month }).
Migrate: rename params that contain -, e.g. :test-id → :test_id or :testId.
Several params in one segment: the first one takes as little as possible (#230)
When a segment holds more than one param, the first one now matches as little as it can, like URLPattern.
| Pattern | Path | v0.10 | v0.11 |
|---|---|---|---|
/:name.:ext |
/a.tar.gz |
{ name: "a.tar", ext: "gz" } |
{ name: "a", ext: "tar.gz" } |
/:a-:b |
/x-y-z |
{ "a-": "x-y-", b: "z" } |
{ a: "x", b: "y-z" } |
Migrate: to keep the old split, constrain the param that follows: /:name.:ext(\w+) gives { name: "a.tar", ext: "gz" }.
prefix-:param? makes only the param optional (#230)
A ? on a param that does not start its segment now makes only that param optional, not the whole segment.
| Pattern | Path | v0.10 | v0.11 |
|---|---|---|---|
/a/pre-:x? |
/a/pre-b |
{ x: "b" } |
{ x: "b" } |
/a/pre-:x? |
/a/pre- |
no match | match (no x) |
/a/pre-:x? |
/a |
match | no match |
Migrate: to make the whole segment optional, write /a/{pre-:x}?.
Required params never match an empty segment (#230)
A :name, :name+ or **:name now needs a value, as in URLPattern. Handlers no longer need to guard against "" for these params.
| Pattern | Path | v0.10 | v0.11 |
|---|---|---|---|
/foo/:bar |
/foo// |
{ bar: "" } |
no match |
/foo/:bar/x |
/foo//x |
{ bar: "" } |
no match |
/foo/:bar+ |
/foo// |
{ bar: "" } |
no match |
/foo/**:rest |
/foo// |
{ rest: "" } |
no match |
/foo/*, /foo/**, /foo/:bar* |
/foo// |
match | match (unchanged) |
Migrate: if you need to accept an empty segment, use *, **, :name*, or a constraint that allows empty, such as :id(\d*).
Method-agnostic routes are no longer hidden by method routes (#223)
A route added with method "" could disappear when a route for a specific method landed on the same place in the tree, even if that method route didn't match. Now both are always considered, and the more specific route wins. On a tie, the method route still wins.
addRoute(router, "", "/u/*", "any");
addRoute(router, "GET", "/u/:id(\\d+)", "get");
findRoute(router, "GET", "/u/abc"); // v0.10: undefined v0.11: "any"
findAllRoutes(router, "GET", "/u/42"); // v0.10: ["get"] v0.11: ["any", "get"]findAllRoutes(and compiledmatchAll) now return both routes, method-agnostic first, so a""middleware or auth route is no longer silently dropped.- A more specific
""route now beats a broader method route: with""/u/:id(\d+)andGET/u/*,GET /u/1returns the""route (v0.10 returnedGET /u/*).
Migrate: nothing to change unless you relied on a method route hiding a "" route on the same path. If so, expect the extra match in findAllRoutes.
Backslash escapes mean a literal character (#228)
Outside a regex constraint, any \x is now a literal x, as in URLPattern. Before, the router kept the backslash in static segments, while routeToRegExp already dropped it, so the two disagreed.
| Pattern | Path | v0.10 | v0.11 |
|---|---|---|---|
/foo\.bar |
/foo.bar |
no match | match |
/a\*b |
/a*b |
no match | match |
/a/\(:x |
/a/(1 |
no match | { x: "1" } |
\\is a literal backslash.- A
\/or a trailing\throws.
Migrate: if a route really needs a backslash in the path, write \\.
Regex constraints: no anchors, look-arounds, backreferences or capturing groups (#226, #228, #230)
These now throw, because the router tests a constraint against one segment while routeToRegExp puts it inside the whole-path regex, so they matched different paths.
| Pattern | v0.11 | Write instead |
|---|---|---|
/:slug((?!admin)\w+) |
throws (look-around) | /:slug(\w+) plus a static /admin route, which always wins over a param |
/:x(^\d+$) |
throws (anchor) | /:x(\d+) (constraints already match the whole segment) |
/:x((a)) |
throws (capturing group inside a constraint) | /:x((?:a)) |
/a/(), /a/(?:a|b), /a/(?<n>x) |
throws (empty or (? group) |
/a/:x((?:a|b)), /a/:n(x) |
Migrate: rewrite constraints as shown. Look-arounds have no direct replacement: register the excluded paths as their own routes or check the value in your handler.
Pattern syntax with no meaning now throws (#226, #230)
addRoute (and routeToRegExp) now throw rou3: <what> (<your pattern>) for syntax that used to be accepted with a surprising meaning. The error quotes the route, so failures show up at startup.
| Pattern | v0.10 | Write instead |
|---|---|---|
/a/:x(\d+)+, /a/pre-:x+ |
dropped the constraint or prefix (/a/:x(\d+)+ matched /a/b/c as { x: "b/c" }) |
+ / * only after a whole-segment :name (/a/:x+) |
/a/**?, /a/*+, /:x??, /p/**:x? |
accepted, with an unclear meaning | /p/:x* for an optional catch-all |
/foo? |
never matched (lookup paths have no query) | /foo\? for a literal ? |
/a**b |
read as two wildcards | /a*b, or /a\*\*b for literal stars |
/a/**:x.json, /a/**:x(\d+) |
param named "x.json" / "x(\d+)" |
a plain **:name as the whole last segment |
/:0, /:1st, /:café, /:id$ |
accepted as names | /:v1, /:_1, /:cafe, /:id\$ |
/a/:, /a/x: |
literal : |
/a/\: |
/a/:x/:x, /a/:x.:x |
later value won, or a raw SyntaxError |
unique names |
/a/{b, /a/b}, /a/{{b}} |
literal or mis-parsed braces | \{ / \} for literal braces, no nested groups |
/a/{b}+, /a{/b}* |
threw a non-rou3: error |
not supported (only {…} and {…}?) |
| U+FFFD–U+FFFF in a pattern | accepted | not allowed (used internally) |
Migrate: fix each pattern the error points to, using the right-hand column.
regExpToRoute rejects unanchored regexes and u / v flags (#218)
An unanchored regex matches any path that contains it, so turning it into a whole-path route was wrong. The u / v flags change what a constraint means, so they can't be represented either.
| Input | v0.10 | v0.11 |
|---|---|---|
/\/users\/(?<id>\d+)/ |
/users/:id(\d+) |
throws |
/^\/users/ |
/users |
throws |
/^\/a\/(?<x>\p{L}+)$/u |
/a/:x(\p{L}+) |
throws |
/^\/users$/ |
/users |
/users |
- Regexes produced by
routeToRegExpare always anchored and keep working. The one exception is the v0.10 output for:x(\d+)+-style routes, which now throws since that pattern itself is rejected.
Migrate: anchor hand-written regexes with ^…$ and drop the u / v flags.
compileRouterToString emits route data as plain JSON (#225)
Route data in the generated code now always goes through JSON.stringify, so the output is always valid code.
| Route data | v0.10 output | v0.11 output |
|---|---|---|
new Date(0) |
invalid code | "1970-01-01T00:00:00.000Z" |
{ toJSON: () => ({ a: 1 }) } |
invalid code | { a: 1 } |
{ toJSON: () => "code" } |
emitted code as raw JS |
emitted as the string "code" |
| a function, symbol or bigint (anywhere in the data) | silently dropped, or a raw TypeError |
throws a rou3: error |
compileRouterToStringnow takes an options object:compileRouterToString(router, { functionName, matchAll, serialize }). The old(router, "name", opts)form still works but is deprecated.RouterCompilerOptionsis deprecated in favor ofCompileRouterToStringOptionsandCompileRouterOptions.compileRouter<T>(router, { matchAll: true })is now typed as returning an array.
Migrate: if your data contains functions or used toJSON() to emit code, pass serialize: (data) => "<js expression>".
Node.js 20.19+ (#224)
package.json now declares "engines": { "node": ">=20.19.0" }.
No action needed
findRouteandfindAllRoutesreturn a fresh{ data, params }object on every call. Static matches andparams: falseresults no longer expose internal fields, and mutating a result no longer affects later lookups (#219).- Falsy route data (
0,false,"") is returned as-is instead ofnull. Missing data is stillnull(#219). findOverlappingRoutesreports a route with optional parts once, and reports distinct routes separately even when they share the same data object (#220).
🚀 Enhancements
- compiler:
⚠️ Options object,matchAlloverloads and JSON route data (#225)
🩹 Fixes
- regexp-to-route: Reject unanchored regexes and unicode flags (#218)
- router: End param names at a
-no word char follows (#222) - router:
⚠️ Never let a method-scoped route hide a method-agnostic one (#223) - router:
⚠️ Reject pattern syntax with no meaning yet (#226) - overlap: Dedupe findOverlappingRoutes by registration, not data reference (#220)
- find: Return fresh match objects from the static fast path (#219)
- router:
⚠️ Read any\xas a literalx, reject anchors in constraints (#228) ⚠️ Align route pattern syntax with URLPattern (#230)