@@ -68,17 +68,6 @@ const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: strin
6868 return ! ! matchPath ( { pathname, componentProps : route . props } ) ;
6969} ;
7070
71- /**
72- * Checks if a route path consists entirely of parameterized segments (e.g., ":slug", ":category/:id").
73- * These routes match any single segment and should not drive the parent path deeper
74- * than the outlet's established mount point.
75- */
76- const isPurelyParameterized = ( routePath : string | undefined ) : boolean => {
77- if ( ! routePath ) return false ;
78- const segments = routePath . split ( '/' ) . filter ( Boolean ) ;
79- return segments . length > 0 && segments . every ( ( segment ) => segment . startsWith ( ':' ) ) ;
80- } ;
81-
8271/**
8372 * Checks if a route is a specific match (not wildcard-only or index).
8473 */
@@ -180,12 +169,21 @@ const couldSpecificRouteMatch = (
180169 // When mount path is established, skip this lookahead: the parent depth is known,
181170 // and purely parameterized routes (e.g., :slug) matching the last segment should
182171 // not prevent the wildcard from claiming the full remaining path.
172+ //
173+ // Only allow purely literal routes (no :params) in the lookahead. Routes with
174+ // parameters are positionally ambiguous — their params match any segment, so a
175+ // coincidental match at a deeper level (e.g., details/:id matching "details/99"
176+ // inside "extra/details/99") should not prevent the wildcard from capturing the
177+ // full remaining path.
183178 if ( ! outletMountPath ) {
184179 for ( let j = 1 ; j < segments . length ; j ++ ) {
185180 const futureRemaining = segments . slice ( j ) . join ( '/' ) ;
186181 const futureMatch = findFirstSpecificMatchingRoute ( routeChildren , futureRemaining ) ;
187- if ( futureMatch && ! isPurelyParameterized ( futureMatch . props . path as string ) ) {
188- return true ;
182+ if ( futureMatch ) {
183+ const futurePath = futureMatch . props . path as string | undefined ;
184+ if ( futurePath && ! futurePath . includes ( ':' ) ) {
185+ return true ;
186+ }
189187 }
190188 }
191189 }
@@ -314,13 +312,12 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
314312
315313 // Check for specific route match (highest priority)
316314 if ( ! firstSpecificMatch && findSpecificMatch ( routeChildren , remainingPath ) ) {
317- // Don't let purely parameterized routes (e.g., :slug, :id) drive the
318- // parent deeper than where a wildcard already matched. A :slug route
319- // matching the last segment of "deep/nested/path" shouldn't pull the
320- // parent to /parent/deep/nested — the wildcard at the correct depth
321- // should catch the full remaining path instead.
322- // Literal routes (e.g., "settings", "redirect") can still match beyond
323- // the wildcard depth to support redirect scenarios.
315+ // Don't let routes containing parameter segments (e.g., :slug, details/:id)
316+ // drive the parent deeper than where a wildcard already matched. Parameter
317+ // segments match any value, making tail-slice matches positionally ambiguous:
318+ // e.g., "details/:id" matching "details/99" inside "extra/details/99" is a
319+ // coincidental match at the wrong depth. Only purely literal routes (e.g.,
320+ // "settings", "redirect") can override the wildcard at deeper levels.
324321 //
325322 // Also don't let empty/default path routes (path="" or undefined) drive
326323 // the parent deeper than a wildcard match. An empty path route matching
@@ -334,7 +331,13 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath
334331 if ( matchingRoute ) {
335332 const matchingPath = matchingRoute . props . path as string | undefined ;
336333 const isEmptyPath = ! matchingPath || matchingPath === '' ;
337- if ( shouldSkipParameterized && ( isPurelyParameterized ( matchingPath as string ) || isEmptyPath ) ) {
334+ // When the parent path is deeper than expected (shouldSkipParameterized),
335+ // skip routes containing ANY parameterized segments. Parameters make tail-
336+ // slice matches positionally ambiguous: e.g., "details/:id" matching
337+ // "details/99" inside "extra/details/99" is a coincidental match at the
338+ // wrong depth. Only purely literal routes (e.g., "settings") can override
339+ // the wildcard at deeper levels.
340+ if ( shouldSkipParameterized && ( matchingPath ?. includes ( ':' ) || isEmptyPath ) ) {
338341 continue ;
339342 }
340343 if ( firstWildcardMatch && isEmptyPath ) {
0 commit comments