Skip to content

Commit d34dfc0

Browse files
authored
feat(router): replace path-to-regexp with internal matcher (#64)
Closes #58
1 parent 9ed17ec commit d34dfc0

File tree

8 files changed

+227
-47
lines changed

8 files changed

+227
-47
lines changed

libs/angular-routing/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
"rxjs": ">=6.5.3"
2828
},
2929
"dependencies": {
30-
"path-to-regexp": "^6.1.0",
3130
"query-string": "^6.13.1"
3231
},
3332
"sideEffects": false

libs/angular-routing/src/lib/router.component.ts

+28-28
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,11 @@ import {
1313
debounceTime,
1414
map,
1515
} from 'rxjs/operators';
16-
17-
import { pathToRegexp, match } from 'path-to-regexp';
18-
1916
import { Route, ActiveRoute } from './route';
2017
import { Router } from './router.service';
21-
import { compareParams, Params } from './route-params.service';
18+
import { compareParams } from './route-params.service';
2219
import { compareRoutes } from './utils/compare-routes';
20+
import { matchRoute, parsePath } from './utils/path-parser';
2321

2422
interface State {
2523
activeRoute: ActiveRoute | null;
@@ -47,6 +45,7 @@ export class RouterComponent implements OnInit, OnDestroy {
4745
);
4846
readonly routes$ = this.state$.pipe(
4947
map((state) => state.routes),
48+
distinctUntilChanged(this.compareRoutes),
5049
takeUntil(this.destroy$)
5150
);
5251

@@ -72,7 +71,7 @@ export class RouterComponent implements OnInit, OnDestroy {
7271
tap(([routes, url]: [Route[], string]) => {
7372
let routeToRender = null;
7473
for (const route of routes) {
75-
routeToRender = this.findRouteMatch(route, url);
74+
routeToRender = this.isRouteMatch(url, route);
7675

7776
if (routeToRender) {
7877
this.setRoute(url, route);
@@ -89,34 +88,18 @@ export class RouterComponent implements OnInit, OnDestroy {
8988
.subscribe();
9089
}
9190

92-
findRouteMatch(route: Route, url: string) {
93-
const matchedRoute = route.matcher ? route.matcher.exec(url) : null;
94-
95-
if (matchedRoute) {
96-
return matchedRoute;
97-
}
98-
99-
return null;
100-
}
101-
10291
setRoute(url: string, route: Route) {
103-
const pathInfo = match(this.normalizePath(route.path), {
104-
end: route.options.exact,
105-
})(url);
10692
this.basePath = route.path;
107-
108-
const routeParams: Params = pathInfo ? pathInfo.params : {};
109-
const path: string = pathInfo ? pathInfo.path : '';
110-
this.setActiveRoute({ route, params: routeParams || {}, path });
93+
const match = matchRoute(url, route);
94+
this.setActiveRoute({
95+
route,
96+
params: match?.params || {},
97+
path: match?.path || '',
98+
});
11199
}
112100

113101
registerRoute(route: Route) {
114-
const normalized = this.normalizePath(route.path);
115-
const routeRegex = pathToRegexp(normalized, [], {
116-
end: route.options.exact ?? true,
117-
});
118-
119-
route.matcher = route.matcher || routeRegex;
102+
route.matcher = route.matcher || parsePath(route);
120103
this.updateRoutes(route);
121104

122105
return route;
@@ -138,6 +121,10 @@ export class RouterComponent implements OnInit, OnDestroy {
138121
this.destroy$.next();
139122
}
140123

124+
private isRouteMatch(url: string, route: Route) {
125+
return route.matcher?.exec(url);
126+
}
127+
141128
private compareActiveRoutes(
142129
previous: ActiveRoute,
143130
current: ActiveRoute
@@ -156,6 +143,19 @@ export class RouterComponent implements OnInit, OnDestroy {
156143
);
157144
}
158145

146+
private compareRoutes(previous: Route[], current: Route[]): boolean {
147+
if (previous === current) {
148+
return true;
149+
}
150+
if (!previous) {
151+
return false;
152+
}
153+
return (
154+
previous.length === current.length &&
155+
previous.every((route, i) => route[i] === current[i])
156+
);
157+
}
158+
159159
private updateState(newState: Partial<State>) {
160160
this.state$.next({ ...this.state$.value, ...newState });
161161
}

libs/angular-routing/src/lib/utils/compare-routes.spec.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { pathToRegexp } from 'path-to-regexp';
21
import { Route } from '../route';
32
import { compareRoutes } from './compare-routes';
3+
import { parsePath } from './path-parser';
44

55
describe('compareRoutes', () => {
66
it('should return 0 if matchers are same', () => {
@@ -60,12 +60,6 @@ describe('compareRoutes', () => {
6060
});
6161

6262
function makeRoute(route: Route): Route {
63-
route.matcher = pathToRegexp(normalizePath(route), [], {
64-
end: route.options.exact ?? true,
65-
});
63+
route.matcher = parsePath(route);
6664
return route;
6765
}
68-
69-
function normalizePath(route: Route): string {
70-
return route.path.startsWith('/') ? route.path : `/${route.path}`;
71-
}

libs/angular-routing/src/lib/utils/compare-routes.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Route } from '../route';
2+
import { getPathSegments } from './path-parser';
23

34
/**
45
* Compares two routes and returns sorting number
@@ -27,10 +28,6 @@ export const compareRoutes = (a: Route, b: Route): number => {
2728
return a.options.exact ?? true ? -1 : 1;
2829
};
2930

30-
function getPathSegments(route: Route): string[] {
31-
return route.path.replace(/^\//, '').split('/');
32-
}
33-
3431
function compareSegments(
3532
aSegments: string[],
3633
bSegments: string[],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Route } from '../route';
2+
import { matchRoute, parsePath } from './path-parser';
3+
4+
describe('parsePath', () => {
5+
it('should parse empty route', () => {
6+
expect(parsePath({ path: '', options: {} })).toEqual(/^[\/#\?]?$/i);
7+
expect(parsePath({ path: '/', options: {} })).toEqual(/^[\/#\?]?$/i);
8+
});
9+
it('should parse empty wildcard route', () => {
10+
expect(parsePath({ path: '', options: { exact: false } })).toEqual(
11+
/^(?:[\/#\?](?=[]|$))?/i
12+
);
13+
expect(parsePath({ path: '/', options: { exact: false } })).toEqual(
14+
/^(?:[\/#\?](?=[]|$))?/i
15+
);
16+
});
17+
it('should parse static route', () => {
18+
expect(parsePath({ path: 'first/second', options: {} })).toEqual(
19+
/^\/first\/second[\/#\?]?$/i
20+
);
21+
expect(parsePath({ path: '/first/second', options: {} })).toEqual(
22+
/^\/first\/second[\/#\?]?$/i
23+
);
24+
});
25+
it('should remove ending slash', () => {
26+
expect(parsePath({ path: 'first/', options: {} })).toEqual(
27+
/^\/first[\/#\?]?$/i
28+
);
29+
expect(parsePath({ path: '/first/', options: {} })).toEqual(
30+
/^\/first[\/#\?]?$/i
31+
);
32+
});
33+
it('should parse static wildcard route', () => {
34+
expect(
35+
parsePath({ path: 'first/second', options: { exact: false } })
36+
).toEqual(/^\/first\/second(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i);
37+
expect(
38+
parsePath({ path: '/first/second', options: { exact: false } })
39+
).toEqual(/^\/first\/second(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i);
40+
});
41+
42+
it('should parse dynamic route', () => {
43+
expect(parsePath({ path: ':id', options: {} })).toEqual(
44+
/^(?:\/([^\/#\?]+?))[\/#\?]?$/i
45+
);
46+
expect(parsePath({ path: '/books/:bookId', options: {} })).toEqual(
47+
/^\/books(?:\/([^\/#\?]+?))[\/#\?]?$/i
48+
);
49+
});
50+
51+
it('should parse dynamic wildcard route', () => {
52+
expect(parsePath({ path: ':id', options: { exact: false } })).toEqual(
53+
/^(?:\/([^\/#\?]+?))(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i
54+
);
55+
expect(
56+
parsePath({ path: '/books/:bookId', options: { exact: false } })
57+
).toEqual(
58+
/^\/books(?:\/([^\/#\?]+?))(?:[\/#\?](?=[]|$))?(?=[\/#\?]|[]|$)/i
59+
);
60+
});
61+
});
62+
63+
describe('matchRoute', () => {
64+
it('should match wildcard route', () => {
65+
const route: Route = { path: '', options: { exact: false } };
66+
route.matcher = parsePath(route);
67+
68+
expect(matchRoute('/', route)).toEqual({ path: '/', params: {} });
69+
expect(matchRoute('/first', route)).toEqual({ path: '', params: {} });
70+
expect(matchRoute('/first/second/third', route)).toEqual({
71+
path: '',
72+
params: {},
73+
});
74+
});
75+
it('should match empty route', () => {
76+
const route: Route = { path: '', options: {} };
77+
route.matcher = parsePath(route);
78+
79+
expect(matchRoute('/', route)).toEqual({ path: '/', params: {} });
80+
expect(matchRoute('/first', route)).not.toBeDefined();
81+
expect(matchRoute('/first/second', route)).not.toBeDefined();
82+
});
83+
it('should match static wildcard route', () => {
84+
const route: Route = { path: 'first/second', options: { exact: false } };
85+
route.matcher = parsePath(route);
86+
87+
expect(matchRoute('/first/second', route)).toEqual({
88+
path: '/first/second',
89+
params: {},
90+
});
91+
expect(matchRoute('/first', route)).not.toBeDefined();
92+
expect(matchRoute('/first/second/third', route)).toEqual({
93+
path: '/first/second',
94+
params: {},
95+
});
96+
});
97+
it('should match static route', () => {
98+
const route: Route = { path: 'first/second', options: {} };
99+
route.matcher = parsePath(route);
100+
101+
expect(matchRoute('/first/second', route)).toEqual({
102+
path: '/first/second',
103+
params: {},
104+
});
105+
expect(matchRoute('/first', route)).not.toBeDefined();
106+
expect(matchRoute('/first/second/third', route)).not.toBeDefined();
107+
});
108+
it('should match dynamic wildcard route', () => {
109+
const route: Route = { path: 'first/:id', options: { exact: false } };
110+
route.matcher = parsePath(route);
111+
112+
expect(matchRoute('/first/second', route)).toEqual({
113+
path: '/first/second',
114+
params: { id: 'second' },
115+
});
116+
expect(matchRoute('/first', route)).not.toBeDefined();
117+
expect(matchRoute('/first/second/third', route)).toEqual({
118+
path: '/first/second',
119+
params: { id: 'second' },
120+
});
121+
});
122+
it('should match dynamic route', () => {
123+
const route: Route = { path: 'first/:id/:name', options: {} };
124+
route.matcher = parsePath(route);
125+
126+
expect(matchRoute('/first/second', route)).not.toBeDefined();
127+
expect(matchRoute('/first', route)).not.toBeDefined();
128+
expect(matchRoute('/first/second/third', route)).toEqual({
129+
path: '/first/second/third',
130+
params: { id: 'second', name: 'third' },
131+
});
132+
});
133+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Route } from '../route';
2+
import { Params } from '../route-params.service';
3+
4+
const PARAM_PREFIX = ':';
5+
6+
export interface RouteMatch {
7+
path: string;
8+
params: Params;
9+
}
10+
11+
const DIV = '\\/'; // /
12+
const DIV_PARAM = `(?:${DIV}([^\\/#\\?]+?))`; // capturing group for one or more of not (/, # or ?), optional (TODO: check if optional is needed)
13+
const PATH_END = '[\\/#\\?]'; // path end: /, # or ?
14+
const END = '[]|$'; // null or end
15+
const EXACT_END = `${PATH_END}?$`; // match PATH_END optionally and END
16+
const WILDCARD = `(?:${PATH_END}(?=${END}))?`; // match optionally PATH_END followed by END
17+
const NON_EXACT_END = `${WILDCARD}(?=${PATH_END}|${END})`; // match WILDCARD followed by PATH_END or END
18+
19+
export function getPathSegments(route: Route): string[] {
20+
const sanitizedPath = route.path.replace(/^\//, '').replace(/(?:\/$)/, '');
21+
return sanitizedPath ? sanitizedPath.split('/') : [];
22+
}
23+
24+
export const parsePath = (route: Route): RegExp => {
25+
const segments = getPathSegments(route);
26+
const regexBody = segments.reduce(
27+
(acc, segment) =>
28+
segment.startsWith(PARAM_PREFIX)
29+
? `${acc}${DIV_PARAM}`
30+
: `${acc}${DIV}${segment}`,
31+
''
32+
);
33+
34+
if (route.options.exact ?? true) {
35+
return new RegExp(`^${regexBody}${EXACT_END}`, 'i');
36+
} else {
37+
return new RegExp(
38+
`^${regexBody}${regexBody ? NON_EXACT_END : WILDCARD}`,
39+
'i'
40+
);
41+
}
42+
};
43+
44+
export const matchRoute = (
45+
url: string,
46+
route: Route
47+
): RouteMatch | undefined => {
48+
const match = route.matcher?.exec(url);
49+
if (!match) {
50+
return;
51+
}
52+
const keys = getPathSegments(route)
53+
.filter((s) => s.startsWith(PARAM_PREFIX))
54+
.map((s) => s.slice(1));
55+
56+
return {
57+
path: match[0],
58+
params: keys.reduce(
59+
(acc, key, index) => ({ ...acc, [key]: match[index + 1] }),
60+
{}
61+
),
62+
};
63+
};

package.json

-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
"@nrwl/angular": "^9.4.5",
4848
"angular-in-memory-web-api": "^0.11.0",
4949
"hammerjs": "^2.0.8",
50-
"path-to-regexp": "^6.1.0",
5150
"query-string": "^6.13.1",
5251
"rxjs": "~6.5.4",
5352
"tslib": "^1.10.0",

yarn.lock

-5
Original file line numberDiff line numberDiff line change
@@ -8872,11 +8872,6 @@ path-to-regexp@0.1.7:
88728872
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
88738873
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
88748874

8875-
path-to-regexp@^6.1.0:
8876-
version "6.1.0"
8877-
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.1.0.tgz#0b18f88b7a0ce0bfae6a25990c909ab86f512427"
8878-
integrity sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==
8879-
88808875
path-type@^1.0.0:
88818876
version "1.1.0"
88828877
resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"

0 commit comments

Comments
 (0)