├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── ci-tests.yml │ └── size.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── icon_duotone.svg ├── logo.svg ├── wouter-skate.svg └── wouter.svg ├── bun.lock ├── package.json ├── packages ├── wouter-preact │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ └── preact-deps.js │ ├── test │ │ ├── preact-ssr.test.tsx │ │ ├── preact.test-d.ts │ │ └── preact.test.tsx │ ├── tsconfig.json │ ├── types │ │ ├── index.d.ts │ │ ├── location-hook.d.ts │ │ ├── memory-location.d.ts │ │ ├── router.d.ts │ │ ├── use-browser-location.d.ts │ │ └── use-hash-location.d.ts │ └── vitest.config.ts └── wouter │ ├── package.json │ ├── rollup.config.js │ ├── setup-vitest.ts │ ├── src │ ├── index.js │ ├── memory-location.js │ ├── paths.js │ ├── react-deps.js │ ├── use-browser-location.js │ ├── use-hash-location.js │ ├── use-sync-external-store.js │ └── use-sync-external-store.native.js │ ├── test │ ├── global-this-at-component-level.test.tsx │ ├── global-this-at-top-level.test.ts │ ├── history-patch.test.ts │ ├── link.test-d.tsx │ ├── link.test.tsx │ ├── location-hook.test-d.ts │ ├── match-route.test-d.ts │ ├── memory-location.test-d.ts │ ├── memory-location.test.ts │ ├── nested-route.test.tsx │ ├── parser.test.tsx │ ├── redirect.test-d.tsx │ ├── redirect.test.tsx │ ├── route.test-d.tsx │ ├── route.test.tsx │ ├── router.test-d.tsx │ ├── router.test.tsx │ ├── ssr.test.tsx │ ├── switch.test.tsx │ ├── test-utils.ts │ ├── use-browser-location.test-d.ts │ ├── use-browser-location.test.tsx │ ├── use-hash-location.test-d.ts │ ├── use-hash-location.test.tsx │ ├── use-location.test.tsx │ ├── use-params.test-d.ts │ ├── use-params.test.tsx │ ├── use-route.test-d.ts │ ├── use-route.test.tsx │ ├── use-search-params.test.tsx │ └── use-search.test.tsx │ ├── tsconfig.json │ ├── types │ ├── index.d.ts │ ├── location-hook.d.ts │ ├── memory-location.d.ts │ ├── router.d.ts │ ├── use-browser-location.d.ts │ └── use-hash-location.d.ts │ └── vitest.config.ts └── vitest.workspace.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ["molefrog"] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests & Linters 2 | 3 | on: 4 | push: 5 | branches: "*" 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | env: 14 | FORCE_COLOR: true 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Bun 20 | uses: oven-sh/setup-bun@v2 21 | with: 22 | bun-version: latest 23 | 24 | - name: Install Dependencies 25 | run: bun install --frozen-lockfile 26 | 27 | - name: Build packages 28 | run: npm run build 29 | 30 | - name: Run test 31 | run: bun run test -- --run --coverage 32 | 33 | - name: Run type check 34 | run: bun run lint-types 35 | 36 | - name: Lint Sources with ESLint 37 | run: bun run lint 38 | 39 | - name: Upload Coverage Report to Codecov 40 | run: bash <(curl -s https://codecov.io/bash) -t 7a260fc2-03ff-4e98-b0a5-11a2d5c53a29 41 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: Size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # NPM 2 | npm-debug.log* 3 | node_modules/ 4 | .npm 5 | 6 | # IDEs 7 | .vscode/ 8 | *.code-workspace 9 | 10 | # bundler 11 | esm/ 12 | .cache 13 | 14 | # OSX 15 | .DS_Store 16 | .AppleDouble 17 | .LSOverride 18 | 19 | # test coverage 20 | coverage/ 21 | 22 | # vitest internals 23 | tsconfig.vitest-temp.json 24 | 25 | # type definitions in the project root are copied from types/ folder 26 | # before publishing, so they should be ignored 27 | /*.d.ts 28 | 29 | # README is copied from the root folder 30 | packages/wouter/README.md 31 | packages/wouter-preact/README.md 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /assets/icon_duotone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/wouter-skate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/wouter.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monorepo", 3 | "private": true, 4 | "description": "A minimalistic routing for React and Preact. Monorepo package.", 5 | "type": "module", 6 | "workspaces": [ 7 | "packages/wouter", 8 | "packages/wouter-preact" 9 | ], 10 | "scripts": { 11 | "fix:p": "prettier --write \"./**/*.(js|ts){x,}\"", 12 | "test": "vitest", 13 | "size": "size-limit", 14 | "build": "npm run build -ws", 15 | "watch": "concurrently -n wouter,wouter-preact \"npm run -w packages/wouter watch\" \"npm run -w packages/wouter-preact watch\"", 16 | "lint": "eslint packages/**/*.js", 17 | "lint-types": "vitest --typecheck" 18 | }, 19 | "author": "Alexey Taktarov ", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/molefrog/wouter.git" 23 | }, 24 | "license": "ISC", 25 | "prettier": { 26 | "tabWidth": 2, 27 | "semi": true, 28 | "singleQuote": false, 29 | "printWidth": 80 30 | }, 31 | "size-limit": [ 32 | { 33 | "path": "packages/wouter/esm/index.js", 34 | "limit": "2500 B", 35 | "ignore": [ 36 | "react", 37 | "use-sync-external-store" 38 | ] 39 | }, 40 | { 41 | "path": "packages/wouter/esm/use-browser-location.js", 42 | "limit": "1000 B", 43 | "import": "{ useBrowserLocation }", 44 | "ignore": [ 45 | "react", 46 | "use-sync-external-store" 47 | ] 48 | }, 49 | { 50 | "path": "packages/wouter/esm/memory-location.js", 51 | "limit": "1000 B", 52 | "ignore": [ 53 | "react", 54 | "use-sync-external-store" 55 | ] 56 | }, 57 | { 58 | "path": "packages/wouter/esm/use-hash-location.js", 59 | "limit": "1000 B", 60 | "ignore": [ 61 | "react", 62 | "use-sync-external-store" 63 | ] 64 | }, 65 | { 66 | "path": "packages/wouter-preact/esm/index.js", 67 | "limit": "2500 B", 68 | "ignore": [ 69 | "preact", 70 | "preact/hooks" 71 | ] 72 | }, 73 | { 74 | "path": "packages/wouter-preact/esm/use-browser-location.js", 75 | "limit": "1000 B", 76 | "import": "{ useBrowserLocation }", 77 | "ignore": [ 78 | "preact", 79 | "preact/hooks" 80 | ] 81 | }, 82 | { 83 | "path": "packages/wouter-preact/esm/use-hash-location.js", 84 | "limit": "1000 B", 85 | "ignore": [ 86 | "preact", 87 | "preact/hooks" 88 | ] 89 | }, 90 | { 91 | "path": "packages/wouter-preact/esm/memory-location.js", 92 | "limit": "1000 B", 93 | "ignore": [ 94 | "preact", 95 | "preact/hooks" 96 | ] 97 | } 98 | ], 99 | "husky": { 100 | "hooks": { 101 | "commit-msg": "npm run fix:p" 102 | } 103 | }, 104 | "eslintConfig": { 105 | "extends": "eslint:recommended", 106 | "parserOptions": { 107 | "sourceType": "module", 108 | "ecmaFeatures": { 109 | "jsx": true 110 | } 111 | }, 112 | "env": { 113 | "es2020": true, 114 | "browser": true, 115 | "node": true 116 | }, 117 | "rules": { 118 | "no-unused-vars": [ 119 | "error", 120 | { 121 | "varsIgnorePattern": "^_", 122 | "argsIgnorePattern": "^_" 123 | } 124 | ], 125 | "react-hooks/rules-of-hooks": "error", 126 | "react-hooks/exhaustive-deps": "warn" 127 | }, 128 | "plugins": [ 129 | "react-hooks" 130 | ], 131 | "ignorePatterns": [ 132 | "types/**" 133 | ] 134 | }, 135 | "devDependencies": { 136 | "@preact/preset-vite": "^2.9.0", 137 | "@rollup/plugin-alias": "^5.0.0", 138 | "@rollup/plugin-node-resolve": "^15.0.2", 139 | "@rollup/plugin-replace": "^5.0.7", 140 | "@size-limit/preset-small-lib": "^11.2.0", 141 | "@testing-library/dom": "^10.4.0", 142 | "@testing-library/jest-dom": "^6.1.4", 143 | "@testing-library/react": "^16.3.0", 144 | "@types/babel__core": "^7.20.5", 145 | "@types/bun": "^1.2.14", 146 | "@types/react": "^18.2.0", 147 | "@types/react-test-renderer": "^18.0.0", 148 | "@vitejs/plugin-react": "^4.0.4", 149 | "@vitest/coverage-v8": "^3.0.8", 150 | "concurrently": "^8.2.2", 151 | "copyfiles": "^2.4.1", 152 | "eslint": "^7.19.0", 153 | "eslint-plugin-react-hooks": "^4.6.2", 154 | "happy-dom": "^17.4.7", 155 | "husky": "^4.3.0", 156 | "path-to-regexp": "^6.2.1", 157 | "preact": "^10.23.2", 158 | "preact-render-to-string": "^6.5.9", 159 | "prettier": "^2.4.1", 160 | "react": "^18.2.0", 161 | "react-dom": "^18.2.0", 162 | "rimraf": "^3.0.2", 163 | "rollup": "^3.29.5", 164 | "size-limit": "^10.0.1", 165 | "typescript": "5.2.2", 166 | "vitest": "^3.0.8" 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /packages/wouter-preact/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wouter-preact", 3 | "version": "3.7.1", 4 | "description": "Minimalist-friendly ~1.5KB router for Preact", 5 | "type": "module", 6 | "keywords": [ 7 | "react", 8 | "preact", 9 | "router", 10 | "tiny", 11 | "routing", 12 | "hooks", 13 | "useLocation" 14 | ], 15 | "files": [ 16 | "esm", 17 | "types/**/*.d.ts", 18 | "types/*.d.ts" 19 | ], 20 | "main": "esm/index.js", 21 | "exports": { 22 | ".": { 23 | "types": "./types/index.d.ts", 24 | "default": "./esm/index.js" 25 | }, 26 | "./use-browser-location": { 27 | "types": "./types/use-browser-location.d.ts", 28 | "default": "./esm/use-browser-location.js" 29 | }, 30 | "./use-hash-location": { 31 | "types": "./types/use-hash-location.d.ts", 32 | "default": "./esm/use-hash-location.js" 33 | }, 34 | "./memory-location": { 35 | "types": "./types/memory-location.d.ts", 36 | "default": "./esm/memory-location.js" 37 | } 38 | }, 39 | "types": "types/index.d.ts", 40 | "typesVersions": { 41 | ">=4.1": { 42 | "types/index.d.ts": [ 43 | "types/index.d.ts" 44 | ], 45 | "use-browser-location": [ 46 | "types/use-browser-location.d.ts" 47 | ], 48 | "use-hash-location": [ 49 | "types/use-hash-location.d.ts" 50 | ], 51 | "memory-location": [ 52 | "types/memory-location.d.ts" 53 | ] 54 | } 55 | }, 56 | "scripts": { 57 | "build": "rollup -c", 58 | "watch": "rollup -c -w", 59 | "prepublishOnly": "npm run build && cp ../../README.md ." 60 | }, 61 | "author": "Alexey Taktarov ", 62 | "repository": { 63 | "type": "git", 64 | "url": "git+https://github.com/molefrog/wouter.git" 65 | }, 66 | "license": "Unlicense", 67 | "peerDependencies": { 68 | "preact": "^10.0.0" 69 | }, 70 | "dependencies": { 71 | "mitt": "^3.0.1", 72 | "regexparam": "^3.0.0" 73 | }, 74 | "devDependencies": { 75 | "wouter": "*" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/wouter-preact/rollup.config.js: -------------------------------------------------------------------------------- 1 | import alias from "@rollup/plugin-alias"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import { defineConfig } from "rollup"; 4 | 5 | export default defineConfig([ 6 | { 7 | input: [ 8 | "wouter", 9 | "wouter/use-browser-location", 10 | "wouter/use-hash-location", 11 | "wouter/memory-location", 12 | ], 13 | external: ["preact", "preact/hooks", "regexparam", "mitt"], 14 | 15 | output: { 16 | dir: "esm", 17 | format: "esm", 18 | }, 19 | plugins: [ 20 | nodeResolve(), 21 | alias({ 22 | entries: { 23 | "./react-deps.js": "./src/preact-deps.js", 24 | }, 25 | }), 26 | ], 27 | }, 28 | ]); 29 | -------------------------------------------------------------------------------- /packages/wouter-preact/src/preact-deps.js: -------------------------------------------------------------------------------- 1 | import { useState, useLayoutEffect, useEffect, useRef } from "preact/hooks"; 2 | export { 3 | isValidElement, 4 | createContext, 5 | cloneElement, 6 | createElement, 7 | Fragment, 8 | } from "preact"; 9 | export { 10 | useMemo, 11 | useRef, 12 | useLayoutEffect as useIsomorphicLayoutEffect, 13 | useLayoutEffect as useInsertionEffect, 14 | useState, 15 | useContext, 16 | } from "preact/hooks"; 17 | 18 | // Copied from: 19 | // https://github.com/facebook/react/blob/main/packages/shared/ExecutionEnvironment.js 20 | const canUseDOM = !!( 21 | typeof window !== "undefined" && 22 | typeof window.document !== "undefined" && 23 | typeof window.document.createElement !== "undefined" 24 | ); 25 | 26 | // TODO: switch to `export { useSyncExternalStore } from "preact/compat"` once we update Preact to >= 10.11.3 27 | function is(x, y) { 28 | return (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y); 29 | } 30 | export function useSyncExternalStore(subscribe, getSnapshot, getSSRSnapshot) { 31 | if (getSSRSnapshot && !canUseDOM) getSnapshot = getSSRSnapshot; 32 | const value = getSnapshot(); 33 | 34 | const [{ _instance }, forceUpdate] = useState({ 35 | _instance: { _value: value, _getSnapshot: getSnapshot }, 36 | }); 37 | 38 | useLayoutEffect(() => { 39 | _instance._value = value; 40 | _instance._getSnapshot = getSnapshot; 41 | 42 | if (!is(_instance._value, getSnapshot())) { 43 | forceUpdate({ _instance }); 44 | } 45 | }, [subscribe, value, getSnapshot]); 46 | 47 | useEffect(() => { 48 | if (!is(_instance._value, _instance._getSnapshot())) { 49 | forceUpdate({ _instance }); 50 | } 51 | 52 | return subscribe(() => { 53 | if (!is(_instance._value, _instance._getSnapshot())) { 54 | forceUpdate({ _instance }); 55 | } 56 | }); 57 | }, [subscribe]); 58 | 59 | return value; 60 | } 61 | 62 | // provide forwardRef stub for preact 63 | export function forwardRef(component) { 64 | return component; 65 | } 66 | 67 | // Userland polyfill while we wait for the forthcoming 68 | // https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md 69 | // Note: "A high-fidelty polyfill for useEvent is not possible because 70 | // there is no lifecycle or Hook in React that we can use to switch 71 | // .current at the right timing." 72 | // So we will have to make do with this "close enough" approach for now. 73 | export const useEvent = (fn) => { 74 | const ref = useRef([fn, (...args) => ref[0](...args)]).current; 75 | useLayoutEffect(() => { 76 | ref[0] = fn; 77 | }); 78 | return ref[1]; 79 | }; 80 | -------------------------------------------------------------------------------- /packages/wouter-preact/test/preact-ssr.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | 5 | import renderToString from "preact-render-to-string"; 6 | import { it, expect, describe } from "vitest"; 7 | import { Router, useLocation } from "wouter-preact"; 8 | 9 | describe("Preact SSR", () => { 10 | it("supports SSR", () => { 11 | const LocationPrinter = () => <>location = {useLocation()[0]}; 12 | 13 | const rendered = renderToString( 14 | 15 | 16 | 17 | ); 18 | 19 | expect(rendered).toBe("location = /ssr/preact"); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/wouter-preact/test/preact.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, assertType } from "vitest"; 2 | import { useRoute } from "wouter-preact"; 3 | 4 | it("should only accept strings", () => { 5 | // @ts-expect-error 6 | assertType(useRoute(Symbol())); 7 | // @ts-expect-error 8 | assertType(useRoute()); 9 | assertType(useRoute("/")); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/wouter-preact/test/preact.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"; 2 | import { render } from "preact"; 3 | import { act, setupRerender, teardown } from "preact/test-utils"; 4 | 5 | import { Route, Link, Switch } from "wouter-preact"; 6 | 7 | describe("Preact support", () => { 8 | beforeEach(() => { 9 | history.replaceState(null, "", "/non-existing/route"); 10 | setupRerender(); 11 | }); 12 | 13 | afterEach(() => { 14 | teardown(); 15 | }); 16 | 17 | it("renders properly and reacts on navigation", () => { 18 | const container = document.body.appendChild(document.createElement("div")); 19 | const fn = vi.fn(); 20 | 21 | const App = () => { 22 | const handleAsChildClick = vi.fn(); 23 | 24 | return ( 25 | <> 26 | 41 | 42 |
43 | 44 | <>Welcome to the list of {100} greatest albums of all time! 45 | Rolling Stones Best 100 Albums 46 | 47 | {(params) => `Album ${params.name}`} 48 | 49 | Nothing was found! 50 | 51 |
52 | 53 | ); 54 | }; 55 | 56 | let node = render(, container); 57 | 58 | const routesEl = container.querySelector('[data-testid="routes"]')!; 59 | const indexLinkEl = container.querySelector('[data-testid="index-link"]')!; 60 | const featLinkEl = container.querySelector( 61 | '[data-testid="featured-link"]' 62 | )!; 63 | 64 | // default route should be rendered 65 | expect(routesEl.textContent).toBe("Nothing was found!"); 66 | expect(featLinkEl.getAttribute("href")).toBe("/albums/london-calling"); 67 | 68 | // link renders as A element 69 | expect(indexLinkEl.tagName).toBe("A"); 70 | 71 | act(() => { 72 | const evt = new MouseEvent("click", { 73 | bubbles: true, 74 | cancelable: true, 75 | button: 0, 76 | }); 77 | 78 | indexLinkEl.dispatchEvent(evt); 79 | }); 80 | 81 | // performs a navigation when the link is clicked 82 | expect(location.pathname).toBe("/albums/all"); 83 | 84 | // Link accepts an `onClick` prop, fired after the navigation 85 | expect(fn).toHaveBeenCalledTimes(1); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/wouter-preact/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "jsx": "react-jsx", 7 | "jsxImportSource": "preact", 8 | "types": ["preact"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/wouter-preact/types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Minimum TypeScript Version: 4.1 2 | // tslint:disable:no-unnecessary-generics 3 | 4 | import { 5 | JSX, 6 | FunctionComponent, 7 | ComponentType, 8 | ComponentChildren, 9 | } from "preact"; 10 | 11 | import { 12 | Path, 13 | PathPattern, 14 | BaseLocationHook, 15 | HookReturnValue, 16 | HookNavigationOptions, 17 | BaseSearchHook, 18 | } from "./location-hook.js"; 19 | import { 20 | BrowserLocationHook, 21 | BrowserSearchHook, 22 | } from "./use-browser-location.js"; 23 | 24 | import { RouterObject, RouterOptions, Parser } from "./router.js"; 25 | 26 | // these files only export types, so we can re-export them as-is 27 | // in TS 5.0 we'll be able to use `export type * from ...` 28 | export * from "./location-hook.js"; 29 | export * from "./router.js"; 30 | 31 | import { RouteParams } from "regexparam"; 32 | 33 | export type StringRouteParams = RouteParams & { 34 | [param: number]: string | undefined; 35 | }; 36 | export type RegexRouteParams = { [key: string | number]: string | undefined }; 37 | 38 | /** 39 | * Route patterns and parameters 40 | */ 41 | export interface DefaultParams { 42 | readonly [paramName: string | number]: string | undefined; 43 | } 44 | 45 | export type Params = T; 46 | 47 | export type MatchWithParams = [ 48 | true, 49 | Params 50 | ]; 51 | export type NoMatch = [false, null]; 52 | export type Match = 53 | | MatchWithParams 54 | | NoMatch; 55 | 56 | /* 57 | * Components: 58 | */ 59 | 60 | export interface RouteComponentProps { 61 | params: T; 62 | } 63 | 64 | export interface RouteProps< 65 | T extends DefaultParams | undefined = undefined, 66 | RoutePath extends PathPattern = PathPattern 67 | > { 68 | children?: 69 | | (( 70 | params: T extends DefaultParams 71 | ? T 72 | : RoutePath extends string 73 | ? StringRouteParams 74 | : RegexRouteParams 75 | ) => ComponentChildren) 76 | | ComponentChildren; 77 | path?: RoutePath; 78 | component?: ComponentType< 79 | RouteComponentProps< 80 | T extends DefaultParams 81 | ? T 82 | : RoutePath extends string 83 | ? StringRouteParams 84 | : RegexRouteParams 85 | > 86 | >; 87 | nest?: boolean; 88 | } 89 | 90 | export function Route< 91 | T extends DefaultParams | undefined = undefined, 92 | RoutePath extends PathPattern = PathPattern 93 | >(props: RouteProps): ReturnType; 94 | 95 | /* 96 | * Components: & 97 | */ 98 | 99 | export type NavigationalProps< 100 | H extends BaseLocationHook = BrowserLocationHook 101 | > = ({ to: Path; href?: never } | { href: Path; to?: never }) & 102 | HookNavigationOptions; 103 | 104 | type AsChildProps = 105 | | ({ asChild?: false } & DefaultElementProps) 106 | | ({ asChild: true } & ComponentProps); 107 | 108 | type HTMLLinkAttributes = Omit & { 109 | className?: string | undefined | ((isActive: boolean) => string | undefined); 110 | }; 111 | 112 | export type LinkProps = 113 | NavigationalProps & 114 | AsChildProps< 115 | { children: ComponentChildren; onClick?: JSX.MouseEventHandler }, 116 | HTMLLinkAttributes 117 | >; 118 | 119 | export type RedirectProps = 120 | NavigationalProps & { 121 | children?: never; 122 | }; 123 | 124 | export function Redirect( 125 | props: RedirectProps, 126 | context?: any 127 | ): null; 128 | 129 | export function Link( 130 | props: LinkProps, 131 | context?: any 132 | ): ReturnType; 133 | 134 | /* 135 | * Components: 136 | */ 137 | 138 | export interface SwitchProps { 139 | location?: string; 140 | children: ComponentChildren; 141 | } 142 | export const Switch: FunctionComponent; 143 | 144 | /* 145 | * Components: 146 | */ 147 | 148 | export type RouterProps = RouterOptions & { 149 | children: ComponentChildren; 150 | }; 151 | 152 | export const Router: FunctionComponent; 153 | 154 | /* 155 | * Hooks 156 | */ 157 | 158 | export function useRouter(): RouterObject; 159 | 160 | export function useRoute< 161 | T extends DefaultParams | undefined = undefined, 162 | RoutePath extends PathPattern = PathPattern 163 | >( 164 | pattern: RoutePath 165 | ): Match< 166 | T extends DefaultParams 167 | ? T 168 | : RoutePath extends string 169 | ? StringRouteParams 170 | : RegexRouteParams 171 | >; 172 | 173 | export function useLocation< 174 | H extends BaseLocationHook = BrowserLocationHook 175 | >(): HookReturnValue; 176 | 177 | export function useSearch< 178 | H extends BaseSearchHook = BrowserSearchHook 179 | >(): ReturnType; 180 | 181 | export type URLSearchParamsInit = ConstructorParameters< 182 | typeof URLSearchParams 183 | >[0]; 184 | 185 | export type SetSearchParams = ( 186 | nextInit: 187 | | URLSearchParamsInit 188 | | ((prev: URLSearchParams) => URLSearchParamsInit), 189 | options?: { replace?: boolean; state?: any } 190 | ) => void; 191 | 192 | export function useSearchParams(): [URLSearchParams, SetSearchParams]; 193 | 194 | export function useParams(): T extends string 195 | ? StringRouteParams 196 | : T extends undefined 197 | ? DefaultParams 198 | : T; 199 | 200 | /* 201 | * Helpers 202 | */ 203 | 204 | export function matchRoute< 205 | T extends DefaultParams | undefined = undefined, 206 | RoutePath extends PathPattern = PathPattern 207 | >( 208 | parser: Parser, 209 | pattern: RoutePath, 210 | path: string, 211 | loose?: boolean 212 | ): Match< 213 | T extends DefaultParams 214 | ? T 215 | : RoutePath extends string 216 | ? StringRouteParams 217 | : RegexRouteParams 218 | >; 219 | 220 | // tslint:enable:no-unnecessary-generics 221 | -------------------------------------------------------------------------------- /packages/wouter-preact/types/location-hook.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Foundation: useLocation and paths 3 | */ 4 | 5 | export type Path = string; 6 | 7 | export type PathPattern = string | RegExp; 8 | 9 | export type SearchString = string; 10 | 11 | // the base useLocation hook type. Any custom hook (including the 12 | // default one) should inherit from it. 13 | export type BaseLocationHook = ( 14 | ...args: any[] 15 | ) => [Path, (path: Path, ...args: any[]) => any]; 16 | 17 | export type BaseSearchHook = (...args: any[]) => SearchString; 18 | 19 | /* 20 | * Utility types that operate on hook 21 | */ 22 | 23 | // Returns the type of the location tuple of the given hook. 24 | export type HookReturnValue = ReturnType; 25 | 26 | // Returns the type of the navigation options that hook's push function accepts. 27 | export type HookNavigationOptions = 28 | HookReturnValue[1] extends ( 29 | path: Path, 30 | options: infer R, 31 | ...rest: any[] 32 | ) => any 33 | ? R extends { [k: string]: any } 34 | ? R 35 | : {} 36 | : {}; 37 | -------------------------------------------------------------------------------- /packages/wouter-preact/types/memory-location.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseLocationHook, Path } from "./location-hook.js"; 2 | 3 | type Navigate = ( 4 | to: Path, 5 | options?: { replace?: boolean; state?: S } 6 | ) => void; 7 | 8 | type HookReturnValue = { hook: BaseLocationHook; navigate: Navigate }; 9 | type StubHistory = { history: Path[]; reset: () => void }; 10 | 11 | export function memoryLocation(options?: { 12 | path?: Path; 13 | static?: boolean; 14 | record?: false; 15 | }): HookReturnValue; 16 | export function memoryLocation(options?: { 17 | path?: Path; 18 | static?: boolean; 19 | record: true; 20 | }): HookReturnValue & StubHistory; 21 | -------------------------------------------------------------------------------- /packages/wouter-preact/types/router.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Path, 3 | SearchString, 4 | BaseLocationHook, 5 | BaseSearchHook, 6 | } from "./location-hook.js"; 7 | 8 | export type Parser = ( 9 | route: Path, 10 | loose?: boolean 11 | ) => { pattern: RegExp; keys: string[] }; 12 | 13 | export type HrefsFormatter = (href: string, router: RouterObject) => string; 14 | 15 | // the object returned from `useRouter` 16 | export interface RouterObject { 17 | readonly hook: BaseLocationHook; 18 | readonly searchHook: BaseSearchHook; 19 | readonly base: Path; 20 | readonly ownBase: Path; 21 | readonly parser: Parser; 22 | readonly ssrPath?: Path; 23 | readonly ssrSearch?: SearchString; 24 | readonly hrefs: HrefsFormatter; 25 | } 26 | 27 | // basic options to construct a router 28 | export type RouterOptions = { 29 | hook?: BaseLocationHook; 30 | searchHook?: BaseSearchHook; 31 | base?: Path; 32 | parser?: Parser; 33 | ssrPath?: Path; 34 | ssrSearch?: SearchString; 35 | hrefs?: HrefsFormatter; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/wouter-preact/types/use-browser-location.d.ts: -------------------------------------------------------------------------------- 1 | import { Path, SearchString } from "./location-hook.js"; 2 | 3 | type Primitive = string | number | bigint | boolean | null | undefined | symbol; 4 | export const useLocationProperty: ( 5 | fn: () => S, 6 | ssrFn?: () => S 7 | ) => S; 8 | 9 | export type BrowserSearchHook = (options?: { 10 | ssrSearch?: SearchString; 11 | }) => SearchString; 12 | 13 | export const useSearch: BrowserSearchHook; 14 | 15 | export const usePathname: (options?: { ssrPath?: Path }) => Path; 16 | 17 | export const useHistoryState: () => T; 18 | 19 | export const navigate: ( 20 | to: string | URL, 21 | options?: { replace?: boolean; state?: S } 22 | ) => void; 23 | 24 | /* 25 | * Default `useLocation` 26 | */ 27 | 28 | // The type of the default `useLocation` hook that wouter uses. 29 | // It operates on current URL using History API, supports base path and can 30 | // navigate with `pushState` or `replaceState`. 31 | export type BrowserLocationHook = (options?: { 32 | ssrPath?: Path; 33 | }) => [Path, typeof navigate]; 34 | 35 | export const useBrowserLocation: BrowserLocationHook; 36 | -------------------------------------------------------------------------------- /packages/wouter-preact/types/use-hash-location.d.ts: -------------------------------------------------------------------------------- 1 | import { Path } from "./location-hook.js"; 2 | 3 | export function navigate( 4 | to: Path, 5 | options?: { state?: S; replace?: boolean } 6 | ): void; 7 | 8 | export function useHashLocation(options?: { 9 | ssrPath?: Path; 10 | }): [Path, typeof navigate]; 11 | -------------------------------------------------------------------------------- /packages/wouter-preact/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject } from "vitest/config"; 2 | import preact from "@preact/preset-vite"; 3 | 4 | export default defineProject({ 5 | plugins: [preact()], 6 | test: { 7 | name: "wouter-preact", 8 | environment: "happy-dom", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/wouter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wouter", 3 | "version": "3.7.1", 4 | "description": "Minimalist-friendly ~1.5KB router for React", 5 | "type": "module", 6 | "keywords": [ 7 | "react", 8 | "preact", 9 | "router", 10 | "tiny", 11 | "routing", 12 | "hooks", 13 | "useLocation" 14 | ], 15 | "files": [ 16 | "esm", 17 | "types/**/*.d.ts", 18 | "types/*.d.ts" 19 | ], 20 | "main": "esm/index.js", 21 | "exports": { 22 | ".": { 23 | "types": "./types/index.d.ts", 24 | "default": "./esm/index.js" 25 | }, 26 | "./use-browser-location": { 27 | "types": "./types/use-browser-location.d.ts", 28 | "default": "./esm/use-browser-location.js" 29 | }, 30 | "./use-hash-location": { 31 | "types": "./types/use-hash-location.d.ts", 32 | "default": "./esm/use-hash-location.js" 33 | }, 34 | "./memory-location": { 35 | "types": "./types/memory-location.d.ts", 36 | "default": "./esm/memory-location.js" 37 | } 38 | }, 39 | "types": "types/index.d.ts", 40 | "typesVersions": { 41 | ">=4.1": { 42 | "types/index.d.ts": [ 43 | "types/index.d.ts" 44 | ], 45 | "use-browser-location": [ 46 | "types/use-browser-location.d.ts" 47 | ], 48 | "use-hash-location": [ 49 | "types/use-hash-location.d.ts" 50 | ], 51 | "memory-location": [ 52 | "types/memory-location.d.ts" 53 | ] 54 | } 55 | }, 56 | "scripts": { 57 | "build": "rollup -c", 58 | "watch": "rollup -c -w", 59 | "prepublishOnly": "npm run build && cp ../../README.md ." 60 | }, 61 | "author": "Alexey Taktarov ", 62 | "repository": { 63 | "type": "git", 64 | "url": "git+https://github.com/molefrog/wouter.git" 65 | }, 66 | "license": "Unlicense", 67 | "peerDependencies": { 68 | "react": ">=16.8.0" 69 | }, 70 | "dependencies": { 71 | "mitt": "^3.0.1", 72 | "regexparam": "^3.0.0", 73 | "use-sync-external-store": "^1.0.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/wouter/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "rollup"; 2 | 3 | export default defineConfig([ 4 | { 5 | input: ["src/react-deps.js"], 6 | output: { 7 | dir: "esm", 8 | format: "esm", 9 | }, 10 | external: [ 11 | "react", 12 | "use-sync-external-store/shim/index.js", 13 | "use-sync-external-store/shim/index.native.js", 14 | ], 15 | }, 16 | { 17 | input: [ 18 | "src/index.js", 19 | "src/use-browser-location.js", 20 | "src/use-hash-location.js", 21 | "src/memory-location.js", 22 | ], 23 | external: [/react-deps/, "regexparam", "mitt"], 24 | output: { 25 | dir: "esm", 26 | format: "esm", 27 | }, 28 | }, 29 | ]); 30 | -------------------------------------------------------------------------------- /packages/wouter/setup-vitest.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/vitest"; 2 | -------------------------------------------------------------------------------- /packages/wouter/src/index.js: -------------------------------------------------------------------------------- 1 | import { parse as parsePattern } from "regexparam"; 2 | 3 | import { 4 | useBrowserLocation, 5 | useSearch as useBrowserSearch, 6 | } from "./use-browser-location.js"; 7 | 8 | import { 9 | useRef, 10 | useContext, 11 | createContext, 12 | isValidElement, 13 | cloneElement, 14 | createElement as h, 15 | Fragment, 16 | forwardRef, 17 | useIsomorphicLayoutEffect, 18 | useEvent, 19 | useMemo, 20 | } from "./react-deps.js"; 21 | import { absolutePath, relativePath, sanitizeSearch } from "./paths.js"; 22 | 23 | /* 24 | * Router and router context. Router is a lightweight object that represents the current 25 | * routing options: how location is managed, base path etc. 26 | * 27 | * There is a default router present for most of the use cases, however it can be overridden 28 | * via the component. 29 | */ 30 | 31 | const defaultRouter = { 32 | hook: useBrowserLocation, 33 | searchHook: useBrowserSearch, 34 | parser: parsePattern, 35 | base: "", 36 | // this option is used to override the current location during SSR 37 | ssrPath: undefined, 38 | ssrSearch: undefined, 39 | // optional context to track render state during SSR 40 | ssrContext: undefined, 41 | // customizes how `href` props are transformed for 42 | hrefs: (x) => x, 43 | }; 44 | 45 | const RouterCtx = createContext(defaultRouter); 46 | 47 | // gets the closest parent router from the context 48 | export const useRouter = () => useContext(RouterCtx); 49 | 50 | /** 51 | * Parameters context. Used by `useParams()` to get the 52 | * matched params from the innermost `Route` component. 53 | */ 54 | 55 | const Params0 = {}, 56 | ParamsCtx = createContext(Params0); 57 | 58 | export const useParams = () => useContext(ParamsCtx); 59 | 60 | /* 61 | * Part 1, Hooks API: useRoute and useLocation 62 | */ 63 | 64 | // Internal version of useLocation to avoid redundant useRouter calls 65 | 66 | const useLocationFromRouter = (router) => { 67 | const [location, navigate] = router.hook(router); 68 | 69 | // the function reference should stay the same between re-renders, so that 70 | // it can be passed down as an element prop without any performance concerns. 71 | // (This is achieved via `useEvent`.) 72 | return [ 73 | relativePath(router.base, location), 74 | useEvent((to, navOpts) => navigate(absolutePath(to, router.base), navOpts)), 75 | ]; 76 | }; 77 | 78 | export const useLocation = () => useLocationFromRouter(useRouter()); 79 | 80 | export const useSearch = () => { 81 | const router = useRouter(); 82 | return sanitizeSearch(router.searchHook(router)); 83 | }; 84 | 85 | export const matchRoute = (parser, route, path, loose) => { 86 | // if the input is a regexp, skip parsing 87 | const { pattern, keys } = 88 | route instanceof RegExp 89 | ? { keys: false, pattern: route } 90 | : parser(route || "*", loose); 91 | 92 | // array destructuring loses keys, so this is done in two steps 93 | const result = pattern.exec(path) || []; 94 | 95 | // when parser is in "loose" mode, `$base` is equal to the 96 | // first part of the route that matches the pattern 97 | // (e.g. for pattern `/a/:b` and path `/a/1/2/3` the `$base` is `a/1`) 98 | // we use this for route nesting 99 | const [$base, ...matches] = result; 100 | 101 | return $base !== undefined 102 | ? [ 103 | true, 104 | 105 | (() => { 106 | // for regex paths, `keys` will always be false 107 | 108 | // an object with parameters matched, e.g. { foo: "bar" } for "/:foo" 109 | // we "zip" two arrays here to construct the object 110 | // ["foo"], ["bar"] → { foo: "bar" } 111 | const groups = 112 | keys !== false 113 | ? Object.fromEntries(keys.map((key, i) => [key, matches[i]])) 114 | : result.groups; 115 | 116 | // convert the array to an instance of object 117 | // this makes it easier to integrate with the existing param implementation 118 | let obj = { ...matches }; 119 | 120 | // merge named capture groups with matches array 121 | groups && Object.assign(obj, groups); 122 | 123 | return obj; 124 | })(), 125 | 126 | // the third value if only present when parser is in "loose" mode, 127 | // so that we can extract the base path for nested routes 128 | ...(loose ? [$base] : []), 129 | ] 130 | : [false, null]; 131 | }; 132 | 133 | export const useRoute = (pattern) => 134 | matchRoute(useRouter().parser, pattern, useLocation()[0]); 135 | 136 | /* 137 | * Part 2, Low Carb Router API: Router, Route, Link, Switch 138 | */ 139 | 140 | export const Router = ({ children, ...props }) => { 141 | // the router we will inherit from - it is the closest router in the tree, 142 | // unless the custom `hook` is provided (in that case it's the default one) 143 | const parent_ = useRouter(); 144 | const parent = props.hook ? defaultRouter : parent_; 145 | 146 | // holds to the context value: the router object 147 | let value = parent; 148 | 149 | // when `ssrPath` contains a `?` character, we can extract the search from it 150 | const [path, search] = props.ssrPath?.split("?") ?? []; 151 | if (search) (props.ssrSearch = search), (props.ssrPath = path); 152 | 153 | // hooks can define their own `href` formatter (e.g. for hash location) 154 | props.hrefs = props.hrefs ?? props.hook?.hrefs; 155 | 156 | // what is happening below: to avoid unnecessary rerenders in child components, 157 | // we ensure that the router object reference is stable, unless there are any 158 | // changes that require reload (e.g. `base` prop changes -> all components that 159 | // get the router from the context should rerender, even if the component is memoized). 160 | // the expected behaviour is: 161 | // 162 | // 1) when the resulted router is no different from the parent, use parent 163 | // 2) if the custom `hook` prop is provided, we always inherit from the 164 | // default router instead. this resets all previously overridden options. 165 | // 3) when the router is customized here, it should stay stable between renders 166 | let ref = useRef({}), 167 | prev = ref.current, 168 | next = prev; 169 | 170 | for (let k in parent) { 171 | const option = 172 | k === "base" 173 | ? /* base is special case, it is appended to the parent's base */ 174 | parent[k] + (props[k] || "") 175 | : props[k] || parent[k]; 176 | 177 | if (prev === next && option !== next[k]) { 178 | ref.current = next = { ...next }; 179 | } 180 | 181 | next[k] = option; 182 | 183 | // the new router is no different from the parent or from the memoized value, use parent 184 | if (option !== parent[k] || option !== value[k]) value = next; 185 | } 186 | 187 | return h(RouterCtx.Provider, { value, children }); 188 | }; 189 | 190 | const h_route = ({ children, component }, params) => { 191 | // React-Router style `component` prop 192 | if (component) return h(component, { params }); 193 | 194 | // support render prop or plain children 195 | return typeof children === "function" ? children(params) : children; 196 | }; 197 | 198 | // Cache params object between renders if values are shallow equal 199 | const useCachedParams = (value) => { 200 | let prev = useRef(Params0); 201 | const curr = prev.current; 202 | return (prev.current = 203 | // Update cache if number of params changed or any value changed 204 | Object.keys(value).length !== Object.keys(curr).length || 205 | Object.entries(value).some(([k, v]) => v !== curr[k]) 206 | ? value // Return new value if there are changes 207 | : curr); // Return cached value if nothing changed 208 | }; 209 | 210 | export function useSearchParams() { 211 | const [location, navigate] = useLocation(); 212 | 213 | const search = useSearch(); 214 | const searchParams = useMemo(() => new URLSearchParams(search), [search]); 215 | 216 | // cached value before next render, so you can call setSearchParams multiple times 217 | let tempSearchParams = searchParams; 218 | 219 | const setSearchParams = useEvent((nextInit, options) => { 220 | tempSearchParams = new URLSearchParams( 221 | typeof nextInit === "function" ? nextInit(tempSearchParams) : nextInit 222 | ); 223 | navigate(location + "?" + tempSearchParams, options); 224 | }); 225 | 226 | return [searchParams, setSearchParams]; 227 | } 228 | 229 | export const Route = ({ path, nest, match, ...renderProps }) => { 230 | const router = useRouter(); 231 | const [location] = useLocationFromRouter(router); 232 | 233 | const [matches, routeParams, base] = 234 | // `match` is a special prop to give up control to the parent, 235 | // it is used by the `Switch` to avoid double matching 236 | match ?? matchRoute(router.parser, path, location, nest); 237 | 238 | // when `routeParams` is `null` (there was no match), the argument 239 | // below becomes {...null} = {}, see the Object Spread specs 240 | // https://tc39.es/proposal-object-rest-spread/#AbstractOperations-CopyDataProperties 241 | const params = useCachedParams({ ...useParams(), ...routeParams }); 242 | 243 | if (!matches) return null; 244 | 245 | const children = base 246 | ? h(Router, { base }, h_route(renderProps, params)) 247 | : h_route(renderProps, params); 248 | 249 | return h(ParamsCtx.Provider, { value: params, children }); 250 | }; 251 | 252 | export const Link = forwardRef((props, ref) => { 253 | const router = useRouter(); 254 | const [currentPath, navigate] = useLocationFromRouter(router); 255 | 256 | const { 257 | to = "", 258 | href: targetPath = to, 259 | onClick: _onClick, 260 | asChild, 261 | children, 262 | className: cls, 263 | /* eslint-disable no-unused-vars */ 264 | replace /* ignore nav props */, 265 | state /* ignore nav props */, 266 | /* eslint-enable no-unused-vars */ 267 | 268 | ...restProps 269 | } = props; 270 | 271 | const onClick = useEvent((event) => { 272 | // ignores the navigation when clicked using right mouse button or 273 | // by holding a special modifier key: ctrl, command, win, alt, shift 274 | if ( 275 | event.ctrlKey || 276 | event.metaKey || 277 | event.altKey || 278 | event.shiftKey || 279 | event.button !== 0 280 | ) 281 | return; 282 | 283 | _onClick?.(event); 284 | if (!event.defaultPrevented) { 285 | event.preventDefault(); 286 | navigate(targetPath, props); 287 | } 288 | }); 289 | 290 | // handle nested routers and absolute paths 291 | const href = router.hrefs( 292 | targetPath[0] === "~" ? targetPath.slice(1) : router.base + targetPath, 293 | router // pass router as a second argument for convinience 294 | ); 295 | 296 | return asChild && isValidElement(children) 297 | ? cloneElement(children, { onClick, href }) 298 | : h("a", { 299 | ...restProps, 300 | onClick, 301 | href, 302 | // `className` can be a function to apply the class if this link is active 303 | className: cls?.call ? cls(currentPath === targetPath) : cls, 304 | children, 305 | ref, 306 | }); 307 | }); 308 | 309 | const flattenChildren = (children) => 310 | Array.isArray(children) 311 | ? children.flatMap((c) => 312 | flattenChildren(c && c.type === Fragment ? c.props.children : c) 313 | ) 314 | : [children]; 315 | 316 | export const Switch = ({ children, location }) => { 317 | const router = useRouter(); 318 | const [originalLocation] = useLocationFromRouter(router); 319 | 320 | for (const element of flattenChildren(children)) { 321 | let match = 0; 322 | 323 | if ( 324 | isValidElement(element) && 325 | // we don't require an element to be of type Route, 326 | // but we do require it to contain a truthy `path` prop. 327 | // this allows to use different components that wrap Route 328 | // inside of a switch, for example . 329 | (match = matchRoute( 330 | router.parser, 331 | element.props.path, 332 | location || originalLocation, 333 | element.props.nest 334 | ))[0] 335 | ) 336 | return cloneElement(element, { match }); 337 | } 338 | 339 | return null; 340 | }; 341 | 342 | export const Redirect = (props) => { 343 | const { to, href = to } = props; 344 | const router = useRouter(); 345 | const [, navigate] = useLocationFromRouter(router); 346 | const redirect = useEvent(() => navigate(to || href, props)); 347 | const { ssrContext } = router; 348 | 349 | // redirect is guaranteed to be stable since it is returned from useEvent 350 | useIsomorphicLayoutEffect(() => { 351 | redirect(); 352 | }, []); // eslint-disable-line react-hooks/exhaustive-deps 353 | 354 | if (ssrContext) { 355 | ssrContext.redirectTo = to; 356 | } 357 | 358 | return null; 359 | }; 360 | -------------------------------------------------------------------------------- /packages/wouter/src/memory-location.js: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | import { useSyncExternalStore } from "./react-deps.js"; 3 | 4 | /** 5 | * In-memory location that supports navigation 6 | */ 7 | 8 | export const memoryLocation = ({ 9 | path = "/", 10 | searchPath = "", 11 | static: staticLocation, 12 | record, 13 | } = {}) => { 14 | let initialPath = path; 15 | if (searchPath) { 16 | // join with & if path contains search query, and ? otherwise 17 | initialPath += path.split("?")[1] ? "&" : "?"; 18 | initialPath += searchPath; 19 | } 20 | 21 | let [currentPath, currentSearch = ""] = initialPath.split("?"); 22 | const history = [initialPath]; 23 | const emitter = mitt(); 24 | 25 | const navigateImplementation = (path, { replace = false } = {}) => { 26 | if (record) { 27 | if (replace) { 28 | history.splice(history.length - 1, 1, path); 29 | } else { 30 | history.push(path); 31 | } 32 | } 33 | 34 | [currentPath, currentSearch = ""] = path.split("?"); 35 | emitter.emit("navigate", path); 36 | }; 37 | 38 | const navigate = !staticLocation ? navigateImplementation : () => null; 39 | 40 | const subscribe = (cb) => { 41 | emitter.on("navigate", cb); 42 | return () => emitter.off("navigate", cb); 43 | }; 44 | 45 | const useMemoryLocation = () => [ 46 | useSyncExternalStore(subscribe, () => currentPath), 47 | navigate, 48 | ]; 49 | 50 | const useMemoryQuery = () => 51 | useSyncExternalStore(subscribe, () => currentSearch); 52 | 53 | function reset() { 54 | // clean history array with mutation to preserve link 55 | history.splice(0, history.length); 56 | 57 | navigateImplementation(initialPath); 58 | } 59 | 60 | return { 61 | hook: useMemoryLocation, 62 | searchHook: useMemoryQuery, 63 | navigate, 64 | history: record ? history : undefined, 65 | reset: record ? reset : undefined, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/wouter/src/paths.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Transforms `path` into its relative `base` version 3 | * If base isn't part of the path provided returns absolute path e.g. `~/app` 4 | */ 5 | const _relativePath = (base, path) => 6 | !path.toLowerCase().indexOf(base.toLowerCase()) 7 | ? path.slice(base.length) || "/" 8 | : "~" + path; 9 | 10 | /** 11 | * When basepath is `undefined` or '/' it is ignored (we assume it's empty string) 12 | */ 13 | const baseDefaults = (base = "") => (base === "/" ? "" : base); 14 | 15 | export const absolutePath = (to, base) => 16 | to[0] === "~" ? to.slice(1) : baseDefaults(base) + to; 17 | 18 | export const relativePath = (base = "", path) => 19 | _relativePath(unescape(baseDefaults(base)), unescape(path)); 20 | 21 | /* 22 | * Removes leading question mark 23 | */ 24 | const stripQm = (str) => (str[0] === "?" ? str.slice(1) : str); 25 | 26 | /* 27 | * decodes escape sequences such as %20 28 | */ 29 | const unescape = (str) => { 30 | try { 31 | return decodeURI(str); 32 | } catch (_e) { 33 | // fail-safe mode: if string can't be decoded do nothing 34 | return str; 35 | } 36 | }; 37 | 38 | export const sanitizeSearch = (search) => unescape(stripQm(search)); 39 | -------------------------------------------------------------------------------- /packages/wouter/src/react-deps.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // React.useInsertionEffect is not available in React <18 4 | // This hack fixes a transpilation issue on some apps 5 | const useBuiltinInsertionEffect = React["useInsertion" + "Effect"]; 6 | 7 | export { 8 | useMemo, 9 | useRef, 10 | useState, 11 | useContext, 12 | createContext, 13 | isValidElement, 14 | cloneElement, 15 | createElement, 16 | Fragment, 17 | forwardRef, 18 | } from "react"; 19 | 20 | // To resolve webpack 5 errors, while not presenting problems for native, 21 | // we copy the approaches from https://github.com/TanStack/query/pull/3561 22 | // and https://github.com/TanStack/query/pull/3601 23 | // ~ Show this aging PR some love to remove the need for this hack: 24 | // https://github.com/facebook/react/pull/25231 ~ 25 | export { useSyncExternalStore } from "./use-sync-external-store.js"; 26 | 27 | // Copied from: 28 | // https://github.com/facebook/react/blob/main/packages/shared/ExecutionEnvironment.js 29 | const canUseDOM = !!( 30 | typeof window !== "undefined" && 31 | typeof window.document !== "undefined" && 32 | typeof window.document.createElement !== "undefined" 33 | ); 34 | 35 | // Copied from: 36 | // https://github.com/reduxjs/react-redux/blob/master/src/utils/useIsomorphicLayoutEffect.ts 37 | // "React currently throws a warning when using useLayoutEffect on the server. 38 | // To get around it, we can conditionally useEffect on the server (no-op) and 39 | // useLayoutEffect in the browser." 40 | export const useIsomorphicLayoutEffect = canUseDOM 41 | ? React.useLayoutEffect 42 | : React.useEffect; 43 | 44 | // useInsertionEffect is already a noop on the server. 45 | // See: https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFizzHooks.js 46 | export const useInsertionEffect = 47 | useBuiltinInsertionEffect || useIsomorphicLayoutEffect; 48 | 49 | // Userland polyfill while we wait for the forthcoming 50 | // https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md 51 | // Note: "A high-fidelity polyfill for useEvent is not possible because 52 | // there is no lifecycle or Hook in React that we can use to switch 53 | // .current at the right timing." 54 | // So we will have to make do with this "close enough" approach for now. 55 | export const useEvent = (fn) => { 56 | const ref = React.useRef([fn, (...args) => ref[0](...args)]).current; 57 | // Per Dan Abramov: useInsertionEffect executes marginally closer to the 58 | // correct timing for ref synchronization than useLayoutEffect on React 18. 59 | // See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360 60 | useInsertionEffect(() => { 61 | ref[0] = fn; 62 | }); 63 | return ref[1]; 64 | }; 65 | -------------------------------------------------------------------------------- /packages/wouter/src/use-browser-location.js: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from "./react-deps.js"; 2 | 3 | /** 4 | * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History 5 | */ 6 | const eventPopstate = "popstate"; 7 | const eventPushState = "pushState"; 8 | const eventReplaceState = "replaceState"; 9 | const eventHashchange = "hashchange"; 10 | const events = [ 11 | eventPopstate, 12 | eventPushState, 13 | eventReplaceState, 14 | eventHashchange, 15 | ]; 16 | 17 | const subscribeToLocationUpdates = (callback) => { 18 | for (const event of events) { 19 | addEventListener(event, callback); 20 | } 21 | return () => { 22 | for (const event of events) { 23 | removeEventListener(event, callback); 24 | } 25 | }; 26 | }; 27 | 28 | export const useLocationProperty = (fn, ssrFn) => 29 | useSyncExternalStore(subscribeToLocationUpdates, fn, ssrFn); 30 | 31 | const currentSearch = () => location.search; 32 | 33 | export const useSearch = ({ ssrSearch = "" } = {}) => 34 | useLocationProperty(currentSearch, () => ssrSearch); 35 | 36 | const currentPathname = () => location.pathname; 37 | 38 | export const usePathname = ({ ssrPath } = {}) => 39 | useLocationProperty( 40 | currentPathname, 41 | ssrPath ? () => ssrPath : currentPathname 42 | ); 43 | 44 | const currentHistoryState = () => history.state; 45 | export const useHistoryState = () => 46 | useLocationProperty(currentHistoryState, () => null); 47 | 48 | export const navigate = (to, { replace = false, state = null } = {}) => 49 | history[replace ? eventReplaceState : eventPushState](state, "", to); 50 | 51 | // the 2nd argument of the `useBrowserLocation` return value is a function 52 | // that allows to perform a navigation. 53 | export const useBrowserLocation = (opts = {}) => [usePathname(opts), navigate]; 54 | 55 | const patchKey = Symbol.for("wouter_v3"); 56 | 57 | // While History API does have `popstate` event, the only 58 | // proper way to listen to changes via `push/replaceState` 59 | // is to monkey-patch these methods. 60 | // 61 | // See https://stackoverflow.com/a/4585031 62 | if (typeof history !== "undefined" && typeof window[patchKey] === "undefined") { 63 | for (const type of [eventPushState, eventReplaceState]) { 64 | const original = history[type]; 65 | // TODO: we should be using unstable_batchedUpdates to avoid multiple re-renders, 66 | // however that will require an additional peer dependency on react-dom. 67 | // See: https://github.com/reactwg/react-18/discussions/86#discussioncomment-1567149 68 | history[type] = function () { 69 | const result = original.apply(this, arguments); 70 | const event = new Event(type); 71 | event.arguments = arguments; 72 | 73 | dispatchEvent(event); 74 | return result; 75 | }; 76 | } 77 | 78 | // patch history object only once 79 | // See: https://github.com/molefrog/wouter/issues/167 80 | Object.defineProperty(window, patchKey, { value: true }); 81 | } 82 | -------------------------------------------------------------------------------- /packages/wouter/src/use-hash-location.js: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from "./react-deps.js"; 2 | 3 | // array of callback subscribed to hash updates 4 | const listeners = { 5 | v: [], 6 | }; 7 | 8 | const onHashChange = () => listeners.v.forEach((cb) => cb()); 9 | 10 | // we subscribe to `hashchange` only once when needed to guarantee that 11 | // all listeners are called synchronously 12 | const subscribeToHashUpdates = (callback) => { 13 | if (listeners.v.push(callback) === 1) 14 | addEventListener("hashchange", onHashChange); 15 | 16 | return () => { 17 | listeners.v = listeners.v.filter((i) => i !== callback); 18 | if (!listeners.v.length) removeEventListener("hashchange", onHashChange); 19 | }; 20 | }; 21 | 22 | // leading '#' is ignored, leading '/' is optional 23 | const currentHashLocation = () => "/" + location.hash.replace(/^#?\/?/, ""); 24 | 25 | export const navigate = (to, { state = null, replace = false } = {}) => { 26 | const [hash, search] = to.replace(/^#?\/?/, "").split("?"); 27 | 28 | const newRelativePath = 29 | location.pathname + (search ? `?${search}` : location.search) + `#/${hash}`; 30 | const oldURL = location.href; 31 | const newURL = new URL(newRelativePath, location.origin).href; 32 | 33 | if (replace) { 34 | history.replaceState(state, "", newRelativePath); 35 | } else { 36 | history.pushState(state, "", newRelativePath); 37 | } 38 | 39 | const event = 40 | typeof HashChangeEvent !== "undefined" 41 | ? new HashChangeEvent("hashchange", { oldURL, newURL }) 42 | : new Event("hashchange", { detail: { oldURL, newURL } }); 43 | 44 | dispatchEvent(event); 45 | }; 46 | 47 | export const useHashLocation = ({ ssrPath = "/" } = {}) => [ 48 | useSyncExternalStore( 49 | subscribeToHashUpdates, 50 | currentHashLocation, 51 | () => ssrPath 52 | ), 53 | navigate, 54 | ]; 55 | 56 | useHashLocation.hrefs = (href) => "#" + href; 57 | -------------------------------------------------------------------------------- /packages/wouter/src/use-sync-external-store.js: -------------------------------------------------------------------------------- 1 | export { useSyncExternalStore } from "use-sync-external-store/shim/index.js"; 2 | -------------------------------------------------------------------------------- /packages/wouter/src/use-sync-external-store.native.js: -------------------------------------------------------------------------------- 1 | export { useSyncExternalStore } from "use-sync-external-store/shim/index.native.js"; 2 | -------------------------------------------------------------------------------- /packages/wouter/test/global-this-at-component-level.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | 5 | import { test, expect, describe } from "vitest"; 6 | import { renderToStaticMarkup } from "react-dom/server"; 7 | import { useSearch, useLocation, Router } from "wouter"; 8 | 9 | describe("useSearch", () => { 10 | test("works in node", () => { 11 | const App = () => { 12 | const search = useSearch(); 13 | return <>{search}; 14 | }; 15 | 16 | const rendered = renderToStaticMarkup( 17 | 18 | 19 | 20 | ); 21 | expect(rendered).toBe("foo=1"); 22 | }); 23 | 24 | test("works in node without options", () => { 25 | const App = () => { 26 | const search = useSearch(); 27 | return <>search: {search}; 28 | }; 29 | 30 | const rendered = renderToStaticMarkup(); 31 | expect(rendered).toBe("search: "); 32 | }); 33 | }); 34 | 35 | test("useLocation works in node", () => { 36 | const App = () => { 37 | const [path] = useLocation(); 38 | return <>{path}; 39 | }; 40 | 41 | const rendered = renderToStaticMarkup( 42 | 43 | 44 | 45 | ); 46 | expect(rendered).toBe("/hello-from-server"); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/wouter/test/global-this-at-top-level.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | 5 | import { test, expect } from "vitest"; 6 | 7 | test("use-browser-location should work in node environment", () => { 8 | expect(() => import("wouter/use-browser-location")).not.toThrow(); 9 | }); 10 | 11 | test("wouter should work in node environment", () => { 12 | expect(() => import("wouter")).not.toThrow(); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/wouter/test/history-patch.test.ts: -------------------------------------------------------------------------------- 1 | import { useLocation as reactHook } from "wouter"; 2 | import { useLocation as preactHook } from "wouter-preact"; 3 | import { renderHook, act } from "@testing-library/react"; 4 | 5 | import { vi, it, expect, describe } from "vitest"; 6 | 7 | describe("history patch", () => { 8 | it("exports should exists", () => { 9 | expect(reactHook).toBeDefined(); 10 | expect(preactHook).toBeDefined(); 11 | }); 12 | 13 | it("history should be patched once", () => { 14 | const fn = vi.fn(); 15 | const { result, unmount } = renderHook(() => reactHook()); 16 | 17 | addEventListener("pushState", (e) => { 18 | fn(); 19 | }); 20 | 21 | expect(result.current[0]).toBe("/"); 22 | expect(fn).toBeCalledTimes(0); 23 | 24 | act(() => result.current[1]("/hello")); 25 | act(() => result.current[1]("/world")); 26 | 27 | expect(result.current[0]).toBe("/world"); 28 | expect(fn).toBeCalledTimes(2); 29 | 30 | unmount(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/wouter/test/link.test-d.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expectTypeOf, it } from "vitest"; 2 | import { Link, LinkProps, type Path } from "wouter"; 3 | import * as React from "react"; 4 | 5 | type NetworkLocationHook = () => [ 6 | Path, 7 | (path: string, options: { host: string; retries?: number }) => void 8 | ]; 9 | 10 | describe(" types", () => { 11 | it("should have required prop href", () => { 12 | // @ts-expect-error 13 | test; 14 | test; 15 | }); 16 | 17 | it("does not allow `to` and `href` props to be used at the same time", () => { 18 | // @ts-expect-error 19 | 20 | Hello 21 | ; 22 | }); 23 | 24 | it("should inherit props from `HTMLAnchorElement`", () => { 25 | 26 | Hello 27 | ; 28 | 29 | 30 | Hello 31 | ; 32 | 33 | 34 | Hello 35 | ; 36 | 37 | 38 | Hello 39 | ; 40 | }); 41 | 42 | it("can accept function as `className`", () => { 43 | (isActive ? "active" : "non-active")} 46 | />; 47 | 48 | (isActive ? "active" : undefined)} 51 | />; 52 | }); 53 | 54 | it("should support other navigation params", () => { 55 | 56 | test 57 | ; 58 | 59 | 60 | test 61 | ; 62 | 63 | // @ts-expect-error 64 | 65 | Hello 66 | ; 67 | 68 | 69 | test 70 | ; 71 | }); 72 | 73 | it("should work with generic type", () => { 74 | href="/" host="wouter.com"> 75 | test 76 | ; 77 | 78 | // @ts-expect-error 79 | href="/">test; 80 | 81 | href="/" host="wouter.com" retries={4}> 82 | test 83 | ; 84 | }); 85 | }); 86 | 87 | describe(" with ref", () => { 88 | it("should work", () => { 89 | const ref = React.useRef(null); 90 | 91 | 92 | Hello 93 | ; 94 | }); 95 | 96 | it("should have error when type is `unknown`", () => { 97 | const ref = React.useRef(); 98 | 99 | // @ts-expect-error 100 | 101 | Hello 102 | ; 103 | }); 104 | 105 | it("should have error when type is miss matched", () => { 106 | const ref = React.useRef(null); 107 | 108 | // @ts-expect-error 109 | 110 | Hello 111 | ; 112 | }); 113 | }); 114 | 115 | describe(" with `asChild` prop", () => { 116 | it("should work", () => { 117 | 118 | Hello 119 | ; 120 | }); 121 | 122 | it("does not allow `to` and `href` props to be used at the same time", () => { 123 | // @ts-expect-error 124 | 125 | Hello 126 | ; 127 | }); 128 | 129 | it("can only have valid element as a child", () => { 130 | // @ts-expect-error strings are not valid children 131 | 132 | {true ? "Hello" : "World"} 133 | ; 134 | 135 | // @ts-expect-error can't use multiple nodes as children 136 | 137 | Link 138 |
icon
139 | ; 140 | }); 141 | 142 | it("does not allow other props", () => { 143 | // @ts-expect-error 144 | 145 | Hello 146 | ; 147 | 148 | // @ts-expect-error 149 | 150 | Hello 151 | ; 152 | 153 | // @ts-expect-error 154 | 155 | Hello 156 | ; 157 | 158 | // @ts-expect-error 159 | 160 | Hello 161 | ; 162 | }); 163 | 164 | it("should support other navigation params", () => { 165 | 166 | Hello 167 | ; 168 | 169 | // @ts-expect-error 170 | 171 | Hello 172 | ; 173 | 174 | 175 | Hello 176 | ; 177 | }); 178 | 179 | it("should work with generic type", () => { 180 | asChild to="/" host="wouter.com"> 181 |
test
182 | ; 183 | 184 | // @ts-expect-error 185 | asChild to="/"> 186 |
test
187 | ; 188 | 189 | asChild to="/" host="wouter.com" retries={4}> 190 |
test
191 | ; 192 | }); 193 | 194 | it("accepts `onClick` prop that overwrites child's handler", () => { 195 | { 199 | expectTypeOf(e).toEqualTypeOf(); 200 | }} 201 | > 202 | Hello 203 | ; 204 | }); 205 | 206 | it("should work with `ComponentProps`", () => { 207 | type LinkComponentProps = React.ComponentProps; 208 | 209 | // Because Link is a generic component, the props 210 | // cant't contain navigation options of the default generic 211 | // parameter `BrowserLocationHook`. 212 | // So the best we can get are the props such as `href` etc. 213 | expectTypeOf().toMatchTypeOf(); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /packages/wouter/test/link.test.tsx: -------------------------------------------------------------------------------- 1 | import { type MouseEventHandler } from "react"; 2 | import { it, expect, afterEach, vi, describe } from "vitest"; 3 | import { render, cleanup, fireEvent, act } from "@testing-library/react"; 4 | 5 | import { Router, Link } from "wouter"; 6 | import { memoryLocation } from "wouter/memory-location"; 7 | 8 | afterEach(cleanup); 9 | 10 | describe("", () => { 11 | it("renders a link with proper attributes", () => { 12 | const { getByText } = render( 13 | 14 | Click Me 15 | 16 | ); 17 | 18 | const element = getByText("Click Me"); 19 | 20 | expect(element).toBeInTheDocument(); 21 | expect(element).toHaveAttribute("href", "/about"); 22 | expect(element).toHaveClass("link--active"); 23 | }); 24 | 25 | it("passes ref to ", () => { 26 | const refCallback = vi.fn<[HTMLAnchorElement], void>(); 27 | const { getByText } = render( 28 | 29 | Testing 30 | 31 | ); 32 | 33 | const element = getByText("Testing"); 34 | 35 | expect(element).toBeInTheDocument(); 36 | expect(element).toHaveAttribute("href", "/"); 37 | 38 | expect(refCallback).toBeCalledTimes(1); 39 | expect(refCallback).toBeCalledWith(element); 40 | }); 41 | 42 | it("still creates a plain link when nothing is passed", () => { 43 | const { getByTestId } = render(); 44 | 45 | const element = getByTestId("link"); 46 | 47 | expect(element).toBeInTheDocument(); 48 | expect(element).toHaveAttribute("href", "/about"); 49 | expect(element).toBeEmptyDOMElement(); 50 | }); 51 | 52 | it("supports `to` prop as an alias to `href`", () => { 53 | const { getByText } = render(Hello); 54 | const element = getByText("Hello"); 55 | 56 | expect(element).toBeInTheDocument(); 57 | expect(element).toHaveAttribute("href", "/about"); 58 | }); 59 | 60 | it("performs a navigation when the link is clicked", () => { 61 | const { getByTestId } = render( 62 | 63 | link 64 | 65 | ); 66 | 67 | fireEvent.click(getByTestId("link")); 68 | 69 | expect(location.pathname).toBe("/goo-baz"); 70 | }); 71 | 72 | it("supports replace navigation", () => { 73 | const { getByTestId } = render( 74 | 75 | link 76 | 77 | ); 78 | 79 | const histBefore = history.length; 80 | 81 | fireEvent.click(getByTestId("link")); 82 | 83 | expect(location.pathname).toBe("/goo-baz"); 84 | expect(history.length).toBe(histBefore); 85 | }); 86 | 87 | it("ignores the navigation when clicked with modifiers", () => { 88 | const { getByTestId } = render( 89 | 90 | click 91 | 92 | ); 93 | const clickEvt = new MouseEvent("click", { 94 | bubbles: true, 95 | cancelable: true, 96 | button: 0, 97 | ctrlKey: true, 98 | }); 99 | 100 | // js-dom doesn't implement browser navigation (e.g. changing location 101 | // when a link is clicked) so we need just ingore it to avoid warnings 102 | clickEvt.preventDefault(); 103 | 104 | fireEvent(getByTestId("link"), clickEvt); 105 | expect(location.pathname).not.toBe("/users"); 106 | }); 107 | 108 | it("ignores the navigation when event is cancelled", () => { 109 | const clickHandler: MouseEventHandler = (e) => { 110 | e.preventDefault(); 111 | }; 112 | 113 | const { getByTestId } = render( 114 | 115 | click 116 | 117 | ); 118 | 119 | fireEvent.click(getByTestId("link")); 120 | expect(location.pathname).not.toBe("/users"); 121 | }); 122 | 123 | it("accepts an `onClick` prop, fired before the navigation", () => { 124 | const clickHandler = vi.fn(); 125 | 126 | const { getByTestId } = render( 127 | 128 | ); 129 | 130 | fireEvent.click(getByTestId("link")); 131 | expect(clickHandler).toHaveBeenCalledTimes(1); 132 | }); 133 | 134 | it("renders `href` with basepath", () => { 135 | const { getByTestId } = render( 136 | 137 | 138 | 139 | ); 140 | 141 | const link = getByTestId("link"); 142 | expect(link.getAttribute("href")).toBe("/app/dashboard"); 143 | }); 144 | 145 | it("renders `href` with absolute links", () => { 146 | const { getByTestId } = render( 147 | 148 | 149 | 150 | ); 151 | 152 | const element = getByTestId("link"); 153 | expect(element).toHaveAttribute("href", "/home"); 154 | }); 155 | 156 | it("supports history state", () => { 157 | const testState = { hello: "world" }; 158 | const { getByTestId } = render( 159 | 160 | link 161 | 162 | ); 163 | 164 | fireEvent.click(getByTestId("link")); 165 | expect(location.pathname).toBe("/goo-baz"); 166 | expect(history.state).toStrictEqual(testState); 167 | }); 168 | 169 | it("can be configured to use custom href formatting", () => { 170 | const formatter = (href: string) => `#${href}`; 171 | 172 | const { getByTestId } = render( 173 | <> 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | ); 184 | 185 | expect(getByTestId("root")).toHaveAttribute("href", "#/"); 186 | expect(getByTestId("home")).toHaveAttribute("href", "#/home"); 187 | expect(getByTestId("absolute")).toHaveAttribute("href", "#/home"); 188 | }); 189 | }); 190 | 191 | describe("active links", () => { 192 | it("proxies `className` when it is a string", () => { 193 | const { getByText } = render( 194 | 195 | Click Me 196 | 197 | ); 198 | 199 | const element = getByText("Click Me"); 200 | expect(element).toHaveAttribute("class", "link--active warning"); 201 | }); 202 | 203 | it("calls the `className` function with active link flag", () => { 204 | const { navigate, hook } = memoryLocation({ path: "/" }); 205 | 206 | const { getByText } = render( 207 | 208 | { 211 | return [isActive ? "active" : "", "link"].join(" "); 212 | }} 213 | > 214 | Click Me 215 | 216 | 217 | ); 218 | 219 | const element = getByText("Click Me"); 220 | expect(element).toBeInTheDocument(); 221 | expect(element).toHaveClass("active"); 222 | expect(element).toHaveClass("link"); 223 | 224 | act(() => navigate("/about")); 225 | 226 | expect(element).not.toHaveClass("active"); 227 | expect(element).toHaveClass("link"); 228 | }); 229 | 230 | it("correctly highlights active links when using custom href formatting", () => { 231 | const formatter = (href: string) => `#${href}`; 232 | const { navigate, hook } = memoryLocation({ path: "/" }); 233 | 234 | const { getByText } = render( 235 | 236 | { 239 | return [isActive ? "active" : "", "link"].join(" "); 240 | }} 241 | > 242 | Click Me 243 | 244 | 245 | ); 246 | 247 | const element = getByText("Click Me"); 248 | expect(element).toBeInTheDocument(); 249 | expect(element).toHaveClass("active"); 250 | expect(element).toHaveClass("link"); 251 | 252 | act(() => navigate("/about")); 253 | 254 | expect(element).not.toHaveClass("active"); 255 | expect(element).toHaveClass("link"); 256 | }); 257 | }); 258 | 259 | describe(" with `asChild` prop", () => { 260 | it("when `asChild` is not specified, wraps the children in an ", () => { 261 | const { getByText } = render( 262 | 263 |
Click Me
264 | 265 | ); 266 | 267 | const link = getByText("Click Me"); 268 | 269 | expect(link.tagName).toBe("DIV"); 270 | expect(link).not.toHaveAttribute("href"); 271 | expect(link).toHaveClass("link--wannabe"); 272 | expect(link).toHaveTextContent("Click Me"); 273 | 274 | expect(link.parentElement?.tagName).toBe("A"); 275 | expect(link.parentElement).toHaveAttribute("href", "/about"); 276 | }); 277 | 278 | it("when invalid element is provided, wraps the children in an
", () => { 279 | const { getByText } = render( 280 | /* @ts-expect-error */ 281 | 282 | Click Me 283 | 284 | ); 285 | 286 | const link = getByText("Click Me"); 287 | 288 | expect(link.tagName).toBe("A"); 289 | expect(link).toHaveAttribute("href", "/about"); 290 | expect(link).toHaveTextContent("Click Me"); 291 | }); 292 | 293 | it("when more than one element is provided, wraps the children in an ", async () => { 294 | const { getByText } = render( 295 | /* @ts-expect-error */ 296 | 297 | 1 298 | 2 299 | 3 300 | 301 | ); 302 | 303 | const span = getByText("1"); 304 | 305 | expect(span.parentElement?.tagName).toBe("A"); 306 | 307 | expect(span.parentElement).toHaveAttribute("href", "/about"); 308 | expect(span.parentElement).toHaveTextContent("123"); 309 | }); 310 | 311 | it("injects href prop when rendered with `asChild`", () => { 312 | const { getByText } = render( 313 | 314 |
Click Me
315 | 316 | ); 317 | 318 | const link = getByText("Click Me"); 319 | 320 | expect(link.tagName).toBe("DIV"); 321 | expect(link).toHaveClass("link--wannabe"); 322 | expect(link).toHaveAttribute("href", "/about"); 323 | expect(link).toHaveTextContent("Click Me"); 324 | }); 325 | 326 | it("missing href or to won't crash", () => { 327 | const { getByText } = render( 328 | /* @ts-expect-error */ 329 | Click Me 330 | ); 331 | 332 | const link = getByText("Click Me"); 333 | 334 | expect(link.tagName).toBe("A"); 335 | expect(link).toHaveAttribute("href", undefined); 336 | expect(link).toHaveTextContent("Click Me"); 337 | }); 338 | }); 339 | -------------------------------------------------------------------------------- /packages/wouter/test/location-hook.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, expectTypeOf, describe } from "vitest"; 2 | import { BaseLocationHook, HookNavigationOptions } from "wouter"; 3 | 4 | describe("`HookNavigationOptions` utility type", () => { 5 | it("should return empty interface for hooks with no nav options", () => { 6 | const hook = (): [string, (path: string) => void] => { 7 | return ["stub", (path: string) => {}]; 8 | }; 9 | 10 | type Options = HookNavigationOptions; 11 | 12 | expectTypeOf().toEqualTypeOf<{}>(); 13 | 14 | const optionsExt: Options | { a: 1 } = { a: 1, b: 2 }; 15 | }); 16 | 17 | it("should return object with required navigation params", () => { 18 | const hook = (): [ 19 | string, 20 | (path: string, options: { replace: boolean; optional?: number }) => void 21 | ] => { 22 | return ["stub", () => {}]; 23 | }; 24 | 25 | type Options = HookNavigationOptions; 26 | 27 | // @ts-expect-error 28 | expectTypeOf().toEqualTypeOf<{ 29 | replace: boolean; 30 | foo: string; 31 | }>(); 32 | 33 | expectTypeOf().toEqualTypeOf<{ 34 | replace: boolean; 35 | optional?: number; 36 | }>(); 37 | }); 38 | 39 | it("should not contain never when options are optional", () => { 40 | const hook = ( 41 | param: string 42 | ): [string, (path: string, options?: { replace: boolean }) => void] => { 43 | return ["stub", () => {}]; 44 | }; 45 | 46 | type Options = HookNavigationOptions; 47 | 48 | expectTypeOf().toEqualTypeOf<{ 49 | replace: boolean; 50 | }>(); 51 | }); 52 | 53 | it("should only support valid hooks", () => { 54 | // @ts-expect-error 55 | type A = HookNavigationOptions; 56 | // @ts-expect-error 57 | type B = HookNavigationOptions<{}>; 58 | // @ts-expect-error 59 | type C = HookNavigationOptions<() => []>; 60 | }); 61 | 62 | it("should return empty object when `BaseLocationHook` is given", () => { 63 | type Options = HookNavigationOptions; 64 | expectTypeOf().toEqualTypeOf<{}>(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/wouter/test/match-route.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, expectTypeOf, assertType } from "vitest"; 2 | import { matchRoute, useRouter } from "wouter"; 3 | 4 | const { parser } = useRouter(); 5 | 6 | it("should only accept strings", () => { 7 | // @ts-expect-error 8 | assertType(matchRoute(parser, Symbol(), "")); 9 | // @ts-expect-error 10 | assertType(matchRoute(parser, undefined, "")); 11 | assertType(matchRoute(parser, "/", "")); 12 | }); 13 | 14 | it('has a boolean "match" result as a first returned value', () => { 15 | const [match] = matchRoute(parser, "/", ""); 16 | expectTypeOf(match).toEqualTypeOf(); 17 | }); 18 | 19 | it("returns null as parameters when there was no match", () => { 20 | const [match, params] = matchRoute(parser, "/foo", ""); 21 | 22 | if (!match) { 23 | expectTypeOf(params).toEqualTypeOf(); 24 | } 25 | }); 26 | 27 | it("accepts the type of parameters as a generic argument", () => { 28 | const [match, params] = matchRoute<{ id: string; name: string | undefined }>( 29 | parser, 30 | "/app/users/:name?/:id", 31 | "" 32 | ); 33 | 34 | if (match) { 35 | expectTypeOf(params).toEqualTypeOf<{ 36 | id: string; 37 | name: string | undefined; 38 | }>(); 39 | } 40 | }); 41 | 42 | it("infers parameters from the route path", () => { 43 | const [, inferedParams] = matchRoute(parser, "/app/users/:name?/:id/*?", ""); 44 | 45 | if (inferedParams) { 46 | expectTypeOf(inferedParams).toMatchTypeOf<{ 47 | 0?: string; 48 | 1?: string; 49 | 2?: string; 50 | name?: string; 51 | id: string; 52 | wildcard?: string; 53 | }>(); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /packages/wouter/test/memory-location.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, assertType, expectTypeOf } from "vitest"; 2 | import { memoryLocation } from "wouter/memory-location"; 3 | import { BaseLocationHook } from "wouter"; 4 | 5 | it("should return hook that supports location spec", () => { 6 | const { hook } = memoryLocation(); 7 | 8 | expectTypeOf(hook).toMatchTypeOf(); 9 | 10 | const [location, navigate] = hook(); 11 | 12 | assertType(location); 13 | assertType(navigate); 14 | }); 15 | 16 | it("should return `navigate` method for navigating outside of components", () => { 17 | const { navigate } = memoryLocation(); 18 | 19 | assertType(navigate); 20 | }); 21 | 22 | it("should support `record` option for saving the navigation history", () => { 23 | const { history, reset } = memoryLocation({ record: true }); 24 | 25 | assertType(history); 26 | assertType(reset); 27 | }); 28 | 29 | it("should have history only wheen record is true", () => { 30 | // @ts-expect-error 31 | const { history, reset } = memoryLocation({ record: false }); 32 | assertType(history); 33 | assertType(reset); 34 | }); 35 | 36 | it("should support initial path", () => { 37 | const { hook } = memoryLocation({ path: "/initial-path" }); 38 | 39 | expectTypeOf(hook).toMatchTypeOf(); 40 | }); 41 | 42 | it("should support `static` option", () => { 43 | const { hook } = memoryLocation({ static: true }); 44 | 45 | expectTypeOf(hook).toMatchTypeOf(); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/wouter/test/memory-location.test.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from "vitest"; 2 | import { renderHook, act } from "@testing-library/react"; 3 | import { memoryLocation } from "wouter/memory-location"; 4 | 5 | it("returns a hook that is compatible with location spec", () => { 6 | const { hook } = memoryLocation(); 7 | 8 | const { result, unmount } = renderHook(() => hook()); 9 | const [value, update] = result.current; 10 | 11 | expect(typeof value).toBe("string"); 12 | expect(typeof update).toBe("function"); 13 | unmount(); 14 | }); 15 | 16 | it("should support initial path", () => { 17 | const { hook } = memoryLocation({ path: "/test-case" }); 18 | 19 | const { result, unmount } = renderHook(() => hook()); 20 | const [value] = result.current; 21 | 22 | expect(value).toBe("/test-case"); 23 | unmount(); 24 | }); 25 | 26 | it("should support initial path with query", () => { 27 | const { searchHook } = memoryLocation({ path: "/test-case?foo=bar" }); 28 | 29 | const { result, unmount } = renderHook(() => searchHook()); 30 | const value = result.current; 31 | 32 | expect(value).toBe("foo=bar"); 33 | unmount(); 34 | }); 35 | 36 | it("should support search path as parameter", () => { 37 | const { searchHook } = memoryLocation({ 38 | path: "/test-case?foo=bar", 39 | searchPath: "key=value", 40 | }); 41 | 42 | const { result, unmount } = renderHook(() => searchHook()); 43 | const value = result.current; 44 | 45 | expect(value).toBe("foo=bar&key=value"); 46 | unmount(); 47 | }); 48 | 49 | it('should return location hook that has initial path "/" by default', () => { 50 | const { hook } = memoryLocation(); 51 | 52 | const { result, unmount } = renderHook(() => hook()); 53 | const [value] = result.current; 54 | 55 | expect(value).toBe("/"); 56 | unmount(); 57 | }); 58 | 59 | it('should return search hook that has initial query "" by default', () => { 60 | const { searchHook } = memoryLocation(); 61 | 62 | const { result, unmount } = renderHook(() => searchHook()); 63 | const value = result.current; 64 | 65 | expect(value).toBe(""); 66 | unmount(); 67 | }); 68 | 69 | it("should return standalone `navigate` method", () => { 70 | const { hook, navigate } = memoryLocation(); 71 | 72 | const { result, unmount } = renderHook(() => hook()); 73 | 74 | act(() => navigate("/standalone")); 75 | 76 | const [value] = result.current; 77 | expect(value).toBe("/standalone"); 78 | unmount(); 79 | }); 80 | 81 | it("should return location hook that supports navigation", () => { 82 | const { hook } = memoryLocation(); 83 | 84 | const { result, unmount } = renderHook(() => hook()); 85 | 86 | act(() => result.current[1]("/location")); 87 | 88 | const [value] = result.current; 89 | expect(value).toBe("/location"); 90 | unmount(); 91 | }); 92 | 93 | it("should record all history when `record` option is provided", () => { 94 | const { 95 | hook, 96 | history, 97 | navigate: standalone, 98 | } = memoryLocation({ record: true, path: "/test" }); 99 | 100 | const { result, unmount } = renderHook(() => hook()); 101 | 102 | act(() => standalone("/standalone")); 103 | act(() => result.current[1]("/location")); 104 | 105 | expect(result.current[0]).toBe("/location"); 106 | 107 | expect(history).toStrictEqual(["/test", "/standalone", "/location"]); 108 | 109 | act(() => standalone("/standalone", { replace: true })); 110 | 111 | expect(history).toStrictEqual(["/test", "/standalone", "/standalone"]); 112 | 113 | act(() => result.current[1]("/location", { replace: true })); 114 | 115 | expect(history).toStrictEqual(["/test", "/standalone", "/location"]); 116 | 117 | unmount(); 118 | }); 119 | 120 | it("should not have history when `record` option is falsy", () => { 121 | // @ts-expect-error 122 | const { history, reset } = memoryLocation(); 123 | expect(history).not.toBeDefined(); 124 | expect(reset).not.toBeDefined(); 125 | }); 126 | 127 | it("should have reset method when `record` option is provided", () => { 128 | const { history, reset, navigate } = memoryLocation({ 129 | path: "/initial", 130 | record: true, 131 | }); 132 | expect(history).toBeDefined(); 133 | expect(reset).toBeDefined(); 134 | 135 | navigate("test-1"); 136 | navigate("test-2"); 137 | 138 | reset(); 139 | 140 | expect(history).toStrictEqual(["/initial"]); 141 | }); 142 | 143 | it("should have reset method that reset hook location", () => { 144 | const { hook, history, navigate, reset } = memoryLocation({ 145 | record: true, 146 | path: "/test", 147 | }); 148 | const { result, unmount } = renderHook(() => hook()); 149 | 150 | act(() => navigate("/location")); 151 | 152 | expect(result.current[0]).toBe("/location"); 153 | 154 | expect(history).toStrictEqual(["/test", "/location"]); 155 | 156 | act(() => reset()); 157 | 158 | expect(history).toStrictEqual(["/test"]); 159 | 160 | expect(result.current[0]).toBe("/test"); 161 | 162 | unmount(); 163 | }); 164 | -------------------------------------------------------------------------------- /packages/wouter/test/nested-route.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect, describe } from "vitest"; 2 | import { act, render, renderHook } from "@testing-library/react"; 3 | 4 | import { Route, Router, Switch, useRouter } from "wouter"; 5 | import { memoryLocation } from "wouter/memory-location"; 6 | 7 | describe("when `nest` prop is given", () => { 8 | it("renders by default", () => { 9 | const { container } = render(matched!); 10 | expect(container.innerHTML).toBe("matched!"); 11 | }); 12 | 13 | it("matches the pattern loosely", () => { 14 | const { hook, navigate } = memoryLocation(); 15 | 16 | const { container } = render( 17 | 18 | 19 | matched! 20 | 21 | 22 | ); 23 | 24 | expect(container.innerHTML).toBe(""); 25 | 26 | act(() => navigate("/posts/all")); // full match 27 | expect(container.innerHTML).toBe("matched!"); 28 | 29 | act(() => navigate("/users")); 30 | expect(container.innerHTML).toBe(""); 31 | 32 | act(() => navigate("/posts/10-react-tricks/table-of-contents")); 33 | expect(container.innerHTML).toBe("matched!"); 34 | }); 35 | 36 | it("can be used inside a Switch", () => { 37 | const { container } = render( 38 | 43 | 44 | about 45 | 46 | nested 47 | 48 | default 49 | 50 | 51 | ); 52 | 53 | expect(container.innerHTML).toBe("nested"); 54 | }); 55 | 56 | it("sets the base to the matched segment", () => { 57 | const { result } = renderHook(() => useRouter().base, { 58 | wrapper: (props) => ( 59 | 62 | 63 | {props.children} 64 | 65 | 66 | ), 67 | }); 68 | 69 | expect(result.current).toBe("/2012/04"); 70 | }); 71 | 72 | it("can be nested in another nested `Route` or `Router`", () => { 73 | const { container } = render( 74 | 83 | 84 | should not be rendered 85 | 86 | 87 | All settings 88 | 89 | 90 | 91 | ); 92 | 93 | expect(container.innerHTML).toBe("All settings"); 94 | }); 95 | 96 | it("reacts to `nest` updates", () => { 97 | const { hook } = memoryLocation({ 98 | path: "/app/apple/products", 99 | static: true, 100 | }); 101 | 102 | const App = ({ nested }: { nested: boolean }) => { 103 | return ( 104 | 105 | 106 | matched! 107 | 108 | 109 | ); 110 | }; 111 | 112 | const { container, rerender } = render(); 113 | expect(container.innerHTML).toBe("matched!"); 114 | 115 | rerender(); 116 | expect(container.innerHTML).toBe(""); 117 | }); 118 | 119 | it("works with one optional segment", () => { 120 | const { hook, navigate } = memoryLocation({ 121 | path: "/", 122 | }); 123 | 124 | const App = () => { 125 | return ( 126 | 127 | 128 | {({ version }) => version ?? "default"} 129 | 130 | 131 | ); 132 | }; 133 | 134 | const { container } = render(); 135 | expect(container.innerHTML).toBe("default"); 136 | 137 | act(() => navigate("/v1")); 138 | expect(container.innerHTML).toBe("v1"); 139 | 140 | act(() => navigate("/v2/dashboard")); 141 | expect(container.innerHTML).toBe("v2"); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /packages/wouter/test/parser.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect } from "vitest"; 2 | 3 | import { pathToRegexp, Key } from "path-to-regexp"; 4 | import { renderHook } from "@testing-library/react"; 5 | 6 | import { Router, useRouter, useRoute, Parser } from "wouter"; 7 | import { memoryLocation } from "wouter/memory-location"; 8 | 9 | // Custom parser that uses `path-to-regexp` instead of `regexparam` 10 | const pathToRegexpParser: Parser = (route: string) => { 11 | const keys: Key[] = []; 12 | const pattern = pathToRegexp(route, keys); 13 | 14 | return { pattern, keys: keys.map((k) => String(k.name)) }; 15 | }; 16 | 17 | it("overrides the `parser` prop on the current router", () => { 18 | const { result } = renderHook(() => useRouter(), { 19 | wrapper: ({ children }) => ( 20 | {children} 21 | ), 22 | }); 23 | 24 | const router = result.current; 25 | expect(router.parser).toBe(pathToRegexpParser); 26 | }); 27 | 28 | it("allows to change the behaviour of route matching", () => { 29 | const { result } = renderHook( 30 | () => useRoute("/(home|dashboard)/:pages?/users/:rest*"), 31 | { 32 | wrapper: ({ children }) => ( 33 | 37 | {children} 38 | 39 | ), 40 | } 41 | ); 42 | 43 | expect(result.current).toStrictEqual([ 44 | true, 45 | { 0: "home", 1: undefined, 2: "10/bio", pages: undefined, rest: "10/bio" }, 46 | ]); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/wouter/test/redirect.test-d.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, assertType } from "vitest"; 2 | import { Redirect } from "wouter"; 3 | 4 | describe("Redirect types", () => { 5 | it("should have required prop href", () => { 6 | // @ts-expect-error 7 | assertType(); 8 | assertType(); 9 | }); 10 | 11 | it("should support state prop", () => { 12 | assertType(); 13 | assertType(); 14 | assertType(); 15 | assertType(); 16 | }); 17 | 18 | it("always renders nothing", () => { 19 | // can be used in JSX 20 |
21 | 22 |
; 23 | 24 | assertType(Redirect({ href: "/" })); 25 | }); 26 | 27 | it("can not accept children", () => { 28 | // @ts-expect-error 29 | hi!; 30 | 31 | // prettier-ignore 32 | // @ts-expect-error 33 | <>
Fragment
; 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/wouter/test/redirect.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect } from "vitest"; 2 | import { render } from "@testing-library/react"; 3 | import { useState } from "react"; 4 | 5 | import { Redirect, Router } from "wouter"; 6 | 7 | export const customHookWithReturn = 8 | (initialPath = "/") => 9 | () => { 10 | const [path, updatePath] = useState(initialPath); 11 | const navigate = (path: string) => { 12 | updatePath(path); 13 | return "foo"; 14 | }; 15 | 16 | return [path, navigate]; 17 | }; 18 | 19 | it("renders nothing", () => { 20 | const { container, unmount } = render(); 21 | expect(container.childNodes.length).toBe(0); 22 | unmount(); 23 | }); 24 | 25 | it("results in change of current location", () => { 26 | const { unmount } = render(); 27 | 28 | expect(location.pathname).toBe("/users"); 29 | unmount(); 30 | }); 31 | 32 | it("supports `base` routers with relative path", () => { 33 | const { unmount } = render( 34 | 35 | 36 | 37 | ); 38 | 39 | expect(location.pathname).toBe("/app/nested"); 40 | unmount(); 41 | }); 42 | 43 | it("supports `base` routers with absolute path", () => { 44 | const { unmount } = render( 45 | 46 | 47 | 48 | ); 49 | 50 | expect(location.pathname).toBe("/absolute"); 51 | unmount(); 52 | }); 53 | 54 | it("supports replace navigation", () => { 55 | const histBefore = history.length; 56 | 57 | const { unmount } = render(); 58 | 59 | expect(location.pathname).toBe("/users"); 60 | expect(history.length).toBe(histBefore); 61 | unmount(); 62 | }); 63 | 64 | it("supports history state", () => { 65 | const testState = { hello: "world" }; 66 | const { unmount } = render(); 67 | 68 | expect(location.pathname).toBe("/users"); 69 | expect(history.state).toStrictEqual(testState); 70 | unmount(); 71 | }); 72 | 73 | it("useLayoutEffect should return nothing", () => { 74 | const { unmount } = render( 75 | // @ts-expect-error 76 | 77 | 78 | 79 | ); 80 | 81 | expect(location.pathname).toBe("/users"); 82 | unmount(); 83 | }); 84 | -------------------------------------------------------------------------------- /packages/wouter/test/route.test-d.tsx: -------------------------------------------------------------------------------- 1 | import { it, describe, expectTypeOf, assertType } from "vitest"; 2 | import { Route } from "wouter"; 3 | import { ComponentProps } from "react"; 4 | import * as React from "react"; 5 | 6 | describe("`path` prop", () => { 7 | it("is optional", () => { 8 | assertType(); 9 | }); 10 | 11 | it("should be a string or RegExp", () => { 12 | let a: ComponentProps["path"]; 13 | expectTypeOf(a).toMatchTypeOf(); 14 | }); 15 | }); 16 | 17 | it("accepts the optional boolean `nest` prop", () => { 18 | assertType(); 19 | assertType(); 20 | 21 | // @ts-expect-error - should be boolean 22 | assertType(); 23 | }); 24 | 25 | it("renders a component provided in the `component` prop", () => { 26 | const Header = () =>
; 27 | const Profile = () => null; 28 | 29 | ; 30 | ; 31 | 32 | // @ts-expect-error must be a component, not JSX 33 | } />; 34 | }); 35 | 36 | it("accepts class components in the `component` prop", () => { 37 | class A extends React.Component<{ params: {} }> { 38 | render() { 39 | return
; 40 | } 41 | } 42 | 43 | ; 44 | }); 45 | 46 | it("accepts children", () => { 47 | 48 |
49 | ; 50 | 51 | 52 | This is a mixed content 53 | ; 54 | 55 | 56 | <> 57 |
58 | 59 | ; 60 | }); 61 | 62 | it("supports functions as children", () => { 63 | 64 | {(params) => { 65 | expectTypeOf(params).toMatchTypeOf<{}>(); 66 | return
; 67 | }} 68 | ; 69 | 70 | {({ id }) => `User id: ${id}`}; 71 | 72 | 73 | {({ age }: { age: string }) => `User age: ${age}`} 74 | ; 75 | 76 | // @ts-expect-error function should return valid JSX 77 | {() => {}}; 78 | 79 | // prettier-ignore 80 | // @ts-expect-error you can't use JSX together with render function 81 | {() =>
}Link; 82 | }); 83 | 84 | describe("parameter inference", () => { 85 | it("can infer type of params from the path given", () => { 86 | 87 | {({ first, second, third }) => { 88 | expectTypeOf(first).toEqualTypeOf(); 89 | return
{`${first}, ${second}, ${third}`}
; 90 | }} 91 |
; 92 | 93 | 94 | {/* @ts-expect-error - `age` param is not present in the pattern */} 95 | {({ name, age }) => { 96 | return
{`Hello, ${name}`}
; 97 | }} 98 |
; 99 | }); 100 | 101 | it("extract wildcard params into `wild` property", () => { 102 | 103 | {({ wild }) => { 104 | expectTypeOf(wild).toEqualTypeOf(); 105 | return
The path is {wild}
; 106 | }} 107 |
; 108 | }); 109 | 110 | it("allows to customize type of params via generic parameter", () => { 111 | path="/users/:name/:age"> 112 | {(params) => { 113 | expectTypeOf(params.lastName).toEqualTypeOf(); 114 | return
This really is undefined {params.lastName}
; 115 | }} 116 | ; 117 | }); 118 | 119 | it("can't infer the type when the path isn't known at compile time", () => { 120 | 121 | {(params) => { 122 | // @ts-expect-error 123 | params.section; 124 | return
; 125 | }} 126 | ; 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /packages/wouter/test/route.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect, afterEach } from "vitest"; 2 | import { render, act, cleanup } from "@testing-library/react"; 3 | 4 | import { Router, Route } from "wouter"; 5 | import { memoryLocation } from "wouter/memory-location"; 6 | import { ReactElement } from "react"; 7 | 8 | // Clean up after each test to avoid DOM pollution 9 | afterEach(cleanup); 10 | 11 | const testRouteRender = (initialPath: string, jsx: ReactElement) => { 12 | return render( 13 | {jsx} 14 | ); 15 | }; 16 | 17 | it("always renders its content when `path` is empty", () => { 18 | const { container } = testRouteRender( 19 | "/nothing", 20 | 21 |

Hello!

22 |
23 | ); 24 | 25 | const heading = container.querySelector("h1"); 26 | expect(heading).toBeInTheDocument(); 27 | expect(heading).toHaveTextContent("Hello!"); 28 | }); 29 | 30 | it("accepts plain children", () => { 31 | const { container } = testRouteRender( 32 | "/foo", 33 | 34 |

Hello!

35 |
36 | ); 37 | 38 | const heading = container.querySelector("h1"); 39 | expect(heading).toBeInTheDocument(); 40 | expect(heading).toHaveTextContent("Hello!"); 41 | }); 42 | 43 | it("works with render props", () => { 44 | const { container } = testRouteRender( 45 | "/foo", 46 | {() =>

Hello!

}
47 | ); 48 | 49 | const heading = container.querySelector("h1"); 50 | expect(heading).toBeInTheDocument(); 51 | expect(heading).toHaveTextContent("Hello!"); 52 | }); 53 | 54 | it("passes a match param object to the render function", () => { 55 | const { container } = testRouteRender( 56 | "/users/alex", 57 | {(params) =>

{params.name}

}
58 | ); 59 | 60 | const heading = container.querySelector("h1"); 61 | expect(heading).toBeInTheDocument(); 62 | expect(heading).toHaveTextContent("alex"); 63 | }); 64 | 65 | it("renders nothing when there is not match", () => { 66 | const { container } = testRouteRender( 67 | "/bar", 68 | 69 |
Hi!
70 |
71 | ); 72 | 73 | expect(container.querySelector("div")).not.toBeInTheDocument(); 74 | }); 75 | 76 | it("supports `component` prop similar to React-Router", () => { 77 | const Users = () =>

All users

; 78 | 79 | const { container } = testRouteRender( 80 | "/foo", 81 | 82 | ); 83 | 84 | const heading = container.querySelector("h2"); 85 | expect(heading).toBeInTheDocument(); 86 | expect(heading).toHaveTextContent("All users"); 87 | }); 88 | 89 | it("supports `base` routers with relative path", () => { 90 | const { container, unmount } = render( 91 | 92 | 93 |

Nested

94 |
95 | 96 |

Absolute

97 |
98 |
99 | ); 100 | 101 | act(() => history.replaceState(null, "", "/app/nested")); 102 | 103 | expect(container.children).toHaveLength(1); 104 | expect(container.firstChild).toHaveProperty("tagName", "H1"); 105 | 106 | unmount(); 107 | }); 108 | 109 | it("supports `path` prop with regex", () => { 110 | const { container } = testRouteRender( 111 | "/foo", 112 | 113 |

Hello!

114 |
115 | ); 116 | 117 | const heading = container.querySelector("h1"); 118 | expect(heading).toBeInTheDocument(); 119 | expect(heading).toHaveTextContent("Hello!"); 120 | }); 121 | 122 | it("supports regex path named params", () => { 123 | const { container } = testRouteRender( 124 | "/users/alex", 125 | [a-z]+)/}> 126 | {(params) =>

{params.name}

} 127 |
128 | ); 129 | 130 | const heading = container.querySelector("h1"); 131 | expect(heading).toBeInTheDocument(); 132 | expect(heading).toHaveTextContent("alex"); 133 | }); 134 | 135 | it("supports regex path anonymous params", () => { 136 | const { container } = testRouteRender( 137 | "/users/alex", 138 | 139 | {(params) =>

{params[0]}

} 140 |
141 | ); 142 | 143 | const heading = container.querySelector("h1"); 144 | expect(heading).toBeInTheDocument(); 145 | expect(heading).toHaveTextContent("alex"); 146 | }); 147 | 148 | it("rejects when a path does not match the regex", () => { 149 | const { container } = testRouteRender( 150 | "/users/1234", 151 | [a-z]+)/}> 152 | {(params) =>

{params.name}

} 153 |
154 | ); 155 | 156 | expect(container.querySelector("h1")).not.toBeInTheDocument(); 157 | }); 158 | -------------------------------------------------------------------------------- /packages/wouter/test/router.test-d.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | import { it, expectTypeOf } from "vitest"; 3 | import { 4 | Router, 5 | Route, 6 | BaseLocationHook, 7 | useRouter, 8 | Parser, 9 | Path, 10 | } from "wouter"; 11 | 12 | it("should have at least one child", () => { 13 | // @ts-expect-error 14 | ; 15 | }); 16 | 17 | it("accepts valid elements as children", () => { 18 | const Header = ({ title }: { title: string }) =>

{title}

; 19 | 20 | 21 | 22 | Hello! 23 | ; 24 | 25 | 26 | Hello, we have
and some {1337} numbers here. 27 | ; 28 | 29 | 30 | <>Fragments! 31 | ; 32 | 33 | 34 | {/* @ts-expect-error should be a valid element */} 35 | {() =>
} 36 | ; 37 | }); 38 | 39 | it("can be customized with router properties passed as props", () => { 40 | // @ts-expect-error 41 | ; 42 | 43 | const useFakeLocation: BaseLocationHook = () => ["/foo", () => {}]; 44 | this is a valid router; 45 | 46 | let fn: ComponentProps["hook"]; 47 | expectTypeOf(fn).exclude().toBeFunction(); 48 | 49 | Hello World!; 50 | 51 | SSR; 52 | 53 | 54 | Custom 55 | ; 56 | }); 57 | 58 | it("accepts `hrefs` function for transforming href strings", () => { 59 | const router = useRouter(); 60 | expectTypeOf(router.hrefs).toBeFunction(); 61 | 62 | href + "1"}>0; 63 | 64 | { 66 | expectTypeOf(router).toEqualTypeOf(); 67 | return href + router.base; 68 | }} 69 | > 70 | routers as a second argument 71 | ; 72 | }); 73 | 74 | it("accepts `parser` function for generating regular expressions", () => { 75 | const parser: Parser = (path: Path, loose?: boolean) => { 76 | return { 77 | pattern: new RegExp(`^${path}${loose === true ? "(?=$|[/])" : "[/]$"}`), 78 | keys: [], 79 | }; 80 | }; 81 | 82 | this is a valid router; 83 | }); 84 | 85 | it("does not accept other props", () => { 86 | const router = useRouter(); 87 | 88 | // @ts-expect-error `parent` prop isn't defined 89 | Parent router; 90 | }); 91 | -------------------------------------------------------------------------------- /packages/wouter/test/router.test.tsx: -------------------------------------------------------------------------------- 1 | import { memo, ReactElement, cloneElement, ComponentProps } from "react"; 2 | import { renderHook, render } from "@testing-library/react"; 3 | import { it, expect, describe } from "vitest"; 4 | import { 5 | Router, 6 | DefaultParams, 7 | useRouter, 8 | Parser, 9 | BaseLocationHook, 10 | } from "wouter"; 11 | 12 | it("creates a router object on demand", () => { 13 | const { result } = renderHook(() => useRouter()); 14 | expect(result.current).toBeInstanceOf(Object); 15 | }); 16 | 17 | it("creates a router object only once", () => { 18 | const { result, rerender } = renderHook(() => useRouter()); 19 | const router = result.current; 20 | 21 | rerender(); 22 | expect(result.current).toBe(router); 23 | }); 24 | 25 | it("does not create new router when rerenders", () => { 26 | const { result, rerender } = renderHook(() => useRouter(), { 27 | wrapper: (props) => {props.children}, 28 | }); 29 | const router = result.current; 30 | 31 | rerender(); 32 | expect(result.current).toBe(router); 33 | }); 34 | 35 | it("alters the current router with `parser` and `hook` options", () => { 36 | const newParser: Parser = () => ({ pattern: /(.*)/, keys: [] }); 37 | const hook: BaseLocationHook = () => ["/foo", () => {}]; 38 | 39 | const { result } = renderHook(() => useRouter(), { 40 | wrapper: (props) => ( 41 | 42 | {props.children} 43 | 44 | ), 45 | }); 46 | const router = result.current; 47 | 48 | expect(router).toBeInstanceOf(Object); 49 | expect(router.parser).toBe(newParser); 50 | expect(router.hook).toBe(hook); 51 | }); 52 | 53 | it("accepts `ssrPath` and `ssrSearch` params", () => { 54 | const { result } = renderHook(() => useRouter(), { 55 | wrapper: (props) => ( 56 | 57 | {props.children} 58 | 59 | ), 60 | }); 61 | 62 | expect(result.current.ssrPath).toBe("/users"); 63 | expect(result.current.ssrSearch).toBe("a=b&c=d"); 64 | }); 65 | 66 | it("can extract `ssrSearch` from `ssrPath` after the '?' symbol", () => { 67 | let ssrPath: string | undefined = "/no-search"; 68 | let ssrSearch: string | undefined = undefined; 69 | 70 | const { result, rerender } = renderHook(() => useRouter(), { 71 | wrapper: (props) => ( 72 | 73 | {props.children} 74 | 75 | ), 76 | }); 77 | 78 | expect(result.current.ssrPath).toBe("/no-search"); 79 | expect(result.current.ssrSearch).toBe(undefined); 80 | 81 | ssrPath = "/with-search?a=b&c=d"; 82 | rerender(); 83 | 84 | expect(result.current.ssrPath).toBe("/with-search"); 85 | expect(result.current.ssrSearch).toBe("a=b&c=d"); 86 | 87 | ssrSearch = "x=y&z=w"; 88 | rerender(); 89 | expect(result.current.ssrSearch).toBe("a=b&c=d"); 90 | }); 91 | 92 | it("shares one router instance between components", () => { 93 | const routers: any[] = []; 94 | 95 | const RouterGetter = ({ index }: { index: number }) => { 96 | const router = useRouter(); 97 | routers[index] = router; 98 | return
; 99 | }; 100 | 101 | render( 102 | <> 103 | 104 | 105 | 106 | 107 | 108 | ); 109 | 110 | const uniqRouters = [...new Set(routers)]; 111 | expect(uniqRouters.length).toBe(1); 112 | }); 113 | 114 | describe("`base` prop", () => { 115 | it("is an empty string by default", () => { 116 | const { result } = renderHook(() => useRouter()); 117 | expect(result.current.base).toBe(""); 118 | }); 119 | 120 | it("can be customized via the `base` prop", () => { 121 | const { result } = renderHook(() => useRouter(), { 122 | wrapper: (props) => {props.children}, 123 | }); 124 | expect(result.current.base).toBe("/foo"); 125 | }); 126 | 127 | it("appends provided path to the parent router's base", () => { 128 | const { result } = renderHook(() => useRouter(), { 129 | wrapper: (props) => ( 130 | 131 | 132 | {props.children} 133 | 134 | 135 | ), 136 | }); 137 | expect(result.current.base).toBe("/baz/foo/bar"); 138 | }); 139 | }); 140 | 141 | describe("`hook` prop", () => { 142 | it("when provided, the router isn't inherited from the parent", () => { 143 | const customHook: BaseLocationHook = () => ["/foo", () => {}]; 144 | const newParser: Parser = () => ({ pattern: /(.*)/, keys: [] }); 145 | 146 | const { 147 | result: { current: router }, 148 | } = renderHook(() => useRouter(), { 149 | wrapper: (props) => ( 150 | 151 | 152 | {props.children} 153 | 154 | 155 | ), 156 | }); 157 | 158 | expect(router.hook).toBe(customHook); 159 | expect(router.parser).not.toBe(newParser); 160 | expect(router.base).toBe("/bar"); 161 | }); 162 | }); 163 | 164 | describe("`hrefs` prop", () => { 165 | it("sets the router's `hrefs` property", () => { 166 | const formatter = () => "noop"; 167 | 168 | const { 169 | result: { current: router }, 170 | } = renderHook(() => useRouter(), { 171 | wrapper: (props) => {props.children}, 172 | }); 173 | 174 | expect(router.hrefs).toBe(formatter); 175 | }); 176 | 177 | it("can infer `hrefs` from the `hook`", () => { 178 | const hookHrefs = () => "noop"; 179 | const hook = (): [string, (v: string) => void] => { 180 | return ["/foo", () => {}]; 181 | }; 182 | 183 | hook.hrefs = hookHrefs; 184 | 185 | let hrefsRouterOption: ((href: string) => string) | undefined; 186 | 187 | const { rerender, result } = renderHook(() => useRouter(), { 188 | wrapper: (props) => ( 189 | 190 | {props.children} 191 | 192 | ), 193 | }); 194 | 195 | expect(result.current.hrefs).toBe(hookHrefs); 196 | 197 | // `hrefs` passed directly to the router should take precedence 198 | hrefsRouterOption = (href) => "custom formatter"; 199 | rerender(); 200 | 201 | expect(result.current.hrefs).toBe(hrefsRouterOption); 202 | }); 203 | }); 204 | 205 | it("updates the context when settings are changed", () => { 206 | const state: { renders: number } & Partial> = { 207 | renders: 0, 208 | }; 209 | 210 | const Memoized = memo((props) => { 211 | const router = useRouter(); 212 | state.renders++; 213 | 214 | state.hook = router.hook; 215 | state.base = router.base; 216 | 217 | return <>; 218 | }); 219 | 220 | const { rerender } = render( 221 | 222 | 223 | 224 | ); 225 | 226 | expect(state.renders).toEqual(1); 227 | expect(state.base).toBe("/app"); 228 | 229 | rerender( 230 | 231 | 232 | 233 | ); 234 | expect(state.renders).toEqual(1); // nothing changed 235 | 236 | // should re-render the hook 237 | const newHook: BaseLocationHook = () => ["/location", () => {}]; 238 | rerender( 239 | 240 | 241 | 242 | ); 243 | expect(state.renders).toEqual(2); 244 | expect(state.base).toEqual("/app"); 245 | expect(state.hook).toEqual(newHook); 246 | 247 | // should update the context when the base changes as well 248 | rerender( 249 | 250 | 251 | 252 | ); 253 | expect(state.renders).toEqual(3); 254 | expect(state.base).toEqual(""); 255 | expect(state.hook).toEqual(newHook); 256 | 257 | // the last check that the router context is stable during re-renders 258 | rerender( 259 | 260 | 261 | 262 | ); 263 | expect(state.renders).toEqual(3); // nothing changed 264 | }); 265 | -------------------------------------------------------------------------------- /packages/wouter/test/ssr.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | 5 | import { it, expect, describe } from "vitest"; 6 | import { renderToStaticMarkup } from "react-dom/server"; 7 | import { 8 | Route, 9 | Router, 10 | useRoute, 11 | Link, 12 | Redirect, 13 | useSearch, 14 | useLocation, 15 | SsrContext, 16 | } from "wouter"; 17 | 18 | describe("server-side rendering", () => { 19 | it("works via `ssrPath` prop", () => { 20 | const App = () => ( 21 | 22 | foo 23 | bar 24 | {(params) => params.id} 25 | should not be rendered 26 | 27 | ); 28 | 29 | const rendered = renderToStaticMarkup(); 30 | expect(rendered).toBe("foobarbaz"); 31 | }); 32 | 33 | it("supports hook-based routes", () => { 34 | const HookRoute = () => { 35 | const [match, params] = useRoute("/pages/:name"); 36 | return <>{match ? `Welcome to ${params.name}!` : "Not Found!"}; 37 | }; 38 | 39 | const App = () => ( 40 | 41 | 42 | 43 | ); 44 | 45 | const rendered = renderToStaticMarkup(); 46 | expect(rendered).toBe("Welcome to intro!"); 47 | }); 48 | 49 | it("renders valid and accessible link elements", () => { 50 | const App = () => ( 51 | 52 | 53 | Mark 54 | 55 | 56 | ); 57 | 58 | const rendered = renderToStaticMarkup(); 59 | expect(rendered).toBe(`Mark`); 60 | }); 61 | 62 | it("renders redirects however they have effect only on a client-side", () => { 63 | const App = () => ( 64 | 65 | 66 | 67 | 68 | 69 | You won't see that in SSR page 70 | 71 | ); 72 | 73 | const rendered = renderToStaticMarkup(); 74 | expect(rendered).toBe(""); 75 | }); 76 | 77 | it("update ssr context", () => { 78 | const context: SsrContext = {}; 79 | const App = () => ( 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | 87 | renderToStaticMarkup(); 88 | expect(context.redirectTo).toBe("/foo"); 89 | }); 90 | 91 | describe("rendering with given search string", () => { 92 | it("is empty when not specified", () => { 93 | const PrintSearch = () => <>{useSearch()}; 94 | 95 | const rendered = renderToStaticMarkup( 96 | 97 | 98 | 99 | ); 100 | 101 | expect(rendered).toBe(""); 102 | }); 103 | 104 | it("allows to override search string", () => { 105 | const App = () => { 106 | const search = useSearch(); 107 | const [location] = useLocation(); 108 | 109 | return ( 110 | <> 111 | {location} filter by {search} 112 | 113 | ); 114 | }; 115 | 116 | const rendered = renderToStaticMarkup( 117 | 118 | 119 | 120 | ); 121 | 122 | expect(rendered).toBe("/catalog filter by sort=created_at"); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /packages/wouter/test/switch.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect, afterEach } from "vitest"; 2 | 3 | import { Router, Route, Switch } from "wouter"; 4 | import { memoryLocation } from "wouter/memory-location"; 5 | 6 | import { render, act, cleanup } from "@testing-library/react"; 7 | import { PropsWithChildren, ReactElement } from "react"; 8 | 9 | // Clean up after each test to avoid DOM pollution 10 | afterEach(cleanup); 11 | 12 | const raf = () => new Promise((resolve) => requestAnimationFrame(resolve)); 13 | 14 | const testRouteRender = (initialPath: string, jsx: ReactElement) => { 15 | return render( 16 | {jsx} 17 | ); 18 | }; 19 | 20 | it("works well when nothing is provided", () => { 21 | const { container } = testRouteRender("/users/12", {null}); 22 | // When Switch has no matching children, it renders null, so container should be empty 23 | expect(container).toBeEmptyDOMElement(); 24 | }); 25 | 26 | it("always renders no more than 1 matched children", () => { 27 | const { container } = testRouteRender( 28 | "/users/12", 29 | 30 | 31 |

32 | 33 | 34 |

35 | 36 | 37 |

38 | 39 | 40 | ); 41 | 42 | // Should only render the h2 that matches /users/:id 43 | expect(container.querySelectorAll("h1, h2, h3")).toHaveLength(1); 44 | expect(container.querySelector("h2")).toBeInTheDocument(); 45 | expect(container.querySelector("h1")).not.toBeInTheDocument(); 46 | expect(container.querySelector("h3")).not.toBeInTheDocument(); 47 | }); 48 | 49 | it("ignores mixed children", () => { 50 | const { container } = testRouteRender( 51 | "/users", 52 | 53 | Here is aroute 54 | route 55 | 56 | ); 57 | 58 | // Should only render the route content, ignoring text nodes 59 | expect(container).toHaveTextContent("route"); 60 | // The text "Here is a" and "route" outside the Route should be ignored 61 | expect(container.textContent).toBe("route"); 62 | }); 63 | 64 | it("ignores falsy children", () => { 65 | const { container } = testRouteRender( 66 | "/users", 67 | 68 | {""} 69 | {false} 70 | {null} 71 | {undefined} 72 | route 73 | 74 | ); 75 | 76 | // Should only render the route content 77 | expect(container).toHaveTextContent("route"); 78 | expect(container.textContent).toBe("route"); 79 | }); 80 | 81 | it("matches regular components as well", () => { 82 | const Dummy = (props: PropsWithChildren<{ path: string }>) => ( 83 | <>{props.children} 84 | ); 85 | 86 | const { container } = testRouteRender( 87 | "/", 88 | 89 | Component 90 | Bold 91 | 92 | ); 93 | 94 | // Should render the Dummy component content 95 | expect(container).toHaveTextContent("Component"); 96 | expect(container.querySelector("b")).not.toBeInTheDocument(); 97 | }); 98 | 99 | it("allows to specify which routes to render via `location` prop", () => { 100 | const { container } = testRouteRender( 101 | "/something-different", 102 | 103 | route 104 | 105 | ); 106 | 107 | // Should render based on the location prop, not the actual path 108 | expect(container).toHaveTextContent("route"); 109 | }); 110 | 111 | it("always ensures the consistency of inner routes rendering", async () => { 112 | history.replaceState(null, "", "/foo/bar"); 113 | 114 | const { unmount } = render( 115 | 116 | 117 | {(params) => { 118 | if (!params) 119 | throw new Error("Render prop is called with falsy params!"); 120 | return null; 121 | }} 122 | 123 | 124 | ); 125 | 126 | await act(async () => { 127 | await raf(); 128 | history.pushState(null, "", "/"); 129 | }); 130 | 131 | unmount(); 132 | }); 133 | 134 | it("supports catch-all routes with wildcard segments", async () => { 135 | const { container } = testRouteRender( 136 | "/something-different", 137 | 138 | 139 |

140 | 141 | 142 |

143 | 144 | 145 | ); 146 | 147 | // Should match the catch-all route 148 | expect(container.querySelectorAll("h1, h2")).toHaveLength(1); 149 | expect(container.querySelector("h2")).toBeInTheDocument(); 150 | expect(container.querySelector("h1")).not.toBeInTheDocument(); 151 | }); 152 | 153 | it("uses a route without a path prop as a fallback", async () => { 154 | const { container } = testRouteRender( 155 | "/something-different", 156 | 157 | 158 |

159 | 160 | 161 |

162 | 163 | 164 | ); 165 | 166 | // Should match the fallback route (no path) 167 | expect(container.querySelectorAll("h1, h2")).toHaveLength(1); 168 | expect(container.querySelector("h2")).toBeInTheDocument(); 169 | expect(container.querySelector("h1")).not.toBeInTheDocument(); 170 | }); 171 | 172 | it("correctly handles arrays as children", async () => { 173 | const { container } = testRouteRender( 174 | "/in-array-3", 175 | 176 | {[1, 2, 3].map((i) => { 177 | const H = `h${i}` as keyof JSX.IntrinsicElements; 178 | return ( 179 | 180 | 181 | 182 | ); 183 | })} 184 | 185 |

186 | 187 | 188 | ); 189 | 190 | // Should match the third route (/in-array-3) 191 | expect(container.querySelectorAll("h1, h2, h3, h4")).toHaveLength(1); 192 | expect(container.querySelector("h3")).toBeInTheDocument(); 193 | expect(container.querySelector("h1")).not.toBeInTheDocument(); 194 | expect(container.querySelector("h2")).not.toBeInTheDocument(); 195 | expect(container.querySelector("h4")).not.toBeInTheDocument(); 196 | }); 197 | 198 | it("correctly handles fragments as children", async () => { 199 | const { container } = testRouteRender( 200 | "/in-fragment-2", 201 | 202 | <> 203 | {[1, 2, 3].map((i) => { 204 | const H = `h${i}` as keyof JSX.IntrinsicElements; 205 | return ( 206 | 207 | 208 | 209 | ); 210 | })} 211 | 212 | 213 |

214 | 215 | 216 | ); 217 | 218 | // Should match the second route (/in-fragment-2) 219 | expect(container.querySelectorAll("h1, h2, h3, h4")).toHaveLength(1); 220 | expect(container.querySelector("h2")).toBeInTheDocument(); 221 | expect(container.querySelector("h1")).not.toBeInTheDocument(); 222 | expect(container.querySelector("h3")).not.toBeInTheDocument(); 223 | expect(container.querySelector("h4")).not.toBeInTheDocument(); 224 | }); 225 | -------------------------------------------------------------------------------- /packages/wouter/test/test-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Executes a callback and returns a promise that resolve when `hashchange` event is fired. 3 | * Rejects after `throwAfter` milliseconds. 4 | */ 5 | export const waitForHashChangeEvent = async ( 6 | cb: () => void, 7 | throwAfter = 1000 8 | ) => 9 | new Promise((resolve, reject) => { 10 | let timeout: ReturnType; 11 | 12 | const onChange = () => { 13 | resolve(); 14 | clearTimeout(timeout); 15 | window.removeEventListener("hashchange", onChange); 16 | }; 17 | 18 | window.addEventListener("hashchange", onChange); 19 | cb(); 20 | 21 | timeout = setTimeout(() => { 22 | reject(new Error("Timed out: `hashchange` event did not fire!")); 23 | window.removeEventListener("hashchange", onChange); 24 | }, throwAfter); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/wouter/test/use-browser-location.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, assertType, describe, expectTypeOf } from "vitest"; 2 | import { 3 | useBrowserLocation, 4 | useSearch, 5 | useHistoryState, 6 | } from "wouter/use-browser-location"; 7 | 8 | describe("useBrowserLocation", () => { 9 | it("should return string, function tuple", () => { 10 | const [loc, navigate] = useBrowserLocation(); 11 | 12 | assertType(loc); 13 | assertType(navigate); 14 | }); 15 | 16 | it("should return `navigate` function with `path` and `options` parameters", () => { 17 | const [, navigate] = useBrowserLocation(); 18 | 19 | assertType(navigate("/path")); 20 | assertType(navigate("")); 21 | 22 | // @ts-expect-error 23 | assertType(navigate()); 24 | // @ts-expect-error 25 | assertType(navigate(null)); 26 | 27 | assertType(navigate("/path", { replace: true })); 28 | // @ts-expect-error 29 | assertType(navigate("/path", { unknownOption: true })); 30 | }); 31 | 32 | it("should support `ssrPath` option", () => { 33 | assertType(useBrowserLocation({ ssrPath: "/something" })); 34 | // @ts-expect-error 35 | assertType(useBrowserLocation({ foo: "bar" })); 36 | }); 37 | }); 38 | 39 | describe("useSearch", () => { 40 | it("should return string", () => { 41 | type Search = ReturnType; 42 | const search = useSearch(); 43 | 44 | assertType(search); 45 | const allowedSearchValues: Search[] = ["", "?leading", "no-?-sign"]; 46 | }); 47 | }); 48 | 49 | describe("useHistoryState", () => { 50 | it("should support generics", () => { 51 | type TestCase = { hello: string }; 52 | const state = useHistoryState(); 53 | 54 | expectTypeOf(state).toEqualTypeOf(); 55 | }); 56 | 57 | it("should fallback to any when type doesn't provided", () => { 58 | const state = useHistoryState(); 59 | 60 | expectTypeOf(state).toEqualTypeOf(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/wouter/test/use-browser-location.test.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { it, expect, describe, beforeEach } from "vitest"; 3 | import { renderHook, act, waitFor } from "@testing-library/react"; 4 | import { 5 | useBrowserLocation, 6 | navigate, 7 | useSearch, 8 | useHistoryState, 9 | } from "wouter/use-browser-location"; 10 | 11 | it("returns a pair [value, update]", () => { 12 | const { result, unmount } = renderHook(() => useBrowserLocation()); 13 | const [value, update] = result.current; 14 | 15 | expect(typeof value).toBe("string"); 16 | expect(typeof update).toBe("function"); 17 | unmount(); 18 | }); 19 | 20 | describe("`value` first argument", () => { 21 | beforeEach(() => history.replaceState(null, "", "/")); 22 | 23 | it("reflects the current pathname", () => { 24 | const { result, unmount } = renderHook(() => useBrowserLocation()); 25 | expect(result.current[0]).toBe("/"); 26 | unmount(); 27 | }); 28 | 29 | it("reacts to `pushState` / `replaceState`", () => { 30 | const { result, unmount } = renderHook(() => useBrowserLocation()); 31 | 32 | act(() => history.pushState(null, "", "/foo")); 33 | expect(result.current[0]).toBe("/foo"); 34 | 35 | act(() => history.replaceState(null, "", "/bar")); 36 | expect(result.current[0]).toBe("/bar"); 37 | unmount(); 38 | }); 39 | 40 | it("supports history.back() navigation", async () => { 41 | const { result, unmount } = renderHook(() => useBrowserLocation()); 42 | 43 | act(() => history.pushState(null, "", "/foo")); 44 | await waitFor(() => expect(result.current[0]).toBe("/foo")); 45 | 46 | act(() => { 47 | history.back(); 48 | }); 49 | 50 | // Workaround for happy-dom: manually dispatch popstate event 51 | // happy-dom doesn't fully implement history.back() popstate events 52 | act(() => { 53 | const popstateEvent = new PopStateEvent("popstate", { 54 | state: history.state, 55 | }); 56 | window.dispatchEvent(popstateEvent); 57 | }); 58 | 59 | await waitFor(() => expect(result.current[0]).toBe("/"), { timeout: 1000 }); 60 | unmount(); 61 | }); 62 | 63 | it("supports history state", () => { 64 | const { result, unmount } = renderHook(() => useBrowserLocation()); 65 | const { result: state, unmount: unmountState } = renderHook(() => 66 | useHistoryState() 67 | ); 68 | 69 | const navigate = result.current[1]; 70 | 71 | act(() => navigate("/path", { state: { hello: "world" } })); 72 | 73 | expect(state.current).toStrictEqual({ hello: "world" }); 74 | 75 | unmount(); 76 | unmountState(); 77 | }); 78 | 79 | it("uses fail-safe escaping", () => { 80 | const { result } = renderHook(() => useBrowserLocation()); 81 | const navigate = result.current[1]; 82 | 83 | act(() => navigate("/%not-valid")); 84 | expect(result.current[0]).toBe("/%not-valid"); 85 | 86 | act(() => navigate("/99%")); 87 | expect(result.current[0]).toBe("/99%"); 88 | }); 89 | }); 90 | 91 | describe("`useSearch` hook", () => { 92 | beforeEach(() => history.replaceState(null, "", "/")); 93 | 94 | it("allows to get current search string", () => { 95 | const { result: searchResult } = renderHook(() => useSearch()); 96 | act(() => navigate("/foo?hello=world&whats=up")); 97 | 98 | expect(searchResult.current).toBe("?hello=world&whats=up"); 99 | }); 100 | 101 | it("returns empty string when there is no search string", () => { 102 | const { result: searchResult } = renderHook(() => useSearch()); 103 | 104 | expect(searchResult.current).toBe(""); 105 | 106 | act(() => navigate("/foo")); 107 | expect(searchResult.current).toBe(""); 108 | 109 | act(() => navigate("/foo? ")); 110 | expect(searchResult.current).toBe(""); 111 | }); 112 | 113 | it("does not re-render when only pathname is changed", () => { 114 | // count how many times each hook is rendered 115 | const locationRenders = { current: 0 }; 116 | const searchRenders = { current: 0 }; 117 | 118 | // count number of rerenders for each hook 119 | renderHook(() => { 120 | useEffect(() => { 121 | locationRenders.current += 1; 122 | }); 123 | return useBrowserLocation(); 124 | }); 125 | 126 | renderHook(() => { 127 | useEffect(() => { 128 | searchRenders.current += 1; 129 | }); 130 | return useSearch(); 131 | }); 132 | 133 | expect(locationRenders.current).toBe(1); 134 | expect(searchRenders.current).toBe(1); 135 | 136 | act(() => navigate("/foo")); 137 | 138 | expect(locationRenders.current).toBe(2); 139 | expect(searchRenders.current).toBe(1); 140 | 141 | act(() => navigate("/foo?bar")); 142 | expect(locationRenders.current).toBe(2); // no re-render 143 | expect(searchRenders.current).toBe(2); 144 | 145 | act(() => navigate("/baz?bar")); 146 | expect(locationRenders.current).toBe(3); // no re-render 147 | expect(searchRenders.current).toBe(2); 148 | }); 149 | }); 150 | 151 | describe("`update` second parameter", () => { 152 | it("rerenders the component", () => { 153 | const { result, unmount } = renderHook(() => useBrowserLocation()); 154 | const update = result.current[1]; 155 | 156 | act(() => update("/about")); 157 | expect(result.current[0]).toBe("/about"); 158 | unmount(); 159 | }); 160 | 161 | it("changes the current location", () => { 162 | const { result, unmount } = renderHook(() => useBrowserLocation()); 163 | const update = result.current[1]; 164 | 165 | act(() => update("/about")); 166 | expect(location.pathname).toBe("/about"); 167 | unmount(); 168 | }); 169 | 170 | it("saves a new entry in the History object", () => { 171 | const { result, unmount } = renderHook(() => useBrowserLocation()); 172 | const update = result.current[1]; 173 | 174 | const histBefore = history.length; 175 | act(() => update("/about")); 176 | 177 | expect(history.length).toBe(histBefore + 1); 178 | unmount(); 179 | }); 180 | 181 | it("replaces last entry with a new entry in the History object", () => { 182 | const { result, unmount } = renderHook(() => useBrowserLocation()); 183 | const update = result.current[1]; 184 | 185 | const histBefore = history.length; 186 | act(() => update("/foo", { replace: true })); 187 | 188 | expect(history.length).toBe(histBefore); 189 | expect(location.pathname).toBe("/foo"); 190 | unmount(); 191 | }); 192 | 193 | it("stays the same reference between re-renders (function ref)", () => { 194 | const { result, rerender, unmount } = renderHook(() => 195 | useBrowserLocation() 196 | ); 197 | 198 | const updateWas = result.current[1]; 199 | rerender(); 200 | const updateNow = result.current[1]; 201 | 202 | expect(updateWas).toBe(updateNow); 203 | unmount(); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /packages/wouter/test/use-hash-location.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, assertType, describe, expectTypeOf } from "vitest"; 2 | import { useHashLocation, navigate } from "wouter/use-hash-location"; 3 | import { BaseLocationHook } from "wouter"; 4 | 5 | it("is a location hook", () => { 6 | expectTypeOf(useHashLocation).toMatchTypeOf(); 7 | expectTypeOf(useHashLocation()).toMatchTypeOf<[string, Function]>(); 8 | }); 9 | 10 | it("accepts a `ssrPath` path option", () => { 11 | useHashLocation({ ssrPath: "/foo" }); 12 | useHashLocation({ ssrPath: "" }); 13 | 14 | // @ts-expect-error 15 | useHashLocation({ base: 123 }); 16 | // @ts-expect-error 17 | useHashLocation({ unknown: "/base" }); 18 | }); 19 | 20 | describe("`navigate` function", () => { 21 | it("accepts an arbitrary `state` option", () => { 22 | navigate("/object", { state: { foo: "bar" } }); 23 | navigate("/symbol", { state: Symbol("foo") }); 24 | navigate("/string", { state: "foo" }); 25 | navigate("/undef", { state: undefined }); 26 | }); 27 | 28 | it("returns nothing", () => { 29 | assertType(navigate("/foo")); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/wouter/test/use-hash-location.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, expect, beforeEach, vi } from "vitest"; 2 | import { renderHook, render } from "@testing-library/react"; 3 | import { renderToStaticMarkup } from "react-dom/server"; 4 | 5 | import { Router, Route, useLocation, Link } from "wouter"; 6 | import { useHashLocation } from "wouter/use-hash-location"; 7 | 8 | import { waitForHashChangeEvent } from "./test-utils"; 9 | import { ReactNode, useSyncExternalStore } from "react"; 10 | 11 | beforeEach(() => { 12 | history.replaceState(null, "", "/"); 13 | location.hash = ""; 14 | }); 15 | 16 | it("gets current location from `location.hash`", () => { 17 | location.hash = "/app/users"; 18 | const { result } = renderHook(() => useHashLocation()); 19 | const [path] = result.current; 20 | 21 | expect(path).toBe("/app/users"); 22 | }); 23 | 24 | it("isn't sensitive to leading slash", () => { 25 | location.hash = "app/users"; 26 | const { result } = renderHook(() => useHashLocation()); 27 | const [path] = result.current; 28 | 29 | expect(path).toBe("/app/users"); 30 | }); 31 | 32 | it("rerenders when hash changes", async () => { 33 | const { result } = renderHook(() => useHashLocation()); 34 | 35 | expect(result.current[0]).toBe("/"); 36 | 37 | await waitForHashChangeEvent(() => { 38 | location.hash = "/app/users"; 39 | }); 40 | 41 | expect(result.current[0]).toBe("/app/users"); 42 | }); 43 | 44 | it("changes current hash when navigation is performed", () => { 45 | const { result } = renderHook(() => useHashLocation()); 46 | const [, navigate] = result.current; 47 | 48 | navigate("/app/users"); 49 | expect(location.hash).toBe("#/app/users"); 50 | }); 51 | 52 | it("should not rerender when pathname changes", () => { 53 | let renderCount = 0; 54 | location.hash = "/app"; 55 | 56 | const { result } = renderHook(() => { 57 | useHashLocation(); 58 | return ++renderCount; 59 | }); 60 | 61 | expect(result.current).toBe(1); 62 | history.replaceState(null, "", "/foo?bar#/app"); 63 | 64 | expect(result.current).toBe(1); 65 | }); 66 | 67 | it("does not change anything besides the hash when doesn't contain ? symbol", () => { 68 | history.replaceState(null, "", "/foo?bar#/app"); 69 | 70 | const { result } = renderHook(() => useHashLocation()); 71 | const [, navigate] = result.current; 72 | 73 | navigate("/settings/general"); 74 | expect(location.pathname).toBe("/foo"); 75 | expect(location.search).toBe("?bar"); 76 | }); 77 | 78 | it("changes search and hash when contains ? symbol", () => { 79 | history.replaceState(null, "", "/foo?bar#/app"); 80 | 81 | const { result } = renderHook(() => useHashLocation()); 82 | const [, navigate] = result.current; 83 | 84 | navigate("/abc?def"); 85 | expect(location.pathname).toBe("/foo"); 86 | expect(location.search).toBe("?def"); 87 | expect(location.hash).toBe("#/abc"); 88 | }); 89 | 90 | it("creates a new history entry when navigating", () => { 91 | const { result } = renderHook(() => useHashLocation()); 92 | const [, navigate] = result.current; 93 | 94 | const initialLength = history.length; 95 | navigate("/about"); 96 | expect(history.length).toBe(initialLength + 1); 97 | }); 98 | 99 | it("supports `state` option when navigating", () => { 100 | const { result } = renderHook(() => useHashLocation()); 101 | const [, navigate] = result.current; 102 | 103 | navigate("/app/users", { state: { hello: "world" } }); 104 | expect(history.state).toStrictEqual({ hello: "world" }); 105 | }); 106 | 107 | it("never changes reference to `navigate` between rerenders", () => { 108 | const { result, rerender } = renderHook(() => useHashLocation()); 109 | 110 | const updateWas = result.current[1]; 111 | rerender(); 112 | 113 | expect(result.current[1]).toBe(updateWas); 114 | }); 115 | 116 | it("uses `ssrPath` when rendered on the server", () => { 117 | const App = () => { 118 | const [path] = useHashLocation({ ssrPath: "/hello-from-server" }); 119 | return <>{path}; 120 | }; 121 | 122 | const rendered = renderToStaticMarkup(); 123 | expect(rendered).toBe("/hello-from-server"); 124 | }); 125 | 126 | it("is not sensitive to leading / or # when navigating", async () => { 127 | const { result } = renderHook(() => useHashLocation()); 128 | const [, navigate] = result.current; 129 | 130 | await waitForHashChangeEvent(() => navigate("look-ma-no-slashes")); 131 | expect(location.hash).toBe("#/look-ma-no-slashes"); 132 | expect(result.current[0]).toBe("/look-ma-no-slashes"); 133 | 134 | await waitForHashChangeEvent(() => navigate("#/look-ma-no-hashes")); 135 | expect(location.hash).toBe("#/look-ma-no-hashes"); 136 | expect(result.current[0]).toBe("/look-ma-no-hashes"); 137 | }); 138 | 139 | it("works even if `hashchange` listeners are called asynchronously ", async () => { 140 | const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 141 | 142 | // we want `hashchange` to stop invoking listeners before it reaches the 143 | // outer . this is done to simulate a situation when 144 | // `hashchange` listeners are called asynchrounously 145 | // 146 | // per https://github.com/whatwg/html/issues/1792 147 | // some browsers fire `hashchange` and `popstate` asynchronously, so 148 | // when the event listeners are called, a microtask can be scheduled in between, 149 | // and we may end up with a teared state. inner components subscribe to `hashchange` 150 | // earlier so they may render even though their parent route does not match 151 | const subscribeToHashchange = (cb: () => void) => { 152 | const fn = (event: HashChangeEvent) => { 153 | event.stopImmediatePropagation(); 154 | cb(); 155 | }; 156 | 157 | window.addEventListener("hashchange", fn); 158 | return () => window.removeEventListener("hashchange", fn); 159 | }; 160 | 161 | const InterceptAndStopHashchange = ({ 162 | children, 163 | }: { 164 | children: ReactNode; 165 | }) => { 166 | useSyncExternalStore(subscribeToHashchange, () => true); 167 | return <>{children}; 168 | }; 169 | 170 | const paths: string[] = []; 171 | 172 | // keep track of rendered paths 173 | const LogLocations = () => { 174 | paths.push(useLocation()[0]); 175 | return null; 176 | }; 177 | 178 | location.hash = "#/a"; 179 | 180 | const { unmount } = render( 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | ); 189 | 190 | location.hash = "#/b"; 191 | 192 | // wait for all `hashchange` listeners to be called 193 | // can't use `waitForHashChangeEvent` here because it gets cancelled along the way 194 | await nextTick(); 195 | 196 | // paths should not contain "b", because the outer route 197 | // does not match, so inner component should not be rendered 198 | expect(paths).toEqual(["/a"]); 199 | unmount(); 200 | }); 201 | 202 | it("defines a custom way of rendering link hrefs", () => { 203 | const { getByTestId } = render( 204 | 205 | 206 | 207 | ); 208 | 209 | expect(getByTestId("link")).toHaveAttribute("href", "#/app"); 210 | }); 211 | 212 | it("interacts properly with the history stack", () => { 213 | const { result } = renderHook(() => useHashLocation()); 214 | const [, navigate] = result.current; 215 | 216 | // case: replace, expect no history stack changes 217 | const historyStackCountBeforeReplace = history.length; 218 | navigate("/app/users", { replace: true }); 219 | expect(location.hash).toBe("#/app/users"); 220 | expect(history.length).toBe(historyStackCountBeforeReplace); 221 | 222 | // case: push, expect history stack increase by 1 223 | const historyStackCountBeforePush = history.length; 224 | navigate("/app/users/2"); 225 | expect(location.hash).toBe("#/app/users/2"); 226 | expect(history.length).toBe(historyStackCountBeforePush + 1); 227 | }); 228 | 229 | it("dispatches hashchange event when options.replace is true", () => { 230 | const { result } = renderHook(() => useHashLocation()); 231 | const [, navigate] = result.current; 232 | 233 | const hashChangeFn = vi.fn(); 234 | addEventListener("hashchange", hashChangeFn); 235 | 236 | navigate("/foo/bar", { replace: true }); 237 | expect(hashChangeFn).toBeCalled(); 238 | 239 | removeEventListener("hashchange", hashChangeFn); 240 | }); 241 | 242 | it("detects history change when navigate with options.replace is called", async () => { 243 | const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 244 | 245 | const { result } = renderHook(() => useHashLocation()); 246 | const [, navigate] = result.current; 247 | 248 | const newPath = "/foo/bar/baz"; 249 | navigate(newPath, { replace: true }); 250 | await nextTick(); 251 | expect(result.current[0]).toBe(newPath); 252 | }); 253 | 254 | it("uses string URLs as hashchange event payload", () => { 255 | const { result } = renderHook(() => useHashLocation()); 256 | const [, navigate] = result.current; 257 | 258 | const relativeOldPath = "/foo"; 259 | const relativeNewPath = "/foo/bar/#hash"; 260 | const baseURL = "http://localhost:3000/#"; 261 | 262 | navigate(relativeOldPath); 263 | 264 | let changeEvent = new HashChangeEvent("hashchange"); 265 | const hashChangeFn = (event: HashChangeEvent) => { 266 | changeEvent = event; 267 | }; 268 | 269 | addEventListener("hashchange", hashChangeFn); 270 | 271 | navigate(relativeNewPath); 272 | expect(changeEvent?.newURL).toBe(`${baseURL}${relativeNewPath}`); 273 | expect(changeEvent?.oldURL).toBe(`${baseURL}${relativeOldPath}`); 274 | 275 | removeEventListener("hashchange", hashChangeFn); 276 | }); 277 | -------------------------------------------------------------------------------- /packages/wouter/test/use-location.test.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, ReactNode } from "react"; 2 | import { it, expect, describe, beforeEach } from "vitest"; 3 | import { renderHook, act } from "@testing-library/react"; 4 | import { Router, useLocation } from "wouter"; 5 | import { 6 | useBrowserLocation, 7 | navigate as browserNavigation, 8 | } from "wouter/use-browser-location"; 9 | 10 | import { 11 | useHashLocation, 12 | navigate as hashNavigation, 13 | } from "wouter/use-hash-location"; 14 | import { waitForHashChangeEvent } from "./test-utils"; 15 | 16 | import { memoryLocation } from "wouter/memory-location"; 17 | 18 | function createContainer( 19 | options: Omit, "children"> = {} 20 | ) { 21 | return ({ children }: { children: ReactNode }) => ( 22 | {children} 23 | ); 24 | } 25 | 26 | const memory = memoryLocation({ record: true }); 27 | 28 | describe.each([ 29 | { 30 | name: "useBrowserLocation", 31 | hook: useBrowserLocation, 32 | location: () => location.pathname, 33 | navigate: browserNavigation, 34 | act, 35 | clear: () => { 36 | history.replaceState(null, "", "/"); 37 | }, 38 | }, 39 | { 40 | name: "useHashLocation", 41 | hook: useHashLocation, 42 | location: () => "/" + location.hash.replace(/^#?\/?/, ""), 43 | navigate: hashNavigation, 44 | act: (cb: () => void) => waitForHashChangeEvent(() => act(cb)), 45 | clear: () => { 46 | location.hash = ""; 47 | history.replaceState(null, "", "/"); 48 | }, 49 | }, 50 | { 51 | name: "memoryLocation", 52 | hook: memory.hook, 53 | location: () => memory.history.at(-1) ?? "", 54 | navigate: memory.navigate, 55 | act, 56 | clear: () => { 57 | memory.reset(); 58 | }, 59 | }, 60 | ])("$name", (stub) => { 61 | beforeEach(() => stub.clear()); 62 | 63 | it("returns a pair [value, update]", () => { 64 | const { result, unmount } = renderHook(() => useLocation(), { 65 | wrapper: createContainer({ hook: stub.hook }), 66 | }); 67 | const [value, update] = result.current; 68 | 69 | expect(typeof value).toBe("string"); 70 | expect(typeof update).toBe("function"); 71 | unmount(); 72 | }); 73 | 74 | describe("`value` first argument", () => { 75 | it("returns `/` when URL contains only a basepath", async () => { 76 | const { result, unmount } = renderHook(() => useLocation(), { 77 | wrapper: createContainer({ 78 | base: "/app", 79 | hook: stub.hook, 80 | }), 81 | }); 82 | 83 | await stub.act(() => stub.navigate("/app")); 84 | expect(result.current[0]).toBe("/"); 85 | unmount(); 86 | }); 87 | 88 | it("basepath should be case-insensitive", async () => { 89 | const { result, unmount } = renderHook(() => useLocation(), { 90 | wrapper: createContainer({ 91 | base: "/MyApp", 92 | hook: stub.hook, 93 | }), 94 | }); 95 | 96 | await stub.act(() => stub.navigate("/myAPP/users/JohnDoe")); 97 | expect(result.current[0]).toBe("/users/JohnDoe"); 98 | unmount(); 99 | }); 100 | 101 | it("returns an absolute path in case of unmatched base path", async () => { 102 | const { result, unmount } = renderHook(() => useLocation(), { 103 | wrapper: createContainer({ 104 | base: "/MyApp", 105 | hook: stub.hook, 106 | }), 107 | }); 108 | 109 | await stub.act(() => stub.navigate("/MyOtherApp/users/JohnDoe")); 110 | expect(result.current[0]).toBe("~/MyOtherApp/users/JohnDoe"); 111 | unmount(); 112 | }); 113 | 114 | it("automatically unescapes specials characters", async () => { 115 | const { result, unmount } = renderHook(() => useLocation(), { 116 | wrapper: createContainer({ 117 | hook: stub.hook, 118 | }), 119 | }); 120 | 121 | await stub.act(() => 122 | stub.navigate("/пользователи/показать все/101/げんきです") 123 | ); 124 | expect(result.current[0]).toBe( 125 | "/пользователи/показать все/101/げんきです" 126 | ); 127 | 128 | await stub.act(() => stub.navigate("/%D1%88%D0%B5%D0%BB%D0%BB%D1%8B")); 129 | expect(result.current[0]).toBe("/шеллы"); 130 | unmount(); 131 | }); 132 | 133 | it("can accept unescaped basepaths", async () => { 134 | const { result, unmount } = renderHook(() => useLocation(), { 135 | wrapper: createContainer({ 136 | base: "/hello мир", // basepath is not escaped 137 | hook: stub.hook, 138 | }), 139 | }); 140 | 141 | await stub.act(() => stub.navigate("/hello%20%D0%BC%D0%B8%D1%80/rel")); 142 | expect(result.current[0]).toBe("/rel"); 143 | 144 | unmount(); 145 | }); 146 | 147 | it("can accept unescaped basepaths", async () => { 148 | const { result, unmount } = renderHook(() => useLocation(), { 149 | wrapper: createContainer({ 150 | base: "/hello%20%D0%BC%D0%B8%D1%80", // basepath is already escaped 151 | hook: stub.hook, 152 | }), 153 | }); 154 | 155 | await stub.act(() => stub.navigate("/hello мир/rel")); 156 | expect(result.current[0]).toBe("/rel"); 157 | 158 | unmount(); 159 | }); 160 | }); 161 | 162 | describe("`update` second parameter", () => { 163 | it("rerenders the component", async () => { 164 | const { result, unmount } = renderHook(() => useLocation(), { 165 | wrapper: createContainer({ hook: stub.hook }), 166 | }); 167 | const update = result.current[1]; 168 | 169 | await stub.act(() => update("/about")); 170 | expect(stub.location()).toBe("/about"); 171 | unmount(); 172 | }); 173 | 174 | it("stays the same reference between re-renders (function ref)", () => { 175 | const { result, rerender, unmount } = renderHook(() => useLocation(), { 176 | wrapper: createContainer({ hook: stub.hook }), 177 | }); 178 | 179 | const updateWas = result.current[1]; 180 | rerender(); 181 | const updateNow = result.current[1]; 182 | 183 | expect(updateWas).toBe(updateNow); 184 | unmount(); 185 | }); 186 | 187 | it("supports a basepath", async () => { 188 | const { result, unmount } = renderHook(() => useLocation(), { 189 | wrapper: createContainer({ 190 | base: "/app", 191 | hook: stub.hook, 192 | }), 193 | }); 194 | 195 | const update = result.current[1]; 196 | 197 | await stub.act(() => update("/dashboard")); 198 | expect(stub.location()).toBe("/app/dashboard"); 199 | unmount(); 200 | }); 201 | 202 | it("ignores the '/' basepath", async () => { 203 | const { result, unmount } = renderHook(() => useLocation(), { 204 | wrapper: createContainer({ 205 | base: "/", 206 | hook: stub.hook, 207 | }), 208 | }); 209 | 210 | const update = result.current[1]; 211 | 212 | await stub.act(() => update("/dashboard")); 213 | expect(stub.location()).toBe("/dashboard"); 214 | unmount(); 215 | }); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /packages/wouter/test/use-params.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, expectTypeOf } from "vitest"; 2 | import { useParams } from "wouter"; 3 | 4 | it("does not accept any arguments", () => { 5 | expectTypeOf().parameters.toEqualTypeOf<[]>(); 6 | }); 7 | 8 | it("returns an object with arbitrary parameters", () => { 9 | const params = useParams(); 10 | 11 | expectTypeOf(params).toBeObject(); 12 | expectTypeOf(params.any).toEqualTypeOf(); 13 | expectTypeOf(params[0]).toEqualTypeOf(); 14 | }); 15 | 16 | it("can infer the type of parameters from the route path", () => { 17 | const params = useParams<"/app/users/:name?/:id">(); 18 | 19 | expectTypeOf(params).toMatchTypeOf<{ 20 | 0?: string; 21 | 1?: string; 22 | id: string; 23 | name?: string; 24 | }>(); 25 | }); 26 | 27 | it("can accept the custom type of parameters as a generic argument", () => { 28 | const params = useParams<{ foo: number; bar?: string }>(); 29 | 30 | expectTypeOf(params).toMatchTypeOf<{ 31 | foo: number; 32 | bar?: string; 33 | }>(); 34 | 35 | //@ts-expect-error 36 | return params.notFound; 37 | }); 38 | -------------------------------------------------------------------------------- /packages/wouter/test/use-params.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "@testing-library/react"; 2 | import { it, expect } from "vitest"; 3 | import { useParams, Router, Route, Switch } from "wouter"; 4 | 5 | import { memoryLocation } from "wouter/memory-location"; 6 | 7 | it("returns empty object when used outside of ", () => { 8 | const { result } = renderHook(() => useParams()); 9 | expect(result.current).toEqual({}); 10 | }); 11 | 12 | it("contains a * parameter when used inside an empty ", () => { 13 | const { result } = renderHook(() => useParams(), { 14 | wrapper: (props) => ( 15 | 16 | {props.children} 17 | 18 | ), 19 | }); 20 | 21 | expect(result.current).toEqual({ 22 | 0: "app-2/goods/tees", 23 | "*": "app-2/goods/tees", 24 | }); 25 | }); 26 | 27 | it("returns an empty object when there are no params", () => { 28 | const { result } = renderHook(() => useParams(), { 29 | wrapper: (props) => {props.children}, 30 | }); 31 | 32 | expect(result.current).toEqual({}); 33 | }); 34 | 35 | it("contains parameters from the closest parent ", () => { 36 | const { result } = renderHook(() => useParams(), { 37 | wrapper: (props) => ( 38 | 39 | 40 | {props.children} 41 | 42 | 43 | ), 44 | }); 45 | 46 | expect(result.current).toMatchObject({ 47 | 0: "1", 48 | 1: "maria", 49 | id: "1", 50 | name: "maria", 51 | }); 52 | }); 53 | 54 | it("inherits parameters from parent nested routes", () => { 55 | const { result } = renderHook(() => useParams(), { 56 | wrapper: (props) => ( 57 | 63 | 64 | 65 | {props.children} 66 | 67 | 68 | 69 | ), 70 | }); 71 | 72 | expect(result.current).toMatchObject({ 73 | name: "john", // name gets overriden 74 | "*": "summary-1", 75 | page: "dash", 76 | id: "10", 77 | // number params are overriden 78 | 0: "john", 79 | 1: "summary-1", 80 | }); 81 | }); 82 | 83 | it("rerenders with parameters change", () => { 84 | const { hook, navigate } = memoryLocation({ path: "/" }); 85 | 86 | const { result } = renderHook(() => useParams(), { 87 | wrapper: (props) => ( 88 | 89 | {props.children} 90 | 91 | ), 92 | }); 93 | 94 | expect(result.current).toBeNull(); 95 | 96 | act(() => navigate("/posts/all")); 97 | expect(result.current).toMatchObject({ 98 | 0: "posts", 99 | 1: "all", 100 | a: "posts", 101 | b: "all", 102 | }); 103 | 104 | act(() => navigate("/posts/latest")); 105 | expect(result.current).toMatchObject({ 106 | 0: "posts", 107 | 1: "latest", 108 | a: "posts", 109 | b: "latest", 110 | }); 111 | }); 112 | 113 | it("extracts parameters of the nested route", () => { 114 | const { hook } = memoryLocation({ 115 | path: "/v2/eth/txns", 116 | static: true, 117 | }); 118 | 119 | const { result } = renderHook(() => useParams(), { 120 | wrapper: (props) => ( 121 | 122 | 123 | {props.children} 124 | 125 | 126 | ), 127 | }); 128 | 129 | expect(result.current).toEqual({ 130 | 0: "v2", 131 | 1: "eth", 132 | version: "v2", 133 | chain: "eth", 134 | }); 135 | }); 136 | 137 | it("keeps the object ref the same if params haven't changed", () => { 138 | const { hook } = memoryLocation({ path: "/foo/bar" }); 139 | 140 | const { result, rerender } = renderHook(() => useParams(), { 141 | wrapper: (props) => ( 142 | 143 | {props.children} 144 | 145 | ), 146 | }); 147 | 148 | const firstRenderedParams = result.current; 149 | rerender(); 150 | expect(result.current).toBe(firstRenderedParams); 151 | }); 152 | 153 | it("works when the route becomes matching", () => { 154 | const { hook, navigate } = memoryLocation({ path: "/" }); 155 | 156 | const { result } = renderHook(() => useParams(), { 157 | wrapper: (props) => ( 158 | 159 | {props.children} 160 | 161 | ), 162 | }); 163 | 164 | act(() => navigate("/123")); 165 | expect(result.current).toMatchObject({ id: "123" }); 166 | }); 167 | 168 | it("makes the params an empty object, when there are no path params", () => { 169 | const { hook, navigate } = memoryLocation({ path: "/" }); 170 | 171 | const { result } = renderHook(() => useParams(), { 172 | wrapper: (props) => ( 173 | 174 | 175 | {props.children} 176 | {props.children} 177 | 178 | 179 | ), 180 | }); 181 | 182 | act(() => navigate("/posts/all")); 183 | act(() => navigate("/posts")); 184 | expect(Object.keys(result.current).length).toBe(0); 185 | }); 186 | 187 | it("removes route parameters when no longer present in the path", () => { 188 | // Start at a route that has both 'category' and 'page' in its params 189 | const { hook, navigate } = memoryLocation({ 190 | path: "/products/categories/apple/page/1", 191 | }); 192 | 193 | // Render useParams within two routes: one with /page/:page, one without 194 | const { result } = renderHook(() => useParams(), { 195 | wrapper: (props) => ( 196 | 197 | 198 | {props.children} 199 | 200 | {props.children} 201 | 202 | 203 | 204 | ), 205 | }); 206 | 207 | // Initial params should include 'category' and 'page' 208 | expect(result.current).toMatchObject({ 209 | 0: "apple", 210 | 1: "1", 211 | category: "apple", 212 | page: "1", 213 | }); 214 | 215 | // Navigate to a path that no longer contains the page param 216 | act(() => navigate("/products/categories/apple")); 217 | 218 | // The 'page' param should now be removed 219 | expect(result.current).toEqual({ 220 | 0: "apple", 221 | category: "apple", 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /packages/wouter/test/use-route.test-d.ts: -------------------------------------------------------------------------------- 1 | import { it, expectTypeOf, assertType } from "vitest"; 2 | import { useRoute } from "wouter"; 3 | 4 | it("should only accept strings", () => { 5 | // @ts-expect-error 6 | assertType(useRoute(Symbol())); 7 | // @ts-expect-error 8 | assertType(useRoute()); 9 | assertType(useRoute("/")); 10 | }); 11 | 12 | it('has a boolean "match" result as a first returned value', () => { 13 | const [match] = useRoute("/"); 14 | expectTypeOf(match).toEqualTypeOf(); 15 | }); 16 | 17 | it("returns null as parameters when there was no match", () => { 18 | const [match, params] = useRoute("/foo"); 19 | 20 | if (!match) { 21 | expectTypeOf(params).toEqualTypeOf(); 22 | } 23 | }); 24 | 25 | it("accepts the type of parameters as a generic argument", () => { 26 | const [match, params] = useRoute<{ id: string; name: string | undefined }>( 27 | "/app/users/:name?/:id" 28 | ); 29 | 30 | if (match) { 31 | expectTypeOf(params).toEqualTypeOf<{ 32 | id: string; 33 | name: string | undefined; 34 | }>(); 35 | } 36 | }); 37 | 38 | it("infers parameters from the route path", () => { 39 | const [, inferedParams] = useRoute("/app/users/:name?/:id/*?"); 40 | 41 | if (inferedParams) { 42 | expectTypeOf(inferedParams).toMatchTypeOf<{ 43 | 0?: string; 44 | 1?: string; 45 | 2?: string; 46 | name?: string; 47 | id: string; 48 | wildcard?: string; 49 | }>(); 50 | } 51 | }); 52 | -------------------------------------------------------------------------------- /packages/wouter/test/use-route.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react"; 2 | import { useRoute, Match, Router, RegexRouteParams } from "wouter"; 3 | import { it, expect } from "vitest"; 4 | import { memoryLocation } from "wouter/memory-location"; 5 | 6 | it("is case insensitive", () => { 7 | assertRoute("/Users", "/users", {}); 8 | assertRoute("/HomePage", "/Homepage", {}); 9 | assertRoute("/Users/:Name", "/users/alex", { 0: "alex", Name: "alex" }); 10 | }); 11 | 12 | it("supports required segments", () => { 13 | assertRoute("/:page", "/users", { 0: "users", page: "users" }); 14 | assertRoute("/:page", "/users/all", false); 15 | assertRoute("/:page", "/1", { 0: "1", page: "1" }); 16 | 17 | assertRoute("/home/:page/etc", "/home/users/etc", { 18 | 0: "users", 19 | page: "users", 20 | }); 21 | assertRoute("/home/:page/etc", "/home/etc", false); 22 | 23 | assertRoute( 24 | "/root/payments/:id/refunds/:refId", 25 | "/root/payments/1/refunds/2", 26 | [true, { 0: "1", 1: "2", id: "1", refId: "2" }] 27 | ); 28 | }); 29 | 30 | it("ignores the trailing slash", () => { 31 | assertRoute("/home", "/home/", {}); 32 | assertRoute("/home", "/home", {}); 33 | 34 | assertRoute("/home/", "/home/", {}); 35 | assertRoute("/home/", "/home", {}); 36 | 37 | assertRoute("/:page", "/users/", [true, { 0: "users", page: "users" }]); 38 | assertRoute("/catalog/:section?", "/catalog/", { 39 | 0: undefined, 40 | section: undefined, 41 | }); 42 | }); 43 | 44 | it("supports trailing wildcards", () => { 45 | assertRoute("/app/*", "/app/", { 0: "", "*": "" }); 46 | assertRoute("/app/*", "/app/dashboard/intro", { 47 | 0: "dashboard/intro", 48 | "*": "dashboard/intro", 49 | }); 50 | assertRoute("/app/*", "/app/charges/1", { 0: "charges/1", "*": "charges/1" }); 51 | }); 52 | 53 | it("supports wildcards in the middle of the pattern", () => { 54 | assertRoute("/app/*/settings", "/app/users/settings", { 55 | 0: "users", 56 | "*": "users", 57 | }); 58 | assertRoute("/app/*/settings", "/app/users/1/settings", { 59 | 0: "users/1", 60 | "*": "users/1", 61 | }); 62 | 63 | assertRoute("/*/payments/:id", "/home/payments/1", { 64 | 0: "home", 65 | 1: "1", 66 | "*": "home", 67 | id: "1", 68 | }); 69 | assertRoute("/*/payments/:id?", "/home/payments", { 70 | 0: "home", 71 | 1: undefined, 72 | "*": "home", 73 | id: undefined, 74 | }); 75 | }); 76 | 77 | it("uses a question mark to define optional segments", () => { 78 | assertRoute("/books/:genre/:title?", "/books/scifi", { 79 | 0: "scifi", 80 | 1: undefined, 81 | genre: "scifi", 82 | title: undefined, 83 | }); 84 | assertRoute("/books/:genre/:title?", "/books/scifi/dune", { 85 | 0: "scifi", 86 | 1: "dune", 87 | genre: "scifi", 88 | title: "dune", 89 | }); 90 | assertRoute("/books/:genre/:title?", "/books/scifi/dune/all", false); 91 | 92 | assertRoute("/app/:company?/blog/:post", "/app/apple/blog/mac", { 93 | 0: "apple", 94 | 1: "mac", 95 | company: "apple", 96 | post: "mac", 97 | }); 98 | 99 | assertRoute("/app/:company?/blog/:post", "/app/blog/mac", { 100 | 0: undefined, 101 | 1: "mac", 102 | company: undefined, 103 | post: "mac", 104 | }); 105 | }); 106 | 107 | it("supports optional wildcards", () => { 108 | assertRoute("/app/*?", "/app/blog/mac", { 0: "blog/mac", "*": "blog/mac" }); 109 | assertRoute("/app/*?", "/app", { 0: undefined, "*": undefined }); 110 | assertRoute("/app/*?/dashboard", "/app/v1/dashboard", { 0: "v1", "*": "v1" }); 111 | assertRoute("/app/*?/dashboard", "/app/dashboard", { 112 | 0: undefined, 113 | "*": undefined, 114 | }); 115 | assertRoute("/app/*?/users/:name", "/app/users/karen", { 116 | 0: undefined, 117 | 1: "karen", 118 | "*": undefined, 119 | name: "karen", 120 | }); 121 | }); 122 | 123 | it("supports other characters in segments", () => { 124 | assertRoute("/users/:name", "/users/1-alex", { 0: "1-alex", name: "1-alex" }); 125 | assertRoute("/staff/:name/:bio?", "/staff/John Doe 3", { 126 | 0: "John Doe 3", 127 | 1: undefined, 128 | name: "John Doe 3", 129 | bio: undefined, 130 | }); 131 | assertRoute("/staff/:name/:bio?", "/staff/John Doe 3/bio", { 132 | 0: "John Doe 3", 133 | 1: "bio", 134 | name: "John Doe 3", 135 | bio: "bio", 136 | }); 137 | 138 | assertRoute("/users/:name/bio", "/users/$102_Kathrine&/bio", { 139 | 0: "$102_Kathrine&", 140 | name: "$102_Kathrine&", 141 | }); 142 | }); 143 | 144 | it("ignores escaped slashes", () => { 145 | assertRoute("/:param/bar", "/foo%2Fbar/bar", { 146 | 0: "foo%2Fbar", 147 | param: "foo%2Fbar", 148 | }); 149 | assertRoute("/:param", "/foo%2Fbar%D1%81%D0%B0%D0%BD%D1%8F", { 150 | 0: "foo%2Fbarсаня", 151 | param: "foo%2Fbarсаня", 152 | }); 153 | }); 154 | 155 | it("supports regex patterns", () => { 156 | assertRoute(/[/]foo/, "/foo", {}); 157 | assertRoute(/[/]([a-z]+)/, "/bar", { 0: "bar" }); 158 | assertRoute(/[/]([a-z]+)/, "/123", false); 159 | assertRoute(/[/](?[a-z]+)/, "/bar", { 0: "bar", param: "bar" }); 160 | assertRoute(/[/](?[a-z]+)/, "/123", false); 161 | }); 162 | 163 | it("reacts to pattern updates", () => { 164 | const { result, rerender } = renderHook( 165 | ({ pattern }: { pattern: string }) => useRoute(pattern), 166 | { 167 | wrapper: (props) => ( 168 | 175 | ), 176 | initialProps: { pattern: "/" }, 177 | } 178 | ); 179 | 180 | expect(result.current).toStrictEqual([false, null]); 181 | 182 | rerender({ pattern: "/blog/:category/:post/:action" }); 183 | expect(result.current).toStrictEqual([ 184 | true, 185 | { 186 | 0: "products", 187 | 1: "40", 188 | 2: "read-all", 189 | category: "products", 190 | post: "40", 191 | action: "read-all", 192 | }, 193 | ]); 194 | 195 | rerender({ pattern: "/blog/products/:id?/read-all" }); 196 | expect(result.current).toStrictEqual([true, { 0: "40", id: "40" }]); 197 | 198 | rerender({ pattern: "/blog/products/:name" }); 199 | expect(result.current).toStrictEqual([false, null]); 200 | 201 | rerender({ pattern: "/blog/*" }); 202 | expect(result.current).toStrictEqual([ 203 | true, 204 | { 0: "products/40/read-all", "*": "products/40/read-all" }, 205 | ]); 206 | }); 207 | 208 | it("reacts to location updates", () => { 209 | const { hook, navigate } = memoryLocation(); 210 | 211 | const { result } = renderHook(() => useRoute("/cities/:city?"), { 212 | wrapper: (props) => , 213 | }); 214 | 215 | expect(result.current).toStrictEqual([false, null]); 216 | 217 | act(() => navigate("/cities/berlin")); 218 | expect(result.current).toStrictEqual([true, { 0: "berlin", city: "berlin" }]); 219 | 220 | act(() => navigate("/cities/Tokyo")); 221 | expect(result.current).toStrictEqual([true, { 0: "Tokyo", city: "Tokyo" }]); 222 | 223 | act(() => navigate("/about")); 224 | expect(result.current).toStrictEqual([false, null]); 225 | 226 | act(() => navigate("/cities")); 227 | expect(result.current).toStrictEqual([ 228 | true, 229 | { 0: undefined, city: undefined }, 230 | ]); 231 | }); 232 | 233 | /** 234 | * Assertion helper to test useRoute() return values. 235 | */ 236 | 237 | const assertRoute = ( 238 | pattern: string | RegExp, 239 | location: string, 240 | rhs: false | Match | Record 241 | ) => { 242 | const { result } = renderHook(() => useRoute(pattern), { 243 | wrapper: (props) => ( 244 | 248 | ), 249 | }); 250 | 251 | if (rhs === false) { 252 | expect(result.current).toStrictEqual([false, null]); 253 | } else if (Array.isArray(rhs)) { 254 | expect(result.current).toStrictEqual(rhs); 255 | } else { 256 | expect(result.current).toStrictEqual([true, rhs]); 257 | } 258 | }; 259 | -------------------------------------------------------------------------------- /packages/wouter/test/use-search-params.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react"; 2 | import { useSearchParams, Router } from "wouter"; 3 | import { navigate } from "wouter/use-browser-location"; 4 | import { it, expect, beforeEach } from "vitest"; 5 | 6 | beforeEach(() => history.replaceState(null, "", "/")); 7 | 8 | it("can return browser search params", () => { 9 | history.replaceState(null, "", "/users?active=true"); 10 | const { result } = renderHook(() => useSearchParams()); 11 | 12 | expect(result.current[0].get("active")).toBe("true"); 13 | }); 14 | 15 | it("can change browser search params", () => { 16 | history.replaceState(null, "", "/users?active=true"); 17 | const { result } = renderHook(() => useSearchParams()); 18 | 19 | expect(result.current[0].get("active")).toBe("true"); 20 | 21 | act(() => 22 | result.current[1]((prev) => { 23 | prev.set("active", "false"); 24 | return prev; 25 | }) 26 | ); 27 | 28 | expect(result.current[0].get("active")).toBe("false"); 29 | }); 30 | 31 | it("can be customized in the Router", () => { 32 | const customSearchHook = ({ customOption = "unused" }) => "none"; 33 | 34 | const { result } = renderHook(() => useSearchParams(), { 35 | wrapper: (props) => { 36 | return {props.children}; 37 | }, 38 | }); 39 | 40 | expect(Array.from(result.current[0].keys())).toEqual(["none"]); 41 | }); 42 | 43 | it("unescapes search string", () => { 44 | const { result: searchResult } = renderHook(() => useSearchParams()); 45 | 46 | expect(Array.from(searchResult.current[0].keys()).length).toBe(0); 47 | 48 | act(() => navigate("/?nonce=not Found&country=საქართველო")); 49 | expect(searchResult.current[0].get("nonce")).toBe("not Found"); 50 | expect(searchResult.current[0].get("country")).toBe("საქართველო"); 51 | 52 | // question marks 53 | act(() => navigate("/?вопрос=как дела?")); 54 | expect(searchResult.current[0].get("вопрос")).toBe("как дела?"); 55 | }); 56 | 57 | it("is safe against parameter injection", () => { 58 | history.replaceState(null, "", "/?search=foo%26parameter_injection%3Dbar"); 59 | const { result } = renderHook(() => useSearchParams()); 60 | 61 | expect(result.current[0].get("search")).toBe("foo¶meter_injection=bar"); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/wouter/test/use-search.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from "@testing-library/react"; 2 | import { useSearch, Router } from "wouter"; 3 | import { navigate } from "wouter/use-browser-location"; 4 | import { memoryLocation } from "wouter/memory-location"; 5 | import { it, expect, beforeEach } from "vitest"; 6 | 7 | beforeEach(() => history.replaceState(null, "", "/")); 8 | 9 | it("returns browser search string", () => { 10 | history.replaceState(null, "", "/users?active=true"); 11 | const { result } = renderHook(() => useSearch()); 12 | 13 | expect(result.current).toEqual("active=true"); 14 | }); 15 | 16 | it("can be customized in the Router", () => { 17 | const customSearchHook = ({ customOption = "unused" }) => "none"; 18 | 19 | const { result } = renderHook(() => useSearch(), { 20 | wrapper: (props) => { 21 | return {props.children}; 22 | }, 23 | }); 24 | 25 | expect(result.current).toEqual("none"); 26 | }); 27 | 28 | it("can be customized with memoryLocation", () => { 29 | const { searchHook } = memoryLocation({ path: "/foo?key=value" }); 30 | 31 | const { result } = renderHook(() => useSearch(), { 32 | wrapper: (props) => { 33 | return {props.children}; 34 | }, 35 | }); 36 | 37 | expect(result.current).toEqual("key=value"); 38 | }); 39 | 40 | it("can be customized with memoryLocation using search path parameter", () => { 41 | const { searchHook } = memoryLocation({ 42 | path: "/foo?key=value", 43 | searchPath: "foo=bar", 44 | }); 45 | 46 | const { result } = renderHook(() => useSearch(), { 47 | wrapper: (props) => { 48 | return {props.children}; 49 | }, 50 | }); 51 | 52 | expect(result.current).toEqual("key=value&foo=bar"); 53 | }); 54 | 55 | it("unescapes search string", () => { 56 | const { result: searchResult } = renderHook(() => useSearch()); 57 | 58 | expect(searchResult.current).toBe(""); 59 | 60 | act(() => navigate("/?nonce=not Found&country=საქართველო")); 61 | expect(searchResult.current).toBe("nonce=not Found&country=საქართველო"); 62 | 63 | // question marks 64 | act(() => navigate("/?вопрос=как дела?")); 65 | expect(searchResult.current).toBe("вопрос=как дела?"); 66 | }); 67 | 68 | it("is safe against parameter injection", () => { 69 | history.replaceState(null, "", "/?search=foo%26parameter_injection%3Dbar"); 70 | const { result } = renderHook(() => useSearch()); 71 | 72 | const searchParams = new URLSearchParams(result.current); 73 | const query = Object.fromEntries(searchParams.entries()); 74 | 75 | expect(query).toEqual({ search: "foo¶meter_injection=bar" }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/wouter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "moduleResolution": "node", 5 | "module": "es2020", 6 | "jsx": "react-jsx", 7 | "strict": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/wouter/types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Minimum TypeScript Version: 4.1 2 | 3 | // tslint:disable:no-unnecessary-generics 4 | 5 | import { 6 | AnchorHTMLAttributes, 7 | FunctionComponent, 8 | RefAttributes, 9 | ComponentType, 10 | ReactNode, 11 | ReactElement, 12 | MouseEventHandler, 13 | } from "react"; 14 | 15 | import { 16 | Path, 17 | PathPattern, 18 | BaseLocationHook, 19 | HookReturnValue, 20 | HookNavigationOptions, 21 | BaseSearchHook, 22 | } from "./location-hook.js"; 23 | import { 24 | BrowserLocationHook, 25 | BrowserSearchHook, 26 | } from "./use-browser-location.js"; 27 | 28 | import { Parser, RouterObject, RouterOptions } from "./router.js"; 29 | 30 | // these files only export types, so we can re-export them as-is 31 | // in TS 5.0 we'll be able to use `export type * from ...` 32 | export * from "./location-hook.js"; 33 | export * from "./router.js"; 34 | 35 | import { RouteParams } from "regexparam"; 36 | 37 | export type StringRouteParams = RouteParams & { 38 | [param: number]: string | undefined; 39 | }; 40 | export type RegexRouteParams = { [key: string | number]: string | undefined }; 41 | 42 | /** 43 | * Route patterns and parameters 44 | */ 45 | export interface DefaultParams { 46 | readonly [paramName: string | number]: string | undefined; 47 | } 48 | 49 | export type Params = T; 50 | 51 | export type MatchWithParams = [ 52 | true, 53 | Params 54 | ]; 55 | export type NoMatch = [false, null]; 56 | export type Match = 57 | | MatchWithParams 58 | | NoMatch; 59 | 60 | /* 61 | * Components: 62 | */ 63 | 64 | export interface RouteComponentProps { 65 | params: T; 66 | } 67 | 68 | export interface RouteProps< 69 | T extends DefaultParams | undefined = undefined, 70 | RoutePath extends PathPattern = PathPattern 71 | > { 72 | children?: 73 | | (( 74 | params: T extends DefaultParams 75 | ? T 76 | : RoutePath extends string 77 | ? StringRouteParams 78 | : RegexRouteParams 79 | ) => ReactNode) 80 | | ReactNode; 81 | path?: RoutePath; 82 | component?: ComponentType< 83 | RouteComponentProps< 84 | T extends DefaultParams 85 | ? T 86 | : RoutePath extends string 87 | ? StringRouteParams 88 | : RegexRouteParams 89 | > 90 | >; 91 | nest?: boolean; 92 | } 93 | 94 | export function Route< 95 | T extends DefaultParams | undefined = undefined, 96 | RoutePath extends PathPattern = PathPattern 97 | >(props: RouteProps): ReturnType; 98 | 99 | /* 100 | * Components: & 101 | */ 102 | 103 | export type NavigationalProps< 104 | H extends BaseLocationHook = BrowserLocationHook 105 | > = ({ to: Path; href?: never } | { href: Path; to?: never }) & 106 | HookNavigationOptions; 107 | 108 | export type RedirectProps = 109 | NavigationalProps & { 110 | children?: never; 111 | }; 112 | 113 | export function Redirect( 114 | props: RedirectProps, 115 | context?: any 116 | ): null; 117 | 118 | type AsChildProps = 119 | | ({ asChild?: false } & DefaultElementProps) 120 | | ({ asChild: true } & ComponentProps); 121 | 122 | type HTMLLinkAttributes = Omit< 123 | AnchorHTMLAttributes, 124 | "className" 125 | > & { 126 | className?: string | undefined | ((isActive: boolean) => string | undefined); 127 | }; 128 | 129 | export type LinkProps = 130 | NavigationalProps & 131 | AsChildProps< 132 | { children: ReactElement; onClick?: MouseEventHandler }, 133 | HTMLLinkAttributes & RefAttributes 134 | >; 135 | 136 | export function Link( 137 | props: LinkProps, 138 | context?: any 139 | ): ReturnType; 140 | 141 | /* 142 | * Components: 143 | */ 144 | 145 | export interface SwitchProps { 146 | location?: string; 147 | children: ReactNode; 148 | } 149 | export const Switch: FunctionComponent; 150 | 151 | /* 152 | * Components: 153 | */ 154 | 155 | export type RouterProps = RouterOptions & { 156 | children: ReactNode; 157 | }; 158 | 159 | export const Router: FunctionComponent; 160 | 161 | /* 162 | * Hooks 163 | */ 164 | 165 | export function useRouter(): RouterObject; 166 | 167 | export function useRoute< 168 | T extends DefaultParams | undefined = undefined, 169 | RoutePath extends PathPattern = PathPattern 170 | >( 171 | pattern: RoutePath 172 | ): Match< 173 | T extends DefaultParams 174 | ? T 175 | : RoutePath extends string 176 | ? StringRouteParams 177 | : RegexRouteParams 178 | >; 179 | 180 | export function useLocation< 181 | H extends BaseLocationHook = BrowserLocationHook 182 | >(): HookReturnValue; 183 | 184 | export function useSearch< 185 | H extends BaseSearchHook = BrowserSearchHook 186 | >(): ReturnType; 187 | 188 | export type URLSearchParamsInit = ConstructorParameters< 189 | typeof URLSearchParams 190 | >[0]; 191 | export type SetSearchParams = ( 192 | nextInit: 193 | | URLSearchParamsInit 194 | | ((prev: URLSearchParams) => URLSearchParamsInit), 195 | options?: { replace?: boolean; state?: any } 196 | ) => void; 197 | 198 | export function useSearchParams(): [URLSearchParams, SetSearchParams]; 199 | 200 | export function useParams(): T extends string 201 | ? StringRouteParams 202 | : T extends undefined 203 | ? DefaultParams 204 | : T; 205 | 206 | /* 207 | * Helpers 208 | */ 209 | 210 | export function matchRoute< 211 | T extends DefaultParams | undefined = undefined, 212 | RoutePath extends PathPattern = PathPattern 213 | >( 214 | parser: Parser, 215 | pattern: RoutePath, 216 | path: string, 217 | loose?: boolean 218 | ): Match< 219 | T extends DefaultParams 220 | ? T 221 | : RoutePath extends string 222 | ? StringRouteParams 223 | : RegexRouteParams 224 | >; 225 | 226 | // tslint:enable:no-unnecessary-generics 227 | -------------------------------------------------------------------------------- /packages/wouter/types/location-hook.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Foundation: useLocation and paths 3 | */ 4 | 5 | export type Path = string; 6 | 7 | export type PathPattern = string | RegExp; 8 | 9 | export type SearchString = string; 10 | 11 | // the base useLocation hook type. Any custom hook (including the 12 | // default one) should inherit from it. 13 | export type BaseLocationHook = ( 14 | ...args: any[] 15 | ) => [Path, (path: Path, ...args: any[]) => any]; 16 | 17 | export type BaseSearchHook = (...args: any[]) => SearchString; 18 | 19 | /* 20 | * Utility types that operate on hook 21 | */ 22 | 23 | // Returns the type of the location tuple of the given hook. 24 | export type HookReturnValue = ReturnType; 25 | 26 | // Utility type that allows us to handle cases like `any` and `never` 27 | type EmptyInterfaceWhenAnyOrNever = 0 extends 1 & T 28 | ? {} 29 | : [T] extends [never] 30 | ? {} 31 | : T; 32 | 33 | // Returns the type of the navigation options that hook's push function accepts. 34 | export type HookNavigationOptions = 35 | EmptyInterfaceWhenAnyOrNever< 36 | NonNullable[1]>[1]> // get's the second argument of a tuple returned by the hook 37 | >; 38 | -------------------------------------------------------------------------------- /packages/wouter/types/memory-location.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseLocationHook, 3 | BaseSearchHook, 4 | Path, 5 | SearchString, 6 | } from "./location-hook.js"; 7 | 8 | type Navigate = ( 9 | to: Path, 10 | options?: { replace?: boolean; state?: S } 11 | ) => void; 12 | 13 | type HookReturnValue = { 14 | hook: BaseLocationHook; 15 | searchHook: BaseSearchHook; 16 | navigate: Navigate; 17 | }; 18 | type StubHistory = { history: Path[]; reset: () => void }; 19 | 20 | export function memoryLocation(options?: { 21 | path?: Path; 22 | searchPath?: SearchString; 23 | static?: boolean; 24 | record?: false; 25 | }): HookReturnValue; 26 | export function memoryLocation(options?: { 27 | path?: Path; 28 | searchPath?: SearchString; 29 | static?: boolean; 30 | record: true; 31 | }): HookReturnValue & StubHistory; 32 | -------------------------------------------------------------------------------- /packages/wouter/types/router.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Path, 3 | SearchString, 4 | BaseLocationHook, 5 | BaseSearchHook, 6 | } from "./location-hook.js"; 7 | 8 | export type Parser = ( 9 | route: Path, 10 | loose?: boolean 11 | ) => { pattern: RegExp; keys: string[] }; 12 | 13 | export type HrefsFormatter = (href: string, router: RouterObject) => string; 14 | 15 | // the object returned from `useRouter` 16 | export interface RouterObject { 17 | readonly hook: BaseLocationHook; 18 | readonly searchHook: BaseSearchHook; 19 | readonly base: Path; 20 | readonly ownBase: Path; 21 | readonly parser: Parser; 22 | readonly ssrPath?: Path; 23 | readonly ssrSearch?: SearchString; 24 | readonly hrefs: HrefsFormatter; 25 | } 26 | 27 | // state captured during SSR render 28 | export type SsrContext = { 29 | // if a redirect was encountered, this will be populated with the path 30 | redirectTo?: Path; 31 | }; 32 | 33 | // basic options to construct a router 34 | export type RouterOptions = { 35 | hook?: BaseLocationHook; 36 | searchHook?: BaseSearchHook; 37 | base?: Path; 38 | parser?: Parser; 39 | ssrPath?: Path; 40 | ssrSearch?: SearchString; 41 | ssrContext?: SsrContext; 42 | hrefs?: HrefsFormatter; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/wouter/types/use-browser-location.d.ts: -------------------------------------------------------------------------------- 1 | import { Path, SearchString } from "./location-hook.js"; 2 | 3 | type Primitive = string | number | bigint | boolean | null | undefined | symbol; 4 | export const useLocationProperty: ( 5 | fn: () => S, 6 | ssrFn?: () => S 7 | ) => S; 8 | 9 | export type BrowserSearchHook = (options?: { 10 | ssrSearch?: SearchString; 11 | }) => SearchString; 12 | 13 | export const useSearch: BrowserSearchHook; 14 | 15 | export const usePathname: (options?: { ssrPath?: Path }) => Path; 16 | 17 | export const useHistoryState: () => T; 18 | 19 | export const navigate: ( 20 | to: string | URL, 21 | options?: { replace?: boolean; state?: S } 22 | ) => void; 23 | 24 | /* 25 | * Default `useLocation` 26 | */ 27 | 28 | // The type of the default `useLocation` hook that wouter uses. 29 | // It operates on current URL using History API, supports base path and can 30 | // navigate with `pushState` or `replaceState`. 31 | export type BrowserLocationHook = (options?: { 32 | ssrPath?: Path; 33 | }) => [Path, typeof navigate]; 34 | 35 | export const useBrowserLocation: BrowserLocationHook; 36 | -------------------------------------------------------------------------------- /packages/wouter/types/use-hash-location.d.ts: -------------------------------------------------------------------------------- 1 | import { Path } from "./location-hook.js"; 2 | 3 | export function navigate( 4 | to: Path, 5 | options?: { state?: S; replace?: boolean } 6 | ): void; 7 | 8 | export function useHashLocation(options?: { 9 | ssrPath?: Path; 10 | }): [Path, typeof navigate]; 11 | -------------------------------------------------------------------------------- /packages/wouter/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject } from "vitest/config"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | export default defineProject({ 5 | plugins: [react({ jsxRuntime: "automatic" })], 6 | test: { 7 | name: "wouter-react", 8 | setupFiles: "./setup-vitest.ts", 9 | environment: "happy-dom", 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from "vitest/config"; 2 | 3 | export default defineWorkspace(["packages/*/vitest.config.ts"]); 4 | --------------------------------------------------------------------------------