Files
cloudflare__vinext/tests/route-pattern.test.ts
T
Nathan Nguyen 6c786d69a9 fix(routing): decode matched params with decodeURIComponent (#1049)
* fix(routing): decode matched params with decodeURIComponent

Vinext's segment-safe normalization preserves encoded delimiters (%2F) for
correct route matching, but the captured param values were left undecoded.
This caused dynamic params to return "a%2Fb" instead of "a/b", diverging
from Next.js which decodes at the route-matcher level.

Add decodeURIComponent pass in trieMatch and matchRoutePattern — the two
route matching entry points — so all downstream consumers (pages, route
handlers, metadata, useParams) receive decoded param values.

Mirrors Next.js: packages/next/src/shared/lib/router/utils/route-matcher.ts L25-27

* test(app-rsc-route-matching): update param decode assertion to match new behavior
2026-05-04 18:58:48 +01:00

98 lines
3.8 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
fillRoutePatternSegments,
matchRoutePattern,
routePattern,
routePatternParts,
} from "../packages/vinext/src/routing/route-pattern.js";
describe("route pattern helpers", () => {
it("normalizes app route segments into vinext pattern parts", () => {
expect(routePatternParts("/docs/[section]/[[...slug]]/icon")).toEqual([
"docs",
":section",
":slug*",
"icon",
]);
expect(routePattern("/shop/[...slug]/opengraph-image")).toBe("/shop/:slug+/opengraph-image");
expect(routePattern("/")).toBe("");
});
it("fills dynamic route segments from params and rejects incomplete paths", () => {
expect(
fillRoutePatternSegments("/docs/[section]/[[...slug]]/icon", {
section: "api",
}),
).toBe("/docs/api/icon");
expect(
fillRoutePatternSegments("/docs/[section]/[[...slug]]/icon", {
section: "api",
slug: ["routing", "metadata"],
}),
).toBe("/docs/api/routing/metadata/icon");
expect(fillRoutePatternSegments("/docs/[...slug]/icon", {})).toBeNull();
expect(fillRoutePatternSegments("/docs/[section]/icon", { section: ["a", "b"] })).toBeNull();
});
it("matches dynamic pattern parts with catch-all segments before suffixes", () => {
expect(
matchRoutePattern(
["metadata-multi-catchall", "a", "b", "icon"],
["metadata-multi-catchall", ":slug+", "icon"],
),
).toEqual({ slug: ["a", "b"] });
expect(matchRoutePattern(["shop"], ["shop", ":slug*"])).toEqual({});
expect(
matchRoutePattern(
["metadata-multi-catchall", "icon"],
["metadata-multi-catchall", ":slug+", "icon"],
),
).toBeNull();
});
it("stores prototype-named params as own values", () => {
const singleParam = matchRoutePattern(["first"], [":__proto__"]);
expect(Object.hasOwn(singleParam ?? {}, "__proto__")).toBe(true);
expect(singleParam?.__proto__).toBe("first");
const catchAllParam = matchRoutePattern(["first", "second", "icon"], [":__proto__+", "icon"]);
expect(Object.hasOwn(catchAllParam ?? {}, "__proto__")).toBe(true);
expect(catchAllParam?.__proto__).toEqual(["first", "second"]);
});
it("treats literal route segments ending in pattern markers as literals", () => {
expect(matchRoutePattern(["docs+", "icon"], ["docs+", "icon"])).toEqual({});
expect(matchRoutePattern(["docs", "icon"], ["docs+", "icon"])).toBeNull();
});
});
// Ported from Next.js: route-matcher.ts decodeURIComponent behaviour
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/route-matcher.ts#L25-L27
describe("matchRoutePattern param decoding", () => {
it("decodes %2F, %23, %3F in dynamic segment params without splitting segments", () => {
expect(matchRoutePattern(["files", "a%2Fb"], ["files", ":name"])).toEqual({ name: "a/b" });
expect(matchRoutePattern(["files", "a%23b"], ["files", ":name"])).toEqual({ name: "a#b" });
expect(matchRoutePattern(["files", "a%3Fb"], ["files", ":name"])).toEqual({ name: "a?b" });
});
it("decodes each element of catch-all and optional catch-all arrays individually", () => {
expect(matchRoutePattern(["docs", "a%2Fb", "c%23d"], ["docs", ":rest+"])).toEqual({
rest: ["a/b", "c#d"],
});
expect(matchRoutePattern(["docs", "a%2Fb", "c%23d"], ["docs", ":rest*"])).toEqual({
rest: ["a/b", "c#d"],
});
});
it("preserves malformed percent escapes without throwing", () => {
expect(matchRoutePattern(["files", "a%GGb"], ["files", ":name"])).toEqual({ name: "a%GGb" });
});
it("applies exactly one decodeURIComponent pass (double-encoded stays single-encoded)", () => {
expect(matchRoutePattern(["files", "a%252Fb"], ["files", ":name"])).toEqual({ name: "a%2Fb" });
});
});