Skip to content

Commit 2a5fdac

Browse files
committed
Align route manifest specificity sorting with React Router
1 parent 36f900f commit 2a5fdac

2 files changed

Lines changed: 40 additions & 42 deletions

File tree

packages/react/src/reactrouter-compat-utils/route-manifest.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -125,47 +125,49 @@ function splitPath(path: string): string[] {
125125
return path.split('/').filter(Boolean);
126126
}
127127

128+
/**
129+
* React Router scoring weights.
130+
* https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/utils.ts
131+
*/
132+
const STATIC_SEGMENT_SCORE = 10;
133+
const DYNAMIC_SEGMENT_SCORE = 3;
134+
const SPLAT_PENALTY = -2;
135+
136+
/** Computes a specificity score for a route pattern. */
137+
function computeScore(pattern: string): number {
138+
const segments = splitPath(pattern);
139+
let score = 0;
140+
141+
for (const segment of segments) {
142+
if (segment === '*') {
143+
score += SPLAT_PENALTY;
144+
} else if (segment.startsWith(':')) {
145+
score += DYNAMIC_SEGMENT_SCORE;
146+
} else {
147+
score += STATIC_SEGMENT_SCORE;
148+
}
149+
}
150+
151+
return score;
152+
}
153+
128154
/**
129155
* Sorts route patterns by specificity (most specific first).
130-
* Mimics React Router's ranking algorithm from computeScore():
156+
* Implements React Router's ranking algorithm from computeScore():
131157
* https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/utils.ts
132158
*
133-
* React Router scoring: static=10, dynamic=3, splat=-2 penalty, index=+2 bonus
134-
* Our simplified approach produces equivalent ordering:
135-
* - Non-wildcard patterns are more specific than wildcard patterns
136-
* - More segments = more specific
137-
* - Among same-length patterns, more literal segments = more specific
138-
* - Equal specificity: preserves manifest order (same as React Router)
159+
* React Router scoring: static=10, dynamic=3, splat=-2 penalty
160+
* Higher score = more specific pattern.
161+
* Equal scores preserve manifest order (same as React Router).
139162
*
140163
* Note: Users should order their manifest from most specific to least specific
141164
* when patterns have equal specificity (e.g., `/users/:id/settings` and `/:type/123/settings`).
142165
*/
143166
function sortBySpecificity(manifest: string[]): string[] {
144167
return [...manifest].sort((a, b) => {
145-
const aSegments = splitPath(a);
146-
const bSegments = splitPath(b);
147-
const aHasWildcard = aSegments.length > 0 && aSegments[aSegments.length - 1] === '*';
148-
const bHasWildcard = bSegments.length > 0 && bSegments[bSegments.length - 1] === '*';
149-
150-
// Non-wildcard patterns are more specific than wildcard patterns
151-
if (aHasWildcard !== bHasWildcard) {
152-
return aHasWildcard ? 1 : -1;
153-
}
154-
155-
// For comparison, exclude wildcard from segment count
156-
const aLen = aHasWildcard ? aSegments.length - 1 : aSegments.length;
157-
const bLen = bHasWildcard ? bSegments.length - 1 : bSegments.length;
158-
159-
// More segments = more specific
160-
if (aLen !== bLen) {
161-
return bLen - aLen;
162-
}
163-
164-
// Same length: count literal segments (non-params, non-wildcards)
165-
const aLiterals = aSegments.filter(s => !s.startsWith(':') && s !== '*').length;
166-
const bLiterals = bSegments.filter(s => !s.startsWith(':') && s !== '*').length;
168+
const aScore = computeScore(a);
169+
const bScore = computeScore(b);
167170

168-
// More literals = more specific (equal specificity preserves original order)
169-
return bLiterals - aLiterals;
171+
return bScore - aScore;
170172
});
171173
}

packages/react/test/reactrouter-compat-utils/route-manifest.test.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe('matchRouteManifest', () => {
7878
describe('specificity sorting (React Router parity)', () => {
7979
// Verifies our sorting matches React Router's computeScore() algorithm
8080
// See: https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/router/utils.ts
81-
// React Router scoring: static=10, dynamic=3, splat=-2 penalty, index=+2 bonus
81+
// React Router scoring: static=10, dynamic=3, splat=-2 penalty
8282
// For equal scores, manifest order is preserved (same as React Router)
8383

8484
it('returns more specific route when multiple match', () => {
@@ -88,36 +88,32 @@ describe('matchRouteManifest', () => {
8888

8989
it('prefers literal segments over parameters (React Router: static=10 > dynamic=3)', () => {
9090
const manifestWithOverlap = ['/users/:id', '/users/me'];
91-
// /users/me: 2 + 10 + 10 = 22
92-
// /users/:id: 2 + 10 + 3 = 15
9391
expect(matchRouteManifest('/users/me', manifestWithOverlap)).toBe('/users/me');
9492
expect(matchRouteManifest('/users/123', manifestWithOverlap)).toBe('/users/:id');
9593
});
9694

9795
it('prefers more segments (React Router: higher segment count = higher base score)', () => {
9896
const m = ['/users', '/users/:id', '/users/:id/posts'];
99-
// /users: 1 + 10 = 11
100-
// /users/:id: 2 + 10 + 3 = 15
101-
// /users/:id/posts: 3 + 10 + 3 + 10 = 26
10297
expect(matchRouteManifest('/users/123/posts', m)).toBe('/users/:id/posts');
10398
});
10499

105100
it('prefers non-wildcard over wildcard (React Router: splat=-2 penalty)', () => {
106101
const m = ['/docs/*', '/docs/api'];
107-
// /docs/*: 2 + 10 + (-2) = 10
108-
// /docs/api: 2 + 10 + 10 = 22
109102
expect(matchRouteManifest('/docs/api', m)).toBe('/docs/api');
110103
});
111104

112105
it('prefers longer wildcard prefix over shorter (React Router: more segments before splat)', () => {
113106
const m = ['/*', '/docs/*', '/docs/api/*'];
114-
// /*: 1 + (-2) = -1
115-
// /docs/*: 2 + 10 + (-2) = 10
116-
// /docs/api/*: 3 + 10 + 10 + (-2) = 21
117107
expect(matchRouteManifest('/docs/api/methods', m)).toBe('/docs/api/*');
118108
expect(matchRouteManifest('/docs/guide', m)).toBe('/docs/*');
119109
expect(matchRouteManifest('/other', m)).toBe('/*');
120110
});
111+
112+
it('prefers static segments over dynamic in wildcard patterns (React Router: static=10 > dynamic=3)', () => {
113+
// /a/b/* ranks higher than /:x/:y/:z/* despite fewer prefix segments
114+
const m = ['/:x/:y/:z/*', '/a/b/*'];
115+
expect(matchRouteManifest('/a/b/c/d', m)).toBe('/a/b/*');
116+
});
121117
});
122118

123119
describe('no match', () => {

0 commit comments

Comments
 (0)