From f5c01d3bb149323a8ab90fa85266e335c15d49d6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:13:49 +1000 Subject: [PATCH] fix(routing): omit empty optional catch-all params Optional catch-all routes currently materialize the missing segment list as an empty array. Next.js leaves the param key absent when the optional catch-all capture group does not match, so pages and app routes can observe a different params shape at the zero-segment path. The route trie treated zero remaining segments as a captured value. The fix keeps the route match while returning an empty params object for that zero-segment branch, and applies the same normalization to standalone app RSC pattern matching. Tests cover shared trie matching, app RSC matching, app route discovery, pages route discovery, and hyphenated optional catch-all params. --- packages/vinext/src/routing/route-trie.ts | 17 +++++++----- .../src/server/app-rsc-route-matching.ts | 15 ++++++++--- tests/app-rsc-route-matching.test.ts | 27 ++++++++++++++++--- tests/route-trie.test.ts | 23 +++++++++++++--- tests/routing.test.ts | 6 ++--- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/routing/route-trie.ts b/packages/vinext/src/routing/route-trie.ts index 16d46b64e..1f4d78fe8 100644 --- a/packages/vinext/src/routing/route-trie.ts +++ b/packages/vinext/src/routing/route-trie.ts @@ -133,6 +133,10 @@ export function trieMatch( return match(root, urlParts, 0); } +function createParams(): Record { + return Object.create(null); +} + function match( node: TrieNode, urlParts: string[], @@ -142,14 +146,15 @@ function match( if (index === urlParts.length) { // Exact match at this node if (node.route !== null) { - return { route: node.route, params: Object.create(null) }; + return { route: node.route, params: createParams() }; } // Optional catch-all with 0 segments if (node.optionalCatchAllChild !== null) { - const params: Record = Object.create(null); - params[node.optionalCatchAllChild.paramName] = []; - return { route: node.optionalCatchAllChild.route, params }; + return { + route: node.optionalCatchAllChild.route, + params: createParams(), + }; } return null; @@ -178,7 +183,7 @@ function match( // 3. Try catch-all (1+ remaining segments) if (node.catchAllChild !== null) { const remaining = urlParts.slice(index); - const params: Record = Object.create(null); + const params = createParams(); params[node.catchAllChild.paramName] = remaining; return { route: node.catchAllChild.route, params }; } @@ -186,7 +191,7 @@ function match( // 4. Try optional catch-all (0+ remaining segments) if (node.optionalCatchAllChild !== null) { const remaining = urlParts.slice(index); - const params: Record = Object.create(null); + const params = createParams(); params[node.optionalCatchAllChild.paramName] = remaining; return { route: node.optionalCatchAllChild.route, params }; } diff --git a/packages/vinext/src/server/app-rsc-route-matching.ts b/packages/vinext/src/server/app-rsc-route-matching.ts index ecf821e27..7a8729ae6 100644 --- a/packages/vinext/src/server/app-rsc-route-matching.ts +++ b/packages/vinext/src/server/app-rsc-route-matching.ts @@ -32,6 +32,10 @@ type AppRscInterceptLookupEntry = { params: readonly string[]; }; +function createRouteParams(): AppRscRouteParams { + return Object.create(null); +} + export function createAppRscRouteMatcher( routes: Route[], ): { @@ -55,7 +59,7 @@ export function createAppRscRouteMatcher( for (const entry of interceptLookup) { const params = matchAppRscRoutePattern(urlParts, entry.targetPatternParts); if (params !== null) { - let sourceParams: AppRscRouteParams = Object.create(null); + let sourceParams = createRouteParams(); if (sourcePathname !== null) { const sourceRoute = routes[entry.sourceRouteIndex]; const sourceParts = sourcePathname.split("/").filter(Boolean); @@ -103,7 +107,7 @@ export function matchAppRscRoutePattern( urlParts: string[], patternParts: string[], ): AppRscRouteParams | null { - const params: AppRscRouteParams = Object.create(null); + const params = createRouteParams(); for (let i = 0; i < patternParts.length; i++) { const patternPart = patternParts[i]; if (patternPart.startsWith(":") && patternPart.endsWith("+")) { @@ -117,7 +121,10 @@ export function matchAppRscRoutePattern( if (patternPart.startsWith(":") && patternPart.endsWith("*")) { if (i !== patternParts.length - 1) return null; const paramName = patternPart.slice(1, -1); - params[paramName] = urlParts.slice(i); + const remaining = urlParts.slice(i); + if (remaining.length > 0) { + params[paramName] = remaining; + } return params; } if (patternPart.startsWith(":")) { @@ -135,5 +142,5 @@ function mergeMatchedParams( sourceParams: AppRscRouteParams, targetParams: AppRscRouteParams, ): AppRscRouteParams { - return Object.assign(Object.create(null), sourceParams, targetParams); + return Object.assign(createRouteParams(), sourceParams, targetParams); } diff --git a/tests/app-rsc-route-matching.test.ts b/tests/app-rsc-route-matching.test.ts index 624511990..f46285749 100644 --- a/tests/app-rsc-route-matching.test.ts +++ b/tests/app-rsc-route-matching.test.ts @@ -22,9 +22,30 @@ describe("App RSC route matching", () => { route: { pattern: "/docs/:path+" }, params: { path: ["guides", "rsc"] }, }); - expect(matcher.matchRoute("/shop")).toMatchObject({ - route: { pattern: "/shop/:path*" }, - params: { path: [] }, + const result = matcher.matchRoute("/shop"); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/shop/:path*"); + expect(result!.params).toEqual({}); + }); + + it("omits optional catch-all params when zero segments are matched", () => { + // Next.js represents a missing optional catch-all param as absent at the + // route-match boundary; app rendering later treats that as the `null` + // tree segment for optional catch-all. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/route-matcher.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts + const matcher = createAppRscRouteMatcher([route("/shop/:path*", ["shop", ":path*"])]); + + const result = matcher.matchRoute("/shop"); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/shop/:path*"); + expect(result!.params).toEqual({}); + }); + + it("omits optional catch-all params from standalone route pattern matches", () => { + expect(matchAppRscRoutePattern(["shop"], ["shop", ":path*"])).toEqual({}); + expect(matchAppRscRoutePattern(["shop", "a", "b"], ["shop", ":path*"])).toEqual({ + path: ["a", "b"], }); }); diff --git a/tests/route-trie.test.ts b/tests/route-trie.test.ts index 0a0a9ca6d..e183e665b 100644 --- a/tests/route-trie.test.ts +++ b/tests/route-trie.test.ts @@ -115,13 +115,17 @@ describe("buildRouteTrie + trieMatch", () => { }); describe("optional catch-all routes", () => { - it("matches optional catch-all with zero segments", () => { + it("matches optional catch-all with zero segments without materializing the param", () => { + // Next.js route-regex makes the optional catch-all capture group optional, and + // route-matcher only writes params for defined capture groups: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/route-regex.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/route-matcher.ts const routes = [r("/docs/:path*")]; const trie = buildRouteTrie(routes); const result = trieMatch(trie, ["docs"]); expect(result).not.toBeNull(); - expect(result!.params).toEqual({ path: [] }); + expect(result!.params).toEqual({}); }); it("matches optional catch-all with one segment", () => { @@ -141,6 +145,16 @@ describe("buildRouteTrie + trieMatch", () => { expect(result).not.toBeNull(); expect(result!.params).toEqual({ path: ["api", "ref"] }); }); + + it("matches root-level optional catch-all with zero segments without materializing the param", () => { + const routes = [r("/:path*")]; + const trie = buildRouteTrie(routes); + + const result = trieMatch(trie, []); + expect(result).not.toBeNull(); + expect(result!.route.pattern).toBe("/:path*"); + expect(result!.params).toEqual({}); + }); }); describe("priority / precedence", () => { @@ -280,7 +294,10 @@ describe("buildRouteTrie + trieMatch", () => { if (pp.endsWith("*")) { if (i !== patternParts.length - 1) return null; const paramName = pp.slice(1, -1); - params[paramName] = urlParts.slice(i); + const remaining = urlParts.slice(i); + if (remaining.length > 0) { + params[paramName] = remaining; + } return params; } if (pp.startsWith(":")) { diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 0e39e81ac..b3cc159e8 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -984,7 +984,7 @@ describe("matchAppRoute - URL matching", () => { const result = matchAppRoute("/optional", routes); expect(result).not.toBeNull(); expect(result!.route.pattern).toBe("/optional/:path*"); - expect(result!.params.path).toEqual([]); + expect(result!.params).not.toHaveProperty("path"); }); it("matches optional catch-all with multiple segments", async () => { @@ -1518,7 +1518,7 @@ describe("matchAppRoute - URL matching", () => { const result = matchAppRoute("/sign-in", routes); expect(result).not.toBeNull(); expect(result!.route.pattern).toBe("/sign-in/:sign-in*"); - expect(result!.params["sign-in"]).toEqual([]); + expect(result!.params).not.toHaveProperty("sign-in"); }); it("matches hyphenated optional catch-all with segments", async () => { @@ -1666,7 +1666,7 @@ describe("pagesRouter - hyphenated param names", () => { const result = matchRoute("/sign-up", routes); expect(result).not.toBeNull(); expect(result!.route.pattern).toBe("/sign-up/:sign-up*"); - expect(result!.params["sign-up"]).toEqual([]); + expect(result!.params).not.toHaveProperty("sign-up"); }); it("matches hyphenated optional catch-all with segments", async () => {