From ae11729d4157192f43ce8f0c8cb5527557a090c1 Mon Sep 17 00:00:00 2001 From: Liam Ma Date: Thu, 2 Jan 2025 16:12:38 +1100 Subject: [PATCH 1/2] order independent routing --- src/common/utils/index.ts | 5 + .../match-route-order-independent/index.tsx | 326 +++++++++++ .../match-route-order-independent/test.tsx | 528 ++++++++++++++++++ .../match-route-order-independent/tree.tsx | 90 +++ .../match-route-order-independent/utils.tsx | 69 +++ src/utils.ts | 3 + 6 files changed, 1021 insertions(+) create mode 100644 src/common/utils/match-route-order-independent/index.tsx create mode 100644 src/common/utils/match-route-order-independent/test.tsx create mode 100644 src/common/utils/match-route-order-independent/tree.tsx create mode 100644 src/common/utils/match-route-order-independent/utils.tsx diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 7621b735..7b11afd1 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -7,5 +7,10 @@ export { matchInvariantRoute, warmupMatchRouteCache, } from './match-route'; +export { + default as matchRouteOrderIndependent, + matchRouteByTree as matchRouteOrderIndependentByTree, +} from './match-route-order-independent'; +export { treeify } from './match-route-order-independent/tree'; export { findRouterContext, createRouterContext } from './router-context'; export { isSameRouteMatch } from './is-same-route'; diff --git a/src/common/utils/match-route-order-independent/index.tsx b/src/common/utils/match-route-order-independent/index.tsx new file mode 100644 index 00000000..dd4fa16c --- /dev/null +++ b/src/common/utils/match-route-order-independent/index.tsx @@ -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 = [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( + 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; diff --git a/src/common/utils/match-route-order-independent/test.tsx b/src/common/utils/match-route-order-independent/test.tsx new file mode 100644 index 00000000..15ba3816 --- /dev/null +++ b/src/common/utils/match-route-order-independent/test.tsx @@ -0,0 +1,528 @@ +import { qs } from 'url-parse'; + +import type { Query } from '../../types.tsx'; + +import { matchRouteCache } from './utils'; + +import matchRoute from './index'; + +describe('matchRoute()', () => { + beforeEach(() => { + matchRouteCache.cache.clear(); + }); + + const Noop = () => null; + const DEFAULT_ROUTE_NAME = 'default'; + + describe('pathname', () => { + it('should match a pathname without a query string', () => { + const route1 = { + path: '/foo', + exact: false, + component: Noop, + name: 'ROUTE_1', + }; + const route2 = { + path: '/foo/abc', + exact: true, + component: Noop, + name: 'ROUTE_1', + }; + expect(matchRoute([route1, route2], '/foo/abc')).toEqual({ + route: route2, + match: { + params: {}, + isExact: true, + path: '/foo/abc', + query: {}, + url: '/foo/abc', + }, + }); + }); + + it('should match by path specifity', () => { + const route = { + path: '/foo/:bar', + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect(matchRoute([route], '/foo/abc')).toEqual({ + route, + match: { + params: { bar: 'abc' }, + isExact: true, + path: '/foo/:bar', + query: {}, + url: '/foo/abc', + }, + }); + + expect(matchRoute([route], '/baz/abc')).toBeNull(); + }); + }); + + describe('pathname with basePath', () => { + it('should match a pathname when basePath is empty', () => { + const route = { + path: '/foo/:bar', + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect(matchRoute([route], '/foo/abc')).toEqual({ + route, + match: { + params: { bar: 'abc' }, + isExact: true, + path: '/foo/:bar', + query: {}, + url: '/foo/abc', + }, + }); + + expect(matchRoute([route], '/hello/foo/abc')).toBeNull(); + expect(matchRoute([route], '/baz/abc')).toBeNull(); + }); + + it('should match a basePath+pathname without a query string', () => { + const route = { + path: '/foo/:bar', + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + const basePath = '/base'; + expect(matchRoute([route], '/base/foo/abc', undefined, basePath)).toEqual( + { + route, + match: { + params: { bar: 'abc' }, + isExact: true, + path: '/base/foo/:bar', + query: {}, + url: '/base/foo/abc', + }, + } + ); + + expect(matchRoute([route], '/foo/abc', undefined, basePath)).toBeNull(); + expect( + matchRoute([route], '/base/baz/abc', undefined, basePath) + ).toBeNull(); + }); + }); + + describe('query', () => { + it('should match query config requiring query name to be present', () => { + const route = { + path: '/abc/:bar', + query: ['foo'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=baz&spa=awesome') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: 'baz', + }, + }, + }); + + expect(matchRoute([route], '/abc/def')).toBeNull(); + expect( + matchRoute([route], '/abc/def', qs.parse('?spa=awesome') as Query) + ).toBeNull(); + }); + + it('should match query config with multiple query params if all of them match', () => { + const multiple = { + path: '/abc/:bar', + query: ['foo', 'spa'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute( + [multiple], + '/abc/def', + qs.parse('?foo=baz&spa=awesome') as Query + ) + ).toMatchObject({ + route: multiple, + }); + expect( + matchRoute([multiple], '/abc/def', qs.parse('?foo=baz') as Query) + ).toBeNull(); + expect( + matchRoute([multiple], '/abc/def', qs.parse('?spa=awesome') as Query) + ).toBeNull(); + }); + + it('should return same match object as matching pathname but with additional query object containing all query params', () => { + const route = { + path: '/abc/:bar', + query: ['foo'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=baz&spa=awesome') as Query + ) + ).toEqual({ + route, + match: { + params: { + bar: 'def', + }, + query: { + foo: 'baz', + }, + isExact: true, + path: '/abc/:bar', + url: '/abc/def', + }, + }); + }); + + it('should match query config requiring query param to equal specific value', () => { + const route = { + path: '/abc/:bar', + query: ['foo=baz'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=baz&spa=awesome') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: 'baz', + }, + }, + }); + + expect(matchRoute([route], '/abc/def')).toBeNull(); + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=abc&spa=awesome') as Query + ) + ).toBeNull(); + }); + + it('should match query config requiring query param to equal a regex value', () => { + const route = { + path: '/abc/:bar', + query: ['foo=(plan.*)'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=plan&spa=awesome') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: 'plan', + }, + }, + }); + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=planning&spa=awesome') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: 'planning', + }, + }, + }); + + expect(matchRoute([route], '/abc/def')).toBeNull(); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=pla') as Query) + ).toBeNull(); + + const numberRegexRoute = { + path: '/abc/:bar', + query: ['spaAwesomeFactor=(\\d)'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + + expect( + matchRoute( + [numberRegexRoute], + '/abc/def', + qs.parse('spaAwesomeFactor=9') as Query + ) + ).toMatchObject({ + route: numberRegexRoute, + }); + // Should be only one number + expect( + matchRoute( + [numberRegexRoute], + '/abc/def', + qs.parse('spaAwesomeFactor=10') as Query + ) + ).toBeNull(); + // Should be a number + expect( + matchRoute( + [numberRegexRoute], + '/abc/def', + qs.parse('spaAwesomeFactor=abc') as Query + ) + ).toBeNull(); + }); + + it('should match query params literally instead of as a regex when value does not start with parentheses', () => { + const route = { + path: '/abc/:bar', + query: ['foo=plan.detail'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=plan.detail') as Query) + ).toMatchObject({ + route, + match: { + query: { + foo: 'plan.detail', + }, + }, + }); + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=plansdetail') as Query) + ).toBeNull(); + }); + + it('should match query config requiring query param to not equal a specific value', () => { + const route = { + path: '/abc/:bar', + query: ['foo!=bar'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=baz&spa=awesome') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: 'baz', + }, + }, + }); + expect( + matchRoute([route], '/abc/def', qs.parse('?spa=awesome') as Query) + ).toMatchObject({ + route, + match: { + query: {}, + }, + }); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=bar') as Query) + ).toBeNull(); + }); + + it('should match query config requiring alternative params', () => { + const route = { + path: '/abc/:bar', + query: ['foo|foo2'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo=bar&spa=awesome') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: 'bar', + }, + }, + }); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo2=1') as Query) + ).toMatchObject({ + route, + match: { + query: { + foo2: '1', + }, + }, + }); + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=bar&foo2=1') as Query) + ).toMatchObject({ + route, + match: { + query: { + foo: 'bar', + foo2: '1', + }, + }, + }); + + expect( + matchRoute([route], '/abc/def', qs.parse('?spa=awesome') as Query) + ).toBeNull(); + }); + + it('should match query config including optional params', () => { + const route = { + path: '/abc/:bar', + query: ['foo', 'baz?', 'bar?=(\\d+)'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo') as Query) + ).toMatchObject({ + route, + match: { + query: { + foo: '', + }, + }, + }); + expect( + matchRoute([route], '/abc/def', qs.parse('?foo&baz=cool') as Query) + ).toMatchObject({ + route, + match: { + query: { + foo: '', + baz: 'cool', + }, + }, + }); + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo&bar=1&baz=cool') as Query + ) + ).toMatchObject({ + route, + match: { + query: { + foo: '', + bar: '1', + baz: 'cool', + }, + }, + }); + expect( + matchRoute( + [route], + '/abc/def', + qs.parse('?foo&baz=cool&bar=cool') as Query + ) + ).toBeNull(); + }); + + it('should fail gracefully if passed invalid query string', () => { + const route = { + path: '/abc/:bar', + query: ['foo'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute([route], '/abc/def', qs.parse('?badstring=%') as Query) + ).toBeNull(); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=%') as Query) + ).toBeNull(); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=%2') as Query) + ).toBeNull(); + }); + + it('should handle non-standard characters', () => { + const route = { + path: '/abc/:bar', + query: ['foo', 'bar?'], + component: Noop, + name: DEFAULT_ROUTE_NAME, + }; + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=a%0Ab&bar=3') as Query) // %0A == line feed + ).toMatchObject({ + route, + match: { + query: { + foo: 'a\nb', + bar: '3', + }, + }, + }); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=a%00b&bar=3') as Query) // %00 == null + ).toMatchObject({ + route, + match: { + query: { + foo: 'a\0b', + bar: '3', + }, + }, + }); + + expect( + matchRoute([route], '/abc/def', qs.parse('?foo=prøve&bar=3') as Query) // ø is non-ascii character + ).toMatchObject({ + route, + match: { + query: { + foo: 'prøve', + bar: '3', + }, + }, + }); + }); + }); +}); diff --git a/src/common/utils/match-route-order-independent/tree.tsx b/src/common/utils/match-route-order-independent/tree.tsx new file mode 100644 index 00000000..8ce55728 --- /dev/null +++ b/src/common/utils/match-route-order-independent/tree.tsx @@ -0,0 +1,90 @@ +import type { Route } from '../../types.tsx'; + +export class Node { + children: { [key: string]: Node }; + + // routes that match this node + // this is an array because multiple routes can match the same path, but have different query params + routes: Route[]; + + // the level of the node in the tree, starting from 0 + level: number; + + // the segment pattern of the node e.g. '/:id' + segmentPattern: string; + + constructor(level: number, segmentPattern: string) { + this.children = {}; + this.routes = []; + this.level = level; + this.segmentPattern = segmentPattern; + } +} + +export class Tree { + root: Node; + + // fallback route is the routes that match everything. + // this is normally useful for 404 pages + // we specifically handle this route for performance reason + // because if we put it into the tree, it will always get traversed and has the least specificity. + fallbackRoute: Route | null = null; + + constructor() { + this.root = new Node(0, ''); + } + + insert(segmentPatterns: Array, route: Route) { + let current = this.root; + for (let i = 0; i < segmentPatterns.length; i++) { + const segmentPattern = segmentPatterns[i]; + if (!current.children[segmentPattern]) { + current.children[segmentPattern] = new Node(i + 1, segmentPattern); + } + current = current.children[segmentPattern]; + } + current.routes.push(route); + } +} + +const trim = (segmentPatterns: Array) => { + // remove the first empty string + if (segmentPatterns[0] === '') segmentPatterns.shift(); + // remove the last empty string + if (segmentPatterns[segmentPatterns.length - 1] === '') segmentPatterns.pop(); +}; + +export const treeify = ( + routes: Route[], + handler?: (route: Route, tree: Tree) => boolean +) => { + const tree = new Tree(); + routes.forEach(route => { + if ( + typeof route.path !== 'string' || + route.path === '' || + route.path === '/*' || + route.path === '*' + ) { + if (tree.fallbackRoute) { + throw new Error( + 'There should be only one route that mates everything.' + ); + } + tree.fallbackRoute = route; + + return; + } + + const handled = + typeof handler === 'function' ? handler(route, tree) : false; + + if (!handled) { + const segmentPatterns = route.path.split('/'); + trim(segmentPatterns); + tree.insert(segmentPatterns, route); + } + }); + + return tree; +}; diff --git a/src/common/utils/match-route-order-independent/utils.tsx b/src/common/utils/match-route-order-independent/utils.tsx new file mode 100644 index 00000000..b2d55a0a --- /dev/null +++ b/src/common/utils/match-route-order-independent/utils.tsx @@ -0,0 +1,69 @@ +import type { Match, Query } from '../../types.tsx'; + +const hasOwnProperty = Object.prototype.hasOwnProperty; +const MAX_CACHE_SIZE = 1000; + +function shallowEqual(objA: any, objB: any) { + if (objA === objB) { + return true; + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) { + return false; + } + + // Test for A's keys different from B. + for (let i = 0; i < keysA.length; i++) { + if ( + !hasOwnProperty.call(objB, keysA[i]) || + objA[keysA[i]] !== objB[keysA[i]] + ) { + return false; + } + } + + return true; +} + +export const matchRouteCache = { + cache: new Map>(), + get( + pathname: string, + queryObj: Query, + basePath: string + ): { route: T; match: Match } | undefined { + const pathCache = this.cache.get(basePath + pathname); + if (pathCache) { + for (const [key, value] of pathCache) { + if (shallowEqual(key, queryObj)) return value; + } + } + }, + set( + pathname: string, + queryObj: Query, + basePath: string, + matchRoute: { route: T; match: Match } + ): void { + if (this.cache.size > MAX_CACHE_SIZE) this.cache.clear(); + const pathCache = this.cache.get(basePath + pathname); + if (pathCache) { + if (pathCache.size > MAX_CACHE_SIZE / 10) pathCache.clear(); + pathCache.set(queryObj, matchRoute); + } else { + this.cache.set(basePath + pathname, new Map([[queryObj, matchRoute]])); + } + }, +}; diff --git a/src/utils.ts b/src/utils.ts index c171c377..05c2079b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,9 @@ export { matchRoute, matchInvariantRoute, isSameRouteMatch, + matchRouteOrderIndependent, + matchRouteOrderIndependentByTree, + treeify, } from './common/utils'; export { shouldReloadWhenRouteMatchChanges } from './common/utils/should-reload-when-route-match-changes'; From db43d3153e8764b0f18132d3fee36b26072385e8 Mon Sep 17 00:00:00 2001 From: Liam Ma Date: Thu, 2 Jan 2025 17:05:38 +1100 Subject: [PATCH 2/2] Add changset --- .changeset/plenty-bears-crash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/plenty-bears-crash.md diff --git a/.changeset/plenty-bears-crash.md b/.changeset/plenty-bears-crash.md new file mode 100644 index 00000000..61a613c6 --- /dev/null +++ b/.changeset/plenty-bears-crash.md @@ -0,0 +1,5 @@ +--- +"react-resource-router": minor +--- + +Added order-independent matchRoute