Skip to content

fix(vercel): correct ISR route rewrite query preservation and decoding#4409

Open
pi0x wants to merge 3 commits into
mainfrom
fix/vercel-isr-query-preserve
Open

fix(vercel): correct ISR route rewrite query preservation and decoding#4409
pi0x wants to merge 3 commits into
mainfrom
fix/vercel-isr-query-preserve

Conversation

@pi0x

@pi0x pi0x commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Closes #4408

This PR fixes two bugs in the Vercel ISR route rewrite (isrRouteRewrite), both verified end-to-end on a live Vercel deployment.

Bug 1 — dropped query string (#4408)

isrRouteRewrite handled its two branches asymmetrically. The x-now-route-matches branch — the path taken for Vercel prerender-function invocations — returned an empty query string, discarding every allowQuery/passQuery param:

if (xNowRouteMatches) {
  const isrURL = new URLSearchParams(xNowRouteMatches).get(ISR_URL_PARAM);
  if (isrURL) {
    return [decodeURIComponent(isrURL), ""]; // ← whole query dropped
  }
}

The header-less branch already preserved the query (deleting only __isr_route). So with a rule like { isr: { allowQuery: ["lang"], passQuery: true } }, Vercel correctly keys the cache by ?lang and forwards the query to the function, but the runtime threw it away — SSR rendered the bare path and the ISR cache stored that document under the query-keyed entry (e.g. English HTML served/cached at ?lang=es).

Fix

Source the preserved query solely from the rewritten request URL (dropping only __isr_route), and use x-now-route-matches only to read the ISR routing param.

allowQuery/passQuery params are forwarded by Vercel onto the rewritten request URL, so the request URL is the authoritative source. x-now-route-matches is intentionally not merged into the query: it carries the route regex capture groups — named (e.g. slug for /posts/:slug, generated by normalizeRouteSrc) as well as numeric (0, 1, …) — which are routing internals, not user query. Merging them would leak ?slug=… into the render and pollute the shared ISR cache entry.

This keeps both branches consistent and, as a side effect, preserves repeated (array-style) query params and never lets a header value override a real request-URL param.

Bug 2 — route param decoded twice (crash on %)

URLSearchParams.get() already percent-decodes the ISR routing param once. The extra decodeURIComponent(isrURL) decoded it a second time:

  • A slug containing a literal % (e.g. /posts/100%off) → after the first decode the string contains a bare %, and the second decodeURIComponent throws URIError: URI malformed → the function crashes with a 400 (FUNCTION_INVOCATION_FAILED).
  • A slug containing an encoded sequence (e.g. a%2520b) → silently over-decoded to a%20b, so ISR renders and caches under the wrong path.

This is masked for plain-ASCII paths (the once-decoded value has no %, so the second decode is a no-op), which is why it went unnoticed. It is pre-existing — the original header-less branch already double-decoded — and independent of #4408.

Fix

Drop the redundant decodeURIComponent; the URLSearchParams decode is exactly the right amount.

Testing

Added test/unit/vercel-isr.test.ts (12 cases). Query-preservation cases fail on main; the double-decode cases fail on main with URIError: URI malformed. Coverage:

  • header-less and header branches drop __isr_route and preserve other params
  • Vercel numeric (0) and named (slug) regex-capture groups are not leaked
  • repeated (array-style) query params are preserved
  • ISR route param is percent-decoded exactly once (literal %, encoded sequences, both branches)

End-to-end (live Vercel)

Built the playground/ app with an ISR route and deployed via vercel deploy --prebuilt:

  • /?lang=es → renders Hola and caches under the ?lang=es entry (/ still renders Hello) — confirms Bug 1 fixed and no cross-entry cache pollution. Non-allowQuery params (?foo=bar) are correctly stripped.
  • /posts/100%25off200 with the after fix (was 400 before), and encoded paths (a%2520b, caf%C3%A9, hello%20world) now match non-ISR behaviour — confirms Bug 2 fixed.

Credit to the reporter of #4408 for the diagnosis and original patch.

@pi0x
pi0x requested a review from pi0 as a code owner July 7, 2026 16:24
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nitro.build Ready Ready Preview, Comment Jul 7, 2026 5:14pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Reworks isrRouteRewrite so Vercel ISR rewrites preserve non-ISR query parameters from either x-now-route-matches or the request URL. Adds unit tests for missing parameters, query preservation, regex-capture filtering, repeated params, and single decoding.

Changes

ISR route rewrite fix

Layer / File(s) Summary
Rewrite query handling
src/presets/vercel/runtime/isr.ts
isrRouteRewrite precomputes request query params, reads __isr_route from x-now-route-matches when present or the request query otherwise, removes only the routing parameter, and returns the remaining query string without extra decoding.
Rewrite behavior tests
test/unit/vercel-isr.test.ts
The Vitest suite covers missing ISR parameters, header-less rewrites, x-now-route-matches rewrites, query pass-through regressions, regex-capture exclusions, repeated query parameters, and single decoding of the ISR route.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • nitrojs/nitro#3539: Both PRs adjust isrRouteRewrite's handling of __isr_route and x-now-route-matches in the Vercel preset runtime.
  • nitrojs/nitro#3851: Both PRs modify the same isrRouteRewrite function's query extraction and rewrite logic.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #4408 by preserving request query params, keeping capture groups out of the query, and fixing the ISR rewrite path.
Out of Scope Changes check ✅ Passed The extra decode-once fix and tests are still directly related to the ISR rewrite bug and its documented edge cases.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title follows conventional commits and accurately summarizes the main ISR rewrite fix.
Description check ✅ Passed The description clearly relates to the ISR query-preservation and decoding fixes in this changeset.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/vercel-isr-query-preserve

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/nitro@4409

commit: e7b56bc

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
test/unit/vercel-isr.test.ts (1)

26-39: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a case for conflicting values between matches and reqParams.

Current tests cover disjoint keys (lang present in one source but not the other) but not a case where both sources define the same key with different values, which would exercise the "first occurrence wins" precedence noted in Line 22 of isr.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/vercel-isr.test.ts` around lines 26 - 39, Add a regression test for
conflicting query values coming from both isrRouteRewrite inputs so the
precedence in isr.ts is covered. Extend test/unit/vercel-isr.test.ts near
isrRouteRewrite to include a case where x-now-route-matches and the request URL
both provide the same key with different values, and assert the function keeps
the first occurrence wins behavior. Use the existing isrRouteRewrite helper in
the new test to verify the merged output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/unit/vercel-isr.test.ts`:
- Around line 26-39: Add a regression test for conflicting query values coming
from both isrRouteRewrite inputs so the precedence in isr.ts is covered. Extend
test/unit/vercel-isr.test.ts near isrRouteRewrite to include a case where
x-now-route-matches and the request URL both provide the same key with different
values, and assert the function keeps the first occurrence wins behavior. Use
the existing isrRouteRewrite helper in the new test to verify the merged output.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: df4c7e94-dcb9-4ceb-987a-948a76d7efa2

📥 Commits

Reviewing files that changed from the base of the PR and between 2eaa45d and 2ef7ebd.

📒 Files selected for processing (2)
  • src/presets/vercel/runtime/isr.ts
  • test/unit/vercel-isr.test.ts

…tches is present

The `x-now-route-matches` branch of `isrRouteRewrite` returned an empty
query string, discarding every `allowQuery`/`passQuery` param (e.g.
`?lang`). The function then rendered the bare path and the ISR cache
stored that document under the query-keyed entry (e.g. English HTML
served at `?lang=es`). The header-less branch already preserved the
query.

Rebuild the query from both `x-now-route-matches` and the rewritten
request URL, skipping the ISR routing param and Vercel's numeric
regex-capture groups.

Closes #4408

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous rewrite merged `x-now-route-matches` into the preserved
query. That header carries route regex capture groups (named like
`slug`, numeric like `0`), not user query, so they leaked into the
render and polluted the shared ISR cache entry. `allowQuery` params are
forwarded by Vercel onto the rewritten request URL, so source the query
solely from there and use the header only to read `__isr_route`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`URLSearchParams` already percent-decodes the ISR routing param once, so
the extra `decodeURIComponent` decoded it a second time. This over-decoded
encoded slugs (e.g. `a%2520b` rendered/cached as `a%20b`) and threw
`URIError: URI malformed` on a literal `%` (e.g. `/posts/100%off`),
crashing the function with a 400. Pre-existing in the header-less branch.

Verified on a live Vercel deployment: `/posts/100%25off` returned 400
before and 200 after, and encoded paths now match non-ISR behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pi0x pi0x changed the title fix(vercel): preserve query string in ISR rewrite when x-now-route-matches is present fix(vercel): correct ISR route rewrite query preservation and decoding Jul 7, 2026
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.

Vercel ISR: isrRouteRewrite drops the entire query string when x-now-route-matches is present (allowQuery/passQuery params never reach the render)

2 participants