Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/remix/src/config/createRemixRouteManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand Down Expand Up @@ -75,18 +76,22 @@ export function convertRemixRouteToPath(filename: string): { path: string; isDyn
continue;
}

Comment thread
nicohrubec marked this conversation as resolved.
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);
}
}

Expand Down
12 changes: 8 additions & 4 deletions packages/remix/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
19 changes: 19 additions & 0 deletions packages/remix/test/config/routeConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/remix/test/utils/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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/:*');
Expand Down
Loading