From 08e44a23441364b40d5c66bb4e9846d076c27682 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 7 Jul 2026 16:24:03 +0000 Subject: [PATCH 1/3] fix(vercel): preserve query string in ISR rewrite when x-now-route-matches 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) --- src/presets/vercel/runtime/isr.ts | 37 ++++++++++++++++------- test/unit/vercel-isr.test.ts | 50 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 test/unit/vercel-isr.test.ts diff --git a/src/presets/vercel/runtime/isr.ts b/src/presets/vercel/runtime/isr.ts index 10c5c0f7ca..f1dcc57670 100644 --- a/src/presets/vercel/runtime/isr.ts +++ b/src/presets/vercel/runtime/isr.ts @@ -4,20 +4,35 @@ export function isrRouteRewrite( reqUrl: string, xNowRouteMatches: string | null ): [pathname: string, search: string] | undefined { + const queryIndex = reqUrl.indexOf("?"); if (xNowRouteMatches) { - const isrURL = new URLSearchParams(xNowRouteMatches).get(ISR_URL_PARAM); + const matches = new URLSearchParams(xNowRouteMatches); + const isrURL = matches.get(ISR_URL_PARAM); if (isrURL) { - return [decodeURIComponent(isrURL), ""]; - } - } else { - const queryIndex = reqUrl.indexOf("?"); - if (queryIndex !== -1) { - const params = new URLSearchParams(reqUrl.slice(queryIndex + 1)); - const isrURL = params.get(ISR_URL_PARAM); - if (isrURL) { - params.delete(ISR_URL_PARAM); - return [decodeURIComponent(isrURL), params.toString()]; + // 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 (`0`, `1`, ...). Without this, `allowQuery` + // params (e.g. `?lang`) are dropped and the ISR cache stores the + // bare-path document under the query-keyed entry. + const sources = + queryIndex === -1 + ? [matches] + : [matches, new URLSearchParams(reqUrl.slice(queryIndex + 1))]; + const params = new URLSearchParams(); + for (const source of sources) { + for (const [key, value] of source) { + if (key === ISR_URL_PARAM || /^\d+$/.test(key)) continue; + if (!params.has(key)) params.set(key, value); + } } + return [decodeURIComponent(isrURL), params.toString()]; + } + } else if (queryIndex !== -1) { + const reqParams = new URLSearchParams(reqUrl.slice(queryIndex + 1)); + const isrURL = reqParams.get(ISR_URL_PARAM); + if (isrURL) { + reqParams.delete(ISR_URL_PARAM); + return [decodeURIComponent(isrURL), reqParams.toString()]; } } } diff --git a/test/unit/vercel-isr.test.ts b/test/unit/vercel-isr.test.ts new file mode 100644 index 0000000000..f5a4a50d89 --- /dev/null +++ b/test/unit/vercel-isr.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { isrRouteRewrite } from "../../src/presets/vercel/runtime/isr.ts"; + +describe("isrRouteRewrite", () => { + it("returns undefined when there is no ISR routing param", () => { + expect(isrRouteRewrite("/index-isr", null)).toBeUndefined(); + expect(isrRouteRewrite("/index-isr?foo=bar", null)).toBeUndefined(); + expect(isrRouteRewrite("/index-isr", "foo=bar")).toBeUndefined(); + }); + + describe("header-less branch (query on request URL)", () => { + it("rewrites to the ISR route and drops the routing param", () => { + expect(isrRouteRewrite("/index-isr?__isr_route=%2F", null)).toEqual(["/", ""]); + }); + + it("preserves other query params", () => { + expect(isrRouteRewrite("/index-isr?__isr_route=%2F&lang=es", null)).toEqual(["/", "lang=es"]); + }); + }); + + describe("x-now-route-matches branch", () => { + it("rewrites to the ISR route", () => { + expect(isrRouteRewrite("/index-isr?__isr_route=%2F", "__isr_route=%2F")).toEqual(["/", ""]); + }); + + // Regression: https://github.com/nitrojs/nitro/issues/4408 + // The query string (allowQuery/passQuery params) must reach the render. + it("preserves allowQuery params from x-now-route-matches", () => { + expect( + isrRouteRewrite("/index-isr?__isr_route=%2F&lang=es", "__isr_route=%2F&lang=es") + ).toEqual(["/", "lang=es"]); + }); + + it("preserves allowQuery params from the request URL", () => { + expect(isrRouteRewrite("/index-isr?__isr_route=%2F&lang=es", "__isr_route=%2F")).toEqual([ + "/", + "lang=es", + ]); + }); + + it("skips Vercel numeric regex-capture groups", () => { + expect( + isrRouteRewrite( + "/posts/hello-isr?__isr_route=%2Fposts%2Fhello&lang=es", + "0=%2Fposts%2Fhello&__isr_route=%2Fposts%2Fhello&lang=es" + ) + ).toEqual(["/posts/hello", "lang=es"]); + }); + }); +}); From 335d4e1d5d717c9c761db8ea604816a3d08ba8c4 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 7 Jul 2026 16:55:29 +0000 Subject: [PATCH 2/3] fix(vercel): source ISR rewrite query from request URL only 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) --- src/presets/vercel/runtime/isr.ts | 47 +++++++++++-------------------- test/unit/vercel-isr.test.ts | 19 +++++++++++++ 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/presets/vercel/runtime/isr.ts b/src/presets/vercel/runtime/isr.ts index f1dcc57670..ff7d7bb575 100644 --- a/src/presets/vercel/runtime/isr.ts +++ b/src/presets/vercel/runtime/isr.ts @@ -5,34 +5,21 @@ export function isrRouteRewrite( xNowRouteMatches: string | null ): [pathname: string, search: string] | undefined { const queryIndex = reqUrl.indexOf("?"); - if (xNowRouteMatches) { - const matches = new URLSearchParams(xNowRouteMatches); - const isrURL = matches.get(ISR_URL_PARAM); - if (isrURL) { - // 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 (`0`, `1`, ...). Without this, `allowQuery` - // params (e.g. `?lang`) are dropped and the ISR cache stores the - // bare-path document under the query-keyed entry. - const sources = - queryIndex === -1 - ? [matches] - : [matches, new URLSearchParams(reqUrl.slice(queryIndex + 1))]; - const params = new URLSearchParams(); - for (const source of sources) { - for (const [key, value] of source) { - if (key === ISR_URL_PARAM || /^\d+$/.test(key)) continue; - if (!params.has(key)) params.set(key, value); - } - } - return [decodeURIComponent(isrURL), params.toString()]; - } - } else if (queryIndex !== -1) { - const reqParams = new URLSearchParams(reqUrl.slice(queryIndex + 1)); - const isrURL = reqParams.get(ISR_URL_PARAM); - if (isrURL) { - reqParams.delete(ISR_URL_PARAM); - return [decodeURIComponent(isrURL), reqParams.toString()]; - } - } + const reqParams = + queryIndex === -1 ? new URLSearchParams() : new URLSearchParams(reqUrl.slice(queryIndex + 1)); + + // The ISR routing param is carried by `x-now-route-matches` when Vercel + // rewrites via the route regex, otherwise it lives on the request URL. + const isrURL = xNowRouteMatches + ? new URLSearchParams(xNowRouteMatches).get(ISR_URL_PARAM) + : reqParams.get(ISR_URL_PARAM); + if (!isrURL) return; + + // Preserve `allowQuery` params, which Vercel forwards onto the rewritten + // request URL. `x-now-route-matches` is intentionally not merged in: it + // carries the route regex capture groups (named like `slug`, numeric like + // `0`), not user query, and merging them would pollute the render and the + // shared ISR cache entry. + reqParams.delete(ISR_URL_PARAM); + return [decodeURIComponent(isrURL), reqParams.toString()]; } diff --git a/test/unit/vercel-isr.test.ts b/test/unit/vercel-isr.test.ts index f5a4a50d89..b700295415 100644 --- a/test/unit/vercel-isr.test.ts +++ b/test/unit/vercel-isr.test.ts @@ -46,5 +46,24 @@ describe("isrRouteRewrite", () => { ) ).toEqual(["/posts/hello", "lang=es"]); }); + + // Regression: named route capture groups (e.g. `slug` for `/posts/:slug`) + // are emitted by normalizeRouteSrc and echoed in `x-now-route-matches`. + // They must not leak into the preserved query. + it("skips Vercel named regex-capture groups", () => { + expect( + isrRouteRewrite( + "/posts/hello-isr?__isr_route=%2Fposts%2Fhello&lang=es", + "slug=hello&__isr_route=%2Fposts%2Fhello&lang=es" + ) + ).toEqual(["/posts/hello", "lang=es"]); + }); + + it("preserves repeated (array-style) query params", () => { + expect(isrRouteRewrite("/index-isr?__isr_route=%2F&tag=a&tag=b", "__isr_route=%2F")).toEqual([ + "/", + "tag=a&tag=b", + ]); + }); }); }); From e7b56bc1caf38378bf2376b97dfbbbe8df62f547 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 7 Jul 2026 17:11:54 +0000 Subject: [PATCH 3/3] fix(vercel): decode ISR route param exactly once `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) --- src/presets/vercel/runtime/isr.ts | 4 +++- test/unit/vercel-isr.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/presets/vercel/runtime/isr.ts b/src/presets/vercel/runtime/isr.ts index ff7d7bb575..c7005fd8d8 100644 --- a/src/presets/vercel/runtime/isr.ts +++ b/src/presets/vercel/runtime/isr.ts @@ -10,6 +10,8 @@ export function isrRouteRewrite( // The ISR routing param is carried by `x-now-route-matches` when Vercel // rewrites via the route regex, otherwise it lives on the request URL. + // `URLSearchParams` already percent-decodes the value once; decoding again + // would over-decode encoded slugs and throw `URIError` on a literal `%`. const isrURL = xNowRouteMatches ? new URLSearchParams(xNowRouteMatches).get(ISR_URL_PARAM) : reqParams.get(ISR_URL_PARAM); @@ -21,5 +23,5 @@ export function isrRouteRewrite( // `0`), not user query, and merging them would pollute the render and the // shared ISR cache entry. reqParams.delete(ISR_URL_PARAM); - return [decodeURIComponent(isrURL), reqParams.toString()]; + return [isrURL, reqParams.toString()]; } diff --git a/test/unit/vercel-isr.test.ts b/test/unit/vercel-isr.test.ts index b700295415..31ad2f0315 100644 --- a/test/unit/vercel-isr.test.ts +++ b/test/unit/vercel-isr.test.ts @@ -66,4 +66,33 @@ describe("isrRouteRewrite", () => { ]); }); }); + + // Regression: the routing param is already percent-decoded once by + // `URLSearchParams`, so it must NOT be decoded a second time. A redundant + // `decodeURIComponent` over-decodes encoded slugs and throws `URIError` on a + // literal `%` (e.g. `/posts/100%off`), crashing the function. + describe("percent-encoded routes are decoded exactly once", () => { + it("does not throw on a route containing a literal percent", () => { + // Vercel delivers slug `100%off` as `__isr_route=%2Fposts%2F100%25off`. + expect(isrRouteRewrite("/x?__isr_route=%2Fposts%2F100%25off", null)).toEqual([ + "/posts/100%off", + "", + ]); + }); + + it("does not over-decode an encoded sequence in the route", () => { + // Slug `a%2520b` arrives as `__isr_route=%2Fposts%2Fa%252520b`. + expect(isrRouteRewrite("/x?__isr_route=%2Fposts%2Fa%252520b", null)).toEqual([ + "/posts/a%2520b", + "", + ]); + }); + + it("decodes the header branch exactly once too", () => { + expect(isrRouteRewrite("/x", "__isr_route=%2Fposts%2F100%25off")).toEqual([ + "/posts/100%off", + "", + ]); + }); + }); });