diff --git a/src/presets/vercel/runtime/isr.ts b/src/presets/vercel/runtime/isr.ts index 10c5c0f7ca..c7005fd8d8 100644 --- a/src/presets/vercel/runtime/isr.ts +++ b/src/presets/vercel/runtime/isr.ts @@ -4,20 +4,24 @@ export function isrRouteRewrite( reqUrl: string, xNowRouteMatches: string | null ): [pathname: string, search: string] | undefined { - if (xNowRouteMatches) { - const isrURL = new URLSearchParams(xNowRouteMatches).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()]; - } - } - } + const queryIndex = reqUrl.indexOf("?"); + 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. + // `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); + 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 [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..31ad2f0315 --- /dev/null +++ b/test/unit/vercel-isr.test.ts @@ -0,0 +1,98 @@ +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"]); + }); + + // 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", + ]); + }); + }); + + // 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", + "", + ]); + }); + }); +});