Skip to content

fix(routing): omit empty optional catch-all params#987

Merged
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/fix-optional-catchall-params
Apr 30, 2026
Merged

fix(routing): omit empty optional catch-all params#987
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/fix-optional-catchall-params

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

Optional catch-all routes now omit the param key when the route matches with zero remaining segments. For example, /shop matching /shop/[[...path]] now produces {} instead of { path: [] }, while /shop/a/b still produces { path: ["a", "b"] }.

Why

Next.js treats the zero-segment optional catch-all case as a missing capture, not as an empty captured array. The relevant source path is:

  • route-regex.ts emits an optional repeated capture for optional catch-all params.
  • route-matcher.ts only writes a param when the regex capture is defined.
  • get-dynamic-param.test.ts covers the downstream app-router interpretation of an absent optional catch-all param as a null tree segment.

vinext's route trie instead materialized the zero-segment optional catch-all as [], which made app/pages route params observably diverge from Next.js.

Approach

The fix keeps app shape in codegen and behavior in normal modules:

  • Update the shared route trie so the zero-segment optional catch-all branch returns an empty params object while non-empty optional catch-all matches still assign the remaining segment array.
  • Apply the same absent-key semantics to standalone app RSC pattern matching used outside the route trie.
  • Add regression coverage at the trie boundary, the app RSC matcher boundary, and the pages/app route fixture boundary, including hyphenated param names.

Validation

  • vp test run tests/route-trie.test.ts tests/app-rsc-route-matching.test.ts tests/routing.test.ts
  • vp test run tests/pages-router.test.ts tests/app-router.test.ts -t "optional catch-all"
  • vp check packages/vinext/src/routing/route-trie.ts packages/vinext/src/server/app-rsc-route-matching.ts tests/route-trie.test.ts tests/app-rsc-route-matching.test.ts tests/routing.test.ts

Risks / follow-ups

This is a compatibility change for the zero-segment optional catch-all params shape. Code that depended on vinext's previous [] value for missing optional catch-all params will need to use the same params.slug ?? [] pattern that works in Next.js.

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.
Copilot AI review requested due to automatic review settings April 30, 2026 07:14
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 30, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@987

commit: f5c01d3

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped parity fix. The change is correct: Next.js's route-matcher.ts only writes a param when the regex capture is defined, so optional catch-all with zero segments should produce {}, not { path: [] }. Both changed files (route-trie.ts and app-rsc-route-matching.ts) are consistent with each other, the test coverage is thorough (trie boundary, RSC matcher boundary, integration fixtures with hyphenated names), and downstream consumers are already safe:

  • resolveAppPageSegmentParams in app-page-params.ts handles both undefined and empty arrays via isEmptyOptionalCatchAll
  • pickRouteParams in app-page-request.ts explicitly checks value !== undefined
  • buildUrlFromParams in prerender.ts produces the same URL for both undefined and []
  • The test fixture pages already use the params.path ?? [] pattern, which is the standard Next.js idiom

The createParams() / createRouteParams() helper extraction is a nice cleanup too — reduces duplication of Object.create(null) and makes the null-prototype intent obvious at a glance.

One minor nit below on the linear matcher parity test, but not blocking.

Comment thread tests/route-trie.test.ts
Comment on lines 294 to 301
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this linearMatchPattern helper is the reference implementation that the parity fuzz test (below) compares the trie against. Updating it in the same PR as the trie change is correct — the two must stay in sync — but it's worth a one-line comment here noting why, so a future reader doesn't wonder if the test is just tautologically matching the implementation.

Something like:

Suggested change
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.endsWith("*")) {
if (i !== patternParts.length - 1) return null;
const paramName = pp.slice(1, -1);
const remaining = urlParts.slice(i);
// Match Next.js: omit key when zero segments captured (parity with trie)
if (remaining.length > 0) {
params[paramName] = remaining;
}
return params;
}

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review submitted. Here's a summary of what I found:

Verdict: Approved

This is a clean, well-scoped Next.js parity fix. The change correctly aligns vinext with Next.js behavior where optional catch-all routes with zero matched segments produce {} (key absent) instead of { path: [] }.

Key findings from the review:

  1. Correctness -- The change matches Next.js's route-matcher.ts behavior exactly: params are only written when the regex capture group is defined (non-empty).

  2. Consistency -- Both route-trie.ts and app-rsc-route-matching.ts are updated with the same semantics, keeping the two matchers in sync.

  3. Downstream safety -- All downstream consumers already handle the undefined case correctly:

    • resolveAppPageSegmentParams checks for both undefined and empty arrays
    • pickRouteParams explicitly checks value !== undefined
    • buildUrlFromParams produces the same URL for both cases
    • Test fixture pages use params.path ?? [], the standard Next.js pattern
  4. Test coverage -- Regression tests added at three boundaries: trie, RSC matcher, and integration (including hyphenated param names).

  5. One minor nit -- Suggested adding a clarifying comment to the linearMatchPattern parity helper in the test file to explain why it was updated alongside the trie.

github run

@james-elicx james-elicx merged commit 1e4c175 into cloudflare:main Apr 30, 2026
28 of 29 checks passed
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.

3 participants