├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.cjs ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── bun.lockb ├── bunfig.toml ├── docs └── params.png ├── eslintrc.cjs ├── happydom.ts ├── package.json ├── src ├── __tests__ │ ├── action.typetest.ts │ ├── browser-router.test.ts │ └── loader.typetest.ts ├── action.ts ├── browser-router.ts ├── components │ ├── Await.tsx │ ├── Await.typetest.tsx │ └── index.ts ├── defer.ts ├── index.ts ├── loader.ts ├── revalidate.ts └── utils.ts ├── tsconfig.json └── tsup.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: write-all 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v3 18 | 19 | - uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | 23 | - name: Creating .npmrc 24 | run: | 25 | cat << EOF > "$HOME/.npmrc" 26 | //registry.npmjs.org/:_authToken=$NPM_TOKEN 27 | EOF 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - name: Install dependencies 32 | shell: bash 33 | run: bun install 34 | 35 | - name: Create Release Pull Request or Publish to npm 36 | id: changesets 37 | uses: changesets/action@v1 38 | with: 39 | publish: bun run release 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | printWidth: 120, 4 | useTabs: true, 5 | singleQuote: true, 6 | endOfLine: 'auto', 7 | experimentalTernaries: true, 8 | }; 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 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 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-router-typesafe 2 | 3 | ## 1.5.0 4 | 5 | ### Minor Changes 6 | 7 | - a001404: typesafeBrowserRouter now infers correct paths for relative paths 8 | 9 | ### Patch Changes 10 | 11 | - a001404: Fixed an issue where mixed paths with children and without would lead to flaky inference of paths 12 | 13 | ## 1.4.4 14 | 15 | ### Patch Changes 16 | 17 | - 1093a48: `typesafeBrowserRouter`: cache union of all paths in router scope 18 | 19 | ## 1.4.3 20 | 21 | ### Patch Changes 22 | 23 | - 1c5a838: Pathless routes with children now correctly infer their path 24 | 25 | ## 1.4.2 26 | 27 | ### Patch Changes 28 | 29 | - 41af0d5: Improve compiler performance for the `href` function returned by `typesafeBrowserRouter` by lazily calculating route params 30 | 31 | ## 1.4.1 32 | 33 | ### Patch Changes 34 | 35 | - 8917a9d: `typesafeBrowserRouter`: no longer requires `pathParams` to be passed as an empty object when route does not have dynamic segments 36 | 37 | ## 1.4.0 38 | 39 | ### Minor Changes 40 | 41 | - ce9bf9d: Added new utility `typesafeBrowserRouter` 42 | 43 | ## 1.3.4 44 | 45 | ### Patch Changes 46 | 47 | - `useActionData` now ignores Response objects as expected 48 | 49 | ## 1.3.3 50 | 51 | ### Patch Changes 52 | 53 | - a85b1a7: Add link to github repo to npm 54 | 55 | ## 1.3.2 56 | 57 | ### Patch Changes 58 | 59 | - Fixed re-export of useRouteLoaderData from mistakenly exporting useLoaderData to the right module 60 | 61 | ## 1.3.1 62 | 63 | ### Patch Changes 64 | 65 | - Export `useRouteLoaderData` 66 | 67 | ## 1.3.0 68 | 69 | ### Minor Changes 70 | 71 | - 8355264: Patch component 72 | 73 | ## 1.2.0 74 | 75 | ### Minor Changes 76 | 77 | - Add `useLoaderData` hook types 78 | - Migrate from pnpm to bun 79 | 80 | ## 1.1.0 81 | 82 | ### Minor Changes 83 | 84 | - Add `makeLoader`, `makeAction` utilities 85 | Re-exported `ActionFunction, `LoaderFunction`and`redirect` for commodity 86 | 87 | ## 1.0.1 88 | 89 | ### Patch Changes 90 | 91 | - 9706126: Fixed files exposed with package.json 92 | 93 | ## 1.0.0 94 | 95 | ### Major Changes 96 | 97 | - Initial release 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Router Typesafe 2 | 3 | React Router Typesafe is a minimal (235 bytes gzipped) patch built upon [react-router](https://github.com/remix-run/react-router) to add type-safety via the use of generics. It brings type functionality closer to Remix, the full-stack framework from the same authors. 4 | 5 | ## Getting Started 6 | 7 | Install the package: 8 | 9 | ```bash 10 | npm install react-router-typesafe 11 | ``` 12 | 13 | Replace your imports from `react-router` to `react-router-typesafe`: 14 | 15 | ```diff 16 | - import { defer, useLoaderData, useActionData } from "react-router"; 17 | + import { defer, useLoaderData, useActionData } from "react-router-typesafe"; 18 | ``` 19 | 20 | ## Usage 21 | 22 | ### useLoaderData / useActionData 23 | 24 | ```tsx 25 | import { useLoaderData, useActionData, LoaderFunction, ActionFunction } from 'react-router-typesafe'; 26 | 27 | const loader = (() => ({ message: 'Hello World' })) satisfies LoaderFunction; 28 | 29 | const action = (() => ({ ok: true })) satisfies ActionFunction; 30 | 31 | const Component = () => { 32 | const data = useLoaderData(); 33 | const actionData = useActionData(); 34 | 35 | return
{data.message}
; 36 | }; 37 | ``` 38 | 39 | > **Warning** 40 | > Do not annotate the type of the loader/action function. It will break the type-safety. Instead rely on either the `satisfies` keyword from Typescript 4.9 onwards, or the `makeLoader` / `makeAction` utilities proveded by this library. 41 | 42 | ## Utilities 43 | 44 | ### makeLoader / makeAction 45 | 46 | The `makeLoader` and `makeAction` utils replace the need for the `satisfies` keyword without adding any runtime overhead. 47 | 48 | ```tsx 49 | import { makeLoader, makeAction } from 'react-router-typesafe'; 50 | 51 | const loader = makeLoader(() => ({ message: 'Hello World' })); 52 | 53 | const action = makeAction(() => ({ ok: true })); 54 | ``` 55 | 56 | ### typesafeBrowserRouter ❇️ NEW 57 | 58 | The `typesafeBrowserRouter` is a wrapper around `createBrowserRoute` that returns a `href` function in addition to the routes. 59 | 60 | It’s easy to incrementally adopt, and you can use `href` anywhere, not just in `` components. 61 | 62 | Set up your routes like this: 63 | 64 | ```diff 65 | - import { createBrowserRouter } from "react-router-dom"; 66 | + import { typesafeBrowserRouter } from "react-router-typesafe"; 67 | 68 | - export const router = createBrowserRouter([ 69 | + export const { router, href } = typesafeBrowserRouter([ 70 | { path: "/", Component: HomePage }, 71 | { path: "/projects/:projectId", Component: ProjectPage }, 72 | ]); 73 | ``` 74 | 75 | - ✅ No need to change your existing `` components. 76 | - ✅ URL params are inferred and type-checked. 77 | - ✅ Supports query params and URL hash 78 | - ✅ Refactor-friendly: **Rename Symbol** on the route path and it’ll be updated everywhere. 79 | 80 | Then use `href` to generate URLs: 81 | 82 | ```tsx 83 | import { Link } from 'react-router-dom'; 84 | import { href } from './router'; 85 | 86 | const ProjectCard = (props: { id: string }) => { 87 | return ( 88 | 89 |

Project {projectId}

90 | 91 | ); 92 | }; 93 | ``` 94 | 95 | ## Contributing 96 | 97 | Feel free to improve the code and submit a pull request. If you're not sure about something, create an issue first to discuss it. 98 | 99 | ## Functions 100 | 101 | | Status | Utility | Before | After | 102 | | ------ | ----------------------- | ---------- | ------------------------------------------------------------ | 103 | | ✅ | `defer` | `Response` | Generic matching the first argument | 104 | | | `json` | `Response` | Serialized data passed in | 105 | | ✅ | `useLoaderData` | `unknown` | Generic function with the type of the loader function passed | 106 | | ✅ | `useActionData` | `unknown` | Generic function with the type of the action function passed | 107 | | ✅ | `useRouteLoaderData` | `unknown` | Generic function with the type of the loader function passed | 108 | | NEW | `makeLoader` | | Wrapper around `satisfies` for ergonomics | 109 | | NEW | `makeAction` | | Wrapper around `satisfies` for ergonomics | 110 | | NEW | `typesafeBrowserRouter` | | Extension of `createBrowserRouter` | 111 | 112 | ## Patched components 113 | 114 | | Status | Component | Before | After | 115 | | ------ | --------- | --------------------------------------------- | --------------------------------------------- | 116 | | ✅ | `` | children render props would be typed as `any` | Generic component makes render props typesafe | 117 | 118 | ## About 119 | 120 | React Router is developed and maintained by [Remix Software](https://remix.run) and many [amazing contributors](https://github.com/remix-run/react-router/graphs/contributors). 121 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredericoo/react-router-typesafe/c05b11c816dad7e36d63a150a3ed9650756f9d4c/bun.lockb -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = "./happydom.ts" -------------------------------------------------------------------------------- /docs/params.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredericoo/react-router-typesafe/c05b11c816dad7e36d63a150a3ed9650756f9d4c/docs/params.png -------------------------------------------------------------------------------- /eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'prettier', 4 | '@remix-run/eslint-config', 5 | 'plugin:import/recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:import/typescript', 8 | ], 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['import', 'unused-imports', 'simple-import-sort', '@typescript-eslint'], 11 | 12 | parserOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | project: true, 16 | tsconfigRootDir: __dirname, 17 | }, 18 | settings: { 19 | 'import/parsers': { 20 | '@typescript-eslint/parser': ['.ts', '.tsx'], 21 | }, 22 | 'import/resolver': { 23 | typescript: { 24 | project: ['tsconfig.json'], 25 | }, 26 | node: { 27 | project: ['tsconfig.json'], 28 | }, 29 | }, 30 | }, 31 | 32 | rules: { 33 | /** No absolute imports */ 34 | 'import/no-absolute-path': 'error', 35 | 36 | /** Ensures all imports appear before other statements */ 37 | 'import/first': ['error'], 38 | 39 | /** Ensures there’s an empty line between imports and other statements */ 40 | 'import/newline-after-import': ['warn', { count: 1 }], 41 | 42 | /** Sorts imports automatically */ 43 | 'simple-import-sort/imports': 'warn', 44 | 45 | /** Ensures no unused imports are present, and only _ prefixed variables can be unused */ 46 | 'no-unused-vars': 'off', 47 | 'unused-imports/no-unused-vars': [ 48 | 'warn', 49 | { 50 | vars: 'all', 51 | varsIgnorePattern: '^_', 52 | args: 'after-used', 53 | argsIgnorePattern: '^_', 54 | }, 55 | ], 56 | 'unused-imports/no-unused-imports': 'error', 57 | '@typescript-eslint/no-misused-promises': 'off', 58 | 59 | 'no-restricted-syntax': [ 60 | 'warn', 61 | { 62 | selector: 'TSEnumDeclaration', 63 | message: 'Don’t declare enums! Use string literal unions instead, they’re safer and more ergonomic.', 64 | }, 65 | ], 66 | 67 | 'no-restricted-imports': [ 68 | 'error', 69 | { 70 | paths: [ 71 | { 72 | name: 'react-router-dom', 73 | importNames: ['defer'], 74 | message: "Please import defer from '~/domains/routing/routing.utils' instead.", 75 | }, 76 | { 77 | name: 'react-router-dom', 78 | importNames: ['useLoaderData'], 79 | message: "Please import useLoaderData from '~/domains/routing/routing.utils' instead.", 80 | }, 81 | { 82 | name: 'react-router-dom', 83 | importNames: ['useActionData'], 84 | message: "Please import useActionData from '~/domains/routing/routing.utils' instead.", 85 | }, 86 | ], 87 | }, 88 | ], 89 | 90 | '@typescript-eslint/no-unnecessary-condition': 'warn', 91 | '@typescript-eslint/no-unnecessary-type-arguments': 'warn', 92 | '@typescript-eslint/prefer-for-of': 'warn', 93 | '@typescript-eslint/prefer-function-type': 'warn', 94 | 95 | /** Prefer types over interfaces */ 96 | '@typescript-eslint/consistent-type-definitions': ['warn', 'type'], 97 | 98 | '@typescript-eslint/no-confusing-non-null-assertion': 'error', 99 | 100 | /** Standardises arrays. Simple arrays use brackets, complex arrays uses generic syntax 101 | * @example - ❌ `const foo: Array = [];` 102 | * @example - ✅ `const foo: string[] = [];` 103 | * @example - ❌ `const foo: ReturnType[] = [];` 104 | * @example - ✅ `const foo: Array> = [];` 105 | */ 106 | '@typescript-eslint/array-type': ['warn'], 107 | 108 | /** Enforces generics on the cunstructor, not as type annotation. 109 | * @example - ❌ `const foo: Foo = new Foo();` 110 | * @example - ✅ `const foo = new Foo();` 111 | */ 112 | '@typescript-eslint/consistent-generic-constructors': ['warn', 'constructor'], 113 | 114 | /** Prefer Record over {[key: X]: Y} syntax */ 115 | '@typescript-eslint/consistent-indexed-object-style': ['warn', 'record'], 116 | 117 | /** Already handled by unused-imports */ 118 | '@typescript-eslint/no-unused-vars': 'off', 119 | 120 | /** React uses that a lot */ 121 | '@typescript-eslint/unbound-method': 'off', 122 | 123 | '@typescript-eslint/ban-ts-comment': [ 124 | 'error', 125 | { 126 | 'ts-expect-error': 'allow-with-description', 127 | 'ts-ignore': true, 128 | 'ts-nocheck': true, 129 | 'ts-check': false, 130 | minimumDescriptionLength: 5, 131 | }, 132 | ], 133 | }, 134 | }; 135 | -------------------------------------------------------------------------------- /happydom.ts: -------------------------------------------------------------------------------- 1 | import { GlobalRegistrator } from '@happy-dom/global-registrator'; 2 | 3 | GlobalRegistrator.register(); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-typesafe", 3 | "version": "1.5.0", 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 | "@types/bun": "^1.0.12", 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" 54 | } 55 | -------------------------------------------------------------------------------- /src/__tests__/action.typetest.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'bun:test'; 2 | import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom'; 3 | import { useActionData } from '..'; 4 | /** TODO: wait for feedback on issue about act-compat importing deprecated "react-dom/test-utils" */ 5 | import { renderHook } from '@testing-library/react'; 6 | import { expectTypeOf } from 'expect-type'; 7 | 8 | test('works with non-promises', () => { 9 | const testAction = (() => { 10 | return { foo: 'bar' }; 11 | }) satisfies ActionFunction; 12 | 13 | const { result } = renderHook(useActionData); 14 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string } | undefined>(); 15 | }); 16 | 17 | test('works with promises', () => { 18 | const testAction = (async () => { 19 | return { foo: 'bar' }; 20 | }) satisfies ActionFunction; 21 | 22 | const { result } = renderHook(useActionData); 23 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string } | undefined>(); 24 | }); 25 | 26 | test('ignores redirects or responses', () => { 27 | const testAction = (() => { 28 | if (Math.random() > 0.5) { 29 | return redirect('/foo'); 30 | } 31 | if (Math.random() > 0.5) { 32 | return new Response(null, {}); 33 | } 34 | return { foo: 'bar' }; 35 | }) satisfies ActionFunction; 36 | 37 | const { result } = renderHook(useActionData); 38 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string } | undefined>(); 39 | }); 40 | -------------------------------------------------------------------------------- /src/__tests__/browser-router.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'bun:test'; 2 | import { typesafeBrowserRouter } from '../browser-router'; 3 | import { RouteObject } from 'react-router-dom'; 4 | 5 | test('returns pathname with replaced params', () => { 6 | const { href } = typesafeBrowserRouter([ 7 | { path: '/blog', children: [{ path: '/blog/:postId', children: [{ path: '/blog/:postId/:commentId' }] }] }, 8 | ]); 9 | 10 | const output = href({ path: '/blog/:postId/:commentId', params: { postId: 'foo', commentId: 'bar' } }); 11 | // @ts-expect-error 12 | const wrongOutput = href({ path: 'non-existing-route' }); 13 | 14 | expect(output).toEqual('/blog/foo/bar'); 15 | }); 16 | 17 | test('Returns right paths with pathless routes with children', () => { 18 | const Component = () => null; 19 | const { href } = typesafeBrowserRouter([ 20 | { 21 | id: 'auth', 22 | Component, 23 | children: [{ path: '/dashboard' }], 24 | }, 25 | { 26 | id: 'public', 27 | children: [ 28 | { path: '/blog', children: [{ path: '/blog/:postId', children: [{ path: '/blog/:postId/:commentId' }] }] }, 29 | ], 30 | }, 31 | ]); 32 | 33 | const output = href({ path: '/blog/:postId/:commentId', params: { postId: 'foo', commentId: 'bar' } }); 34 | // @ts-expect-error 35 | const wrongOutput = href({ path: 'non-existing-route' }); 36 | 37 | expect(output).toEqual('/blog/foo/bar'); 38 | }); 39 | 40 | test('returns pathname with search params if object is passed', () => { 41 | const { href } = typesafeBrowserRouter([{ path: '/blog', children: [{ path: '/blog/:postId' }] }]); 42 | 43 | const output = href({ path: '/blog/:postId', params: { postId: 'foo' }, searchParams: { foo: 'bar' } }); 44 | // @ts-expect-error 45 | const wrongOutput = href({ path: 'non-existing-route' }); 46 | 47 | expect(output).toEqual('/blog/foo?foo=bar'); 48 | }); 49 | 50 | test('returns pathname with search params if URLSearchParams is passed', () => { 51 | const { href } = typesafeBrowserRouter([{ path: '/blog', children: [{ path: '/blog/:postId' }] }]); 52 | 53 | const output = href({ 54 | path: '/blog/:postId', 55 | params: { postId: 'foo' }, 56 | searchParams: new URLSearchParams({ foo: 'bar' }), 57 | }); 58 | // @ts-expect-error 59 | const wrongOutput = href({ path: 'non-existing-route' }); 60 | 61 | expect(output).toEqual('/blog/foo?foo=bar'); 62 | }); 63 | 64 | test('returns pathname with hash', () => { 65 | const { href } = typesafeBrowserRouter([{ path: '/blog', children: [{ path: '/blog/:postId' }] }]); 66 | 67 | const output = href({ path: '/blog/:postId', params: { postId: 'foo' }, hash: '#foo' }); 68 | // @ts-expect-error 69 | const wrongOutput = href({ path: 'non-existing-route' }); 70 | 71 | expect(output).toEqual('/blog/foo#foo'); 72 | }); 73 | 74 | test('typescript stress test with many routes and layers', () => { 75 | const { href } = typesafeBrowserRouter([ 76 | { 77 | path: '/blog', 78 | children: [ 79 | { 80 | path: '/blog/:postId', 81 | children: [ 82 | { path: '/blog/:postId/:commentId' }, 83 | { path: '/blog/:postId/:commentId/:replyId' }, 84 | { path: '/blog/:postId/:commentId/:replyId/:fooId' }, 85 | { path: '/blog/:postId/:commentId/:replyId/:fooId/:barId' }, 86 | { path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId' }, 87 | { path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId' }, 88 | { path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId' }, 89 | { path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId' }, 90 | { path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId' }, 91 | { 92 | path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId', 93 | }, 94 | { 95 | path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId', 96 | }, 97 | { 98 | path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId', 99 | }, 100 | { 101 | path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId/:plughId', 102 | }, 103 | { 104 | path: '/blog/:postId/:commentId/:replyId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId/:plughId/:xyzzyId', 105 | }, 106 | ], 107 | }, 108 | ], 109 | }, 110 | { 111 | path: '/dashboard', 112 | children: [ 113 | { 114 | path: '/dashboard/:dashboardId', 115 | children: [ 116 | { path: '/dashboard/:dashboardId/:fooId' }, 117 | { path: '/dashboard/:dashboardId/:fooId/:barId' }, 118 | { path: '/dashboard/:dashboardId/:fooId/:barId/:bazId' }, 119 | { path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId' }, 120 | { path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId' }, 121 | { path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId' }, 122 | { path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId' }, 123 | { path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId' }, 124 | { 125 | path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId', 126 | }, 127 | { 128 | path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId', 129 | }, 130 | { 131 | path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId/:plughId', 132 | }, 133 | { 134 | path: '/dashboard/:dashboardId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId/:plughId/:xyzzyId', 135 | }, 136 | ], 137 | }, 138 | ], 139 | }, 140 | { 141 | path: '/profile', 142 | children: [ 143 | { 144 | path: '/profile/:profileId', 145 | children: [ 146 | { path: '/profile/:profileId/:fooId' }, 147 | { path: '/profile/:profileId/:fooId/:barId' }, 148 | { path: '/profile/:profileId/:fooId/:barId/:bazId' }, 149 | { path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId' }, 150 | { path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId' }, 151 | { path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId' }, 152 | { path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId' }, 153 | { path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId' }, 154 | { path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId' }, 155 | { 156 | path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId', 157 | }, 158 | { 159 | path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId/:plughId', 160 | }, 161 | { 162 | path: '/profile/:profileId/:fooId/:barId/:bazId/:quxId/:quuxId/:corgeId/:graultId/:garplyId/:waldoId/:fredId/:plughId/:xyzzyId', 163 | }, 164 | ], 165 | }, 166 | ], 167 | }, 168 | ]); 169 | 170 | const output = href({ path: '/blog' }); 171 | // @ts-expect-error 172 | const wrongOutput = href({ path: 'non-existing-route' }); 173 | 174 | expect(output).toEqual('/blog'); 175 | }); 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 | }); 237 | -------------------------------------------------------------------------------- /src/__tests__/loader.typetest.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'bun:test'; 2 | import { LoaderFunction, json, redirect } from 'react-router-dom'; 3 | import { useLoaderData } from '..'; 4 | /** TODO: wait for feedback on issue about act-compat importing deprecated "react-dom/test-utils" */ 5 | import { renderHook } from '@testing-library/react'; 6 | import { expectTypeOf } from 'expect-type'; 7 | import { useRouteLoaderData } from '../loader'; 8 | 9 | test('works with non-promises', () => { 10 | const testLoader = (() => { 11 | return { foo: 'bar' }; 12 | }) satisfies LoaderFunction; 13 | 14 | const { result } = renderHook(useLoaderData); 15 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string }>(); 16 | }); 17 | 18 | test('works with promises', () => { 19 | const testLoader = (() => { 20 | return Promise.resolve({ foo: 'bar' }); 21 | }) satisfies LoaderFunction; 22 | 23 | const { result } = renderHook(useLoaderData); 24 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string }>(); 25 | }); 26 | 27 | test('works with mixed values', () => { 28 | const testLoader = (() => { 29 | if (Math.random() > 0.5) { 30 | return { bar: 'baz' }; 31 | } 32 | return { foo: 'bar' }; 33 | }) satisfies LoaderFunction; 34 | 35 | const { result } = renderHook(useLoaderData); 36 | expectTypeOf(result.current).toEqualTypeOf< 37 | | { 38 | bar: string; 39 | foo?: never; 40 | } 41 | | { 42 | foo: string; 43 | bar?: never; 44 | } 45 | >(); 46 | }); 47 | 48 | test('works with redirects', () => { 49 | const testLoader = (() => { 50 | if (Math.random() > 0.5) { 51 | return redirect('/foo'); 52 | } 53 | return { foo: 'bar' }; 54 | }) satisfies LoaderFunction; 55 | 56 | const { result } = renderHook(useLoaderData); 57 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string }>(); 58 | }); 59 | 60 | test('routeLoaderData works the same way as useLoaderData', () => { 61 | const testLoader = (() => { 62 | if (Math.random() > 0.5) { 63 | return redirect('/foo'); 64 | } 65 | return { foo: 'bar' }; 66 | }) satisfies LoaderFunction; 67 | 68 | const { result } = renderHook(() => useRouteLoaderData('test')); 69 | expectTypeOf(result.current).toEqualTypeOf<{ foo: string }>(); 70 | }); 71 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunction, useActionData as rrUseActionData } from 'react-router-dom'; 2 | 3 | export type ActionData = Awaited> extends Response | infer D 4 | ? D | undefined 5 | : never; 6 | 7 | /** Returns the action data for the nearest ancestor Route action 8 | * @example 9 | * const data = useActionData(); 10 | */ 11 | export const useActionData = rrUseActionData as () => ActionData; 12 | -------------------------------------------------------------------------------- /src/browser-router.ts: -------------------------------------------------------------------------------- 1 | import { RouteObject, createBrowserRouter } from 'react-router-dom'; 2 | 3 | type Narrowable = string | number | bigint | boolean; 4 | type NarrowKeys = 5 | | (A extends Narrowable ? A : never) 6 | | { [K in keyof A]: A[K] extends Function ? A[K] : NarrowKeys }; 7 | 8 | type NarrowArray = NarrowKeys[]; 9 | 10 | type Flatten = { [K in keyof T]: T[K] } & {}; 11 | 12 | type PathParams = keyof T extends never ? { params?: never } : { params: T }; 13 | 14 | type ExtractParam = Path extends `:${infer Param}` ? Record & NextPart : NextPart; 15 | 16 | type ExtractParams = 17 | Path extends `${infer Segment}/${infer Rest}` ? ExtractParam> : ExtractParam; 18 | 19 | type PrefixIfRelative = 20 | Path extends `/${string}` ? Path 21 | : Prefix extends '' ? `/${Path}` 22 | : Prefix extends '/' ? `${Prefix}${Path}` 23 | : `${Prefix}/${Path}`; 24 | 25 | type ExtractPaths = 26 | Route extends ( 27 | { 28 | children: infer C extends RouteObject[]; 29 | path: infer P extends string; 30 | } 31 | ) ? 32 | PrefixIfRelative | ExtractPaths> 33 | : Route extends { children: infer C extends RouteObject[] } ? ExtractPaths 34 | : Route extends { path: infer P extends string } ? PrefixIfRelative 35 | : never; 36 | 37 | type TypesafeSearchParams = Record | URLSearchParams; 38 | export type RouteExtraParams = { hash?: string; searchParams?: TypesafeSearchParams }; 39 | 40 | const joinValidWith = 41 | (separator: string) => 42 | (...valid: any[]) => 43 | valid.filter(Boolean).join(separator); 44 | 45 | export const typesafeBrowserRouter = (routes: NarrowArray) => { 46 | type Paths = ExtractPaths; 47 | 48 | function href

( 49 | params: { path: Extract } & PathParams>> & RouteExtraParams, 50 | ) { 51 | // applies all params to the path 52 | const path = 53 | params?.params ? 54 | Object.keys(params.params).reduce((path, param) => { 55 | const value = params.params![param as keyof ExtractParams

]; 56 | if (typeof value !== 'string') throw new Error(`Route param ${param} must be a string`); 57 | return path.replace(`:${param}`, value); 58 | }, params.path) 59 | : params.path; 60 | 61 | const searchParams = new URLSearchParams(params?.searchParams); 62 | const hash = params?.hash?.replace(/^#/, ''); 63 | 64 | return joinValidWith('#')(joinValidWith('?')(path, searchParams.toString()), hash); 65 | } 66 | 67 | return { 68 | router: createBrowserRouter(routes as RouteObject[]), 69 | href, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/Await.tsx: -------------------------------------------------------------------------------- 1 | import { Await as RRAwait } from 'react-router-dom'; 2 | 3 | export type AwaitResolveRenderFunction = { 4 | (data: Awaited): React.ReactNode; 5 | }; 6 | 7 | export type AwaitProps = { 8 | children: React.ReactNode | AwaitResolveRenderFunction; 9 | errorElement?: React.ReactNode; 10 | resolve: T; 11 | }; 12 | 13 | export const Await = RRAwait as (props: AwaitProps) => React.ReactNode; 14 | -------------------------------------------------------------------------------- /src/components/Await.typetest.tsx: -------------------------------------------------------------------------------- 1 | import { test } from 'bun:test'; 2 | import { expectTypeOf } from 'expect-type'; 3 | import { Await } from './Await'; 4 | 5 | test('render props return awaited promise resolved value', () => { 6 | const promiseThatReturnsNumber = Promise.resolve(1); 7 | 8 | const rendered = Await({ 9 | resolve: promiseThatReturnsNumber, 10 | children: resolved => { 11 | expectTypeOf(resolved).toEqualTypeOf(); 12 | return resolved; 13 | }, 14 | }); 15 | }); 16 | 17 | test('does not error/warn with non-promises', () => { 18 | const rendered = Await({ 19 | resolve: 1, 20 | children: resolved => { 21 | expectTypeOf(resolved).toEqualTypeOf(); 22 | return resolved; 23 | }, 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Await'; 2 | -------------------------------------------------------------------------------- /src/defer.ts: -------------------------------------------------------------------------------- 1 | import { defer as rrDefer } from 'react-router-dom'; 2 | 3 | /** Unfortunately DeferredData is not exported by react-router-dom so we have to re-declare it */ 4 | export declare class DeferredData> { 5 | private pendingKeysSet; 6 | private controller; 7 | private abortPromise; 8 | private unlistenAbortSignal; 9 | private subscribers; 10 | data: TData; 11 | init?: ResponseInit; 12 | deferredKeys: string[]; 13 | constructor(data: Record, responseInit?: ResponseInit); 14 | private trackPromise; 15 | private onSettle; 16 | private emit; 17 | subscribe(fn: (aborted: boolean, settledKey?: string) => void): () => boolean; 18 | cancel(): void; 19 | resolveData(signal: AbortSignal): Promise; 20 | get done(): boolean; 21 | get unwrappedData(): {}; 22 | get pendingKeys(): string[]; 23 | } 24 | type DeferFunction = >(data: T, init?: number | ResponseInit) => DeferredData; 25 | 26 | /** Patch of react router’s defer to support generics. */ 27 | export const defer = rrDefer as unknown as DeferFunction; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { defer } from './defer'; 2 | export { type LoaderData, useLoaderData, useRouteLoaderData } from './loader'; 3 | export { type ActionData, useActionData } from './action'; 4 | export { makeLoader, makeAction } from './utils'; 5 | export * from './components'; 6 | export * from './browser-router'; 7 | 8 | /** Re-exports for commodity */ 9 | export { type LoaderFunction, type ActionFunction, redirect } from 'react-router-dom'; 10 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoaderFunction, 3 | useLoaderData as rrUseLoaderData, 4 | useRouteLoaderData as rrUseRouteLoaderData, 5 | } from 'react-router-dom'; 6 | import { DeferredData } from './defer'; 7 | 8 | export type LoaderData = Awaited> extends Response | infer D 9 | ? D extends DeferredData 10 | ? TDeferred 11 | : D 12 | : never; 13 | 14 | /** Returns the loader data for the nearest ancestor Route loader 15 | * @example 16 | * const data = useLoaderData(); 17 | */ 18 | export const useLoaderData = rrUseLoaderData as () => LoaderData; 19 | 20 | export const useRouteLoaderData = rrUseRouteLoaderData as ( 21 | routeId: string, 22 | ) => LoaderData; 23 | -------------------------------------------------------------------------------- /src/revalidate.ts: -------------------------------------------------------------------------------- 1 | import type { ShouldRevalidateFunction } from 'react-router-dom'; 2 | 3 | export type RevalidateWhenFunctionArgs = { 4 | /** Will revalidate if any of the dependencies change. This is an **OR**. */ 5 | deps: { 6 | /** Will revalidate if any of these **URL Params** have changed. 7 | * @example for `/users/:id/posts/:postId` you could pass `['id', 'postId]` 8 | * to revalidate loader when the `id` **or** `postId` params changed. 9 | */ 10 | params?: string[]; 11 | /** Will revalidate loader if **any** of these search params change value */ 12 | search?: string[]; 13 | /** Will revalidate if there’s a form submission with the encoding type */ 14 | submission?: 'any' | 'formdata' | 'json' | 'text'; 15 | /** Escape hatch for the default shouldRevalidate function. Note that `defaultShouldRevalidate` here will be true 16 | * if any of the dependencies have changed, so you can use this to override that behavior. */ 17 | shouldRevalidate?: ShouldRevalidateFunction; 18 | }; 19 | /** Will not revalidate if the time in milliseconds between the last call to the loader and now is less than this value. 20 | * This is an **OR** with the other dependencies, so it will revalidate if the time has passed **OR** any of the other dependencies have changed. 21 | * @example for `1000` will revalidate if the last call was more than 1 second ago, regardless of changes. 22 | */ 23 | olderThanMs?: number; 24 | }; 25 | 26 | export const revalidateWhen = ({ deps, olderThanMs }: RevalidateWhenFunctionArgs): ShouldRevalidateFunction => { 27 | let lastLoadedAt = 0; 28 | return ({ defaultShouldRevalidate, ...params }) => { 29 | const depsChanged = (() => { 30 | if (deps.params?.some(param => params.nextParams[param] !== params.currentParams[param])) { 31 | return true; 32 | } 33 | if ( 34 | deps.search?.some(param => params.currentUrl.searchParams.get(param) === params.nextUrl.searchParams.get(param)) 35 | ) { 36 | return true; 37 | } 38 | 39 | switch (deps.submission) { 40 | case 'any': 41 | if (params.formData || params.json || params.text) return true; 42 | break; 43 | case 'formdata': 44 | if (params.formData) return true; 45 | break; 46 | case 'json': 47 | if (params.json) return true; 48 | break; 49 | case 'text': 50 | if (params.text) return true; 51 | break; 52 | } 53 | 54 | return false; 55 | })(); 56 | 57 | const now = Date.now(); 58 | const stale = olderThanMs ? now - lastLoadedAt < olderThanMs : false; 59 | 60 | const revalidate = stale || depsChanged; 61 | 62 | return deps.shouldRevalidate?.({ defaultShouldRevalidate: revalidate, ...params }) ?? revalidate; 63 | }; 64 | }; 65 | 66 | const shouldRevalidate = revalidateWhen({ 67 | deps: { params: ['id'], search: ['q'] }, 68 | olderThanMs: 1000, 69 | }); 70 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction, LoaderFunction } from 'react-router-dom'; 2 | 3 | export const makeLoader = (loaderFn: TLoaderFn) => { 4 | return loaderFn; 5 | }; 6 | 7 | export const makeAction = (actionFn: TActionFn) => { 8 | return actionFn; 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "noUncheckedIndexedAccess": true, 19 | "noImplicitAny": true, 20 | "jsx": "react-jsx" 21 | }, 22 | "exclude": ["node_modules", "dist", "eslintrc.cjs"] 23 | } 24 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | clean: true, 6 | dts: true, 7 | format: ['esm', 'cjs'], 8 | external: ['react-router-dom', 'react'], 9 | }); 10 | --------------------------------------------------------------------------------