From fdd896aadb1c470db358b4d9fe76fb53c9325f21 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 2 Jul 2026 14:17:48 -0400 Subject: [PATCH 1/3] Add tsconfigs for test files (#15279) --- packages/create-react-router/__tests__/tsconfig.json | 10 ++++++++++ .../react-router-architect/__tests__/tsconfig.json | 10 ++++++++++ packages/react-router-dev/__tests__/tsconfig.json | 10 ++++++++++ packages/react-router-express/__tests__/tsconfig.json | 10 ++++++++++ .../react-router-fs-routes/__tests__/tsconfig.json | 10 ++++++++++ packages/react-router-node/__tests__/tsconfig.json | 10 ++++++++++ .../__tests__/tsconfig.json | 10 ++++++++++ packages/react-router/__tests__/tsconfig.json | 10 ++++++++++ 8 files changed, 80 insertions(+) create mode 100644 packages/create-react-router/__tests__/tsconfig.json create mode 100644 packages/react-router-architect/__tests__/tsconfig.json create mode 100644 packages/react-router-dev/__tests__/tsconfig.json create mode 100644 packages/react-router-express/__tests__/tsconfig.json create mode 100644 packages/react-router-fs-routes/__tests__/tsconfig.json create mode 100644 packages/react-router-node/__tests__/tsconfig.json create mode 100644 packages/react-router-remix-routes-option-adapter/__tests__/tsconfig.json create mode 100644 packages/react-router/__tests__/tsconfig.json diff --git a/packages/create-react-router/__tests__/tsconfig.json b/packages/create-react-router/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/create-react-router/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router-architect/__tests__/tsconfig.json b/packages/react-router-architect/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/react-router-architect/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router-dev/__tests__/tsconfig.json b/packages/react-router-dev/__tests__/tsconfig.json new file mode 100644 index 0000000000..c1fc79c286 --- /dev/null +++ b/packages/react-router-dev/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "vite/client", "@vitejs/plugin-rsc/types", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router-express/__tests__/tsconfig.json b/packages/react-router-express/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/react-router-express/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router-fs-routes/__tests__/tsconfig.json b/packages/react-router-fs-routes/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/react-router-fs-routes/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router-node/__tests__/tsconfig.json b/packages/react-router-node/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/react-router-node/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router-remix-routes-option-adapter/__tests__/tsconfig.json b/packages/react-router-remix-routes-option-adapter/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/react-router-remix-routes-option-adapter/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} diff --git a/packages/react-router/__tests__/tsconfig.json b/packages/react-router/__tests__/tsconfig.json new file mode 100644 index 0000000000..031ccf7354 --- /dev/null +++ b/packages/react-router/__tests__/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"], + "compilerOptions": { + "types": ["node", "jest"], + "rootDir": "..", + "noEmit": true + } +} From bf4032e5bff9dc87b86a784d9bf3566aab80a73e Mon Sep 17 00:00:00 2001 From: spokodev Date: Thu, 2 Jul 2026 21:46:30 +0100 Subject: [PATCH 2/3] fix: rank a static route above a dynamic param with a static suffix (#15273) * fix: rank a static route above a dynamic param with a static suffix computeScore scored a segment such as :lang.xml as fully static because the paramRe anchor required the whole segment to be a bare param. compilePath compiles :lang.xml to a dynamic capture, so /:lang.xml tied with a truly static /sitemap.xml and the tie was broken by declaration order. A request for the literal /sitemap.xml could then match /:lang.xml with params { lang: 'sitemap' } instead of the static route, breaking the static-beats-dynamic invariant. Drop the trailing anchor so a segment counts as dynamic when it starts with a param token, matching compilePath and the rest of the router. * Sign the CLA * Add change file for suffixed param ranking * Apply suggestion from @brophdawg11 * Apply suggestion from @brophdawg11 * Handle dynamic+suffix vs dynamic --------- Co-authored-by: Matt Brophy --- contributors.yml | 1 + ...ank-static-routes-above-suffixed-params.md | 4 + .../__tests__/path-matching-test.tsx | 79 +++++++++++++++++++ packages/react-router/lib/router/utils.ts | 15 ++-- 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md diff --git a/contributors.yml b/contributors.yml index bf14f84770..f6f3163a20 100644 --- a/contributors.yml +++ b/contributors.yml @@ -433,6 +433,7 @@ - souzasmatheus - SovietGhost - soxtoby +- spokodev - srmagura - SsongQ-92 - stasundr diff --git a/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md b/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md new file mode 100644 index 0000000000..5dbc3dd447 --- /dev/null +++ b/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md @@ -0,0 +1,4 @@ +Fix route ranking/scoring bug with dynamic parameters containing static extension suffixes (i.e., `/:name.xml`) + - These were not being detected as dynamic param segments and instead got incorrectly scored higher as a static segment + - This meant they could potentially tie truly static routes like `/sitemap.xml` and outrank them based on definition order + - These are now correctly identified as dynamic parameter segments and scored correctly diff --git a/packages/react-router/__tests__/path-matching-test.tsx b/packages/react-router/__tests__/path-matching-test.tsx index 6fe401fdf6..c6c30d50d0 100644 --- a/packages/react-router/__tests__/path-matching-test.tsx +++ b/packages/react-router/__tests__/path-matching-test.tsx @@ -190,6 +190,85 @@ describe("path matching", () => { ).toStrictEqual({ lang: "en" }); expect(matchPath("/sitemap/:lang?.xml", "/sitemap.xml")).toBeNull(); }); + + it("ranks a static routes above dynamic params with a static suffix", () => { + // Dynamic before static + expect( + pickPathsAndParams( + [{ path: "/:lang.xml" }, { path: "/sitemap.xml" }], + "/sitemap.xml", + ), + ).toEqual([ + { + path: "/sitemap.xml", + params: {}, + }, + ]); + + // Static before dynamic + expect( + pickPathsAndParams( + [{ path: "/sitemap.xml" }, { path: "/:lang.xml" }], + "/sitemap.xml", + ), + ).toEqual([ + { + path: "/sitemap.xml", + params: {}, + }, + ]); + + // Params still get extracted for dynamic-matching paths + expect( + pickPathsAndParams( + [{ path: "/sitemap.xml" }, { path: "/:lang.xml" }], + "/fr.xml", + ), + ).toEqual([ + { + path: "/:lang.xml", + params: { lang: "fr" }, + }, + ]); + }); + + it("ranks dynamic params with a suffix above dynamic params", () => { + // Dynamic+suffix before dynamic + expect( + pickPathsAndParams( + [{ path: "/:a.json" }, { path: "/:b" }], + "/thing.json", + ), + ).toEqual([ + { + path: "/:a.json", + params: { a: "thing" }, + }, + ]); + + // Dynamic before dynamic+suffix + expect( + pickPathsAndParams( + [{ path: "/:b" }, { path: "/:a.json" }], + "/thing.json", + ), + ).toEqual([ + { + path: "/:a.json", + params: { a: "thing" }, + }, + ]); + + // Non-suffix still matches dynamic param + expect( + pickPathsAndParams([{ path: "/:a.json" }, { path: "/:b" }], "/thing"), + ).toEqual([ + { + path: "/:b", + params: { b: "thing" }, + }, + ]); + }); }); describe("path matching with a basename", () => { diff --git a/packages/react-router/lib/router/utils.ts b/packages/react-router/lib/router/utils.ts index b44e6daf02..356c429caa 100644 --- a/packages/react-router/lib/router/utils.ts +++ b/packages/react-router/lib/router/utils.ts @@ -1352,6 +1352,8 @@ function rankRouteBranches(branches: RouteBranch[]): void { } const paramRe = /^:[\w-]+$/; +const partialParamRe = /^:[\w-]+/; +const partialDynamicSegmentValue = 3.5; const dynamicSegmentValue = 3; const indexRouteValue = 2; const emptySegmentValue = 1; @@ -1374,12 +1376,13 @@ function computeScore(path: string, index: boolean | undefined): number { .filter((s) => !isSplat(s)) .reduce( (score, segment) => - score + - (paramRe.test(segment) - ? dynamicSegmentValue - : segment === "" - ? emptySegmentValue - : staticSegmentValue), + // prettier-ignore + score + ( + paramRe.test(segment) ? dynamicSegmentValue : + partialParamRe.test(segment) ? partialDynamicSegmentValue : + segment === "" ? emptySegmentValue : + staticSegmentValue + ), initialScore, ); } From 0cd115752ad14f7debfba61cb5b0f185d6fa74e0 Mon Sep 17 00:00:00 2001 From: Remix Run Bot Date: Thu, 2 Jul 2026 20:47:12 +0000 Subject: [PATCH 3/3] chore: format --- .../patch.rank-static-routes-above-suffixed-params.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md b/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md index 5dbc3dd447..0132c8269e 100644 --- a/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md +++ b/packages/react-router/.changes/patch.rank-static-routes-above-suffixed-params.md @@ -1,4 +1,5 @@ Fix route ranking/scoring bug with dynamic parameters containing static extension suffixes (i.e., `/:name.xml`) - - These were not being detected as dynamic param segments and instead got incorrectly scored higher as a static segment - - This meant they could potentially tie truly static routes like `/sitemap.xml` and outrank them based on definition order - - These are now correctly identified as dynamic parameter segments and scored correctly + +- These were not being detected as dynamic param segments and instead got incorrectly scored higher as a static segment +- This meant they could potentially tie truly static routes like `/sitemap.xml` and outrank them based on definition order +- These are now correctly identified as dynamic parameter segments and scored correctly