Skip to content

Commit

Permalink
fix: inference of paths for typesafeBrowserRouter
Browse files Browse the repository at this point in the history
  • Loading branch information
fredericoo committed Apr 3, 2024
1 parent 0008353 commit a001404
Show file tree
Hide file tree
Showing 10 changed files with 248 additions and 87 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-pugs-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router-typesafe": patch
---

Fixed an issue where mixed paths with children and without would lead to flaky inference of paths
5 changes: 5 additions & 0 deletions .changeset/loud-feet-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router-typesafe": minor
---

typesafeBrowserRouter now infers correct paths for relative paths
1 change: 1 addition & 0 deletions .prettierrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ module.exports = {
useTabs: true,
singleQuote: true,
endOfLine: 'auto',
experimentalTernaries: true,
};
32 changes: 16 additions & 16 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"typescript.preferences.importModuleSpecifier": "non-relative"
},
"[typescriptreact]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"window.commandCenter": true,
"editor.rulers": [120],
"workbench.tree.indent": 16
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"typescript.preferences.importModuleSpecifier": "non-relative"
},
"[typescriptreact]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
}
},
"window.commandCenter": true,
"editor.rulers": [120],
"workbench.tree.indent": 16
}
Binary file modified bun.lockb
Binary file not shown.
104 changes: 52 additions & 52 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,54 +1,54 @@
{
"name": "react-router-typesafe",
"version": "1.4.4",
"author": "fredericoo",
"repository": {
"type": "git",
"url": "https://github.com/stargaze-co/react-router-typesafe.git"
},
"scripts": {
"build": "tsup",
"release": "bun run build && changeset publish"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@happy-dom/global-registrator": "^11.0.6",
"@testing-library/react": "^14.0.0",
"bun-types": "^1.0.1",
"eslint": "^8.45.0",
"expect-type": "^0.16.0",
"happy-dom": "^11.0.6",
"prettier": "^3.0.0",
"react": "^18.2.0",
"react-router-dom": "^6.16.0",
"tsup": "^7.1.0",
"typescript": "^5.1.6"
},
"peerDependencies": {
"react": ">= 17",
"react-router-dom": ">= 6.4.0",
"typescript": ">= 4.9"
},
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"description": "type safe patches of react-router-dom",
"files": [
"dist"
],
"keywords": [
"react",
"react-router",
"react-router-dom",
"remix",
"remix-router"
],
"license": "ISC",
"types": "./dist/index.d.ts"
"name": "react-router-typesafe",
"version": "1.4.4",
"author": "fredericoo",
"repository": {
"type": "git",
"url": "https://github.com/stargaze-co/react-router-typesafe.git"
},
"scripts": {
"build": "tsup",
"release": "bun run build && changeset publish"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"devDependencies": {
"@changesets/cli": "^2.26.2",
"@happy-dom/global-registrator": "^11.0.6",
"@testing-library/react": "^14.0.0",
"bun-types": "^1.0.1",
"eslint": "^8.45.0",
"expect-type": "^0.16.0",
"happy-dom": "^11.0.6",
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-router-dom": "^6.16.0",
"tsup": "^7.1.0",
"typescript": "^5.1.6"
},
"peerDependencies": {
"react": ">= 17",
"react-router-dom": ">= 6.4.0",
"typescript": ">= 4.9"
},
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"description": "type safe patches of react-router-dom",
"files": [
"dist"
],
"keywords": [
"react",
"react-router",
"react-router-dom",
"remix",
"remix-router"
],
"license": "ISC",
"types": "./dist/index.d.ts"
}
74 changes: 74 additions & 0 deletions src/__tests__/browser-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { test, expect } from 'bun:test';
import { typesafeBrowserRouter } from '../browser-router';
import { RouteObject } from 'react-router-dom';

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

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

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

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

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

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

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

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

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

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

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

expect(output).toEqual('/blog');
});

test('works with pathless routes', () => {
const grandChildren = [{ element: null }, { path: '/blog/:postId/comments' }] as const satisfies RouteObject[];
const children = [{ children: grandChildren }] as const satisfies RouteObject[];

const { href } = typesafeBrowserRouter([{ path: '/blog', children }]);

const output = href({ path: '/blog/:postId/comments', params: { postId: 'asd' } });
// @ts-expect-error
const wrongOutput = href({ path: 'non-existing-route' });

expect(output).toEqual('/blog/asd/comments');
});

test('can reference groups of routes by variable on several layers', () => {
const appRoutes = [
{
index: true,
element: null,
},
{
path: '/app/contact',
element: null,
},
] as const satisfies RouteObject[];

const { href } = typesafeBrowserRouter([
{
path: '/',
children: [
{
index: true,
element: null,
},
{
path: '/app',
element: null,
children: appRoutes,
},
],
},
]);

const output = href({ path: '/app/contact' });
// @ts-expect-error
const wrongOutput = href({ path: 'non-existing-route' });

expect(output).toEqual('/app/contact');
});

test('works with relative paths', () => {
const { href } = typesafeBrowserRouter([
{ path: '/', children: [{ path: 'blog', children: [{ path: ':postId', children: [{ path: 'comments' }] }] }] },
]);

const output = href({ path: '/blog/:postId/comments', params: { postId: 'asd' } });
// @ts-expect-error
const wrongOutput = href({ path: '/comments' });

expect(output).toEqual('/blog/asd/comments');
});
43 changes: 25 additions & 18 deletions src/browser-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@ type PathParams<T> = keyof T extends never ? { params?: never } : { params: T };

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

type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
? ExtractParam<Segment, ExtractParams<Rest>>
: ExtractParam<Path, {}>;
type ExtractParams<Path> =
Path extends `${infer Segment}/${infer Rest}` ? ExtractParam<Segment, ExtractParams<Rest>> : ExtractParam<Path, {}>;

type ExtractPaths<Route extends RouteObject> = Route extends {
children: infer C extends RouteObject[];
path: infer P extends string;
}
? P | ExtractPaths<C[number]>
: Route extends { children: infer C extends RouteObject[] }
? ExtractPaths<C[number]>
: Route extends { path: infer P extends string }
? P
type PrefixIfRelative<Path extends string, Prefix extends string> =
Path extends `/${string}` ? Path
: Prefix extends '' ? `/${Path}`
: Prefix extends '/' ? `${Prefix}${Path}`
: `${Prefix}/${Path}`;

type ExtractPaths<Route extends RouteObject, Prefix extends string> =
Route extends (
{
children: infer C extends RouteObject[];
path: infer P extends string;
}
) ?
PrefixIfRelative<P, Prefix> | ExtractPaths<C[number], PrefixIfRelative<P, Prefix>>
: Route extends { children: infer C extends RouteObject[] } ? ExtractPaths<C[number], Prefix>
: Route extends { path: infer P extends string } ? PrefixIfRelative<P, Prefix>
: never;

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

export const typesafeBrowserRouter = <R extends RouteObject>(routes: NarrowArray<R>) => {
type Paths = ExtractPaths<R>;
export const typesafeBrowserRouter = <const R extends RouteObject>(routes: NarrowArray<R>) => {
type Paths = ExtractPaths<R, ''>;

function href<P extends Paths>(
params: { path: Extract<P, string> } & PathParams<Flatten<ExtractParams<P>>> & RouteExtraParams,
) {
// applies all params to the path
const path = params?.params
? Object.keys(params.params).reduce((path, param) => {
const path =
params?.params ?
Object.keys(params.params).reduce((path, param) => {
const value = params.params![param as keyof ExtractParams<P>];
if (typeof value !== 'string') throw new Error(`Route param ${param} must be a string`);
return path.replace(`:${param}`, value);
}, params.path)
: params.path;
}, params.path)
: params.path;

const searchParams = new URLSearchParams(params?.searchParams);
const hash = params?.hash?.replace(/^#/, '');
Expand Down
Loading

0 comments on commit a001404

Please sign in to comment.