-
Notifications
You must be signed in to change notification settings - Fork 28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
order independent routing #250
Open
liamqma
wants to merge
2
commits into
master
Choose a base branch
from
order-independent-routing
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"react-resource-router": minor | ||
--- | ||
|
||
Added order-independent matchRoute |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
326 changes: 326 additions & 0 deletions
326
src/common/utils/match-route-order-independent/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,326 @@ | ||
import { pathToRegexp } from 'path-to-regexp'; | ||
import { qs } from 'url-parse'; | ||
|
||
import { Query, Routes, Route } from '../../types'; | ||
import execRouteMatching from '../match-route/exec-route-matching'; | ||
import matchQuery from '../match-route/matchQuery'; | ||
|
||
import { type Tree, Node, treeify } from './tree'; | ||
import { matchRouteCache } from './utils'; | ||
|
||
function pushOrUnshiftByCaptureGroup(arr: Node[], node: Node) { | ||
if (node.segmentPattern.includes('(') && node.segmentPattern.includes(')')) { | ||
// if the segmentPattern has capturing group, it's more specific | ||
// so we place it at the beginning of the array | ||
arr.unshift(node); | ||
} else { | ||
// otherwise place at the end of the array | ||
arr.push(node); | ||
} | ||
} | ||
|
||
// Find matching nodes by segment | ||
// sort the nodes by specificity | ||
function matchChildren(node: Node, segments: string[]) { | ||
// how do we define specificity? This is a tricky question. | ||
// the specificity goes like this: | ||
// 1. if the segment is an exact match, of course it's the most specific | ||
// 2. the rest is regex match, within the regex match, we have to consider: | ||
// 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 | ||
// 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 | ||
// | ||
// This is not a comprehensive solution to the specificity problem | ||
// I will use production urls to verify this heuristic | ||
|
||
const exactMatch: Node[] = []; // segment is an exact match e.g. /jira matches /jira | ||
const lengthMatch: Node[] = []; // check #2.1 from the above comment | ||
const rest: Node[] = []; | ||
|
||
const { children } = node; | ||
// treat url segment as empty string if it's undefined | ||
// possible if we have optional segmentPattern | ||
const segment = segments[node.level] || ''; | ||
// check if there is next segment | ||
const hasNextSegment = segments.length > node.level; | ||
|
||
for (const segmentPattern in children) { | ||
if (Object.prototype.hasOwnProperty.call(children, segmentPattern)) { | ||
const child = children[segmentPattern]; | ||
|
||
if (segment === segmentPattern) { | ||
// we have exact segment match | ||
exactMatch.push(child); | ||
} else { | ||
const regex = pathToRegexp(segmentPattern, [], { | ||
end: true, | ||
strict: true, | ||
sensitive: false, | ||
}); | ||
if (regex.test(segment)) { | ||
const nodeAhasChildren = Object.keys(child.children).length > 0; | ||
|
||
if (hasNextSegment && nodeAhasChildren) { | ||
// if there is a next segment, we should prioritize nodes with children | ||
pushOrUnshiftByCaptureGroup(lengthMatch, child); | ||
} else if (!hasNextSegment && !nodeAhasChildren) { | ||
// if there is no next segment, we should prioritize nodes without children | ||
pushOrUnshiftByCaptureGroup(lengthMatch, child); | ||
} else { | ||
pushOrUnshiftByCaptureGroup(rest, child); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
return [...exactMatch, ...lengthMatch, ...rest]; | ||
} | ||
|
||
function recursivelyFindOptionalNodes(node: Node, queryParams: Query = {}) { | ||
const { segmentPattern, children, routes } = node; | ||
|
||
if (segmentPattern.endsWith('?')) { | ||
const maybeMatchedRoute = matchRoutesByQuery(routes, queryParams); | ||
if (maybeMatchedRoute) { | ||
return maybeMatchedRoute; | ||
} | ||
for (const key in children) { | ||
if (Object.prototype.hasOwnProperty.call(children, key)) { | ||
return recursivelyFindOptionalNodes(children[key]); | ||
} | ||
} | ||
} | ||
} | ||
|
||
function matchRoutesByQuery(routes: Route[], queryParamObject: Query) { | ||
if (routes.length === 0) return null; | ||
|
||
// why do we sort the routes by query length? | ||
// because we want to match the most specific route first | ||
// and we assume that the more query params a route has, the more specific it is | ||
// of course, this is a heuristic and is prehaps not true in all cases but good enough for now | ||
const sortedRoutes = routes.sort((a, b) => { | ||
const aQueryLength = a.query?.length || 0; | ||
const bQueryLength = b.query?.length || 0; | ||
|
||
return bQueryLength - aQueryLength; | ||
}); | ||
|
||
const filterRoutes = sortedRoutes.filter(route => { | ||
// if route has no query, anything query param will match | ||
if (route.query === undefined) return true; | ||
// we will get a real match from the execRouteMatching function later | ||
const fakeMatch = { | ||
params: {}, | ||
query: {}, | ||
isExact: false, | ||
path: '', | ||
url: '', | ||
}; | ||
|
||
return !!matchQuery(route.query, queryParamObject, fakeMatch); | ||
}); | ||
|
||
if (filterRoutes.length) { | ||
// return the first (most specific) route that matches the query | ||
return filterRoutes[0]; | ||
} | ||
|
||
return null; | ||
} | ||
|
||
const findRoute = ( | ||
tree: Tree, | ||
p: string, | ||
queryParams: Query = {}, | ||
basePath: string | ||
) => { | ||
const pathname = p.replace(basePath, ''); | ||
// split the pathname into segments | ||
// e.g. /jira/projects/123 => ['', 'jira', 'projects', '123'] | ||
const segments = pathname.split('/'); | ||
|
||
// remove the first empty string | ||
if (segments[0] === '') segments.shift(); | ||
// remove the last empty string | ||
if (segments[segments.length - 1] === '') segments.pop(); | ||
|
||
// a first-in-first-out stack to keep track of the nodes we need to visit | ||
// start with the root node | ||
const stack: Array<Node | Route> = [tree.root]; | ||
|
||
let count = 0; | ||
const maxCount = 2000; // to prevent infinite loop | ||
|
||
// when we exacust the stack and can't find a match, means nothing matches | ||
while (stack.length > 0 && count < maxCount) { | ||
count += 1; | ||
// pop the first node from the stack | ||
const node = stack.shift(); | ||
|
||
// to make TypeScript happy. It's impossible to have a null node | ||
if (!node) return null; | ||
|
||
// if the node is a Route, it means we have traversed its children and cannot find a higher specificity match | ||
// we should return this route | ||
if (!(node instanceof Node)) { | ||
// we found a match | ||
return node; | ||
} | ||
|
||
const { children, routes, level } = node; | ||
|
||
let maybeMatchedRoute = null; | ||
let shouldMatchChildren = true; | ||
|
||
if (Object.keys(children).length === 0) { | ||
// we've reached the end of a branch | ||
|
||
if (!routes.length) { | ||
throw new Error('It should have a route at the end of a branch.'); | ||
} | ||
|
||
// let's match query | ||
maybeMatchedRoute = matchRoutesByQuery(routes, queryParams); | ||
|
||
if (maybeMatchedRoute) { | ||
// do we have more segments to match with? | ||
if (segments.length > level) { | ||
// we have more segments to match but this branch doesn't have any children left | ||
|
||
// let's check if the route has `exact: true`. | ||
if (maybeMatchedRoute.exact) { | ||
// let's go to another branch. | ||
maybeMatchedRoute = null; | ||
} | ||
} | ||
} | ||
} else if (segments.length === level) { | ||
// we've reached the end of the segments | ||
|
||
// does the node have a route? | ||
if (routes.length) { | ||
// let's match query | ||
maybeMatchedRoute = matchRoutesByQuery(routes, queryParams); | ||
} | ||
} else if (segments.length < level) { | ||
// we've exceeded the segments and shouldn't match children anymore | ||
shouldMatchChildren = false; | ||
|
||
// we check if this node and its children are optional | ||
// e.g. `/:a?/:b?/:c?` matches `/` | ||
// we check if `/:a?` node has a route, if `/:b?` node has a route, and if `/:c?` node has a route | ||
// if any of them has a route, we have a match | ||
maybeMatchedRoute = recursivelyFindOptionalNodes(node, queryParams); | ||
} else { | ||
// there are more segments to match and this node has children | ||
|
||
// we need to check if this node has a route that has `exact: false` | ||
// if it has, we have a potential match. We will unshift it to the stack. | ||
// we will continue to check the children of this node to see if we can find a more specific match | ||
// let's match query | ||
const lowSpecifityRoute = matchRoutesByQuery(routes, queryParams); | ||
if (lowSpecifityRoute && !lowSpecifityRoute.exact) { | ||
// we have a potential match | ||
stack.unshift(lowSpecifityRoute); | ||
} | ||
} | ||
|
||
// yay, we found a match | ||
if (maybeMatchedRoute) { | ||
return maybeMatchedRoute; | ||
} | ||
|
||
if (shouldMatchChildren) { | ||
// if we haven't found a match, let's check the current node's children | ||
const nodes = matchChildren(node, segments); | ||
// add potential matched children to the stack | ||
stack.unshift(...nodes); | ||
} | ||
// go back to the beginning of the loop, pop out the next node from the stack, and repeat | ||
} | ||
|
||
return null; | ||
}; | ||
|
||
function execRouteMatchingAndCache( | ||
route: Route | null, | ||
pathname: string, | ||
queryParamObject: Query, | ||
basePath: string | ||
) { | ||
if (route) { | ||
const matchedRoute = execRouteMatching( | ||
route, | ||
pathname, | ||
queryParamObject, | ||
basePath | ||
); | ||
|
||
if (matchedRoute) { | ||
matchRouteCache.set(pathname, queryParamObject, basePath, matchedRoute); | ||
|
||
return matchedRoute; | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
const matchRoute = ( | ||
routes: Routes, | ||
pathname: string, | ||
queryParams: Query = {}, | ||
basePath = '' | ||
) => { | ||
const queryParamObject = | ||
typeof queryParams === 'string' | ||
? (qs.parse(queryParams) as Query) | ||
: queryParams; | ||
|
||
const cachedMatch = matchRouteCache.get<Route>( | ||
pathname, | ||
queryParamObject, | ||
basePath | ||
); | ||
if (cachedMatch && routes.includes(cachedMatch.route)) return cachedMatch; | ||
|
||
// fast return if there is no route or only one route | ||
if (routes.length === 0) return null; | ||
if (routes.length === 1) | ||
return execRouteMatchingAndCache( | ||
routes[0], | ||
pathname, | ||
queryParamObject, | ||
basePath | ||
); | ||
|
||
const tree = treeify(routes); | ||
const route = | ||
findRoute(tree, pathname, queryParamObject, basePath) || tree.fallbackRoute; | ||
|
||
return execRouteMatchingAndCache(route, pathname, queryParamObject, basePath); | ||
}; | ||
|
||
export const matchRouteByTree = ( | ||
tree: Tree, | ||
pathname: string, | ||
queryParams: Query = {}, | ||
basePath = '' | ||
) => { | ||
const queryParamObject = | ||
typeof queryParams === 'string' | ||
? (qs.parse(queryParams) as Query) | ||
: queryParams; | ||
|
||
const route = | ||
findRoute(tree, pathname, queryParamObject, basePath) || tree.fallbackRoute; | ||
|
||
if (route) { | ||
return execRouteMatching(route, pathname, queryParamObject, basePath); | ||
} | ||
|
||
return null; | ||
}; | ||
|
||
export default matchRoute; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we use
.ts
file extension for files in this directory?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah, good point. Let me change it to
.ts
.