├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── size.yml │ ├── test.yaml │ └── codeql.yaml ├── docs ├── _config.docker.yml ├── .gitignore ├── api │ ├── useFullPath.md │ ├── usePath.md │ ├── useHash.md │ ├── useBasePath.md │ ├── useHistory.md │ ├── RouterProvider.md │ ├── useNavigationPrompt.md │ ├── ActiveLink.md │ ├── useRedirect.md │ ├── Redirect.md │ ├── Link.md │ ├── useNavigate.md │ ├── useMatch.md │ ├── navigate.md │ ├── useQueryParams.md │ ├── usePathParams.md │ ├── useLocationChange.md │ └── useRoutes.md ├── docker-compose.yaml ├── README.md ├── index.md ├── _config.yml ├── guides │ ├── migration_to_v5.md │ └── migration_to_v4.md └── _sass │ └── custom │ └── custom.scss ├── scripts └── start-docs ├── .gitignore ├── example ├── vite.config.ts ├── src │ ├── Nav.tsx │ ├── main.tsx │ ├── app.css │ ├── Form.tsx │ ├── NestedApp.tsx │ └── App.tsx ├── index.html ├── .gitignore ├── tsconfig.json ├── eslint.config.js └── package.json ├── src ├── typeChecks.ts ├── node.ts ├── hooks.ts ├── types.ts ├── main.ts ├── context.tsx ├── redirect.ts ├── intercept.ts ├── querystring.ts ├── Link.tsx ├── navigate.ts ├── location.ts └── router.tsx ├── CONTRIBUTING.md ├── jest.config.ts ├── eslint.config.mjs ├── LICENSE ├── test ├── utils.ts ├── context.spec.tsx ├── redirect.spec.tsx ├── querystring.spec.tsx ├── Link.spec.tsx ├── navigate.spec.tsx ├── location.spec.tsx └── router.spec.tsx ├── rollup.config.mjs ├── tsconfig.json ├── README.md ├── package.json └── CHANGELOG.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @kyeotic 2 | -------------------------------------------------------------------------------- /docs/_config.docker.yml: -------------------------------------------------------------------------------- 1 | url: "http://localhost:4000" -------------------------------------------------------------------------------- /scripts/start-docs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd docs && docker compose up -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .parcel-cache 6 | dist 7 | dist_old/ 8 | build/ 9 | junit.xml 10 | coverage/ -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/typeChecks.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 2 | export function isFunction(obj: unknown): obj is Function { 3 | return !!obj && typeof obj === 'function' 4 | } 5 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Hugo default output directory 2 | /public 3 | 4 | ## OS Files 5 | # Windows 6 | Thumbs.db 7 | ehthumbs.db 8 | Desktop.ini 9 | $RECYCLE.BIN/ 10 | 11 | # OSX 12 | .DS_Store 13 | /node_modules 14 | /dist 15 | /example 16 | /_site/ -------------------------------------------------------------------------------- /example/src/Nav.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children } from 'react' 2 | 3 | export default function Nav({ children }) { 4 | return ( 5 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for npm 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | allow: 9 | - dependency-name: "*" 10 | dependency-type: "production" 11 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './app.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /docs/api/useFullPath.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useFullPath" 3 | permalink: /use-full-path/ 4 | nav_order: 9 5 | --- 6 | 7 | # `useFullPath` 8 | 9 | Get the current path of the page, ignoring any `basePath` provided by Raviger contexts. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function useFullPath(): string 15 | ``` 16 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | let ssrPath = '/' 2 | let isNode = true 3 | try { 4 | isNode = window === undefined 5 | } catch {} // eslint-disable-line no-empty 6 | 7 | export { isNode } 8 | export function getSsrPath(): string { 9 | return ssrPath 10 | } 11 | export function setSsrPath(path: string): void { 12 | ssrPath = path 13 | } 14 | -------------------------------------------------------------------------------- /docs/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | jekyll: 4 | environment: 5 | - JEKYLL_ENV=docker 6 | command: jekyll serve --force_polling --watch --config _config.yml,_config.docker.yml 7 | image: jekyll/jekyll:pages 8 | volumes: 9 | - .:/srv/jekyll 10 | ports: 11 | - 4000:4000 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Playground 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Docs 2 | 3 | Docs generated with [Jekyll](https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/setting-up-a-github-pages-site-with-jekyll) and the [Just the Docs theme](https://github.com/pmarsceill/just-the-docs). 4 | 5 | Local docs can be run using `make docs` from the project root, or `docker-compose up` from the `/docs` folder. 6 | -------------------------------------------------------------------------------- /docs/api/usePath.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "usePath" 3 | permalink: /use-path/ 4 | nav_order: 8 5 | --- 6 | 7 | # `usePath` 8 | 9 | Get the current `decodeURIComponent`-ed path of the page. If `basePath` is provided and missing from the current path `null` is returned. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function usePath(basePath?: string): string 15 | ``` 16 | -------------------------------------------------------------------------------- /example/src/app.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | #root { 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | ul.nav { 14 | list-style: none; 15 | padding: 1rem 0; 16 | margin: 1rem 0; 17 | background-color: #80808045; 18 | } 19 | 20 | ul.nav li { 21 | margin-right: 1rem; 22 | display: inline; 23 | } 24 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from 'react' 2 | 3 | export function useMountedLayout( 4 | fn: () => unknown, 5 | deps: React.DependencyList | undefined, 6 | { onInitial = false } = {}, 7 | ): void { 8 | const hasMounted = useRef(onInitial) 9 | useLayoutEffect(() => { 10 | if (!hasMounted.current) hasMounted.current = true 11 | else fn() 12 | }, deps) 13 | } 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this project please adhere to the following 4 | 5 | * Code should be submitted by forking the project and opening a Pull Request 6 | * Commit messages should use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 7 | * Code style should use [Prettier](https://prettier.io) using the local configuration 8 | * All tests should pass. New features should include relevant unit tests. 9 | -------------------------------------------------------------------------------- /docs/api/useHash.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useHash" 3 | permalink: /use-hash/ 4 | nav_order: 9 5 | --- 6 | 7 | # `useHash` 8 | 9 | A hook for getting the current hash of the page. Will cause re-renders when the hash changes. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function useHash(options?: { stripHash?: boolean }): string 15 | ``` 16 | 17 | ## stripHash 18 | 19 | If `options.stripHash` is `true` the hash will be returned without the literal "#" at the beginning. If you need the "#" set `options.stripHash` to `false`. `default = true` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type EmptyRecord = Record 2 | export type ValueOf = T[keyof T] 3 | 4 | export type NonEmptyRecord = Params extends EmptyRecord 5 | ? undefined 6 | : { [Key in keyof Params]: Params[Key] } // Mapped type is used to simplify the output (cleaner autocomplete) 7 | 8 | export type Split< 9 | Value extends string, 10 | Separator extends string, 11 | > = Value extends `${infer Head}${Separator}${infer Tail}` 12 | ? [Head, ...Split] 13 | : Value extends Separator 14 | ? [] 15 | : [Value] 16 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - name: Use Node.js 20 9 | uses: actions/setup-node@v4 10 | with: 11 | node-version: 20.x 12 | - name: Test 13 | run: | 14 | yarn install 15 | npm run build 16 | npm run size 17 | # - uses: andresz1/size-limit-action@v1.8.0 18 | # with: 19 | # github_token: ${{ secrets.GITHUB_TOKEN }} 20 | # package_manager: yarn 21 | -------------------------------------------------------------------------------- /docs/api/useBasePath.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useBasePath" 3 | permalink: /use-base-path/ 4 | nav_order: 8 5 | --- 6 | 7 | # `useBasePath` 8 | 9 | Get the `basePath` set by a parent `useRoutes` component (empty string if none) 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function useBasePath(): string 15 | ``` 16 | 17 | ## Basic 18 | 19 | ```jsx 20 | import { useRoutes, useBasePath } from 'raviger' 21 | 22 | function Home () { 23 | let basePath = useBasePath() 24 | // Will be 'app' when render by the parent below 25 | return {basePath} 26 | } 27 | 28 | const routes = { 29 | '/': () => 30 | } 31 | 32 | export default function App() { 33 | return useRoutes(routes, { basePath: 'app' }) 34 | ) 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest' 2 | 3 | const config: Config = { 4 | clearMocks: true, 5 | collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/main.ts'], 6 | coverageDirectory: 'coverage', 7 | coverageThreshold: { 8 | global: { 9 | branches: 80, 10 | functions: 50, 11 | lines: 50, 12 | statements: 50, 13 | }, 14 | }, 15 | preset: 'ts-jest', 16 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], 17 | testEnvironment: 'jest-environment-jsdom', 18 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], 19 | transform: { 20 | '^.+.tsx?$': ['ts-jest', {}], 21 | }, 22 | watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], 23 | } 24 | 25 | export default config 26 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | export { useRoutes, useMatch, usePathParams } from './router' 2 | export type { Routes, RouteOptionParams, PathParamOptions } from './router' 3 | 4 | export { RouterProvider } from './context' 5 | 6 | export { useRedirect, Redirect } from './redirect' 7 | export type { RedirectProps } from './redirect' 8 | 9 | export { Link, ActiveLink } from './Link' 10 | 11 | export { navigate, useNavigate, useNavigationPrompt } from './navigate' 12 | 13 | export { 14 | usePath, 15 | useHash, 16 | useFullPath, 17 | useBasePath, 18 | useLocationChange, 19 | useHistory, 20 | } from './location' 21 | export type { LocationChangeSetFn, LocationChangeOptionParams } from './location' 22 | 23 | export { useQueryParams } from './querystring' 24 | export type { QueryParam, setQueryParamsOptions } from './querystring' 25 | -------------------------------------------------------------------------------- /example/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config' 2 | import reactHooks from 'eslint-plugin-react-hooks' 3 | import jest from 'eslint-plugin-jest' 4 | import eslint from '@eslint/js' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | // This global ignore syntax is maximum wtf 9 | { ignores: ['node_modules/', 'coverage/', 'dist/', 'example/'] }, 10 | { 11 | extends: [ 12 | eslint.configs.recommended, 13 | tseslint.configs.recommended, 14 | // reactHooks.configs.recommended, 15 | // jest.configs.recommended, 16 | ], 17 | files: ['{src,test}/**/*.{ts,tsx}'], 18 | 19 | plugins: { 20 | jest, 21 | 'react-hooks': reactHooks, 22 | }, 23 | rules: { 24 | 'react/display-name': 0, 25 | '@typescript-eslint/no-use-before-define': ['error', { functions: false }], 26 | '@typescript-eslint/ban-ts-comment': 0, 27 | }, 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "alias": { 13 | "react": "../node_modules/react", 14 | "react-dom": "../node_modules/react-dom/profiling", 15 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "^9.21.0", 19 | "@types/react": "^19.0.10", 20 | "@types/react-dom": "^19.0.4", 21 | "@vitejs/plugin-react": "^4.3.4", 22 | "eslint": "^9.21.0", 23 | "eslint-plugin-react-hooks": "^5.1.0", 24 | "eslint-plugin-react-refresh": "^0.4.19", 25 | "globals": "^15.15.0", 26 | "typescript": "~5.7.2", 27 | "typescript-eslint": "^8.24.1", 28 | "url": "^0.11.0", 29 | "vite": "^6.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | pull_request: 10 | branches: 11 | - main 12 | paths-ignore: 13 | - 'docs/**' 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [20.x, 22.x, 24.x] 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - name: Test 30 | run: | 31 | yarn install 32 | npm run build 33 | npm run test:ci 34 | env: 35 | CI: true 36 | - name: Archive code coverage results 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: code-coverage-report-${{ matrix.node-version }} 40 | path: junit.xml 41 | 42 | -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useMemo } from 'react' 2 | 3 | const BasePathContext = createContext('') 4 | const PathContext = createContext(null) 5 | 6 | export { BasePathContext } 7 | export { PathContext } 8 | 9 | export function useRouter(): { basePath: string; path: string | null } { 10 | const [basePath, path] = [useContext(BasePathContext), useContext(PathContext)] 11 | return useMemo(() => ({ basePath, path }), [basePath, path]) 12 | } 13 | 14 | export function RouterProvider({ 15 | basePath = '', 16 | path, 17 | children, 18 | }: { 19 | basePath?: string 20 | path?: string 21 | children?: React.ReactNode 22 | }): JSX.Element { 23 | return ( 24 | // The ordering here is important, the basePath will change less often 25 | // So putting it on the outside reduces its need to re-render 26 | 27 | {children} 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /docs/api/useHistory.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useHistory" 3 | permalink: /use-history/ 4 | nav_order: 11 5 | --- 6 | 7 | # `useHistory` 8 | 9 | Get the current browser history state and scroll restoration setting. This hook provides access to the browser's history API state and scroll restoration behavior, updating automatically when the location changes. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function useHistory(): RavigerHistory 15 | 16 | interface RavigerHistory { 17 | scrollRestoration: 'auto' | 'manual' 18 | state: unknown 19 | } 20 | ``` 21 | 22 | ## Properties 23 | 24 | - **`scrollRestoration`**: The current scroll restoration mode ('auto' or 'manual') 25 | - **`state`**: The current history state object, or `null` if no state is set 26 | 27 | ## Example 28 | 29 | ```typescript 30 | import { useHistory } from 'raviger' 31 | 32 | function MyComponent() { 33 | const history = useHistory() 34 | 35 | console.log('Scroll restoration:', history.scrollRestoration) 36 | console.log('History state:', history.state) 37 | 38 | return
Current state: {JSON.stringify(history.state)}
39 | } 40 | ``` -------------------------------------------------------------------------------- /docs/api/RouterProvider.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "RouterProvider" 3 | permalink: /router-provider/ 4 | nav_order: 59 5 | --- 6 | 7 | # STOP 8 | 9 | This component exists for advanced use-cases. Most applications will be better of by using the [`useRoutes`](/use-routes) hook, which sets up this provider automatically. 10 | 11 | # `RouterProvider` 12 | 13 | This component provides the React Context providers for `useBasePath` and `usePath`. It is useful if your application is divided in such a way that putting `useRoutes` at the top is infeasible, or if your app doesn't use `useRoutes` at all. 14 | 15 | 16 | ```ts 17 | function RouterProvider(props: { 18 | basePath?: string 19 | path?: string 20 | children?: React.ReactNode 21 | }): JSX.Element 22 | ``` 23 | 24 | Both `basePath` and `path` can be safely omitted, leaving their default context values in place. 25 | 26 | ## Example 27 | 28 | ```ts 29 | import { RouterProvider } from 'raviger' 30 | 31 | function App() { 32 | return ( 33 | 34 | { /* main content */} 35 | 36 | ) 37 | } 38 | 39 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tim Kye 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/api/useNavigationPrompt.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useNavigationPrompt" 3 | permalink: /use-navigation-prompt/ 4 | nav_order: 58 5 | --- 6 | 7 | # `useNavigationPrompt` 8 | 9 | This hook causes a confirmation to block navigation. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function useNavigationPrompt( 15 | predicate = true, 16 | prompt?: string 17 | ): void 18 | ``` 19 | 20 | ## Basic 21 | 22 | 23 | If `predicate` is truthy the user will be prompted if they try to navigate away from the page, either by leaving the site or through `` or `navigate` being invoked. 24 | 25 | A standard `prompt` will be used if none is provided. **Note**: due to browser restrictions custom prompts are ignored when the user is trying to leave the site. Custom prompts will always work for in-site navigation from `` or `navigate` being invoked 26 | 27 | ```jsx 28 | import React, { useState } from 'react' 29 | import { useNavigationPrompt, navigate } from 'raviger' 30 | 31 | function Form({ isFormDirty }) { 32 | // When isFormDirty navigation will cause a confirm dialog 33 | useNavigationPrompt(isFormDirty) 34 | return (/* */) 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /example/src/Form.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useNavigationPrompt, navigate } from '../../src/main' 3 | 4 | export default function Form() { 5 | const [names, setNames] = useState([]) 6 | const [newName, setName] = useState('') 7 | useNavigationPrompt( 8 | !!newName.length, 9 | 'Are you sure you want to leave the name form?' 10 | ) 11 | const handleSubmit = (e) => { 12 | e.preventDefault() 13 | setNames(names.concat(newName)) 14 | setName('') 15 | } 16 | const saveAndNavigate = () => { 17 | setNames(names.concat(newName)) 18 | navigate('/') 19 | } 20 | return ( 21 |
22 |
23 | 31 | 32 | 33 |
34 |
    35 | {names.map((name) => ( 36 |
  • {name}
  • 37 | ))} 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { act } from 'react' 2 | 3 | import { navigate } from '../src/main' 4 | 5 | const originalConfirm = window.confirm 6 | const originalScrollTo = window.scrollTo 7 | const originalAssign = window.location.assign 8 | const originalReplaceState = window.history.replaceState 9 | const originalPushState = window.history.pushState 10 | 11 | export function restoreWindow(): void { 12 | window.confirm = originalConfirm 13 | window.scrollTo = originalScrollTo 14 | window.history.replaceState = originalReplaceState 15 | window.history.pushState = originalPushState 16 | // This gets around location read-only error 17 | Object.defineProperty(window, 'location', { 18 | value: { 19 | ...window.location, 20 | assign: originalAssign, 21 | }, 22 | }) 23 | } 24 | 25 | export function mockNavigation(): void { 26 | beforeEach(() => { 27 | // restoreWindow() 28 | act(() => navigate('/')) 29 | }) 30 | 31 | afterEach(async () => { 32 | restoreWindow() 33 | // We must wait for the intercept reset op 34 | return delay(5) 35 | }) 36 | } 37 | 38 | export function delay(ms: number): Promise { 39 | return new Promise((resolve) => setTimeout(() => resolve(), ms)) 40 | } 41 | -------------------------------------------------------------------------------- /docs/api/ActiveLink.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "ActiveLink" 3 | permalink: /active-link/ 4 | nav_order: 4 5 | --- 6 | 7 | # `` 8 | 9 | Like the standard [Link](/api/link) component, but with built-in `className` transformation when a matching path is detected. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export interface ActiveLinkProps extends LinkProps { 15 | activeClass?: string 16 | exactActiveClass?: string 17 | } 18 | export const ActiveLink: React.ForwardRefExoticComponent> 19 | ``` 20 | 21 | ## Basic 22 | 23 | Just like ``, but with two additional properties for modifying the `className` 24 | 25 | * **activeClass** If the `href` matches the start of the current path this will be appended to the `` `className`. 26 | * **exactActiveClass** If the `href` matches the cirrent path exactly this will be appended to the `` `className`. Stacks with *activeClass* 27 | 28 | ```jsx 29 | 34 | go to foo 35 | 36 | ``` 37 | 38 | ## Ref passing 39 | 40 | `ActiveLink` supports the standard [forwardRef](https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components) API. 41 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | permalink: / 4 | nav_order: 1 5 | --- 6 | 7 | 8 | # raviger 9 | 10 | **R**eact N**avig**at**or**. A React hook-based router that updates on **all** url changes. Heavily inspired by [hookrouter](https://github.com/Paratron/hookrouter). 11 | 12 | Zero dependencies. Tiny footprint. 13 | 14 | # Quick Start 15 | 16 | Getting started with [raviger](https://github.com/kyeotic/raviger) 17 | 18 | ## Installation 19 | 20 | ``` 21 | npm i raviger 22 | ``` 23 | 24 | ## Quick Start 25 | 26 | This basic setup shows how to display an `` component that renders a route based on the current path, with links to different routes. 27 | 28 | ```jsx 29 | import { useRoutes, Link, useQueryParams } from 'raviger' 30 | import { Home, About, Users } from './Pages.js' 31 | 32 | const routes = { 33 | '/': () => , 34 | '/about': () => , 35 | '/users/:userId': ({ userId }) => 36 | } 37 | 38 | export default function App() { 39 | let route = useRoutes(routes) 40 | return ( 41 |
42 |
43 | Home 44 | About 45 | Tom 46 | Jane 47 |
48 | {route} 49 |
50 | ) 51 | } 52 | ``` -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: Raviger 2 | description: React routing library with hooks! 3 | lsi: false 4 | safe: true 5 | source: 6 | incremental: false 7 | highlighter: rouge 8 | # theme: jekyll-theme-architect 9 | # theme: jekyll-theme-midnight 10 | remote_theme: pmarsceill/just-the-docs 11 | gist: 12 | noscript: false 13 | kramdown: 14 | math_engine: mathjax 15 | syntax_highlighter: rouge 16 | permalink: pretty 17 | aux_links: 18 | "GitHub Repo": 19 | - "//github.com/kyeotic/raviger" 20 | back_to_top: true 21 | back_to_top_text: "Back to top" 22 | heading_anchors: true 23 | # baseurl: /ea-governance/ea-standards 24 | # Footer "Edit this page on GitHub" link text 25 | gh_edit_link: true # show or hide edit this page link 26 | gh_edit_link_text: "Edit this page on GitHub" 27 | gh_edit_repository: "https://github.com/kyeotic/raviger" 28 | gh_edit_branch: "master" # the branch that your docs is served from 29 | # gh_edit_source: docs # the source that your files originate from 30 | gh_edit_view_mode: "tree" # "tree" or "edit" if you want the user to jump into the editor immediately 31 | compress_html: 32 | clippings: all 33 | comments: all 34 | endings: all 35 | startings: [] 36 | blanklines: false 37 | profile: false 38 | # ignore: 39 | # envs: all 40 | exclude: 41 | - "README.md" 42 | plugins: 43 | - jekyll-relative-links -------------------------------------------------------------------------------- /docs/api/useRedirect.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useRedirect" 3 | permalink: /use-redirect/ 4 | nav_order: 2 5 | --- 6 | 7 | # `useRedirect` 8 | 9 | This hook causes a browser redirect to occur if its `predicateUrl` matches. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function useRedirect( 15 | predicateUrl: string, 16 | targetUrl: string, 17 | options?: { 18 | query?: QueryParam | URLSearchParams 19 | replace?: boolean 20 | merge?: boolean 21 | state?: unknown 22 | } 23 | ): void 24 | ``` 25 | 26 | ## Basic 27 | 28 | If `predicateUrl` is the current path, redirect to the `targetUrl`. `queryObj` is optional, and uses the same serializer that `useQueryParams` uses by default. If `replace` (default: true) it will replace the current URL (back button will skip the `predicateUrl`). If `merge` is true the `query` will be merged with the current url query. `state` is optional, and can be used to set the page's state in the history stack. 29 | 30 | See [navigate()](/api/navigate) for more details on this function's paramaters. `useRedirect()` calls `navigate()` internally. 31 | 32 | ```jsx 33 | import { useRedirect } from 'raviger' 34 | 35 | function Route () { 36 | // Will redirect to '/new' if current path is '/old' 37 | useRedirect('/old', '/new', { query: { name: 'kyeotic' } }) 38 | return Home 39 | } 40 | ``` 41 | 42 | -------------------------------------------------------------------------------- /src/redirect.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | import { getCurrentHash, usePath } from './location' 4 | import { navigate, NavigateOptions } from './navigate' 5 | import { useQueryParams } from './querystring' 6 | 7 | export interface RedirectProps extends NavigateOptions { 8 | to: string 9 | merge?: boolean 10 | } 11 | 12 | export function Redirect({ 13 | to, 14 | query, 15 | replace = true, 16 | merge = true, 17 | state, 18 | }: RedirectProps): JSX.Element | null { 19 | useRedirect(usePath(), to, { query, replace, merge, state }) 20 | return null 21 | } 22 | 23 | export interface UseRedirectOptions extends NavigateOptions { 24 | merge?: boolean 25 | } 26 | 27 | export function useRedirect( 28 | predicateUrl: string | null, 29 | targetUrl: string, 30 | { query, replace = true, merge = true, state }: UseRedirectOptions = {}, 31 | ): void { 32 | const currentPath = usePath() 33 | const [currentQuery] = useQueryParams() 34 | const hash = getCurrentHash() 35 | 36 | let url = targetUrl 37 | const targetQuery = new URLSearchParams({ 38 | ...(merge ? currentQuery : {}), 39 | ...query, 40 | }).toString() 41 | if (targetQuery) { 42 | url += '?' + targetQuery 43 | } 44 | if (merge && hash && hash.length) { 45 | url += hash 46 | } 47 | 48 | useLayoutEffect(() => { 49 | if (currentPath === predicateUrl) { 50 | navigate(url, { replace, state }) 51 | } 52 | }, [predicateUrl, url, replace, currentPath, state]) 53 | } 54 | -------------------------------------------------------------------------------- /src/intercept.ts: -------------------------------------------------------------------------------- 1 | const interceptors = new Set<() => string | void>() 2 | 3 | export const defaultPrompt = 'Are you sure you want to leave this page?' 4 | 5 | let hasIntercepted = false 6 | let hasUserCancelled = false 7 | let lastScroll = [0, 0] as [number, number] 8 | 9 | export function shouldCancelNavigation(): boolean { 10 | lastScroll = [window.scrollX, window.scrollY] 11 | if (hasIntercepted) return hasUserCancelled 12 | 13 | // confirm if any interceptors return true 14 | return Array.from(interceptors).some((interceptor) => { 15 | const prompt = interceptor() 16 | if (!prompt) return false 17 | 18 | // cancel navigation if user declines 19 | hasUserCancelled = !window.confirm(prompt) 20 | 21 | // track user response so that multiple interceptors don't prompt 22 | hasIntercepted = true 23 | 24 | // reset so that future navigation attempts are prompted 25 | setTimeout(() => { 26 | hasIntercepted = false 27 | hasUserCancelled = false 28 | }, 0) 29 | 30 | return hasUserCancelled 31 | }) 32 | } 33 | 34 | export function addInterceptor(handler: () => string | void): void { 35 | window.addEventListener('beforeunload', handler) 36 | interceptors.add(handler) 37 | } 38 | 39 | export function removeInterceptor(handler: () => string | void): void { 40 | window.removeEventListener('beforeunload', handler) 41 | interceptors.delete(handler) 42 | } 43 | 44 | export function undoNavigation(lastPath: string): void { 45 | window.history.pushState(null, null as unknown as string, lastPath) 46 | setTimeout(() => { 47 | window.scrollTo(...lastScroll) 48 | }, 0) 49 | } 50 | -------------------------------------------------------------------------------- /docs/api/Redirect.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Redirect" 3 | permalink: /redirect/ 4 | nav_order: 5 5 | --- 6 | 7 | # `` 8 | 9 | A React component for causing a browser redirect 10 | 11 | ## API 12 | 13 | ```typescript 14 | export interface RedirectProps { 15 | to: string 16 | query?: QueryParam | URLSearchParams 17 | replace?: boolean 18 | merge?: boolean 19 | state?: unknown 20 | } 21 | export const Redirect: React.FC 22 | ``` 23 | 24 | ## Basic 25 | 26 | If rendered this component will force a redirect. Useful as a route function 27 | 28 | ```jsx 29 | import { useRoutes, Redirect } from 'raviger' 30 | 31 | const routes = { 32 | '/': ({ title }) => , 33 | '/about': ({ title }) => , 34 | '/redirect': () => 35 | } 36 | 37 | export default function App() { 38 | let route = useRoutes(routes) 39 | return ( 40 |
41 | {route} 42 |
43 | ) 44 | } 45 | ``` 46 | 47 | By default it will navigate with `replace` and `merge` both `true`. 48 | 49 | ## replace 50 | 51 | If `replace` is `true` the redirect will not create a new entry in the history stack. `default = true` 52 | 53 | ## query 54 | 55 | Provide an object or URLSearchParams to be appended to the `url`. Will be merged over the current query values if `merge: true` 56 | 57 | ## merge 58 | 59 | If `merge` is `true` the redirect will use existing `location.hash` and `location.query` values. Useful for rewriting URLs without losing their intended state. `default = true` 60 | 61 | ## state 62 | 63 | State to be passed to the new page, via history. See [navigate()](/api/navigate) for more details. -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import replace from '@rollup/plugin-replace' 2 | import packageJson from './package.json' with { type: "json" } 3 | import terser from '@rollup/plugin-terser' 4 | import typescript from '@rollup/plugin-typescript' 5 | 6 | const deps = Object.keys(packageJson.dependencies || []).concat( 7 | Object.keys(packageJson.peerDependencies) 8 | ) 9 | 10 | const terserConfig = terser({ 11 | ecma: '2019', 12 | mangle: { 13 | keep_fnames: true, 14 | keep_classnames: true, 15 | }, 16 | compress: { 17 | keep_fnames: true, 18 | keep_classnames: true, 19 | }, 20 | output: { 21 | comments: false, 22 | }, 23 | }) 24 | 25 | const entryFile = 'src/main.ts' 26 | 27 | export default [ 28 | { 29 | input: entryFile, 30 | output: { 31 | file: 'dist/main.js', 32 | format: 'cjs', 33 | name: 'raviger', 34 | globals: { 35 | react: 'React', 36 | }, 37 | // interop: false, 38 | sourcemap: true, 39 | }, 40 | external: deps, 41 | plugins: [ 42 | replace({ 43 | preventAssignment: false, 44 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 45 | }), 46 | typescript(), 47 | terserConfig, 48 | ], 49 | }, 50 | { 51 | input: entryFile, 52 | output: { 53 | file: 'dist/module.js', 54 | format: 'esm', 55 | name: 'raviger', 56 | sourcemap: true, 57 | globals: { 58 | react: 'React', 59 | }, 60 | }, 61 | external: deps, 62 | plugins: [ 63 | replace({ 64 | preventAssignment: false, 65 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 66 | }), 67 | typescript(), 68 | terserConfig, 69 | ], 70 | }, 71 | ] 72 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "module": "ES2020", 6 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 7 | "target": "ES2019", 8 | // "noEmitHelpers": true, 9 | "importHelpers": true, 10 | // output .d.ts declaration files for consumers 11 | "declaration": true, 12 | // output .js.map sourcemap files for consumers 13 | "sourceMap": true, 14 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 15 | "rootDir": "./src", 16 | "outDir": "dist", 17 | // stricter type-checking for stronger correctness. Recommended by TS 18 | "strict": true, 19 | // linter checks for common issues 20 | "noImplicitReturns": true, 21 | "noImplicitAny": false, 22 | "strictNullChecks": true, 23 | "noFallthroughCasesInSwitch": true, 24 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | // use Node's module resolution algorithm, instead of the legacy TS one 28 | "moduleResolution": "node", 29 | // transpile JSX to React.createElement 30 | "jsx": "react", 31 | // interop between ESM and CJS modules. Recommended by TS 32 | "esModuleInterop": true, 33 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 34 | "skipLibCheck": true, 35 | // error out if import and file system have a casing mismatch. Recommended by TS 36 | "forceConsistentCasingInFileNames": true 37 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 38 | // "noEmit": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/api/Link.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Link" 3 | permalink: /link/ 4 | nav_order: 3 5 | --- 6 | # `` 7 | 8 | A React component for rendering a `
` that uses *history* navigation for local URLs. Supports `ref` forwarding. 9 | 10 | ## API 11 | 12 | ```typescript 13 | export interface LinkProps 14 | extends React.AnchorHTMLAttributes { 15 | href: string 16 | basePath?: string 17 | } 18 | export const Link: React.ForwardRefExoticComponent> 19 | ``` 20 | 21 | ## Basic 22 | 23 | This component is the preferred method of navigation, alongside the **navigate** function. 24 | 25 | This component takes all the same parameters as the built-in `` tag. It's `onClick` will be extended to perform local navigation, and if it is inside a component returned from `useRoutes` it will have the provided `basePath` preprended to its `href`. 26 | 27 | ```jsx 28 | 29 | go to foo 30 | 31 | ``` 32 | 33 | ## BasePath 34 | 35 | If a `` component is inside a router context (there is a `useRoutes` in its parent heirarchy) the `basePath` will be inherited. You can also provide a `basePath` as a `` prop, which will override an inherited one. 36 | 37 | ```jsx 38 | import { useRoutes, Link } from 'raviger' 39 | 40 | function Home () { 41 | return ( 42 |
43 | {/* href = /app/foo */} 44 | {/* href = /bar/foo */} 45 |
46 | ) 47 | } 48 | 49 | const routes = { 50 | '/': () => 51 | } 52 | 53 | export default function App() { 54 | return useRoutes(routes, { basePath: 'app' }) 55 | ) 56 | } 57 | ``` 58 | 59 | 60 | ## Ref Passing 61 | 62 | `Link` supports the standard [forwardRef](https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components) API. -------------------------------------------------------------------------------- /test/context.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { act } from 'react' 2 | import { render } from '@testing-library/react' 3 | 4 | import { useRoutes, navigate, RouteOptionParams, Routes } from '../src/main' 5 | import { useRouter } from '../src/context' 6 | 7 | beforeEach(() => { 8 | act(() => navigate('/')) 9 | }) 10 | 11 | describe('useRouter', () => { 12 | function Harness({ routes, options }: { routes: Routes; options?: RouteOptionParams }) { 13 | const route = useRoutes(routes, options) || not found 14 | return route 15 | } 16 | 17 | function Route({ label }: { label: string }) { 18 | const { basePath, path } = useRouter() 19 | return ( 20 |
21 | {basePath} 22 | {path} 23 | {label} 24 |
25 | ) 26 | } 27 | const routes: Routes = { 28 | '/': () => , 29 | '/about': () => , 30 | } 31 | 32 | test('provides basePath', async () => { 33 | const { getByTestId } = render() 34 | 35 | act(() => navigate('/home')) 36 | expect(getByTestId('basePath')).toHaveTextContent('home') 37 | }) 38 | 39 | test('provides path', async () => { 40 | const { getByTestId } = render() 41 | 42 | act(() => navigate('/about')) 43 | expect(getByTestId('path')).toHaveTextContent('about') 44 | }) 45 | 46 | test('provides null path when basePath is missing', async () => { 47 | const { getByTestId } = render() 48 | 49 | act(() => navigate('/about')) 50 | expect(getByTestId('label')).toHaveTextContent('not found') 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /docs/guides/migration_to_v5.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Migration Guide - v5" 3 | permalink: /migraiton-to-v5/ 4 | nav_order: 98 5 | --- 6 | 7 | # v5 Migration Guide 8 | 9 | Raviger v5 comes with just one breaking change to the API. 10 | 11 | - **BREAKING**: changed parameters on `useQueryParams` setter options from `replace, replaceHistory` to `overwrite, replace` to keep consistency with `replace` on other navigation functions. 12 | 13 | ## useQueryParams setter 14 | 15 | Originally `useQueryParams` had an options object that allowed it to control whether the querystring was merged with the setter values or replaced by them. The options object used the `replace` key for this behavior. 16 | 17 | However, `useNavigate` used `replace` to control whether `history.replaceState` or `history.pushState` was used. When `useQueryParams` was extended to support this `history.replaceState` behavior, the natural name "replace" was already taken, so `replaceHistory` was used as a temporary measure. 18 | 19 | Since the `replace` used by `useNavigate` is a natural name, `useQueryParams` is taking the breaking change to align with this. The setter's options `replace` has been renamed to `overwrite` `replaceHistory` has been renamed to `replace`. 20 | 21 | ```typescript 22 | export interface setQueryParamsOptions { 23 | /** 24 | * Controls whether the querystring is overwritten or merged into 25 | * 26 | * default: true 27 | */ 28 | overwrite?: boolean 29 | /** 30 | * Controls whether the querystring update causes a history push or replace 31 | * 32 | * default: false 33 | */ 34 | replace?: boolean 35 | } 36 | 37 | ``` 38 | 39 | ### How to fix 40 | 41 | Find all the uses of `useQueryParams` where the setter uses a `replace` or `replaceHistory` option. 42 | 43 | - Rename `replace` to `overwrite`. Keep the same value. 44 | - Rename `replaceHistory` to `replace`. Keep the same value. -------------------------------------------------------------------------------- /docs/api/useNavigate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useNavigate" 3 | permalink: /use-navigate/ 4 | nav_order: 5 5 | --- 6 | 7 | # `useNavigate` 8 | 9 | A hook for imperative route changes that include any configured `basePath` 10 | 11 | ## API 12 | 13 | ```typescript 14 | type NavigateWithReplace = (url: string, replace?: boolean) => void; 15 | type NavigateWithQuery = (url: string, query?: URLSearchParams, replace?: boolean) => void; 16 | 17 | export function useNavigate(optBasePath?: string): NavigateWithReplace & NavigateWithQuery; 18 | ``` 19 | 20 | The function returned by `useNavigate` has the same signature as the non-hook [navigate function](/api/navigate). The only difference is that this function considers the `basePath` when navigating. 21 | 22 | ## Basic 23 | 24 | ```jsx 25 | import { useRoutes, useNavigate } from 'raviger' 26 | 27 | function Home () { 28 | const navigate = useNavigate() 29 | 30 | // pathname will be /app/about after navigation 31 | return 32 | } 33 | 34 | function About () { 35 | const navigate = useNavigate() 36 | 37 | // pathname will be /app/ after navigation 38 | return 39 | } 40 | 41 | const routes = { 42 | '/': () => 43 | '/about': () => 44 | } 45 | 46 | export default function App() { 47 | return useRoutes(routes, { basePath: '/app' }) 48 | } 49 | ``` 50 | 51 | ## Outside a Router 52 | 53 | If the `useNavigate` hook is used inside a router context (there is a `useRoutes` in its parent heirarchy), the `basePath` will be inherited. If the hook is used outside a router context, or if you want to override the `basePath`, you can call the `useNavigate` hook with an `optBasePath` argument. 54 | 55 | ## Query Params and Replacing State 56 | 57 | As with the non-hook `navigate` function, the function returned by `useNavigate` can be called with query params or with a boolean indicating whether to replace the current history entry rather than adding to the history. 58 | -------------------------------------------------------------------------------- /example/src/NestedApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { render } from 'react-dom' 3 | import { 4 | useRoutes, 5 | usePath, 6 | useBasePath, 7 | useQueryParams, 8 | navigate, 9 | Link 10 | } from '../../src/main.js' 11 | 12 | const NotFound = () => { 13 | return ( 14 |
15 | not found 16 |
17 | ) 18 | } 19 | 20 | function Harness({ routes, basePath }) { 21 | const route = useRoutes(routes, { basePath }) || 22 | 23 | const contextPath = usePath() 24 | const contextBasePath = useBasePath() 25 | 26 | return ( 27 | <> 28 |
29 |

Router wrapper

30 |

Path: {contextPath}

31 |

BasePath: {contextBasePath}

32 |
33 | {route} 34 | 35 | ) 36 | } 37 | 38 | function Route({ label, extra }) { 39 | let path = usePath() 40 | let basePath = useBasePath() 41 | return ( 42 |
43 |

Route

44 |

Label: "{label}"

45 |

Path: "{path}"

46 |

Base Path: "{basePath}"

47 |

Param: "{extra}"

48 |
49 | ) 50 | } 51 | 52 | const nestedRoutes = { 53 | '/': () => , 54 | '/about': () => , 55 | '/test': () => 56 | } 57 | 58 | const routes = { 59 | '/': () => , 60 | '/test/*': () => , 61 | '/:param*': ({ param }) => ( 62 | 63 | ) 64 | } 65 | 66 | export default function App() { 67 | return ( 68 |
69 | 70 | 71 | root 72 |
73 | nested with a param About 74 |
75 | nested with a param Test 76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /docs/api/useMatch.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useMatch" 3 | permalink: /use-match/ 4 | nav_order: 7 5 | --- 6 | 7 | # useMatch 8 | 9 | A hook for detecting whether a provided path or array of paths is currently active. It uses the same path-matching syntax, as well as the `basePath` and `matchTrailingSlash` options, as [useRoutes](/use-routes). 10 | 11 | This can be useful for implementing components that have route-conditional logic, such as Navigation Bars that hide menus on certain pages. 12 | 13 | ```ts 14 | function useMatch(routes: string | string[], options?: PathParamOptions): string | null 15 | 16 | interface PathParamOptions { 17 | basePath?: string 18 | matchTrailingSlash?: boolean 19 | } 20 | ``` 21 | 22 | > If no `basePath` is provided the hook will inherit it from the context of any active `useRoutes` ancestor. 23 | 24 | * `basePath`: Override the `basePath` from the context, if any is present. (`'/'` can be used to clear any inherited `basePath`) 25 | * If `matchTrailingSlash` is true (which it is by default) a route with a `/` on the end will still match a defined route without a trialing slash. For example, `'/about': () => ` would match the path `/about/`. If `matchTrailingSlash` is false then a trailing slash will cause a match failure unless the defined route also has a trailing slash. 26 | 27 | ## Matching a single path 28 | 29 | `useMatch` either returns `null` if the path-pattern does not match the current path, or it returns the matching path. 30 | 31 | ```tsx 32 | function NavBar () { 33 | const match = useMatch('/home') // returns "/home" 34 | 35 | return { 36 | <> 37 | Contact 38 | { !!match && Go Back} 39 | 40 | } 41 | } 42 | ``` 43 | 44 | ## Matching an array of paths 45 | 46 | `useMatch` returns either `null` if none of them match the path, or it returns the matching path. 47 | 48 | ```tsx 49 | function Page () { 50 | const page = useMatch(['/', '/about']) 51 | 52 | return { 53 | <> 54 |

{page === '/' ? 'Home' : page === '/about' ? 'About Us' : 'Default Title' }

55 | 56 | } 57 | } 58 | ``` -------------------------------------------------------------------------------- /src/querystring.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | 3 | import { navigate } from './navigate' 4 | import { isNode, getSsrPath } from './node' 5 | import { getCurrentPath, getCurrentHash, useLocationChange } from './location' 6 | 7 | export interface QueryParam { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | [key: string]: any 10 | } 11 | 12 | export interface setQueryParamsOptions { 13 | /** 14 | * Controls whether the querystring is overwritten or merged into 15 | * 16 | * default: true 17 | */ 18 | overwrite?: boolean 19 | /** 20 | * Controls whether the querystring update causes a history push or replace 21 | * 22 | * default: false 23 | */ 24 | replace?: boolean 25 | } 26 | 27 | export function useQueryParams( 28 | parseFn: (query: string) => T = parseQuery, 29 | serializeFn: (query: Partial) => string = serializeQuery, 30 | ): [T, (query: T, options?: setQueryParamsOptions) => void] { 31 | const [querystring, setQuerystring] = useState(getQueryString()) 32 | const setQueryParams = useCallback( 33 | (params, { overwrite = true, replace = false } = {}) => { 34 | let path = getCurrentPath() 35 | params = overwrite ? params : { ...parseFn(querystring), ...params } 36 | const serialized = serializeFn(params).toString() 37 | 38 | if (serialized) path += '?' + serialized 39 | if (!overwrite) path += getCurrentHash() 40 | 41 | navigate(path, { replace }) 42 | }, 43 | [querystring, parseFn, serializeFn], 44 | ) 45 | 46 | // Update state when route changes 47 | const updateQuery = useCallback(() => setQuerystring(getQueryString()), []) 48 | 49 | useLocationChange(updateQuery) 50 | return [parseFn(querystring), setQueryParams] 51 | } 52 | 53 | function parseQuery(querystring: string): T { 54 | const q = new URLSearchParams(querystring) 55 | return Object.fromEntries(q.entries()) as T 56 | } 57 | 58 | function serializeQuery(queryParams: T): string { 59 | return new URLSearchParams(Object.entries(queryParams).filter(([, v]) => v !== null)).toString() 60 | } 61 | 62 | export function getQueryString(): string { 63 | if (isNode) { 64 | const ssrPath = getSsrPath() 65 | const queryIndex = ssrPath.indexOf('?') 66 | return queryIndex === -1 ? '' : ssrPath.substring(queryIndex + 1) 67 | } 68 | return window.location.search 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | language: [ 'javascript' ] 34 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 35 | # Learn more: 36 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v2 41 | 42 | # Initializes the CodeQL tools for scanning. 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v3 45 | with: 46 | languages: ${{ matrix.language }} 47 | # If you wish to specify custom queries, you can do so here or in a config file. 48 | # By default, queries listed here will override any specified in a config file. 49 | # Prefix the list here with "+" to use these queries and those in the config file. 50 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 51 | 52 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 53 | # If this step fails, then you should remove it and run the build manually (see below) 54 | # - name: Autobuild 55 | # uses: github/codeql-action/autobuild@v1 56 | 57 | # ℹ️ Command-line programs to run using the OS shell. 58 | # 📚 https://git.io/JvXDl 59 | 60 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 61 | # and modify them (or add more) to build your code if your project 62 | # uses a compiled language 63 | 64 | #- run: | 65 | # make bootstrap 66 | # make release 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v3 70 | -------------------------------------------------------------------------------- /docs/guides/migration_to_v4.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Migration Guide - v4" 3 | permalink: /migraiton-to-v4/ 4 | nav_order: 99 5 | --- 6 | 7 | # v4 Migration Guide 8 | 9 | Raviger v4 comes with several high-impact breaking changes. 10 | 11 | - **BREAKING**: `navigate` now has `options` object instead of overloads 12 | - **BREAKING**: `useNavigate` uses updated `navigate` params 13 | - **BREAKING**: `useLocationChange` now invokes the setter with a `RavigerLocation` object instead of the `path` 14 | 15 | ## navigate 16 | 17 | The changes to `navigate` create a more readable API but require manual changes to migrate to. This guide will provide some find/replace regexs, but they do not account for calls that might be broken onto multiple lines. You will need to inspect every call to `navigate` in your app to ensure it is changed correctly. 18 | 19 | Here are some sample migrations 20 | 21 | 22 | ```js 23 | // Replace 24 | /* 25 | find: navigate\((.+?)(, (true|false))\) 26 | replace: navigate($1, { replace: $3 }) 27 | */ 28 | navigate('/path', true) // -> navigate('/path', { replace: true }) 29 | navigate('/path', false) // -> navigate('/path', { replace: false }) 30 | 31 | // Query 32 | /* 33 | find: navigate\((.+?),\s?(\{.+?\})\) 34 | replace: navigate($1, { query: $2 }) 35 | */ 36 | navigate('/path', { name: 'raviger' }) // -> navigate('/path', { query: { name: 'raviger' } }) 37 | 38 | // State 39 | navigate('/path', undefined, undefined, { name: 'raviger'}) // -> navigate('/path', { state: { name: 'raviger' } }) 40 | ``` 41 | 42 | The old `navigate` signature had complex combinations of overloads, so it is unlikely that simple substitutions will cover all the cases in even smaller applications. I'm sorry for the pain this change is going to cause, and doubly sorry because I already knew of the code smell of boolean arguments in functions with more than two parameters. I should have used an **options object** to begin with and I won't be making exceptions like this again. 43 | 44 | 45 | ## useLocationChange 46 | 47 | Before v4 useLocationChange provided just the **path** to its `setFn`. It now provides a `window.location` like object that contains a `path` (and `pathname`, just to be consistent with `window.location`). While this new signature is strictly more useful, changes to your application can be avoided by adding a small utility method that emulates the previous behavior. You can replace all old usage of `useLocationChange` with this wrapper and be done. 48 | 49 | ```typescript 50 | export function usePathChange( 51 | setFn: (path: string | null) => void, 52 | options: LocationChangeOptionParams = {} 53 | ): void { 54 | useLocationChange( 55 | useCallback((location: RavigerLocation) => setFn(location.path), [setFn]), 56 | options 57 | ) 58 | } 59 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # raviger 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 |

11 | 12 | 13 | **R**eact N**avig**at**or**. A React hook-based router that updates on **all** url changes. Heavily inspired by [hookrouter](https://github.com/Paratron/hookrouter). 14 | 15 | Zero dependencies. Tiny footprint. 16 | 17 | > Note: Raviger is considered feature complete and will very likely receive only maintainace patches going forward. 18 | 19 | # Installation 20 | 21 | ``` 22 | npm i raviger 23 | ``` 24 | 25 | # Docs 26 | 27 | Complete documentation is available [here on GitHub Pages](https://kyeotic.github.io/raviger/) 28 | 29 | # Quick Start 30 | 31 | ```jsx 32 | import { useRoutes, Link, useQueryParams } from 'raviger' 33 | 34 | const routes = { 35 | '/': () => , 36 | '/about': () => , 37 | '/users/:userId': ({ userId }) => 38 | } 39 | 40 | export default function App() { 41 | let route = useRoutes(routes) 42 | return ( 43 |
44 |
45 | Home 46 | About 47 | Tom 48 | Jane 49 |
50 | {route} 51 |
52 | ) 53 | } 54 | ``` 55 | 56 | ## Query Strings 57 | 58 | ```javascript 59 | import { useQueryParams } from 'raviger' 60 | 61 | function UserList ({ users }) { 62 | const [{ startsWith }, setQuery] = useQueryParams() 63 | 64 | return ( 65 |
66 | 70 | {users.filter(u => !startsWith || u.name.startsWith(startsWith).map(user => ( 71 |

{user.name}

72 | )))} 73 |
74 | ) 75 | } 76 | ``` 77 | 78 | ## Navigation 79 | 80 | The preferred method for navigation is the `` component, which uses all the same properties as the standard `` element, and requires `href`. Internally `` uses `history.pushState` to ensure navigation without a page refresh. If you need to perform programmatic navigation raviger exports a `navigate` function. 81 | 82 | Some routing libraries only trigger React component updates if navigation was triggered using specific methods, such as a specific instance of **history**. **raviger** listens for all `popstate` events and checks for changes. You can even have two isolated React instances on a page and URL changes will properly trigger **raviger** hooks. 83 | -------------------------------------------------------------------------------- /docs/api/navigate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "navigate" 3 | permalink: /navigate/ 4 | nav_order: 5 5 | --- 6 | 7 | # `navigate` 8 | 9 | This function causes programmatic navigation and causes all **raviger** hooks to re-render. Internally it used by the `` component. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export function navigate( 15 | url: string, 16 | options?: { 17 | /** 18 | * Use a `replace` instead of `push` for navigation 19 | * @default false */ 20 | replace?: boolean 21 | /** Values to serialize as a querystring, which will be appended to the `url` */ 22 | query?: QueryParam | URLSearchParams 23 | /** value to pass as the state/data to history push/replace*/ 24 | state?: unknown 25 | } 26 | ): void 27 | ``` 28 | 29 | **Note**: `navigate` does not consider any `basePath` that may be set. The `useNavigate` hook should be used if you want to prepend the `basePath` to the URL when navigating. 30 | 31 | ## Basic 32 | 33 | the `navigate` function is intended to be used outside of components to perform page navigation programmatically. 34 | 35 | ```jsx 36 | import { navigate } from 'raviger' 37 | 38 | export async function createUser () { 39 | let user = await createUser() 40 | navigate(`/users/${user.id}`) 41 | } 42 | ``` 43 | 44 | Normal navigation adds an entry to the browsers **history** stack, enabling the **back button** to return to the previous location. To instead change the page without adding to the history stack use the `replace` option. This is sometimes desirable when creating objects as the creation-page form may no longer be a valid location 45 | 46 | ```jsx 47 | import { navigate } from 'raviger' 48 | 49 | export async function createUser () { 50 | let user = await createUser() 51 | navigate(`/users/${user.id}`, { replace: true }) 52 | } 53 | ``` 54 | 55 | ## Navigating with Query Params 56 | 57 | To navigate with a serialized query string pass an object to the `query` option. 58 | 59 | ```jsx 60 | import { navigate } from 'raviger' 61 | 62 | export async function createUser () { 63 | let user = await createUser() 64 | navigate(`/users/${user.id}`, { query: { ref: 'create page' }}) 65 | } 66 | ``` 67 | 68 | ## Navigating with History State 69 | 70 | By default the `state` is `null`. You can control the `state` passed to `history.pushState | history.replaceState` using the `state` option. 71 | 72 | ```jsx 73 | import { navigate } from 'raviger' 74 | 75 | export async function createUser () { 76 | let user = await createUser() 77 | navigate(`/users/${user.id}`, { state: { user: user } }) 78 | } 79 | ``` 80 | 81 | ## Detecting `navigate` events 82 | 83 | `navigate` has two modes: intra-domain navigation and extra-domain navigation. When navigating outside the current origin navigation is done directly, and no `popstate` event is dispatched. When navigating to the current origin a custom `popstate` event is dispatched. The event has a `__tag: 'raviger:navigation'` property is attached to help programmatically distinguish these events from `popstate` events dispatched from other sources, such as the browser **back button**. -------------------------------------------------------------------------------- /src/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, forwardRef, Ref } from 'react' 2 | 3 | import { navigate, NavigateOptions } from './navigate' 4 | import { useBasePath, useFullPath } from './location' 5 | 6 | export interface LinkProps extends React.AnchorHTMLAttributes, NavigateOptions { 7 | href: string 8 | basePath?: string 9 | children?: React.ReactNode 10 | } 11 | export type LinkRef = HTMLAnchorElement | null 12 | 13 | export interface ActiveLinkProps extends LinkProps { 14 | activeClass?: string 15 | exactActiveClass?: string 16 | } 17 | 18 | function Link( 19 | { href, basePath, replace, query, state, ...props }: LinkProps, 20 | ref?: Ref, 21 | ) { 22 | basePath = useLinkBasePath(basePath) 23 | href = getLinkHref(href, basePath) 24 | 25 | const { onClick, target } = props 26 | 27 | const handleClick = useCallback>( 28 | (e) => { 29 | try { 30 | if (onClick) onClick(e) 31 | } catch (ex) { 32 | e.preventDefault() 33 | throw ex 34 | } 35 | if (shouldTrap(e, target)) { 36 | e.preventDefault() // prevent the link from actually navigating 37 | navigate(e.currentTarget.href, { replace: replace, query: query, state: state }) 38 | } 39 | }, 40 | [onClick, target], 41 | ) 42 | 43 | return 44 | } 45 | 46 | const RefLink = forwardRef(Link) as ( 47 | props: LinkProps & { ref?: React.ForwardedRef }, 48 | ) => ReturnType 49 | 50 | export default RefLink 51 | export { RefLink as Link } 52 | 53 | function ActiveLink( 54 | { basePath, className, exactActiveClass, activeClass, ...props }: ActiveLinkProps, 55 | ref?: Ref, 56 | ) { 57 | basePath = useLinkBasePath(basePath) 58 | const fullPath = useFullPath() 59 | 60 | let { href } = props 61 | href = absolutePathName(getLinkHref(href, basePath)) 62 | 63 | if (exactActiveClass && fullPath === href) 64 | className = `${className ?? ``} ${exactActiveClass}`.trim() 65 | if (activeClass && fullPath.startsWith(href)) 66 | className = `${className ?? ``} ${activeClass}`.trim() 67 | 68 | return 69 | } 70 | 71 | const ActiveLinkRef = forwardRef(ActiveLink) as ( 72 | props: ActiveLinkProps & { ref?: React.ForwardedRef }, 73 | ) => ReturnType 74 | 75 | export { ActiveLinkRef as ActiveLink } 76 | 77 | function useLinkBasePath(basePath?: string): string { 78 | const contextBasePath = useBasePath() 79 | if (basePath === '/') return '' 80 | return basePath || contextBasePath 81 | } 82 | 83 | function getLinkHref(href: string, basePath = '') { 84 | return href.startsWith('/') ? basePath + href : href 85 | } 86 | 87 | function absolutePathName(href: string): string { 88 | if (href.startsWith('/')) return href 89 | return new URL(href, document.baseURI).pathname 90 | } 91 | 92 | function shouldTrap(e: React.MouseEvent, target?: string) { 93 | return ( 94 | !e.defaultPrevented && // onClick prevented default 95 | e.button === 0 && // ignore everything but left clicks 96 | !(target || target === '_self') && // don't trap target === blank 97 | !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /docs/api/useQueryParams.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useQueryParams" 3 | permalink: /use-query-params/ 4 | nav_order: 6 5 | --- 6 | 7 | # `useQueryParams` 8 | 9 | A hook for reading and updating the query string parameters on the page. Updates on all URL changes. Returns an array that, much like React's own [`useState`](https://reactjs.org/docs/hooks-reference.html#usestate), has a value and a setter function. The value is a parsed querystring object, and the setter takes an object that it will serialize into the query string. 10 | 11 | ## API 12 | 13 | 14 | ```typescript 15 | export function useQueryParams( 16 | parseFn?: (query: string) => T, 17 | serializeFn?: (query: Partial) => string 18 | ): [T, (query: T, options?: { replace?: boolean, historyReplace?: boolean }) => void] 19 | ``` 20 | 21 | ## Basic 22 | 23 | The default parse and serialize functions utilize the browser's built-in `URLSearchParams`. You can provide custom parse and serialize functions to override this behavior. 24 | 25 | ```jsx 26 | import { useQueryParams } from 'raviger' 27 | 28 | function UserList ({ users }) { 29 | const [{ startsWith }, setQuery] = useQueryParams() 30 | 31 | return ( 32 |
33 | 37 | {users.filter(u => !startsWith || u.name.startsWith(startsWith).map(user => ( 38 |

{user.name}

39 | )))} 40 |
41 | ) 42 | } 43 | ``` 44 | 45 | ## Updating the Query with merge 46 | 47 | The second return value from `useQueryParams` is a function that updates the query string. By default it overwrites the entire query, but it can merge with the query object by setting the `replace` option to `false`. 48 | 49 | ```jsx 50 | import { useQueryParams } from 'raviger' 51 | 52 | function UserList ({ users }) { 53 | const [{ startsWith }, setQuery] = useQueryParams() 54 | return ( 55 | setQuery({ startsWith: e.target.value}, { replace: false })} /> 56 | ) 57 | } 58 | ``` 59 | 60 | The `replace: false` setting also preserves the `location.hash`. The intent should be thought of as updating only the part of the URL that the `setQuery` object describes. 61 | 62 | You can also control whether the navigation replaces the current history entry by using the `historyReplace` option: 63 | 64 | ```jsx 65 | import { useQueryParams } from 'raviger' 66 | 67 | function UserList ({ users }) { 68 | const [{ startsWith }, setQuery] = useQueryParams() 69 | 70 | // This will update the query params without adding a new history entry 71 | const handleChange = (e) => { 72 | setQuery({ startsWith: e.target.value}, { historyReplace: true }) 73 | } 74 | 75 | return ( 76 | 77 | ) 78 | } 79 | ``` 80 | 81 | > Warning: using `setQuery` inside of a `useEffect` (or other on-mount/on-update lifecycle methods) can result in unwanted navigations, which show up as duplicate entries in the browser history stack. 82 | 83 | ## Custom serialization and parsing 84 | 85 | Its possible to override either the querystring *serializer*, *deserializer*, or both, by providing functions to `useQueryParams`. Use a custom wrapper hook to reuse throughout your application. 86 | 87 | ```javascript 88 | import { useQueryParams } from 'raviger' 89 | import qs from 'qs' 90 | 91 | export function useCustomQuery() { 92 | return useQueryParams(qs.parse, qs.stringify) 93 | } 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/api/usePathParams.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "usePathParams" 3 | permalink: /use-path-params/ 4 | nav_order: 8 5 | --- 6 | 7 | # usePathParams 8 | 9 | A hook for extracting the path parameters from a path or array of paths. It uses the same path-matching syntax, as well as the `basePath` and `matchTrailingSlash` options, as [useRoutes](/use-routes). 10 | 11 | In most use cases `useRoutes` is a better way to provide components with their path parameters as it centralizes the routing knowledge in your application and reduces the dependency of individual pages on the url structure. `usePathParams` also does not establish React Contexts for `usePath` and `useBasePath` like `useRoutes` does; it is not a *routing* hook, it is a *path* hook. 12 | 13 | ```ts 14 | // This is a simplified type contract, the real one is quite complex 15 | // It is not valid, but it concisely conveys the real input and ouput types 16 | function usePathParams( 17 | routes: T, 18 | options?: PathParamOption 19 | ): T extends string[] ? [string, ExtractPathParams] : ExtractPathParams 20 | 21 | interface PathParamOptions { 22 | basePath?: string 23 | matchTrailingSlash?: boolean 24 | } 25 | ``` 26 | 27 | > If no `basePath` is provided the hook will inherit it from the context of any active `useRoutes` ancestor. 28 | 29 | * `basePath`: Override the `basePath` from the context, if any is present. (`'/'` can be used to clear any inherited `basePath`) 30 | * If `matchTrailingSlash` is true (which it is by default) a route with a `/` on the end will still match a defined route without a trialing slash. For example, `'/about': () => ` would match the path `/about/`. If `matchTrailingSlash` is false then a trailing slash will cause a match failure unless the defined route also has a trailing slash. 31 | 32 | ## Matching a single path 33 | 34 | When called with a single path pattern `usePathParams` returns either `null`, if the path didn't match, or an object with the extracted path parameters, if the path did match. 35 | 36 | ```tsx 37 | // with path = /home 38 | const props = usePathParams('/users') // props === null 39 | 40 | // with path = /users 41 | const props = usePathParams('/users') // props === {} 42 | 43 | // with path = /users/tester 44 | const props = usePathParams('/users/:userId') // props === { userId: 'tester' } 45 | ``` 46 | 47 | ## Matching multiple paths 48 | 49 | When called with an array of path patterns `usePathParams` returns `[null, null]`, if the path didn't math, or `[string, ExtractPathParams]`, if the path did match. The path-matching return is the literal path-pattern that matched and the extracted path parameters. 50 | 51 | ```tsx 52 | // with path = /home 53 | const [path, props] = usePathParams(['/users']) // [null, null] 54 | 55 | // with path = /users 56 | const [path, props] = usePathParams(['/users']) // ['/users', {}] 57 | 58 | // with path = /users/tester 59 | const [path, props] = usePathParams('/users/:userId') // ['/users/:userId', { userId: 'tester' }] 60 | ``` 61 | 62 | ## Community Contributions 63 | 64 | The `props` returned from either mode are strongly typed using the parameter names of the input. If you are using typescript you can get type checking by discriminating the `null` value, or the `path` when using the array input. 65 | 66 | ```ts 67 | const params = usePathParams([ 68 | "/groups", 69 | "/groups/:groupId", 70 | ]); 71 | 72 | if (params[0] === "/groups") { 73 | const props = params[1]; // {} 74 | } 75 | if (params[0] === "/groups/:groupId") { 76 | const props = params[1]; // { groupId: string } 77 | } 78 | ``` 79 | 80 | These typescript typings were contributed by [zoontek](https://github.com/kyeotic/raviger/pull/109#issuecomment-950228780). I am incredibly grateful for them. 81 | 82 | -------------------------------------------------------------------------------- /docs/_sass/custom/custom.scss: -------------------------------------------------------------------------------- 1 | .highlight pre, .highlight code, div.highlighter-rouge { background-color: #272822; } 2 | .highlight .hll { background-color: #272822; } 3 | .highlight .c { color: #75715e } /* Comment */ 4 | .highlight .err { color: #960050; background-color: #1e0010 } /* Error */ 5 | .highlight .k { color: #66d9ef } /* Keyword */ 6 | .highlight .l { color: #ae81ff } /* Literal */ 7 | .highlight .n { color: #f8f8f2 } /* Name */ 8 | .highlight .o { color: #f92672 } /* Operator */ 9 | .highlight .p { color: #f8f8f2 } /* Punctuation */ 10 | .highlight .cm { color: #75715e } /* Comment.Multiline */ 11 | .highlight .cp { color: #75715e } /* Comment.Preproc */ 12 | .highlight .c1 { color: #75715e } /* Comment.Single */ 13 | .highlight .cs { color: #75715e } /* Comment.Special */ 14 | .highlight .ge { font-style: italic } /* Generic.Emph */ 15 | .highlight .gs { font-weight: bold } /* Generic.Strong */ 16 | .highlight .kc { color: #66d9ef } /* Keyword.Constant */ 17 | .highlight .kd { color: #66d9ef } /* Keyword.Declaration */ 18 | .highlight .kn { color: #f92672 } /* Keyword.Namespace */ 19 | .highlight .kp { color: #66d9ef } /* Keyword.Pseudo */ 20 | .highlight .kr { color: #66d9ef } /* Keyword.Reserved */ 21 | .highlight .kt { color: #66d9ef } /* Keyword.Type */ 22 | .highlight .ld { color: #e6db74 } /* Literal.Date */ 23 | .highlight .m { color: #ae81ff } /* Literal.Number */ 24 | .highlight .s { color: #e6db74 } /* Literal.String */ 25 | .highlight .na { color: #a6e22e } /* Name.Attribute */ 26 | .highlight .nb { color: #f8f8f2 } /* Name.Builtin */ 27 | .highlight .nc { color: #a6e22e } /* Name.Class */ 28 | .highlight .no { color: #66d9ef } /* Name.Constant */ 29 | .highlight .nd { color: #a6e22e } /* Name.Decorator */ 30 | .highlight .ni { color: #f8f8f2 } /* Name.Entity */ 31 | .highlight .ne { color: #a6e22e } /* Name.Exception */ 32 | .highlight .nf { color: #a6e22e } /* Name.Function */ 33 | .highlight .nl { color: #f8f8f2 } /* Name.Label */ 34 | .highlight .nn { color: #f8f8f2 } /* Name.Namespace */ 35 | .highlight .nx { color: #a6e22e } /* Name.Other */ 36 | .highlight .py { color: #f8f8f2 } /* Name.Property */ 37 | .highlight .nt { color: #f92672 } /* Name.Tag */ 38 | .highlight .nv { color: #f8f8f2 } /* Name.Variable */ 39 | .highlight .ow { color: #f92672 } /* Operator.Word */ 40 | .highlight .w { color: #f8f8f2 } /* Text.Whitespace */ 41 | .highlight .mf { color: #ae81ff } /* Literal.Number.Float */ 42 | .highlight .mh { color: #ae81ff } /* Literal.Number.Hex */ 43 | .highlight .mi { color: #ae81ff } /* Literal.Number.Integer */ 44 | .highlight .mo { color: #ae81ff } /* Literal.Number.Oct */ 45 | .highlight .sb { color: #e6db74 } /* Literal.String.Backtick */ 46 | .highlight .sc { color: #e6db74 } /* Literal.String.Char */ 47 | .highlight .sd { color: #e6db74 } /* Literal.String.Doc */ 48 | .highlight .s2 { color: #e6db74 } /* Literal.String.Double */ 49 | .highlight .se { color: #ae81ff } /* Literal.String.Escape */ 50 | .highlight .sh { color: #e6db74 } /* Literal.String.Heredoc */ 51 | .highlight .si { color: #e6db74 } /* Literal.String.Interpol */ 52 | .highlight .sx { color: #e6db74 } /* Literal.String.Other */ 53 | .highlight .sr { color: #e6db74 } /* Literal.String.Regex */ 54 | .highlight .s1 { color: #e6db74 } /* Literal.String.Single */ 55 | .highlight .ss { color: #e6db74 } /* Literal.String.Symbol */ 56 | .highlight .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */ 57 | .highlight .vc { color: #f8f8f2 } /* Name.Variable.Class */ 58 | .highlight .vg { color: #f8f8f2 } /* Name.Variable.Global */ 59 | .highlight .vi { color: #f8f8f2 } /* Name.Variable.Instance */ 60 | .highlight .il { color: #ae81ff } /* Literal.Number.Integer.Long */ 61 | 62 | .highlight .gh { } /* Generic Heading & Diff Header */ 63 | .highlight .gu { color: #75715e; } /* Generic.Subheading & Diff Unified/Comment? */ 64 | .highlight .gd { color: #f92672; } /* Generic.Deleted & Diff Deleted */ 65 | .highlight .gi { color: #a6e22e; } /* Generic.Inserted & Diff Inserted */ -------------------------------------------------------------------------------- /src/navigate.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect } from 'react' 2 | 3 | import { useBasePath } from './location' 4 | import { isNode } from './node' 5 | import type { QueryParam } from './querystring' 6 | import { 7 | shouldCancelNavigation, 8 | addInterceptor, 9 | removeInterceptor, 10 | defaultPrompt, 11 | undoNavigation, 12 | } from './intercept' 13 | 14 | export interface NavigateOptions { 15 | /** 16 | * Use a `replace` instead of `push` for navigation 17 | * @default false */ 18 | replace?: boolean 19 | /** Values to serialize as a querystring, which will be appended to the `url` */ 20 | query?: QueryParam | URLSearchParams 21 | /** value to pass as the state/data to history push/replace*/ 22 | state?: unknown 23 | } 24 | 25 | let lastPath = '' 26 | 27 | export function navigate(url: string, options?: NavigateOptions): void { 28 | if (typeof url !== 'string') { 29 | throw new Error(`"url" must be a string, was provided a(n) ${typeof url}`) 30 | } 31 | 32 | if (Array.isArray(options?.query)) { 33 | throw new Error('"query" a serializable object or URLSearchParams') 34 | } 35 | 36 | if (shouldCancelNavigation()) return 37 | if (options?.query) { 38 | url += '?' + new URLSearchParams(options.query).toString() 39 | } 40 | 41 | lastPath = url 42 | // if the origin does not match history navigation will fail with 43 | // "cannot be created in a document with origin" 44 | // When navigating to another domain we must use location instead of history 45 | if (isAbsolute(url) && !isCurrentOrigin(url)) { 46 | window.location.assign(url) 47 | return 48 | } 49 | 50 | if (options?.replace) window.history.replaceState(options?.state, '', url) 51 | else window.history.pushState(options?.state, '', url) 52 | 53 | const event = new PopStateEvent('popstate') 54 | // Tag the event so navigation can be filtered out from browser events 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | ;(event as any).__tag = 'raviger:navigation' 57 | dispatchEvent(event) 58 | } 59 | 60 | export function useNavigationPrompt(predicate = true, prompt: string = defaultPrompt): void { 61 | if (isNode) return 62 | 63 | useLayoutEffect(() => { 64 | const onPopStateNavigation = () => { 65 | if (shouldCancelNavigation()) { 66 | undoNavigation(lastPath) 67 | } 68 | } 69 | window.addEventListener('popstate', onPopStateNavigation) 70 | return () => window.removeEventListener('popstate', onPopStateNavigation) 71 | }, []) 72 | 73 | useLayoutEffect(() => { 74 | const handler = (e?: BeforeUnloadEvent): string | void => { 75 | if (predicate) { 76 | return e ? cancelNavigation(e, prompt) : prompt 77 | } 78 | } 79 | addInterceptor(handler) 80 | return () => removeInterceptor(handler) 81 | }, [predicate, prompt]) 82 | } 83 | 84 | function cancelNavigation(event: BeforeUnloadEvent, prompt: string) { 85 | // Cancel the event as stated by the standard. 86 | event.preventDefault() 87 | // Chrome requires returnValue to be set. 88 | event.returnValue = prompt 89 | // Return value for prompt per spec 90 | return prompt 91 | } 92 | 93 | export function useNavigate(optBasePath = ''): typeof navigate { 94 | const basePath = useBasePath() 95 | const navigateWithBasePath = useCallback( 96 | (url: string, options?: NavigateOptions) => { 97 | const base = optBasePath || basePath 98 | const href = url.startsWith('/') ? base + url : url 99 | navigate(href, options) 100 | }, 101 | [basePath, optBasePath], 102 | ) 103 | return navigateWithBasePath 104 | } 105 | 106 | function isAbsolute(url: string) { 107 | return /^(?:[a-z]+:)?\/\//i.test(url) 108 | } 109 | 110 | function isCurrentOrigin(url: string) { 111 | return window.location.origin === new URL(url).origin 112 | } 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "raviger", 3 | "version": "5.2.0", 4 | "description": "React routing with hooks", 5 | "keywords": [ 6 | "react", 7 | "hooks", 8 | "router", 9 | "routing", 10 | "route", 11 | "navigation", 12 | "navigator" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/kyeotic/raviger.git" 17 | }, 18 | "homepage": "https://kyeotic.github.io/raviger", 19 | "author": "Tim Kye ", 20 | "license": "MIT", 21 | "private": false, 22 | "main": "dist/main.js", 23 | "module": "dist/module.js", 24 | "typings": "dist/main.d.ts", 25 | "files": [ 26 | "dist" 27 | ], 28 | "engines": { 29 | "node": ">=20" 30 | }, 31 | "scripts": { 32 | "style": "prettier --config package.json --write \"{src,test}/**/*.{ts,tsx}\"", 33 | "style:ci": "prettier --config package.json --check \"{src,test}/**/*.{ts,tsx}\"", 34 | "lint": "eslint", 35 | "check": "npm run style && npm run lint", 36 | "check:ci": "npm run style:ci && npm run lint", 37 | "build:clean": "rimraf dist && mkdirp dist", 38 | "build:compile": "rollup -c", 39 | "build:tsc": "tsc", 40 | "build": "run-s build:clean build:compile", 41 | "build:watch": "rollup -c --watch", 42 | "example": "npm run start --prefix example", 43 | "test": "jest", 44 | "test:watch": "jest --watch", 45 | "test:coverage": "jest --coverage", 46 | "test:coverage:open": "run-s test:coverage coverage:report", 47 | "test:unit:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit", 48 | "coverage:report": "open coverage/lcov-report/index.html", 49 | "test:ci": "run-s check:ci test:unit:ci", 50 | "size": "run-s build size:check", 51 | "size:check": "size-limit", 52 | "prepublishOnly": "run-s test:ci", 53 | "release": "np", 54 | "analyze": "size-limit --why" 55 | }, 56 | "peerDependencies": { 57 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 58 | }, 59 | "prettier": { 60 | "tabWidth": 2, 61 | "semi": false, 62 | "singleQuote": true, 63 | "printWidth": 100 64 | }, 65 | "size-limit": [ 66 | { 67 | "name": "CommonJS", 68 | "path": "dist/main.js", 69 | "limit": "2.9kb" 70 | }, 71 | { 72 | "name": "ESM", 73 | "webpack": false, 74 | "path": "dist/module.js", 75 | "limit": "3.5kb" 76 | } 77 | ], 78 | "np": { 79 | "yarn": true 80 | }, 81 | "devDependencies": { 82 | "@eslint/js": "^9.22.0", 83 | "@rollup/plugin-replace": "^6.0.2", 84 | "@rollup/plugin-terser": "^0.4.4", 85 | "@rollup/plugin-typescript": "^12.1.2", 86 | "@size-limit/preset-small-lib": "9.x", 87 | "@size-limit/webpack": "9.x", 88 | "@testing-library/jest-dom": "^5.16.4", 89 | "@testing-library/react": "15.x", 90 | "@types/react": "18", 91 | "@types/react-dom": "18", 92 | "eslint": "^9.22.0", 93 | "eslint-plugin-import": "^2.24.0", 94 | "eslint-plugin-jest": "^28.11.0", 95 | "eslint-plugin-react": "^7.37.4", 96 | "eslint-plugin-react-hooks": "^5.2.0", 97 | "jest": "^29.7.0", 98 | "jest-environment-jsdom": "^29.7.0", 99 | "jest-junit": "^16.0.0", 100 | "jest-watch-typeahead": "^2.2.2", 101 | "mkdirp": "^3.0.1", 102 | "np": "^10.2.0", 103 | "npm-run-all": "^4.1.5", 104 | "prettier": "^3.5.3", 105 | "react": "18", 106 | "react-dom": "18", 107 | "rimraf": "^6.0.1", 108 | "rollup": "^4.35.0", 109 | "size-limit": "9.x", 110 | "ts-jest": "^29.2.6", 111 | "ts-node": "^10.9.2", 112 | "tslib": "^2.8.1", 113 | "typescript": "^5.8.2", 114 | "typescript-eslint": "^8.26.1" 115 | }, 116 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 117 | } 118 | -------------------------------------------------------------------------------- /test/redirect.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { act } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { navigate, useRedirect, Redirect, useRoutes } from '../src/main' 4 | 5 | beforeEach(() => { 6 | act(() => navigate('/')) 7 | }) 8 | 9 | describe('useRedirect', () => { 10 | function Mock() { 11 | useRedirect('/fail', '/catch', { query: { name: 'kyeotic' } }) 12 | useRedirect('/miss', '/catch') 13 | return Mock 14 | } 15 | 16 | test('route is unchanged when predicate does not match', async () => { 17 | act(() => navigate('/')) 18 | render() 19 | expect(window.location.toString()).toEqual('http://localhost/') 20 | act(() => navigate('/test')) 21 | expect(window.location.toString()).toEqual('http://localhost/test') 22 | }) 23 | 24 | test('route is changed when predicate matches', async () => { 25 | act(() => navigate('/fail')) 26 | expect(window.location.toString()).toEqual('http://localhost/fail') 27 | render() 28 | expect(window.location.toString()).toEqual('http://localhost/catch?name=kyeotic') 29 | }) 30 | 31 | test('route handles empty redirect', async () => { 32 | act(() => navigate('/')) 33 | expect(window.location.toString()).toEqual('http://localhost/') 34 | render() 35 | act(() => navigate('/miss')) 36 | expect(window.location.toString()).toEqual('http://localhost/catch') 37 | }) 38 | }) 39 | 40 | describe('Redirect', () => { 41 | test('redirects to "to" without merge', async () => { 42 | render() 43 | expect(window.location.toString()).toEqual('http://localhost/foo') 44 | }) 45 | 46 | test('redirects to "to" without merge and query', async () => { 47 | act(() => navigate('/?things=act')) 48 | render() 49 | expect(window.location.toString()).toEqual('http://localhost/foo') 50 | }) 51 | 52 | test('redirects to "to" with merge (default) and query', async () => { 53 | act(() => navigate('/?things=act')) 54 | render() 55 | expect(window.location.toString()).toEqual('http://localhost/foo?things=act') 56 | }) 57 | 58 | test('redirects to "to" with merge and query/hash', async () => { 59 | act(() => navigate('/?things=act#test')) 60 | render() 61 | expect(window.location.toString()).toEqual('http://localhost/foo?things=act#test') 62 | }) 63 | 64 | test('redirects to "to" with query when merge', async () => { 65 | act(() => navigate('/?things=act#test')) 66 | render() 67 | expect(window.location.toString()).toEqual('http://localhost/foo?things=act&stuff=junk#test') 68 | }) 69 | 70 | test('redirects to "to" with query when not merge', async () => { 71 | act(() => navigate('/?things=act#test')) 72 | render() 73 | expect(window.location.toString()).toEqual('http://localhost/foo?stuff=junk') 74 | }) 75 | test('redirects from useRoutes', async () => { 76 | const Page = () => { 77 | return pass 78 | } 79 | const routes = { 80 | '/redirect': () => , 81 | '/real/page': () => , 82 | '*': () => , 83 | } 84 | 85 | function Harness() { 86 | const route = useRoutes(routes) || not found 87 | return route 88 | } 89 | 90 | act(() => navigate('/')) 91 | const { getByTestId } = render() 92 | 93 | // Home 94 | expect(getByTestId('label')).toHaveTextContent('pass') 95 | 96 | // real 97 | act(() => navigate('/real/page')) 98 | expect(getByTestId('label')).toHaveTextContent('pass') 99 | 100 | // redirect 101 | act(() => navigate('/redirect')) 102 | expect(getByTestId('label')).toHaveTextContent('pass') 103 | 104 | // catch all redirect 105 | act(() => navigate('/fake')) 106 | expect(getByTestId('label')).toHaveTextContent('pass') 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /docs/api/useLocationChange.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useLocationChange" 3 | permalink: /use-location-change/ 4 | nav_order: 10 5 | --- 6 | 7 | # `useLocationChange` 8 | 9 | This hook invokes a setter whenever the page location is updated. It uses a `window.location` like object instead of the actual `window.location`; the type is exported as `RavigerLocation`. 10 | 11 | ## API 12 | 13 | ```typescript 14 | export interface RavigerLocation { 15 | /** The current path; alias of `pathname` */ 16 | path: string | null 17 | /** The current path; alias of `path` */ 18 | pathname: string | null 19 | /** The full path, ignores any `basePath` in the context */ 20 | fullPath: string 21 | basePath?: string 22 | search: string 23 | hash: string 24 | host: string 25 | hostname: string 26 | href: string 27 | origin: string 28 | } 29 | 30 | export function useLocationChange( 31 | setFn: (location: RavigerLocation) => void, 32 | { 33 | inheritBasePath = true, 34 | basePath = '', 35 | isActive, 36 | onInitial = false, 37 | }: { 38 | inheritBasePath?: boolean 39 | basePath?: string 40 | isActive?: boolean | (() => boolean) 41 | onInitial?: boolean 42 | } = {} 43 | ): void 44 | ``` 45 | 46 | **Note**: `options.inheritBasePath` defaults to `true` (even if `options` is not provided), and takes precedence over `options.basePath` if `true`. If no BasePath is in the context to inherit `options.basePath` will be used as a fallback, if present. If `basePath` is provided, either by parameter or by context, and is missing from the current path `null` is sent to the `setFn` callback. 47 | 48 | By default this hook will not run on the initial mount for the component. You can get the location on the first render (mount) by setting `onInitial: true` in the `options` argument. 49 | 50 | ## Basic 51 | 52 | The first parameter is a setter-function that is invoked with the new *RavigerLocation* whenever the url is changed. It does not automatically cause a re-render of the parent component (see _re-rendering_ below). 53 | 54 | ```jsx 55 | import { useLocationChange } from 'raviger' 56 | import { pageChanged } from './monitoring' 57 | 58 | function App () { 59 | useLocationChange(pageChanged) 60 | 61 | return ( 62 | // ... 63 | ) 64 | } 65 | ``` 66 | 67 | You should try to provide the same function (referential-equality) to `useLocationChange` whenever possible. If you are unable to create the function outside the component scope use the [`useCallback`](https://reactjs.org/docs/hooks-reference.html#usecallback) to get a memoized function. 68 | 69 | ```jsx 70 | import { useCallback } from 'react' 71 | import { useLocationChange } from 'raviger' 72 | import { pageChanged } from './monitoring' 73 | 74 | function App () { 75 | const onChange = useCallback(location => pageChanged('App', location.path), []) 76 | useLocationChange(onChange) 77 | 78 | return ( 79 | // ... 80 | ) 81 | } 82 | ``` 83 | 84 | ## Conditional Updates 85 | 86 | When `options.isActive` is both **defined** and **falsey** the `setFn` will not be invoked during location changes. If it is **undefined** or **truthy** the `setFn` will be invoked. 87 | 88 | ## Re-rendering 89 | 90 | **useLocationChange** does not itself cause re-rendering. However, it is possible to trigger re-rendering by combining **useLocationChange** with **useState**. 91 | 92 | ```jsx 93 | import { useState } from 'react' 94 | import { useLocationChange } from 'raviger' 95 | 96 | function Route () { 97 | const [location, setLoc] = useState(null) 98 | useLocationChange(setLoc, { onInitial: true }) 99 | 100 | return ( 101 | // ... 102 | ) 103 | } 104 | ``` 105 | 106 | ## Previous Behavior 107 | 108 | Prior to v4 this hook called `setFn` with the path instead of a `RavigerLocation`. If you prefer the previous behavior you can use this wrapper 109 | 110 | 111 | ```typescript 112 | export function usePathChange( 113 | setFn: (path: string | null) => void, 114 | options: LocationChangeOptionParams = {} 115 | ): void { 116 | useLocationChange( 117 | useCallback((location: RavigerLocation) => setFn(location.path), [setFn]), 118 | options 119 | ) 120 | } 121 | ``` 122 | 123 | -------------------------------------------------------------------------------- /test/querystring.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { act } from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import { navigate, useQueryParams } from '../src/main' 4 | 5 | beforeEach(() => { 6 | act(() => navigate('/')) 7 | }) 8 | 9 | describe('useQueryParams', () => { 10 | function Route() { 11 | const [query] = useQueryParams() 12 | return {JSON.stringify(query)} 13 | } 14 | test('parses query', async () => { 15 | act(() => navigate('/about', { query: { foo: 'bar' } })) 16 | const { getByTestId } = render() 17 | expect(getByTestId('label')).toHaveTextContent(JSON.stringify({ foo: 'bar' })) 18 | }) 19 | test('navigation updates query', async () => { 20 | const q1 = { foo: 'bar' } 21 | const q2 = { bar: 'foo' } 22 | act(() => navigate('/about', { query: q1 })) 23 | const { getByTestId } = render() 24 | expect(getByTestId('label')).toHaveTextContent(JSON.stringify(q1)) 25 | act(() => navigate('/check', { query: q2 })) 26 | expect(getByTestId('label')).toHaveTextContent(JSON.stringify(q2)) 27 | // Jest doesn't appear to do this... 28 | // act(() => window.history.back()) 29 | act(() => navigate('/about', { query: q1 })) 30 | expect(getByTestId('label')).toHaveTextContent(JSON.stringify(q1)) 31 | }) 32 | }) 33 | 34 | describe('setQueryParams', () => { 35 | function Route({ 36 | overwrite, 37 | replace, 38 | foo = 'bar', 39 | }: { 40 | overwrite?: boolean 41 | replace?: boolean 42 | foo?: string | null 43 | }) { 44 | const [query, setQuery] = useQueryParams() 45 | return ( 46 | 49 | ) 50 | } 51 | test('updates query', async () => { 52 | act(() => navigate('/about', { query: { bar: 'foo' } })) 53 | const { getByTestId } = render() 54 | 55 | act(() => void fireEvent.click(getByTestId('update'))) 56 | expect(document.location.search).toEqual('?foo=bar') 57 | }) 58 | test('handles encoded values', async () => { 59 | act(() => navigate('/about', { query: { foo: 'foo' } })) 60 | const { getByTestId } = render() 61 | 62 | act(() => void fireEvent.click(getByTestId('update'))) 63 | expect(document.location.search).toContain(`foo=${encodeURIComponent('%100')}`) 64 | expect(getByTestId('update')).toHaveTextContent('Set Query: %100') 65 | }) 66 | test('merges query', async () => { 67 | act(() => navigate('/about', { query: { bar: 'foo' } })) 68 | const { getByTestId } = render() 69 | 70 | act(() => void fireEvent.click(getByTestId('update'))) 71 | expect(document.location.search).toContain('bar=foo') 72 | expect(document.location.search).toContain('foo=bar') 73 | }) 74 | test('merges query without null', async () => { 75 | act(() => navigate('/about', { query: { bar: 'foo' } })) 76 | const { getByTestId } = render() 77 | 78 | act(() => void fireEvent.click(getByTestId('update'))) 79 | expect(document.location.search).toContain('bar=foo') 80 | expect(document.location.search).not.toContain('foo=') 81 | }) 82 | test('removes has when overwrite is true', async () => { 83 | act(() => navigate('/about#test')) 84 | const { getByTestId } = render() 85 | act(() => void fireEvent.click(getByTestId('update'))) 86 | expect(document.location.hash).toEqual('') 87 | }) 88 | test('retains has when overwrite is false', async () => { 89 | act(() => navigate('/about#test')) 90 | const { getByTestId } = render() 91 | act(() => void fireEvent.click(getByTestId('update'))) 92 | expect(document.location.hash).toEqual('#test') 93 | }) 94 | 95 | test('uses history.replaceState when replace is true', async () => { 96 | const replaceStateSpy = jest.spyOn(window.history, 'replaceState') 97 | replaceStateSpy.mockClear() 98 | act(() => navigate('/about', { query: { bar: 'foo' } })) 99 | const { getByTestId } = render() 100 | act(() => void fireEvent.click(getByTestId('update'))) 101 | expect(replaceStateSpy).toHaveBeenCalled() 102 | expect(document.location.search).toEqual('?foo=bar') 103 | replaceStateSpy.mockRestore() 104 | }) 105 | 106 | test('uses history.pushState when replace is false', async () => { 107 | const pushStateSpy = jest.spyOn(window.history, 'pushState') 108 | pushStateSpy.mockClear() 109 | act(() => navigate('/about', { query: { bar: 'foo' } })) 110 | const { getByTestId } = render() 111 | act(() => void fireEvent.click(getByTestId('update'))) 112 | expect(pushStateSpy).toHaveBeenCalled() 113 | expect(document.location.search).toEqual('?foo=bar') 114 | pushStateSpy.mockRestore() 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /docs/api/useRoutes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useRoutes" 3 | permalink: /use-routes/ 4 | nav_order: 1 5 | --- 6 | 7 | # `useRoutes` 8 | 9 | This hook is the main entry point for an application using raviger. Returns the result of matching against a route (wrapped in a [RouterProvider]/router-provider), or `null` of no match was found. 10 | 11 | ## API 12 | 13 | ```typescript 14 | function useRoutes( 15 | routes: { [key: string]: (props: { [k: string]: any }) => JSX.Element } 16 | | { path: string, fn: (props: { [k: string]: any }) => JSX.Element }, 17 | options?: { 18 | basePath?: string 19 | routeProps?: { [k: string]: any } 20 | overridePathParams?: boolean // default true 21 | matchTrailingSlash?: boolean // default true 22 | } 23 | ): JSX.Element 24 | ``` 25 | 26 | ## Basic 27 | 28 | The first parameter is an object of path keys whose values are functions that return a **ReactElement** (or null when no match is found). The paths should start with a forward-slash `/` and then contain literal matches (`/base`), path variables (`/:userId`, in the format `: + [a-zA-Z_]+`), and a `*` for catch-all wildcards. Path variables will be provided to the matching route-function. 29 | 30 | ```jsx 31 | import { useRoutes, Link } from 'raviger' 32 | 33 | const routes = { 34 | '/': () => , 35 | '/about': () => , 36 | '/users/:userId': ({ userId }) => 37 | } 38 | 39 | function NavBar () { 40 | return ( 41 |
42 | Home 43 | About 44 | Tom 45 | Jane 46 |
47 | ) 48 | } 49 | 50 | export default function App() { 51 | let route = useRoutes(routes) 52 | return ( 53 |
54 | 55 | {route} 56 |
57 | ) 58 | } 59 | ``` 60 | 61 | If `matchTrailingSlash` is true (which it is by default) a route with a `/` on the end will still match a defined route without a trialing slash. For example, `'/about': () => ` would match the path `/about/`. If `matchTrailingSlash` is false then a trailing slash will cause a match failure unless the defined route also has a trailing slash. 62 | 63 | ## Using a Base Path 64 | 65 | The `basePath` option sets a base path that causes all routes to match as if they had the base path prepended to them. It also sets the base path on the router's context, making it available to hooks and `` components lower in matching *route's* tree. If `basePath` is provided and missing from the current path `null` is returned. 66 | 67 | ```jsx 68 | import { useRoutes } from 'raviger' 69 | 70 | const routes = { 71 | '/': () => , 72 | '/about': () => , 73 | '/users/:userId': ({ userId }) => 74 | } 75 | 76 | export default function App() { 77 | // For the path "/app/about" the route will match 78 | let route = useRoutes(routes, { basePath: 'app' }) 79 | return ( 80 |
81 | {route} 82 |
83 | ) 84 | } 85 | ``` 86 | 87 | ## Sharing Props with routes 88 | 89 | The `routeProps` option can be used to pass data to the matching route. This is useful for sharing props that won't appear in the route's path, or reducing duplication in path parameter declarations. 90 | 91 | ```jsx 92 | import { useRoutes } from 'raviger' 93 | 94 | const routes = { 95 | '/': ({ title }) => , 96 | '/about': ({ title }) => 97 | } 98 | 99 | export default function App() { 100 | let route = useRoutes(routes, { routeProps: { title: 'App' } }) 101 | return ( 102 |
103 | {route} 104 |
105 | ) 106 | } 107 | ``` 108 | 109 | This can be combined with the `overridePathParams` option to provide a value that is used even if a path parameter would match for the route. In the below example if `maybeGetUserId` returns an ID it will be provided to the `` component instead of the value from the path, otherwise the route param will be used. 110 | 111 | ```jsx 112 | import { useRoutes } from 'raviger' 113 | 114 | const routes = { 115 | '/': () => , 116 | '/about': () => , 117 | '/users/:userId': ({ userId }) => 118 | } 119 | 120 | export default function App() { 121 | let userId = maybeGetUserId() 122 | let route = useRoutes(routes, { routeProps: { userId }, overridePathParams: true }) 123 | return ( 124 |
125 | {route} 126 |
127 | ) 128 | } 129 | ``` 130 | 131 | ## Using an Array to Control Priority 132 | 133 | Raviger will normally match routes in the order they are defined in the routes object, allowing you to control matching priority. However, this behavior is not guaranteed by JS, and if you dynamically construct routes you may have difficulty ordering object keys. 134 | 135 | For this case, raviger supports taking an array of `{ path, fn }` objects, where the priority is determined by position in the array. 136 | 137 | Consider this case: 138 | 139 | ```javascript 140 | { 141 | '/comp1/*': () =>

Just Comp-1

, 142 | '/comp1/view2/*': () =>

Comp-1-view-2

, 143 | '/comp1/view1/*': () =>

Comp-1-view1

, 144 | '/comp2/*': () =>

Comp-2

145 | } 146 | ``` 147 | 148 | If the app tries to route to /comp1/view1, instead of matching route /comp1/view1/* it matches /comp1/*. This can be fixed if we define the routes like this 149 | 150 | ```javascript 151 | [ 152 | 153 | { path: '/comp1/view2/*', fn:() =>

Comp-1-view-2

, }, 154 | { path: '/comp1/view1/*', fn:() =>

Comp-1-view1

, }, 155 | { path: '/comp1/*', fn:() =>

Just Comp-1

, }, 156 | { path: '/comp2/*', fn:() =>

Comp-2

}, 157 | ] 158 | ``` -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import React from 'react' 3 | 4 | import { 5 | useRoutes, 6 | Link, 7 | usePath, 8 | useQueryParams, 9 | useHash, 10 | Redirect, 11 | navigate, 12 | useHistory, 13 | } from '../../src/main' 14 | import Nav from './Nav' 15 | import Form from './Form' 16 | 17 | const routes = { 18 | '/': () => , 19 | '/about': () => about, 20 | '/contact': () => contact, 21 | '/hash-debug': () => , 22 | '/form': () =>
, 23 | '/error': () => , 24 | '/weird (route)': () => Weird Route, 25 | '/users/:userId': ({ userId }: { userId: string }) => User: {userId}, 26 | '/filter': () => , 27 | '/redirect': () => , 28 | '/redirect-external': () => , 29 | '/deep*': () => , 30 | '/*': () => , 31 | } 32 | 33 | let renders = 0 34 | function App(): JSX.Element { 35 | renders++ 36 | const route = useRoutes(routes) 37 | const path = usePath() 38 | console.log('rendered', renders, route?.props?.children?.type?.name) 39 | return ( 40 |
41 | 57 |
58 | Root Path: {path} 59 | Render Count: {renders} 60 | {route} 61 |
62 | ) 63 | } 64 | 65 | // App.whyDidYouRender = { 66 | // logOnDifferentValues: true 67 | // } 68 | 69 | export default App 70 | 71 | function Home(): JSX.Element { 72 | console.log('rendering Home') 73 | 74 | return ( 75 |
76 |

Home

77 | 90 | 103 |
104 | ) 105 | } 106 | 107 | function HashDebug() { 108 | const hash = useHash() 109 | 110 | return ( 111 |
116 | ) 117 | } 118 | 119 | function Error(): JSX.Element | null { 120 | console.log('rendering /error') 121 | 122 | const { state } = useHistory() 123 | 124 | if (!state) { 125 | console.log('no history state, so navigating to /') 126 | navigate('/') 127 | return null 128 | } 129 | 130 | return ( 131 |
132 |

Error

133 |

{JSON.stringify(state)}

134 | 142 |
143 | ) 144 | } 145 | 146 | const DeepAbout = () => ( 147 | 153 | ) 154 | 155 | const deepRoutes = { 156 | '/': () => , 157 | '/about': () => , 158 | '/contact': () => , 159 | } 160 | 161 | function Deep() { 162 | const route = useRoutes(deepRoutes, { basePath: '/deep' }) 163 | return ( 164 |
165 | 170 | {route} 171 |
172 | ) 173 | } 174 | 175 | function Filter() { 176 | const [{ type, name }, setQuery] = useQueryParams() 177 | return ( 178 | 191 | ) 192 | } 193 | 194 | function DisplayPath() { 195 | const path = usePath() 196 | return Star Path: {path} 197 | } 198 | -------------------------------------------------------------------------------- /test/Link.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import React, { useRef, act } from 'react' 3 | import { render, fireEvent } from '@testing-library/react' 4 | 5 | import { useRoutes, navigate, Link, ActiveLink } from '../src/main' 6 | 7 | beforeEach(() => { 8 | act(() => navigate('/')) 9 | }) 10 | 11 | describe('Link', () => { 12 | test('renders', async () => { 13 | const { getByTestId } = render( 14 | 15 | go to foo 16 | , 17 | ) 18 | 19 | act(() => navigate('/')) 20 | expect(getByTestId('link')).toHaveTextContent('go to foo') 21 | }) 22 | 23 | test('navigates to href', async () => { 24 | act(() => navigate('/')) 25 | const { getByTestId } = render( 26 | 27 | go to foo 28 | , 29 | ) 30 | act(() => void fireEvent.click(getByTestId('link'))) 31 | expect(document.location.pathname).toEqual('/foo') 32 | }) 33 | 34 | test('navigates to href with basePath', async () => { 35 | act(() => navigate('/')) 36 | const { getByTestId } = render( 37 | 38 | go to foo 39 | , 40 | ) 41 | act(() => void fireEvent.click(getByTestId('link'))) 42 | expect(document.location.pathname).toEqual('/bar/foo') 43 | }) 44 | 45 | test('allows basePath to be overridden for absolute links', async () => { 46 | // act(() => navigate('/')) 47 | 48 | act(() => navigate('/top')) 49 | function Host() { 50 | return ( 51 | <> 52 | 53 | nested 54 | 55 | 56 | ) 57 | } 58 | function App() { 59 | return useRoutes( 60 | { 61 | '/': () => , 62 | }, 63 | { basePath: '/top' }, 64 | ) 65 | } 66 | 67 | const { getByTestId } = render() 68 | act(() => void fireEvent.click(getByTestId('nested'))) 69 | 70 | expect(document.location.pathname).toEqual('/nested') 71 | }) 72 | 73 | test('navigates to href with basePath without root slash', async () => { 74 | act(() => navigate('/')) 75 | const { getByTestId } = render( 76 | 77 | go to foo 78 | , 79 | ) 80 | act(() => void fireEvent.click(getByTestId('link'))) 81 | expect(document.location.pathname).toEqual('/bar/foo') 82 | }) 83 | 84 | test('fires onClick', async () => { 85 | const spy = jest.fn() 86 | act(() => navigate('/')) 87 | const { getByTestId } = render( 88 | 89 | go to foo 90 | , 91 | ) 92 | act(() => void fireEvent.click(getByTestId('link'))) 93 | expect(document.location.pathname).toEqual('/foo') 94 | expect(spy).toHaveBeenCalled() 95 | }) 96 | 97 | test('doesnt navigate for target=blank', async () => { 98 | act(() => navigate('/')) 99 | const { getByTestId } = render( 100 | 101 | go to foo 102 | , 103 | ) 104 | act(() => void fireEvent.click(getByTestId('link'))) 105 | expect(document.location.pathname).toEqual('/') 106 | }) 107 | 108 | test('passes ref to anchor element', async () => { 109 | act(() => navigate('/')) 110 | 111 | let ref: any 112 | function LinkTest() { 113 | const linkRef = useRef(null) 114 | ref = linkRef 115 | return ( 116 | 117 | go to foo 118 | 119 | ) 120 | } 121 | const { getByTestId } = render() 122 | expect(getByTestId('linkref')).toBe(ref?.current) 123 | expect(ref?.current).toBeInstanceOf(HTMLAnchorElement) 124 | }) 125 | }) 126 | 127 | describe('ActiveLink', () => { 128 | test('adds active class when active', async () => { 129 | const { getByTestId } = render( 130 | 137 | go to foo 138 | , 139 | ) 140 | 141 | act(() => navigate('/')) 142 | expect(getByTestId('link')).toHaveClass('base') 143 | 144 | act(() => navigate('/foo')) 145 | expect(getByTestId('link')).toHaveClass('extra') 146 | expect(getByTestId('link')).toHaveClass('double') 147 | 148 | act(() => navigate('/foo/bar')) 149 | expect(getByTestId('link')).toHaveClass('extra') 150 | expect(getByTestId('link')).not.toHaveClass('double') 151 | }) 152 | 153 | test('navigates to href with basePath', async () => { 154 | act(() => navigate('/')) 155 | const { getByTestId } = render( 156 | 164 | go to foo 165 | , 166 | ) 167 | 168 | expect(getByTestId('link')).not.toHaveClass('active') 169 | expect(getByTestId('link')).not.toHaveClass('exact') 170 | 171 | act(() => void fireEvent.click(getByTestId('link'))) 172 | expect(document.location.pathname).toEqual('/bar/foo') 173 | 174 | expect(getByTestId('link')).toHaveClass('active') 175 | expect(getByTestId('link')).toHaveClass('exact') 176 | }) 177 | 178 | test('href without leading slash', async () => { 179 | act(() => navigate('/bar/foo')) 180 | const { getByTestId } = render( 181 | 188 | go to foo 189 | , 190 | ) 191 | 192 | expect(getByTestId('link')).not.toHaveClass('active') 193 | expect(getByTestId('link')).not.toHaveClass('exact') 194 | 195 | act(() => void fireEvent.click(getByTestId('link'))) 196 | expect(document.location.pathname).toEqual('/bar/test') 197 | 198 | expect(getByTestId('link')).toHaveClass('active') 199 | expect(getByTestId('link')).toHaveClass('exact') 200 | }) 201 | 202 | test('empty classname on active link', async () => { 203 | const { getByTestId } = render( 204 | 205 | go to foo 206 | , 207 | ) 208 | 209 | act(() => navigate('/')) 210 | expect(getByTestId('link')).not.toHaveAttribute('class') 211 | 212 | act(() => navigate('/foo')) 213 | expect(getByTestId('link')).toHaveAttribute('class', 'double extra') 214 | 215 | act(() => navigate('/foo/bar')) 216 | expect(getByTestId('link')).toHaveAttribute('class', 'extra') 217 | }) 218 | }) 219 | -------------------------------------------------------------------------------- /src/location.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useRef, useContext, useLayoutEffect } from 'react' 2 | 3 | import { BasePathContext, PathContext } from './context' 4 | import { useMountedLayout } from './hooks' 5 | import { getSsrPath, isNode } from './node' 6 | import { shouldCancelNavigation } from './intercept' 7 | import { isFunction } from './typeChecks' 8 | 9 | export interface RavigerLocation { 10 | /** The current path; alias of `pathname` */ 11 | path: string | null 12 | /** The current path; alias of `path` */ 13 | pathname: string | null 14 | /** The full path, ignores any `basePath` in the context */ 15 | fullPath: string 16 | basePath?: string 17 | search: string 18 | hash: string 19 | host: string 20 | hostname: string 21 | href: string 22 | origin: string 23 | } 24 | 25 | export interface RavigerHistory { 26 | scrollRestoration: 'auto' | 'manual' 27 | state: unknown 28 | } 29 | 30 | export interface LocationChangeSetFn { 31 | (location: RavigerLocation): void 32 | } 33 | export interface LocationChangeOptionParams { 34 | inheritBasePath?: boolean 35 | basePath?: string 36 | isActive?: boolean | (() => boolean) 37 | onInitial?: boolean 38 | } 39 | 40 | export function usePath(basePath?: string): string | null { 41 | const contextPath = useContext(PathContext) 42 | const contextBasePath = useBasePath() // hooks can't be called conditionally 43 | basePath = basePath || contextBasePath 44 | 45 | // Don't bother tracking the actual path, it can get out of sync 46 | // due to React parent/child render ordering, especially with onmount navigation 47 | // See issues: 48 | // https://github.com/kyeotic/raviger/issues/116 49 | // https://github.com/kyeotic/raviger/issues/64 50 | // 51 | // This is just used to force a re-render 52 | const [, setPath] = useState(getFormattedPath(basePath)) 53 | const onChange = useCallback(({ path: newPath }) => setPath(newPath), []) 54 | useLocationChange(onChange, { 55 | basePath, 56 | inheritBasePath: !basePath, 57 | // Use on initial to handle to force state updates from on-mount navigation 58 | onInitial: true, 59 | }) 60 | 61 | return contextPath || getFormattedPath(basePath) 62 | } 63 | 64 | export function useBasePath(): string { 65 | return useContext(BasePathContext) 66 | } 67 | 68 | export function useFullPath(): string { 69 | const [path, setPath] = useState(getCurrentPath()) 70 | const onChange = useCallback(({ path: newPath }) => setPath(newPath), []) 71 | useLocationChange(onChange, { inheritBasePath: false }) 72 | 73 | return path || '/' 74 | } 75 | 76 | export function useHash({ stripHash = true } = {}): string { 77 | const [hash, setHash] = useState(window.location.hash) 78 | const handleHash = useCallback(() => { 79 | const newHash = window.location.hash 80 | if (newHash === hash) return 81 | setHash(newHash) 82 | }, [setHash, hash]) 83 | 84 | useLayoutEffect(() => { 85 | window.addEventListener('hashchange', handleHash, false) 86 | return () => window.removeEventListener('hashchange', handleHash) 87 | }, [handleHash]) 88 | 89 | useLocationChange(handleHash) 90 | return stripHash ? hash.substring(1) : hash 91 | } 92 | 93 | export function getCurrentPath(): string { 94 | return isNode ? getSsrPath() : window.location.pathname || '/' 95 | } 96 | 97 | export function getCurrentHash(): string { 98 | if (isNode) { 99 | const path = getSsrPath() 100 | const hashIndex = path.indexOf('#') 101 | return path.substring(hashIndex) 102 | } 103 | return window.location.hash 104 | } 105 | 106 | export function useLocationChange( 107 | setFn: LocationChangeSetFn, 108 | { 109 | inheritBasePath = true, 110 | basePath = '', 111 | isActive, 112 | onInitial = false, 113 | }: LocationChangeOptionParams = {}, 114 | ): void { 115 | if (isNode) return 116 | 117 | // All hooks after this are conditional, but the runtime can't actually change 118 | 119 | const routerBasePath = useBasePath() 120 | if (inheritBasePath && routerBasePath) basePath = routerBasePath 121 | 122 | const setRef = useRef(setFn) 123 | useLayoutEffect(() => { 124 | // setFn could be an in-render declared callback, making it unstable 125 | // This is a method of using an often-changing callback from React Hooks 126 | // https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback 127 | // While not recommended, it is the best current (16.9) available method 128 | // For reducing the useEffect cleanup from setFn changing every render 129 | setRef.current = setFn 130 | }) 131 | 132 | const onPopState = useCallback(() => { 133 | // No predicate defaults true 134 | if (isActive !== undefined && !isPredicateActive(isActive)) return 135 | if (shouldCancelNavigation()) return 136 | setRef.current(getFormattedLocation(basePath)) 137 | }, [isActive, basePath]) 138 | 139 | useLayoutEffect(() => { 140 | window.addEventListener('popstate', onPopState) 141 | return () => window.removeEventListener('popstate', onPopState) 142 | }, [onPopState]) 143 | 144 | // When the basePath changes re-check the path after the render completes 145 | // This allows nested contexts to get an up-to-date formatted path 146 | useMountedLayout( 147 | () => { 148 | if (isActive !== undefined && !isPredicateActive(isActive)) return 149 | setRef.current(getFormattedLocation(basePath)) 150 | }, 151 | [basePath, isActive], 152 | { onInitial }, 153 | ) 154 | } 155 | 156 | export function useHistory(): RavigerHistory { 157 | const [history, setHistory] = useState(getRavigerHistory()) 158 | useLocationChange(useCallback(() => setHistory(getRavigerHistory()), [setHistory])) 159 | return history 160 | } 161 | 162 | function getRavigerHistory(): RavigerHistory { 163 | if (isNode) return { scrollRestoration: 'manual', state: null } 164 | return { 165 | scrollRestoration: window.history.scrollRestoration, 166 | state: window.history.state, 167 | } 168 | } 169 | 170 | /** 171 | * Returns the current path after decoding. If basePath is provided it will be removed from the front of the path. 172 | * If basePath is provided and the path does not begin with it will return null 173 | * @param {string} basePath basePath, if any 174 | * @return {string | null} returns path with basePath prefix removed, or null if basePath is provided and missing 175 | */ 176 | export function getFormattedPath(basePath: string): string | null { 177 | const path = getCurrentPath() 178 | const baseMissing = basePath && !isPathInBase(basePath, path) 179 | if (path === null || baseMissing) return null 180 | return decodeURIComponent(!basePath ? path : path.replace(basePathMatcher(basePath), '') || '/') 181 | } 182 | 183 | function getFormattedLocation(basePath: string): RavigerLocation { 184 | const path = getFormattedPath(basePath) 185 | return { 186 | basePath, 187 | path, 188 | pathname: path, 189 | fullPath: getCurrentPath(), 190 | search: window.location.search, 191 | hash: getCurrentHash(), 192 | host: window.location.host, 193 | hostname: window.location.hostname, 194 | href: window.location.href, 195 | origin: window.location.origin, 196 | } 197 | } 198 | 199 | function isPredicateActive(predicate: boolean | (() => boolean)): boolean { 200 | return isFunction(predicate) ? predicate() : predicate 201 | } 202 | 203 | function basePathMatcher(basePath: string): RegExp { 204 | return new RegExp('^' + basePath, 'i') 205 | } 206 | 207 | function isPathInBase(basePath: string, path: string): boolean { 208 | return !!(basePath && path && path.toLowerCase().startsWith(basePath.toLowerCase())) 209 | } 210 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [5.2.0] - 2025-12-10 8 | ### Added 9 | - `` support the `state` param 10 | 11 | ## [5.1.0] - 2025-09-30 12 | ### Added 13 | - `` props for `NavigateOptions` 14 | 15 | ## [5.0.0] - 2025-06-29 16 | ### Added 17 | - Added support for `useRoutes` to take an array to specify route matching priority 18 | - Support for React 19 19 | ### Changed 20 | - **BREAKING**: changed parameters on `useQueryParams` setter options from `replace, replaceHistory` to `overwrite, replace` to keep consistency with `replace` on other navigation functions. 21 | - Added support for underscore `_` in path part matchers 22 | - Removed support for Node versions older than 20 23 | 24 | ## [4.2.1] - 2025-04-08 25 | ### Added 26 | - `historyReplace` to setQuery option on `useQuery` hook result, to control how history is updated 27 | 28 | ## [4.1.2] - 2022-11-02 29 | ### Fixed 30 | - `useHash` undefined error from missing prop on event 31 | 32 | ## [4.1.0] - 2022-04-24 33 | ### Added 34 | - Support for React 18 35 | 36 | ## [4.0.0] - 2022-03-11 37 | ### Added 38 | - `useHistory` 39 | - `usePath` 40 | ### Changed 41 | - **BREAKING**: `navigate` now has `options` object instead of overloads 42 | - **BREAKING**: `useNavigate` uses updated `navigate` params 43 | - **BREAKING**: `useLocationChange` now invokes the setter with a `RavigerLocation` object instead of the `path` string 44 | ### Fixed 45 | - `usePath` getting an old path if `navigate` was called during the initial render 46 | 47 | ## [3.1.0] - 2022-01-17 48 | ### Added 49 | - `__tag` to the PopState events dispatched by `navigate` 50 | 51 | ## [3.0.0] - 2021-11-02 52 | ### Changed 53 | - **BREAKING**: `usePath` returns `decodeURIComponent`-ed path 54 | - **BREAKING**: `useRoutes`, `useMatch`, and `usePathParams` match paths that have been `decodeURIComponent`-ed (e.g. `/weird (route)` will match a path of `/weird%20(route)`) 55 | - **BREAKING**: type `RouteParams` renamed to `Routes` 56 | - **BREAKING**: `usePathParams` return type now depends on the input params. When a string it returns just the props, when an array it returns `[path, props]` 57 | ### Added 58 | - `RouterProvider` 59 | ### Fixed 60 | - `useRoutes` function-values to use props type `string` (was `any`) 61 | 62 | ## [2.6.0] - 2021-10-15 63 | ### Added 64 | - `useMatch` hook 65 | - `usePathParams` hook 66 | 67 | ## [2.5.5] - 2021-10-03 68 | ### Fixed 69 | - `` not skipping context `basePath` 70 | 71 | ## [2.5.4] - 2021-08-27 72 | ### Fixed 73 | - terser mangling `location` in querystring methods 74 | 75 | ## [2.5.3] - 2021-08-25 76 | ### Changed 77 | - Updated transpilation to reduce size, remove Babel 78 | 79 | ## [2.5.2] - 2021-08-19 80 | ### Fixed 81 | - `` matching in nested contexts 82 | - `` matching for relative paths 83 | 84 | ## [2.5.0] - 2021-08-15 85 | ### Changed 86 | - `src/` to typescript 87 | - types are now natively generated from source and provided as declaration files 88 | - Minimum NodeJS version is now 12 89 | 90 | ## [2.4.2] - 2021-07-16 91 | ### Fixed 92 | - `useRoutes` not updating after an internal `` updates the location 93 | 94 | ## [2.4.1] - 2021-06-28 95 | ### Changed 96 | - Rollup build to `keep_fnames` to retain component name checking 97 | 98 | ## [2.4.0] - 2021-06-28 99 | ### Added 100 | - `navigate` support for urls with different origins (external navigation) 101 | 102 | ## [2.3.1] - 2021-06-18 103 | ### Added 104 | - Sourcemaps to published package 105 | ### Changed 106 | - `useNavigationPrompt` now restores scroll position after undoing navigation 107 | 108 | ## [2.3.0] - 2021-06-18 109 | ### Changed 110 | - `useNavigationPrompt` now intercepts browser back/forward button navigation 111 | 112 | ## [2.2.0] - 2021-06-07 113 | ### Changed 114 | - Added support for React@17 in `peerDependencies` 115 | 116 | ## [2.1.0] - 2021-05-02 117 | ### Added 118 | - `options.onInitial` parameter for `useLocationChange` that controls the first render behavior. `default: false`. 119 | ### Fixed 120 | - `useLocationChange` invoking the setter on initial render. This was not intended and was an unannounced change from the v1 behavior, so reverting it is not considered an API change but a bugfix. 121 | 122 | ## [2.0.2] - 2021-03-22 123 | ### Added 124 | - `state` parameter for `navigate` 125 | 126 | ## [2.0.1] - 2021-01-07 127 | ### Removed 128 | - `engine` requirement for 'less than' Node 15 129 | 130 | ## [2.0.0] - 2020-11-17 131 | ### Changed 132 | - **BREAKING**: `useRoutes` and `usePath` will return `null` if `basePath` is provided and missing from path 133 | - **BREAKING**: `useLocationChange` will invoke callback with `null` if `basePath` is provided and missing from path 134 | - **BREAKING**: `useLocationChange` option `inheritBasePath` now accepts any false value (previously required `false` with `===`) 135 | - **BREAKING**: `useRoutes` option `matchTrailingSlash` default to `true` (was `false`) 136 | - **BREAKING**: removed `linkRef` prop from `Link` and `ActiveLink`, replaced with standard React `forwardRef` 137 | - **BREAKING**: `useQueryParams` setter second argument changed from `replace` to options param with `replace` property 138 | - **BREAKING**: `useRedirect` parameters changed to match properties on `Redirect` component 139 | ### Added 140 | - `useFullPath` for getting the full path, ignoring any context-provided `basePath` 141 | - Support for Node 14 142 | - Rollup-plugin-terser for builds 143 | ### Removed 144 | - Support for Node 8 145 | 146 | ## [1.6.0] - 2020-10-22 147 | ### Added 148 | - `useNavigate` hook 149 | 150 | ## [1.5.1] - 2020-10-09 151 | ### Fixed 152 | - `matchTrailingSlash` not matching on the root route `/` 153 | 154 | ## [1.5.0] - 2020-10-09 155 | ### Added 156 | - `query` prop to `` 157 | 158 | ## [1.4.6] - 2020-10-07 159 | ### Fixed 160 | - `useRoutes` path tracking with `usePath` causing improper child invocations 161 | 162 | ## [1.4.5] - 2020-07-31 163 | ### Fixed 164 | - `navigate` handling of `replace` in edge cases for `replaceOrQuery` 165 | 166 | ## [1.4.4] - 2020-06-26 167 | ### Fixed 168 | - `basePath` matches not using case-insensitive like route paths 169 | 170 | ## [1.4.3] - 2020-05-12 171 | ### Fixed 172 | - `useQueryParam` using a `?` when no query is set 173 | - typescript declaration for `useNavigationPrompt` 174 | 175 | ## [1.4.2] - 2020-04-30 176 | ### Fixed 177 | - `useRoutes` error when changing the number of routes 178 | 179 | ## [1.4.1] - 2020-04-28 180 | ### Fixed 181 | - `usePath` sets `inheritBasePath: false` when using provided `basePath` 182 | 183 | ## [1.4.0] - 2020-03-18 184 | ### Added 185 | - `` Component 186 | - `useHash` hook 187 | 188 | ## [1.3.0] - 2020-01-27 189 | ### Added 190 | - `` prop override. 191 | ### Fixed 192 | - double decoding on `useQueryParams` 193 | 194 | ## [1.2.0] - 2020-01-21 195 | ### Changed 196 | - Internal React-Context setup, reduces wasteful re-renders 197 | 198 | ## [1.1.1] - 2020-01-09 199 | ### Fixed 200 | - `replace: false` on `setQueryParams` replacing `location.hash` 201 | 202 | ## [1.1.0] - 2019-11-21 203 | ### Added 204 | - `linkRef` prop to `` 205 | 206 | ## [1.0.0] - 2019-11-12 207 | ### Added 208 | - `useLocationChange`: similar to use `usePopState`, but uses different parameters 209 | ### Removed 210 | - `usePopState` hook 211 | 212 | ## [0.5.9] - 2019-10-28 213 | ### Added 214 | - `useNavigationPrompt` for confirming navigation changes 215 | 216 | ## [0.5.8] - 2019-10-14 217 | ### Fixed 218 | - `` triggering local navigation 219 | 220 | ## [0.5.7] - 2019-09-26 221 | ### Added 222 | - `` 223 | 224 | ## [0.5.5] - 2019-09-23 225 | ### Added 226 | - `useRoutes` option `matchTrailingSlash` 227 | 228 | ## [0.5.4] - 2019-09-16 229 | ### Added 230 | - typescript declarations 231 | 232 | ## [0.5.0] - 2019-09-13 233 | ### Changed 234 | - `useRoutes` second parameter from `basePath` to `options` 235 | ### Added 236 | - `useRoutes` option `routeProps` 237 | - `useRoutes` option `overridePathParams` 238 | 239 | ## [0.4.0] - 2019-09-12 240 | ### Added 241 | - `useBasePath` hook to retrieve the basePath 242 | 243 | ## [0.3.11] - 2019-09-09 244 | ### Fixed 245 | - `` cmd key detection 246 | 247 | ## [0.3.9] - 2019-08-29 248 | ### Added 249 | - `navigate` checks `url` param 250 | 251 | ## [0.3.8] - 2019-08-27 252 | ### Fixed 253 | - `useRedirect` adding null query 254 | 255 | ## [0.3.6] - 2019-08-27 256 | ### Fixed 257 | - Rollup dist output 258 | 259 | ## [0.3.4] - 2019-08-21 260 | ### Added 261 | - `useRedirect` hook 262 | 263 | ## [0.3.3] - 2019-08-21 264 | ### Added 265 | - `navigate(url, queryStringObj)` overload 266 | 267 | ## [0.3.2] - 2019-08-08 268 | ### Added 269 | - rollup output for module and cjs 270 | -------------------------------------------------------------------------------- /test/navigate.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import React, { act } from 'react' 3 | import { render } from '@testing-library/react' 4 | import { mockNavigation, delay } from './utils' 5 | 6 | import { 7 | navigate, 8 | useNavigate, 9 | useRoutes, 10 | useNavigationPrompt, 11 | QueryParam, 12 | Routes, 13 | } from '../src/main' 14 | 15 | mockNavigation() 16 | 17 | describe('useNavigate', () => { 18 | function Route({ 19 | newPath, 20 | query, 21 | replace, 22 | }: { 23 | newPath: string 24 | query?: QueryParam | URLSearchParams 25 | replace?: boolean 26 | }) { 27 | const navigateWithBasePath = useNavigate() 28 | return ( 29 | 36 | ) 37 | } 38 | 39 | const routes: Routes = { 40 | // @ts-expect-error there come from RouteProps, find a way to type them 41 | '/*': ({ newPath, query, replace }) => ( 42 | 43 | ), 44 | } 45 | 46 | function App({ 47 | basePath, 48 | newPath, 49 | query, 50 | replace, 51 | }: { 52 | basePath?: string 53 | newPath?: any 54 | query?: any 55 | replace?: boolean 56 | }) { 57 | const route = useRoutes(routes, { 58 | basePath, 59 | routeProps: { newPath, query, replace }, 60 | }) 61 | return route 62 | } 63 | 64 | test('navigate with a basePath', async () => { 65 | const basePath = '/base' 66 | const newPath = '/path' 67 | 68 | const { container } = render() 69 | act(() => navigate(`${basePath}/`)) 70 | 71 | const button = container.querySelector('button') 72 | act(() => { 73 | button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) 74 | }) 75 | 76 | expect(document.location.pathname).toEqual(basePath + newPath) 77 | }) 78 | 79 | test('navigate without a basePath', async () => { 80 | const newPath = '/path' 81 | 82 | const { container } = render() 83 | act(() => navigate('/')) 84 | 85 | const button = container.querySelector('button') 86 | act(() => { 87 | button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) 88 | }) 89 | 90 | expect(document.location.pathname).toEqual(newPath) 91 | }) 92 | 93 | test('navigate to a relative path', async () => { 94 | const basePath = '/base' 95 | const newPath = 'relative' 96 | 97 | const { container } = render() 98 | act(() => navigate(`${basePath}/page`)) 99 | 100 | const button = container.querySelector('button') 101 | act(() => { 102 | button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) 103 | }) 104 | 105 | expect(document.location.pathname).toEqual(`${basePath}/${newPath}`) 106 | }) 107 | 108 | test('navigate with a query', async () => { 109 | const basePath = '/base' 110 | const newPath = '/path' 111 | const query = { foo: 'bar' } 112 | 113 | const { container } = render() 114 | act(() => navigate(`${basePath}/`)) 115 | 116 | const button = container.querySelector('button') 117 | act(() => { 118 | button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) 119 | }) 120 | 121 | expect(document.location.pathname).toEqual(basePath + newPath) 122 | expect(document.location.search).toEqual('?foo=bar') 123 | }) 124 | 125 | test('navigate without history', async () => { 126 | const basePath = '/base' 127 | const newPath = '/path' 128 | 129 | const { container } = render() 130 | act(() => navigate(`${basePath}/`)) 131 | 132 | window.history.replaceState = jest.fn() 133 | const button = container.querySelector('button') 134 | act(() => { 135 | button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) 136 | }) 137 | 138 | const calls = (window.history.replaceState as jest.Mock).mock.calls 139 | 140 | expect(calls).toHaveLength(1) 141 | expect(calls[0]).toHaveLength(3) 142 | expect(calls[0][2]).toEqual(basePath + newPath) 143 | jest.clearAllMocks() 144 | }) 145 | }) 146 | 147 | describe('useNavigationPrompt', () => { 148 | function Route({ block = true, prompt }: { block: boolean; prompt?: string }) { 149 | useNavigationPrompt(block, prompt) 150 | return null 151 | } 152 | test('navigation does not block when prompt is false', async () => { 153 | render() 154 | act(() => navigate('/foo')) 155 | 156 | expect(document.location.pathname).toEqual('/foo') 157 | }) 158 | 159 | test('navigation does not block when prompt is accepted', async () => { 160 | window.confirm = jest.fn().mockImplementation(() => true) 161 | act(() => navigate('/')) 162 | render() 163 | act(() => navigate('/foo')) 164 | 165 | expect(document.location.pathname).toEqual('/foo') 166 | }) 167 | 168 | test('navigation blocks when prompt is declined', async () => { 169 | window.confirm = jest.fn().mockImplementation(() => false) 170 | act(() => navigate('/')) 171 | render() 172 | act(() => navigate('/foo')) 173 | 174 | expect(document.location.pathname).toEqual('/') 175 | }) 176 | 177 | test('window navigation blocks when prompt is declined', async () => { 178 | window.confirm = jest.fn().mockImplementation(() => false) 179 | act(() => navigate('/')) 180 | render() 181 | const event = window.document.createEvent('Event') 182 | event.initEvent('beforeunload', true, true) 183 | window.dispatchEvent(event) 184 | 185 | expect(document.location.pathname).toEqual('/') 186 | }) 187 | 188 | test('popstate navigation restores scroll when prompt is declined', async () => { 189 | window.confirm = jest.fn().mockImplementation(() => false) 190 | window.scrollTo = jest.fn() 191 | act(() => navigate('/')) 192 | render() 193 | 194 | // Modify scroll to check restoration 195 | Object.defineProperty(window, 'scrollX', { 196 | value: 10, 197 | }) 198 | Object.defineProperty(window, 'scrollY', { 199 | value: 12, 200 | }) 201 | 202 | dispatchEvent(new PopStateEvent('popstate', undefined)) 203 | 204 | expect(document.location.pathname).toEqual('/') 205 | 206 | // Wait for scroll restoration 207 | await delay(10) 208 | expect(window.scrollTo).toHaveBeenCalledWith(10, 12) 209 | }) 210 | 211 | test('navigation is confirmed with custom prompt', async () => { 212 | window.confirm = jest.fn().mockImplementation(() => false) 213 | act(() => navigate('/')) 214 | render() 215 | act(() => navigate('/foo')) 216 | 217 | expect(window.confirm).toHaveBeenCalledWith('custom') 218 | }) 219 | }) 220 | 221 | describe('navigate', () => { 222 | test('replace is correctly set in all cases', async () => { 223 | window.history.replaceState = jest.fn() 224 | window.history.pushState = jest.fn() 225 | const url = '/foo' 226 | 227 | navigate(url, { replace: false }) 228 | expect(window.history.pushState).toHaveBeenCalled() 229 | jest.clearAllMocks() 230 | 231 | navigate(url, { replace: true }) 232 | expect(window.history.replaceState).toHaveBeenCalled() 233 | jest.clearAllMocks() 234 | 235 | navigate(url, { replace: true }) 236 | expect(window.history.replaceState).toHaveBeenCalled() 237 | jest.clearAllMocks() 238 | 239 | navigate(url, { replace: false }) 240 | expect(window.history.pushState).toHaveBeenCalled() 241 | jest.clearAllMocks() 242 | 243 | navigate(url, { replace: true }) 244 | expect(window.history.replaceState).toHaveBeenCalled() 245 | jest.clearAllMocks() 246 | 247 | navigate(url, { replace: false }) 248 | expect(window.history.pushState).toHaveBeenCalled() 249 | jest.clearAllMocks() 250 | 251 | navigate(url, { query: {} }) 252 | expect(window.history.pushState).toHaveBeenCalled() 253 | jest.clearAllMocks() 254 | 255 | navigate(url, { replace: true, query: {} }) 256 | expect(window.history.replaceState).toHaveBeenCalled() 257 | jest.clearAllMocks() 258 | 259 | navigate(url, { replace: false, query: {} }) 260 | expect(window.history.pushState).toHaveBeenCalled() 261 | jest.clearAllMocks() 262 | }) 263 | test('handles changing origins', async () => { 264 | const currentHref = window.location.href 265 | window.history.replaceState = jest.fn() 266 | window.history.pushState = jest.fn() 267 | window.location.assign = jest.fn() 268 | 269 | navigate('http://localhost.new/') 270 | 271 | expect(window.history.pushState).not.toHaveBeenCalled() 272 | expect(window.history.replaceState).not.toHaveBeenCalled() 273 | expect(window.location.assign).toHaveBeenCalledWith('http://localhost.new/') 274 | 275 | navigate(currentHref) 276 | }) 277 | test('navigate dispatches custom event', () => { 278 | window.history.replaceState = jest.fn() 279 | window.history.pushState = jest.fn() 280 | 281 | const listen = jest.fn() 282 | 283 | window.addEventListener('popstate', listen) 284 | 285 | navigate('/foo') 286 | 287 | window.removeEventListener('popstate', listen) 288 | 289 | expect(listen).toHaveBeenCalledWith(expect.objectContaining({ __tag: 'raviger:navigation' })) 290 | jest.clearAllMocks() 291 | }) 292 | }) 293 | -------------------------------------------------------------------------------- /test/location.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, act } from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import { navigate, useLocationChange, usePath, useRoutes, useHash, Routes } from '../src/main' 4 | 5 | beforeEach(() => { 6 | act(() => navigate('/')) 7 | }) 8 | 9 | describe('useLocationChange', () => { 10 | function Route({ 11 | onChange, 12 | isActive, 13 | basePath, 14 | onInitial = false, 15 | }: { 16 | onChange: (path: string | null) => void 17 | isActive?: boolean 18 | basePath?: string 19 | onInitial?: boolean 20 | }) { 21 | useLocationChange(({ path }) => onChange(path), { isActive, basePath, onInitial }) 22 | return null 23 | } 24 | test("setter doesn't get updated on mount", async () => { 25 | const watcher = jest.fn() 26 | render() 27 | 28 | expect(watcher).not.toHaveBeenCalled() 29 | }) 30 | test('setter is updated on mount when onInitial is true', async () => { 31 | const watcher = jest.fn() 32 | render() 33 | 34 | expect(watcher).toHaveBeenCalled() 35 | }) 36 | test('setter gets updated path', async () => { 37 | const watcher = jest.fn() 38 | render() 39 | 40 | act(() => navigate('/foo')) 41 | expect(watcher).toHaveBeenCalledWith('/foo') 42 | act(() => navigate('/base')) 43 | expect(watcher).toHaveBeenCalledWith('/base') 44 | }) 45 | test('setter is not updated when isActive is false', async () => { 46 | const watcher = jest.fn() 47 | render() 48 | act(() => navigate('/foo')) 49 | 50 | expect(watcher).not.toHaveBeenCalled() 51 | }) 52 | test('setter gets null when provided basePath is missing', async () => { 53 | const watcher = jest.fn() 54 | render() 55 | 56 | act(() => navigate('/foo')) 57 | expect(watcher).toHaveBeenCalledWith(null) 58 | act(() => navigate('/home')) 59 | expect(watcher).toHaveBeenCalledWith('/') 60 | }) 61 | }) 62 | 63 | describe('usePath', () => { 64 | function Route() { 65 | const path = usePath() 66 | return {path} 67 | } 68 | test('returns original path', async () => { 69 | act(() => navigate('/')) 70 | const { getByTestId } = render() 71 | 72 | expect(getByTestId('path')).toHaveTextContent('/') 73 | }) 74 | 75 | test('returns updated path', async () => { 76 | act(() => navigate('/')) 77 | const { getByTestId } = render() 78 | act(() => navigate('/about')) 79 | 80 | expect(getByTestId('path')).toHaveTextContent('/about') 81 | }) 82 | 83 | test('does not include parent router base path', async () => { 84 | function Harness({ routes, basePath }: { routes: Routes; basePath?: string }) { 85 | const route = useRoutes(routes, { basePath }) 86 | return route 87 | } 88 | 89 | const nestedRoutes = { 90 | '/': () => , 91 | '/about': () => , 92 | } 93 | const routes = { 94 | '/': () => , 95 | '/about': () => , 96 | '/nested*': () => , 97 | } 98 | 99 | const { getByTestId } = render() 100 | act(() => navigate('/')) 101 | expect(getByTestId('path')).toHaveTextContent('/') 102 | 103 | act(() => navigate('/about')) 104 | expect(getByTestId('path')).toHaveTextContent('/about') 105 | 106 | act(() => navigate('/nested/about')) 107 | expect(getByTestId('path')).toHaveTextContent('/about') 108 | 109 | // Yes, check twice 110 | // This is a regression check for this bug: https://github.com/kyeotic/raviger/issues/34 111 | act(() => navigate('/nested/about')) 112 | expect(getByTestId('path')).toHaveTextContent('/about') 113 | expect(getByTestId('path')).not.toHaveTextContent('/nested') 114 | }) 115 | 116 | test('has correct path for nested base path', async () => { 117 | function Harness({ routes, basePath }: { routes: Routes; basePath?: string }) { 118 | const route = useRoutes(routes, { basePath }) 119 | return route 120 | } 121 | 122 | const nestedRoutes = { 123 | '/': () => , 124 | '/about': () => , 125 | '/info': () => , 126 | } 127 | const routes = { 128 | '/': () => , 129 | '/about': () => , 130 | '/nested*': () => , 131 | } 132 | 133 | const { getByTestId } = render() 134 | act(() => navigate('/foo')) 135 | expect(getByTestId('path')).toHaveTextContent('/') 136 | 137 | act(() => navigate('/foo/nested/about')) 138 | expect(getByTestId('path')).toHaveTextContent('/about') 139 | 140 | // Yes, check twice 141 | // This is a regression check for this bug: https://github.com/kyeotic/raviger/issues/50 142 | act(() => navigate('/foo/nested/info')) 143 | expect(getByTestId('path')).toHaveTextContent('/') 144 | act(() => navigate('/foo/nested/info')) 145 | expect(getByTestId('path')).toHaveTextContent('/') 146 | }) 147 | 148 | // tracks regression of https://github.com/kyeotic/raviger/issues/64 149 | test('usePath is not called when unmounting', async () => { 150 | const homeFn = jest.fn() 151 | function Home() { 152 | const path = usePath() 153 | useEffect(() => { 154 | // console.log('home', path) 155 | homeFn(path) 156 | }, [path]) 157 | return {path} 158 | } 159 | const aboutFn = jest.fn() 160 | function About() { 161 | const path = usePath() 162 | useEffect(() => { 163 | // console.log('about', path) 164 | aboutFn(path) 165 | }, [path]) 166 | return {path} 167 | } 168 | 169 | const routes = { 170 | '/': () => , 171 | '/about': () => , 172 | } 173 | function Harness({ routes, basePath }: { routes: Routes; basePath?: string }) { 174 | // console.log('start harness update') 175 | const route = useRoutes(routes, { basePath }) 176 | const onGoHome = useCallback( 177 | () => setTimeout(() => navigate('/'), 50), 178 | // () => setTimeout(() => act(() => navigate('/')), 50), 179 | [], 180 | ) 181 | // console.log('harness update', route.props.children.props.children.type) 182 | return ( 183 |
184 | {route} 185 | 188 |
189 | ) 190 | } 191 | 192 | // console.log('start') 193 | // start with about mounted 194 | act(() => navigate('/about')) 195 | const { getByTestId } = render() 196 | 197 | expect(getByTestId('path')).toHaveTextContent('/about') 198 | expect(aboutFn).toHaveBeenCalledTimes(1) 199 | // console.log('preset') 200 | 201 | // reset 202 | aboutFn.mockClear() 203 | 204 | // console.log('reset') 205 | // act(() => navigate('/')) 206 | 207 | // go home with async 208 | act(() => void fireEvent.click(getByTestId('home-btn'))) 209 | // console.log('acted') 210 | // Wait for the internal setTimeout 211 | await act(() => delay(100)) 212 | 213 | expect(getByTestId('path')).toHaveTextContent('/') 214 | expect(homeFn).toHaveBeenCalledTimes(1) 215 | expect(aboutFn).toHaveBeenCalledTimes(0) 216 | }) 217 | 218 | test('returns null when provided basePath is missing', async () => { 219 | function Route() { 220 | const path = usePath('/home') 221 | return {path || 'not found'} 222 | } 223 | act(() => navigate('/')) 224 | const { getByTestId } = render() 225 | act(() => navigate('/about')) 226 | 227 | expect(getByTestId('path')).toHaveTextContent('not found') 228 | }) 229 | 230 | test('has correct path after navigate during mount', async () => { 231 | const trace = jest.fn() 232 | 233 | const routes = { 234 | '/': () =>
, 235 | '/sub': () => , 236 | } 237 | 238 | function App() { 239 | const route = useRoutes(routes) 240 | 241 | trace(usePath()) 242 | 243 | return route 244 | } 245 | 246 | function Main() { 247 | return main 248 | } 249 | 250 | function Sub() { 251 | navigate('/') 252 | return sub 253 | } 254 | 255 | act(() => navigate('/sub')) 256 | 257 | const { getByTestId } = render() 258 | 259 | expect(trace).toHaveBeenCalledWith('/') 260 | expect(getByTestId('render')).toHaveTextContent('main') 261 | }) 262 | }) 263 | 264 | describe('useHash', () => { 265 | function Route({ skip }: { skip?: boolean }) { 266 | const hash = useHash({ stripHash: skip ? false : undefined }) 267 | return {hash} 268 | } 269 | test('returns original hash', async () => { 270 | act(() => navigate('/#test')) 271 | const { getByTestId } = render() 272 | 273 | expect(getByTestId('hash')).toHaveTextContent('test') 274 | }) 275 | 276 | test('returns updated hash', async () => { 277 | act(() => navigate('/#test')) 278 | const { getByTestId } = render() 279 | expect(getByTestId('hash')).toHaveTextContent('test') 280 | 281 | act(() => navigate('/#updated')) 282 | expect(getByTestId('hash')).toHaveTextContent('updated') 283 | }) 284 | test('returns hash without stripping when stripHash is false', async () => { 285 | act(() => navigate('/#test')) 286 | const { getByTestId } = render() 287 | 288 | expect(getByTestId('hash')).toHaveTextContent('#test') 289 | }) 290 | }) 291 | 292 | async function delay(ms: number): Promise { 293 | return new Promise((resolve) => setTimeout(() => resolve(), ms)) 294 | } 295 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react' 2 | 3 | import { RouterProvider } from './context' 4 | import { isNode, setSsrPath, getSsrPath } from './node' 5 | import { getFormattedPath, usePath } from './location' 6 | import type { NonEmptyRecord, Split, ValueOf } from './types' 7 | 8 | const emptyPathResult: [null, null] = [null, null] 9 | 10 | export interface PathParamOptions { 11 | basePath?: string 12 | matchTrailingSlash?: boolean 13 | } 14 | export interface RouteOptionParams extends PathParamOptions { 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | routeProps?: { [k: string]: any } 17 | overridePathParams?: boolean 18 | } 19 | interface RouteMatcher { 20 | path: string 21 | regex: RegExp 22 | props: string[] 23 | } 24 | 25 | type ExtractPathParams> = Parts extends [ 26 | infer Head, 27 | ...infer Tail, 28 | ] 29 | ? Head extends `:${infer Name}` 30 | ? { [N in Name]: string } & ExtractPathParams 31 | : ExtractPathParams 32 | : unknown 33 | 34 | export type Route = { 35 | path: Path 36 | fn: ( 37 | params: NonEmptyRecord>, 38 | ) => JSX.Element 39 | } 40 | 41 | export type Routes = 42 | | { 43 | [P in Path]: ( 44 | params: NonEmptyRecord>, 45 | ) => JSX.Element 46 | } 47 | | Route[] 48 | 49 | export function useRoutes( 50 | routes: Routes, 51 | { 52 | basePath = '', 53 | routeProps = {}, 54 | overridePathParams = true, 55 | matchTrailingSlash = true, 56 | }: RouteOptionParams = {}, 57 | ): JSX.Element | null { 58 | /* 59 | This is a hack to setup a listener for the path while always using this latest path 60 | The issue with usePath is that, in order to not re-render nested components when 61 | their parent router changes the path, it uses the context's path 62 | But since that path has to get _set_ here in useRoutes something has to give 63 | If usePath returns latest it causes render thrashing 64 | If useRoutes hacks itself into the latest path nothing bad happens (...afaik) 65 | */ 66 | const path = usePath(basePath) && getFormattedPath(basePath) 67 | 68 | // Handle potential use in routes 69 | useRedirectDetection(basePath, usePath(basePath)) 70 | 71 | // Get the current route 72 | const route = useMatchRoute(routes, path, { 73 | routeProps, 74 | overridePathParams, 75 | matchTrailingSlash, 76 | }) 77 | 78 | // No match should not return an empty Provider, just null 79 | if (!route || path === null) return null 80 | return ( 81 | 82 | {route} 83 | 84 | ) 85 | } 86 | 87 | function useMatchRoute( 88 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 89 | routes: { [key: string]: (...props: any) => JSX.Element } | Route[], 90 | path: string | null, 91 | { 92 | routeProps, 93 | overridePathParams, 94 | matchTrailingSlash, 95 | }: Omit & { matchTrailingSlash: boolean }, 96 | ) { 97 | path = trailingMatch(path, matchTrailingSlash) 98 | const mappedRoutes = Array.isArray(routes) 99 | ? routes 100 | : Object.entries(routes).reduce((arr, [path, fn]) => { 101 | // console.log('route fn', fn()) 102 | arr.push({ path, fn }) 103 | return arr 104 | }, [] as Route[]) 105 | const matchers = useMatchers(mappedRoutes.map((r) => r.path)) 106 | 107 | // console.log('mapped Routes', mappedRoutes) 108 | // console.log('matchers', matchers) 109 | 110 | if (path === null) return null 111 | const [pathMatch, props] = getMatchParams(path, matchers) 112 | 113 | if (!pathMatch) return null 114 | 115 | const routeMatch = mappedRoutes.find((r) => r.path == pathMatch.path) 116 | 117 | if (!routeMatch) return null 118 | 119 | return routeMatch.fn( 120 | overridePathParams ? { ...props, ...routeProps } : { ...routeProps, ...props }, 121 | ) 122 | } 123 | 124 | export function usePathParams( 125 | route: Path, 126 | options?: PathParamOptions, 127 | ): NonEmptyRecord> | null 128 | 129 | export function usePathParams( 130 | routes: ReadonlyArray, 131 | options?: PathParamOptions, 132 | ): 133 | | ValueOf<{ 134 | [P in (typeof routes)[number]]: [ 135 | P, 136 | NonEmptyRecord>, 137 | ] 138 | }> 139 | | [null, null] 140 | 141 | export function usePathParams | string>( 142 | routes: Params, 143 | options: PathParamOptions = {}, 144 | ): Params extends ReadonlyArray 145 | ? 146 | | ValueOf<{ 147 | [P in (typeof routes)[number]]: [ 148 | P, 149 | NonEmptyRecord>, 150 | ] 151 | }> 152 | | [null, null] 153 | : Params extends string 154 | ? NonEmptyRecord> | null 155 | : never { 156 | const isSingle = !Array.isArray(routes) 157 | const [path, matchers] = usePathOptions(routes as string | string[], options) 158 | 159 | // @ts-expect-error inference is not carried forward and I don't know how to resolve this type 160 | if (path === null) return isSingle ? null : emptyPathResult 161 | 162 | const [routeMatch, props] = getMatchParams(path, matchers) 163 | // @ts-expect-error inference is not carried forward and I don't know how to resolve this type 164 | if (!routeMatch) return isSingle ? null : emptyPathResult 165 | 166 | // @ts-ignore inference is not carried forward and I don't know how to resolve this type 167 | return isSingle 168 | ? // @ts-ignore 169 | props 170 | : // @ts-ignore 171 | ([routeMatch.path, props] as ValueOf<{ 172 | [P in (typeof routes)[number]]: [ 173 | P, 174 | NonEmptyRecord>, 175 | ] 176 | }>) 177 | } 178 | 179 | export function useMatch(routes: string | string[], options: PathParamOptions = {}): string | null { 180 | const [path, matchers] = usePathOptions(routes, options) 181 | const match = matchers.find(({ regex }) => path?.match(regex)) 182 | 183 | return match?.path ?? null 184 | } 185 | 186 | function usePathOptions( 187 | routeOrRoutes: string | string[], 188 | { basePath, matchTrailingSlash = true }: PathParamOptions, 189 | ): [string | null, RouteMatcher[]] { 190 | const routes = (!Array.isArray(routeOrRoutes) ? [routeOrRoutes] : routeOrRoutes) as string[] 191 | const matchers = useMatchers(routes) 192 | 193 | return [trailingMatch(usePath(basePath), matchTrailingSlash), matchers] 194 | } 195 | 196 | function useMatchers(routes: string[]): RouteMatcher[] { 197 | return useMemo(() => routes.map(createRouteMatcher), [hashParams(routes)]) 198 | } 199 | 200 | function getMatchParams( 201 | path: string, 202 | routeMatchers: RouteMatcher[], 203 | ): [RouteMatcher, Record] | [null, null] { 204 | let pathParams: RegExpMatchArray | null = null 205 | 206 | // Hacky method for find + map 207 | const routeMatch = routeMatchers.find(({ regex }) => { 208 | pathParams = path.match(regex) 209 | return !!pathParams 210 | }) 211 | 212 | if (!routeMatch || pathParams === null) return emptyPathResult 213 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 214 | const props = routeMatch.props.reduce((props: any, prop, i) => { 215 | // The following `match` can't be null because the above return asserts it 216 | props[prop] = pathParams![i + 1] 217 | return props 218 | }, {}) 219 | 220 | return [routeMatch, props] 221 | } 222 | 223 | const pathPartRegex = /:[a-zA-Z_]+/g 224 | 225 | function createRouteMatcher(path: string): RouteMatcher { 226 | return { 227 | path, 228 | regex: new RegExp( 229 | `${path.substr(0, 1) === '*' ? '' : '^'}${escapeRegExp(path) 230 | .replace(pathPartRegex, '([^/]+)') 231 | .replace(/\*/g, '')}${path.substr(-1) === '*' ? '' : '$'}`, 232 | 'i', 233 | ), 234 | props: (path.match(pathPartRegex) ?? []).map((paramName) => paramName.substr(1)), 235 | } 236 | } 237 | 238 | export function setPath(path: string): void { 239 | if (!isNode) { 240 | throw new Error('This method should only be used in NodeJS environments') 241 | } 242 | // eslint-disable-next-line @typescript-eslint/no-require-imports 243 | const url = require('url') 244 | setSsrPath(url.resolve(getSsrPath(), path)) 245 | } 246 | 247 | // React doesn't like when the hook dependency array changes size 248 | // >> Warning: The final argument passed to useMemo changed size between renders. The order and size of this array must remain constant. 249 | // It is recommended to use a hashing function to produce a single, stable value 250 | // https://github.com/facebook/react/issues/14324#issuecomment-441489421 251 | function hashParams(params: string[]): string { 252 | return [...params].sort().join(':') 253 | } 254 | 255 | // React appears to suppress parent's re-rendering when a child's 256 | // useLayoutEffect updates internal state 257 | // the `navigate` call in useRedirect *does* cause usePath/useLocationChange 258 | // to fire, but without this hack useRoutes suppresses the update 259 | // TODO: find a better way to cause a synchronous update from useRoutes 260 | function useRedirectDetection(basePath: string, path: string | null) { 261 | const [, updateState] = useState({}) 262 | const forceRender = useCallback(() => updateState({}), []) 263 | 264 | useLayoutEffect(() => { 265 | if (path !== getFormattedPath(basePath)) { 266 | forceRender() 267 | } 268 | }, [forceRender, basePath, path]) 269 | } 270 | 271 | function trailingMatch(path: string | null, matchTrailingSlash: boolean): string | null { 272 | if (path === null) return path 273 | // path.length > 1 ensure we still match on the root route "/" when matchTrailingSlash is set 274 | if (matchTrailingSlash && path && path[path.length - 1] === '/' && path.length > 1) { 275 | path = path.substring(0, path.length - 1) 276 | } 277 | return path 278 | } 279 | 280 | // Taken from: https://stackoverflow.com/a/3561711 281 | // modified to NOT ESCAPE "/" and "*" since we use those as path parts 282 | function escapeRegExp(string: string): string { 283 | return string.replace(/[-\\^$+?.()|[\]{}]/g, '\\$&') 284 | } 285 | -------------------------------------------------------------------------------- /test/router.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, act } from '@testing-library/react' 3 | import { 4 | navigate, 5 | useRoutes, 6 | usePath, 7 | useBasePath, 8 | useQueryParams, 9 | RouteOptionParams, 10 | Routes, 11 | useMatch, 12 | usePathParams, 13 | } from '../src/main' 14 | 15 | beforeEach(() => { 16 | act(() => navigate('/')) 17 | }) 18 | 19 | describe('useRoutes', () => { 20 | function Harness({ 21 | routes, 22 | options, 23 | }: { 24 | routes: Routes 25 | options?: RouteOptionParams 26 | }) { 27 | const route = useRoutes(routes, options) || not found 28 | return route 29 | } 30 | 31 | function Route({ label, extra }: { label: string; extra?: string }) { 32 | const path = usePath() 33 | return ( 34 |
35 | {label} 36 | {path} 37 | {extra} 38 |
39 | ) 40 | } 41 | const routes = { 42 | '/': () => , 43 | '/about': ({ extra }: { extra?: string }) => , 44 | '/users/:userId': ({ userId }) => , 45 | '/people/:person_id': ({ person_id }) => , 46 | '/weird (route)': () => , 47 | '/weird (route+2)': () => , 48 | } 49 | 50 | test('matches current route', async () => { 51 | const { getByTestId } = render() 52 | 53 | act(() => navigate('/')) 54 | expect(getByTestId('label')).toHaveTextContent('home') 55 | }) 56 | 57 | test('returns null when no match', async () => { 58 | const { getByTestId } = render() 59 | 60 | act(() => navigate('/missing')) 61 | expect(getByTestId('label')).toHaveTextContent('not found') 62 | }) 63 | 64 | test('matches updated route', async () => { 65 | const { getByTestId } = render() 66 | 67 | act(() => navigate('/')) 68 | expect(getByTestId('label')).toHaveTextContent('home') 69 | 70 | act(() => navigate('/about')) 71 | expect(getByTestId('label')).toHaveTextContent('about') 72 | }) 73 | 74 | test('matches trailing slash by default', async () => { 75 | const { getByTestId } = render() 76 | act(() => navigate('/')) 77 | act(() => navigate('/about/')) 78 | expect(getByTestId('label')).toHaveTextContent('about') 79 | }) 80 | 81 | test('does not match trailing slash with option', async () => { 82 | const { getByTestId } = render( 83 | , 84 | ) 85 | act(() => navigate('/')) 86 | act(() => navigate('/about/')) 87 | expect(getByTestId('label')).toHaveTextContent('not found') 88 | }) 89 | 90 | test('matches trailing slash with option', async () => { 91 | const { getByTestId } = render( 92 | , 93 | ) 94 | act(() => navigate('/')) 95 | act(() => navigate('/about/')) 96 | expect(getByTestId('label')).toHaveTextContent('about') 97 | }) 98 | 99 | test('matches trailing slash on "/"', async () => { 100 | const { getByTestId } = render( 101 | , 102 | ) 103 | act(() => navigate('/')) 104 | expect(getByTestId('label')).toHaveTextContent('home') 105 | }) 106 | 107 | test('matches route with parameters', async () => { 108 | const { getByTestId } = render() 109 | 110 | act(() => navigate('/users/1')) 111 | expect(getByTestId('label')).toHaveTextContent('User 1') 112 | }) 113 | 114 | test('matches route with underscored parameters', async () => { 115 | const { getByTestId } = render() 116 | 117 | act(() => navigate('/people/1')) 118 | expect(getByTestId('label')).toHaveTextContent('Person 1') 119 | }) 120 | 121 | test('matches case insensitive rout', async () => { 122 | const { getByTestId } = render() 123 | 124 | act(() => navigate('/About')) 125 | expect(getByTestId('label')).toHaveTextContent('about') 126 | }) 127 | 128 | test('passes extra route props to route', async () => { 129 | const { getByTestId } = render( 130 | , 131 | ) 132 | act(() => navigate('/about')) 133 | expect(getByTestId('extra')).toHaveTextContent('injected') 134 | }) 135 | 136 | test('overrides route props', async () => { 137 | const { getByTestId } = render( 138 | , 139 | ) 140 | act(() => navigate('/users/1')) 141 | expect(getByTestId('label')).toHaveTextContent('User 4') 142 | }) 143 | 144 | test('underrides route props', async () => { 145 | const { getByTestId } = render( 146 | , 150 | ) 151 | act(() => navigate('/users/1')) 152 | expect(getByTestId('label')).toHaveTextContent('User 1') 153 | }) 154 | 155 | test('matches routes with encoded characters', async () => { 156 | const { getByTestId } = render() 157 | act(() => navigate('/weird (route)')) 158 | expect(getByTestId('label')).toHaveTextContent('weird1') 159 | expect(getByTestId('path')).toHaveTextContent('/weird (route)') 160 | 161 | act(() => navigate(`/${encodeURIComponent('weird (route)')}`)) 162 | expect(getByTestId('label')).toHaveTextContent('weird1') 163 | expect(getByTestId('path')).toHaveTextContent('/weird (route)') 164 | 165 | act(() => navigate('/weird (route+2)')) 166 | expect(getByTestId('label')).toHaveTextContent('weird2') 167 | expect(getByTestId('path')).toHaveTextContent('/weird (route+2)') 168 | 169 | act(() => navigate(`/${encodeURIComponent('weird (route+2)')}`)) 170 | expect(getByTestId('label')).toHaveTextContent('weird2') 171 | expect(getByTestId('path')).toHaveTextContent('/weird (route+2)') 172 | }) 173 | 174 | test('handles dynamic routes', async () => { 175 | const Harness = () => { 176 | const [myRoutes, setRoutes] = React.useState(routes) 177 | 178 | const addNewRoute = () => { 179 | setRoutes((prevRoutes) => ({ 180 | ...prevRoutes, 181 | '/new': () => , 182 | })) 183 | } 184 | 185 | const route = useRoutes(myRoutes) || not found 186 | 187 | return ( 188 | <> 189 | {route} 190 | 193 | 194 | ) 195 | } 196 | 197 | const { getByTestId } = render() 198 | act(() => navigate('/new')) 199 | expect(getByTestId('label')).not.toHaveTextContent('new route') 200 | 201 | const button = getByTestId('add-route') 202 | 203 | act(() => button.click()) 204 | act(() => navigate('/new')) 205 | expect(getByTestId('label')).toHaveTextContent('new route') 206 | }) 207 | 208 | describe('with array routes', () => { 209 | test('matches catch-all first', async () => { 210 | const routes = [ 211 | { path: '/layer*', fn: () => }, 212 | { path: '/layer/under', fn: () => }, 213 | ] 214 | act(() => navigate('/layer')) 215 | const { getByTestId } = render() 216 | 217 | // act(() => navigate('/foo')) 218 | expect(getByTestId('label')).toHaveTextContent('top') 219 | act(() => navigate('/layer/under')) 220 | expect(getByTestId('label')).toHaveTextContent('top') 221 | }) 222 | 223 | test('matches catch-all last', async () => { 224 | const routes = [ 225 | { path: '/layer/under', fn: () => }, 226 | { path: '/layer*', fn: () => }, 227 | ] 228 | 229 | act(() => navigate('/layer')) 230 | const { getByTestId } = render() 231 | 232 | // act(() => navigate('/foo')) 233 | expect(getByTestId('label')).toHaveTextContent('top') 234 | act(() => navigate('/layer/under')) 235 | expect(getByTestId('label')).toHaveTextContent('bottom') 236 | }) 237 | }) 238 | 239 | describe('with basePath', () => { 240 | const routes = { 241 | '/': () => , 242 | '/about': () => , 243 | } 244 | 245 | test('matches current route', async () => { 246 | act(() => navigate('/foo')) 247 | const options = { basePath: '/foo' } 248 | const { getByTestId } = render() 249 | 250 | // act(() => navigate('/foo')) 251 | expect(getByTestId('label')).toHaveTextContent('home') 252 | act(() => navigate('/foo/about')) 253 | expect(getByTestId('label')).toHaveTextContent('about') 254 | }) 255 | 256 | test('does not match current route', async () => { 257 | const options = { basePath: '/foo' } 258 | const { getByTestId } = render() 259 | 260 | act(() => navigate('/')) 261 | expect(getByTestId('label')).toHaveTextContent('not found') 262 | 263 | act(() => navigate('/about')) 264 | expect(getByTestId('label')).toHaveTextContent('not found') 265 | }) 266 | 267 | test('nested router with basePath matches after navigation', async () => { 268 | const appRoutes = { 269 | '/app/:id*': ({ id }) => SubRouter({ id }), 270 | } 271 | function App() { 272 | const route = useRoutes(appRoutes) 273 | return route || 274 | } 275 | function SubRouter({ id }: { id: string }) { 276 | const subRoutes = React.useMemo( 277 | () => ({ 278 | '/settings': () => , 279 | }), 280 | [id], 281 | ) 282 | const route = useRoutes(subRoutes, { basePath: `/app/${id}` }) 283 | return route || 284 | } 285 | function RouteItem({ id }: { id: string }) { 286 | return {id} 287 | } 288 | 289 | function NotFound() { 290 | return Not Found 291 | } 292 | act(() => navigate('/app/1/settings')) 293 | const { getByTestId } = render() 294 | 295 | expect(getByTestId('label')).toHaveTextContent('1') 296 | 297 | act(() => navigate('/app/2/settings')) 298 | expect(getByTestId('label')).toHaveTextContent('2') 299 | }) 300 | }) 301 | }) 302 | 303 | describe('useBasePath', () => { 304 | function Harness({ 305 | routes, 306 | basePath, 307 | }: { 308 | routes: Routes 309 | basePath?: string 310 | }) { 311 | const route = useRoutes(routes, { basePath }) 312 | return route 313 | } 314 | 315 | function Route() { 316 | const basePath = useBasePath() 317 | return {basePath || 'none'} 318 | } 319 | 320 | const nestedRoutes = { 321 | '/': () => , 322 | '/about': () => , 323 | } 324 | 325 | const routes = { 326 | '/': () => , 327 | '/about': () => , 328 | '/nested*': () => , 329 | } 330 | test('returns basePath inside useRoutes', async () => { 331 | const { getByTestId } = render() 332 | act(() => navigate('/')) 333 | expect(getByTestId('basePath')).toHaveTextContent('none') 334 | act(() => navigate('/about')) 335 | expect(getByTestId('basePath')).toHaveTextContent('none') 336 | act(() => navigate('/nested')) 337 | expect(getByTestId('basePath')).toHaveTextContent('nested') 338 | act(() => navigate('/Nested')) 339 | expect(getByTestId('basePath')).toHaveTextContent('nested') 340 | }) 341 | test('returns empty string outside', async () => { 342 | const { getByTestId } = render() 343 | act(() => navigate('/')) 344 | expect(getByTestId('basePath')).toHaveTextContent('none') 345 | }) 346 | }) 347 | 348 | describe('useQueryParams', () => { 349 | function Route({ label }: { label?: string }) { 350 | const [params, setQuery] = useQueryParams() 351 | return ( 352 | setQuery({ name: 'click' })}> 353 | {label} 354 | {JSON.stringify(params)} 355 | 356 | ) 357 | } 358 | test('returns current params', async () => { 359 | act(() => navigate('/?name=test')) 360 | const { getByTestId } = render() 361 | 362 | expect(getByTestId('params')).toHaveTextContent('name":"test') 363 | }) 364 | 365 | test('returns updated params', async () => { 366 | act(() => navigate('/?name=test')) 367 | const { getByTestId } = render() 368 | act(() => navigate('/?name=updated')) 369 | 370 | expect(getByTestId('params')).toHaveTextContent('name":"updated') 371 | }) 372 | 373 | test('sets query', async () => { 374 | act(() => navigate('/?name=test')) 375 | const { getByTestId } = render() 376 | act(() => void fireEvent.click(getByTestId('params'))) 377 | 378 | expect(getByTestId('params')).toHaveTextContent('name":"click') 379 | }) 380 | }) 381 | 382 | describe('navigate', () => { 383 | test('updates the url', async () => { 384 | act(() => navigate('/')) 385 | expect(window.location.toString()).toEqual('http://localhost/') 386 | act(() => navigate('/home')) 387 | expect(window.location.toString()).toEqual('http://localhost/home') 388 | // console.log(window.location.toString()) 389 | }) 390 | 391 | test('allows query string objects', async () => { 392 | // console.log(URLSearchParams) 393 | act(() => navigate('/', { query: { q: 'name', env: 'test' } })) 394 | expect(window.location.search).toContain('q=name') 395 | expect(window.location.search).toContain('env=test') 396 | }) 397 | }) 398 | 399 | describe('useMatch', () => { 400 | test('matches on single path', async () => { 401 | function Route() { 402 | const matched = useMatch('/about') 403 | return {matched ?? 'null'} 404 | } 405 | act(() => navigate('/')) 406 | const { getByTestId } = render() 407 | 408 | expect(getByTestId('params')).toHaveTextContent('null') 409 | 410 | act(() => navigate('/about')) 411 | expect(getByTestId('params')).toHaveTextContent('/about') 412 | }) 413 | 414 | test('matches on multiple paths', async () => { 415 | function Route() { 416 | const matched = useMatch(['/', '/about']) 417 | return {matched} 418 | } 419 | act(() => navigate('/')) 420 | const { getByTestId } = render() 421 | 422 | expect(getByTestId('params')).toHaveTextContent('/') 423 | expect(getByTestId('params')).not.toHaveTextContent('/about') 424 | 425 | act(() => navigate('/about')) 426 | expect(getByTestId('params')).toHaveTextContent('about') 427 | }) 428 | }) 429 | 430 | describe('usePathParams', () => { 431 | test('matches on single path without params', async () => { 432 | function Route() { 433 | const props = usePathParams('/about') 434 | return {JSON.stringify({ matched: !!props, props })} 435 | } 436 | act(() => navigate('/')) 437 | const { getByTestId } = render() 438 | 439 | expect(getByTestId('params')).toHaveTextContent('{"matched":false,"props":null}') 440 | 441 | act(() => navigate('/about')) 442 | expect(getByTestId('params')).toHaveTextContent('{"matched":true,"props":{}}') 443 | }) 444 | 445 | test('matches on single path with params', async () => { 446 | function Route() { 447 | const props = usePathParams('/user/:userId') 448 | return {JSON.stringify({ matched: !!props, props })} 449 | } 450 | act(() => navigate('/')) 451 | const { getByTestId } = render() 452 | 453 | expect(getByTestId('params')).toHaveTextContent('{"matched":false,"props":null}') 454 | 455 | act(() => navigate('/user/tester')) 456 | expect(getByTestId('params')).toHaveTextContent('{"matched":true,"props":{"userId":"tester"}}') 457 | }) 458 | 459 | test('strongly types params for string', async () => { 460 | function Route() { 461 | const props = usePathParams('/user/:userId/child/:childId') 462 | return ( 463 | 464 | {JSON.stringify(props ? { user: props.userId, child: props.childId } : null)} 465 | 466 | ) 467 | } 468 | act(() => navigate('/')) 469 | const { getByTestId } = render() 470 | 471 | act(() => navigate('/user/tester/child/qa')) 472 | expect(getByTestId('params')).toHaveTextContent('{"user":"tester","child":"qa"}') 473 | }) 474 | 475 | test('matches on multiple paths without params', async () => { 476 | function Route() { 477 | const [matched, props] = usePathParams(['/contact', '/about']) 478 | return {JSON.stringify({ matched, props })} 479 | } 480 | act(() => navigate('/')) 481 | const { getByTestId } = render() 482 | 483 | expect(getByTestId('params')).toHaveTextContent('{"matched":null,"props":null}') 484 | 485 | act(() => navigate('/about')) 486 | expect(getByTestId('params')).toHaveTextContent('{"matched":"/about","props":{}}') 487 | 488 | act(() => navigate('/contact')) 489 | expect(getByTestId('params')).toHaveTextContent('{"matched":"/contact","props":{}}') 490 | }) 491 | 492 | test('matches on multiple paths with params', async () => { 493 | function Route() { 494 | const [matched, props] = usePathParams(['/user/:userId']) 495 | return {JSON.stringify({ matched, props })} 496 | } 497 | act(() => navigate('/')) 498 | const { getByTestId } = render() 499 | 500 | expect(getByTestId('params')).toHaveTextContent('{"matched":null,"props":null}') 501 | 502 | act(() => navigate('/user/tester')) 503 | expect(getByTestId('params')).toHaveTextContent( 504 | '{"matched":"/user/:userId","props":{"userId":"tester"}}', 505 | ) 506 | }) 507 | 508 | test('strongly types params for array', async () => { 509 | function Route() { 510 | const [path, props] = usePathParams(['/user/:userId/child/:childId', '/users']) 511 | // The props here don't seem to be strongly typed 512 | // though that appears to be an artifact of the test setup, 513 | // as they are strongly typed when used directly :shrug: 514 | 515 | if (path === '/user/:userId/child/:childId') { 516 | return ( 517 | 518 | {/* @ts-expect-error test is missing typing for some reason? */} 519 | {JSON.stringify(props ? { path, user: props.userId, child: props.childId } : null)} 520 | 521 | ) 522 | } else if (path === '/users') { 523 | return {JSON.stringify(props)} 524 | } 525 | return {JSON.stringify(null)} 526 | } 527 | act(() => navigate('/users')) 528 | const { getByTestId } = render() 529 | expect(getByTestId('params')).toHaveTextContent('{}') 530 | 531 | act(() => navigate('/user/tester/child/qa')) 532 | expect(getByTestId('params')).toHaveTextContent( 533 | '{"path":"/user/:userId/child/:childId","user":"tester","child":"qa"}', 534 | ) 535 | }) 536 | }) 537 | --------------------------------------------------------------------------------