diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index afb42941d1d..f196c298c00 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -11,6 +11,7 @@ import { import { getRouteNodes as virtualGetRouteNodes } from './filesystem/virtual/getRouteNodes' import { rootPathId } from './filesystem/physical/rootPathId' import { + RoutePrefixMap, buildFileRoutesByPathInterface, buildImportString, buildRouteTreeConfig, @@ -37,7 +38,6 @@ import { removeTrailingSlash, removeUnderscores, replaceBackslash, - resetRegex, trimPathLeft, } from './utils' import { fillTemplate, getTargetTemplate } from './template' @@ -188,9 +188,14 @@ export class Generator { private runPromise: Promise | undefined private fileEventQueue: Array = [] private plugins: Array = [] - private static routeGroupPatternRegex = /\(.+\)/g + private static routeGroupPatternRegex = /\(.+\)/ private physicalDirectories: Array = [] + private indexTokenRegex: RegExp + private routeTokenRegex: RegExp + private static componentPieceRegex = + /[./](component|errorComponent|notFoundComponent|pendingComponent|loader|lazy)[.]/ + constructor(opts: { config: Config; root: string; fs?: fs }) { this.config = opts.config this.logger = logging({ disabled: this.config.disableLogging }) @@ -202,6 +207,9 @@ export class Generator { this.routesDirectoryPath = this.getRoutesDirectoryPath() this.plugins.push(...(opts.config.plugins || [])) + this.indexTokenRegex = new RegExp(`[./]${this.config.indexToken}[.]`) + this.routeTokenRegex = new RegExp(`[./]${this.config.routeToken}[.]`) + for (const plugin of this.plugins) { plugin.init?.({ generator: this }) } @@ -346,20 +354,9 @@ export class Generator { const preRouteNodes = multiSortBy(beforeRouteNodes, [ (d) => (d.routePath === '/' ? -1 : 1), (d) => d.routePath?.split('/').length, - (d) => - d.filePath.match(new RegExp(`[./]${this.config.indexToken}[.]`)) - ? 1 - : -1, - (d) => - d.filePath.match( - /[./](component|errorComponent|notFoundComponent|pendingComponent|loader|lazy)[.]/, - ) - ? 1 - : -1, - (d) => - d.filePath.match(new RegExp(`[./]${this.config.routeToken}[.]`)) - ? -1 - : 1, + (d) => (d.filePath.match(this.indexTokenRegex) ? 1 : -1), + (d) => (d.filePath.match(Generator.componentPieceRegex) ? 1 : -1), + (d) => (d.filePath.match(this.routeTokenRegex) ? -1 : 1), (d) => (d.routePath?.endsWith('/') ? -1 : 1), (d) => d.routePath, ]).filter((d) => { @@ -411,8 +408,10 @@ export class Generator { routeNodesByPath: new Map(), } + const prefixMap = new RoutePrefixMap(routeFileResult) + for (const node of routeFileResult) { - Generator.handleNode(node, acc, this.config) + Generator.handleNode(node, acc, prefixMap, this.config) } this.crawlingResult = { rootRouteNode, routeFileResult, acc } @@ -549,44 +548,53 @@ export class Generator { (d) => d, ]) - const routeImports = sortedRouteNodes - .filter((d) => !d.isVirtual) - .flatMap((node) => - getImportForRouteNode( - node, - config, - this.generatedRouteTreePath, - this.root, - ), - ) + const routeImports: Array = [] + const virtualRouteNodes: Array = [] - const virtualRouteNodes = sortedRouteNodes - .filter((d) => d.isVirtual) - .map((node) => { - return `const ${ - node.variableName - }RouteImport = createFileRoute('${node.routePath}')()` - }) + for (const node of sortedRouteNodes) { + if (node.isVirtual) { + virtualRouteNodes.push( + `const ${node.variableName}RouteImport = createFileRoute('${node.routePath}')()`, + ) + } else { + routeImports.push( + getImportForRouteNode( + node, + config, + this.generatedRouteTreePath, + this.root, + ), + ) + } + } const imports: Array = [] - if (acc.routeNodes.some((n) => n.isVirtual)) { + if (virtualRouteNodes.length > 0) { imports.push({ specifiers: [{ imported: 'createFileRoute' }], source: this.targetTemplate.fullPkg, }) } // Add lazyRouteComponent import if there are component pieces - const hasComponentPieces = sortedRouteNodes.some( - (node) => - acc.routePiecesByPath[node.routePath!]?.component || - acc.routePiecesByPath[node.routePath!]?.errorComponent || - acc.routePiecesByPath[node.routePath!]?.notFoundComponent || - acc.routePiecesByPath[node.routePath!]?.pendingComponent, - ) - // Add lazyFn import if there are loader pieces - const hasLoaderPieces = sortedRouteNodes.some( - (node) => acc.routePiecesByPath[node.routePath!]?.loader, - ) + let hasComponentPieces = false + let hasLoaderPieces = false + for (const node of sortedRouteNodes) { + const pieces = acc.routePiecesByPath[node.routePath!] + if (pieces) { + if ( + pieces.component || + pieces.errorComponent || + pieces.notFoundComponent || + pieces.pendingComponent + ) { + hasComponentPieces = true + } + if (pieces.loader) { + hasLoaderPieces = true + } + if (hasComponentPieces && hasLoaderPieces) break + } + } if (hasComponentPieces || hasLoaderPieces) { const runtimeImport: ImportDeclaration = { specifiers: [], @@ -606,21 +614,23 @@ export class Generator { source: this.targetTemplate.fullPkg, importKind: 'type', } - if ( - sortedRouteNodes.some( - (d) => - isRouteNodeValidForAugmentation(d) && d._fsRouteType !== 'lazy', - ) - ) { + let needsCreateFileRoute = false + let needsCreateLazyFileRoute = false + for (const node of sortedRouteNodes) { + if (isRouteNodeValidForAugmentation(node)) { + if (node._fsRouteType !== 'lazy') { + needsCreateFileRoute = true + } + if (acc.routePiecesByPath[node.routePath!]?.lazy) { + needsCreateLazyFileRoute = true + } + } + if (needsCreateFileRoute && needsCreateLazyFileRoute) break + } + if (needsCreateFileRoute) { typeImport.specifiers.push({ imported: 'CreateFileRoute' }) } - if ( - sortedRouteNodes.some( - (node) => - acc.routePiecesByPath[node.routePath!]?.lazy && - isRouteNodeValidForAugmentation(node), - ) - ) { + if (needsCreateLazyFileRoute) { typeImport.specifiers.push({ imported: 'CreateLazyFileRoute' }) } @@ -636,15 +646,13 @@ export class Generator { ) const createUpdateRoutes = sortedRouteNodes.map((node) => { - const loaderNode = acc.routePiecesByPath[node.routePath!]?.loader - const componentNode = acc.routePiecesByPath[node.routePath!]?.component - const errorComponentNode = - acc.routePiecesByPath[node.routePath!]?.errorComponent - const notFoundComponentNode = - acc.routePiecesByPath[node.routePath!]?.notFoundComponent - const pendingComponentNode = - acc.routePiecesByPath[node.routePath!]?.pendingComponent - const lazyComponentNode = acc.routePiecesByPath[node.routePath!]?.lazy + const pieces = acc.routePiecesByPath[node.routePath!] + const loaderNode = pieces?.loader + const componentNode = pieces?.component + const errorComponentNode = pieces?.errorComponent + const notFoundComponentNode = pieces?.notFoundComponent + const pendingComponentNode = pieces?.pendingComponent + const lazyComponentNode = pieces?.lazy return [ [ @@ -752,13 +760,11 @@ export class Generator { // Generate update for root route if it has component pieces const rootRoutePath = `/${rootPathId}` - const rootComponentNode = acc.routePiecesByPath[rootRoutePath]?.component - const rootErrorComponentNode = - acc.routePiecesByPath[rootRoutePath]?.errorComponent - const rootNotFoundComponentNode = - acc.routePiecesByPath[rootRoutePath]?.notFoundComponent - const rootPendingComponentNode = - acc.routePiecesByPath[rootRoutePath]?.pendingComponent + const rootPieces = acc.routePiecesByPath[rootRoutePath] + const rootComponentNode = rootPieces?.component + const rootErrorComponentNode = rootPieces?.errorComponent + const rootNotFoundComponentNode = rootPieces?.notFoundComponent + const rootPendingComponentNode = rootPieces?.pendingComponent let rootRouteUpdate = '' if ( @@ -816,16 +822,23 @@ export class Generator { let fileRoutesByFullPath = '' if (!config.disableTypes) { + const routeNodesByFullPath = createRouteNodesByFullPath( + acc.routeNodes, + config, + ) + const routeNodesByTo = createRouteNodesByTo(acc.routeNodes, config) + const routeNodesById = createRouteNodesById(acc.routeNodes) + fileRoutesByFullPath = [ `export interface FileRoutesByFullPath { -${[...createRouteNodesByFullPath(acc.routeNodes, config).entries()] +${[...routeNodesByFullPath.entries()] .filter(([fullPath]) => fullPath) .map(([fullPath, routeNode]) => { return `'${fullPath}': typeof ${getResolvedRouteNodeVariableName(routeNode)}` })} }`, `export interface FileRoutesByTo { -${[...createRouteNodesByTo(acc.routeNodes, config).entries()] +${[...routeNodesByTo.entries()] .filter(([to]) => to) .map(([to, routeNode]) => { return `'${to}': typeof ${getResolvedRouteNodeVariableName(routeNode)}` @@ -833,7 +846,7 @@ ${[...createRouteNodesByTo(acc.routeNodes, config).entries()] }`, `export interface FileRoutesById { '${rootRouteId}': typeof rootRouteImport, -${[...createRouteNodesById(acc.routeNodes).entries()].map(([id, routeNode]) => { +${[...routeNodesById.entries()].map(([id, routeNode]) => { return `'${id}': typeof ${getResolvedRouteNodeVariableName(routeNode)}` })} }`, @@ -841,7 +854,7 @@ ${[...createRouteNodesById(acc.routeNodes).entries()].map(([id, routeNode]) => { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: ${ acc.routeNodes.length > 0 - ? [...createRouteNodesByFullPath(acc.routeNodes, config).keys()] + ? [...routeNodesByFullPath.keys()] .filter((fullPath) => fullPath) .map((fullPath) => `'${fullPath}'`) .join('|') @@ -850,13 +863,13 @@ fullPaths: ${ fileRoutesByTo: FileRoutesByTo to: ${ acc.routeNodes.length > 0 - ? [...createRouteNodesByTo(acc.routeNodes, config).keys()] + ? [...routeNodesByTo.keys()] .filter((to) => to) .map((to) => `'${to}'`) .join('|') : 'never' } -id: ${[`'${rootRouteId}'`, ...[...createRouteNodesById(acc.routeNodes).keys()].map((id) => `'${id}'`)].join('|')} +id: ${[`'${rootRouteId}'`, ...[...routeNodesById.keys()].map((id) => `'${id}'`)].join('|')} fileRoutesById: FileRoutesById }`, `export interface RootRouteChildren { @@ -1344,18 +1357,14 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved private static handleNode( node: RouteNode, acc: HandleNodeAccumulator, + prefixMap: RoutePrefixMap, config?: Config, ) { - // Do not remove this as we need to set the lastIndex to 0 as it - // is necessary to reset the regex's index when using the global flag - // otherwise it might not match the next time it's used const useExperimentalNonNestedRoutes = config?.experimental?.nonNestedRoutes ?? false - resetRegex(this.routeGroupPatternRegex) - const parentRoute = hasParentRoute( - acc.routeNodes, + prefixMap, node, node.routePath, node.originalRoutePath, @@ -1425,6 +1434,7 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved _fsRouteType: 'static', }, acc, + prefixMap, config, ) } @@ -1447,9 +1457,12 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved const candidate = acc.routeNodesByPath.get(searchPath) if (candidate && !candidate.isVirtual && candidate.path !== '/') { node.parent = candidate - node.path = node.routePath + node.path = + node.routePath?.replace(candidate.routePath ?? '', '') || '/' + const pathRelativeToParent = + immediateParentPath.replace(candidate.routePath ?? '', '') || '/' node.cleanedPath = removeGroups( - removeUnderscores(removeLayoutSegments(immediateParentPath)) ?? '', + removeUnderscores(removeLayoutSegments(pathRelativeToParent)) ?? '', ) break } diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts index d1d579ed81f..2f1bb1441b3 100644 --- a/packages/router-generator/src/utils.ts +++ b/packages/router-generator/src/utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/prefer-for-of */ import * as fsp from 'node:fs/promises' import path from 'node:path' import * as prettier from 'prettier' @@ -5,34 +6,170 @@ import { rootPathId } from './filesystem/physical/rootPathId' import type { Config } from './config' import type { ImportDeclaration, RouteNode } from './types' +/** + * Prefix map for O(1) parent route lookups. + * Maps each route path prefix to the route node that owns that prefix. + * Enables finding longest matching parent without linear search. + */ +export class RoutePrefixMap { + private prefixToRoute: Map = new Map() + private layoutRoutes: Array = [] + private nonNestedRoutes: Array = [] + + constructor(routes: Array) { + for (const route of routes) { + if (!route.routePath || route.routePath === `/${rootPathId}`) continue + + // Index by exact path for direct lookups + this.prefixToRoute.set(route.routePath, route) + + // Track layout routes separately for non-nested route handling + if ( + route._fsRouteType === 'pathless_layout' || + route._fsRouteType === 'layout' || + route._fsRouteType === '__root' + ) { + this.layoutRoutes.push(route) + } + + // Track non-nested routes separately + if (route._isExperimentalNonNestedRoute) { + this.nonNestedRoutes.push(route) + } + } + + // Sort by path length descending for longest-match-first + this.layoutRoutes.sort( + (a, b) => (b.routePath?.length ?? 0) - (a.routePath?.length ?? 0), + ) + this.nonNestedRoutes.sort( + (a, b) => (b.routePath?.length ?? 0) - (a.routePath?.length ?? 0), + ) + } + + /** + * Find the longest matching parent route for a given path. + * O(k) where k is the number of path segments, not O(n) routes. + */ + findParent(routePath: string): RouteNode | null { + if (!routePath || routePath === '/') return null + + // Walk up the path segments + let searchPath = routePath + while (searchPath.length > 0) { + const lastSlash = searchPath.lastIndexOf('/') + if (lastSlash <= 0) break + + searchPath = searchPath.substring(0, lastSlash) + const parent = this.prefixToRoute.get(searchPath) + if (parent && parent.routePath !== routePath) { + return parent + } + } + return null + } + + /** + * Find parent for non-nested routes (needs layout route matching). + */ + findParentForNonNested( + routePath: string, + originalRoutePath: string | undefined, + nonNestedSegments: Array, + ): RouteNode | null { + // First check for other non-nested routes that are prefixes + // Use pre-sorted array for longest-match-first + for (const route of this.nonNestedRoutes) { + if ( + route.routePath !== routePath && + originalRoutePath?.startsWith(`${route.originalRoutePath}/`) + ) { + return route + } + } + + // Then check layout routes + for (const route of this.layoutRoutes) { + if (route.routePath === '/') continue + + // Skip if this route's original path + underscore matches a non-nested segment + if ( + nonNestedSegments.some((seg) => seg === `${route.originalRoutePath}_`) + ) { + continue + } + + // Check if this layout route is a prefix of the path we're looking for + if ( + routePath.startsWith(`${route.routePath}/`) && + route.routePath !== routePath + ) { + return route + } + } + + return null + } + + /** + * Check if a route exists at the given path. + */ + has(routePath: string): boolean { + return this.prefixToRoute.has(routePath) + } + + /** + * Get a route by exact path. + */ + get(routePath: string): RouteNode | undefined { + return this.prefixToRoute.get(routePath) + } +} + export function multiSortBy( arr: Array, accessors: Array<(item: T) => any> = [(d) => d], ): Array { - return arr - .map((d, i) => [d, i] as const) - .sort(([a, ai], [b, bi]) => { - for (const accessor of accessors) { - const ao = accessor(a) - const bo = accessor(b) - - if (typeof ao === 'undefined') { - if (typeof bo === 'undefined') { - continue - } - return 1 - } + const len = arr.length + // Pre-compute all accessor values to avoid repeated function calls during sort + const indexed: Array<{ item: T; index: number; keys: Array }> = + new Array(len) + for (let i = 0; i < len; i++) { + const item = arr[i]! + const keys = new Array(accessors.length) + for (let j = 0; j < accessors.length; j++) { + keys[j] = accessors[j]!(item) + } + indexed[i] = { item, index: i, keys } + } + + indexed.sort((a, b) => { + for (let j = 0; j < accessors.length; j++) { + const ao = a.keys[j] + const bo = b.keys[j] - if (ao === bo) { + if (typeof ao === 'undefined') { + if (typeof bo === 'undefined') { continue } + return 1 + } - return ao > bo ? 1 : -1 + if (ao === bo) { + continue } - return ai - bi - }) - .map(([d]) => d) + return ao > bo ? 1 : -1 + } + + return a.index - b.index + }) + + const result: Array = new Array(len) + for (let i = 0; i < len; i++) { + result[i] = indexed[i]!.item + } + return result } export function cleanPath(path: string) { @@ -143,54 +280,74 @@ export function determineInitialRoutePath( } } +const backslashRegex = /\\/g + export function replaceBackslash(s: string) { - return s.replaceAll(/\\/gi, '/') + return s.replace(backslashRegex, '/') } -export function routePathToVariable(routePath: string): string { - const toVariableSafeChar = (char: string): string => { - if (/[a-zA-Z0-9_]/.test(char)) { - return char // Keep alphanumeric characters and underscores as is - } +const alphanumericRegex = /[a-zA-Z0-9_]/ +const splatSlashRegex = /\/\$\//g +const trailingSplatRegex = /\$$/g +const bracketSplatRegex = /\$\{\$\}/g +const dollarSignRegex = /\$/g +const splitPathRegex = /[/-]/g +const leadingDigitRegex = /^(\d)/g + +const toVariableSafeChar = (char: string): string => { + if (alphanumericRegex.test(char)) { + return char // Keep alphanumeric characters and underscores as is + } - // Replace special characters with meaningful text equivalents - switch (char) { - case '.': - return 'Dot' - case '-': - return 'Dash' - case '@': - return 'At' - case '(': - return '' // Removed since route groups use parentheses - case ')': - return '' // Removed since route groups use parentheses - case ' ': - return '' // Remove spaces - default: - return `Char${char.charCodeAt(0)}` // For any other characters + // Replace special characters with meaningful text equivalents + switch (char) { + case '.': + return 'Dot' + case '-': + return 'Dash' + case '@': + return 'At' + case '(': + return '' // Removed since route groups use parentheses + case ')': + return '' // Removed since route groups use parentheses + case ' ': + return '' // Remove spaces + default: + return `Char${char.charCodeAt(0)}` // For any other characters + } +} + +export function routePathToVariable(routePath: string): string { + const cleaned = removeUnderscores(routePath) + if (!cleaned) return '' + + const parts = cleaned + .replace(splatSlashRegex, '/splat/') + .replace(trailingSplatRegex, 'splat') + .replace(bracketSplatRegex, 'splat') + .replace(dollarSignRegex, '') + .split(splitPathRegex) + + let result = '' + for (let i = 0; i < parts.length; i++) { + const part = parts[i]! + const segment = i > 0 ? capitalize(part) : part + for (let j = 0; j < segment.length; j++) { + result += toVariableSafeChar(segment[j]!) } } - return ( - removeUnderscores(routePath) - ?.replace(/\/\$\//g, '/splat/') - .replace(/\$$/g, 'splat') - .replace(/\$\{\$\}/g, 'splat') - .replace(/\$/g, '') - .split(/[/-]/g) - .map((d, i) => (i > 0 ? capitalize(d) : d)) - .join('') - .split('') - .map(toVariableSafeChar) - .join('') - // .replace(/([^a-zA-Z0-9]|[.])/gm, '') - .replace(/^(\d)/g, 'R$1') ?? '' - ) + return result.replace(leadingDigitRegex, 'R$1') } +const underscoreStartEndRegex = /(^_|_$)/gi +const underscoreSlashRegex = /(\/_|_\/)/gi + export function removeUnderscores(s?: string) { - return s?.replaceAll(/(^_|_$)/gi, '').replaceAll(/(\/_|_\/)/gi, '/') + return s + ?.replace(underscoreStartEndRegex, '') + .replace(underscoreSlashRegex, '/') } function escapeRegExp(s: string): string { @@ -361,86 +518,53 @@ export function removeLastSegmentFromPath(routePath: string = '/'): string { return segments.join('/') } +const nonNestedSegmentRegex = /_(?=\/|$)/g +const openBracketRegex = /\[/g +const closeBracketRegex = /\]/g + +/** + * Extracts non-nested segments from a route path. + * Used for determining parent routes in non-nested route scenarios. + */ +export function getNonNestedSegments(routePath: string): Array { + nonNestedSegmentRegex.lastIndex = 0 + const result: Array = [] + for (const match of routePath.matchAll(nonNestedSegmentRegex)) { + const beforeStr = routePath.substring(0, match.index) + openBracketRegex.lastIndex = 0 + closeBracketRegex.lastIndex = 0 + const openBrackets = beforeStr.match(openBracketRegex)?.length ?? 0 + const closeBrackets = beforeStr.match(closeBracketRegex)?.length ?? 0 + if (openBrackets === closeBrackets) { + result.push(routePath.substring(0, match.index + 1)) + } + } + return result.reverse() +} + +/** + * Find parent route using RoutePrefixMap for O(k) lookups instead of O(n). + */ export function hasParentRoute( - routes: Array, + prefixMap: RoutePrefixMap, node: RouteNode, routePathToCheck: string | undefined, originalRoutePathToCheck: string | undefined, ): RouteNode | null { - const getNonNestedSegments = (routePath: string) => { - const regex = /_(?=\/|$)/g - - return [...routePath.matchAll(regex)] - .filter((match) => { - const beforeStr = routePath.substring(0, match.index) - const openBrackets = (beforeStr.match(/\[/g) || []).length - const closeBrackets = (beforeStr.match(/\]/g) || []).length - return openBrackets === closeBrackets - }) - .map((match) => routePath.substring(0, match.index + 1)) - .reverse() - } - if (!routePathToCheck || routePathToCheck === '/') { return null } - const sortedNodes = multiSortBy(routes, [ - (d) => d.routePath!.length * -1, - (d) => d.variableName, - ]).filter((d) => d.routePath !== `/${rootPathId}`) - - const filteredNodes = node._isExperimentalNonNestedRoute - ? [] - : [...sortedNodes] - if (node._isExperimentalNonNestedRoute && originalRoutePathToCheck) { const nonNestedSegments = getNonNestedSegments(originalRoutePathToCheck) - - for (const route of sortedNodes) { - if (route.routePath === '/') continue - - if ( - route._isExperimentalNonNestedRoute && - route.routePath !== routePathToCheck && - originalRoutePathToCheck.startsWith(`${route.originalRoutePath}/`) - ) { - return route - } - - if ( - nonNestedSegments.find( - (seg) => seg === `${route.originalRoutePath}_`, - ) || - !( - route._fsRouteType === 'pathless_layout' || - route._fsRouteType === 'layout' || - route._fsRouteType === '__root' - ) - ) { - continue - } - - filteredNodes.push(route) - } - } - - for (const route of filteredNodes) { - if (route.routePath === '/') continue - - if ( - routePathToCheck.startsWith(`${route.routePath}/`) && - route.routePath !== routePathToCheck - ) { - return route - } + return prefixMap.findParentForNonNested( + routePathToCheck, + originalRoutePathToCheck, + nonNestedSegments, + ) } - const segments = routePathToCheck.split('/') - segments.pop() // Remove the last segment - const parentRoutePath = segments.join('/') - - return hasParentRoute(routes, node, parentRoutePath, originalRoutePathToCheck) + return prefixMap.findParent(routePathToCheck) } /** @@ -682,28 +806,33 @@ export function lowerCaseFirstChar(value: string) { export function mergeImportDeclarations( imports: Array, ): Array { - const merged: Record = {} + const merged = new Map() for (const imp of imports) { - const key = `${imp.source}-${imp.importKind}` - if (!merged[key]) { - merged[key] = { ...imp, specifiers: [] } + const key = `${imp.source}-${imp.importKind ?? ''}` + let existing = merged.get(key) + if (!existing) { + existing = { ...imp, specifiers: [] } + merged.set(key, existing) } + + const existingSpecs = existing.specifiers for (const specifier of imp.specifiers) { - // check if the specifier already exists in the merged import - if ( - !merged[key].specifiers.some( - (existing) => - existing.imported === specifier.imported && - existing.local === specifier.local, - ) - ) { - merged[key].specifiers.push(specifier) + let found = false + for (let i = 0; i < existingSpecs.length; i++) { + const e = existingSpecs[i]! + if (e.imported === specifier.imported && e.local === specifier.local) { + found = true + break + } + } + if (!found) { + existingSpecs.push(specifier) } } } - return Object.values(merged) + return [...merged.values()] } export const findParent = (node: RouteNode | undefined): string => { diff --git a/packages/router-generator/tests/generator/flat-route-group/routeTree.nonnested.snapshot.ts b/packages/router-generator/tests/generator/flat-route-group/routeTree.nonnested.snapshot.ts new file mode 100644 index 00000000000..b0f4031474d --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routeTree.nonnested.snapshot.ts @@ -0,0 +1,145 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteImport } from './routes/app' +import { Route as AppcomprasComprasOrdenesRouteImport } from './routes/app.(compras)/compras_.ordenes' +import { Route as AppcomprasComprasMasRouteImport } from './routes/app.(compras)/compras_._mas' +import { Route as AppcomprasComprasMasDivisionesRouteImport } from './routes/app.(compras)/compras_._mas.divisiones' + +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const AppcomprasComprasOrdenesRoute = + AppcomprasComprasOrdenesRouteImport.update({ + id: '/app/(compras)/compras/ordenes', + path: '/app/compras/ordenes', + getParentRoute: () => rootRouteImport, + } as any) +const AppcomprasComprasMasRoute = AppcomprasComprasMasRouteImport.update({ + id: '/(compras)/compras/_mas', + path: '/compras', + getParentRoute: () => AppRoute, +} as any) +const AppcomprasComprasMasDivisionesRoute = + AppcomprasComprasMasDivisionesRouteImport.update({ + id: '/divisiones', + path: '/divisiones', + getParentRoute: () => AppcomprasComprasMasRoute, + } as any) + +export interface FileRoutesByFullPath { + '/app': typeof AppRouteWithChildren + '/app/compras': typeof AppcomprasComprasMasRouteWithChildren + '/app/compras/ordenes': typeof AppcomprasComprasOrdenesRoute + '/app/compras/divisiones': typeof AppcomprasComprasMasDivisionesRoute +} +export interface FileRoutesByTo { + '/app': typeof AppRouteWithChildren + '/app/compras': typeof AppcomprasComprasMasRouteWithChildren + '/app/compras/ordenes': typeof AppcomprasComprasOrdenesRoute + '/app/compras/divisiones': typeof AppcomprasComprasMasDivisionesRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/app': typeof AppRouteWithChildren + '/app/(compras)/compras/_mas': typeof AppcomprasComprasMasRouteWithChildren + '/app/(compras)/compras/ordenes': typeof AppcomprasComprasOrdenesRoute + '/app/(compras)/compras/_mas/divisiones': typeof AppcomprasComprasMasDivisionesRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/app' + | '/app/compras' + | '/app/compras/ordenes' + | '/app/compras/divisiones' + fileRoutesByTo: FileRoutesByTo + to: + | '/app' + | '/app/compras' + | '/app/compras/ordenes' + | '/app/compras/divisiones' + id: + | '__root__' + | '/app' + | '/app/(compras)/compras/_mas' + | '/app/(compras)/compras/ordenes' + | '/app/(compras)/compras/_mas/divisiones' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AppRoute: typeof AppRouteWithChildren + AppcomprasComprasOrdenesRoute: typeof AppcomprasComprasOrdenesRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/app/(compras)/compras/ordenes': { + id: '/app/(compras)/compras/ordenes' + path: '/app/compras/ordenes' + fullPath: '/app/compras/ordenes' + preLoaderRoute: typeof AppcomprasComprasOrdenesRouteImport + parentRoute: typeof rootRouteImport + } + '/app/(compras)/compras/_mas': { + id: '/app/(compras)/compras/_mas' + path: '/compras' + fullPath: '/app/compras' + preLoaderRoute: typeof AppcomprasComprasMasRouteImport + parentRoute: typeof AppRoute + } + '/app/(compras)/compras/_mas/divisiones': { + id: '/app/(compras)/compras/_mas/divisiones' + path: '/divisiones' + fullPath: '/app/compras/divisiones' + preLoaderRoute: typeof AppcomprasComprasMasDivisionesRouteImport + parentRoute: typeof AppcomprasComprasMasRoute + } + } +} + +interface AppcomprasComprasMasRouteChildren { + AppcomprasComprasMasDivisionesRoute: typeof AppcomprasComprasMasDivisionesRoute +} + +const AppcomprasComprasMasRouteChildren: AppcomprasComprasMasRouteChildren = { + AppcomprasComprasMasDivisionesRoute: AppcomprasComprasMasDivisionesRoute, +} + +const AppcomprasComprasMasRouteWithChildren = + AppcomprasComprasMasRoute._addFileChildren(AppcomprasComprasMasRouteChildren) + +interface AppRouteChildren { + AppcomprasComprasMasRoute: typeof AppcomprasComprasMasRouteWithChildren +} + +const AppRouteChildren: AppRouteChildren = { + AppcomprasComprasMasRoute: AppcomprasComprasMasRouteWithChildren, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AppRoute: AppRouteWithChildren, + AppcomprasComprasOrdenesRoute: AppcomprasComprasOrdenesRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/flat-route-group/routeTree.snapshot.ts b/packages/router-generator/tests/generator/flat-route-group/routeTree.snapshot.ts new file mode 100644 index 00000000000..7f7990e7870 --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routeTree.snapshot.ts @@ -0,0 +1,147 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteImport } from './routes/app' +import { Route as AppcomprasComprasOrdenesRouteImport } from './routes/app.(compras)/compras_.ordenes' +import { Route as AppcomprasCompras_masRouteImport } from './routes/app.(compras)/compras_._mas' +import { Route as AppcomprasCompras_masDivisionesRouteImport } from './routes/app.(compras)/compras_._mas.divisiones' + +const AppRoute = AppRouteImport.update({ + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) +const AppcomprasComprasOrdenesRoute = + AppcomprasComprasOrdenesRouteImport.update({ + id: '/(compras)/compras_/ordenes', + path: '/compras/ordenes', + getParentRoute: () => AppRoute, + } as any) +const AppcomprasCompras_masRoute = AppcomprasCompras_masRouteImport.update({ + id: '/(compras)/compras_/_mas', + path: '/compras', + getParentRoute: () => AppRoute, +} as any) +const AppcomprasCompras_masDivisionesRoute = + AppcomprasCompras_masDivisionesRouteImport.update({ + id: '/divisiones', + path: '/divisiones', + getParentRoute: () => AppcomprasCompras_masRoute, + } as any) + +export interface FileRoutesByFullPath { + '/app': typeof AppRouteWithChildren + '/app/compras': typeof AppcomprasCompras_masRouteWithChildren + '/app/compras/ordenes': typeof AppcomprasComprasOrdenesRoute + '/app/compras/divisiones': typeof AppcomprasCompras_masDivisionesRoute +} +export interface FileRoutesByTo { + '/app': typeof AppRouteWithChildren + '/app/compras': typeof AppcomprasCompras_masRouteWithChildren + '/app/compras/ordenes': typeof AppcomprasComprasOrdenesRoute + '/app/compras/divisiones': typeof AppcomprasCompras_masDivisionesRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/app': typeof AppRouteWithChildren + '/app/(compras)/compras_/_mas': typeof AppcomprasCompras_masRouteWithChildren + '/app/(compras)/compras_/ordenes': typeof AppcomprasComprasOrdenesRoute + '/app/(compras)/compras_/_mas/divisiones': typeof AppcomprasCompras_masDivisionesRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/app' + | '/app/compras' + | '/app/compras/ordenes' + | '/app/compras/divisiones' + fileRoutesByTo: FileRoutesByTo + to: + | '/app' + | '/app/compras' + | '/app/compras/ordenes' + | '/app/compras/divisiones' + id: + | '__root__' + | '/app' + | '/app/(compras)/compras_/_mas' + | '/app/(compras)/compras_/ordenes' + | '/app/(compras)/compras_/_mas/divisiones' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AppRoute: typeof AppRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteImport + parentRoute: typeof rootRouteImport + } + '/app/(compras)/compras_/ordenes': { + id: '/app/(compras)/compras_/ordenes' + path: '/compras/ordenes' + fullPath: '/app/compras/ordenes' + preLoaderRoute: typeof AppcomprasComprasOrdenesRouteImport + parentRoute: typeof AppRoute + } + '/app/(compras)/compras_/_mas': { + id: '/app/(compras)/compras_/_mas' + path: '/compras' + fullPath: '/app/compras' + preLoaderRoute: typeof AppcomprasCompras_masRouteImport + parentRoute: typeof AppRoute + } + '/app/(compras)/compras_/_mas/divisiones': { + id: '/app/(compras)/compras_/_mas/divisiones' + path: '/divisiones' + fullPath: '/app/compras/divisiones' + preLoaderRoute: typeof AppcomprasCompras_masDivisionesRouteImport + parentRoute: typeof AppcomprasCompras_masRoute + } + } +} + +interface AppcomprasCompras_masRouteChildren { + AppcomprasCompras_masDivisionesRoute: typeof AppcomprasCompras_masDivisionesRoute +} + +const AppcomprasCompras_masRouteChildren: AppcomprasCompras_masRouteChildren = { + AppcomprasCompras_masDivisionesRoute: AppcomprasCompras_masDivisionesRoute, +} + +const AppcomprasCompras_masRouteWithChildren = + AppcomprasCompras_masRoute._addFileChildren( + AppcomprasCompras_masRouteChildren, + ) + +interface AppRouteChildren { + AppcomprasCompras_masRoute: typeof AppcomprasCompras_masRouteWithChildren + AppcomprasComprasOrdenesRoute: typeof AppcomprasComprasOrdenesRoute +} + +const AppRouteChildren: AppRouteChildren = { + AppcomprasCompras_masRoute: AppcomprasCompras_masRouteWithChildren, + AppcomprasComprasOrdenesRoute: AppcomprasComprasOrdenesRoute, +} + +const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AppRoute: AppRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/flat-route-group/routes/__root.tsx b/packages/router-generator/tests/generator/flat-route-group/routes/__root.tsx new file mode 100644 index 00000000000..a8c4d4c8938 --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routes/__root.tsx @@ -0,0 +1,2 @@ +// @ts-nocheck +export const Route = createFileRoute() diff --git a/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_._mas.divisiones.tsx b/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_._mas.divisiones.tsx new file mode 100644 index 00000000000..ebec0274136 --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_._mas.divisiones.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/(compras)/compras_/_mas/divisiones')( + { + component: RouteComponent, + }, +) + +function RouteComponent() { + return
Hello "/app/(compras)/compras/_mas/divisiones"!
+} diff --git a/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_._mas.tsx b/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_._mas.tsx new file mode 100644 index 00000000000..a10276884a6 --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_._mas.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/(compras)/compras_/_mas')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/(compras)/compras/mas"!
+} diff --git a/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_.ordenes.tsx b/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_.ordenes.tsx new file mode 100644 index 00000000000..ce1a19c9add --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routes/app.(compras)/compras_.ordenes.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/(compras)/compras_/ordenes')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app/(compras)/compras/mas/divisiones"!
+} diff --git a/packages/router-generator/tests/generator/flat-route-group/routes/app.tsx b/packages/router-generator/tests/generator/flat-route-group/routes/app.tsx new file mode 100644 index 00000000000..fdc75fcb17f --- /dev/null +++ b/packages/router-generator/tests/generator/flat-route-group/routes/app.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/app')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/app"!
+} diff --git a/packages/router-generator/tests/utils.test.ts b/packages/router-generator/tests/utils.test.ts index 268986cb7a9..1d185639ad7 100644 --- a/packages/router-generator/tests/utils.test.ts +++ b/packages/router-generator/tests/utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + RoutePrefixMap, cleanPath, determineInitialRoutePath, isValidNonNestedRoute, @@ -11,7 +12,7 @@ import { removeUnderscores, routePathToVariable, } from '../src/utils' -import type { ImportDeclaration } from '../src/types' +import type { ImportDeclaration, RouteNode } from '../src/types' describe('cleanPath', () => { it('keeps path with leading slash and trailing slash', () => { @@ -19,6 +20,73 @@ describe('cleanPath', () => { }) }) +describe('multiSortBy', () => { + it('sorts by single accessor', () => { + const arr = [{ v: 3 }, { v: 1 }, { v: 2 }] + const result = multiSortBy(arr, [(d) => d.v]) + expect(result.map((d) => d.v)).toEqual([1, 2, 3]) + }) + + it('sorts by multiple accessors', () => { + const arr = [ + { a: 2, b: 1 }, + { a: 1, b: 2 }, + { a: 1, b: 1 }, + ] + const result = multiSortBy(arr, [(d) => d.a, (d) => d.b]) + expect(result).toEqual([ + { a: 1, b: 1 }, + { a: 1, b: 2 }, + { a: 2, b: 1 }, + ]) + }) + + it('preserves original order for equal elements', () => { + const arr = [ + { a: 1, id: 'first' }, + { a: 1, id: 'second' }, + { a: 1, id: 'third' }, + ] + const result = multiSortBy(arr, [(d) => d.a]) + expect(result.map((d) => d.id)).toEqual(['first', 'second', 'third']) + }) + + it('handles undefined values', () => { + const arr = [{ v: 1 }, { v: undefined }, { v: 2 }] + const result = multiSortBy(arr, [(d) => d.v]) + // undefined sorts to end + expect(result.map((d) => d.v)).toEqual([1, 2, undefined]) + }) + + it('handles empty array', () => { + const result = multiSortBy([], [(d) => d]) + expect(result).toEqual([]) + }) + + it('handles single element array', () => { + const result = multiSortBy([{ v: 1 }], [(d) => d.v]) + expect(result).toEqual([{ v: 1 }]) + }) + + it('uses default accessor when none provided', () => { + const arr = [3, 1, 2] + const result = multiSortBy(arr) + expect(result).toEqual([1, 2, 3]) + }) + + it('sorts strings correctly', () => { + const arr = [{ s: 'c' }, { s: 'a' }, { s: 'b' }] + const result = multiSortBy(arr, [(d) => d.s]) + expect(result.map((d) => d.s)).toEqual(['a', 'b', 'c']) + }) + + it('handles negative numbers in accessors for reverse sort', () => { + const arr = [{ v: 1 }, { v: 3 }, { v: 2 }] + const result = multiSortBy(arr, [(d) => -d.v]) + expect(result.map((d) => d.v)).toEqual([3, 2, 1]) + }) +}) + describe.each([ { nonNested: true, mode: 'experimental nonNestedPaths' }, { nonNested: false, mode: 'default' }, @@ -403,3 +471,219 @@ describe('isValidNonNestedRoute', () => { ).toBe(false) }) }) + +describe('RoutePrefixMap', () => { + const createRoute = ( + overrides: Partial & { routePath: string }, + ): RouteNode => ({ + filePath: 'test.tsx', + fullPath: overrides.routePath, + variableName: 'Test', + _fsRouteType: 'static', + ...overrides, + }) + + describe('constructor', () => { + it('indexes routes by path', () => { + const routes = [ + createRoute({ routePath: '/users' }), + createRoute({ routePath: '/users/profile' }), + ] + const map = new RoutePrefixMap(routes) + + expect(map.has('/users')).toBe(true) + expect(map.has('/users/profile')).toBe(true) + expect(map.has('/posts')).toBe(false) + }) + + it('skips root path /__root', () => { + const routes = [createRoute({ routePath: '/__root' })] + const map = new RoutePrefixMap(routes) + + expect(map.has('/__root')).toBe(false) + }) + + it('skips empty/undefined routePaths', () => { + const routes = [createRoute({ routePath: '' })] + const map = new RoutePrefixMap(routes) + + expect(map.has('')).toBe(false) + }) + + it('tracks layout routes separately', () => { + const routes = [ + createRoute({ routePath: '/app', _fsRouteType: 'layout' }), + createRoute({ routePath: '/admin', _fsRouteType: 'pathless_layout' }), + createRoute({ routePath: '/users', _fsRouteType: 'static' }), + ] + const map = new RoutePrefixMap(routes) + + // all indexed + expect(map.has('/app')).toBe(true) + expect(map.has('/admin')).toBe(true) + expect(map.has('/users')).toBe(true) + }) + }) + + describe('get', () => { + it('returns route by exact path', () => { + const route = createRoute({ routePath: '/users' }) + const map = new RoutePrefixMap([route]) + + expect(map.get('/users')).toBe(route) + expect(map.get('/other')).toBeUndefined() + }) + }) + + describe('findParent', () => { + it('returns null for root path', () => { + const map = new RoutePrefixMap([]) + + expect(map.findParent('/')).toBeNull() + }) + + it('returns null for empty path', () => { + const map = new RoutePrefixMap([]) + + expect(map.findParent('')).toBeNull() + }) + + it('finds immediate parent', () => { + const parent = createRoute({ routePath: '/users' }) + const map = new RoutePrefixMap([parent]) + + expect(map.findParent('/users/profile')).toBe(parent) + }) + + it('finds ancestor when immediate parent missing', () => { + const grandparent = createRoute({ routePath: '/users' }) + const map = new RoutePrefixMap([grandparent]) + + expect(map.findParent('/users/settings/profile')).toBe(grandparent) + }) + + it('finds closest ancestor with multiple levels', () => { + const grandparent = createRoute({ routePath: '/users' }) + const parent = createRoute({ routePath: '/users/settings' }) + const map = new RoutePrefixMap([grandparent, parent]) + + expect(map.findParent('/users/settings/profile')).toBe(parent) + }) + + it('returns null when no parent exists', () => { + const map = new RoutePrefixMap([createRoute({ routePath: '/posts' })]) + + expect(map.findParent('/users/profile')).toBeNull() + }) + + it('does not return self as parent', () => { + const route = createRoute({ routePath: '/users' }) + const map = new RoutePrefixMap([route]) + + expect(map.findParent('/users')).toBeNull() + }) + }) + + describe('findParentForNonNested', () => { + it('finds other non-nested route as parent', () => { + const nonNestedParent = createRoute({ + routePath: '/app/users', + originalRoutePath: '/app_/users', + _isExperimentalNonNestedRoute: true, + }) + const map = new RoutePrefixMap([nonNestedParent]) + + // originalRoutePath must start with parent's originalRoutePath + '/' + const result = map.findParentForNonNested( + '/app/users/profile', + '/app_/users/profile', + [], + ) + expect(result).toBe(nonNestedParent) + }) + + it('finds layout route as parent for non-nested', () => { + const layout = createRoute({ + routePath: '/app', + _fsRouteType: 'layout', + }) + const map = new RoutePrefixMap([layout]) + + const result = map.findParentForNonNested('/app/users', '/app_/users', []) + expect(result).toBe(layout) + }) + + it('skips root layout route', () => { + const rootLayout = createRoute({ + routePath: '/', + _fsRouteType: 'layout', + }) + const map = new RoutePrefixMap([rootLayout]) + + const result = map.findParentForNonNested('/users', '/users_', []) + expect(result).toBeNull() + }) + + it('skips layout routes matching non-nested segments', () => { + const layout = createRoute({ + routePath: '/app', + originalRoutePath: '/app', + _fsRouteType: 'layout', + }) + const map = new RoutePrefixMap([layout]) + + const result = map.findParentForNonNested('/app/users', '/app_/users', [ + '/app_', + ]) + expect(result).toBeNull() + }) + + it('returns null when no suitable parent', () => { + const map = new RoutePrefixMap([]) + + const result = map.findParentForNonNested('/users', '/users_', []) + expect(result).toBeNull() + }) + + it('finds longest matching non-nested parent when multiple exist', () => { + const parentRoute = createRoute({ + routePath: '/non-nested/deep/$baz/bar', + originalRoutePath: '/non-nested/deep/$baz_/bar', + _isExperimentalNonNestedRoute: true, + }) + const grandparentRoute = createRoute({ + routePath: '/non-nested/deep/$baz', + originalRoutePath: '/non-nested/deep/$baz', + _fsRouteType: 'layout', + }) + const map = new RoutePrefixMap([grandparentRoute, parentRoute]) + + // Child route should find the closest non-nested parent + const result = map.findParentForNonNested( + '/non-nested/deep/$baz/bar/$foo', + '/non-nested/deep/$baz_/bar/$foo', + ['/non-nested/deep/$baz_'], + ) + expect(result).toBe(parentRoute) + }) + + it('correctly handles index vs route distinction via parent matching', () => { + // Simulates route.tsx and index.tsx for same path prefix + const layoutRoute = createRoute({ + routePath: '/non-nested/deep/$baz/bar/$foo', + originalRoutePath: '/non-nested/deep/$baz_/bar/$foo', + _isExperimentalNonNestedRoute: true, + _fsRouteType: 'layout', + }) + const map = new RoutePrefixMap([layoutRoute]) + + // Index route looking for parent should find layout + const result = map.findParentForNonNested( + '/non-nested/deep/$baz/bar/$foo/', + '/non-nested/deep/$baz_/bar/$foo/', + ['/non-nested/deep/$baz_'], + ) + expect(result).toBe(layoutRoute) + }) + }) +})