Skip to content

Commit fb382aa

Browse files
order independent routing
1 parent f00a3fe commit fb382aa

File tree

6 files changed

+1021
-0
lines changed

6 files changed

+1021
-0
lines changed

src/common/utils/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,10 @@ export {
77
matchInvariantRoute,
88
warmupMatchRouteCache,
99
} from './match-route';
10+
export {
11+
default as matchRouteOrderIndependent,
12+
matchRouteByTree as matchRouteOrderIndependentByTree,
13+
} from './match-route-order-independent';
14+
export { treeify } from './match-route-order-independent/tree';
1015
export { findRouterContext, createRouterContext } from './router-context';
1116
export { isSameRouteMatch } from './is-same-route';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { pathToRegexp } from 'path-to-regexp';
2+
import { qs } from 'url-parse';
3+
4+
import { Query, Routes, Route } from '../../types';
5+
import execRouteMatching from '../match-route/exec-route-matching';
6+
import matchQuery from '../match-route/matchQuery';
7+
8+
import { type Tree, Node, treeify } from './tree';
9+
import { matchRouteCache } from './utils';
10+
11+
function pushOrUnshiftByCaptureGroup(arr: Node[], node: Node) {
12+
if (node.segmentPattern.includes('(') && node.segmentPattern.includes(')')) {
13+
// if the segmentPattern has capturing group, it's more specific
14+
// so we place it at the beginning of the array
15+
arr.unshift(node);
16+
} else {
17+
// otherwise place at the end of the array
18+
arr.push(node);
19+
}
20+
}
21+
22+
// Find matching nodes by segment
23+
// sort the nodes by specificity
24+
function matchChildren(node: Node, segments: string[]) {
25+
// how do we define specificity? This is a tricky question.
26+
// the specificity goes like this:
27+
// 1. if the segment is an exact match, of course it's the most specific
28+
// 2. the rest is regex match, within the regex match, we have to consider:
29+
// 2.1 the length of segments and if node has descendants. /jira/:id/summary is more specific than /jira/:id if the request URL is /jira/123/summary
30+
// 3 after checking the length, we have to consider if the segmentPattern has any capturing group. /jira/:id(\d+) is more specific than /jira/:id
31+
//
32+
// This is not a comprehensive solution to the specificity problem
33+
// I will use production urls to verify this heuristic
34+
35+
const exactMatch: Node[] = []; // segment is an exact match e.g. /jira matches /jira
36+
const lengthMatch: Node[] = []; // check #2.1 from the above comment
37+
const rest: Node[] = [];
38+
39+
const { children } = node;
40+
// treat url segment as empty string if it's undefined
41+
// possible if we have optional segmentPattern
42+
const segment = segments[node.level] || '';
43+
// check if there is next segment
44+
const hasNextSegment = segments.length > node.level;
45+
46+
for (const segmentPattern in children) {
47+
if (Object.prototype.hasOwnProperty.call(children, segmentPattern)) {
48+
const child = children[segmentPattern];
49+
50+
if (segment === segmentPattern) {
51+
// we have exact segment match
52+
exactMatch.push(child);
53+
} else {
54+
const regex = pathToRegexp(segmentPattern, [], {
55+
end: true,
56+
strict: true,
57+
sensitive: false,
58+
});
59+
if (regex.test(segment)) {
60+
const nodeAhasChildren = Object.keys(child.children).length > 0;
61+
62+
if (hasNextSegment && nodeAhasChildren) {
63+
// if there is a next segment, we should prioritize nodes with children
64+
pushOrUnshiftByCaptureGroup(lengthMatch, child);
65+
} else if (!hasNextSegment && !nodeAhasChildren) {
66+
// if there is no next segment, we should prioritize nodes without children
67+
pushOrUnshiftByCaptureGroup(lengthMatch, child);
68+
} else {
69+
pushOrUnshiftByCaptureGroup(rest, child);
70+
}
71+
}
72+
}
73+
}
74+
}
75+
76+
return [...exactMatch, ...lengthMatch, ...rest];
77+
}
78+
79+
function recursivelyFindOptionalNodes(node: Node, queryParams: Query = {}) {
80+
const { segmentPattern, children, routes } = node;
81+
82+
if (segmentPattern.endsWith('?')) {
83+
const maybeMatchedRoute = matchRoutesByQuery(routes, queryParams);
84+
if (maybeMatchedRoute) {
85+
return maybeMatchedRoute;
86+
}
87+
for (const key in children) {
88+
if (Object.prototype.hasOwnProperty.call(children, key)) {
89+
return recursivelyFindOptionalNodes(children[key]);
90+
}
91+
}
92+
}
93+
}
94+
95+
function matchRoutesByQuery(routes: Route[], queryParamObject: Query) {
96+
if (routes.length === 0) return null;
97+
98+
// why do we sort the routes by query length?
99+
// because we want to match the most specific route first
100+
// and we assume that the more query params a route has, the more specific it is
101+
// of course, this is a heuristic and is prehaps not true in all cases but good enough for now
102+
const sortedRoutes = routes.sort((a, b) => {
103+
const aQueryLength = a.query?.length || 0;
104+
const bQueryLength = b.query?.length || 0;
105+
106+
return bQueryLength - aQueryLength;
107+
});
108+
109+
const filterRoutes = sortedRoutes.filter(route => {
110+
// if route has no query, anything query param will match
111+
if (route.query === undefined) return true;
112+
// we will get a real match from the execRouteMatching function later
113+
const fakeMatch = {
114+
params: {},
115+
query: {},
116+
isExact: false,
117+
path: '',
118+
url: '',
119+
};
120+
121+
return !!matchQuery(route.query, queryParamObject, fakeMatch);
122+
});
123+
124+
if (filterRoutes.length) {
125+
// return the first (most specific) route that matches the query
126+
return filterRoutes[0];
127+
}
128+
129+
return null;
130+
}
131+
132+
const findRoute = (
133+
tree: Tree,
134+
p: string,
135+
queryParams: Query = {},
136+
basePath: string
137+
) => {
138+
const pathname = p.replace(basePath, '');
139+
// split the pathname into segments
140+
// e.g. /jira/projects/123 => ['', 'jira', 'projects', '123']
141+
const segments = pathname.split('/');
142+
143+
// remove the first empty string
144+
if (segments[0] === '') segments.shift();
145+
// remove the last empty string
146+
if (segments[segments.length - 1] === '') segments.pop();
147+
148+
// a first-in-first-out stack to keep track of the nodes we need to visit
149+
// start with the root node
150+
const stack: Array<Node | Route> = [tree.root];
151+
152+
let count = 0;
153+
const maxCount = 2000; // to prevent infinite loop
154+
155+
// when we exacust the stack and can't find a match, means nothing matches
156+
while (stack.length > 0 && count < maxCount) {
157+
count += 1;
158+
// pop the first node from the stack
159+
const node = stack.shift();
160+
161+
// to make TypeScript happy. It's impossible to have a null node
162+
if (!node) return null;
163+
164+
// if the node is a Route, it means we have traversed its children and cannot find a higher specificity match
165+
// we should return this route
166+
if (!(node instanceof Node)) {
167+
// we found a match
168+
return node;
169+
}
170+
171+
const { children, routes, level } = node;
172+
173+
let maybeMatchedRoute = null;
174+
let shouldMatchChildren = true;
175+
176+
if (Object.keys(children).length === 0) {
177+
// we've reached the end of a branch
178+
179+
if (!routes.length) {
180+
throw new Error('It should have a route at the end of a branch.');
181+
}
182+
183+
// let's match query
184+
maybeMatchedRoute = matchRoutesByQuery(routes, queryParams);
185+
186+
if (maybeMatchedRoute) {
187+
// do we have more segments to match with?
188+
if (segments.length > level) {
189+
// we have more segments to match but this branch doesn't have any children left
190+
191+
// let's check if the route has `exact: true`.
192+
if (maybeMatchedRoute.exact) {
193+
// let's go to another branch.
194+
maybeMatchedRoute = null;
195+
}
196+
}
197+
}
198+
} else if (segments.length === level) {
199+
// we've reached the end of the segments
200+
201+
// does the node have a route?
202+
if (routes.length) {
203+
// let's match query
204+
maybeMatchedRoute = matchRoutesByQuery(routes, queryParams);
205+
}
206+
} else if (segments.length < level) {
207+
// we've exceeded the segments and shouldn't match children anymore
208+
shouldMatchChildren = false;
209+
210+
// we check if this node and its children are optional
211+
// e.g. `/:a?/:b?/:c?` matches `/`
212+
// we check if `/:a?` node has a route, if `/:b?` node has a route, and if `/:c?` node has a route
213+
// if any of them has a route, we have a match
214+
maybeMatchedRoute = recursivelyFindOptionalNodes(node, queryParams);
215+
} else {
216+
// there are more segments to match and this node has children
217+
218+
// we need to check if this node has a route that has `exact: false`
219+
// if it has, we have a potential match. We will unshift it to the stack.
220+
// we will continue to check the children of this node to see if we can find a more specific match
221+
// let's match query
222+
const lowSpecifityRoute = matchRoutesByQuery(routes, queryParams);
223+
if (lowSpecifityRoute && !lowSpecifityRoute.exact) {
224+
// we have a potential match
225+
stack.unshift(lowSpecifityRoute);
226+
}
227+
}
228+
229+
// yay, we found a match
230+
if (maybeMatchedRoute) {
231+
return maybeMatchedRoute;
232+
}
233+
234+
if (shouldMatchChildren) {
235+
// if we haven't found a match, let's check the current node's children
236+
const nodes = matchChildren(node, segments);
237+
// add potential matched children to the stack
238+
stack.unshift(...nodes);
239+
}
240+
// go back to the beginning of the loop, pop out the next node from the stack, and repeat
241+
}
242+
243+
return null;
244+
};
245+
246+
function execRouteMatchingAndCache(
247+
route: Route | null,
248+
pathname: string,
249+
queryParamObject: Query,
250+
basePath: string
251+
) {
252+
if (route) {
253+
const matchedRoute = execRouteMatching(
254+
route,
255+
pathname,
256+
queryParamObject,
257+
basePath
258+
);
259+
260+
if (matchedRoute) {
261+
matchRouteCache.set(pathname, queryParamObject, basePath, matchedRoute);
262+
263+
return matchedRoute;
264+
}
265+
}
266+
267+
return null;
268+
}
269+
270+
const matchRoute = (
271+
routes: Routes,
272+
pathname: string,
273+
queryParams: Query = {},
274+
basePath = ''
275+
) => {
276+
const queryParamObject =
277+
typeof queryParams === 'string'
278+
? (qs.parse(queryParams) as Query)
279+
: queryParams;
280+
281+
const cachedMatch = matchRouteCache.get<Route>(
282+
pathname,
283+
queryParamObject,
284+
basePath
285+
);
286+
if (cachedMatch && routes.includes(cachedMatch.route)) return cachedMatch;
287+
288+
// fast return if there is no route or only one route
289+
if (routes.length === 0) return null;
290+
if (routes.length === 1)
291+
return execRouteMatchingAndCache(
292+
routes[0],
293+
pathname,
294+
queryParamObject,
295+
basePath
296+
);
297+
298+
const tree = treeify(routes);
299+
const route =
300+
findRoute(tree, pathname, queryParamObject, basePath) || tree.fallbackRoute;
301+
302+
return execRouteMatchingAndCache(route, pathname, queryParamObject, basePath);
303+
};
304+
305+
export const matchRouteByTree = (
306+
tree: Tree,
307+
pathname: string,
308+
queryParams: Query = {},
309+
basePath = ''
310+
) => {
311+
const queryParamObject =
312+
typeof queryParams === 'string'
313+
? (qs.parse(queryParams) as Query)
314+
: queryParams;
315+
316+
const route =
317+
findRoute(tree, pathname, queryParamObject, basePath) || tree.fallbackRoute;
318+
319+
if (route) {
320+
return execRouteMatching(route, pathname, queryParamObject, basePath);
321+
}
322+
323+
return null;
324+
};
325+
326+
export default matchRoute;

0 commit comments

Comments
 (0)