UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

1,235 lines (1,151 loc) 37.6 kB
import { invariant } from './invariant' import { trimPathRight } from './path' import { createSieveCache } from './sieve-cache' import { last } from './utils' import type { SieveCache } from './sieve-cache' import type { InterpolationSegment, RouteInterpolation } from './path' export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 const SEGMENT_TYPE_INDEX = 4 const SEGMENT_TYPE_PATHLESS = 5 // only used in matching to represent pathless routes that need to carry more information /** * All the kinds of segments that can be present in a route path. */ export type SegmentKind = | typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM /** * All the kinds of segments that can be present in the segment tree. */ type ExtendedSegmentKind = | SegmentKind | typeof SEGMENT_TYPE_INDEX | typeof SEGMENT_TYPE_PATHLESS export type DynamicPathSegment = [ kind: Exclude<SegmentKind, typeof SEGMENT_TYPE_PATHNAME>, key: string, prefix: string, /** Undefined marks a bare splat, which discards the remaining template. */ suffix: string | undefined, ] export function getParamNames(data: RouteInterpolation): Array<string> { const cached = data.names if (cached) { return cached } const keys: Array<string> = [] for (const segment of data) { if (typeof segment !== 'string') { keys.push(segment[1 /* key */]) } } return (data.names = keys) } /** Parse one segment for matching and retain the same record for interpolation. */ export function parseSegment( /** The full path string containing the segment. */ path: string, /** The starting index of the segment within the path. */ start: number, /** The next slash, or the length of the path. */ end: number, ): InterpolationSegment { const part = path.substring(start, end) if (part.charCodeAt(0) === 36) { return part.length === 1 ? [SEGMENT_TYPE_WILDCARD, '_splat', '', undefined] : [SEGMENT_TYPE_PARAM, part.substring(1), '', ''] } const open = part.indexOf('{') if (open >= 0) { const close = part.indexOf('}', open) const optional = part.charCodeAt(open + 1) === 45 const nameStart = open + (optional ? 3 : 2) if ( close >= 0 && part.charCodeAt(nameStart - 1) === 36 && (!optional || nameStart < close) ) { const key = part.substring(nameStart, close) return [ optional ? SEGMENT_TYPE_OPTIONAL_PARAM : key ? SEGMENT_TYPE_PARAM : SEGMENT_TYPE_WILDCARD, key || '_splat', part.substring(0, open), path.substring(start + close + 1, key ? end : path.length), ] } } return part } type ParsedRoute<T extends RouteLike> = [ node: AnySegmentNode<T>, cursor: number, segments: RouteInterpolation | undefined, ] /** Compile a template, optionally attaching its new segments to the matching trie. */ export function parseSegments<TRouteLike extends RouteLike>( defaultCaseSensitive: boolean, route: TRouteLike, start: number, ): RouteInterpolation export function parseSegments<TRouteLike extends RouteLike>( defaultCaseSensitive: boolean, route: TRouteLike, start: number, node: AnySegmentNode<TRouteLike>, dynamicListsToSort?: Array<Array<DynamicSegmentNode<TRouteLike>>>, parentInterpolation?: RouteInterpolation, ): ParsedRoute<TRouteLike> export function parseSegments<TRouteLike extends RouteLike>( defaultCaseSensitive: boolean, route: TRouteLike, start: number, node?: AnySegmentNode<TRouteLike>, /** Each dynamic sibling list is recorded once, when it first needs sorting. */ dynamicListsToSort?: Array<Array<DynamicSegmentNode<TRouteLike>>>, parentInterpolation?: RouteInterpolation, ): RouteInterpolation | ParsedRoute<TRouteLike> { let cursor = start const path = route.fullPath ?? route.from const options = route.options const length = path.length const literalEnd = path.endsWith('/') ? length - 1 : length const caseSensitive = options?.caseSensitive ?? defaultCaseSensitive const parseParams = options?.params?.parse ?? options?.parseParams let interpolation: Array<InterpolationSegment> | undefined let literalStart = parentInterpolation ? start - 1 : 0 if (!node || path.includes('$')) { interpolation = parentInterpolation?.slice() ?? [] const tail = last(interpolation) if ( tail && typeof tail !== 'string' && tail[0 /* kind */] === SEGMENT_TYPE_WILDCARD ) { // A parent's wildcard consumes the child's template too. // `start` follows that entire template, including its trailing slash. interpolation[interpolation.length - 1] = [ tail[0 /* kind */], tail[1 /* key */], tail[2 /* prefix */], tail[3 /* suffix */] === undefined ? undefined : tail[3 /* suffix */] + path.substring( start - (path[start - 2] === '/' ? 2 : 1), literalEnd, ), ] literalStart = length } } while (cursor < length) { const start = cursor const next = path.indexOf('/', start) let end = next === -1 ? length : next const segment = parseSegment(path, start, end) cursor = end + 1 let nextNode: AnySegmentNode<TRouteLike> if (typeof segment === 'string') { if (!node) { continue } let name = segment let staticChildren: Map<string, StaticSegmentNode<TRouteLike>> if (caseSensitive) { staticChildren = node.static ??= new Map() } else { name = segment.toLowerCase() staticChildren = node.staticInsensitive ??= new Map() } const existingNode = staticChildren.get(name) if (existingNode) { nextNode = existingNode } else { const next = createSegmentNode(node) nextNode = next staticChildren.set(name, next) } } else { const kind = segment[0 /* kind */] let prefix = segment[2 /* prefix */] let suffix = segment[3 /* suffix */] ?? '' if (kind === SEGMENT_TYPE_WILDCARD) { end = length cursor = end + 1 } if (interpolation && literalStart < end) { // Retain original spelling before matcher case folding. if (literalStart < start - 1) { interpolation.push(path.substring(literalStart, start - 1)) } segment[2 /* prefix */] = '/' + prefix if ( kind === SEGMENT_TYPE_WILDCARD && segment[3 /* suffix */] !== undefined && literalEnd < length ) { segment[3 /* suffix */] = suffix.slice(0, -1) } interpolation.push(segment) literalStart = end } if (!node) { continue } const actuallyCaseSensitive = caseSensitive && !!(prefix || suffix) if (!caseSensitive) { prefix = prefix.toLowerCase() suffix = suffix.toLowerCase() } const siblings = kind === SEGMENT_TYPE_PARAM ? (node.dynamic ??= []) : kind === SEGMENT_TYPE_OPTIONAL_PARAM ? (node.optional ??= []) : (node.wildcard ??= []) const existingNode = // Keep wildcard aliases as separate match candidates, even when // they have the same shape and no parser. kind !== SEGMENT_TYPE_WILDCARD && !parseParams && siblings.find( (s) => !s.parse && s.caseSensitive === actuallyCaseSensitive && s.prefix === prefix && s.suffix === suffix, ) if (existingNode) { nextNode = existingNode } else { const next = createSegmentNode( node, kind, actuallyCaseSensitive, prefix, suffix, ) nextNode = next siblings.push(next) if (siblings.length === 2) { dynamicListsToSort?.push(siblings) } } } node = nextNode } if (interpolation && literalStart < literalEnd) { interpolation.push(path.substring(literalStart, literalEnd)) } // Discard push()'s spare capacity before retaining this array on both owners. const segmentData = interpolation?.slice() if (!node) { return segmentData! } // create pathless node if ( parseParams && route.children && !route.isRoot && route.id && route.id.charCodeAt(route.id.lastIndexOf('/') + 1) === 95 /* '_' */ ) { const pathlessNode = createSegmentNode(node, SEGMENT_TYPE_PATHLESS) ;(node.pathless ??= []).push(pathlessNode) node = pathlessNode } const isLeaf = (route.path || !route.children) && !route.isRoot // create index node if (isLeaf && literalEnd < length) { const indexNode = createSegmentNode(node, SEGMENT_TYPE_INDEX) node.index = indexNode node = indexNode } node.parse = parseParams ?? null node.priority = options?.params?.priority ?? 0 // A shared candidate keeps the original names of its first terminal route. if (!node.route) { node.data = segmentData if (isLeaf) { node.route = route } } return [node, cursor, segmentData] } function sortDynamic( a: { prefix: string suffix: string caseSensitive: boolean parse: null | ((params: Record<string, string>) => unknown) priority: number }, b: { prefix: string suffix: string caseSensitive: boolean parse: null | ((params: Record<string, string>) => unknown) priority: number }, ) { if (a.parse && !b.parse) return -1 if (!a.parse && b.parse) return 1 if (a.parse && b.parse && (a.priority || b.priority)) return b.priority - a.priority if (a.prefix && b.prefix && a.prefix !== b.prefix) { if (a.prefix.startsWith(b.prefix)) return -1 if (b.prefix.startsWith(a.prefix)) return 1 } if (a.suffix && b.suffix && a.suffix !== b.suffix) { if (a.suffix.endsWith(b.suffix)) return -1 if (b.suffix.endsWith(a.suffix)) return 1 } if (a.prefix && !b.prefix) return -1 if (!a.prefix && b.prefix) return 1 if (a.suffix && !b.suffix) return -1 if (!a.suffix && b.suffix) return 1 if (a.caseSensitive && !b.caseSensitive) return -1 if (!a.caseSensitive && b.caseSensitive) return 1 // Equal specificity preserves route declaration order through stable sort. return 0 } function createSegmentNode<T extends RouteLike>( parent?: AnySegmentNode<T>, kind?: StaticSegmentNode<T>['kind'], ): StaticSegmentNode<T> function createSegmentNode<T extends RouteLike>( parent: AnySegmentNode<T>, kind: | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM, caseSensitive: boolean, prefix: string, suffix: string, ): DynamicSegmentNode<T> function createSegmentNode<T extends RouteLike>( parent?: AnySegmentNode<T>, kind: ExtendedSegmentKind = SEGMENT_TYPE_PATHNAME, caseSensitive?: boolean, prefix?: string, suffix?: string, ): SegmentNode<T> { return { kind, depth: parent ? parent.depth + 1 : 0, pathless: null, index: null, static: null, staticInsensitive: null, dynamic: null, optional: null, wildcard: null, route: null, data: undefined, parent, parse: null, priority: 0, caseSensitive, prefix, suffix, } } type StaticSegmentNode<T extends RouteLike> = SegmentNode<T> & { kind: | typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PATHLESS | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode<T extends RouteLike> = SegmentNode<T> & { kind: | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM prefix: string suffix: string caseSensitive: boolean } type AnySegmentNode<T extends RouteLike> = | StaticSegmentNode<T> | DynamicSegmentNode<T> type SegmentNode<T extends RouteLike> = { kind: ExtendedSegmentKind prefix?: string suffix?: string caseSensitive?: boolean pathless: Array<StaticSegmentNode<T>> | null /** Exact index segment (highest priority) */ index: StaticSegmentNode<T> | null /** Static segments (2nd priority) */ static: Map<string, StaticSegmentNode<T>> | null /** Case insensitive static segments (3rd highest priority) */ staticInsensitive: Map<string, StaticSegmentNode<T>> | null /** Dynamic segments ($param) */ dynamic: Array<DynamicSegmentNode<T>> | null /** Optional dynamic segments ({-$param}) */ optional: Array<DynamicSegmentNode<T>> | null /** Wildcard segments ($ - lowest priority) */ wildcard: Array<DynamicSegmentNode<T>> | null /** Terminal route (if this path can end here) */ route: T | null /** Original template data for this candidate or parse gate. */ data: RouteInterpolation | undefined parent: AnySegmentNode<T> | undefined depth: number /** route.options.params.parse function, set on the last node of the route */ parse: null | ((params: Record<string, string>) => unknown) /** route.options.params.priority ?? 0 */ priority: number } type RouteLike = { _interpolation?: RouteInterpolation id?: string path?: string // relative path from the parent, children?: Array<RouteLike> // child routes, parentRoute?: RouteLike // parent route, isRoot?: boolean options?: { caseSensitive?: boolean parseParams?: (params: Record<string, string>) => unknown params?: { parse?: (params: Record<string, string>) => unknown priority?: number } } } & // router tree (| { fullPath: string; from?: never } // full path from the root // flat route masks list | { fullPath?: never; from: string } // full path from the root ) export type ProcessedTree< TTree extends Extract<RouteLike, { fullPath: string }>, TFlat extends Extract<RouteLike, { from: string }>, TSingle extends Extract<RouteLike, { from: string }>, > = { /** a representation of the `routeTree` as a segment tree */ segmentTree: AnySegmentNode<TTree> /** a mini route tree generated from the flat `routeMasks` list */ masksTree: AnySegmentNode<TFlat> | null /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ singleCache: SieveCache<string, AnySegmentNode<TSingle>> /** a cache of route matches from the `segmentTree` */ matchCache: SieveCache<string, RouteMatch<TTree> | null> /** a cache of route matches from the `masksTree` */ flatCache: SieveCache<string, ReturnType<typeof findMatch<TFlat>>> | null } export function processRouteMasks< TRouteLike extends Extract<RouteLike, { from: string }>, >( routeList: Array<TRouteLike>, processedTree: ProcessedTree<any, TRouteLike, any>, ) { const segmentTree = createSegmentNode<TRouteLike>() const dynamicListsToSort: Array<Array<DynamicSegmentNode<TRouteLike>>> = [] function visit( route: TRouteLike, start: number, parentNode: AnySegmentNode<TRouteLike>, parentInterpolation?: RouteInterpolation, ) { const [node, cursor, segments] = parseSegments( false, route, start, parentNode, dynamicListsToSort, parentInterpolation, ) if (route.children) { for (const child of route.children) { visit(child as TRouteLike, cursor, node, segments) } } } for (const route of routeList) { visit(route, 1, segmentTree) } for (const nodes of dynamicListsToSort) { nodes.sort(sortDynamic) } processedTree.masksTree = segmentTree processedTree.flatCache = createSieveCache< string, ReturnType<typeof findMatch<TRouteLike>> >(1000) } /** * Take an arbitrary list of routes, create a tree from them (if it hasn't been created already), and match a path against it. */ export function findFlatMatch<T extends Extract<RouteLike, { from: string }>>( /** The path to match. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ processedTree: ProcessedTree<any, T, any>, ) { path ||= '/' const cached = processedTree.flatCache!.get(path) if (cached !== undefined) return cached const result = findMatch(path, processedTree.masksTree!) processedTree.flatCache!.set(path, result) return result } /** * @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ export function findSingleMatch( from: string, caseSensitive: boolean, fuzzy: boolean, path: string, processedTree: ProcessedTree<any, any, { from: string }>, ) { from ||= '/' path ||= '/' const key = caseSensitive ? `case\0${from}` : from let tree = processedTree.singleCache.get(key) if (!tree) { // single flat routes (router.matchRoute) are not eagerly processed, // if we haven't seen this route before, process it now tree = createSegmentNode<{ from: string }>() parseSegments(caseSensitive, { from }, 1, tree) processedTree.singleCache.set(key, tree) } return findMatch(path, tree, fuzzy) } type RouteMatch<T extends Extract<RouteLike, { fullPath: string }>> = { route: T rawParams: Record<string, string> branch: ReadonlyArray<T> } export function findRouteMatch< T extends Extract<RouteLike, { fullPath: string }>, >( /** The path to match against the route tree. */ path: string, /** The `processedTree` returned by the initial `processRouteTree` call. */ processedTree: ProcessedTree<T, any, any>, /** If `true`, allows fuzzy matching (partial matches), i.e. which node in the tree would have been an exact match if the `path` had been shorter? */ fuzzy = false, ): RouteMatch<T> | null { const key = fuzzy ? path : `nofuzz\0${path}` // the main use for `findRouteMatch` is fuzzy:true, so we optimize for that case const cached = processedTree.matchCache.get(key) if (cached !== undefined) return cached path ||= '/' let result: RouteMatch<T> | null try { result = findMatch( path, processedTree.segmentTree, fuzzy, ) as RouteMatch<T> | null } catch (err) { if (err instanceof URIError) { result = null } else { throw err } } if (result) result.branch = buildRouteBranch(result.route) processedTree.matchCache.set(key, result) return result } export interface ProcessRouteTreeResult< TRouteLike extends Extract<RouteLike, { fullPath: string }> & { id: string }, > { /** Should be considered a black box, needs to be provided to all matching functions in this module. */ processedTree: ProcessedTree<TRouteLike, any, any> /** A lookup map of routes by their unique IDs. */ routesById: Record<string, TRouteLike> /** A lookup map of routes by their trimmed full paths. */ routesByPath: Record<string, TRouteLike> } /** * Processes a route tree into a segment trie for efficient path matching. * Also builds lookup maps for routes by ID and by trimmed full path. */ export function processRouteTree< TRouteLike extends Extract<RouteLike, { fullPath: string }> & { id: string init: (originalIndex: number) => void }, >( /** The root of the route tree to process. */ routeTree: TRouteLike, /** Whether matching should be case sensitive by default (overridden by individual route options). */ caseSensitive: boolean = false, ): ProcessRouteTreeResult<TRouteLike> { const segmentTree = createSegmentNode<TRouteLike>() const dynamicListsToSort: Array<Array<DynamicSegmentNode<TRouteLike>>> = [] const routesById = {} as Record<string, TRouteLike> const routesByPath = {} as Record<string, TRouteLike> let index = 0 function visit( route: TRouteLike, start: number, parentNode: AnySegmentNode<TRouteLike>, parentInterpolation?: RouteInterpolation, ) { route.init(index) if (route.id in routesById) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Duplicate routes found with id: ${String(route.id)}`, ) } invariant() } routesById[route.id] = route if (index !== 0 && route.path) { const trimmedFullPath = trimPathRight(route.fullPath) if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { routesByPath[trimmedFullPath] = route } } index++ const [node, cursor, segments] = parseSegments( caseSensitive, route, start, parentNode, dynamicListsToSort, parentInterpolation, ) route._interpolation = segments if (route.children) { for (const child of route.children) { visit(child as TRouteLike, cursor, node, segments) } } } visit(routeTree, 1, segmentTree) for (const nodes of dynamicListsToSort) { nodes.sort(sortDynamic) } const processedTree: ProcessedTree<TRouteLike, any, any> = { segmentTree, singleCache: createSieveCache<string, AnySegmentNode<any>>(1000), matchCache: createSieveCache<string, RouteMatch<TRouteLike> | null>(1000), flatCache: null, masksTree: null, } return { processedTree, routesById, routesByPath, } } function findMatch<T extends RouteLike>( path: string, segmentTree: AnySegmentNode<T>, fuzzy = false, ): { route: T /** * The raw (unparsed) params extracted from the path. * This will be the exhaustive list of all params defined in the route's path. */ rawParams: Record<string, string> } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) return null const [rawParams] = extractParams(path, parts, leaf) return { route: leaf.node.route!, rawParams, } } type ParamExtractionState = { part: number node: number path: number param: number } /** * This function is "resumable": * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call * - the returned `state` can be passed back as `extract` in a future call to continue extracting params from where we left off * * Inputs are *not* mutated. */ function extractParams<T extends RouteLike>( path: string, parts: Array<string>, leaf: { node: AnySegmentNode<T> skipped: number extract?: ParamExtractionState rawParams?: Record<string, string> }, ): [rawParams: Record<string, string>, state: ParamExtractionState] { const list = buildBranch(leaf.node) const names = leaf.node.data && getParamNames(leaf.node.data) const rawParams: Record<string, string> = Object.create(null) /** which segment of the path we're currently processing */ let partIndex = leaf.extract?.part ?? 0 /** which node of the route tree branch we're currently processing */ let nodeIndex = leaf.extract?.node ?? 0 /** index of the 1st character of the segment we're processing in the path string */ let pathIndex = leaf.extract?.path ?? 0 /** Next original parameter name, independent of pathless/static nodes. */ let paramIndex = leaf.extract?.param ?? 0 for (; nodeIndex < list.length; partIndex++, nodeIndex++, pathIndex++) { const node = list[nodeIndex]! // index nodes are terminating nodes, nothing to extract, just leave if (node.kind === SEGMENT_TYPE_INDEX) break // pathless nodes do not consume a path segment if (node.kind === SEGMENT_TYPE_PATHLESS) { partIndex-- pathIndex-- continue } const part = parts[partIndex] const currentPathIndex = pathIndex if (part) pathIndex += part.length if ( node.kind === SEGMENT_TYPE_PARAM || node.kind === SEGMENT_TYPE_OPTIONAL_PARAM ) { const name = names![paramIndex++]! if ( node.kind === SEGMENT_TYPE_OPTIONAL_PARAM && leaf.skipped & (1 << nodeIndex) ) { partIndex-- // stay on the same part pathIndex = currentPathIndex - 1 // undo pathIndex advancement; -1 to account for loop increment continue } const value = node.suffix || node.prefix ? part!.substring( node.prefix.length, part!.length - node.suffix.length, ) : part! if (value || node.kind === SEGMENT_TYPE_PARAM) { rawParams[name] = decodeURIComponent(value) } } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node const value = path.substring( currentPathIndex + n.prefix.length, path.length - n.suffix.length, ) const splat = decodeURIComponent(value) // TODO: Deprecate * rawParams['*'] = splat rawParams._splat = splat break } } if (leaf.rawParams) Object.assign(rawParams, leaf.rawParams) return [ rawParams, { part: partIndex, node: nodeIndex, path: pathIndex, param: paramIndex, }, ] } export function buildRouteBranch<T extends RouteLike>(route: T) { const list = [route] while (route.parentRoute) { route = route.parentRoute as T list.push(route) } list.reverse() return list } function buildBranch<T extends RouteLike>(node: AnySegmentNode<T>) { const list: Array<AnySegmentNode<T>> = Array(node.depth + 1) do { list[node.depth] = node node = node.parent! } while (node) return list } type MatchStackFrame<T extends RouteLike> = { node: AnySegmentNode<T> /** index of the segment of path */ index: number /** * Bitmask of skipped optional segments. * * This is a very performant way of storing an "array of booleans", but it means beyond 32 segments we can't track skipped optionals. * If we really really need to support more than 32 segments we can switch to using a `BigInt` here. It's about 2x slower in worst case scenarios. */ skipped: number /** Positional bitmasks tracking which consumed URL segments matched each segment kind. */ statics: number dynamics: number optionals: number /** intermediary state for param extraction */ extract?: ParamExtractionState /** intermediary params from param extraction */ rawParams?: Record<string, string> } function getNodeMatch<T extends RouteLike>( path: string, parts: Array<string>, segmentTree: AnySegmentNode<T>, fuzzy: boolean, ) { // quick check for root index // this is an optimization, algorithm should work correctly without this block if (path === '/' && segmentTree.index) return { node: segmentTree.index, skipped: 0 } as Pick< Frame, 'node' | 'skipped' > const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' const partsLength = parts.length - (trailingSlash ? 1 : 0) type Frame = MatchStackFrame<T> // use a stack to explore all possible paths (params cause branching) // iterate "backwards" (low priority first) so that we can push() each candidate, and pop() the highest priority candidate first // - pros: it is depth-first, so we find full matches faster // - cons: we cannot short-circuit, because highest priority matches are at the end of the loop (for loop with i--) (but we have no good short-circuiting anyway) // other possible approaches: // - shift instead of pop (measure performance difference), this allows iterating "forwards" (effectively breadth-first) // - never remove from the stack, keep a cursor instead. Then we can push "forwards" and avoid reversing the order of candidates (effectively breadth-first) const stack: Array<Frame> = [ { node: segmentTree, index: 1, skipped: 0, statics: 0, dynamics: 0, optionals: 0, }, ] let bestFuzzy: Frame | null = null let bestMatch: Frame | null = null while (stack.length) { const frame = stack.pop()! const { node, index, skipped, statics, dynamics, optionals } = frame let { extract, rawParams } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a // higher-priority wildcard later fails params.parse. If a better wildcard // has already validated and become bestMatch, lower-priority wildcard // fallbacks cannot win anymore and should not run params.parse. if ( node.kind === SEGMENT_TYPE_WILDCARD && node.route && !isFrameMoreSpecific(bestMatch, frame) ) { continue } if (node.parse) { const result = validateParseParams(path, parts, frame) if (!result) continue rawParams = frame.rawParams extract = frame.extract } // In fuzzy mode, track the best partial match we've found so far if ( fuzzy && node.route && node.kind !== SEGMENT_TYPE_INDEX && isFrameMoreSpecific(bestFuzzy, frame) ) { bestFuzzy = frame } const isBeyondPath = index === partsLength if (isBeyondPath) { if ( node.route && (!pathIsIndex || node.kind === SEGMENT_TYPE_INDEX || node.kind === SEGMENT_TYPE_WILDCARD) && isFrameMoreSpecific(bestMatch, frame) ) { bestMatch = frame } // beyond the length of the path parts, only some segment types can match if (!node.optional && !node.wildcard && !node.index && !node.pathless) continue } const part = isBeyondPath ? undefined : parts[index]! let lowerPart: string // 0. Try index match if (isBeyondPath && node.index) { const indexFrame = { node: node.index, index, skipped, statics, dynamics, optionals, extract, rawParams, } let indexValid = true if (node.index.parse) { const result = validateParseParams(path, parts, indexFrame) if (!result) indexValid = false } if (indexValid) { // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block if ( !dynamics && !optionals && !skipped && isPerfectStaticMatch(statics, partsLength) ) { return indexFrame } if (isFrameMoreSpecific(bestMatch, indexFrame)) { // index matches skip the stack because they cannot have children bestMatch = indexFrame } } } // 5. Try wildcard match if (node.wildcard) { for (let i = node.wildcard.length - 1; i >= 0; i--) { const segment = node.wildcard[i]! const { prefix, suffix } = segment if (prefix) { if (isBeyondPath) continue const casePart = segment.caseSensitive ? part : (lowerPart ??= part!.toLowerCase()) if (!casePart!.startsWith(prefix)) continue } if (suffix) { if (isBeyondPath) continue const end = parts.slice(index).join('/') const suffixPart = end.slice(-suffix.length) const casePart = segment.caseSensitive ? suffixPart : suffixPart.toLowerCase() if ( casePart !== suffix || end.length - suffix.length < prefix.length ) { continue } } // wildcard matches consume the rest of the URL and cannot have children stack.push({ node: segment, index: partsLength, skipped, statics, dynamics, optionals, extract, rawParams, }) } } // 4. Try optional match if (node.optional) { // A skipped optional is keyed by the child node's trie depth. const nextSkipped = skipped | (1 << (node.depth + 1)) for (let i = node.optional.length - 1; i >= 0; i--) { const segment = node.optional[i]! // when skipping, the node advances by 1, but the index doesn't stack.push({ node: segment, index, skipped: nextSkipped, statics, dynamics, optionals, extract, rawParams, }) // enqueue skipping the optional } if (!isBeyondPath) { for (let i = node.optional.length - 1; i >= 0; i--) { const segment = node.optional[i]! const { prefix, suffix } = segment if (prefix || suffix) { const casePart = segment.caseSensitive ? part! : (lowerPart ??= part!.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if ( suffix && casePart.indexOf(suffix, casePart.length - suffix.length) < prefix.length ) { continue } } stack.push({ node: segment, index: index + 1, skipped, statics, dynamics, optionals: optionals + segmentScore(partsLength, index), extract, rawParams, }) } } } // 3. Try dynamic match if (!isBeyondPath && node.dynamic && part) { for (let i = node.dynamic.length - 1; i >= 0; i--) { const segment = node.dynamic[i]! const { prefix, suffix } = segment if (prefix || suffix) { const casePart = segment.caseSensitive ? part : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if ( suffix && casePart.indexOf(suffix, casePart.length - suffix.length) < prefix.length ) { continue } } stack.push({ node: segment, index: index + 1, skipped, statics, dynamics: dynamics + segmentScore(partsLength, index), optionals, extract, rawParams, }) } } // 2. Try case insensitive static match if (!isBeyondPath && node.staticInsensitive) { const match = node.staticInsensitive.get( (lowerPart ??= part!.toLowerCase()), ) if (match) { stack.push({ node: match, index: index + 1, skipped, statics: statics + segmentScore(partsLength, index), dynamics, optionals, extract, rawParams, }) } } // 1. Try static match if (!isBeyondPath && node.static) { const match = node.static.get(part!) if (match) { stack.push({ node: match, index: index + 1, skipped, statics: statics + segmentScore(partsLength, index), dynamics, optionals, extract, rawParams, }) } } // 0. Try pathless match if (node.pathless) { for (let i = node.pathless.length - 1; i >= 0; i--) { const segment = node.pathless[i]! stack.push({ node: segment, index, skipped, statics, dynamics, optionals, extract, rawParams, }) } } } if (bestMatch) return bestMatch if (fuzzy && bestFuzzy) { let sliceIndex = bestFuzzy.index for (let i = 0; i < bestFuzzy.index; i++) { sliceIndex += parts[i]!.length } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) bestFuzzy.rawParams ??= Object.create(null) bestFuzzy.rawParams!['**'] = decodeURIComponent(splat) return bestFuzzy } return null } function segmentScore(partsLength: number, index: number): number { // The specificity scores are bitmasks over consumed URL segments. Earlier // URL segments should dominate later ones when comparing scores, so the // first real segment gets the highest bit and the last gets bit 0. Since // `parts[0]` is the empty string before the leading slash, real URL segments // are [1, partsLength), making this segment's bit `partsLength - index - 1`. return 2 ** (partsLength - index - 1) } function isPerfectStaticMatch(statics: number, partsLength: number): boolean { return statics === 2 ** (partsLength - 1) - 1 } function validateParseParams<T extends RouteLike>( path: string, parts: Array<string>, frame: MatchStackFrame<T>, ) { let rawParams: Record<string, string> let state: ParamExtractionState try { ;[rawParams, state] = extractParams(path, parts, frame) } catch { return null } frame.rawParams = rawParams frame.extract = state if (!frame.node.parse) return true try { if (frame.node.parse(rawParams) === false) return null } catch { // Thrown parse errors should be surfaced on the selected match by // extractStrictParams, not used as fallback route selection. } return true } function isFrameMoreSpecific( // the stack frame previously saved as "best match" prev: MatchStackFrame<any> | null, // the candidate stack frame next: MatchStackFrame<any>, ): boolean { if (!prev) return true return ( next.statics > prev.statics || (next.statics === prev.statics && (next.dynamics > prev.dynamics || (next.dynamics === prev.dynamics && (next.optionals > prev.optionals || (next.optionals === prev.optionals && ((next.node.kind === SEGMENT_TYPE_INDEX) > (prev.node.kind === SEGMENT_TYPE_INDEX) || ((next.node.kind === SEGMENT_TYPE_INDEX) === (prev.node.kind === SEGMENT_TYPE_INDEX) && next.node.depth > prev.node.depth))))))) ) }