diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 827f17c2432..d385901c50b 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -102,7 +102,7 @@ interface ResolvePathOptions { to: string trailingSlash?: 'always' | 'never' | 'preserve' caseSensitive?: boolean - parseCache?: ParsePathnameCache + parseRouteCache?: ParseRouteCache } function segmentToString(segment: Segment): string { @@ -156,13 +156,13 @@ export function resolvePath({ to, trailingSlash = 'never', caseSensitive, - parseCache, + parseRouteCache, }: ResolvePathOptions) { base = removeBasepath(basepath, base, caseSensitive) to = removeBasepath(basepath, to, caseSensitive) - let baseSegments = parsePathname(base, parseCache).slice() - const toSegments = parsePathname(to, parseCache) + let baseSegments = parsePathname(base, parseRouteCache).slice() + const toSegments = parsePathname(to, parseRouteCache) if (baseSegments.length > 1 && last(baseSegments)?.value === '/') { baseSegments.pop() @@ -205,19 +205,27 @@ export function resolvePath({ return joined } -export type ParsePathnameCache = LRUCache> -export const parsePathname = ( - pathname?: string, - cache?: ParsePathnameCache, -): ReadonlyArray => { - if (!pathname) return [] - const cached = cache?.get(pathname) - if (cached) return cached - const parsed = baseParsePathname(pathname) - cache?.set(pathname, parsed) - return parsed +function cacheMethod( + fn: (key: string) => ReadonlyArray, +): ( + key?: string, + cache?: LRUCache>, +) => ReadonlyArray { + return (key, cache) => { + if (!key) return [] + const cached = cache?.get(key) + if (cached) return cached + const result = fn(key) + cache?.set(key, result) + return result + } } +export type ParsePathCache = LRUCache> +export type ParseRouteCache = LRUCache> +export const parsePathname = cacheMethod(baseParsePathname) +const splitPathname = cacheMethod(baseSplitPathname) + const PARAM_RE = /^\$.{1,}$/ // $paramName const PARAM_W_CURLY_BRACES_RE = /^(.*?)\{(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{$paramName}suffix const OPTIONAL_PARAM_W_CURLY_BRACES_RE = @@ -226,6 +234,28 @@ const OPTIONAL_PARAM_W_CURLY_BRACES_RE = const WILDCARD_RE = /^\$$/ // $ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix +function baseSplitPathname(pathname: string): ReadonlyArray { + // Remove empty segments + pathname = cleanPath(pathname) + + if (!pathname || pathname === '/') return ['/'] + + if (pathname.includes('%25')) { + pathname = pathname + .split('%25') + .map((segment) => decodeURI(segment)) + .join('%25') + } else { + pathname = decodeURI(pathname) + } + + const split = pathname.split('/') + if (split[0] === '') split[0] = '/' + if (last(split) === '') split[split.length - 1] = '/' + + return split +} + /** * Required: `/foo/$bar` ✅ * Prefix and Suffix: `/foo/prefix${bar}suffix` ✅ @@ -244,113 +274,86 @@ const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix * - `/foo/{$foo}[$]` - Dynamic route with a static suffix of `$` */ function baseParsePathname(pathname: string): ReadonlyArray { - pathname = cleanPath(pathname) + const split = baseSplitPathname(pathname) - const segments: Array = [] - - if (pathname.slice(0, 1) === '/') { - pathname = pathname.substring(1) - segments.push({ - type: SEGMENT_TYPE_PATHNAME, - value: '/', - }) - } - - if (!pathname) { - return segments - } - - // Remove empty segments and '.' segments - const split = pathname.split('/').filter(Boolean) - - segments.push( - ...split.map((part): Segment => { - // Check for wildcard with curly braces: prefix{$}suffix - const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) - if (wildcardBracesMatch) { - const prefix = wildcardBracesMatch[1] - const suffix = wildcardBracesMatch[2] - return { - type: SEGMENT_TYPE_WILDCARD, - value: '$', - prefixSegment: prefix || undefined, - suffixSegment: suffix || undefined, - } + return split.map((part): Segment => { + if (part === '/') { + return { + type: SEGMENT_TYPE_PATHNAME, + value: '/', } + } - // Check for optional parameter format: prefix{-$paramName}suffix - const optionalParamBracesMatch = part.match( - OPTIONAL_PARAM_W_CURLY_BRACES_RE, - ) - if (optionalParamBracesMatch) { - const prefix = optionalParamBracesMatch[1] - const paramName = optionalParamBracesMatch[2]! - const suffix = optionalParamBracesMatch[3] - return { - type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: paramName, // Now just $paramName (no prefix) - prefixSegment: prefix || undefined, - suffixSegment: suffix || undefined, - } + // Check for wildcard with curly braces: prefix{$}suffix + const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) + if (wildcardBracesMatch) { + const prefix = wildcardBracesMatch[1] + const suffix = wildcardBracesMatch[2] + return { + type: SEGMENT_TYPE_WILDCARD, + value: '$', + prefixSegment: prefix || undefined, + suffixSegment: suffix || undefined, } + } - // Check for the new parameter format: prefix{$paramName}suffix - const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) - if (paramBracesMatch) { - const prefix = paramBracesMatch[1] - const paramName = paramBracesMatch[2] - const suffix = paramBracesMatch[3] - return { - type: SEGMENT_TYPE_PARAM, - value: '' + paramName, - prefixSegment: prefix || undefined, - suffixSegment: suffix || undefined, - } + // Check for optional parameter format: prefix{-$paramName}suffix + const optionalParamBracesMatch = part.match( + OPTIONAL_PARAM_W_CURLY_BRACES_RE, + ) + if (optionalParamBracesMatch) { + const prefix = optionalParamBracesMatch[1] + const paramName = optionalParamBracesMatch[2]! + const suffix = optionalParamBracesMatch[3] + return { + type: SEGMENT_TYPE_OPTIONAL_PARAM, + value: paramName, // Now just $paramName (no prefix) + prefixSegment: prefix || undefined, + suffixSegment: suffix || undefined, } + } - // Check for bare parameter format: $paramName (without curly braces) - if (PARAM_RE.test(part)) { - const paramName = part.substring(1) - return { - type: SEGMENT_TYPE_PARAM, - value: '$' + paramName, - prefixSegment: undefined, - suffixSegment: undefined, - } + // Check for the new parameter format: prefix{$paramName}suffix + const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) + if (paramBracesMatch) { + const prefix = paramBracesMatch[1] + const paramName = paramBracesMatch[2] + const suffix = paramBracesMatch[3] + return { + type: SEGMENT_TYPE_PARAM, + value: '' + paramName, + prefixSegment: prefix || undefined, + suffixSegment: suffix || undefined, } + } - // Check for bare wildcard: $ (without curly braces) - if (WILDCARD_RE.test(part)) { - return { - type: SEGMENT_TYPE_WILDCARD, - value: '$', - prefixSegment: undefined, - suffixSegment: undefined, - } + // Check for bare parameter format: $paramName (without curly braces) + if (PARAM_RE.test(part)) { + const paramName = part.substring(1) + return { + type: SEGMENT_TYPE_PARAM, + value: '$' + paramName, + prefixSegment: undefined, + suffixSegment: undefined, } + } - // Handle regular pathname segment + // Check for bare wildcard: $ (without curly braces) + if (WILDCARD_RE.test(part)) { return { - type: SEGMENT_TYPE_PATHNAME, - value: part.includes('%25') - ? part - .split('%25') - .map((segment) => decodeURI(segment)) - .join('%25') - : decodeURI(part), + type: SEGMENT_TYPE_WILDCARD, + value: '$', + prefixSegment: undefined, + suffixSegment: undefined, } - }), - ) + } - if (pathname.slice(-1) === '/') { - pathname = pathname.substring(1) - segments.push({ + // Handle regular pathname segment + return { type: SEGMENT_TYPE_PATHNAME, - value: '/', - }) - } - - return segments + value: part, + } + }) } interface InterpolatePathOptions { @@ -360,7 +363,8 @@ interface InterpolatePathOptions { leaveParams?: boolean // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params decodeCharMap?: Map - parseCache?: ParsePathnameCache + parseRouteCache?: ParseRouteCache + parsePathCache?: ParsePathCache } type InterPolatePathResult = { @@ -374,9 +378,9 @@ export function interpolatePath({ leaveWildcards, leaveParams, decodeCharMap, - parseCache, + parseRouteCache, }: InterpolatePathOptions): InterPolatePathResult { - const interpolatedPathSegments = parsePathname(path, parseCache) + const interpolatedPathSegments = parsePathname(path, parseRouteCache) function encodeParam(key: string): any { const value = params[key] @@ -494,13 +498,15 @@ export function matchPathname( basepath: string, currentPathname: string, matchLocation: Pick, - parseCache?: ParsePathnameCache, + parseRouteCache?: ParseRouteCache, + parsePathCache?: ParsePathCache, ): AnyPathParams | undefined { const pathParams = matchByPath( basepath, currentPathname, matchLocation, - parseCache, + parseRouteCache, + parsePathCache, ) // const searchMatched = matchBySearch(location.search, matchLocation) @@ -560,7 +566,8 @@ export function matchByPath( fuzzy, caseSensitive, }: Pick, - parseCache?: ParsePathnameCache, + parseRouteCache?: ParseRouteCache, + parsePathCache?: ParsePathCache, ): Record | undefined { // check basepath first if (basepath !== '/' && !from.startsWith(basepath)) { @@ -568,17 +575,21 @@ export function matchByPath( } // Remove the base path from the pathname from = removeBasepath(basepath, from, caseSensitive) - // Default to to $ (wildcard) + // Default to `to = '$'` (wildcard) to = removeBasepath(basepath, `${to ?? '$'}`, caseSensitive) - // Parse the from and to - const baseSegments = parsePathname( + // Parse the `from`, it's usually a full pathname without all the custom syntax. + // We only need the string value of each segment, to match them against the `to`. + const baseSegments = splitPathname( from.startsWith('/') ? from : `/${from}`, - parseCache, + parsePathCache, ) + + // Parse the `to`, it's usually a full route path, with all the custom syntax ($, {}, ...). + // We need the entire definition of each segment to know if `from` matches that route. const routeSegments = parsePathname( to.startsWith('/') ? to : `/${to}`, - parseCache, + parseRouteCache, ) const params: Record = {} @@ -595,7 +606,7 @@ export function matchByPath( } function isMatch( - baseSegments: ReadonlyArray, + baseSegments: ReadonlyArray, routeSegments: ReadonlyArray, params: Record, fuzzy?: boolean, @@ -623,23 +634,18 @@ function isMatch( const suffix = routeSegment.suffixSegment || '' // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value if ('prefixSegment' in routeSegment) { - if (!baseValue.startsWith(prefix)) { + if (!baseSegment.startsWith(prefix)) { return false } } if ('suffixSegment' in routeSegment) { - if ( - !baseSegments[baseSegments.length - 1]?.value.endsWith(suffix) - ) { + if (!baseSegments[baseSegments.length - 1]?.endsWith(suffix)) { return false } } - let rejoinedSplat = decodeURI( - joinPaths(remainingBaseSegments.map((d) => d.value)), - ) + let rejoinedSplat = decodeURI(joinPaths(remainingBaseSegments)) // Remove the prefix and suffix from the rejoined splat if (prefix && rejoinedSplat.startsWith(prefix)) { @@ -656,9 +662,7 @@ function isMatch( _splat = rejoinedSplat } else { // If no prefix/suffix, just rejoin the remaining segments - _splat = decodeURI( - joinPaths(remainingBaseSegments.map((d) => d.value)), - ) + _splat = decodeURI(joinPaths(remainingBaseSegments)) } // TODO: Deprecate * @@ -668,18 +672,18 @@ function isMatch( } if (routeSegment.type === SEGMENT_TYPE_PATHNAME) { - if (routeSegment.value === '/' && !baseSegment?.value) { + if (routeSegment.value === '/' && !baseSegment) { routeIndex++ continue } if (baseSegment) { if (caseSensitive) { - if (routeSegment.value !== baseSegment.value) { + if (routeSegment.value !== baseSegment) { return false } } else if ( - routeSegment.value.toLowerCase() !== baseSegment.value.toLowerCase() + routeSegment.value.toLowerCase() !== baseSegment.toLowerCase() ) { return false } @@ -696,7 +700,7 @@ function isMatch( return false } - if (baseSegment.value === '/') { + if (baseSegment === '/') { return false } @@ -709,7 +713,7 @@ function isMatch( const suffix = routeSegment.suffixSegment || '' // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value + const baseValue = baseSegment if (prefix && !baseValue.startsWith(prefix)) { return false } @@ -729,7 +733,7 @@ function isMatch( matched = true } else { // If no prefix/suffix, just decode the base segment value - _paramValue = decodeURIComponent(baseSegment.value) + _paramValue = decodeURIComponent(baseSegment) matched = true } @@ -750,7 +754,7 @@ function isMatch( continue } - if (baseSegment.value === '/') { + if (baseSegment === '/') { // Skip slash segments for optional params routeIndex++ continue @@ -765,7 +769,7 @@ function isMatch( const suffix = routeSegment.suffixSegment || '' // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value + const baseValue = baseSegment if ( (!prefix || baseValue.startsWith(prefix)) && (!suffix || baseValue.endsWith(suffix)) @@ -798,7 +802,7 @@ function isMatch( const futureRouteSegment = routeSegments[lookAhead] if ( futureRouteSegment?.type === SEGMENT_TYPE_PATHNAME && - futureRouteSegment.value === baseSegment.value + futureRouteSegment.value === baseSegment ) { // The current base segment matches a future pathname segment, // so we should skip this optional parameter @@ -820,7 +824,7 @@ function isMatch( if (shouldMatchOptional) { // If no prefix/suffix, just decode the base segment value - _paramValue = decodeURIComponent(baseSegment.value) + _paramValue = decodeURIComponent(baseSegment) matched = true } } @@ -837,9 +841,7 @@ function isMatch( // If we have base segments left but no route segments, it's a fuzzy match if (baseIndex < baseSegments.length && routeIndex >= routeSegments.length) { - params['**'] = joinPaths( - baseSegments.slice(baseIndex).map((d) => d.value), - ) + params['**'] = joinPaths(baseSegments.slice(baseIndex)) return !!fuzzy && routeSegments[routeSegments.length - 1]?.value !== '/' } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 807c296abf9..4f6a3619964 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -35,7 +35,7 @@ import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' import { createLRUCache } from './lru-cache' import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' -import type { ParsePathnameCache, Segment } from './path' +import type { ParsePathCache, ParseRouteCache, Segment } from './path' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { @@ -1040,7 +1040,7 @@ export class RouterCore< to: cleanPath(path), trailingSlash: this.options.trailingSlash, caseSensitive: this.options.caseSensitive, - parseCache: this.parsePathnameCache, + parseRouteCache: this.parseRouteCache, }) return resolvedPath } @@ -1222,7 +1222,8 @@ export class RouterCore< params: routeParams, leaveWildcards: true, decodeCharMap: this.pathParamsDecodeCharMap, - parseCache: this.parsePathnameCache, + parseRouteCache: this.parseRouteCache, + parsePathCache: this.parsePathCache, }).interpolatedPath + loaderDepsHash // Waste not, want not. If we already have a match for this route, @@ -1366,8 +1367,10 @@ export class RouterCore< return matches } - /** a cache for `parsePathname` */ - private parsePathnameCache: ParsePathnameCache = createLRUCache(1000) + /** a cache of parsed routes for `parsePathname` (e.g. `/foo/$bar` => Array) */ + private parseRouteCache: ParseRouteCache = new Map() + /** a cache of parsed pathnames for `parsePathname` (e.g. `/foo/123` => Array) */ + private parsePathCache: ParsePathCache = createLRUCache(500) getMatchedRoutes: GetMatchRoutesFn = ( pathname: string, @@ -1381,7 +1384,8 @@ export class RouterCore< routesByPath: this.routesByPath, routesById: this.routesById, flatRoutes: this.flatRoutes, - parseCache: this.parsePathnameCache, + parseRouteCache: this.parseRouteCache, + parsePathCache: this.parsePathCache, }) } @@ -1479,7 +1483,8 @@ export class RouterCore< const interpolatedNextTo = interpolatePath({ path: nextTo, params: nextParams, - parseCache: this.parsePathnameCache, + parseRouteCache: this.parseRouteCache, + parsePathCache: this.parsePathCache, }).interpolatedPath const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, { @@ -1505,7 +1510,8 @@ export class RouterCore< leaveWildcards: false, leaveParams: opts.leaveParams, decodeCharMap: this.pathParamsDecodeCharMap, - parseCache: this.parsePathnameCache, + parseRouteCache: this.parseRouteCache, + parsePathCache: this.parsePathCache, }).interpolatedPath // Resolve the next search @@ -1597,7 +1603,8 @@ export class RouterCore< caseSensitive: false, fuzzy: false, }, - this.parsePathnameCache, + this.parseRouteCache, + this.parsePathCache, ) if (match) { @@ -2277,7 +2284,8 @@ export class RouterCore< ...opts, to: next.pathname, }, - this.parsePathnameCache, + this.parseRouteCache, + this.parsePathCache, ) as any if (!match) { @@ -2612,7 +2620,8 @@ export function getMatchedRoutes({ routesByPath, routesById, flatRoutes, - parseCache, + parseRouteCache, + parsePathCache, }: { pathname: string routePathname?: string @@ -2621,7 +2630,8 @@ export function getMatchedRoutes({ routesByPath: Record routesById: Record flatRoutes: Array - parseCache?: ParsePathnameCache + parseRouteCache?: ParseRouteCache + parsePathCache?: ParsePathCache }) { let routeParams: Record = {} const trimmedPath = trimPathRight(pathname) @@ -2635,7 +2645,8 @@ export function getMatchedRoutes({ // we need fuzzy matching for `notFoundMode: 'fuzzy'` fuzzy: true, }, - parseCache, + parseRouteCache, + parsePathCache, ) return result }