Skip to content

Commit 8bc7250

Browse files
authored
perf: simple matchRoutes alternative #1863 (#2673)
1 parent e187522 commit 8bc7250

File tree

2 files changed

+126
-2
lines changed

2 files changed

+126
-2
lines changed

packages/runtime/src/route.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest';
2-
import { pathnameToRouteService } from './route';
2+
import { isActive, matchPath, pathnameToRouteService } from './route';
33

44
vi.mock('virtual-routes', () => {
55
const element = vi.fn();
@@ -32,6 +32,47 @@ vi.mock('virtual-routes', () => {
3232
return { routes };
3333
});
3434

35+
describe('matchPath', () => {
36+
it('should match exact paths', () => {
37+
expect(matchPath('/api/config', '/api/config')).toEqual({
38+
path: '/api/config',
39+
});
40+
expect(matchPath('/api/config/', '/api/config/')).toEqual({
41+
path: '/api/config/',
42+
});
43+
});
44+
45+
it('should normalize trailing slashes', () => {
46+
expect(matchPath('/api/config', '/api/config/')).toEqual({
47+
path: '/api/config',
48+
});
49+
expect(matchPath('/api/config/', '/api/config')).toEqual({
50+
path: '/api/config/',
51+
});
52+
expect(matchPath('/api/config/', '/api/config/index.html')).toEqual({
53+
path: '/api/config/',
54+
});
55+
});
56+
57+
it('should return null for non-matching paths', () => {
58+
expect(matchPath('/api/config', '/api/other')).toBeNull();
59+
expect(matchPath('/api', '/api/config')).toBeNull();
60+
expect(matchPath('/api', '/api/index.md')).toBeNull();
61+
});
62+
});
63+
64+
describe('isActive', () => {
65+
it('should return true for matching normalized paths', () => {
66+
expect(isActive('/api/config', '/api/config')).toBe(true);
67+
expect(isActive('/api/config', '/api/config.html')).toBe(true);
68+
expect(isActive('/api/config', '/api/config/')).toBe(true);
69+
});
70+
71+
it('should return false for non-matching paths', () => {
72+
expect(isActive('/api/config', '/api/other')).toBe(false);
73+
});
74+
});
75+
3576
describe('pathnameToRouteService', () => {
3677
it('0. /api/config', () => {
3778
const pathname = '/api/config';
@@ -67,4 +108,13 @@ describe('pathnameToRouteService', () => {
67108
`"/api/config/"`,
68109
);
69110
});
111+
112+
it('5. /', () => {
113+
expect(pathnameToRouteService('/index.html')?.path).toMatchInlineSnapshot(
114+
`"/"`,
115+
);
116+
expect(pathnameToRouteService('/index.md')?.path).toMatchInlineSnapshot(
117+
`undefined`,
118+
);
119+
});
70120
});

packages/runtime/src/route.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,87 @@
11
import type { Route } from '@rspress/shared';
2-
import { matchPath, matchRoutes } from 'react-router-dom';
32
import { routes } from 'virtual-routes';
43

4+
/**
5+
* Normalize route path by:
6+
* 1. Decoding URI components
7+
* 2. Removing .html suffix
8+
* 3. Converting /index to /
9+
*
10+
* Examples:
11+
* - /api/config → /api/config
12+
* - /api/config.html → /api/config
13+
* - /api/config/index → /api/config/
14+
* - /api/config/index.html → /api/config/
15+
* - /index.html → /
16+
*/
517
function normalizeRoutePath(routePath: string) {
618
return decodeURIComponent(routePath)
719
.replace(/\.html$/, '')
820
.replace(/\/index$/, '/');
921
}
1022

23+
/**
24+
* Simple implementation of matchPath to check if a pattern matches a pathname
25+
* Better performance alternative of `import { matchPath } from 'react-router-dom'`
26+
* @param pattern - The route pattern to match against
27+
* @param pathname - The pathname to check
28+
* @returns Match object if matched, null otherwise
29+
*
30+
* @example
31+
* matchPath('/api/config', '/api/config') // { path: '/api/config' }
32+
* matchPath('/api/config/', '/api/config') // { path: '/api/config/' }
33+
* matchPath('/api/config', '/api/other') // null
34+
*/
35+
export function matchPath(
36+
pattern: string,
37+
pathname: string,
38+
): { path: string } | null {
39+
// Normalize both pattern and pathname for comparison
40+
// Always add trailing slash for consistent comparison
41+
const _pathname = normalizeRoutePath(pathname);
42+
const normalizedPattern = pattern.endsWith('/') ? pattern : `${pattern}/`;
43+
const normalizedPathname = _pathname.endsWith('/')
44+
? _pathname
45+
: `${_pathname}/`;
46+
47+
// Exact match
48+
if (normalizedPattern === normalizedPathname) {
49+
return { path: pattern };
50+
}
51+
52+
return null;
53+
}
54+
55+
// Sort routes by path length (longest first) to match most specific routes first
56+
const sortedRoutes = [...routes].sort((a, b) => {
57+
const pathA = a.path || '';
58+
const pathB = b.path || '';
59+
return pathB.length - pathA.length;
60+
});
61+
62+
/**
63+
* Simple implementation of matchRoutes to find matching routes
64+
* Better performance alternative of `import { matchRoutes } from 'react-router-dom'`
65+
* @param _routes - Array of routes (unused, uses pre-sorted sortedRoutes)
66+
* @param pathname - The pathname to match
67+
* @returns Array of matched routes with route object, or null if no match
68+
*/
69+
function matchRoutes(
70+
_routes: Route[],
71+
pathname: string,
72+
): Array<{ route: Route }> | null {
73+
for (const route of sortedRoutes) {
74+
const routePath = route.path || '';
75+
const match = matchPath(routePath, pathname);
76+
77+
if (match) {
78+
return [{ route }];
79+
}
80+
}
81+
82+
return null;
83+
}
84+
1185
const cache = new Map<string, Route>();
1286
/**
1387
* this is a bridge of two core features Sidebar and RouteService

0 commit comments

Comments
 (0)