Skip to content

Commit eb00778

Browse files
committed
Also cover _index segments at the end
1 parent 50a2c11 commit eb00778

File tree

4 files changed

+218
-6
lines changed

4 files changed

+218
-6
lines changed

packages/remix/src/config/createRemixRouteManifest.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function convertRemixRouteToPath(filename: string): { path: string; isDyn
5656
const segments = normalizedBasename.split('.');
5757
const pathSegments: string[] = [];
5858
let isDynamic = false;
59+
let isIndexRoute = false;
5960

6061
for (let i = 0; i < segments.length; i++) {
6162
const segment = segments[i];
@@ -68,7 +69,16 @@ export function convertRemixRouteToPath(filename: string): { path: string; isDyn
6869
continue;
6970
}
7071

72+
// Handle '_index' segments at the end (always skip - indicates an index route)
73+
if (segment === '_index' && i === segments.length - 1) {
74+
isIndexRoute = true;
75+
continue;
76+
}
77+
78+
// Handle 'index' segments at the end (skip only if there are path segments,
79+
// otherwise root index is handled by the early return above)
7180
if (segment === 'index' && i === segments.length - 1 && pathSegments.length > 0) {
81+
isIndexRoute = true;
7282
continue;
7383
}
7484

@@ -87,13 +97,13 @@ export function convertRemixRouteToPath(filename: string): { path: string; isDyn
8797
}
8898
}
8999

90-
// If all segments were skipped (pathless layout route like _layout.tsx, _auth.tsx),
91-
// return null to indicate this file should not be added to the route manifest
92-
if (pathSegments.length === 0) {
100+
// If all segments were skipped AND it's not an index route,
101+
// it's a pathless layout route (like _layout.tsx, _auth.tsx) - exclude from manifest
102+
if (pathSegments.length === 0 && !isIndexRoute) {
93103
return null;
94104
}
95105

96-
const path = `/${pathSegments.join('/')}`;
106+
const path = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/';
97107
return { path, isDynamic };
98108
}
99109

packages/remix/src/utils/utils.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,13 @@ export function convertRemixRouteIdToPath(routeId: string): string {
7676
continue;
7777
}
7878

79-
// Handle 'index' segments at the end (they don't add to the path)
79+
// Handle '_index' segments at the end (always skip - indicates an index route)
80+
if (segment === '_index' && i === segments.length - 1) {
81+
continue;
82+
}
83+
84+
// Handle 'index' segments at the end (skip only if there are path segments,
85+
// otherwise root index is handled by the early return above)
8086
if (segment === 'index' && i === segments.length - 1 && pathSegments.length > 0) {
8187
continue;
8288
}

packages/remix/test/config/manifest/createRemixRouteManifest.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,95 @@ describe('createRemixRouteManifest', () => {
133133
);
134134
});
135135
});
136+
137+
describe('_index route handling', () => {
138+
it('should handle root _index route', () => {
139+
const { tempDir, routesDir } = createTestDir();
140+
141+
fs.writeFileSync(path.join(routesDir, '_index.tsx'), '// root index');
142+
143+
const manifest = createRemixRouteManifest({ rootDir: tempDir });
144+
145+
expect(manifest.staticRoutes).toHaveLength(1);
146+
expect(manifest.staticRoutes).toContainEqual({ path: '/' });
147+
});
148+
149+
it('should handle nested _index routes using flat file convention', () => {
150+
const { tempDir, routesDir } = createTestDir();
151+
152+
// Flat file convention: users._index.tsx represents /users index
153+
fs.writeFileSync(path.join(routesDir, '_index.tsx'), '// root index');
154+
fs.writeFileSync(path.join(routesDir, 'users._index.tsx'), '// users index');
155+
fs.writeFileSync(path.join(routesDir, 'users.$id.tsx'), '// user detail');
156+
157+
const manifest = createRemixRouteManifest({ rootDir: tempDir });
158+
159+
// Both root and users index should map to their parent paths
160+
expect(manifest.staticRoutes).toContainEqual({ path: '/' });
161+
expect(manifest.staticRoutes).toContainEqual({ path: '/users' });
162+
163+
// users.$id.tsx should be a dynamic route
164+
expect(manifest.dynamicRoutes).toContainEqual(
165+
expect.objectContaining({
166+
path: '/users/:id',
167+
regex: '^/users/([^/]+)$',
168+
paramNames: ['id'],
169+
}),
170+
);
171+
172+
// Should NOT contain /users/_index as a path
173+
expect(manifest.staticRoutes).not.toContainEqual({ path: '/users/_index' });
174+
});
175+
176+
it('should handle deeply nested _index routes', () => {
177+
const { tempDir, routesDir } = createTestDir();
178+
179+
// Flat file convention for deeply nested index
180+
fs.writeFileSync(path.join(routesDir, 'admin.settings._index.tsx'), '// admin settings index');
181+
182+
const manifest = createRemixRouteManifest({ rootDir: tempDir });
183+
184+
expect(manifest.staticRoutes).toContainEqual({ path: '/admin/settings' });
185+
expect(manifest.staticRoutes).not.toContainEqual({ path: '/admin/settings/_index' });
186+
});
187+
188+
it('should handle _index in directory-based nested routes', () => {
189+
const { tempDir, routesDir } = createTestDir();
190+
const usersDir = path.join(routesDir, 'users');
191+
fs.mkdirSync(usersDir, { recursive: true });
192+
193+
fs.writeFileSync(path.join(routesDir, '_index.tsx'), '// root index');
194+
fs.writeFileSync(path.join(usersDir, '_index.tsx'), '// users index');
195+
fs.writeFileSync(path.join(usersDir, '$id.tsx'), '// user detail');
196+
197+
const manifest = createRemixRouteManifest({ rootDir: tempDir });
198+
199+
expect(manifest.staticRoutes).toContainEqual({ path: '/' });
200+
expect(manifest.staticRoutes).toContainEqual({ path: '/users' });
201+
expect(manifest.dynamicRoutes).toContainEqual(
202+
expect.objectContaining({
203+
path: '/users/:id',
204+
}),
205+
);
206+
});
207+
208+
it('should handle _index with layout routes', () => {
209+
const { tempDir, routesDir } = createTestDir();
210+
211+
// _auth is a pathless layout, _auth._index is its index
212+
fs.writeFileSync(path.join(routesDir, '_auth.tsx'), '// auth layout');
213+
fs.writeFileSync(path.join(routesDir, '_auth._index.tsx'), '// auth index');
214+
fs.writeFileSync(path.join(routesDir, '_auth.login.tsx'), '// login page');
215+
216+
const manifest = createRemixRouteManifest({ rootDir: tempDir });
217+
218+
// _auth layout should be excluded (pathless layout)
219+
// _auth._index should map to / (root under pathless layout)
220+
// _auth.login should map to /login
221+
expect(manifest.staticRoutes).toContainEqual({ path: '/' });
222+
expect(manifest.staticRoutes).toContainEqual({ path: '/login' });
223+
expect(manifest.staticRoutes).not.toContainEqual({ path: '/_auth' });
224+
expect(manifest.staticRoutes).not.toContainEqual({ path: '/_auth/_index' });
225+
});
226+
});
136227
});

packages/remix/test/utils/utils.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgnosticRouteObject } from '@remix-run/router';
22
import { describe, expect, it } from 'vitest';
3-
import { getTransactionName } from '../../src/utils/utils';
3+
import { convertRemixRouteIdToPath, getTransactionName } from '../../src/utils/utils';
44

55
describe('getTransactionName', () => {
66
const mockRoutes: AgnosticRouteObject[] = [
@@ -74,5 +74,110 @@ describe('getTransactionName', () => {
7474
expect(name).toBe('/docs/:*');
7575
expect(source).toBe('route');
7676
});
77+
78+
it('should handle nested _index routes', () => {
79+
const routesWithNestedIndex: AgnosticRouteObject[] = [
80+
{
81+
id: 'routes/users._index',
82+
path: '/users',
83+
},
84+
];
85+
86+
const url = new URL('http://localhost/users');
87+
const [name, source] = getTransactionName(routesWithNestedIndex, url);
88+
89+
// Should return /users, not /users/_index
90+
expect(name).toBe('/users');
91+
expect(source).toBe('route');
92+
});
93+
});
94+
});
95+
96+
describe('convertRemixRouteIdToPath', () => {
97+
describe('basic routes', () => {
98+
it('should convert root _index route', () => {
99+
expect(convertRemixRouteIdToPath('routes/_index')).toBe('/');
100+
});
101+
102+
it('should convert root index route', () => {
103+
expect(convertRemixRouteIdToPath('routes/index')).toBe('/');
104+
});
105+
106+
it('should convert static routes', () => {
107+
expect(convertRemixRouteIdToPath('routes/about')).toBe('/about');
108+
expect(convertRemixRouteIdToPath('routes/contact')).toBe('/contact');
109+
});
110+
111+
it('should convert dynamic routes', () => {
112+
expect(convertRemixRouteIdToPath('routes/user.$id')).toBe('/user/:id');
113+
expect(convertRemixRouteIdToPath('routes/blog.$slug')).toBe('/blog/:slug');
114+
});
115+
});
116+
117+
describe('_index route handling', () => {
118+
it('should handle nested _index routes (flat file convention)', () => {
119+
// users._index should map to /users, not /users/_index
120+
expect(convertRemixRouteIdToPath('routes/users._index')).toBe('/users');
121+
});
122+
123+
it('should handle deeply nested _index routes', () => {
124+
expect(convertRemixRouteIdToPath('routes/admin.settings._index')).toBe('/admin/settings');
125+
expect(convertRemixRouteIdToPath('routes/api.v1.users._index')).toBe('/api/v1/users');
126+
});
127+
128+
it('should handle _index with dynamic segments', () => {
129+
// This represents an index route under a dynamic segment
130+
expect(convertRemixRouteIdToPath('routes/users.$id._index')).toBe('/users/:id');
131+
});
132+
133+
it('should handle _index under pathless layouts', () => {
134+
// _auth is a pathless layout, _auth._index is its index (maps to /)
135+
expect(convertRemixRouteIdToPath('routes/_auth._index')).toBe('/');
136+
// _dashboard.settings._index maps to /settings (skipping _dashboard)
137+
expect(convertRemixRouteIdToPath('routes/_dashboard.settings._index')).toBe('/settings');
138+
});
139+
140+
it('should NOT include _index as a path segment', () => {
141+
const result = convertRemixRouteIdToPath('routes/users._index');
142+
expect(result).not.toContain('_index');
143+
});
144+
});
145+
146+
describe('index route handling (non-underscore)', () => {
147+
it('should handle nested index routes', () => {
148+
expect(convertRemixRouteIdToPath('routes/users.index')).toBe('/users');
149+
});
150+
151+
it('should handle deeply nested index routes', () => {
152+
expect(convertRemixRouteIdToPath('routes/admin.settings.index')).toBe('/admin/settings');
153+
});
154+
});
155+
156+
describe('layout routes', () => {
157+
it('should skip pathless layout segments', () => {
158+
expect(convertRemixRouteIdToPath('routes/_auth.login')).toBe('/login');
159+
expect(convertRemixRouteIdToPath('routes/_dashboard.settings')).toBe('/settings');
160+
});
161+
162+
it('should handle multiple pathless layouts', () => {
163+
expect(convertRemixRouteIdToPath('routes/_auth._layout.login')).toBe('/login');
164+
});
165+
});
166+
167+
describe('splat routes', () => {
168+
it('should convert splat routes', () => {
169+
expect(convertRemixRouteIdToPath('routes/docs.$')).toBe('/docs/:*');
170+
expect(convertRemixRouteIdToPath('routes/$')).toBe('/:*');
171+
});
172+
});
173+
174+
describe('complex nested routes', () => {
175+
it('should handle multiple dynamic segments', () => {
176+
expect(convertRemixRouteIdToPath('routes/users.$userId.posts.$postId')).toBe('/users/:userId/posts/:postId');
177+
});
178+
179+
it('should handle mixed static and dynamic segments', () => {
180+
expect(convertRemixRouteIdToPath('routes/api.v1.users.$id.comments')).toBe('/api/v1/users/:id/comments');
181+
});
77182
});
78183
});

0 commit comments

Comments
 (0)