From 9ae4652389885c6f7aface5fc5098fe655d1548c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 7 Sep 2026 09:32:54 +0200 Subject: [PATCH] fix(remix): Strip trailing underscore from route segments A trailing underscore in a Remix v2 flat route segment opts that segment out of layout nesting without appearing in the URL. Neither route-to-path converter handled it, so `routes/concerts_.mine` was named `/concerts_/mine` with source `route` - a high-confidence parameterized name matching no real URL. Strip one trailing underscore after the pathless-layout check and before the dynamic-segment handling, mirroring Remix's own createRoutePath. The ordering keeps `_auth_.login` a skipped layout and makes `$id_` resolve to `:id`. Fixes #24119 Co-Authored-By: Claude Opus 5 --- .../src/config/createRemixRouteManifest.ts | 13 ++++++--- packages/remix/src/utils/utils.ts | 12 ++++++--- .../remix/test/config/routeConversion.test.ts | 19 +++++++++++++ packages/remix/test/utils/utils.test.ts | 27 +++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/packages/remix/src/config/createRemixRouteManifest.ts b/packages/remix/src/config/createRemixRouteManifest.ts index ed0f394e5d66..996f8b479b8d 100644 --- a/packages/remix/src/config/createRemixRouteManifest.ts +++ b/packages/remix/src/config/createRemixRouteManifest.ts @@ -36,6 +36,7 @@ function isRouteFile(filename: string): boolean { * - users/$id.tsx (nested folder) -> /users/:id * - users/$id/posts.tsx (nested folder) -> /users/:id/posts * - users/_index.tsx (nested folder) -> /users + * - concerts_.mine.tsx -> /concerts/mine (trailing underscore opts out of layout nesting only) * - _layout.tsx -> null (pathless layout route, not URL-addressable) * - _auth.tsx -> null (pathless layout route, not URL-addressable) * @@ -75,18 +76,22 @@ export function convertRemixRouteToPath(filename: string): { path: string; isDyn continue; } - if (segment === '$') { + // A trailing underscore opts a segment out of layout nesting without appearing in the URL, + // so it has to be dropped before the segment is turned into a path segment. + const pathSegment = segment.endsWith('_') ? segment.slice(0, -1) : segment; + + if (pathSegment === '$') { pathSegments.push(':*'); isDynamic = true; continue; } - if (segment.startsWith('$')) { - const paramName = segment.substring(1); + if (pathSegment.startsWith('$')) { + const paramName = pathSegment.substring(1); pathSegments.push(`:${paramName}`); isDynamic = true; } else { - pathSegments.push(segment); + pathSegments.push(pathSegment); } } diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index d21bc77a9b16..73b097b2ede0 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -70,19 +70,23 @@ export function convertRemixRouteIdToPath(routeId: string): string { continue; } + // A trailing underscore opts a segment out of layout nesting without appearing in the URL, + // so it has to be dropped before the segment is turned into a path segment. + const pathSegment = segment.endsWith('_') ? segment.slice(0, -1) : segment; + // Handle splat routes (catch-all) // Remix accesses splat params via params["*"] at runtime - if (segment === '$') { + if (pathSegment === '$') { pathSegments.push(':*'); continue; } // Handle dynamic segments (prefixed with $) - if (segment.startsWith('$')) { - const paramName = segment.substring(1); + if (pathSegment.startsWith('$')) { + const paramName = pathSegment.substring(1); pathSegments.push(`:${paramName}`); } else { - pathSegments.push(segment); + pathSegments.push(pathSegment); } } diff --git a/packages/remix/test/config/routeConversion.test.ts b/packages/remix/test/config/routeConversion.test.ts index 53d2d53b668a..02320de1eaeb 100644 --- a/packages/remix/test/config/routeConversion.test.ts +++ b/packages/remix/test/config/routeConversion.test.ts @@ -146,6 +146,25 @@ describe('Route Conversion Consistency', () => { }); }); + describe('Trailing underscore routes', () => { + it('should strip a trailing underscore at build time and at runtime', () => { + const buildTime = convertRemixRouteToPath('concerts_.mine.tsx'); + const runtime = convertRemixRouteIdToPath('routes/concerts_.mine'); + + expect(buildTime?.path).toBe('/concerts/mine'); + expect(runtime).toBe('/concerts/mine'); + }); + + it('should strip a trailing underscore from dynamic segments', () => { + const buildTime = convertRemixRouteToPath('app.projects.$id_.roadmap.tsx'); + const runtime = convertRemixRouteIdToPath('routes/app.projects.$id_.roadmap'); + + expect(buildTime?.path).toBe('/app/projects/:id/roadmap'); + expect(buildTime?.isDynamic).toBe(true); + expect(runtime).toBe('/app/projects/:id/roadmap'); + }); + }); + describe('Pathless layout routes', () => { it('should return null for standalone pathless layout routes', () => { // These are layout routes that don't contribute to the URL path diff --git a/packages/remix/test/utils/utils.test.ts b/packages/remix/test/utils/utils.test.ts index 94bfdacc4d8b..c3a12af0644b 100644 --- a/packages/remix/test/utils/utils.test.ts +++ b/packages/remix/test/utils/utils.test.ts @@ -16,9 +16,21 @@ describe('getTransactionName', () => { id: 'routes/blog.$slug', path: '/blog/:slug', }, + { + id: 'routes/concerts_.mine', + path: '/concerts/mine', + }, ]; describe('route parameterization', () => { + it('should not leak a trailing underscore into the transaction name', () => { + const url = new URL('http://localhost/concerts/mine'); + const [name, source] = getTransactionName(mockRoutes, url); + + expect(name).toBe('/concerts/mine'); + expect(source).toBe('route'); + }); + it('should return parameterized path for matched dynamic routes', () => { const url = new URL('http://localhost/user/123'); const [name, source] = getTransactionName(mockRoutes, url); @@ -150,6 +162,21 @@ describe('convertRemixRouteIdToPath', () => { }); }); + describe('trailing underscore routes', () => { + it('should strip a trailing underscore from static segments', () => { + expect(convertRemixRouteIdToPath('routes/concerts_.mine')).toBe('/concerts/mine'); + expect(convertRemixRouteIdToPath('routes/app_.projects.$id.roadmap')).toBe('/app/projects/:id/roadmap'); + }); + + it('should strip a trailing underscore from dynamic segments', () => { + expect(convertRemixRouteIdToPath('routes/app.projects.$id_.roadmap')).toBe('/app/projects/:id/roadmap'); + }); + + it('should still skip segments that also start with an underscore', () => { + expect(convertRemixRouteIdToPath('routes/_auth_.login')).toBe('/login'); + }); + }); + describe('splat routes', () => { it('should convert splat routes', () => { expect(convertRemixRouteIdToPath('routes/docs.$')).toBe('/docs/:*');