diff --git a/docs/router/framework/react/api/router/MatchRouteOptionsType.md b/docs/router/framework/react/api/router/MatchRouteOptionsType.md index 579c8d427c6..3ec757d4202 100644 --- a/docs/router/framework/react/api/router/MatchRouteOptionsType.md +++ b/docs/router/framework/react/api/router/MatchRouteOptionsType.md @@ -8,7 +8,7 @@ The `MatchRouteOptions` type is used to describe the options that can be used wh ```tsx interface MatchRouteOptions { pending?: boolean - caseSensitive?: boolean + caseSensitive?: boolean /* @deprecated */ includeSearch?: boolean fuzzy?: boolean } @@ -24,11 +24,12 @@ The `MatchRouteOptions` type has the following properties: - Optional - If `true`, will match against pending location instead of the current location -### `caseSensitive` property +### ~~`caseSensitive`~~ property (deprecated) - Type: `boolean` - Optional - If `true`, will match against the current location with case sensitivity +- Declare case sensitivity in the route definition instead, or globally for all routes using the `caseSensitive` option on the router ### `includeSearch` property diff --git a/docs/router/framework/react/how-to/debug-router-issues.md b/docs/router/framework/react/how-to/debug-router-issues.md index 63b3da61264..2d698121a65 100644 --- a/docs/router/framework/react/how-to/debug-router-issues.md +++ b/docs/router/framework/react/how-to/debug-router-issues.md @@ -109,7 +109,7 @@ const route = createRoute({ ```tsx // Debug route tree in console console.log('Route tree:', router.routeTree) -console.log('All routes:', router.flatRoutes) +console.log('All routes:', router.routesById) ``` 3. **Check Parent Route Configuration** diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 29d293fe986..9779e391b38 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -8,10 +8,7 @@ export { trimPathRight, trimPath, resolvePath, - parsePathname, interpolatePath, - matchPathname, - matchByPath, rootRouteId, defaultSerializeError, defaultParseSearch, @@ -37,7 +34,6 @@ export type { RemoveTrailingSlashes, RemoveLeadingSlashes, ActiveOptions, - Segment, ResolveRelativePath, RootRouteId, AnyPathParams, diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx index 735277d1000..6ac7ba3dcf5 100644 --- a/packages/react-router/src/useBlocker.tsx +++ b/packages/react-router/src/useBlocker.tsx @@ -177,10 +177,7 @@ export function useBlocker( location: HistoryLocation, ): AnyShouldBlockFnLocation { const parsedLocation = router.parseLocation(location) - const matchedRoutes = router.getMatchedRoutes( - parsedLocation.pathname, - undefined, - ) + const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname) if (matchedRoutes.foundRoute === undefined) { throw new Error(`No route found for location ${location.href}`) } diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index d2ee9c4d717..dd3ca3dfa6c 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -271,7 +271,7 @@ describe('matching on different param types', () => { path: '/optional-{-$id}', nav: '/optional-', params: {}, - matchParams: { id: '' }, + matchParams: {}, }, { name: 'optional param with suffix and value', @@ -285,14 +285,14 @@ describe('matching on different param types', () => { path: '/{-$id}-optional', nav: '/-optional', params: {}, - matchParams: { id: '' }, + matchParams: {}, }, { name: 'optional param with required param, prefix, suffix, wildcard and no value', path: `/$foo/a{-$id}-optional/$`, nav: '/bar/a-optional/qux', - params: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, - matchParams: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, + params: { foo: 'bar', _splat: 'qux', '*': 'qux' }, + matchParams: { foo: 'bar', _splat: 'qux', '*': 'qux' }, }, { name: 'optional param with required param, prefix, suffix, wildcard and value', diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index cd566e804ff..de0826020d3 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1697,18 +1697,6 @@ describe('route ids should be consistent after rebuilding the route tree', () => }) describe('route id uniqueness', () => { - it('flatRoute should not have routes with duplicated route ids', () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - const routeIdSet = new Set() - - router.flatRoutes.forEach((route) => { - expect(routeIdSet.has(route.id)).toBe(false) - routeIdSet.add(route.id) - }) - }) - it('routesById should not have routes duplicated route ids', () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 3b39b8eee7b..852d186b67d 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -272,6 +272,8 @@ export interface MatchRouteOptions { * If `true`, will match against the current location with case sensitivity. * * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/MatchRouteOptionsType#casesensitive-property) + * + * @deprecated Declare case sensitivity in the route definition instead, or globally for all routes using the `caseSensitive` option on the router. */ caseSensitive?: boolean /** diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index b86a1ed67f5..73019ccbb65 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -100,12 +100,8 @@ export { removeTrailingSlash, exactPathTest, resolvePath, - parsePathname, interpolatePath, - matchPathname, - matchByPath, } from './path' -export type { Segment } from './path' export { encode, decode } from './qss' export { rootRouteId } from './root' export type { RootRouteId } from './root' @@ -193,8 +189,6 @@ export type { RootRoute, FilebaseRouteOptionsInterface, } from './route' -export { processRouteTree } from './process-route-tree' -export type { ProcessRouteTreeResult } from './process-route-tree' export { defaultSerializeError, getLocationChangeInfo, diff --git a/packages/router-core/src/lru-cache.ts b/packages/router-core/src/lru-cache.ts index 12b9fbcd8eb..ee3ec7d7a03 100644 --- a/packages/router-core/src/lru-cache.ts +++ b/packages/router-core/src/lru-cache.ts @@ -1,6 +1,7 @@ export type LRUCache = { get: (key: TKey) => TValue | undefined set: (key: TKey, value: TValue) => void + clear: () => void } export function createLRUCache( @@ -64,5 +65,10 @@ export function createLRUCache( cache.set(key, entry) } }, + clear() { + cache.clear() + oldest = undefined + newest = undefined + }, } } diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts new file mode 100644 index 00000000000..e10be41c84d --- /dev/null +++ b/packages/router-core/src/new-process-route-tree.ts @@ -0,0 +1,1036 @@ +import invariant from 'tiny-invariant' +import { createLRUCache } from './lru-cache' +import { last } from './utils' +import type { LRUCache } from './lru-cache' + +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 + +export type SegmentKind = + | typeof SEGMENT_TYPE_PATHNAME + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM + +const PARAM_W_CURLY_BRACES_RE = + /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix +const OPTIONAL_PARAM_W_CURLY_BRACES_RE = + /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{-$paramName}suffix +const WILDCARD_W_CURLY_BRACES_RE = /^([^{]*)\{\$\}([^}]*)$/ // prefix{$}suffix + +type ParsedSegment = Uint16Array & { + /** segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) */ + 0: SegmentKind + /** index of the end of the prefix */ + 1: number + /** index of the start of the value */ + 2: number + /** index of the end of the value */ + 3: number + /** index of the start of the suffix */ + 4: number + /** index of the end of the segment */ + 5: number +} + +/** + * Populates the `output` array with the parsed representation of the given `segment` string. + * + * Usage: + * ```ts + * let output + * let cursor = 0 + * while (cursor < path.length) { + * output = parseSegment(path, cursor, output) + * const end = output[5] + * cursor = end + 1 + * ``` + * + * `output` is stored outside to avoid allocations during repeated calls. It doesn't need to be typed + * or initialized, it will be done automatically. + */ +export function parseSegment( + /** The full path string containing the segment. */ + path: string, + /** The starting index of the segment within the path. */ + start: number, + /** A Uint16Array (length: 6) to populate with the parsed segment data. */ + output: Uint16Array = new Uint16Array(6), +): ParsedSegment { + const next = path.indexOf('/', start) + const end = next === -1 ? path.length : next + const part = path.substring(start, end) + + if (!part || !part.includes('$')) { + // early escape for static pathname + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return output as ParsedSegment + } + + // $ (wildcard) + if (part === '$') { + const total = path.length + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + output[2] = start + output[3] = total + output[4] = total + output[5] = total + return output as ParsedSegment + } + + // $paramName + if (part.charCodeAt(0) === 36) { + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + output[2] = start + 1 // skip '$' + output[3] = end + output[4] = end + output[5] = end + return output as ParsedSegment + } + + const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE) + if (wildcardBracesMatch) { + const prefix = wildcardBracesMatch[1]! + const pLength = prefix.length + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = start + pLength + output[2] = start + pLength + 1 // skip '{' + output[3] = start + pLength + 2 // '$' + output[4] = start + pLength + 3 // skip '}' + output[5] = path.length + return output as ParsedSegment + } + + const optionalParamBracesMatch = part.match(OPTIONAL_PARAM_W_CURLY_BRACES_RE) + if (optionalParamBracesMatch) { + const prefix = optionalParamBracesMatch[1]! + const paramName = optionalParamBracesMatch[2]! + const suffix = optionalParamBracesMatch[3]! + const pLength = prefix.length + output[0] = SEGMENT_TYPE_OPTIONAL_PARAM + output[1] = start + pLength + output[2] = start + pLength + 3 // skip '{-$' + output[3] = start + pLength + 3 + paramName.length + output[4] = end - suffix.length + output[5] = end + return output as ParsedSegment + } + + const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE) + if (paramBracesMatch) { + const prefix = paramBracesMatch[1]! + const paramName = paramBracesMatch[2]! + const suffix = paramBracesMatch[3]! + const pLength = prefix.length + output[0] = SEGMENT_TYPE_PARAM + output[1] = start + pLength + output[2] = start + pLength + 2 // skip '{$' + output[3] = start + pLength + 2 + paramName.length + output[4] = end - suffix.length + output[5] = end + return output as ParsedSegment + } + + // fallback to static pathname (should never happen) + output[0] = SEGMENT_TYPE_PATHNAME + output[1] = start + output[2] = start + output[3] = end + output[4] = end + output[5] = end + return output as ParsedSegment +} + +/** + * Recursively parses the segments of the given route tree and populates a segment trie. + * + * @param data A reusable Uint16Array for parsing segments. (non important, we're just avoiding allocations) + * @param route The current route to parse. + * @param start The starting index for parsing within the route's full path. + * @param node The current segment node in the trie to populate. + * @param onRoute Callback invoked for each route processed. + */ +function parseSegments( + defaultCaseSensitive: boolean, + data: Uint16Array, + route: TRouteLike, + start: number, + node: AnySegmentNode, + depth: number, + onRoute?: (route: TRouteLike) => void, +) { + onRoute?.(route) + let cursor = start + { + const path = route.fullPath ?? route.from + const length = path.length + const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive + while (cursor < length) { + const segment = parseSegment(path, cursor, data) + let nextNode: AnySegmentNode + const start = cursor + const end = segment[5] + cursor = end + 1 + depth++ + const kind = segment[0] + switch (kind) { + case SEGMENT_TYPE_PATHNAME: { + const value = path.substring(segment[2], segment[3]) + if (caseSensitive) { + const existingNode = node.static?.get(value) + if (existingNode) { + nextNode = existingNode + } else { + node.static ??= new Map() + const next = createStaticNode( + route.fullPath ?? route.from, + ) + next.parent = node + next.depth = depth + nextNode = next + node.static.set(value, next) + } + } else { + const name = value.toLowerCase() + const existingNode = node.staticInsensitive?.get(name) + if (existingNode) { + nextNode = existingNode + } else { + node.staticInsensitive ??= new Map() + const next = createStaticNode( + route.fullPath ?? route.from, + ) + next.parent = node + next.depth = depth + nextNode = next + node.staticInsensitive.set(name, next) + } + } + break + } + case SEGMENT_TYPE_PARAM: { + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) + const prefix = !prefix_raw + ? undefined + : actuallyCaseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : actuallyCaseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const existingNode = node.dynamic?.find( + (s) => + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) + if (existingNode) { + nextNode = existingNode + } else { + const next = createDynamicNode( + SEGMENT_TYPE_PARAM, + route.fullPath ?? route.from, + actuallyCaseSensitive, + prefix, + suffix, + ) + nextNode = next + next.depth = depth + next.parent = node + node.dynamic ??= [] + node.dynamic.push(next) + } + break + } + case SEGMENT_TYPE_OPTIONAL_PARAM: { + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) + const prefix = !prefix_raw + ? undefined + : actuallyCaseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : actuallyCaseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const existingNode = node.optional?.find( + (s) => + s.caseSensitive === actuallyCaseSensitive && + s.prefix === prefix && + s.suffix === suffix, + ) + if (existingNode) { + nextNode = existingNode + } else { + const next = createDynamicNode( + SEGMENT_TYPE_OPTIONAL_PARAM, + route.fullPath ?? route.from, + actuallyCaseSensitive, + prefix, + suffix, + ) + nextNode = next + next.parent = node + next.depth = depth + node.optional ??= [] + node.optional.push(next) + } + break + } + case SEGMENT_TYPE_WILDCARD: { + const prefix_raw = path.substring(start, segment[1]) + const suffix_raw = path.substring(segment[4], end) + const actuallyCaseSensitive = + caseSensitive && !!(prefix_raw || suffix_raw) + const prefix = !prefix_raw + ? undefined + : actuallyCaseSensitive + ? prefix_raw + : prefix_raw.toLowerCase() + const suffix = !suffix_raw + ? undefined + : actuallyCaseSensitive + ? suffix_raw + : suffix_raw.toLowerCase() + const next = createDynamicNode( + SEGMENT_TYPE_WILDCARD, + route.fullPath ?? route.from, + actuallyCaseSensitive, + prefix, + suffix, + ) + nextNode = next + next.parent = node + next.depth = depth + node.wildcard ??= [] + node.wildcard.push(next) + } + } + node = nextNode + } + if ((route.path || !route.children) && !route.isRoot) { + const isIndex = path.endsWith('/') + // we cannot fuzzy match an index route, + // but if there is *also* a layout route at this path, save it as notFound + // we can use it when fuzzy matching to display the NotFound component in the layout route + if (!isIndex) node.notFound = route + if (!node.route || (!node.isIndex && isIndex)) node.route = route + node.isIndex ||= isIndex + } + } + if (route.children) + for (const child of route.children) { + parseSegments( + defaultCaseSensitive, + data, + child as TRouteLike, + cursor, + node, + depth, + onRoute, + ) + } +} + +function sortDynamic( + a: { prefix?: string; suffix?: string; caseSensitive: boolean }, + b: { prefix?: string; suffix?: string; caseSensitive: boolean }, +) { + 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 + + // we don't need a tiebreaker here + // at this point the 2 nodes cannot conflict during matching + return 0 +} + +function sortTreeNodes(node: SegmentNode) { + if (node.static) { + for (const child of node.static.values()) { + sortTreeNodes(child) + } + } + if (node.staticInsensitive) { + for (const child of node.staticInsensitive.values()) { + sortTreeNodes(child) + } + } + if (node.dynamic?.length) { + node.dynamic.sort(sortDynamic) + for (const child of node.dynamic) { + sortTreeNodes(child) + } + } + if (node.optional?.length) { + node.optional.sort(sortDynamic) + for (const child of node.optional) { + sortTreeNodes(child) + } + } + if (node.wildcard?.length) { + node.wildcard.sort(sortDynamic) + for (const child of node.wildcard) { + sortTreeNodes(child) + } + } +} + +function createStaticNode( + fullPath: string, +): StaticSegmentNode { + return { + kind: SEGMENT_TYPE_PATHNAME, + depth: 0, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath, + parent: null, + isIndex: false, + notFound: null, + } +} + +/** + * Keys must be declared in the same order as in `SegmentNode` type, + * to ensure they are represented as the same object class in the engine. + */ +function createDynamicNode( + kind: + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM, + fullPath: string, + caseSensitive: boolean, + prefix?: string, + suffix?: string, +): DynamicSegmentNode { + return { + kind, + depth: 0, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath, + parent: null, + isIndex: false, + notFound: null, + caseSensitive, + prefix, + suffix, + } +} + +type StaticSegmentNode = SegmentNode & { + kind: typeof SEGMENT_TYPE_PATHNAME +} + +type DynamicSegmentNode = SegmentNode & { + kind: + | typeof SEGMENT_TYPE_PARAM + | typeof SEGMENT_TYPE_WILDCARD + | typeof SEGMENT_TYPE_OPTIONAL_PARAM + prefix?: string + suffix?: string + caseSensitive: boolean +} + +type AnySegmentNode = + | StaticSegmentNode + | DynamicSegmentNode + +type SegmentNode = { + kind: SegmentKind + + /** Static segments (highest priority) */ + static: Map> | null + + /** Case insensitive static segments (second highest priority) */ + staticInsensitive: Map> | null + + /** Dynamic segments ($param) */ + dynamic: Array> | null + + /** Optional dynamic segments ({-$param}) */ + optional: Array> | null + + /** Wildcard segments ($ - lowest priority) */ + wildcard: Array> | null + + /** Terminal route (if this path can end here) */ + route: T | null + + /** The full path for this segment node (will only be valid on leaf nodes) */ + fullPath: string + + parent: AnySegmentNode | null + + depth: number + + /** is it an index route (trailing / path), only valid for nodes with a `route` */ + isIndex: boolean + + /** Same as `route`, but only present if both an "index route" and a "layout route" exist at this path */ + notFound: T | null +} + +type RouteLike = { + path?: string // relative path from the parent, + children?: Array // child routes, + parentRoute?: RouteLike // parent route, + isRoot?: boolean + options?: { + caseSensitive?: boolean + } +} & + // 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, + TFlat extends Extract, + TSingle extends Extract, +> = { + /** a representation of the `routeTree` as a segment tree */ + segmentTree: AnySegmentNode + /** a mini route tree generated from the flat `routeMasks` list */ + masksTree: AnySegmentNode | null + /** @deprecated keep until v2 so that `router.matchRoute` can keep not caring about the actual route tree */ + singleCache: Map> + /** a cache of route matches from the `segmentTree` */ + matchCache: LRUCache>> + /** a cache of route matches from the `masksTree` */ + flatCache: LRUCache>> | null +} + +export function processRouteMasks< + TRouteLike extends Extract, +>( + routeList: Array, + processedTree: ProcessedTree, +) { + const segmentTree = createStaticNode('/') + const data = new Uint16Array(6) + for (const route of routeList) { + parseSegments(false, data, route, 1, segmentTree, 0) + } + sortTreeNodes(segmentTree) + processedTree.masksTree = segmentTree + processedTree.flatCache = createLRUCache< + string, + ReturnType> + >(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>( + /** The path to match. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, +) { + path ||= '/' + const cached = processedTree.flatCache!.get(path) + if (cached) 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, +) { + 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 = createStaticNode<{ from: string }>('/') + const data = new Uint16Array(6) + parseSegments(caseSensitive, data, { from }, 1, tree, 0) + processedTree.singleCache.set(key, tree) + } + return findMatch(path, tree, fuzzy) +} + +export function findRouteMatch< + T extends Extract, +>( + /** The path to match against the route tree. */ + path: string, + /** The `processedTree` returned by the initial `processRouteTree` call. */ + processedTree: ProcessedTree, + /** 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, +) { + const key = fuzzy ? `fuzzy\0${path}` : path + const cached = processedTree.matchCache.get(key) + if (cached) return cached + path ||= '/' + const result = findMatch(path, processedTree.segmentTree, fuzzy) + processedTree.matchCache.set(key, result) + return result +} + +/** Trim trailing slashes (except preserving root '/'). */ +export function trimPathRight(path: string) { + return path === '/' ? path : path.replace(/\/{1,}$/, '') +} + +/** + * 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 & { id: string }, +>( + /** 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, + /** Optional callback invoked for each route during processing. */ + initRoute?: (route: TRouteLike, index: number) => void, +): { + /** Should be considered a black box, needs to be provided to all matching functions in this module. */ + processedTree: ProcessedTree + /** A lookup map of routes by their unique IDs. */ + routesById: Record + /** A lookup map of routes by their trimmed full paths. */ + routesByPath: Record +} { + const segmentTree = createStaticNode(routeTree.fullPath) + const data = new Uint16Array(6) + const routesById = {} as Record + const routesByPath = {} as Record + let index = 0 + parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, (route) => { + initRoute?.(route, index) + + invariant( + !(route.id in routesById), + `Duplicate routes found with id: ${String(route.id)}`, + ) + + routesById[route.id] = route + + if (index !== 0 && route.path) { + const trimmedFullPath = trimPathRight(route.fullPath) + if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { + routesByPath[trimmedFullPath] = route + } + } + + index++ + }) + sortTreeNodes(segmentTree) + const processedTree: ProcessedTree = { + segmentTree, + singleCache: new Map(), + matchCache: createLRUCache< + string, + ReturnType> + >(1000), + flatCache: null, + masksTree: null, + } + return { + processedTree, + routesById, + routesByPath, + } +} + +function findMatch( + path: string, + segmentTree: AnySegmentNode, + fuzzy = false, +): { route: T; params: Record } | null { + const parts = path.split('/') + const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) + if (!leaf) return null + const params = extractParams(path, parts, leaf) + const isFuzzyMatch = '**' in leaf + if (isFuzzyMatch) params['**'] = leaf['**'] + const route = isFuzzyMatch + ? (leaf.node.notFound ?? leaf.node.route!) + : leaf.node.route! + return { + route, + params, + } +} + +function extractParams( + path: string, + parts: Array, + leaf: { node: AnySegmentNode; skipped: number }, +) { + const list = buildBranch(leaf.node) + let nodeParts: Array | null = null + const params: Record = {} + for ( + let partIndex = 0, nodeIndex = 0, pathIndex = 0; + nodeIndex < list.length; + partIndex++, nodeIndex++, pathIndex++ + ) { + const node = list[nodeIndex]! + const part = parts[partIndex] + const currentPathIndex = pathIndex + if (part) pathIndex += part.length + if (node.kind === SEGMENT_TYPE_PARAM) { + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[nodeIndex]! + const preLength = node.prefix?.length ?? 0 + // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix + const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' + // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node + if (isCurlyBraced) { + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 2, + nodePart.length - sufLength - 1, + ) + const value = part!.substring(preLength, part!.length - sufLength) + params[name] = decodeURIComponent(value) + } else { + const name = nodePart.substring(1) + params[name] = decodeURIComponent(part!) + } + } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + if (leaf.skipped & (1 << nodeIndex)) { + partIndex-- // stay on the same part + continue + } + nodeParts ??= leaf.node.fullPath.split('/') + const nodePart = nodeParts[nodeIndex]! + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 3, + nodePart.length - sufLength - 1, + ) + const value = + node.suffix || node.prefix + ? part!.substring(preLength, part!.length - sufLength) + : part + if (value) params[name] = decodeURIComponent(value) + } else if (node.kind === SEGMENT_TYPE_WILDCARD) { + const n = node + const value = path.substring( + currentPathIndex + (n.prefix?.length ?? 0), + path.length - (n.suffix?.length ?? 0), + ) + const splat = decodeURIComponent(value) + // TODO: Deprecate * + params['*'] = splat + params._splat = splat + break + } + } + return params +} + +function buildBranch(node: AnySegmentNode) { + const list: Array> = Array(node.depth + 1) + do { + list[node.depth] = node + node = node.parent! + } while (node) + return list +} + +type MatchStackFrame = { + node: AnySegmentNode + /** index of the segment of path */ + index: number + /** how many nodes between `node` and the root of the segment tree */ + depth: 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 + statics: number + dynamics: number + optionals: number +} + +function getNodeMatch( + path: string, + parts: Array, + segmentTree: AnySegmentNode, + fuzzy: boolean, +) { + const trailingSlash = !last(parts) + const pathIsIndex = trailingSlash && path !== '/' + const partsLength = parts.length - (trailingSlash ? 1 : 0) + + type Frame = MatchStackFrame + + // 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 = [ + { + node: segmentTree, + index: 1, + skipped: 0, + depth: 1, + statics: 1, + dynamics: 0, + optionals: 0, + }, + ] + + let wildcardMatch: Frame | null = null + let bestFuzzy: Frame | null = null + let bestMatch: Frame | null = null + + while (stack.length) { + const frame = stack.pop()! + // eslint-disable-next-line prefer-const + let { node, index, skipped, depth, statics, dynamics, optionals } = frame + + // In fuzzy mode, track the best partial match we've found so far + if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) { + bestFuzzy = frame + } + + const isBeyondPath = index === partsLength + if (isBeyondPath) { + if (node.route && (!pathIsIndex || node.isIndex)) { + if (isFrameMoreSpecific(bestMatch, frame)) { + bestMatch = frame + } + + // perfect match, no need to continue + if (statics === partsLength) return bestMatch + } + // beyond the length of the path parts, only skipped optional segments or wildcard segments can match + if (!node.optional && !node.wildcard) continue + } + + const part = isBeyondPath ? undefined : parts[index]! + let lowerPart: string + + // 5. Try wildcard match + if (node.wildcard && isFrameMoreSpecific(wildcardMatch, frame)) { + for (const segment of node.wildcard) { + 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('/').slice(-suffix.length) + const casePart = segment.caseSensitive ? end : end.toLowerCase() + if (casePart !== suffix) continue + } + // the first wildcard match is the highest priority one + wildcardMatch = { + node: segment, + index, + skipped, + depth, + statics, + dynamics, + optionals, + } + break + } + } + + // 4. Try optional match + if (node.optional) { + const nextSkipped = skipped | (1 << depth) + const nextDepth = depth + 1 + for (let i = node.optional.length - 1; i >= 0; i--) { + const segment = node.optional[i]! + // when skipping, node and depth advance by 1, but index doesn't + stack.push({ + node: segment, + index, + skipped: nextSkipped, + depth: nextDepth, + statics, + dynamics, + optionals, + }) // 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.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: nextDepth, + statics, + dynamics, + optionals: optionals + 1, + }) + } + } + } + + // 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.endsWith(suffix)) continue + } + stack.push({ + node: segment, + index: index + 1, + skipped, + depth: depth + 1, + statics, + dynamics: dynamics + 1, + optionals, + }) + } + } + + // 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, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + }) + } + } + + // 1. Try static match + if (!isBeyondPath && node.static) { + const match = node.static.get(part!) + if (match) { + stack.push({ + node: match, + index: index + 1, + skipped, + depth: depth + 1, + statics: statics + 1, + dynamics, + optionals, + }) + } + } + } + + if (bestMatch) return bestMatch + + if (wildcardMatch) return wildcardMatch + + 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) + return { + node: bestFuzzy.node, + skipped: bestFuzzy.skipped, + '**': decodeURIComponent(splat), + } + } + + return null +} + +function isFrameMoreSpecific( + // the stack frame previously saved as "best match" + prev: MatchStackFrame | null, + // the candidate stack frame + next: MatchStackFrame, +): 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))) + ) +} diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 96e8ce2246b..88b489d8aa2 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,25 +1,12 @@ import { last } from './utils' +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + parseSegment, +} from './new-process-route-tree' import type { LRUCache } from './lru-cache' -import type { MatchLocation } from './RouterProvider' -import type { AnyPathParams } from './route' - -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 - -export interface Segment { - readonly type: - | typeof SEGMENT_TYPE_PATHNAME - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM - readonly value: string - readonly prefixSegment?: string - readonly suffixSegment?: string - // Indicates if there is a static segment after this required/optional param - readonly hasStaticAfter?: boolean -} /** Join path segments, cleaning duplicate slashes between parts. */ /** Join path segments, cleaning duplicate slashes between parts. */ @@ -119,52 +106,7 @@ interface ResolvePathOptions { base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' - parseCache?: ParsePathnameCache -} - -function segmentToString(segment: Segment): string { - const { type, value } = segment - if (type === SEGMENT_TYPE_PATHNAME) { - return value - } - - const { prefixSegment, suffixSegment } = segment - - if (type === SEGMENT_TYPE_PARAM) { - const param = value.substring(1) - if (prefixSegment && suffixSegment) { - return `${prefixSegment}{$${param}}${suffixSegment}` - } else if (prefixSegment) { - return `${prefixSegment}{$${param}}` - } else if (suffixSegment) { - return `{$${param}}${suffixSegment}` - } - } - - if (type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const param = value.substring(1) - if (prefixSegment && suffixSegment) { - return `${prefixSegment}{-$${param}}${suffixSegment}` - } else if (prefixSegment) { - return `${prefixSegment}{-$${param}}` - } else if (suffixSegment) { - return `{-$${param}}${suffixSegment}` - } - return `{-$${param}}` - } - - if (type === SEGMENT_TYPE_WILDCARD) { - if (prefixSegment && suffixSegment) { - return `${prefixSegment}{$}${suffixSegment}` - } else if (prefixSegment) { - return `${prefixSegment}{$}` - } else if (suffixSegment) { - return `{$}${suffixSegment}` - } - } - - // This case should never happen, should we throw instead? - return value + cache?: LRUCache } /** @@ -175,203 +117,92 @@ export function resolvePath({ base, to, trailingSlash = 'never', - parseCache, + cache, }: ResolvePathOptions) { - let baseSegments = parsePathname(base, parseCache).slice() - const toSegments = parsePathname(to, parseCache) - - if (baseSegments.length > 1 && last(baseSegments)?.value === '/') { - baseSegments.pop() + const isAbsolute = to.startsWith('/') + const isBase = !isAbsolute && to === '.' + + let key + if (cache) { + // `trailingSlash` is static per router, so it doesn't need to be part of the cache key + key = isAbsolute ? to : isBase ? base : base + '\0' + to + const cached = cache.get(key) + if (cached) return cached } - for (let index = 0, length = toSegments.length; index < length; index++) { - const toSegment = toSegments[index]! - const value = toSegment.value - if (value === '/') { - if (!index) { - // Leading slash - baseSegments = [toSegment] - } else if (index === length - 1) { - // Trailing Slash - baseSegments.push(toSegment) + let baseSegments: Array + if (isBase) { + baseSegments = base.split('/') + } else if (isAbsolute) { + baseSegments = to.split('/') + } else { + baseSegments = base.split('/') + while (baseSegments.length > 1 && last(baseSegments) === '') { + baseSegments.pop() + } + + const toSegments = to.split('/') + for (let index = 0, length = toSegments.length; index < length; index++) { + const value = toSegments[index]! + if (value === '') { + if (!index) { + // Leading slash + baseSegments = [value] + } else if (index === length - 1) { + // Trailing Slash + baseSegments.push(value) + } else { + // ignore inter-slashes + } + } else if (value === '..') { + baseSegments.pop() + } else if (value === '.') { + // ignore } else { - // ignore inter-slashes + baseSegments.push(value) } - } else if (value === '..') { - baseSegments.pop() - } else if (value === '.') { - // ignore - } else { - baseSegments.push(toSegment) } } if (baseSegments.length > 1) { - if (last(baseSegments)!.value === '/') { + if (last(baseSegments) === '') { if (trailingSlash === 'never') { baseSegments.pop() } } else if (trailingSlash === 'always') { - baseSegments.push({ type: SEGMENT_TYPE_PATHNAME, value: '/' }) + baseSegments.push('') } } - const segmentValues = baseSegments.map(segmentToString) - // const joined = joinPaths([basepath, ...segmentValues]) - const joined = joinPaths(segmentValues) - return joined -} - -export type ParsePathnameCache = LRUCache> - -/** - * Parse a pathname into an array of typed segments used by the router's - * matcher. Results are optionally cached via an LRU cache. - */ -/** - * Parse a pathname into an array of typed segments used by the router's - * matcher. Results are optionally cached via an LRU cache. - */ -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 -} - -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 = - /^(.*?)\{-(\$[a-zA-Z_$][a-zA-Z0-9_$]*)\}(.*)$/ // prefix{-$paramName}suffix - -const WILDCARD_RE = /^\$$/ // $ -const WILDCARD_W_CURLY_BRACES_RE = /^(.*?)\{\$\}(.*)$/ // prefix{$}suffix - -/** - * Required: `/foo/$bar` ✅ - * Prefix and Suffix: `/foo/prefix${bar}suffix` ✅ - * Wildcard: `/foo/$` ✅ - * Wildcard with Prefix and Suffix: `/foo/prefix{$}suffix` ✅ - * - * Optional param: `/foo/{-$bar}` - * Optional param with Prefix and Suffix: `/foo/prefix{-$bar}suffix` - - * Future: - * Optional named segment: `/foo/{bar}` - * Optional named segment with Prefix and Suffix: `/foo/prefix{-bar}suffix` - * Escape special characters: - * - `/foo/[$]` - Static route - * - `/foo/[$]{$foo} - Dynamic route with a static prefix of `$` - * - `/foo/{$foo}[$]` - Dynamic route with a static suffix of `$` - */ -function baseParsePathname(pathname: string): ReadonlyArray { - pathname = cleanPath(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, - } - } - - // 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 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 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 bare wildcard: $ (without curly braces) - if (WILDCARD_RE.test(part)) { - return { - type: SEGMENT_TYPE_WILDCARD, - value: '$', - prefixSegment: undefined, - suffixSegment: undefined, - } - } - - // Handle regular pathname segment - return { - type: SEGMENT_TYPE_PATHNAME, - value: part, - } - }), - ) - - if (pathname.slice(-1) === '/') { - pathname = pathname.substring(1) - segments.push({ - type: SEGMENT_TYPE_PATHNAME, - value: '/', - }) + let segment + let joined = '' + for (let i = 0; i < baseSegments.length; i++) { + if (i > 0) joined += '/' + const part = baseSegments[i]! + if (!part) continue + segment = parseSegment(part, 0, segment) + const kind = segment[0] + if (kind === SEGMENT_TYPE_PATHNAME) { + joined += part + continue + } + const end = segment[5] + const prefix = part.substring(0, segment[1]) + const suffix = part.substring(segment[4], end) + const value = part.substring(segment[2], segment[3]) + if (kind === SEGMENT_TYPE_PARAM) { + joined += prefix || suffix ? `${prefix}{$${value}}${suffix}` : `$${value}` + } else if (kind === SEGMENT_TYPE_WILDCARD) { + joined += prefix || suffix ? `${prefix}{$}${suffix}` : '$' + } else { + // SEGMENT_TYPE_OPTIONAL_PARAM + joined += `${prefix}{-$${value}}${suffix}` + } } - - return segments + joined = cleanPath(joined) + const result = joined || '/' + if (key && cache) cache.set(key, result) + return result } interface InterpolatePathOptions { @@ -379,7 +210,6 @@ interface InterpolatePathOptions { params: Record // Map of encoded chars to decoded chars (e.g. '%40' -> '@') that should remain decoded in path params decodeCharMap?: Map - parseCache?: ParsePathnameCache } type InterPolatePathResult = { @@ -387,6 +217,23 @@ type InterPolatePathResult = { usedParams: Record isMissingParams: boolean // true if any params were not available when being looked up in the params object } + +function encodeParam( + key: string, + params: InterpolatePathOptions['params'], + decodeCharMap: InterpolatePathOptions['decodeCharMap'], +): any { + const value = params[key] + if (typeof value !== 'string') return value + + if (key === '_splat') { + // the splat/catch-all routes shouldn't have the '/' encoded out + return encodeURI(value) + } else { + return encodePathParam(value, decodeCharMap) + } +} + /** * Interpolate params and wildcards into a route path template. * @@ -401,95 +248,103 @@ export function interpolatePath({ path, params, decodeCharMap, - parseCache, }: InterpolatePathOptions): InterPolatePathResult { - const interpolatedPathSegments = parsePathname(path, parseCache) - - function encodeParam(key: string): any { - const value = params[key] - const isValueString = typeof value === 'string' - - if (key === '*' || key === '_splat') { - // the splat/catch-all routes shouldn't have the '/' encoded out - return isValueString ? encodeURI(value) : value - } else { - return isValueString ? encodePathParam(value, decodeCharMap) : value - } - } - // Tracking if any params are missing in the `params` object // when interpolating the path let isMissingParams = false - const usedParams: Record = {} - const interpolatedPath = joinPaths( - interpolatedPathSegments.map((segment) => { - if (segment.type === SEGMENT_TYPE_PATHNAME) { - return segment.value - } - if (segment.type === SEGMENT_TYPE_WILDCARD) { - usedParams._splat = params._splat + if (!path || path === '/') + return { interpolatedPath: '/', usedParams, isMissingParams } + if (!path.includes('$')) + return { interpolatedPath: path, usedParams, isMissingParams } - // TODO: Deprecate * - usedParams['*'] = params._splat + const length = path.length + let cursor = 0 + let segment + let joined = '' + while (cursor < length) { + const start = cursor + segment = parseSegment(path, start, segment) + const end = segment[5] + cursor = end + 1 - const segmentPrefix = segment.prefixSegment || '' - const segmentSuffix = segment.suffixSegment || '' + if (start === end) continue - // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. - if (!params._splat) { - isMissingParams = true - // For missing splat parameters, just return the prefix and suffix without the wildcard - // If there is a prefix or suffix, return them joined, otherwise omit the segment - if (segmentPrefix || segmentSuffix) { - return `${segmentPrefix}${segmentSuffix}` - } - return undefined - } - - const value = encodeParam('_splat') + const kind = segment[0] - return `${segmentPrefix}${value}${segmentSuffix}` - } + if (kind === SEGMENT_TYPE_PATHNAME) { + joined += '/' + path.substring(start, end) + continue + } - if (segment.type === SEGMENT_TYPE_PARAM) { - const key = segment.value.substring(1) - if (!isMissingParams && !(key in params)) { - isMissingParams = true + if (kind === SEGMENT_TYPE_WILDCARD) { + const splat = params._splat + usedParams._splat = splat + // TODO: Deprecate * + usedParams['*'] = splat + + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) + + // Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value. + if (!splat) { + isMissingParams = true + // For missing splat parameters, just return the prefix and suffix without the wildcard + // If there is a prefix or suffix, return them joined, otherwise omit the segment + if (prefix || suffix) { + joined += '/' + prefix + suffix } - usedParams[key] = params[key] + continue + } - const segmentPrefix = segment.prefixSegment || '' - const segmentSuffix = segment.suffixSegment || '' + const value = encodeParam('_splat', params, decodeCharMap) + joined += '/' + prefix + value + suffix + continue + } - return `${segmentPrefix}${encodeParam(key) ?? 'undefined'}${segmentSuffix}` + if (kind === SEGMENT_TYPE_PARAM) { + const key = path.substring(segment[2], segment[3]) + if (!isMissingParams && !(key in params)) { + isMissingParams = true } + usedParams[key] = params[key] - if (segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - const key = segment.value.substring(1) + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) + const value = encodeParam(key, params, decodeCharMap) ?? 'undefined' + joined += '/' + prefix + value + suffix + continue + } - const segmentPrefix = segment.prefixSegment || '' - const segmentSuffix = segment.suffixSegment || '' + if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + const key = path.substring(segment[2], segment[3]) + const prefix = path.substring(start, segment[1]) + const suffix = path.substring(segment[4], end) + const valueRaw = params[key] - // Check if optional parameter is missing or undefined - if (!(key in params) || params[key] == null) { + // Check if optional parameter is missing or undefined + if (valueRaw == null) { + if (prefix || suffix) { // For optional params with prefix/suffix, keep the prefix/suffix but omit the param - if (segmentPrefix || segmentSuffix) { - return `${segmentPrefix}${segmentSuffix}` - } - // If no prefix/suffix, omit the entire segment - return undefined + joined += '/' + prefix + suffix } + // If no prefix/suffix, omit the entire segment + continue + } - usedParams[key] = params[key] + usedParams[key] = valueRaw - return `${segmentPrefix}${encodeParam(key) ?? ''}${segmentSuffix}` - } + const value = encodeParam(key, params, decodeCharMap) ?? '' + joined += '/' + prefix + value + suffix + continue + } + } + + if (path.endsWith('/')) joined += '/' + + const interpolatedPath = joined || '/' - return segment.value - }), - ) return { usedParams, interpolatedPath, isMissingParams } } @@ -502,329 +357,3 @@ function encodePathParam(value: string, decodeCharMap?: Map) { } return encoded } - -/** - * Match a pathname against a route destination and return extracted params - * or `undefined`. Uses the same parsing as the router for consistency. - */ -/** - * Match a pathname against a route destination and return extracted params - * or `undefined`. Uses the same parsing as the router for consistency. - */ -export function matchPathname( - currentPathname: string, - matchLocation: Pick, - parseCache?: ParsePathnameCache, -): AnyPathParams | undefined { - const pathParams = matchByPath(currentPathname, matchLocation, parseCache) - // const searchMatched = matchBySearch(location.search, matchLocation) - - if (matchLocation.to && !pathParams) { - return - } - - return pathParams ?? {} -} - -/** Low-level matcher that compares two path strings and extracts params. */ -/** Low-level matcher that compares two path strings and extracts params. */ -export function matchByPath( - from: string, - { - to, - fuzzy, - caseSensitive, - }: Pick, - parseCache?: ParsePathnameCache, -): Record | undefined { - const stringTo = to as string - - // Parse the from and to - const baseSegments = parsePathname( - from.startsWith('/') ? from : `/${from}`, - parseCache, - ) - const routeSegments = parsePathname( - stringTo.startsWith('/') ? stringTo : `/${stringTo}`, - parseCache, - ) - - const params: Record = {} - - const result = isMatch( - baseSegments, - routeSegments, - params, - fuzzy, - caseSensitive, - ) - - return result ? params : undefined -} - -function isMatch( - baseSegments: ReadonlyArray, - routeSegments: ReadonlyArray, - params: Record, - fuzzy?: boolean, - caseSensitive?: boolean, -): boolean { - let baseIndex = 0 - let routeIndex = 0 - - while (baseIndex < baseSegments.length || routeIndex < routeSegments.length) { - const baseSegment = baseSegments[baseIndex] - const routeSegment = routeSegments[routeIndex] - - if (routeSegment) { - if (routeSegment.type === SEGMENT_TYPE_WILDCARD) { - // Capture all remaining segments for a wildcard - const remainingBaseSegments = baseSegments.slice(baseIndex) - - let _splat: string - - // If this is a wildcard with prefix/suffix, we need to handle the first segment specially - if (routeSegment.prefixSegment || routeSegment.suffixSegment) { - if (!baseSegment) return false - - const prefix = routeSegment.prefixSegment || '' - 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)) { - return false - } - } - if ('suffixSegment' in routeSegment) { - if ( - !baseSegments[baseSegments.length - 1]?.value.endsWith(suffix) - ) { - return false - } - } - - let rejoinedSplat = decodeURI( - joinPaths(remainingBaseSegments.map((d) => d.value)), - ) - - // Remove the prefix and suffix from the rejoined splat - if (prefix && rejoinedSplat.startsWith(prefix)) { - rejoinedSplat = rejoinedSplat.slice(prefix.length) - } - - if (suffix && rejoinedSplat.endsWith(suffix)) { - rejoinedSplat = rejoinedSplat.slice( - 0, - rejoinedSplat.length - suffix.length, - ) - } - - _splat = rejoinedSplat - } else { - // If no prefix/suffix, just rejoin the remaining segments - _splat = decodeURI( - joinPaths(remainingBaseSegments.map((d) => d.value)), - ) - } - - // TODO: Deprecate * - params['*'] = _splat - params['_splat'] = _splat - return true - } - - if (routeSegment.type === SEGMENT_TYPE_PATHNAME) { - if (routeSegment.value === '/' && !baseSegment?.value) { - routeIndex++ - continue - } - - if (baseSegment) { - if (caseSensitive) { - if (routeSegment.value !== baseSegment.value) { - return false - } - } else if ( - routeSegment.value.toLowerCase() !== baseSegment.value.toLowerCase() - ) { - return false - } - baseIndex++ - routeIndex++ - continue - } else { - return false - } - } - - if (routeSegment.type === SEGMENT_TYPE_PARAM) { - if (!baseSegment) { - return false - } - - if (baseSegment.value === '/') { - return false - } - - let _paramValue = '' - let matched = false - - // If this param has prefix/suffix, we need to extract the actual parameter value - if (routeSegment.prefixSegment || routeSegment.suffixSegment) { - const prefix = routeSegment.prefixSegment || '' - const suffix = routeSegment.suffixSegment || '' - - // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value - if (prefix && !baseValue.startsWith(prefix)) { - return false - } - if (suffix && !baseValue.endsWith(suffix)) { - return false - } - - let paramValue = baseValue - if (prefix && paramValue.startsWith(prefix)) { - paramValue = paramValue.slice(prefix.length) - } - if (suffix && paramValue.endsWith(suffix)) { - paramValue = paramValue.slice(0, paramValue.length - suffix.length) - } - - _paramValue = decodeURIComponent(paramValue) - matched = true - } else { - // If no prefix/suffix, just decode the base segment value - _paramValue = decodeURIComponent(baseSegment.value) - matched = true - } - - if (matched) { - params[routeSegment.value.substring(1)] = _paramValue - baseIndex++ - } - - routeIndex++ - continue - } - - if (routeSegment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - // Optional parameters can be missing - don't fail the match - if (!baseSegment) { - // No base segment for optional param - skip this route segment - routeIndex++ - continue - } - - if (baseSegment.value === '/') { - // Skip slash segments for optional params - routeIndex++ - continue - } - - let _paramValue = '' - let matched = false - - // If this optional param has prefix/suffix, we need to extract the actual parameter value - if (routeSegment.prefixSegment || routeSegment.suffixSegment) { - const prefix = routeSegment.prefixSegment || '' - const suffix = routeSegment.suffixSegment || '' - - // Check if the base segment starts with prefix and ends with suffix - const baseValue = baseSegment.value - if ( - (!prefix || baseValue.startsWith(prefix)) && - (!suffix || baseValue.endsWith(suffix)) - ) { - let paramValue = baseValue - if (prefix && paramValue.startsWith(prefix)) { - paramValue = paramValue.slice(prefix.length) - } - if (suffix && paramValue.endsWith(suffix)) { - paramValue = paramValue.slice( - 0, - paramValue.length - suffix.length, - ) - } - - _paramValue = decodeURIComponent(paramValue) - matched = true - } - } else { - // For optional params without prefix/suffix, we need to check if the current - // base segment should match this optional param or a later route segment - - // Look ahead to see if there's a later route segment that matches the current base segment - let shouldMatchOptional = true - for ( - let lookAhead = routeIndex + 1; - lookAhead < routeSegments.length; - lookAhead++ - ) { - const futureRouteSegment = routeSegments[lookAhead] - if ( - futureRouteSegment?.type === SEGMENT_TYPE_PATHNAME && - futureRouteSegment.value === baseSegment.value - ) { - // The current base segment matches a future pathname segment, - // so we should skip this optional parameter - shouldMatchOptional = false - break - } - - // If we encounter a required param or wildcard, stop looking ahead - if ( - futureRouteSegment?.type === SEGMENT_TYPE_PARAM || - futureRouteSegment?.type === SEGMENT_TYPE_WILDCARD - ) { - if (baseSegments.length < routeSegments.length) { - shouldMatchOptional = false - } - break - } - } - - if (shouldMatchOptional) { - // If no prefix/suffix, just decode the base segment value - _paramValue = decodeURIComponent(baseSegment.value) - matched = true - } - } - - if (matched) { - params[routeSegment.value.substring(1)] = _paramValue - baseIndex++ - } - - routeIndex++ - continue - } - } - - // 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), - ) - return !!fuzzy && routeSegments[routeSegments.length - 1]?.value !== '/' - } - - // If we have route segments left but no base segments, check if remaining are optional - if (routeIndex < routeSegments.length && baseIndex >= baseSegments.length) { - // Check if all remaining route segments are optional - for (let i = routeIndex; i < routeSegments.length; i++) { - if (routeSegments[i]?.type !== SEGMENT_TYPE_OPTIONAL_PARAM) { - return false - } - } - // All remaining are optional, so we can finish - break - } - - break - } - - return true -} diff --git a/packages/router-core/src/process-route-tree.ts b/packages/router-core/src/process-route-tree.ts deleted file mode 100644 index 85a3b7a1e76..00000000000 --- a/packages/router-core/src/process-route-tree.ts +++ /dev/null @@ -1,241 +0,0 @@ -import invariant from 'tiny-invariant' -import { - SEGMENT_TYPE_OPTIONAL_PARAM, - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - parsePathname, - trimPathLeft, - trimPathRight, -} from './path' -import type { Segment } from './path' -import type { RouteLike } from './route' - -const SLASH_SCORE = 0.75 -const STATIC_SEGMENT_SCORE = 1 -const REQUIRED_PARAM_BASE_SCORE = 0.5 -const OPTIONAL_PARAM_BASE_SCORE = 0.4 -const WILDCARD_PARAM_BASE_SCORE = 0.25 -const STATIC_AFTER_DYNAMIC_BONUS_SCORE = 0.2 -const BOTH_PRESENCE_BASE_SCORE = 0.05 -const PREFIX_PRESENCE_BASE_SCORE = 0.02 -const SUFFIX_PRESENCE_BASE_SCORE = 0.01 -const PREFIX_LENGTH_SCORE_MULTIPLIER = 0.0002 -const SUFFIX_LENGTH_SCORE_MULTIPLIER = 0.0001 - -function handleParam(segment: Segment, baseScore: number) { - if (segment.prefixSegment && segment.suffixSegment) { - return ( - baseScore + - BOTH_PRESENCE_BASE_SCORE + - PREFIX_LENGTH_SCORE_MULTIPLIER * segment.prefixSegment.length + - SUFFIX_LENGTH_SCORE_MULTIPLIER * segment.suffixSegment.length - ) - } - - if (segment.prefixSegment) { - return ( - baseScore + - PREFIX_PRESENCE_BASE_SCORE + - PREFIX_LENGTH_SCORE_MULTIPLIER * segment.prefixSegment.length - ) - } - - if (segment.suffixSegment) { - return ( - baseScore + - SUFFIX_PRESENCE_BASE_SCORE + - SUFFIX_LENGTH_SCORE_MULTIPLIER * segment.suffixSegment.length - ) - } - - return baseScore -} - -function sortRoutes( - routes: ReadonlyArray, -): Array { - const scoredRoutes: Array<{ - child: TRouteLike - trimmed: string - parsed: ReadonlyArray - index: number - scores: Array - hasStaticAfter: boolean - optionalParamCount: number - }> = [] - - routes.forEach((d, i) => { - if (d.isRoot || !d.path) { - return - } - - const trimmed = trimPathLeft(d.fullPath) - let parsed = parsePathname(trimmed) - - // Removes the leading slash if it is not the only remaining segment - let skip = 0 - while (parsed.length > skip + 1 && parsed[skip]?.value === '/') { - skip++ - } - if (skip > 0) parsed = parsed.slice(skip) - - let optionalParamCount = 0 - let hasStaticAfter = false - const scores = parsed.map((segment, index) => { - if (segment.value === '/') { - return SLASH_SCORE - } - - if (segment.type === SEGMENT_TYPE_PATHNAME) { - return STATIC_SEGMENT_SCORE - } - - let baseScore: number | undefined = undefined - if (segment.type === SEGMENT_TYPE_PARAM) { - baseScore = REQUIRED_PARAM_BASE_SCORE - } else if (segment.type === SEGMENT_TYPE_OPTIONAL_PARAM) { - baseScore = OPTIONAL_PARAM_BASE_SCORE - optionalParamCount++ - } else { - baseScore = WILDCARD_PARAM_BASE_SCORE - } - - // if there is any static segment (that is not an index) after a required / optional param, - // we will boost this param so it ranks higher than a required/optional param without a static segment after it - // JUST FOR SORTING, NOT FOR MATCHING - for (let i = index + 1; i < parsed.length; i++) { - const nextSegment = parsed[i]! - if ( - nextSegment.type === SEGMENT_TYPE_PATHNAME && - nextSegment.value !== '/' - ) { - hasStaticAfter = true - return handleParam( - segment, - baseScore + STATIC_AFTER_DYNAMIC_BONUS_SCORE, - ) - } - } - - return handleParam(segment, baseScore) - }) - - scoredRoutes.push({ - child: d, - trimmed, - parsed, - index: i, - scores, - optionalParamCount, - hasStaticAfter, - }) - }) - - const flatRoutes = scoredRoutes - .sort((a, b) => { - const minLength = Math.min(a.scores.length, b.scores.length) - - // Sort by segment-by-segment score comparison ONLY for the common prefix - for (let i = 0; i < minLength; i++) { - if (a.scores[i] !== b.scores[i]) { - return b.scores[i]! - a.scores[i]! - } - } - - // If all common segments have equal scores, then consider length and specificity - if (a.scores.length !== b.scores.length) { - // If different number of optional parameters, fewer optional parameters wins (more specific) - // only if both or none of the routes has static segments after the params - if (a.optionalParamCount !== b.optionalParamCount) { - if (a.hasStaticAfter === b.hasStaticAfter) { - return a.optionalParamCount - b.optionalParamCount - } else if (a.hasStaticAfter && !b.hasStaticAfter) { - return -1 - } else if (!a.hasStaticAfter && b.hasStaticAfter) { - return 1 - } - } - - // If same number of optional parameters, longer path wins (for static segments) - return b.scores.length - a.scores.length - } - - // Sort by min available parsed value for alphabetical ordering - for (let i = 0; i < minLength; i++) { - if (a.parsed[i]!.value !== b.parsed[i]!.value) { - return a.parsed[i]!.value > b.parsed[i]!.value ? 1 : -1 - } - } - - // Sort by original index - return a.index - b.index - }) - .map((d, i) => { - d.child.rank = i - return d.child - }) - - return flatRoutes -} - -export type ProcessRouteTreeResult = { - routesById: Record - routesByPath: Record - flatRoutes: Array -} - -/** - * Build lookup maps and a specificity-sorted flat list from a route tree. - * Returns `routesById`, `routesByPath`, and `flatRoutes`. - */ -/** - * Build lookup maps and a specificity-sorted flat list from a route tree. - * Returns `routesById`, `routesByPath`, and `flatRoutes`. - */ -export function processRouteTree({ - routeTree, - initRoute, -}: { - routeTree: TRouteLike - initRoute?: (route: TRouteLike, index: number) => void -}): ProcessRouteTreeResult { - const routesById = {} as Record - const routesByPath = {} as Record - - const recurseRoutes = (childRoutes: Array) => { - childRoutes.forEach((childRoute, i) => { - initRoute?.(childRoute, i) - - const existingRoute = routesById[childRoute.id] - - invariant( - !existingRoute, - `Duplicate routes found with id: ${String(childRoute.id)}`, - ) - - routesById[childRoute.id] = childRoute - - if (!childRoute.isRoot && childRoute.path) { - const trimmedFullPath = trimPathRight(childRoute.fullPath) - if ( - !routesByPath[trimmedFullPath] || - childRoute.fullPath.endsWith('/') - ) { - routesByPath[trimmedFullPath] = childRoute - } - } - - const children = childRoute.children as Array - - if (children?.length) { - recurseRoutes(children) - } - }) - } - - recurseRoutes([routeTree]) - - const flatRoutes = sortRoutes(Object.values(routesById)) - - return { routesById, routesByPath, flatRoutes } -} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 087b81ea129..e0f6246eabb 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -9,21 +9,26 @@ import { last, replaceEqualDeep, } from './utils' -import { processRouteTree } from './process-route-tree' +import { + findFlatMatch, + findRouteMatch, + findSingleMatch, + processRouteMasks, + processRouteTree, +} from './new-process-route-tree' import { cleanPath, interpolatePath, - matchPathname, resolvePath, trimPath, trimPathRight, } from './path' +import { createLRUCache } from './lru-cache' import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' -import { createLRUCache } from './lru-cache' import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' import { composeRewrites, @@ -31,7 +36,7 @@ import { executeRewriteOutput, rewriteBasepath, } from './rewrite' -import type { ParsePathnameCache } from './path' +import type { ProcessedTree } from './new-process-route-tree' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { @@ -691,10 +696,7 @@ export type ParseLocationFn = ( previousLocation?: ParsedLocation>, ) => ParsedLocation> -export type GetMatchRoutesFn = ( - pathname: string, - routePathname: string | undefined, -) => { +export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: Array routeParams: Record foundRoute: AnyRoute | undefined @@ -902,7 +904,7 @@ export class RouterCore< routeTree!: TRouteTree routesById!: RoutesById routesByPath!: RoutesByPath - flatRoutes!: Array + processedTree!: ProcessedTree isServer!: boolean pathParamsDecodeCharMap?: Map @@ -1094,18 +1096,22 @@ export class RouterCore< } buildRouteTree = () => { - const { routesById, routesByPath, flatRoutes } = processRouteTree({ - routeTree: this.routeTree, - initRoute: (route, i) => { + const { routesById, routesByPath, processedTree } = processRouteTree( + this.routeTree, + this.options.caseSensitive, + (route, i) => { route.init({ originalIndex: i, }) }, - }) + ) + if (this.options.routeMasks) { + processRouteMasks(this.options.routeMasks, processedTree) + } this.routesById = routesById as RoutesById this.routesByPath = routesByPath as RoutesByPath - this.flatRoutes = flatRoutes as Array + this.processedTree = processedTree const notFoundRoute = this.options.notFoundRoute @@ -1203,13 +1209,15 @@ export class RouterCore< return location } + resolvePathCache = createLRUCache(1000) + /** Resolve a path against the router basepath and trailing-slash policy. */ resolvePathWithBase = (from: string, path: string) => { const resolvedPath = resolvePath({ base: from, to: cleanPath(path), trailingSlash: this.options.trailingSlash, - parseCache: this.parsePathnameCache, + cache: this.resolvePathCache, }) return resolvedPath } @@ -1242,7 +1250,6 @@ export class RouterCore< ): Array { const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes( next.pathname, - opts?.dest?.to as string, ) let isGlobalNotFound = false @@ -1535,21 +1542,11 @@ export class RouterCore< return matches } - /** a cache for `parsePathname` */ - private parsePathnameCache: ParsePathnameCache = createLRUCache(1000) - - getMatchedRoutes: GetMatchRoutesFn = ( - pathname: string, - routePathname: string | undefined, - ) => { + getMatchedRoutes: GetMatchRoutesFn = (pathname) => { return getMatchedRoutes({ pathname, - routePathname, - caseSensitive: this.options.caseSensitive, - routesByPath: this.routesByPath, routesById: this.routesById, - flatRoutes: this.flatRoutes, - parseCache: this.parsePathnameCache, + processedTree: this.processedTree, }) } @@ -1612,10 +1609,7 @@ export class RouterCore< process.env.NODE_ENV !== 'production' && dest._isNavigate ) { - const allFromMatches = this.getMatchedRoutes( - dest.from, - undefined, - ).matchedRoutes + const allFromMatches = this.getMatchedRoutes(dest.from).matchedRoutes const matchedFrom = findLast(allCurrentLocationMatches, (d) => { return comparePaths(d.fullPath, dest.from!) @@ -1666,7 +1660,6 @@ export class RouterCore< const interpolatedNextTo = interpolatePath({ path: nextTo, params: nextParams, - parseCache: this.parsePathnameCache, }).interpolatedPath const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, { @@ -1693,7 +1686,6 @@ export class RouterCore< path: nextTo, params: nextParams, decodeCharMap: this.pathParamsDecodeCharMap, - parseCache: this.parsePathnameCache, }).interpolatedPath, ) @@ -1786,35 +1778,23 @@ export class RouterCore< let maskedNext = maskedDest ? build(maskedDest) : undefined if (!maskedNext) { - let params = {} + const params = {} - const foundMask = this.options.routeMasks?.find((d) => { - const match = matchPathname( + if (this.options.routeMasks) { + const match = findFlatMatch>( next.pathname, - { - to: d.from, - caseSensitive: false, - fuzzy: false, - }, - this.parsePathnameCache, + this.processedTree, ) - if (match) { - params = match - return true - } - - return false - }) - - if (foundMask) { - const { from: _from, ...maskProps } = foundMask - maskedDest = { - from: opts.from, - ...maskProps, - params, + Object.assign(params, match.params) // Copy params, because they're cached + const { from: _from, ...maskProps } = match.route + maskedDest = { + from: opts.from, + ...maskProps, + params, + } + maskedNext = build(maskedDest) } - maskedNext = build(maskedDest) } } @@ -2525,31 +2505,31 @@ export class RouterCore< ? this.latestLocation : this.state.resolvedLocation || this.state.location - const match = matchPathname( + const match = findSingleMatch( + next.pathname, + opts?.caseSensitive ?? false, + opts?.fuzzy ?? false, baseLocation.pathname, - { - ...opts, - to: next.pathname, - }, - this.parsePathnameCache, - ) as any + this.processedTree, + ) if (!match) { return false } + if (location.params) { - if (!deepEqual(match, location.params, { partial: true })) { + if (!deepEqual(match.params, location.params, { partial: true })) { return false } } - if (match && (opts?.includeSearch ?? true)) { + if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) - ? match + ? match.params : false } - return match + return match.params } ssr?: { @@ -2645,70 +2625,21 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { */ export function getMatchedRoutes({ pathname, - routePathname, - caseSensitive, - routesByPath, routesById, - flatRoutes, - parseCache, + processedTree, }: { pathname: string - routePathname?: string - caseSensitive?: boolean - routesByPath: Record routesById: Record - flatRoutes: Array - parseCache?: ParsePathnameCache + processedTree: ProcessedTree }) { - let routeParams: Record = {} + const routeParams: Record = {} const trimmedPath = trimPathRight(pathname) - const getMatchedParams = (route: TRouteLike) => { - const result = matchPathname( - trimmedPath, - { - to: route.fullPath, - caseSensitive: route.options?.caseSensitive ?? caseSensitive, - // we need fuzzy matching for `notFoundMode: 'fuzzy'` - fuzzy: true, - }, - parseCache, - ) - return result - } - let foundRoute: TRouteLike | undefined = - routePathname !== undefined ? routesByPath[routePathname] : undefined - if (foundRoute) { - routeParams = getMatchedParams(foundRoute)! - } else { - // iterate over flatRoutes to find the best match - // if we find a fuzzy matching route, keep looking for a perfect fit - let fuzzyMatch: - | { foundRoute: TRouteLike; routeParams: Record } - | undefined = undefined - for (const route of flatRoutes) { - const matchedParams = getMatchedParams(route) - - if (matchedParams) { - if ( - route.path !== '/' && - (matchedParams as Record)['**'] - ) { - if (!fuzzyMatch) { - fuzzyMatch = { foundRoute: route, routeParams: matchedParams } - } - } else { - foundRoute = route - routeParams = matchedParams - break - } - } - } - // did not find a perfect fit, so take the fuzzy matching route if it exists - if (!foundRoute && fuzzyMatch) { - foundRoute = fuzzyMatch.foundRoute - routeParams = fuzzyMatch.routeParams - } + let foundRoute: TRouteLike | undefined = undefined + const match = findRouteMatch(trimmedPath, processedTree, true) + if (match) { + foundRoute = match.route + Object.assign(routeParams, match.params) // Copy params, because they're cached } let routeCursor: TRouteLike = foundRoute || routesById[rootRouteId]! diff --git a/packages/router-core/tests/curly-params-smoke.test.ts b/packages/router-core/tests/curly-params-smoke.test.ts new file mode 100644 index 00000000000..6184a205e7f --- /dev/null +++ b/packages/router-core/tests/curly-params-smoke.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' + +const testCases = [ + { + name: 'param with braces', + path: '/$id', + nav: '/1', + params: { id: '1' }, + }, + { + name: 'param without braces', + path: '/{$id}', + nav: '/2', + params: { id: '2' }, + }, + { + name: 'param with prefix', + path: '/prefix-{$id}', + nav: '/prefix-3', + params: { id: '3' }, + }, + { + name: 'param with suffix', + path: '/{$id}-suffix', + nav: '/4-suffix', + params: { id: '4' }, + }, + { + name: 'param with prefix and suffix', + path: '/prefix-{$id}-suffix', + nav: '/prefix-5-suffix', + params: { id: '5' }, + }, + { + name: 'wildcard with no braces', + path: '/abc/$', + nav: '/abc/6', + params: { '*': '6', _splat: '6' }, + }, + { + name: 'wildcard with braces', + path: '/abc/{$}', + nav: '/abc/7', + params: { '*': '7', _splat: '7' }, + }, + { + name: 'wildcard with prefix', + path: '/abc/prefix{$}', + nav: '/abc/prefix/8', + params: { '*': '/8', _splat: '/8' }, + }, + { + name: 'wildcard with suffix', + path: '/abc/{$}suffix', + nav: '/abc/9/suffix', + params: { _splat: '9/', '*': '9/' }, + }, + { + name: 'optional param with no prefix/suffix and value', + path: '/abc/{-$id}/def', + nav: '/abc/10/def', + params: { id: '10' }, + }, + { + name: 'optional param with no prefix/suffix and requiredParam and no value', + path: '/abc/{-$id}/$foo/def', + nav: '/abc/bar/def', + params: { foo: 'bar' }, + }, + { + name: 'optional param with no prefix/suffix and requiredParam and value', + path: '/abc/{-$id}/$foo/def', + nav: '/abc/10/bar/def', + params: { id: '10', foo: 'bar' }, + }, + { + name: 'optional param with no prefix/suffix and no value', + path: '/abc/{-$id}/def', + nav: '/abc/def', + params: {}, + }, + { + name: 'optional param with prefix and value', + path: '/optional-{-$id}', + nav: '/optional-12', + params: { id: '12' }, + }, + { + name: 'optional param with prefix and no value', + path: '/optional-{-$id}', + nav: '/optional-', + params: {}, + }, + { + name: 'optional param with suffix and value', + path: '/{-$id}-optional', + nav: '/13-optional', + params: { id: '13' }, + }, + { + name: 'optional param with suffix and no value', + path: '/{-$id}-optional', + nav: '/-optional', + params: {}, + }, + { + name: 'optional param with required param, prefix, suffix, wildcard and no value', + path: `/$foo/a{-$id}-optional/$`, + nav: '/bar/a-optional/qux', + params: { foo: 'bar', _splat: 'qux', '*': 'qux' }, + }, + { + name: 'optional param with required param, prefix, suffix, wildcard and value', + path: `/$foo/a{-$id}-optional/$`, + nav: '/bar/a14-optional/qux', + params: { foo: 'bar', id: '14', _splat: 'qux', '*': 'qux' }, + }, +] + +// porting tests from https://github.com/TanStack/router/pull/5851 +describe('curly params smoke tests', () => { + test.each(testCases)('$name', ({ path, nav, params }) => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: path, + fullPath: path, + path: path, + }, + ], + } + const processed = processRouteTree(tree) + const res = findRouteMatch(nav, processed.processedTree) + expect(res?.params).toEqual(params) + }) +}) diff --git a/packages/router-core/tests/match-by-path.test.ts b/packages/router-core/tests/match-by-path.test.ts index 61f0bc369c7..ad108080381 100644 --- a/packages/router-core/tests/match-by-path.test.ts +++ b/packages/router-core/tests/match-by-path.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest' -import { matchByPath } from '../src' +import { + findSingleMatch, + processRouteTree, +} from '../src/new-process-route-tree' + +const { processedTree } = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', +}) describe('default path matching', () => { it.each([ @@ -14,10 +24,9 @@ describe('default path matching', () => { ['/a/', '/a/', {}], ['/a/', '/a', undefined], ['/b', '/a', undefined], - ])('static %s %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('static %s %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -26,26 +35,34 @@ describe('default path matching', () => { ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], ['/a/1_/b/2', '/a/$id/b/$other', { id: '1_', other: '2' }], ['/a/1/b/2', '/a/$id/b/$id', { id: '2' }], - ])('params %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('params %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) it('params support more than alphanumeric characters', () => { // in the value: basically everything except / and % - expect( - matchByPath( - '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - { to: '/a/$id' }, - ), - ).toEqual({ id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{' }) + const anyValueResult = findSingleMatch( + '/a/$id', + false, + false, + '/a/@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', + processedTree, + ) + expect(anyValueResult?.params).toEqual({ + id: '@&é"\'(§è!çà)-_°^¨$*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', + }) // in the key: basically everything except / and % and $ - expect( - matchByPath('/a/1', { - to: '/a/$@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', - }), - ).toEqual({ '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1' }) + const anyKeyResult = findSingleMatch( + '/a/$@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{', + false, + false, + '/a/1', + processedTree, + ) + expect(anyKeyResult?.params).toEqual({ + '@&é"\'(§è!çà)-_°^¨*€£`ù=+:;.,?~<>|î©#0123456789\\😀}{': '1', + }) }) it.each([ @@ -58,21 +75,19 @@ describe('default path matching', () => { ['/a/1/b', '/a/{-$id}/b/{-$other}', { id: '1' }], ['/a/b', '/a/{-$id}/b/{-$other}', {}], ['/a/1/b/2', '/a/{-$id}/b/{-$id}', { id: '2' }], - ])('optional %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('optional %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '/', '*': '/' }], + ['/a/', '/a/$', { _splat: '', '*': '' }], ['/a', '/a/$', { _splat: '', '*': '' }], ['/a/b/c', '/a/$/foo', { _splat: 'b/c', '*': 'b/c' }], - ])('wildcard %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: true, fuzzy: false }), - ).toEqual(result) + ])('wildcard %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, false, path, processedTree) + expect(res?.params).toEqual(result) }) }) @@ -89,10 +104,9 @@ describe('case insensitive path matching', () => { ['/', '/a/', '/A/', {}], ['/', '/a/', '/A', undefined], ['/', '/b', '/A', undefined], - ])('static %s %s => %s', (base, from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('static %s %s => %s', (base, path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -100,10 +114,9 @@ describe('case insensitive path matching', () => { ['/a/1/b', '/A/$id/B', { id: '1' }], ['/a/1/b/2', '/A/$id/B/$other', { id: '1', other: '2' }], ['/a/1/b/2', '/A/$id/B/$id', { id: '2' }], - ])('params %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('params %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -118,21 +131,19 @@ describe('case insensitive path matching', () => { // ['/a/b', '/A/{-$id}/B/{-$other}', {}], ['/a/1/b/2', '/A/{-$id}/B/{-$id}', { id: '2' }], ['/a/1/b/2_', '/A/{-$id}/B/{-$id}', { id: '2_' }], - ])('optional %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('optional %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ ['/a/b/c', '/A/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/A/$', { _splat: '/', '*': '/' }], + ['/a/', '/A/$', { _splat: '', '*': '' }], ['/a', '/A/$', { _splat: '', '*': '' }], ['/a/b/c', '/A/$/foo', { _splat: 'b/c', '*': 'b/c' }], - ])('wildcard %s => %s', (from, to, result) => { - expect( - matchByPath(from, { to, caseSensitive: false, fuzzy: false }), - ).toEqual(result) + ])('wildcard %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, false, false, path, processedTree) + expect(res?.params).toEqual(result) }) }) @@ -154,10 +165,9 @@ describe('fuzzy path matching', () => { ['/', '/a', '/a/b', undefined], ['/', '/b', '/a', undefined], ['/', '/a', '/b', undefined], - ])('static %s %s => %s', (base, from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('static %s %s => %s', (base, path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -166,10 +176,9 @@ describe('fuzzy path matching', () => { ['/a/1/', '/a/$id/', { id: '1' }], ['/a/1/b/2', '/a/$id/b/$other', { id: '1', other: '2' }], ['/a/1/b/2/c', '/a/$id/b/$other', { id: '1', other: '2', '**': 'c' }], - ])('params %s => %s', (from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('params %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ @@ -182,20 +191,18 @@ describe('fuzzy path matching', () => { ['/a/b', '/a/{-$id}/b/{-$other}', {}], ['/a/b/2/d', '/a/{-$id}/b/{-$other}', { other: '2', '**': 'd' }], ['/a/1/b/2/c', '/a/{-$id}/b/{-$other}', { id: '1', other: '2', '**': 'c' }], - ])('optional %s => %s', (from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('optional %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) it.each([ ['/a/b/c', '/a/$', { _splat: 'b/c', '*': 'b/c' }], - ['/a/', '/a/$', { _splat: '/', '*': '/' }], + ['/a/', '/a/$', { _splat: '', '*': '' }], ['/a', '/a/$', { _splat: '', '*': '' }], ['/a/b/c/d', '/a/$/foo', { _splat: 'b/c/d', '*': 'b/c/d' }], - ])('wildcard %s => %s', (from, to, result) => { - expect(matchByPath(from, { to, fuzzy: true, caseSensitive: true })).toEqual( - result, - ) + ])('wildcard %s => %s', (path, pattern, result) => { + const res = findSingleMatch(pattern, true, true, path, processedTree) + expect(res?.params).toEqual(result) }) }) diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts new file mode 100644 index 00000000000..9b9563d98ac --- /dev/null +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -0,0 +1,717 @@ +import { describe, expect, it } from 'vitest' +import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree' +import type { AnyRoute, RouteMask } from '../src' + +function makeTree(routes: Array) { + return processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: routes.map((route) => ({ + id: route, + fullPath: route, + path: route, + })), + }).processedTree +} + +describe('findRouteMatch', () => { + describe('priority', () => { + describe('basic permutations priorities', () => { + it('/static/static vs /static/dynamic', () => { + const tree = makeTree(['/a/b', '/a/$b']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') + }) + it('/static/static vs /static/optional', () => { + const tree = makeTree(['/a/b', '/a/{-$b}']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') + }) + it('/static/static vs /static/wildcard', () => { + const tree = makeTree(['/a/b', '/a/$']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/b') + }) + it('/static/dynamic vs /static/optional', () => { + const tree = makeTree(['/a/$b', '/a/{-$b}']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/$b') + }) + it('/static/dynamic vs /static/wildcard', () => { + const tree = makeTree(['/a/$b', '/a/$']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/$b') + }) + it('/static/optional vs /static/wildcard', () => { + const tree = makeTree(['/a/{-$b}', '/a/$']) + expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/{-$b}') + }) + }) + + describe('prefix / suffix priorities', () => { + it('prefix+suffix dynamic wins over plain dynamic', () => { + const tree = makeTree(['/a/b{$b}b', '/a/$b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$b}b') + }) + it('prefix dynamic wins over plain dynamic', () => { + const tree = makeTree(['/a/b{$b}', '/a/$b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$b}') + }) + it('suffix dynamic wins over plain dynamic', () => { + const tree = makeTree(['/a/{$b}b', '/a/$b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{$b}b') + }) + + it('prefix+suffix optional wins over plain optional', () => { + const tree = makeTree(['/a/b{-$b}b', '/a/{-$b}']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{-$b}b') + }) + it('prefix optional wins over plain optional', () => { + const tree = makeTree(['/a/b{-$b}', '/a/{-$b}']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{-$b}') + }) + it('suffix optional wins over plain optional', () => { + const tree = makeTree(['/a/{-$b}b', '/a/{-$b}']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{-$b}b') + }) + + it('prefix+suffix wildcard wins over plain wildcard', () => { + const tree = makeTree(['/a/b{$}b', '/a/$']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$}b') + }) + it('prefix wildcard wins over plain wildcard', () => { + const tree = makeTree(['/a/b{$}', '/a/$']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$}') + }) + it('suffix wildcard wins over plain wildcard', () => { + const tree = makeTree(['/a/{$}b', '/a/$']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{$}b') + }) + }) + + describe('prefix / suffix lengths', () => { + it('longer overlapping prefix wins over shorter prefix', () => { + const tree = makeTree(['/a/b{$b}', '/a/bbbb{$b}']) + expect(findRouteMatch('/a/bbbbb', tree)?.route.id).toBe('/a/bbbb{$b}') + }) + it('longer overlapping suffix wins over shorter suffix', () => { + const tree = makeTree(['/a/{$b}b', '/a/{$b}bbbb']) + expect(findRouteMatch('/a/bbbbb', tree)?.route.id).toBe('/a/{$b}bbbb') + }) + it('longer prefix and shorter suffix wins over shorter prefix and longer suffix', () => { + const tree = makeTree(['/a/b{$b}bbb', '/a/bbb{$b}b']) + expect(findRouteMatch('/a/bbbbb', tree)?.route.id).toBe('/a/bbb{$b}b') + }) + }) + + describe('root matches', () => { + it('optional at the root matches /', () => { + const tree = makeTree(['/{-$id}']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/{-$id}') + expect(res?.params).toEqual({}) + }) + it('wildcard at the root matches /', () => { + const tree = makeTree(['/$']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/$') + expect(res?.params).toEqual({ '*': '', _splat: '' }) + }) + it('dynamic at the root DOES NOT match /', () => { + const tree = makeTree(['/$id']) + const res = findRouteMatch('/', tree) + expect(res).toBeNull() + }) + }) + + describe('root matches with root index', () => { + it('root index wins over root optional', () => { + const tree = makeTree(['/', '/{-$id}']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/') + }) + it('root index wins over root wildcard', () => { + const tree = makeTree(['/', '/$']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/') + }) + it('root index wins over root dynamic', () => { + const tree = makeTree(['/', '/$id']) + const res = findRouteMatch('/', tree) + expect(res?.route.id).toBe('/') + }) + }) + + describe('edge-case variations', () => { + it('/static/optional/static vs /static/dynamic/static', () => { + const tree = makeTree(['/a/{-$b}/c', '/a/$b/c']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') + }) + it('/static/optional/dynamic vs /static/dynamic/static', () => { + const tree = makeTree(['/a/{-$b}/$c', '/a/$b/c']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/a/$b/c') + }) + it('/static/optional/static vs /static/dynamic', () => { + const tree = makeTree(['/users/{-$org}/settings', '/users/$id']) + expect(findRouteMatch('/users/settings', tree)?.route.id).toBe( + '/users/{-$org}/settings', + ) + }) + it('/optional/static/static vs /static/dynamic', () => { + const tree = makeTree(['/{-$other}/posts/new', '/posts/$id']) + expect(findRouteMatch('/posts/new', tree)?.route.id).toBe( + '/{-$other}/posts/new', + ) + }) + it('/optional/static/static vs /static/static', () => { + const tree = makeTree(['/{-$other}/posts/new', '/posts/new']) + expect(findRouteMatch('/posts/new', tree)?.route.id).toBe('/posts/new') + }) + it('/optional/static/static/dynamic vs /static/dynamic/static/dynamic', () => { + const tree = makeTree(['/{-$other}/posts/a/b/$c', '/posts/$a/b/$c']) + expect(findRouteMatch('/posts/a/b/c', tree)?.route.id).toBe( + '/{-$other}/posts/a/b/$c', + ) + }) + it('chain of optional and static segments: favor earlier static segments', () => { + const tree = makeTree([ + '/{-$a}/{-$b}/{-$c}/d/e', + '/{-$a}/{-$b}/c/d/{-$e}', + ]) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe( + '/{-$a}/{-$b}/c/d/{-$e}', + ) + }) + it('chain of dynamic and static segments: favor earlier static segments', () => { + const tree = makeTree(['/$a/$b/$c/d/e', '/$a/$b/c/d/$e']) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe( + '/$a/$b/c/d/$e', + ) + }) + + it('a short wildcard match does not prevent a longer match', () => { + const tree = makeTree(['/a/$', '/a/b/c/$']) + expect(findRouteMatch('/a/b/c/d/e', tree)?.route.id).toBe('/a/b/c/$') + expect(findRouteMatch('/a/d/e', tree)?.route.id).toBe('/a/$') + }) + }) + }) + + describe.todo('trailing slashes', () => {}) + + describe('case sensitivity competition', () => { + it('a case sensitive segment early on should not prevent a case insensitive match', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/Foo', + fullPath: '/Foo', + path: 'Foo', + options: { caseSensitive: false }, + children: [ + { + id: '/Foo/a', + fullPath: '/Foo/a', + path: '/a', + }, + ], + }, + { + id: '/foo', + fullPath: '/foo', + path: 'foo', + options: { caseSensitive: true }, + children: [ + { + id: '/foo/b', + fullPath: '/foo/b', + path: 'b', + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/foo/a', processedTree)?.route.id).toBe('/Foo/a') + expect(findRouteMatch('/foo/b', processedTree)?.route.id).toBe('/foo/b') + }) + it('a case sensitive segment should have priority over a case insensitive one', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/FOO', + fullPath: '/FOO', + path: 'FOO', + options: { caseSensitive: true }, + }, + { + id: '/foo', + fullPath: '/foo', + path: 'foo', + options: { caseSensitive: false }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/FOO', processedTree)?.route.id).toBe('/FOO') + expect(findRouteMatch('/Foo', processedTree)?.route.id).toBe('/foo') + expect(findRouteMatch('/foo', processedTree)?.route.id).toBe('/foo') + }) + it('a case sensitive prefix/suffix should have priority over a case insensitive one', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/aa{$id}bb', + fullPath: '/aa{$id}bb', + path: 'aa{$id}bb', + options: { caseSensitive: false }, + }, + { + id: '/A{$id}B', + fullPath: '/A{$id}B', + path: 'A{$id}B', + options: { caseSensitive: true }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/AfooB', processedTree)?.route.id).toBe('/A{$id}B') + expect(findRouteMatch('/AAABBB', processedTree)?.route.id).toBe( + '/A{$id}B', + ) + }) + }) + + describe('basic matching', () => { + it('root itself cannot match', () => { + const tree = makeTree([]) + expect(findRouteMatch('/', tree)).toBeNull() + }) + it('single static', () => { + const tree = makeTree(['/a']) + expect(findRouteMatch('/a', tree)?.route.id).toBe('/a') + }) + it('single dynamic', () => { + const tree = makeTree(['/$id']) + expect(findRouteMatch('/123', tree)?.route.id).toBe('/$id') + }) + it('single optional', () => { + const tree = makeTree(['/{-$id}']) + expect(findRouteMatch('/123', tree)?.route.id).toBe('/{-$id}') + expect(findRouteMatch('/', tree)?.route.id).toBe('/{-$id}') + }) + it('single wildcard', () => { + const tree = makeTree(['/$']) + expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/$') + }) + + describe('prefix / suffix variations', () => { + it('dynamic w/ prefix', () => { + const tree = makeTree(['/{$id}.txt']) + expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{$id}.txt') + }) + it('dynamic w/ suffix', () => { + const tree = makeTree(['/file{$id}']) + expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{$id}') + }) + it('dynamic w/ prefix and suffix', () => { + const tree = makeTree(['/file{$id}.txt']) + expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( + '/file{$id}.txt', + ) + }) + it('optional w/ prefix', () => { + const tree = makeTree(['/{-$id}.txt']) + expect(findRouteMatch('/123.txt', tree)?.route.id).toBe('/{-$id}.txt') + expect(findRouteMatch('/.txt', tree)?.route.id).toBe('/{-$id}.txt') + }) + it('optional w/ suffix', () => { + const tree = makeTree(['/file{-$id}']) + expect(findRouteMatch('/file123', tree)?.route.id).toBe('/file{-$id}') + expect(findRouteMatch('/file', tree)?.route.id).toBe('/file{-$id}') + }) + it('optional w/ prefix and suffix', () => { + const tree = makeTree(['/file{-$id}.txt']) + expect(findRouteMatch('/file123.txt', tree)?.route.id).toBe( + '/file{-$id}.txt', + ) + expect(findRouteMatch('/file.txt', tree)?.route.id).toBe( + '/file{-$id}.txt', + ) + }) + }) + + it('optional at the end can still be omitted', () => { + const tree = makeTree(['/a/{-$id}']) + expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$id}') + }) + it('multiple optionals at the end can still be omitted', () => { + const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}']) + expect(findRouteMatch('/a', tree)?.route.id).toBe('/a/{-$b}/{-$c}/{-$d}') + }) + it('multiple optionals at the end -> favor earlier segments', () => { + const tree = makeTree(['/a/{-$b}/{-$c}/{-$d}/{-$e}']) + expect(findRouteMatch('/a/b/c', tree)?.params).toEqual({ b: 'b', c: 'c' }) + }) + it('optional and wildcard at the end can still be omitted', () => { + const tree = makeTree(['/a/{-$id}/$']) + const result = findRouteMatch('/a', tree) + expect(result?.route.id).toBe('/a/{-$id}/$') + expect(result?.params).toEqual({ '*': '', _splat: '' }) + }) + it('multi-segment wildcard w/ prefix', () => { + const tree = makeTree(['/file{$}']) + expect(findRouteMatch('/file/a/b/c', tree)?.route.id).toBe('/file{$}') + }) + it('multi-segment wildcard w/ suffix', () => { + const tree = makeTree(['/{$}/c/file']) + expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/c/file') + }) + it('multi-segment wildcard w/ prefix and suffix', () => { + const tree = makeTree(['/file{$}end']) + expect(findRouteMatch('/file/a/b/c/end', tree)?.route.id).toBe( + '/file{$}end', + ) + }) + + it('edge-case: a single required param early on doesnt prevent another match further down', () => { + const tree = makeTree(['/$one/a/b', '/$two/a/c']) + expect(findRouteMatch('/1/a/b', tree)?.route.id).toBe('/$one/a/b') + expect(findRouteMatch('/2/a/c', tree)?.route.id).toBe('/$two/a/c') + }) + it('edge-case: a single static param early on doesnt prevent another match further down', () => { + const tree = makeTree(['/x/y/z', '/$id/y/w']) + expect(findRouteMatch('/x/y/z', tree)?.route.id).toBe('/x/y/z') + expect(findRouteMatch('/x/y/w', tree)?.route.id).toBe('/$id/y/w') + }) + it('edge-case: presence of a valid wildcard doesnt prevent other matches', () => { + const tree = makeTree(['/yo/foo{-$id}bar/ma', '/yo/$']) + const absent = findRouteMatch('/yo/foobar/ma', tree) + expect(absent?.route.id).toBe('/yo/foo{-$id}bar/ma') + const present = findRouteMatch('/yo/foo123bar/ma', tree) + expect(present?.route.id).toBe('/yo/foo{-$id}bar/ma') + }) + it('edge-case: ???', () => { + // This test comes from the previous processRouteTree tests. + // > This demonstrates that `/foo/{-$p}.tsx` will be matched, not `/foo/{-$p}/{-$x}.tsx` + // > This route has 1 optional parameter, making it more specific than the route with 2 + const tree = makeTree(['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx']) + expect(findRouteMatch('/foo', tree)?.route.id).toBe('/foo/{-$p}.tsx') + expect(findRouteMatch('/foo/bar.tsx', tree)?.route.id).toBe( + '/foo/{-$p}.tsx', + ) + }) + + it('edge-case: two competing nodes at the same depth still produce a valid segment tree', () => { + // this case is not easy to explain, but at some point in the implementation + // the presence of `/a/c/{$foo}suffix` made `processRouteTree` assign an incorrect `depth` + // value to the `/a/b/$` node, causing the params extraction to return incorrect results. + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/a/c/{$foo}suffix', + fullPath: '/a/c/{$foo}suffix', + path: 'a/c/{$foo}suffix', + }, + { + id: '/a/b/$', + fullPath: '/a/b/$', + path: 'a/b/$', + }, + ], + } + const { processedTree } = processRouteTree(tree) + const res = findRouteMatch('/a/b/foo', processedTree, true) + expect(res?.route.id).toBe('/a/b/$') + expect(res?.params).toEqual({ _splat: 'foo', '*': 'foo' }) + }) + }) + + describe('nested routes', () => { + const routeTree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/a', + fullPath: '/a', + path: 'a', + children: [ + { + id: '/a/b', + fullPath: '/a/b', + path: '/b', + children: [ + { + id: '/a/b/c/d', + fullPath: '/a/b/c/d', + path: '/c/d', + }, + ], + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(routeTree) + it('matches the deepest route', () => { + expect(findRouteMatch('/a/b/c/d', processedTree)?.route.id).toBe( + '/a/b/c/d', + ) + }) + it('matches an intermediate route', () => { + expect(findRouteMatch('/a/b', processedTree)?.route.id).toBe('/a/b') + }) + it('matches the root child route', () => { + expect(findRouteMatch('/a', processedTree)?.route.id).toBe('/a') + }) + it('nothing can match the root route', () => { + expect(findRouteMatch('/', processedTree)).toBeNull() + }) + it('does not match a route that doesnt exist', () => { + expect(findRouteMatch('/a/b/c', processedTree)).toBeNull() + }) + }) + + describe('not found / fuzzy matching', () => { + it('returns null when no match is found', () => { + const tree = makeTree(['/', '/a/b/c', '/d/e/f']) + expect(findRouteMatch('/x/y/z', tree)).toBeNull() + }) + + it('cannot consider the root route as a fuzzy match', () => { + const tree = makeTree(['/', '/a/b/c', '/d/e/f']) + const match = findRouteMatch('/x/y/z', tree, true) + expect(match).toBeNull() + }) + + it('finds the greatest partial match', () => { + const tree = makeTree(['/a/b/c', '/a/b', '/a']) + const match = findRouteMatch('/a/b/x/y', tree, true) + expect(match?.route?.id).toBe('/a/b') + expect(match?.params).toMatchInlineSnapshot(` + { + "**": "x/y", + } + `) + }) + + it('when both a layout route and an index route exist on the node that is fuzzy-matched, it uses the layout route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + }, + { + id: '/dashboard', + fullPath: '/dashboard', + path: 'dashboard', + children: [ + { + id: '/dashboard/', + fullPath: '/dashboard/', + path: '/', + }, + { + id: '/dashboard/invoices', + fullPath: '/dashboard/invoices', + path: 'invoices', + }, + { + id: '/dashboard/users', + fullPath: '/dashboard/users', + path: 'users', + }, + ], + }, + { + id: '/_auth', + fullPath: '/', + children: [ + { + id: '/_auth/profile', + fullPath: '/profile', + path: 'profile', + }, + ], + }, + ], + } + const processed = processRouteTree(tree) + const match = findRouteMatch( + '/dashboard/foo', + processed.processedTree, + true, + ) + expect(match?.route.id).toBe('/dashboard') + expect(match?.params).toEqual({ '**': 'foo' }) + }) + + it('cannot use an index route as a fuzzy match', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + }, + { + id: '/dashboard/', + fullPath: '/dashboard/', + path: 'dashboard/', + }, + { + id: '/dashboard', + fullPath: '/dashboard/invoices', + path: 'invoices', + }, + { + id: '/dashboard/users', + fullPath: '/dashboard/users', + path: 'users', + }, + ], + } + const processed = processRouteTree(tree) + const match = findRouteMatch( + '/dashboard/foo', + processed.processedTree, + true, + ) + expect(match).toBeNull() + }) + + it('edge-case: index route is not a child of layout route', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/', + fullPath: '/', + path: '/', + }, + { + id: '/dashboard/', + fullPath: '/dashboard/', + path: 'dashboard/', + }, + { + id: '/dashboard', + fullPath: '/dashboard', + path: 'dashboard', + }, + ], + } + const processed = processRouteTree(tree) + const match = findRouteMatch( + '/dashboard/foo', + processed.processedTree, + true, + ) + expect(match?.route.id).toBe('/dashboard') + expect(match?.params).toEqual({ '**': 'foo' }) + const actualMatch = findRouteMatch('/dashboard', processed.processedTree) + expect(actualMatch?.route.id).toBe('/dashboard/') + }) + }) + + describe('param extraction', () => { + describe('URI decoding', () => { + const URISyntaxCharacters = [ + [';', '%3B'], + [',', '%2C'], + ['/', '%2F'], + ['?', '%3F'], + [':', '%3A'], + ['@', '%40'], + ['&', '%26'], + ['=', '%3D'], + ['+', '%2B'], + ['$', '%24'], + ['#', '%23'], + ['\\', '%5C'], + ['%', '%25'], + ] as const + it.each(URISyntaxCharacters)( + 'decodes %s in dynamic params', + (char, encoded) => { + const tree = makeTree([`/a/$id`]) + const result = findRouteMatch(`/a/${encoded}`, tree) + expect(result?.params).toEqual({ id: char }) + }, + ) + it.each(URISyntaxCharacters)( + 'decodes %s in optional params', + (char, encoded) => { + const tree = makeTree([`/a/{-$id}`]) + const result = findRouteMatch(`/a/${encoded}`, tree) + expect(result?.params).toEqual({ id: char }) + }, + ) + it.each(URISyntaxCharacters)( + 'decodes %s in wildcard params', + (char, encoded) => { + const tree = makeTree([`/a/$`]) + const result = findRouteMatch(`/a/${encoded}`, tree) + expect(result?.params).toEqual({ '*': char, _splat: char }) + }, + ) + it('wildcard splat supports multiple URI encoded characters in multiple URL segments', () => { + const tree = makeTree([`/a/$`]) + const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') + const decoded = URISyntaxCharacters.map(([char]) => char).join('/') + const result = findRouteMatch(`/a/${path}`, tree) + expect(result?.params).toEqual({ '*': decoded, _splat: decoded }) + }) + it('fuzzy splat supports multiple URI encoded characters in multiple URL segments', () => { + const tree = makeTree(['/a']) + const path = URISyntaxCharacters.map(([, encoded]) => encoded).join('/') + const decoded = URISyntaxCharacters.map(([char]) => char).join('/') + const result = findRouteMatch(`/a/${path}`, tree, true) + expect(result?.params).toEqual({ '**': decoded }) + }) + }) + }) +}) + +describe.todo('processRouteMasks', () => { + it('processes a route masks list', () => { + const routeTree = {} as AnyRoute + const routeMasks: Array> = [ + { from: '/a/b/c', routeTree }, + { from: '/a/b/d', routeTree }, + { from: '/a/$param/d', routeTree }, + { from: '/a/{-$optional}/d', routeTree }, + { from: '/a/b/{$}.txt', routeTree }, + ] + // expect(processRouteMasks(routeMasks)).toMatchInlineSnapshot() + }) +}) diff --git a/packages/router-core/tests/optional-path-params-clean.test.ts b/packages/router-core/tests/optional-path-params-clean.test.ts index f9cbd0c4fd9..d8c1411e96b 100644 --- a/packages/router-core/tests/optional-path-params-clean.test.ts +++ b/packages/router-core/tests/optional-path-params-clean.test.ts @@ -1,42 +1,82 @@ import { describe, expect, it } from 'vitest' +import { interpolatePath } from '../src/path' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PATHNAME, - interpolatePath, - matchPathname, - parsePathname, -} from '../src/path' + findSingleMatch, + parseSegment, + processRouteTree, +} from '../src/new-process-route-tree' +import type { SegmentKind } from '../src/new-process-route-tree' describe('Optional Path Parameters - Clean Comprehensive Tests', () => { describe('Optional Dynamic Parameters {-$param}', () => { describe('parsePathname', () => { + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + // Indicates if there is a static segment after this required/optional param + hasStaticAfter?: boolean + } + + const parsePathname = (to: string | undefined) => { + let cursor = 0 + let data + const path = to ?? '' + const segments: Array = [] + while (cursor < path.length) { + const start = cursor + data = parseSegment(path, start, data) + const end = data[5] + cursor = end + 1 + const type = data[0] + const value = path.substring(data[2], data[3]) + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4], end) + const segment: PathSegment = { + type, + value, + } + if (prefix) { + segment.prefixSegment = prefix + } + if (suffix) { + segment.suffixSegment = suffix + } + segments.push(segment) + } + return segments + } + it('should parse single optional dynamic param', () => { const result = parsePathname('/posts/{-$category}') expect(result).toEqual([ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'posts' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, ]) }) it('should parse multiple optional dynamic params', () => { const result = parsePathname('/posts/{-$category}/{-$slug}') expect(result).toEqual([ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'posts' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ]) }) it('should handle prefix/suffix with optional dynamic params', () => { const result = parsePathname('/api/v{-$version}/data') expect(result).toEqual([ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'api' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$version', + value: 'version', prefixSegment: 'v', suffixSegment: undefined, }, @@ -98,6 +138,27 @@ describe('Optional Path Parameters - Clean Comprehensive Tests', () => { }) describe('matchPathname', () => { + const { processedTree } = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + }) + const matchPathname = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, + ) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + const result = match ? match.params : undefined + if (options.to && !result) return + return result ?? {} + } it('should match optional dynamic params when present', () => { const result = matchPathname('/posts/tech', { to: '/posts/{-$category}', diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index bf54607300c..79a368eb520 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -1,16 +1,23 @@ import { describe, expect, it } from 'vitest' +import { interpolatePath } from '../src/path' import { SEGMENT_TYPE_OPTIONAL_PARAM, SEGMENT_TYPE_PARAM, SEGMENT_TYPE_PATHNAME, SEGMENT_TYPE_WILDCARD, - interpolatePath, - matchPathname, - parsePathname, -} from '../src/path' -import type { Segment as PathSegment } from '../src/path' + findSingleMatch, + parseSegment, + processRouteTree, +} from '../src/new-process-route-tree' +import type { SegmentKind } from '../src/new-process-route-tree' describe('Optional Path Parameters', () => { + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + } type ParsePathnameTestScheme = Array<{ name: string to: string | undefined @@ -18,23 +25,51 @@ describe('Optional Path Parameters', () => { }> describe('parsePathname with optional params', () => { + const parsePathname = (to: string | undefined) => { + let cursor = 0 + let data + const path = to ?? '' + const segments: Array = [] + while (cursor < path.length) { + const start = cursor + data = parseSegment(path, start, data) + const end = data[5] + cursor = end + 1 + const type = data[0] + const value = path.substring(data[2], data[3]) + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4], end) + const segment: PathSegment = { + type, + value, + } + if (prefix) { + segment.prefixSegment = prefix + } + if (suffix) { + segment.suffixSegment = suffix + } + segments.push(segment) + } + return segments + } it.each([ { name: 'regular optional param', to: '/{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ], }, { name: 'optional param with prefix', to: '/prefix{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$slug', + value: 'slug', prefixSegment: 'prefix', }, ], @@ -43,10 +78,10 @@ describe('Optional Path Parameters', () => { name: 'optional param with suffix', to: '/{-$slug}suffix', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$slug', + value: 'slug', suffixSegment: 'suffix', }, ], @@ -55,10 +90,10 @@ describe('Optional Path Parameters', () => { name: 'optional param with prefix and suffix', to: '/prefix{-$slug}suffix', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_OPTIONAL_PARAM, - value: '$slug', + value: 'slug', prefixSegment: 'prefix', suffixSegment: 'suffix', }, @@ -68,38 +103,38 @@ describe('Optional Path Parameters', () => { name: 'multiple optional params', to: '/posts/{-$category}/{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'posts' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ], }, { name: 'mixed required and optional params', to: '/users/$id/{-$tab}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'users' }, - { type: SEGMENT_TYPE_PARAM, value: '$id' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$tab' }, + { type: SEGMENT_TYPE_PARAM, value: 'id' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'tab' }, ], }, { name: 'optional param followed by required param', to: '/{-$category}/$slug', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$category' }, - { type: SEGMENT_TYPE_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'category' }, + { type: SEGMENT_TYPE_PARAM, value: 'slug' }, ], }, { name: 'optional param with wildcard', to: '/docs/{-$version}/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'docs' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$version' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'version' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -107,12 +142,12 @@ describe('Optional Path Parameters', () => { name: 'complex path with all param types', to: '/api/{-$version}/users/$id/{-$tab}/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'api' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$version' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'version' }, { type: SEGMENT_TYPE_PATHNAME, value: 'users' }, - { type: SEGMENT_TYPE_PARAM, value: '$id' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$tab' }, + { type: SEGMENT_TYPE_PARAM, value: 'id' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'tab' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -120,18 +155,18 @@ describe('Optional Path Parameters', () => { name: 'optional param at root', to: '/{-$slug}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$slug' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'slug' }, ], }, { name: 'multiple consecutive optional params', to: '/{-$year}/{-$month}/{-$day}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$year' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$month' }, - { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: '$day' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'year' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'month' }, + { type: SEGMENT_TYPE_OPTIONAL_PARAM, value: 'day' }, ], }, ] satisfies ParsePathnameTestScheme)('$name', ({ to, expected }) => { @@ -296,6 +331,28 @@ describe('Optional Path Parameters', () => { }) }) + const { processedTree } = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + }) + const matchPathname = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, + ) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + const result = match ? match.params : undefined + if (options.to && !result) return + return result ?? {} + } + describe('matchPathname with optional params', () => { it.each([ { diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 20eefe3d6cc..6503eb0b755 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -1,17 +1,20 @@ import { describe, expect, it } from 'vitest' import { - SEGMENT_TYPE_PARAM, - SEGMENT_TYPE_PATHNAME, - SEGMENT_TYPE_WILDCARD, exactPathTest, interpolatePath, - matchPathname, - parsePathname, removeTrailingSlash, resolvePath, trimPathLeft, } from '../src/path' -import type { Segment as PathSegment } from '../src/path' +import { + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + findSingleMatch, + parseSegment, + processRouteTree, +} from '../src/new-process-route-tree' +import type { SegmentKind } from '../src/new-process-route-tree' describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', @@ -356,6 +359,41 @@ describe('interpolatePath', () => { }) }) + describe('preserve trailing slash', () => { + it.each([ + { + path: '/', + params: {}, + result: '/', + }, + { + path: '/a/b/', + params: {}, + result: '/a/b/', + }, + { + path: '/a/$id/', + params: { id: '123' }, + result: '/a/123/', + }, + { + path: '/a/{-$id}/', + params: { id: '123' }, + result: '/a/123/', + }, + ])( + 'should preserve trailing slash for $path', + ({ path, params, result }) => { + expect( + interpolatePath({ + path, + params, + }).interpolatedPath, + ).toBe(result) + }, + ) + }) + describe('wildcard (prefix + suffix)', () => { it.each([ { @@ -499,7 +537,54 @@ describe('interpolatePath', () => { }) }) +describe('resolvePath + interpolatePath', () => { + it.each(['never', 'preserve', 'always'] as const)( + 'trailing slash: %s', + (trailingSlash) => { + const tail = trailingSlash === 'always' ? '/' : '' + const defaultedFromPath = '/' + const fromPath = resolvePath({ + base: defaultedFromPath, + to: '.', + trailingSlash, + }) + const nextTo = resolvePath({ + base: fromPath, + to: '/splat/$', + trailingSlash, + }) + const nextParams = { _splat: '' } + const interpolatedNextTo = interpolatePath({ + path: nextTo, + params: nextParams, + }).interpolatedPath + expect(interpolatedNextTo).toBe(`/splat${tail}`) + }, + ) +}) + describe('matchPathname', () => { + const { processedTree } = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + }) + const matchPathname = ( + from: string, + options: { to: string; caseSensitive?: boolean; fuzzy?: boolean }, + ) => { + const match = findSingleMatch( + options.to, + options.caseSensitive ?? false, + options.fuzzy ?? false, + from, + processedTree, + ) + const result = match ? match.params : undefined + if (options.to && !result) return + return result ?? {} + } describe('path param(s) matching', () => { it.each([ { @@ -732,6 +817,42 @@ describe('parsePathname', () => { expected: Array }> + type PathSegment = { + type: SegmentKind + value: string + prefixSegment?: string + suffixSegment?: string + } + + const parsePathname = (to: string | undefined) => { + let cursor = 0 + let data + const path = to ?? '' + const segments: Array = [] + while (cursor < path.length) { + const start = cursor + data = parseSegment(path, start, data) + const end = data[5] + cursor = end + 1 + const type = data[0] + const value = path.substring(data[2], data[3]) + const prefix = path.substring(start, data[1]) + const suffix = path.substring(data[4], end) + const segment: PathSegment = { + type, + value, + } + if (prefix) { + segment.prefixSegment = prefix + } + if (suffix) { + segment.suffixSegment = suffix + } + segments.push(segment) + } + return segments + } + describe('regular usage', () => { it.each([ { @@ -747,13 +868,13 @@ describe('parsePathname', () => { { name: 'should handle pathname at root', to: '/', - expected: [{ type: SEGMENT_TYPE_PATHNAME, value: '/' }], + expected: [{ type: SEGMENT_TYPE_PATHNAME, value: '' }], }, { name: 'should handle pathname with a single segment', to: '/foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, ], }, @@ -761,7 +882,7 @@ describe('parsePathname', () => { name: 'should handle pathname with multiple segments', to: '/foo/bar/baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, { type: SEGMENT_TYPE_PATHNAME, value: 'bar' }, { type: SEGMENT_TYPE_PATHNAME, value: 'baz' }, @@ -771,35 +892,34 @@ describe('parsePathname', () => { name: 'should handle pathname with a trailing slash', to: '/foo/', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, ], }, { name: 'should handle named params', to: '/foo/$bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'should handle named params at the root', to: '/$bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'should handle named params followed by a segment', to: '/foo/$bar/baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, { type: SEGMENT_TYPE_PATHNAME, value: 'baz' }, ], }, @@ -807,19 +927,19 @@ describe('parsePathname', () => { name: 'should handle multiple named params', to: '/foo/$bar/$baz/qux/$quux', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, - { type: SEGMENT_TYPE_PARAM, value: '$baz' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, + { type: SEGMENT_TYPE_PARAM, value: 'baz' }, { type: SEGMENT_TYPE_PATHNAME, value: 'qux' }, - { type: SEGMENT_TYPE_PARAM, value: '$quux' }, + { type: SEGMENT_TYPE_PARAM, value: 'quux' }, ], }, { name: 'should handle splat params', to: '/foo/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PATHNAME, value: 'foo' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], @@ -828,7 +948,7 @@ describe('parsePathname', () => { name: 'should handle splat params at the root', to: '/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -844,7 +964,7 @@ describe('parsePathname', () => { name: 'regular', to: '/$', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -852,7 +972,7 @@ describe('parsePathname', () => { name: 'regular curly braces', to: '/{$}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$' }, ], }, @@ -860,7 +980,7 @@ describe('parsePathname', () => { name: 'with prefix (regular text)', to: '/foo{$}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -872,7 +992,7 @@ describe('parsePathname', () => { name: 'with prefix + followed by special character', to: '/foo.{$}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -884,7 +1004,7 @@ describe('parsePathname', () => { name: 'with suffix', to: '/{$}-foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -896,7 +1016,7 @@ describe('parsePathname', () => { name: 'with prefix + suffix', to: '/foo{$}-bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', @@ -909,13 +1029,13 @@ describe('parsePathname', () => { name: 'with prefix + followed by special character and a segment', to: '/foo.{$}/bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_WILDCARD, value: '$', prefixSegment: 'foo.', + suffixSegment: '/bar', }, - { type: SEGMENT_TYPE_PATHNAME, value: 'bar' }, ], }, ] satisfies ParsePathnameTestScheme)('$name', ({ to, expected }) => { @@ -930,26 +1050,26 @@ describe('parsePathname', () => { name: 'regular', to: '/$bar', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'regular curly braces', to: '/{$bar}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, - { type: SEGMENT_TYPE_PARAM, value: '$bar' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, + { type: SEGMENT_TYPE_PARAM, value: 'bar' }, ], }, { name: 'with prefix (regular text)', to: '/foo{$bar}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', prefixSegment: 'foo', }, ], @@ -958,10 +1078,10 @@ describe('parsePathname', () => { name: 'with prefix + followed by special character', to: '/foo.{$bar}', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', prefixSegment: 'foo.', }, ], @@ -970,10 +1090,10 @@ describe('parsePathname', () => { name: 'with suffix', to: '/{$bar}.foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', suffixSegment: '.foo', }, ], @@ -982,10 +1102,10 @@ describe('parsePathname', () => { name: 'with suffix + started by special character', to: '/{$bar}.foo', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', suffixSegment: '.foo', }, ], @@ -994,10 +1114,10 @@ describe('parsePathname', () => { name: 'with suffix + started by special character and followed by segment', to: '/{$bar}.foo/baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', suffixSegment: '.foo', }, { type: SEGMENT_TYPE_PATHNAME, value: 'baz' }, @@ -1007,10 +1127,10 @@ describe('parsePathname', () => { name: 'with suffix + prefix', to: '/foo{$bar}.baz', expected: [ - { type: SEGMENT_TYPE_PATHNAME, value: '/' }, + { type: SEGMENT_TYPE_PATHNAME, value: '' }, { type: SEGMENT_TYPE_PARAM, - value: '$bar', + value: 'bar', prefixSegment: 'foo', suffixSegment: '.baz', }, diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts deleted file mode 100644 index 3a509a3376f..00000000000 --- a/packages/router-core/tests/processRouteTree.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { processRouteTree } from '../src/process-route-tree' -import { getMatchedRoutes } from '../src/router' -import { joinPaths } from '../src' -import type { RouteLike } from '../src/route' - -type PathOrChildren = string | [string, Array] - -function createRoute( - pathOrChildren: Array, - parentPath: string, -): Array { - return pathOrChildren.map((route) => { - if (Array.isArray(route)) { - const fullPath = joinPaths([parentPath, route[0]]) - const children = createRoute(route[1], fullPath) - const r = { - id: fullPath, - path: route[0], - fullPath, - children: children, - } - children.forEach((child) => { - child.parentRoute = r - }) - - return r - } - - const fullPath = joinPaths([parentPath, route]) - - return { - id: fullPath, - path: route, - fullPath, - } - }) -} - -function createRouteTree(pathOrChildren: Array): RouteLike { - return { - id: '__root__', - fullPath: '', - isRoot: true, - path: undefined, - children: createRoute(pathOrChildren, ''), - } -} - -describe('processRouteTree', () => { - describe('basic functionality', () => { - it('should process a simple route tree', () => { - const routeTree = createRouteTree(['/', '/about']) - - const result = processRouteTree({ routeTree }) - - expect(result.routesById).toHaveProperty('__root__') - expect(result.routesById).toHaveProperty('/') - expect(result.routesById).toHaveProperty('/about') - expect(result.routesByPath).toHaveProperty('/') - expect(result.routesByPath).toHaveProperty('/about') - expect(result.flatRoutes).toHaveLength(2) // excludes root - }) - - it('should assign ranks to routes in flatRoutes', () => { - const routeTree = createRouteTree(['/', '/about']) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes[0]).toHaveProperty('rank', 0) - expect(result.flatRoutes[1]).toHaveProperty('rank', 1) - }) - }) - - describe('route ranking - static segments vs params', () => { - it('should rank static segments higher than param segments', () => { - const routes = ['/users/profile', '/users/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - expect(result.flatRoutes[0]!.rank).toBe(0) - expect(result.flatRoutes[1]!.rank).toBe(1) - }) - - it('should rank static segments higher than optional params', () => { - const routes = ['/users/settings', '/users/{-$id}'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank static segments higher than wildcards', () => { - const routes = ['/api/v1', '/api/$'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - param variations', () => { - it('should rank params higher than optional params', () => { - const routes = ['/users/$id', '/users/{-$id}'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank optional params higher than wildcards', () => { - const routes = ['/files/{-$path}', '/files/$'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - prefix and suffix variations', () => { - it('should rank param with prefix and suffix higher than plain param', () => { - const routes = ['/user/prefix-{$id}-suffix', '/user/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank param with prefix higher than plain param', () => { - const routes = ['/user/prefix-{$id}', '/user/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank param with suffix higher than plain param', () => { - const routes = ['/user/{$id}-suffix', '/user/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - path length priority', () => { - it('should rank longer paths higher when segment scores are equal', () => { - const routes = ['/api/v1', '/api'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank longer param paths higher', () => { - const routes = ['/users/$id', '/$id'] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank index route higher than root catch-all', () => { - const routes = ['/', '/$'] // index route should rank higher than catch-all - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - - it('should rank routes with fewer optional parameters higher (more specific)', () => { - const routes = [ - '/foo', // most specific: exact match only - '/foo/{-$p1}', // less specific: matches /foo or /foo/x - '/foo/{-$p1}/{-$p2}', // least specific: matches /foo or /foo/x or /foo/x/y - ] - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - alphabetical ordering', () => { - it('should sort alphabetically when scores and lengths are equal', () => { - const routes = ['/apple', '/middle', '/zebra'] // in expected alphabetical order - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('route ranking - original index fallback', () => { - it('should use original index when all other criteria are equal', () => { - const routes = ['/first', '/second'] // in expected order (original index determines ranking) - const routeTree = createRouteTree(routes) - - const result = processRouteTree({ routeTree }) - - expect(result.flatRoutes.map((r) => r.id)).toEqual(routes) - }) - }) - - describe('complex routing scenarios', () => { - it('should correctly rank a complex mix of route types', () => { - // Define routes in expected ranking order - createRouteTree will shuffle them to test sorting - const expectedOrder = [ - '/users/profile/settings', // static-deep (longest static path) - '/users/profile', // static-medium (medium static path) - '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) - '/users/$id', // param-simple (plain param) - '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) - '/files/$', // wildcard (lowest priority) - '/about', // static-shallow (shorter static path) - ] - - const routeTree = createRouteTree(expectedOrder) - const result = processRouteTree({ routeTree }) - const actualOrder = result.flatRoutes.map((r) => r.id) - - expect(actualOrder).toEqual(expectedOrder) - }) - }) - - describe('route matching with optional parameters', () => { - it('should match the most specific route when multiple routes could match', () => { - const routes = ['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx'] - const routeTree = createRouteTree(routes) - const result = processRouteTree({ routeTree }) - - // Verify the ranking - fewer optional parameters should rank higher - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/foo/{-$p}.tsx', - '/foo/{-$p}/{-$x}.tsx', - ]) - - // The first route in flatRoutes is what will be matched for `/foo` - // This demonstrates that `/foo/{-$p}.tsx` will be matched, not `/foo/{-$p}/{-$x}.tsx` - const firstMatchingRoute = result.flatRoutes[0]! - expect(firstMatchingRoute.id).toBe('/foo/{-$p}.tsx') - - // This route has 1 optional parameter, making it more specific than the route with 2 - expect(firstMatchingRoute.fullPath).toBe('/foo/{-$p}.tsx') - }) - - it('should demonstrate matching priority for complex optional parameter scenarios', () => { - const routes = [ - '/foo/{-$a}', // 1 optional param - '/foo/{-$a}/{-$b}', // 2 optional params - '/foo/{-$a}/{-$b}/{-$c}', // 3 optional params - '/foo/bar', // static route (should rank highest) - '/foo/bar/{-$x}', // static + 1 optional - ] - const routeTree = createRouteTree(routes) - const result = processRouteTree({ routeTree }) - - // Expected ranking from most to least specific: - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/foo/bar', // Static route wins - '/foo/bar/{-$x}', // Static + optional - '/foo/{-$a}', // Fewest optional params (1) - '/foo/{-$a}/{-$b}', // More optional params (2) - '/foo/{-$a}/{-$b}/{-$c}', // Most optional params (3) - ]) - - // For path `/foo/bar` - static route would match - // For path `/foo/anything` - `/foo/{-$a}` would match (not the routes with more optional params) - // For path `/foo` - `/foo/{-$a}` would match (optional param omitted) - }) - - it('should demonstrate actual path matching behavior', () => { - const routes = ['/foo/{-$p}.tsx', '/foo/{-$p}/{-$x}.tsx'] - const routeTree = createRouteTree(routes) - const result = processRouteTree({ routeTree }) - - // Test actual path matching for `/foo` - const matchResult = getMatchedRoutes({ - pathname: '/foo', - caseSensitive: false, - routesByPath: result.routesByPath, - routesById: result.routesById, - flatRoutes: result.flatRoutes, - }) - - // The foundRoute should be the more specific one (fewer optional parameters) - expect(matchResult.foundRoute?.id).toBe('/foo/{-$p}.tsx') - - // The matched route should be included in the route hierarchy - expect(matchResult.matchedRoutes.map((r) => r.id)).toContain( - '/foo/{-$p}.tsx', - ) - - // Parameters should show the optional parameter as undefined when omitted - expect(matchResult.routeParams).toEqual({ p: undefined }) - }) - }) - - describe('edge cases', () => { - it('should handle root route correctly', () => { - const routeTree = createRouteTree([]) - - const result = processRouteTree({ routeTree }) - - expect(result.routesById).toHaveProperty('__root__') - expect(result.flatRoutes).toHaveLength(0) // root is excluded from flatRoutes - }) - - it('should handle routes without paths', () => { - // This test case is more complex as it involves layout routes - // For now, let's use a simpler approach with createRouteTree - const routeTree = createRouteTree(['/layout/child']) - - const result = processRouteTree({ routeTree }) - - expect(result.routesById).toHaveProperty('/layout/child') - expect(result.flatRoutes).toHaveLength(1) - expect(result.flatRoutes[0]!.id).toBe('/layout/child') - }) - - it('should handle trailing slashes in routesByPath', () => { - const routeTree = createRouteTree(['/test', '/test/']) // without slash first - - const result = processRouteTree({ routeTree }) - - // Route with trailing slash should take precedence in routesByPath - expect(result.routesByPath['/test']).toBeDefined() - }) - - it('routes with same optional count but different static segments', () => { - const routes = [ - '/a/{-$p1}/b/{-$p1}/{-$p1}/{-$p1}', - '/b/{-$p1}/{-$p1}/{-$p1}/{-$p1}', - ] - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - - // Route with more static segments (/a/{-$p1}/b) should rank higher - // than route with fewer static segments (/b) - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/a/{-$p1}/b/{-$p1}/{-$p1}/{-$p1}', - '/b/{-$p1}/{-$p1}/{-$p1}/{-$p1}', - ]) - }) - - it('routes with different optional counts and different static segments', () => { - const routes = [ - '/foo/{-$p1}/foo/{-$p1}/{-$p1}/{-$p1}', - '/foo/{-$p1}/{-$p1}', - ] - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - - // Both routes share common prefix '/foo/{-$p1}', then differ - // Route 1: '/foo/{-$p1}/b/{-$p1}/{-$p1}/{-$p1}' - has static '/b' at position 2, total 4 optional params - // Route 2: '/foo/{-$p1}/{-$p1}' - has optional param at position 2, total 2 optional params - // Since position 2 differs (static vs optional), static should win - expect(result.flatRoutes.map((r) => r.id)).toEqual([ - '/foo/{-$p1}/foo/{-$p1}/{-$p1}/{-$p1}', - '/foo/{-$p1}/{-$p1}', - ]) - }) - - it.each([ - { - routes: ['/foo/{-$p1}/bar', '/foo/{-$p1}', '/foo/$p1', '/foo/$p1/'], - expected: ['/foo/{-$p1}/bar', '/foo/$p1/', '/foo/$p1', '/foo/{-$p1}'], - }, - { - routes: ['/foo/{-$p1}/{-$p2}/bar', '/foo/$p1/$p2/'], - expected: ['/foo/{-$p1}/{-$p2}/bar', '/foo/$p1/$p2/'], - }, - { - routes: ['/foo/{-$p1}/$p2/bar', '/foo/$p1/{-$p2}/bar'], - expected: ['/foo/$p1/{-$p2}/bar', '/foo/{-$p1}/$p2/bar'], - }, - { - routes: [ - '/foo', - '/admin-levels/$adminLevelId/', - '/admin-levels/$adminLevelId', - '/about', - '/admin-levels/{-$adminLevelId}/reports', - '/admin-levels/{-$adminLevelId}', - '/', - ], - expected: [ - '/admin-levels/{-$adminLevelId}/reports', - '/admin-levels/$adminLevelId/', - '/admin-levels/$adminLevelId', - '/about', - '/foo', - '/admin-levels/{-$adminLevelId}', - '/', - ], - }, - ])( - 'static segment after param ranks param higher: $routes', - ({ routes, expected }) => { - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - expect(result.flatRoutes.map((r) => r.id)).toEqual(expected) - }, - ) - - it.each([ - { - routes: ['/f{$param}', '/foo{$param}'], - expected: ['/foo{$param}', '/f{$param}'], - }, - { - routes: ['/{$param}r', '/{$param}bar'], - expected: ['/{$param}bar', '/{$param}r'], - }, - { - routes: ['/f{$param}bar', '/foo{$param}r'], - expected: ['/foo{$param}r', '/f{$param}bar'], - }, - { - routes: ['/foo{$param}r', '/f{$param}baaaaaar'], // very long suffix can "override" prefix - expected: ['/f{$param}baaaaaar', '/foo{$param}r'], - }, - ])( - 'length of prefix and suffix are considered in ranking: $routes', - ({ routes, expected }) => { - const result = processRouteTree({ routeTree: createRouteTree(routes) }) - expect(result.flatRoutes.map((r) => r.id)).toEqual(expected) - }, - ) - }) -}) diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index daafd227c68..67e67877d35 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -338,7 +338,6 @@ export const BaseTanStackRouterDevtoolsPanel = 'state', 'routesById', 'routesByPath', - 'flatRoutes', 'options', 'manifest', ] as const diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index 3af5d70dec9..a5771ebee7e 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -1,5 +1,5 @@ import * as template from '@babel/template' -import type { AnyRoute, AnyRouteMatch } from '@tanstack/router-core' +import type { AnyRoute, AnyRouteMatch, AnyRouter } from '@tanstack/router-core' type AnyRouteWithPrivateProps = AnyRoute & { _path: string @@ -22,10 +22,12 @@ function handleRouteUpdate( const router = window.__TSR_ROUTER__! router.routesById[newRoute.id] = newRoute router.routesByPath[newRoute.fullPath] = newRoute - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute) - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute - } + router.processedTree.matchCache.clear() + router.processedTree.flatCache?.clear() + router.processedTree.singleCache.clear() + router.resolvePathCache.clear() + // TODO: how to rebuild the tree if we add a new route? + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree) const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id if ( router.state.matches.find(filter) || @@ -33,6 +35,21 @@ function handleRouteUpdate( ) { router.invalidate({ filter }) } + function walkReplaceSegmentTree( + route: AnyRouteWithPrivateProps, + node: AnyRouter['processedTree']['segmentTree'], + ) { + if (node.route?.id === route.id) node.route = route + if (node.notFound?.id === route.id) node.notFound = route + + node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.staticInsensitive?.forEach((child) => + walkReplaceSegmentTree(route, child), + ) + node.dynamic?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.optional?.forEach((child) => walkReplaceSegmentTree(route, child)) + node.wildcard?.forEach((child) => walkReplaceSegmentTree(route, child)) + } } export const routeHmrStatement = template.statement( diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index b604359d348..f80eab81e9f 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -20,17 +20,27 @@ if (import.meta.hot) { const router = window.__TSR_ROUTER__; router.routesById[newRoute.id] = newRoute; router.routesByPath[newRoute.fullPath] = newRoute; - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute); - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute; - } - ; + router.processedTree.matchCache.clear(); + router.processedTree.flatCache?.clear(); + router.processedTree.singleCache.clear(); + router.resolvePathCache.clear(); + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { router.invalidate({ filter }); } + ; + function walkReplaceSegmentTree(route, node) { + if (node.route?.id === route.id) node.route = route; + if (node.notFound?.id === route.id) node.notFound = route; + node.static?.forEach(child => walkReplaceSegmentTree(route, child)); + node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); + node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); + node.optional?.forEach(child => walkReplaceSegmentTree(route, child)); + node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child)); + } })(Route, newModule.Route); } }); diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 63ec83eb3aa..1664f20bbbc 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -20,17 +20,27 @@ if (import.meta.hot) { const router = window.__TSR_ROUTER__; router.routesById[newRoute.id] = newRoute; router.routesByPath[newRoute.fullPath] = newRoute; - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute); - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute; - } - ; + router.processedTree.matchCache.clear(); + router.processedTree.flatCache?.clear(); + router.processedTree.singleCache.clear(); + router.resolvePathCache.clear(); + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { router.invalidate({ filter }); } + ; + function walkReplaceSegmentTree(route, node) { + if (node.route?.id === route.id) node.route = route; + if (node.notFound?.id === route.id) node.notFound = route; + node.static?.forEach(child => walkReplaceSegmentTree(route, child)); + node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); + node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); + node.optional?.forEach(child => walkReplaceSegmentTree(route, child)); + node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child)); + } })(Route, newModule.Route); } }); diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index a06fcfefd56..e140124caa2 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -19,17 +19,27 @@ if (import.meta.hot) { const router = window.__TSR_ROUTER__; router.routesById[newRoute.id] = newRoute; router.routesByPath[newRoute.fullPath] = newRoute; - const oldRouteIndex = router.flatRoutes.indexOf(oldRoute); - if (oldRouteIndex > -1) { - router.flatRoutes[oldRouteIndex] = newRoute; - } - ; + router.processedTree.matchCache.clear(); + router.processedTree.flatCache?.clear(); + router.processedTree.singleCache.clear(); + router.resolvePathCache.clear(); + walkReplaceSegmentTree(newRoute, router.processedTree.segmentTree); const filter = m => m.routeId === oldRoute.id; if (router.state.matches.find(filter) || router.state.pendingMatches?.find(filter)) { router.invalidate({ filter }); } + ; + function walkReplaceSegmentTree(route, node) { + if (node.route?.id === route.id) node.route = route; + if (node.notFound?.id === route.id) node.notFound = route; + node.static?.forEach(child => walkReplaceSegmentTree(route, child)); + node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); + node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); + node.optional?.forEach(child => walkReplaceSegmentTree(route, child)); + node.wildcard?.forEach(child => walkReplaceSegmentTree(route, child)); + } })(Route, newModule.Route); } }); diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index f3e4b635bef..6d0c31f2fce 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -8,10 +8,7 @@ export { trimPathRight, trimPath, resolvePath, - parsePathname, interpolatePath, - matchPathname, - matchByPath, rootRouteId, defaultSerializeError, defaultParseSearch, @@ -36,7 +33,6 @@ export type { RemoveTrailingSlashes, RemoveLeadingSlashes, ActiveOptions, - Segment, ResolveRelativePath, RootRouteId, AnyPathParams, diff --git a/packages/solid-router/src/useBlocker.tsx b/packages/solid-router/src/useBlocker.tsx index 1fef61a517f..61b942de435 100644 --- a/packages/solid-router/src/useBlocker.tsx +++ b/packages/solid-router/src/useBlocker.tsx @@ -186,10 +186,7 @@ export function useBlocker( location: HistoryLocation, ): AnyShouldBlockFnLocation { const parsedLocation = router.parseLocation(location) - const matchedRoutes = router.getMatchedRoutes( - parsedLocation.pathname, - undefined, - ) + const matchedRoutes = router.getMatchedRoutes(parsedLocation.pathname) if (matchedRoutes.foundRoute === undefined) { throw new Error(`No route found for location ${location.href}`) } diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx index 394980c234b..63347bd1ff0 100644 --- a/packages/solid-router/tests/Matches.test.tsx +++ b/packages/solid-router/tests/Matches.test.tsx @@ -275,8 +275,8 @@ describe('matching on different param types', () => { name: 'optional param with prefix and no value', path: '/optional-{-$id}', nav: '/optional-', - params: { id: '' }, - matchParams: { id: '' }, + params: {}, + matchParams: {}, }, { name: 'optional param with suffix and value', @@ -289,15 +289,15 @@ describe('matching on different param types', () => { name: 'optional param with suffix and no value', path: '/{-$id}-optional', nav: '/-optional', - params: { id: '' }, - matchParams: { id: '' }, + params: {}, + matchParams: {}, }, { name: 'optional param with required param, prefix, suffix, wildcard and no value', path: `/$foo/a{-$id}-optional/$`, nav: '/bar/a-optional/qux', - params: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, - matchParams: { foo: 'bar', id: '', _splat: 'qux', '*': 'qux' }, + params: { foo: 'bar', _splat: 'qux', '*': 'qux' }, + matchParams: { foo: 'bar', _splat: 'qux', '*': 'qux' }, }, { name: 'optional param with required param, prefix, suffix, wildcard and value', diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index aa30b2a000a..6e515d95fe9 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1326,18 +1326,6 @@ describe('route ids should be consistent after rebuilding the route tree', () => }) describe('route id uniqueness', () => { - it('flatRoute should not have routes with duplicated route ids', () => { - const { router } = createTestRouter({ - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - const routeIdSet = new Set() - - router.flatRoutes.forEach((route) => { - expect(routeIdSet.has(route.id)).toBe(false) - routeIdSet.add(route.id) - }) - }) - it('routesById should not have routes duplicated route ids', () => { const { router } = createTestRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index a304f33dfd3..08ce599e40c 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -359,10 +359,8 @@ async function handleServerRoutes({ let url = new URL(request.url) url = executeRewriteInput(router.rewrite, url) const pathname = url.pathname - const { matchedRoutes, foundRoute, routeParams } = router.getMatchedRoutes( - pathname, - undefined, - ) + const { matchedRoutes, foundRoute, routeParams } = + router.getMatchedRoutes(pathname) // TODO: Error handling? What happens when its `throw redirect()` vs `throw new Error()`?