Skip to content

Commit a001404

Browse files
committed
fix: inference of paths for typesafeBrowserRouter
1 parent 0008353 commit a001404

File tree

10 files changed

+248
-87
lines changed

10 files changed

+248
-87
lines changed

.changeset/friendly-pugs-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router-typesafe": patch
3+
---
4+
5+
Fixed an issue where mixed paths with children and without would lead to flaky inference of paths

.changeset/loud-feet-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-router-typesafe": minor
3+
---
4+
5+
typesafeBrowserRouter now infers correct paths for relative paths

.prettierrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ module.exports = {
44
useTabs: true,
55
singleQuote: true,
66
endOfLine: 'auto',
7+
experimentalTernaries: true,
78
};

.vscode/settings.json

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
{
2-
"editor.codeActionsOnSave": {
3-
"source.fixAll.eslint": true
4-
},
5-
"editor.formatOnSave": true,
6-
"editor.defaultFormatter": "esbenp.prettier-vscode",
7-
"[typescript]": {
8-
"typescript.preferences.importModuleSpecifier": "non-relative"
9-
},
10-
"[typescriptreact]": {
11-
"editor.codeActionsOnSave": {
12-
"source.organizeImports": true
13-
}
14-
},
15-
"window.commandCenter": true,
16-
"editor.rulers": [120],
17-
"workbench.tree.indent": 16
2+
"editor.codeActionsOnSave": {
3+
"source.fixAll.eslint": "explicit"
4+
},
5+
"editor.formatOnSave": true,
6+
"editor.defaultFormatter": "esbenp.prettier-vscode",
7+
"[typescript]": {
8+
"typescript.preferences.importModuleSpecifier": "non-relative"
9+
},
10+
"[typescriptreact]": {
11+
"editor.codeActionsOnSave": {
12+
"source.organizeImports": "explicit"
13+
}
14+
},
15+
"window.commandCenter": true,
16+
"editor.rulers": [120],
17+
"workbench.tree.indent": 16
1818
}

bun.lockb

0 Bytes
Binary file not shown.

package.json

Lines changed: 52 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,54 @@
11
{
2-
"name": "react-router-typesafe",
3-
"version": "1.4.4",
4-
"author": "fredericoo",
5-
"repository": {
6-
"type": "git",
7-
"url": "https://github.com/stargaze-co/react-router-typesafe.git"
8-
},
9-
"scripts": {
10-
"build": "tsup",
11-
"release": "bun run build && changeset publish"
12-
},
13-
"main": "./dist/index.js",
14-
"module": "./dist/index.mjs",
15-
"devDependencies": {
16-
"@changesets/cli": "^2.26.2",
17-
"@happy-dom/global-registrator": "^11.0.6",
18-
"@testing-library/react": "^14.0.0",
19-
"bun-types": "^1.0.1",
20-
"eslint": "^8.45.0",
21-
"expect-type": "^0.16.0",
22-
"happy-dom": "^11.0.6",
23-
"prettier": "^3.0.0",
24-
"react": "^18.2.0",
25-
"react-router-dom": "^6.16.0",
26-
"tsup": "^7.1.0",
27-
"typescript": "^5.1.6"
28-
},
29-
"peerDependencies": {
30-
"react": ">= 17",
31-
"react-router-dom": ">= 6.4.0",
32-
"typescript": ">= 4.9"
33-
},
34-
"exports": {
35-
".": {
36-
"require": "./dist/index.js",
37-
"import": "./dist/index.mjs",
38-
"types": "./dist/index.d.ts"
39-
}
40-
},
41-
"description": "type safe patches of react-router-dom",
42-
"files": [
43-
"dist"
44-
],
45-
"keywords": [
46-
"react",
47-
"react-router",
48-
"react-router-dom",
49-
"remix",
50-
"remix-router"
51-
],
52-
"license": "ISC",
53-
"types": "./dist/index.d.ts"
2+
"name": "react-router-typesafe",
3+
"version": "1.4.4",
4+
"author": "fredericoo",
5+
"repository": {
6+
"type": "git",
7+
"url": "https://github.com/stargaze-co/react-router-typesafe.git"
8+
},
9+
"scripts": {
10+
"build": "tsup",
11+
"release": "bun run build && changeset publish"
12+
},
13+
"main": "./dist/index.js",
14+
"module": "./dist/index.mjs",
15+
"devDependencies": {
16+
"@changesets/cli": "^2.26.2",
17+
"@happy-dom/global-registrator": "^11.0.6",
18+
"@testing-library/react": "^14.0.0",
19+
"bun-types": "^1.0.1",
20+
"eslint": "^8.45.0",
21+
"expect-type": "^0.16.0",
22+
"happy-dom": "^11.0.6",
23+
"prettier": "^3.2.5",
24+
"react": "^18.2.0",
25+
"react-router-dom": "^6.16.0",
26+
"tsup": "^7.1.0",
27+
"typescript": "^5.1.6"
28+
},
29+
"peerDependencies": {
30+
"react": ">= 17",
31+
"react-router-dom": ">= 6.4.0",
32+
"typescript": ">= 4.9"
33+
},
34+
"exports": {
35+
".": {
36+
"require": "./dist/index.js",
37+
"import": "./dist/index.mjs",
38+
"types": "./dist/index.d.ts"
39+
}
40+
},
41+
"description": "type safe patches of react-router-dom",
42+
"files": [
43+
"dist"
44+
],
45+
"keywords": [
46+
"react",
47+
"react-router",
48+
"react-router-dom",
49+
"remix",
50+
"remix-router"
51+
],
52+
"license": "ISC",
53+
"types": "./dist/index.d.ts"
5454
}

src/__tests__/browser-router.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { test, expect } from 'bun:test';
22
import { typesafeBrowserRouter } from '../browser-router';
3+
import { RouteObject } from 'react-router-dom';
34

45
test('returns pathname with replaced params', () => {
56
const { href } = typesafeBrowserRouter([
67
{ path: '/blog', children: [{ path: '/blog/:postId', children: [{ path: '/blog/:postId/:commentId' }] }] },
78
]);
89

910
const output = href({ path: '/blog/:postId/:commentId', params: { postId: 'foo', commentId: 'bar' } });
11+
// @ts-expect-error
12+
const wrongOutput = href({ path: 'non-existing-route' });
1013

1114
expect(output).toEqual('/blog/foo/bar');
1215
});
@@ -28,6 +31,8 @@ test('Returns right paths with pathless routes with children', () => {
2831
]);
2932

3033
const output = href({ path: '/blog/:postId/:commentId', params: { postId: 'foo', commentId: 'bar' } });
34+
// @ts-expect-error
35+
const wrongOutput = href({ path: 'non-existing-route' });
3136

3237
expect(output).toEqual('/blog/foo/bar');
3338
});
@@ -36,6 +41,8 @@ test('returns pathname with search params if object is passed', () => {
3641
const { href } = typesafeBrowserRouter([{ path: '/blog', children: [{ path: '/blog/:postId' }] }]);
3742

3843
const output = href({ path: '/blog/:postId', params: { postId: 'foo' }, searchParams: { foo: 'bar' } });
44+
// @ts-expect-error
45+
const wrongOutput = href({ path: 'non-existing-route' });
3946

4047
expect(output).toEqual('/blog/foo?foo=bar');
4148
});
@@ -48,6 +55,8 @@ test('returns pathname with search params if URLSearchParams is passed', () => {
4855
params: { postId: 'foo' },
4956
searchParams: new URLSearchParams({ foo: 'bar' }),
5057
});
58+
// @ts-expect-error
59+
const wrongOutput = href({ path: 'non-existing-route' });
5160

5261
expect(output).toEqual('/blog/foo?foo=bar');
5362
});
@@ -56,6 +65,8 @@ test('returns pathname with hash', () => {
5665
const { href } = typesafeBrowserRouter([{ path: '/blog', children: [{ path: '/blog/:postId' }] }]);
5766

5867
const output = href({ path: '/blog/:postId', params: { postId: 'foo' }, hash: '#foo' });
68+
// @ts-expect-error
69+
const wrongOutput = href({ path: 'non-existing-route' });
5970

6071
expect(output).toEqual('/blog/foo#foo');
6172
});
@@ -157,6 +168,69 @@ test('typescript stress test with many routes and layers', () => {
157168
]);
158169

159170
const output = href({ path: '/blog' });
171+
// @ts-expect-error
172+
const wrongOutput = href({ path: 'non-existing-route' });
160173

161174
expect(output).toEqual('/blog');
162175
});
176+
177+
test('works with pathless routes', () => {
178+
const grandChildren = [{ element: null }, { path: '/blog/:postId/comments' }] as const satisfies RouteObject[];
179+
const children = [{ children: grandChildren }] as const satisfies RouteObject[];
180+
181+
const { href } = typesafeBrowserRouter([{ path: '/blog', children }]);
182+
183+
const output = href({ path: '/blog/:postId/comments', params: { postId: 'asd' } });
184+
// @ts-expect-error
185+
const wrongOutput = href({ path: 'non-existing-route' });
186+
187+
expect(output).toEqual('/blog/asd/comments');
188+
});
189+
190+
test('can reference groups of routes by variable on several layers', () => {
191+
const appRoutes = [
192+
{
193+
index: true,
194+
element: null,
195+
},
196+
{
197+
path: '/app/contact',
198+
element: null,
199+
},
200+
] as const satisfies RouteObject[];
201+
202+
const { href } = typesafeBrowserRouter([
203+
{
204+
path: '/',
205+
children: [
206+
{
207+
index: true,
208+
element: null,
209+
},
210+
{
211+
path: '/app',
212+
element: null,
213+
children: appRoutes,
214+
},
215+
],
216+
},
217+
]);
218+
219+
const output = href({ path: '/app/contact' });
220+
// @ts-expect-error
221+
const wrongOutput = href({ path: 'non-existing-route' });
222+
223+
expect(output).toEqual('/app/contact');
224+
});
225+
226+
test('works with relative paths', () => {
227+
const { href } = typesafeBrowserRouter([
228+
{ path: '/', children: [{ path: 'blog', children: [{ path: ':postId', children: [{ path: 'comments' }] }] }] },
229+
]);
230+
231+
const output = href({ path: '/blog/:postId/comments', params: { postId: 'asd' } });
232+
// @ts-expect-error
233+
const wrongOutput = href({ path: '/comments' });
234+
235+
expect(output).toEqual('/blog/asd/comments');
236+
});

src/browser-router.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,25 @@ type PathParams<T> = keyof T extends never ? { params?: never } : { params: T };
1313

1414
type ExtractParam<Path, NextPart> = Path extends `:${infer Param}` ? Record<Param, string> & NextPart : NextPart;
1515

16-
type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
17-
? ExtractParam<Segment, ExtractParams<Rest>>
18-
: ExtractParam<Path, {}>;
16+
type ExtractParams<Path> =
17+
Path extends `${infer Segment}/${infer Rest}` ? ExtractParam<Segment, ExtractParams<Rest>> : ExtractParam<Path, {}>;
1918

20-
type ExtractPaths<Route extends RouteObject> = Route extends {
21-
children: infer C extends RouteObject[];
22-
path: infer P extends string;
23-
}
24-
? P | ExtractPaths<C[number]>
25-
: Route extends { children: infer C extends RouteObject[] }
26-
? ExtractPaths<C[number]>
27-
: Route extends { path: infer P extends string }
28-
? P
19+
type PrefixIfRelative<Path extends string, Prefix extends string> =
20+
Path extends `/${string}` ? Path
21+
: Prefix extends '' ? `/${Path}`
22+
: Prefix extends '/' ? `${Prefix}${Path}`
23+
: `${Prefix}/${Path}`;
24+
25+
type ExtractPaths<Route extends RouteObject, Prefix extends string> =
26+
Route extends (
27+
{
28+
children: infer C extends RouteObject[];
29+
path: infer P extends string;
30+
}
31+
) ?
32+
PrefixIfRelative<P, Prefix> | ExtractPaths<C[number], PrefixIfRelative<P, Prefix>>
33+
: Route extends { children: infer C extends RouteObject[] } ? ExtractPaths<C[number], Prefix>
34+
: Route extends { path: infer P extends string } ? PrefixIfRelative<P, Prefix>
2935
: never;
3036

3137
type TypesafeSearchParams = Record<string, string> | URLSearchParams;
@@ -36,20 +42,21 @@ const joinValidWith =
3642
(...valid: any[]) =>
3743
valid.filter(Boolean).join(separator);
3844

39-
export const typesafeBrowserRouter = <R extends RouteObject>(routes: NarrowArray<R>) => {
40-
type Paths = ExtractPaths<R>;
45+
export const typesafeBrowserRouter = <const R extends RouteObject>(routes: NarrowArray<R>) => {
46+
type Paths = ExtractPaths<R, ''>;
4147

4248
function href<P extends Paths>(
4349
params: { path: Extract<P, string> } & PathParams<Flatten<ExtractParams<P>>> & RouteExtraParams,
4450
) {
4551
// applies all params to the path
46-
const path = params?.params
47-
? Object.keys(params.params).reduce((path, param) => {
52+
const path =
53+
params?.params ?
54+
Object.keys(params.params).reduce((path, param) => {
4855
const value = params.params![param as keyof ExtractParams<P>];
4956
if (typeof value !== 'string') throw new Error(`Route param ${param} must be a string`);
5057
return path.replace(`:${param}`, value);
51-
}, params.path)
52-
: params.path;
58+
}, params.path)
59+
: params.path;
5360

5461
const searchParams = new URLSearchParams(params?.searchParams);
5562
const hash = params?.hash?.replace(/^#/, '');

0 commit comments

Comments
 (0)