├── .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 <https://unlicense.org> 25 | -------------------------------------------------------------------------------- /assets/icon_duotone.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="MAIN" x="0" y="0" style="enable-background:new 0 0 256 256" version="1.1" viewBox="0 0 256 256"><style>.st2{opacity:.5;fill:#010101}</style><path id="THING" d="M236.7 152.3c-2.4-2.7-7.6-4.1-10.6-1.7-6.6 5.4 1.4 10.4 6.8 9.5.9-.1 1.7-.5 2.5-1 3.3-2.3 3.1-4.9 1.3-6.8z" style="fill:#010101;stroke:#010101;stroke-width:4;stroke-linecap:round;stroke-linejoin:round"/><path id="HAND" d="M237.9 208.9c-2.1-2.9-3.5-6-4.2-8.7-1.3-4.8-1.4-9.9-.3-14.7 1.2-4.7 5-9.8 9.6-12.8-4.3-5-11.8-6.7-18.1-4.6-7 2.3-12 8.5-14.2 15.3-1.4 4.2-2.1 7.4-3.3 10.1s-3 4.9-6.7 7.4c-5.5 3.6-12 5.2-18.5 4.8-12.9-.7-25.7-7.1-33-17.5-8.3-11.8-9.9-27.8-5.4-41.5 3.8-11.6 12.2-22.9 22.6-29.5 9.4-6 22.9-11.5 33.9-8.9 3.8.9 6 3.3 7.6 6.5 1.6 3.2 2.5 7.1 3.6 10.7 1.3 4.2 3.8 8.6 7.4 11.7 3.5 3.2 8 5.2 13.1 4.7 3.1-.3 6-1.7 8.4-3.7-3.8-2.1-6.3-5.4-7.8-9.2-1.7-4.4-2-9.4-1.5-14.4 1.1-9.5 5.3-19.1 10.8-24.9-2.9-5.5-6.8-10.5-11.7-14.6-12.6-10.6-30.5-13.4-46.4-9.6-15.9 3.8-29.8 13.7-40.4 26-9.2 10.6-15.7 23.1-21.4 35.8 2.9-17 12-32.8 23.6-45.9C158.7 66.6 175 55 191.2 43.6c4.5-3.2 9.3-6.5 12.1-11.6 2.9-5.4 3.1-11.8.5-17.3-1.7-3.4-4.3-6.2-7.5-8.1-4.2 5-9.5 8.9-15.5 11.5-5.6 2.2-11.5 4-17.9 4.1-3.6.1-10.2.2-15.2-2.6-1.9 1.5-3.8 3.1-5.6 4.7-15.6 14.2-27.6 32-36.1 51.3-5.2 11.9-11.4 25.8-14.8 39.8.8-12.3 2.5-24.7 5.7-36.1 4.9-17.9 12-35.3 15.3-53.9.9-5.1 1.4-11-1.9-15.5-2.3-3.1-5.9-4.6-9.7-4.8h-1.4c-1.6 14-10.5 25.3-21.6 30.1-1.6.7-3.7 1-5.9 1-1.4 0-2.8 0-4.1-.4-10.6 23.6-14.6 49-16 74.8l-2-42.6c-.8-16.9-1.8-34.7-10.3-49.6-2-3.6-5-7-8.5-9.3-3.5-2.3-7.6-3.3-11.7-1.8-1 .4-1.9.8-2.7 1.4 3.5 6 4.5 14 3.4 21.5-1.1 7.6-4.1 14.9-9.3 19.4.7 7.1 1.6 14.1 2.4 21.1 2.9 27.1 2.6 54.7 2.6 82 0 22.5-3 45-1.4 67.7.3 4 .3 9.2 1.2 14.1s2.4 9.7 6 12.8c6.5 5.6 17.8 5 25 5.2 9.8.3 19.7-.1 29.6.2 24.4.6 48.7.3 73-.2h.9c22-.3 43.9.6 66-1 8.1-.6 17.3-1.8 22.9-8.3 3.8-4.5 4.9-10.5 5.7-16.1.6-4 1-8 1.4-12.1-3.3-1-6-3.4-7.9-6.1z" style="fill:#010101"/><g id="NAILS"><path id="_x35_" d="M245.2 176.1c-.2-.5-.5-1.1-.8-1.6-.4-.7-.8-1.3-1.3-1.8-4.6 3-8.4 8.1-9.6 12.8-1.2 4.9-1.1 9.9.3 14.7.7 2.6 2.1 5.8 4.2 8.7 2 2.7 4.6 5.1 8 6.2.1-1.4.3-2.8.4-4.2.6-7.6.9-15.3.8-22.9-.2-3.9-.3-8.1-2-11.9z" class="st2"/><path id="_x34_" d="M243.6 93.8c-.6-1.3-1.2-2.6-1.9-3.9-5.5 5.8-9.7 15.4-10.8 24.9-.6 5-.2 10 1.5 14.4 1.5 3.8 4.1 7 7.8 9.2 1.1-.9 2-1.9 2.9-3 .7-1 1.4-2 1.9-3 2.5-5 3.1-10.6 3.1-16.1 0-7.8-1.5-15.5-4.5-22.5z" class="st2"/><path id="_x33_" d="M192.3 4.9c-.8-.3-1.7-.5-2.5-.7-1.5-.2-3.1-.3-4.7-.3s-3.1.2-4.6.5C169.8 6 160 10.7 151.2 16.8c-1.2.9-2.5 1.8-3.7 2.7 4.9 2.8 11.6 2.7 15.2 2.6 6.4-.1 12.2-1.9 17.9-4.1 6-2.5 11.3-6.5 15.5-11.5-1.2-.6-2.5-1.2-3.8-1.6z" class="st2"/><path id="_x32_" d="M95.1 5.7c-1.9.4-3.8 1.1-5.5 2-8.9 4.8-14.7 13.6-19.2 22.4-.3.6-.6 1.3-.9 1.9-.6 1.3-1.2 2.6-1.8 3.8 1.3.4 2.7.4 4.1.4 2.2 0 4.3-.3 5.9-1 11.1-4.8 19.9-16.1 21.6-30.1-1.5.1-2.9.3-4.2.6z" class="st2"/><path id="_x31_" d="M16.4 8.7c-1.2.8-2.1 1.8-2.9 3-.8 1.2-1.5 2.4-2 3.8-1.4 3.6-1.9 7.8-2 11.6-.2 5.7.1 11.5.5 17.2.1 1.8.3 3.5.5 5.3 5.2-4.5 8.3-11.8 9.3-19.4 1.1-7.5.2-15.5-3.4-21.5z" class="st2"/></g></svg> -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | <svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1,.cls-2{fill:#010101;stroke:#010101;stroke-linecap:round;stroke-linejoin:round;stroke-width:4px}.cls-2{fill:none;stroke:#fff}</style></defs><path class="cls-1" d="M149.79 251.54h-.93c-24.32.47-48.64.73-73 .15-9.9-.24-19.8.11-29.6-.18-7.27-.21-18.43.15-24.42-5-6.55-5.68-6.19-18.08-6.78-26.21-1.63-22.54 1.37-45.06 1.4-67.6 0-27.27.33-54.87-2.59-82.06-1.56-14.51-3.76-29-3.44-43.6.17-7.39 2-16.23 9-18.81 7.44-2.77 15.08 3.83 19 10.71 8.41 14.66 9.42 32.26 10.22 49.14l3.35 70.4c.06-37.26 2.52-74.59 19.23-107.89C75.66 21.84 81.36 13.21 90 8.6c6.25-3.34 15.16-3.72 19.38 2 3 4.08 2.56 9.72 1.68 14.73-3.24 18.49-10.36 35.78-15.28 53.8-4.88 17.81-6.78 38.19-6.1 56.67-.78-19.74 9.36-42 17.1-59.75 8.38-19.2 20.28-36.86 35.81-51 10.71-9.74 23.72-17.45 38-19.68a27.57 27.57 0 019-.16 18.29 18.29 0 0113.09 10 18.28 18.28 0 01-.47 16.44c-2.64 4.8-7.29 8.11-11.78 11.27-16.25 11.42-32.55 23.12-45.77 38S121.38 114.08 120 133.9c6.41-14.74 13.5-29.46 24-41.59s24.26-21.92 39.88-25.68 33.19-.91 45.52 9.4c11.91 10 17.62 24.89 17.73 40.23 0 5.36-.58 10.86-3 15.62s-7 8.67-12.35 9.22c-9.53 1-16.89-7.56-19.4-15.71-2.15-7-3.6-15.91-11.92-17.9-11.43-2.72-25.09 2.91-34.65 9-10.57 6.7-19.09 18.19-23 30-4.66 14-3 30.31 5.49 42.4 7.53 10.7 20.63 17.2 33.8 17.9a31.59 31.59 0 0019.13-5c7.79-5.15 7.66-9.73 10.41-18.07 2.17-6.56 7-12.48 13.6-14.67s14.68 0 18.25 6c2.31 3.85 2.52 8.55 2.56 13a260.84 260.84 0 01-2.63 39c-.79 5.53-1.89 11.32-5.51 15.57-5.23 6.15-14.12 7.37-22.17 7.93-22.04 1.54-43.92.64-65.95.99z"/><path class="cls-1" d="M236.7 152.26c-2.42-2.66-7.64-4.09-10.56-1.72-6.59 5.36 1.37 10.44 6.84 9.51a6.12 6.12 0 002.48-1c3.27-2.23 3.01-4.84 1.24-6.79z"/><path class="cls-2" d="M7.73 49.26c11.07-7.71 14-30.06 5.93-41.14M66.87 33.24c1.81 1.53 7.75 1.15 9.92.2 10.72-4.66 19.35-15.82 20.52-29.67M148.16 17.62c4.16 2.58 10.88 2.63 14.5 2.57 6-.1 11.64-1.8 17.18-3.94a38.87 38.87 0 0016-12.48M244.1 90.41c-10.93 10.21-18.38 39.74-1.19 47.07M245.68 173.48c-4.75 2.38-9.2 8-10.36 12.48a27.23 27.23 0 00.25 13.74c1.27 4.77 5.27 12 11.56 13.74"/></svg> -------------------------------------------------------------------------------- /assets/wouter-skate.svg: -------------------------------------------------------------------------------- 1 | <svg width="430" height="430" xmlns="http://www.w3.org/2000/svg"><g fill="#1D1D1B" fill-rule="nonzero"><path d="M391.68 104.27c3.11 8.42 6.43 17.64 4.73 26.74-1.2 6.41-4.59 14.55-9.79 18.68-9.7 7.71-28.29 5.56-38.54.2-2.14-1.12-4 2.12-1.89 3.24 11.59 6.07 31.76 8.25 42.71-.43 5.54-4.39 9-12.24 10.73-19 2.63-10.36-.76-20.78-4.34-30.46-.83-2.24-4.45-1.27-3.62 1l.01.03z"/><path d="M414.07 96.93c9.32 18.09 15.58 42.95 8.21 62.73-7.47 20-25.89 33.22-46.51 36.93-2.37.43-1.37 4 1 3.62 21.58-3.87 40.36-17.71 48.64-38.29 8.43-21 1.85-47.56-8.1-66.87-1.11-2.15-4.34-.25-3.24 1.89v-.01zM363.46 198.92a133.28 133.28 0 0 1-53.46 1.64c-2.36-.42-3.37 3.19-1 3.62a137.86 137.86 0 0 0 55.44-1.64c2.35-.56 1.35-4.18-1-3.62h.02z"/><path d="M393.94 103.89a89.68 89.68 0 0 1 10.81-4c2.37-.73 7.17-3.11 9.62-2.83l-1.81-2.37v.13l2.31-1.31h-.1c-2.34-.61-3.34 3-1 3.62h.1a1.92 1.92 0 0 0 2.31-1.31v-.13a1.9 1.9 0 0 0-1.81-2.37c-3-.35-7.78 2.08-10.61 3a97.3 97.3 0 0 0-11.76 4.33c-2.19 1-.29 4.24 1.89 3.24h.05zM399.1 119.15c7.1-1.91 14-5 21-7.52 2.25-.82 1.28-4.45-1-3.62-6.9 2.53-13.85 5.61-21 7.52-2.33.63-1.34 4.24 1 3.62z"/><path d="M399.42 101.77a216.54 216.54 0 0 1 4.09 11.57c.74 2.29 4.36 1.31 3.62-1a216.54 216.54 0 0 0-4.09-11.57c-.86-2.23-4.49-1.26-3.62 1zM406.45 98.89a111.52 111.52 0 0 1 4.44 11.66c.73 2.29 4.35 1.31 3.62-1A120.18 120.18 0 0 0 409.69 97c-1-2.2-4.22-.29-3.24 1.89zM393.94 99.66c-4.52-3.1-9.19-6.14-13.48-9.56a8 8 0 0 1-1.7-1.53c-1.45-2.14.34-3.27 2.38-2.85 2.47.51 4.91 2.14 7 3.5C390.23 90.58 392 87.3 390 86c-3.11-2-6.86-4.3-10.72-4.17-3.07.1-5.29 2.06-5.09 5.31.2 3.25 3 5.23 5.39 7 4.09 3 8.26 5.93 12.45 8.8 2 1.37 3.87-1.88 1.89-3.24l.02-.04z"/><path d="M391.41 86.75l-3.22-7.1c-.67-1.47-4.3-7.78-3.09-9.45 1.21-1.67 3.71.39 4.46 1.13a14.23 14.23 0 0 1 2.27 3.21 81.32 81.32 0 0 1 4 8.74c.91 2.2 4.54 1.23 3.62-1-1.77-4.28-3.44-9-6.4-12.65-1.9-2.33-5.84-5.28-9.11-4-7.51 3 2.36 18.86 4.23 23 1 2.19 4.23.29 3.24-1.89v.01z"/><path d="M391.42 68.06c-.64-1 .47-2.15 1.25-2.64 1.39-.89 2.43-.42 3.4.73a32.08 32.08 0 0 1 2.78 4.28c1.67 2.84 3.81 5.87 5.08 8.89.92 2.19 4.55 1.23 3.62-1-1.31-3.11-3.33-5.92-4.95-8.88-1.31-2.39-3.21-6.39-5.8-7.67-4.8-2.35-11.8 3.16-8.62 8.23 1.28 2 4.53.16 3.24-1.89v-.05z"/><path d="M400.45 65.22c.1-4.24 3-2.37 4.21-1.42a12.57 12.57 0 0 1 2.52 3.2 47.19 47.19 0 0 1 4.09 8.12c2.54 6.34 2.84 12.42 2 19.23-.29 2.39 3.46 2.37 3.75 0a42.41 42.41 0 0 0-6.34-28.81c-1.58-2.53-3.85-6-7.05-6.52-3.65-.63-6.85 2.79-6.93 6.21-.06 2.41 3.69 2.41 3.75 0v-.01zM311 120.68h66.46a1.88 1.88 0 0 0 0-3.75H311a1.88 1.88 0 0 0 0 3.75z"/><path d="M311 118.81c.69-8.07 4.18-17.53 20.39-17.53 8 0 17.18 7 18.07 16.14"/><path d="M312.9 118.81c.71-7.27 4.22-13.15 11.71-14.94 10.41-2.49 21.45 2.77 23.07 14 .34 2.38 4 1.37 3.62-1-1.83-12.74-14.51-19.25-26.39-16.94-9.86 1.91-14.82 9.26-15.76 18.83-.23 2.4 3.52 2.38 3.75 0v.05zM349.93 134.1c1.61 5.42-1 10.41-5.35 13.62-1.93 1.41-.06 4.67 1.89 3.24 5.73-4.2 9.19-10.73 7.07-17.86-.69-2.31-4.31-1.32-3.62 1h.01z"/><path d="M349 122.14c4 2.33 8.32 4.45 12 7.26v-3.24a162.026 162.026 0 0 1-9.32 5.41c-2.13 1.14-.24 4.38 1.89 3.24 3.167-1.7 6.273-3.503 9.32-5.41a1.9 1.9 0 0 0 0-3.24c-3.71-2.8-8-4.93-12-7.26-2.09-1.21-4 2-1.89 3.24zM337.55 120.61c.172.967.266 1.947.28 2.93v1.15c.227-.727.083-.973-.43-.74-.6-.18-2.06.39-2.69.49-.63.1-1.4.18-2.09.32-3 .58-4.71 2.2-6 5-1 2.18 2.25 4.09 3.24 1.89a4.88 4.88 0 0 1 4.37-3.34 33.74 33.74 0 0 0 5.27-.94c3.12-1 2.07-5.23 1.64-7.71-.43-2.48-4-1.37-3.62 1l.03-.05zM344.37 125.77v.9a1.88 1.88 0 1 0 3.75 0v-.9a1.88 1.88 0 0 0-3.75 0z"/><path d="M314.42 151.31c-5.51.34-11 .92-16.47 1.4-6.39.56-13.18.69-18.1-4.11-10.31-10.06-5.81-27 3.15-36.2 6.15-6.3 13.94-2.42 19.47 2.54a120.21 120.21 0 0 0 13.29 10.32c5 3.35 10.35 6.25 15.14 9.87 5.55 4.18 9.15 10.37 13 16 1.36 2 4.61.1 3.24-1.89-4.29-6.21-8.22-12.81-14.39-17.35-6.47-4.77-13.62-8.55-20.09-13.33-6.28-4.63-11-10.67-18.79-12.75-7.11-1.89-11 1-15.69 6.1-8.81 9.41-11.17 26.63-3 37.1 9.35 12 26.25 6.86 39.21 6.06 2.4-.15 2.41-3.9 0-3.75l.03-.01z"/><path d="M347.14 150c-8.14.08-16.267.363-24.38.85-2.4.14-2.41 3.89 0 3.75 8.12-.487 16.247-.77 24.38-.85 2.41 0 2.42-3.77 0-3.75zM306.93 184.86c3.22 3 3.4 6.67 1.91 10.62-.85 2.26 2.77 3.24 3.62 1 1.94-5.15 1.23-10.42-2.87-14.27-1.76-1.65-4.42 1-2.65 2.65h-.01zM288.94 211.08a48.45 48.45 0 0 0 13.65-3.18c2.79-1 6.7-2.07 8.54-4.59 1.42-2-1.83-3.83-3.24-1.89-1.41 1.94-6.17 2.85-8.18 3.51a42.18 42.18 0 0 1-10.77 2.4c-2.4.12-2.42 3.88 0 3.75zM226.05 162.86c-4.973 4.18-9.923 8.387-14.85 12.62-5 4.28-10.49 8.38-14.88 13.31-7.86 8.82-.6 17.43 7.24 22.84 2 1.37 3.87-1.87 1.89-3.24-4.56-3.15-11.29-8.23-8.14-14.69 2-4.1 7.06-7.44 10.44-10.34 6.953-6 13.933-11.95 20.94-17.85 1.85-1.56-.82-4.2-2.65-2.65h.01zM269.93 127.78c-12.61 9.37-24.79 19.25-36.83 29.35-1.85 1.55.81 4.19 2.65 2.65 11.79-9.9 23.71-19.58 36.07-28.76 1.91-1.42 0-4.68-1.89-3.24z"/><path d="M168.78 139.22c27.37-25.15 68.78-22 101.73-12.19 2.32.69 3.31-2.93 1-3.62-34.32-10.21-77-12.94-105.38 13.16-1.78 1.64.88 4.28 2.65 2.65zM137.84 183.16c1.55-8.09 6.36-14.52 10.82-21.24A89.8 89.8 0 0 1 161.53 146c1.75-1.67-.9-4.32-2.65-2.65A94.08 94.08 0 0 0 145.43 160c-4.64 7-9.59 13.74-11.2 22.13-.45 2.36 3.16 3.37 3.62 1l-.01.03z"/><path d="M134.95 185.32a410.89 410.89 0 0 0 20.89 11.55 1.9 1.9 0 0 0 2.57-.67c5.9-11.85 14.41-22.53 26.06-29.15a45.31 45.31 0 0 1 42-1.24c2.16 1.06 4.06-2.18 1.89-3.24a49.23 49.23 0 0 0-43-.2c-13.5 6.6-23.53 18.69-30.13 31.94l2.57-.67a410.89 410.89 0 0 1-20.89-11.55c-2.08-1.23-4 2-1.89 3.24l-.07-.01z"/><path d="M143.35 167.43l21.89 12.49c2.1 1.2 4-2 1.89-3.24l-21.89-12.49c-2.1-1.2-4 2-1.89 3.24z"/><path d="M151.68 192.2l10.14-15.26c1.34-2-1.91-3.9-3.24-1.89l-10.14 15.26c-1.34 2 1.91 3.9 3.24 1.89zM144.15 186.67c3-5.12 6.14-10.1 9.27-15.11 1.28-2.05-2-3.94-3.24-1.89-3.13 5-6.31 10-9.27 15.11-1.21 2.09 2 4 3.24 1.89zM145.31 205.46c.72 3.82 3.77 22.75 11.3 16 3-2.67 2.78-7.57 2.38-11.16-.33-2.91-.93-5.7-.77-8.64.13-2.47.37-4.47-.46-6.83-.83-2.36-4.42-1.29-3.62 1 1 2.75.16 5.37.28 8.2.12 2.83.9 5.43 1 8.17 0 1 .36 3.74-.3 4.4-2.86 2.91-3.68-1.67-4.15-3.11a72.75 72.75 0 0 1-2.08-9c-.45-2.37-4.06-1.37-3.62 1l.04-.03z"/><path d="M145.25 205.15c-1.94 6.41-4.08 13.71-7.86 19.3-1 1.47-2.44 3.47-3.83 1.79-1.39-1.68-.17-4.61.42-6.25 1.28-3.58 3.26-7.05 4.88-10.49 1-2.17-2.21-4.08-3.24-1.89-2.28 4.82-6.53 10.84-6.33 16.37.11 2.89 1.32 6.95 4.76 7.15 3.74.22 6.32-4.15 7.76-7a107.63 107.63 0 0 0 7.05-18c.7-2.31-2.92-3.3-3.62-1l.01.02z"/><path d="M129.76 220.44c-1.19 2.85-5.46 5.07-5.28.27a16.62 16.62 0 0 1 1.72-5.92 62.47 62.47 0 0 1 5.94-9.89c1.37-2-1.88-3.87-3.24-1.89-3.73 5.43-10.89 14.86-7.45 21.95 3.11 6.4 10.14.73 11.92-3.52.93-2.22-2.7-3.19-3.62-1h.01z"/><path d="M122.08 215c-.64 1.35-3.92 5.54-5.72 2.39-.37-.65.57-2.69.82-3.43a54.3 54.3 0 0 1 3.82-8.56c4.1-7.52 10.12-13.72 15.33-20.45 1.46-1.88-1.17-4.56-2.65-2.65-7.74 10-17 19.34-20.51 31.83-.89 3.15-.81 6.33 2.78 7.56 4.32 1.48 7.58-1 9.33-4.75 1-2.17-2.21-4.08-3.24-1.89l.04-.05zM287.34 210.16a26.15 26.15 0 0 1 .48 8.33c-.25 2.4 3.5 2.38 3.75 0a29.3 29.3 0 0 0-.61-9.32c-.54-2.35-4.16-1.36-3.62 1v-.01z"/><path d="M289.47 217.68h-83.3a1.88 1.88 0 0 0 0 3.75h83.3a1.88 1.88 0 0 0 0-3.75z"/><path d="M202.44 211.06c0 2-.07 4 0 5.93a4.13 4.13 0 0 0 1.72 3.64c2 1.38 3.86-1.87 1.89-3.24.43.3.16.07.13-.4v-5.92a1.88 1.88 0 0 0-3.75 0l.01-.01z"/><path d="M205.11 211.08h83.83a1.88 1.88 0 0 0 0-3.75h-83.83a1.88 1.88 0 0 0 0 3.75z"/><path d="M209.21 210.8v6.76a1.88 1.88 0 0 0 3.75 0v-6.76a1.88 1.88 0 0 0-3.75 0zM217.16 210.8v7.2a1.88 1.88 0 0 0 3.75 0v-7.2a1.88 1.88 0 0 0-3.75 0zM225 210v7.83a1.88 1.88 0 0 0 3.75 0V210a1.88 1.88 0 0 0-3.75 0zM232.55 210.67v7.39a1.88 1.88 0 0 0 3.75 0v-.36a1.88 1.88 0 0 0-3.75 0v.36a1.88 1.88 0 0 0 3.75 0v-7.39a1.88 1.88 0 0 0-3.75 0zM240.38 210.53v7.3a1.88 1.88 0 0 0 3.75 0v-7.3a1.88 1.88 0 0 0-3.75 0zM248.07 210.4v7a1.88 1.88 0 0 0 3.75 0v-7a1.88 1.88 0 0 0-3.75 0zM256.83 210v7.69a1.88 1.88 0 0 0 3.75 0V210a1.88 1.88 0 0 0-3.75 0zM264.78 210.67v7.16a1.88 1.88 0 0 0 3.75 0v-7.16a1.88 1.88 0 0 0-3.75 0zM272.61 210.53v7.56a1.88 1.88 0 0 0 3.75 0v-7.56a1.88 1.88 0 0 0-3.75 0zM279.51 210.53v7.16a1.88 1.88 0 0 0 3.75 0v-7.16a1.88 1.88 0 0 0-3.75 0zM316.53 339.38c2.43 0 6.68-.61 7.74 1.76 1.65 3.69-4.5 7.73-6.4 10.09-3 3.77-2.26 7.7 1.17 10.81 10.32 9.35 34.31 3.4 35.57 21.81.4 5.82-3.54 7.84-8.5 9.07-4.35 1.08-8.85 2.09-13.36 1.78-4.81-.33-9.53-2-14.31-2.61a71.6 71.6 0 0 0-11.45-.64c-8.17.21-15.86 2-23.63 4.46-7.26 2.26-20.26 4.76-22.42-5.7-1.86-9 8.28-18 12-25.53a73.8 73.8 0 0 0 4.4-11.68c1.56-5.29 3.17-11.35 9.22-12.89 2.34-.59 1.35-4.21-1-3.62-8.87 2.25-10.34 11.27-12.84 18.78a63 63 0 0 1-7.35 15c-2.77 4.17-6.06 8.08-7.57 12.94-2.18 7 .25 14 7.07 17.08 8.46 3.8 17.21-.29 25.46-2.57 12.21-3.38 24-2.75 36.26-.21 8.29 1.72 18.37 1.14 26.08-2.77 6.66-3.38 6.84-11.26 4-17.42-3.79-8.26-11.26-10.73-19.62-12.14-4.57-.78-13.15-2.09-15.95-6.23-3.12-4.63.67-5.88 3.15-8.61a17 17 0 0 0 3.29-5.64c3.06-8.37-4.62-9-11-9a1.88 1.88 0 0 0 0 3.75l-.01-.07z"/><path d="M258.51 391c8.1 2.73 16 2.46 24.09-.1 9-2.83 17.64-5.21 27.12-5.63 9.07-.4 17.64 1.54 26.6 2.28 6.81.57 13.39-.53 18.67-5.11 1.83-1.58-.84-4.23-2.65-2.65-7 6.06-16.39 4-24.69 2.77a91.47 91.47 0 0 0-21.6-.8 93.2 93.2 0 0 0-21.08 4.4c-8.76 2.88-16.47 4.26-25.46 1.23-2.29-.77-3.28 2.85-1 3.62V391zM328 366.26c-.45 2.2-1.51 4.25-1.8 6.48l.55-1.33h2.65l-.13-.14c-1.59-1.82-4.24.84-2.65 2.65l.13.14a1.93 1.93 0 0 0 2.65 0c.238-.23.41-.52.5-.84-.18.65.22-1.08.06-.43.16-.68.43-1.34.61-2 .33-1.19.81-2.33 1.06-3.54.48-2.36-3.13-3.36-3.62-1l-.01.01zM334.27 367.86l-1.33 5.84c-.53 2.35 3.08 3.35 3.62 1l1.33-5.84c.53-2.35-3.08-3.35-3.62-1zM340.41 368.94l-1.23 5.86c-.49 2.35 3.12 3.36 3.62 1l1.23-5.86c.49-2.35-3.12-3.36-3.62-1zM179.48 259.73c-3.47 6.22-10 16.41-10.48 18-5.63-3.68-12.62-6.77-18.41-10-11 7.94-19.52 14.68-30.77 22 1.69 1.11 4.31 3.68 5.84 4.69 12.3 8.1 37.85 24.19 46 25.22 12.76 1.62 30.95-16.51 38.89-26.54-10.69-10.72-20.04-23.52-31.07-33.37z"/><path d="M177.86 258.78c-3.42 6.07-8.05 11.94-10.67 18.41l2.75-1.12c-5.91-3.74-12.27-6.63-18.41-10a1.86 1.86 0 0 0-1.89 0c-10.22 7.39-20.22 15.08-30.77 22a1.9 1.9 0 0 0 0 3.24c8 5.82 16 11.35 24.53 16.49 7.77 4.68 15.86 9.83 24.5 12.77 7.81 2.65 15.59-1 22-5.41a101.92 101.92 0 0 0 21.91-20.78 1.92 1.92 0 0 0 0-2.65c-10.66-10.84-19.77-23.13-31-33.37-1.78-1.62-4.44 1-2.65 2.65 11.28 10.24 20.39 22.53 31 33.37v-2.65a100.24 100.24 0 0 1-20.59 19.81c-7.21 5-14.24 7.89-22.57 4.28-16.21-7-31.07-17.45-45.28-27.75v3.24c8-5.22 15.6-10.87 23.26-16.51 1.46-1.08 4.21-4 6-4.36 2.08-.41 3.86 1.08 5.76 2.05 4.18 2.13 8.35 4.26 12.32 6.77a1.9 1.9 0 0 0 2.75-1.12c2.48-6.12 7-11.76 10.29-17.51 1.18-2.06-2.1-3.95-3.24-1.85zM26.89 281.16c6.4 3.76 14.59 2.75 21.15-.16a32 32 0 0 0 9-6.22c4.16-3.95 9.07-9.07 15.3-5.76 5.91 3.14 8.79 12.76 16.39 13.28 6.41.44 7.8-7.72 8-12.47.09-2.41-3.66-2.41-3.75 0-.06 1.7.05 3.93-.81 5.48-2.08 3.72-5 2.79-7.82.41a51.37 51.37 0 0 1-5.53-6c-3.33-3.81-7.9-6.26-13.07-5.25-5.6 1.1-8.91 5.8-13 9.26-6.18 5.18-16.49 8.6-23.94 4.23a1.88 1.88 0 0 0-1.89 3.24l-.03-.04zM114.9 250.36c-.94-3.38-2.06-6.7-4.88-9-3.33-2.68-7.95-3.37-11.64-5.39a25.31 25.31 0 0 1-10.75-10.62c-1.79-3.61-3.14-7.05-6.37-9.64-1.87-1.49-4.54 1.14-2.65 2.65 3.44 2.75 4.34 6.48 6.42 10.14a26.63 26.63 0 0 0 6.58 7.42 34.16 34.16 0 0 0 9 5.18c5.69 2.25 9 3.93 10.71 10.22.65 2.32 4.27 1.34 3.62-1l-.04.04z"/><path d="M88.48 231.88c-2.08 5.71-4 11.85-1.61 17.79 2.18 5.54 7.09 9.71 11.81 13.08 2 1.4 3.84-1.85 1.89-3.24-3.79-2.7-7.86-6.09-9.88-10.39-2.51-5.37-.48-11 1.41-16.24.83-2.27-2.8-3.25-3.62-1zM29.62 278c.95-6 2.89-12.11 7-16.66 2.57-2.86 5.84-5 8.93-7.26a82 82 0 0 0 12-10.88 38.56 38.56 0 0 0 5.07-6.77c1.55-2.74 2.57-5.76 4.16-8.47 2.91-5 6.76-8.43 12.26-10.09l1.18-.57c-2.84-2-5.12-3.57-8.44-3.37a17.76 17.76 0 0 0-10.59 5.15c-2.27 2.6-3.87 5.71-5.66 8.66a75.35 75.35 0 0 1-12.7 15.76c-3.89 3.68-8.18 6.95-11.82 10.88-3.64 3.93-6.67 8.69-7.31 14-.45 3.69.42 7.73 3.12 10.28.34.32.705.615 1.09.88"/><path d="M31.43 278.48c1.41-8.13 4.36-14.76 11-19.88 5.54-4.24 11-8.26 15.72-13.43 4.4-4.78 6.67-10 9.76-15.59 3.19-5.8 7.47-7.91 13.14-10.68 1.37-.67 1.06-2.5 0-3.24-6.43-4.51-13.09-4.56-19.44.39-3.53 2.76-5.52 7-7.8 10.72a75 75 0 0 1-10 13c-6.61 6.84-15.25 12.14-19.66 20.84-3.35 6.6-3.91 15.2 2.33 20.24 1.86 1.5 4.53-1.13 2.65-2.65-7.4-6-2.5-16.17 2.57-21.92 4.84-5.5 11-9.7 15.92-15.09a94.43 94.43 0 0 0 11.19-15.51c2-3.31 3.93-6.3 7.44-8.17 4.92-2.61 8.53-1.7 12.92 1.38v-3.24c-5 2.46-9.15 4.26-12.57 9-2.12 2.93-3.32 6.24-5 9.43a41.84 41.84 0 0 1-7.36 9.75c-5.46 5.67-12.34 9.52-17.9 15-5.1 5-7.4 11.76-8.59 18.67-.41 2.36 3.2 3.37 3.62 1l.06-.02z"/><path d="M56.91 245.55a32.71 32.71 0 0 0 31-3.26c2-1.35.11-4.6-1.89-3.24a29.3 29.3 0 0 1-28.16 2.88c-2.23-.92-3.2 2.71-1 3.62h.05zM58.65 260.83a33.36 33.36 0 0 1 3 5.92c.85 2.23 4.48 1.26 3.62-1a38.23 38.23 0 0 0-3.37-6.81c-1.28-2-4.53-.16-3.24 1.89h-.01zM51.43 264.64a121.685 121.685 0 0 1 3.76 7.07c1.07 2.16 4.31.26 3.24-1.89a140.505 140.505 0 0 0-3.76-7.07c-1.2-2.1-4.44-.21-3.24 1.89zM44.91 269.39A213.674 213.674 0 0 1 49 276.1c1.19 2.1 4.43.21 3.24-1.89a124.72 124.72 0 0 0-4.07-6.71c-1.31-2-4.56-.14-3.24 1.89h-.02zM95.27 266.07c-6.1-6.21-18-9.7-23.68-1.06-1.32 2 1.92 3.91 3.24 1.89 4.26-6.52 13.55-2.5 17.79 1.82 1.69 1.73 4.34-.93 2.65-2.65zM95.46 238.18a23 23 0 0 0 9.86 19.5c2 1.4 3.84-1.85 1.89-3.24a19.12 19.12 0 0 1-8-16.26c.06-2.41-3.69-2.41-3.75 0zM41.56 201.68a7.74 7.74 0 0 0-.74 12.24 8.64 8.64 0 0 0 7 1.88 10.76 10.76 0 0 0 6.33-3.66 8.37 8.37 0 0 0 1.51-2.44c1.2-3.14-.44-7-3.34-8.68a10.15 10.15 0 0 0-9.67 0"/><path d="M40.23 200.35a9.7 9.7 0 0 0-1.6 14c3.82 4.44 10.47 4.26 14.9.89 4-3 5.7-8.08 3-12.58-3.18-5.25-9.74-5.53-14.82-3.22-2.19 1-.29 4.23 1.89 3.24 3.27-1.49 7.57-1.43 9.69 1.88 2 3.06.25 6.26-2.49 8.08-6.81 4.54-15-4-7.89-9.62 1.9-1.49-.77-4.12-2.65-2.65l-.03-.02zM20.72 221.86a10.1 10.1 0 0 0-3.9 6.65 9.09 9.09 0 0 0 .94 6.25C19.2 237 22 238 24.6 238a11 11 0 0 0 7.69-2.82 7.14 7.14 0 0 0 2-3.21c.63-2.27-.2-4.7-1.31-6.78A9.22 9.22 0 0 0 29.2 221a11.17 11.17 0 0 0-3.8-.83 9.56 9.56 0 0 0-4.68 1.69"/><path d="M19.4 220.54c-4.15 3.68-6.53 10.63-3 15.55 3.53 4.92 11 4.71 15.56 1.69 5.58-3.68 5.13-10.12 1.62-15.18-3.25-4.68-9.21-5.36-13.85-2.35-2 1.31-.14 4.56 1.89 3.24 2.73-1.77 6.08-2 8.42.62 3.17 3.53 3.53 8.8-1.13 11.07-3.1 1.51-8 1.5-9.74-1.92-1.86-3.58.08-7.64 2.83-10.07 1.81-1.6-.85-4.25-2.65-2.65h.05zM5.91 243.85a7.72 7.72 0 0 0-3.41 4.69 9.21 9.21 0 0 0-.44 4c.5 3.66 4.3 6.4 8 6.12a9.44 9.44 0 0 0 8-6.75 6.24 6.24 0 0 0 .27-2.67A6.53 6.53 0 0 0 17 246.3a9.07 9.07 0 0 0-5.47-3.66 6.09 6.09 0 0 0-5.57 1.2"/><path d="M5 242.23c-4.46 2.94-6.47 9.72-3.22 14.27a9.75 9.75 0 0 0 13.11 2.41c4.55-2.86 7-8.88 3.72-13.54-3.12-4.45-9-6.21-13.61-3.13-2 1.33-.12 4.58 1.89 3.24 2.81-1.87 5.95-1.12 8.16 1.42 2.55 2.93 1 6.66-1.88 8.64a6 6 0 0 1-7.9-.66c-2.48-2.69-1.28-7.49 1.62-9.4 2-1.32.13-4.57-1.89-3.24v-.01zM47.37 217.3a123 123 0 0 1-.16 17.1c-.19 2.41 3.56 2.39 3.75 0a123 123 0 0 0 .16-17.1c-.14-2.4-3.9-2.41-3.75 0z"/><path d="M34 236.38c5.113 0 10.22-.147 15.32-.44 2.4-.15 2.41-3.9 0-3.75-5.107.313-10.213.46-15.32.44-2.41 0-2.42 3.74 0 3.75z"/><path d="M31.71 235.2c.34 5.23.377 10.476.11 15.71-.12 2.41 3.63 2.41 3.75 0 .267-5.234.23-10.48-.11-15.71-.16-2.4-3.91-2.41-3.75 0z"/><path d="M18.89 254.41a77.94 77.94 0 0 0 16-2.78c2.32-.66 1.33-4.28-1-3.62a73.07 73.07 0 0 1-15 2.65c-2.39.16-2.41 3.92 0 3.75zM112.69 250.09l-1.09-.09L96 263.51a5.2 5.2 0 0 0-1.5 1.77c-1 2.26 1.55 2.33.56 6.76L95 273c10.42 7.36 24.1 16.29 35 23.41 11.25-7.32 21.61-14.31 32.62-22.25a495 495 0 0 0-49.93-24.07z"/><path d="M112.69 248.22c-2.88-.36-4.64 2.34-6.67 4.11-3.31 2.88-6.65 5.72-9.94 8.63-1.79 1.58-3.83 3.08-3.65 5.72.122.515.272 1.022.45 1.52.24 1.37 0 3 .26 4.31.49 2.14 1.55 2.55 3.46 3.88 3.68 2.56 7.387 5.083 11.12 7.57 7.06 4.73 14.17 9.38 21.28 14a1.84 1.84 0 0 0 1.89 0c11-7.19 21.93-14.56 32.62-22.25a1.9 1.9 0 0 0 0-3.24 522.93 522.93 0 0 0-50.32-24.24c-2.23-.93-3.2 2.7-1 3.62a514 514 0 0 1 49.42 23.86v-3.24a805.027 805.027 0 0 1-24.72 17.06c-1.58 1-4.46 3.88-6.3 4.15-1.84.27-3.57-1.49-5.11-2.5a1547.477 1547.477 0 0 1-12.24-8.07c-3.82-2.54-7.623-5.103-11.41-7.69-1-.67-4.42-2.37-4.8-3.09-.17-.33.16-2.28 0-3.19-.21-1.5-.76-1.86-.23-3.32.78-2.14 3.73-3.87 5.43-5.35l5.77-4.87c1-.91 3.34-3.8 4.73-3.63 2.35.3 2.33-3.46-.04-3.75zM357.3 253.6c-3-7-13.12-11.54-19.61-15.41-6.49-3.87-41-16.52-48.22-18.64l-85.32.37c-2.61 1.81-4.41 4.55-6.06 7.26-6.81 11.16-14.45 21.93-19.21 34 11 9.85 21 21.17 31.65 31.9l.49-.6c9.46-12.25 24.22-35 25.49-37.24 21.38 6.62 58 18.29 80.1 21.53-9.22 20.17-24.36 39.37-30.14 60.79l-.43 1.5a816.377 816.377 0 0 1 30.5-1.57l2.4-.51a467.6 467.6 0 0 1 26.12-40.1 118.42 118.42 0 0 0 9-13.33c1.34-2.5 3.05-5.22 3.78-8 .68-2.57 1.4-2.62 1.49-5.28.17-6.27.44-10.88-2.03-16.67z"/><path d="M358.92 252.65c-4-8.11-13.9-12.81-21.55-16.78-6.28-3.26-13.09-5.68-19.68-8.21-7.77-3-15.57-5.88-23.45-8.55a22 22 0 0 0-8-1.43l-76.48.33c-4.86 0-6.86-.38-10.31 3.86-4.41 5.42-7.82 12-11.48 18a141.66 141.66 0 0 0-10.88 20.85 1.82 1.82 0 0 0 .48 1.82c11.13 10 21.11 21.25 31.65 31.9a1.89 1.89 0 0 0 2.65 0 315.77 315.77 0 0 0 26.27-38.22l-2.12.86c26.27 8.14 52.83 17.41 80.1 21.53l-1.12-2.77c-9.87 21.24-24.25 40-30.75 62.73a1.89 1.89 0 0 0 1.81 2.37c6.667-.46 13.333-.847 20-1.16 3.86-.18 9.79.82 13.39-1 1.48-.74 2-2.39 2.83-3.78 1.64-2.773 3.307-5.53 5-8.27a439.277 439.277 0 0 1 10.35-16c11.51-17.09 30.23-35.27 21.49-57.61-.87-2.22-4.5-1.26-3.62 1 3.89 9.95 2 19.48-3.1 28.51-5.2 9.22-12.1 17.44-18 26.21a471.312 471.312 0 0 0-8.44 13 590.649 590.649 0 0 0-4.24 6.86c-1 1.67-2.23 5-3.6 6.27-1.13 1.07-3.74.77-5.39.84-2.86.113-5.72.237-8.58.37-6.04.293-12.073.65-18.1 1.07l1.81 2.37c6.41-22.44 20.64-40.9 30.38-61.84.45-1 .18-2.56-1.12-2.75-27.27-4.12-53.83-13.39-80.1-21.53a1.9 1.9 0 0 0-2.12.86 309.46 309.46 0 0 1-25.68 37.46h2.65c-5.3-5.36-10.46-10.85-15.69-16.28-2.59-2.69-5.2-5.37-7.86-8a165.779 165.779 0 0 0-4-3.87c-2.22-2.09-3.17-2.4-2.06-5.56 2.19-6.26 6.42-12.25 9.9-17.85 1.89-3 3.82-6 5.7-9.07 1.4-2.25 4-8.21 6.37-9.15 1.93-.77 6-.24 8.44-.25l19.44-.08 44.8-.19c3.88 0 8-.45 11.85-.05 4.33.45 9.54 3 13.84 4.59 9.51 3.44 19 6.94 28.33 11a97.84 97.84 0 0 1 9.15 4.7c5.8 3.28 12.57 6.58 15.63 12.84 1.03 2.12 4.29.22 3.21-1.95z"/><path d="M298.64 338.34c-1.74 6.14-2.92 11.58-8.72 15.2a27.91 27.91 0 0 1-7.5 3.09c-2 .54-5.71 2.07-7.7 1.49l.83 3.13.17-.17h-2.65l.12.12c1.68 1.74 4.33-.91 2.65-2.65l-.12-.12a1.9 1.9 0 0 0-2.65 0l-.17.17a1.9 1.9 0 0 0 .83 3.13c2.83.82 6.94-.73 9.69-1.49a31.47 31.47 0 0 0 9-3.85c6.33-4.16 7.85-10.11 9.82-17.05.66-2.33-3-3.32-3.62-1h.02z"/><path d="M289.79 339c-2.43 5.46-5.69 10-12.25 9.76-2.41-.07-2.41 3.68 0 3.75 7.85.23 12.51-4.94 15.49-11.62 1-2.19-2.26-4.1-3.24-1.89zM273.32 362.46c5.44.85 10.61 3.83 14.73 7.45 4.12 3.62 5 8.81 5.73 14.06.32 2.38 3.94 1.37 3.62-1-.8-5.92-2-11.62-6.7-15.71-4.7-4.09-10.28-7.46-16.38-8.41-2.36-.37-3.38 3.24-1 3.62v-.01zM271.11 410.51a10.07 10.07 0 0 0 2.64 15.82 9.34 9.34 0 0 0 5.6 1 9.75 9.75 0 0 0 4.57-2 10.85 10.85 0 0 0 4.07-9.93 10 10 0 0 0-7-8c-3.61-1-7.8 0-9.9 3.11"/><path d="M269.78 409.19c-4.28 5.13-4.41 12.49.74 17.16a11.43 11.43 0 0 0 16.48-1.42 12.22 12.22 0 0 0-.82-16.66c-4.62-4.38-12.42-4.09-16.41.92-1.49 1.87 1.15 4.54 2.65 2.65 6.22-7.83 17.42 1.47 12.45 9.63-6.08 10-19.73-.91-12.45-9.63 1.54-1.84-1.1-4.51-2.65-2.65h.01zM305.42 406.64c-2.56 1.14-4.17 3.73-5.14 6.35-.97 2.62-1.41 5.43-.44 8a8.77 8.77 0 0 0 7.8 5.3 12.29 12.29 0 0 0 12.08-11 8.65 8.65 0 0 0-2.86-7.1 11 11 0 0 0-5.39-2.2c-2.68-.43-5.71-.19-7.65 1.71"/><path d="M304.47 405c-5.67 3.11-9.13 12-5.78 17.89 3.46 6.09 11.58 6.43 16.87 2.72 5.29-3.71 8.14-11.52 4.05-17.17-3.75-5.18-12.26-6-17.12-2.12-1.88 1.51.79 4.15 2.65 2.65 3.68-3 10.83-1.07 12.35 3.4 1.4 4.13-1.32 8.62-4.87 10.69-3.55 2.07-8.54 1.77-10.69-2.06-2.33-4.16.59-10.65 4.44-12.76 2.12-1.16.23-4.4-1.89-3.24h-.01zM333.21 409.48a13.16 13.16 0 0 0-1.93 7.09 7.91 7.91 0 0 0 3.43 6.3 8.36 8.36 0 0 0 3.13 1.11 10.8 10.8 0 0 0 10.16-3.84 9.12 9.12 0 0 0 .3-10.61 10.25 10.25 0 0 0-5.32-3.58 9.73 9.73 0 0 0-6.13 0 6.31 6.31 0 0 0-4.07 4.39"/><path d="M331.59 408.53c-2.88 5.48-3.41 13.37 3.16 16.46a12.53 12.53 0 0 0 15.42-4.77 10.78 10.78 0 0 0-3.78-14.8c-5.51-3.24-13.29-2.26-15.45 4.38-.75 2.3 2.87 3.29 3.62 1 2.82-8.7 19.27.43 11.42 8.79a9 9 0 0 1-8.73 2.34c-5.38-1.58-4.41-7.71-2.42-11.51 1.12-2.14-2.12-4-3.24-1.89zM285.21 408.94L299 397.32h-2.65a567.834 567.834 0 0 1 8.7 8.77c1.68 1.74 4.33-.92 2.65-2.65a567.834 567.834 0 0 0-8.7-8.77 1.92 1.92 0 0 0-2.65 0l-13.83 11.62c-1.85 1.55.81 4.2 2.65 2.65h.04z"/><path d="M310.09 404.77l10.63-8.66c1.87-1.52-.8-4.16-2.65-2.65l-10.63 8.66c-1.87 1.52.8 4.16 2.65 2.65z"/><path d="M319.49 397.77c2.6.87 4.86 2.8 7.1 4.33l3.49 2.37c.84.57 2.89 1.45 3.45 2.24l.29-2.27-.09.07h2.65l-.07-.06c-1.85-1.52-4.52 1.12-2.65 2.65l.07.06a2 2 0 0 0 2.65 0l.09-.07a1.87 1.87 0 0 0 .29-2.27c-1.68-2.36-5.4-4-7.78-5.62-2.72-1.85-5.35-4-8.5-5-2.29-.77-3.28 2.85-1 3.62l.01-.05zM315.16 337.94c-2.34 4.72-6 8.65-5.57 14.31.41 4.85 4.37 8.22 8.9 9.22 2.35.52 3.36-3.1 1-3.62-4.9-1.08-7.29-5.16-5.52-10.12 1-2.77 3.11-5.25 4.42-7.9 1.07-2.16-2.16-4.06-3.24-1.89h.01zM138.65 200.6c2.947 1.793 5.8 3.723 8.56 5.79 1.93 1.45 3.8-1.81 1.89-3.24a104.146 104.146 0 0 0-8.56-5.79c-2.07-1.26-4 2-1.89 3.24z"/><path d="M263 397.05c4.13.82 8.304 1.408 12.5 1.76 1.91.16 2.47-2.61.95-3.49-4.29-2.49-10-2.44-13.67-5.84l-1.82 3.13a63.43 63.43 0 0 1 8 3.59l1.44-3.43a55.18 55.18 0 0 1-6.93-2.47l-.95 3.49c12.37.6 24.3-2.34 36.17-5.45 2.37-.62 1.34-4-1-3.62l-22.19 3.88c-2.4.42-1.34 3.94 1 3.62l25.95-3.62-1-3.62-27 7.34c-2.35.64-1.35 4.07 1 3.62l21.65-4.22-1-3.62-23.73 5.06 1 3.62a153.25 153.25 0 0 1 24-5.19l-.5-3.68a158.06 158.06 0 0 1-24.58 6.44c-2.06.37-1.61 3.92.5 3.68a127.33 127.33 0 0 0 25.43-5.5 1.88 1.88 0 0 0-1-3.62 149.53 149.53 0 0 0-24.08 5.54c-2.29.73-1.34 4.26 1 3.62a689.06 689.06 0 0 1 42.83-10.35l-1-3.62a115.68 115.68 0 0 1-22.27 3.49c-2.38.19-2.45 3.72 0 3.75 9.827.107 19.65.047 29.47-.18v-3.75c-8.947.353-17.89.29-26.83-.19V391c8.1-.82 19.28-3 27.08-.12v-3.62a53.24 53.24 0 0 1-23.33-.73l-.5 3.68a79.48 79.48 0 0 1 26.36 1.1l.5-3.68a81 81 0 0 1-27.9-1.94v3.62c9.45-1.56 18.9-2.9 28.13.41l.5-3.68a116 116 0 0 1-25.76-1.04l-.5 3.68c7.78 0 15.5 0 23.13 1.72l.5-3.68a462.094 462.094 0 0 0-34 2.32c-2.34.25-2.47 3.77 0 3.75a130.73 130.73 0 0 0 19.64-1.7c2.1-.34 1.59-3.76-.5-3.68a92.27 92.27 0 0 0-16.31 2c-2 .44-1.62 3.83.5 3.68 13.18-.92 26.36-1.76 39.58-.95v-3.75a184.46 184.46 0 0 1-20.32-.6c-2.43-.21-2.34 3.46 0 3.75A135.53 135.53 0 0 0 342 392c2.36-.21 2.46-3.73 0-3.75l-21.65-.25v3.75l19.13-.5a1.88 1.88 0 0 0 0-3.75c-3.56-.14-13.72.73-15.81-2.72l-1.67 2.86a92.26 92.26 0 0 1 18.17 2.67l.5-3.68a284.125 284.125 0 0 0-24.42.67 1.88 1.88 0 0 0 0 3.75 68.22 68.22 0 0 1 12.58 2.87l1-3.62-11.21-1.67c-1.45-.22-3.14 1.25-2.12 2.75 3.6 5.3 14.67 4.5 20.16 4a1.88 1.88 0 0 0 .5-3.68 65 65 0 0 0-20.35-3c-2.48 0-2.33 3.45 0 3.75a154.13 154.13 0 0 0 19.79 1.29c3.53 0 8.53.93 10.76-2.42.86-1.29-.31-2.7-1.62-2.82a13.42 13.42 0 0 0-7.39 1.34c-2.08.95-.48 3.74 1.44 3.43a32.63 32.63 0 0 0 11.07-3.93c2-1.12.54-3.73-1.44-3.43a51.74 51.74 0 0 0-8.36 2l1.44 3.43 7.33-3.17c1.93-.83.53-4.21-1.44-3.43l-5.8 2.26c-2.19.87-1.38 4.3 1 3.62a21.21 21.21 0 0 0 4.92-2c1.39-.81 3.24-3 4.8-3.22l-1.87-1.87a4.88 4.88 0 0 1-3.49 4.24l2.57 2.57c1.32-2 2.7-4 4.21-5.93l-3.2-1.33c0 7.32-8.57 7.68-13.89 7.71-2.41 0-2.42 3.76 0 3.75a51.6 51.6 0 0 0 10.5-1.12c2-.43 1.62-3.93-.5-3.68l-12.54 1.48c-2.33.28-2.46 3.87 0 3.75a24.08 24.08 0 0 0 14.11-5.29c1.9-1.48-.77-4.11-2.65-2.65a19.68 19.68 0 0 1-11.46 4.19V395l12.54-1.48-.5-3.68a46.61 46.61 0 0 1-9.5 1v3.75c7.6 0 17.7-1.87 17.64-11.46 0-1.48-2.15-2.66-3.2-1.33-1.7 2.16-3.3 4.37-4.8 6.68-1.13 1.75 1 3.24 2.57 2.57 3.32-1.43 4.9-4 5.34-7.48a1.87 1.87 0 0 0-1.87-1.87c-4.05.52-6.7 4.17-10.71 5.32l1 3.62 5.84-2.31-1.45-3.44-7.33 3.17c-1.95.84-.56 4.08 1.44 3.43a51.74 51.74 0 0 1 8.36-2l-1.47-3.41a29.72 29.72 0 0 1-10.17 3.56l1.44 3.43a8.28 8.28 0 0 1 5.49-.83l-1.62-2.82c-1.21 1.82-11.11.54-13.19.46-4.73-.17-9.43-.6-14.12-1.19v3.75a61.77 61.77 0 0 1 19.35 2.86l.5-3.68a43.23 43.23 0 0 1-8.1.15c-1.94-.17-7.51-.32-8.82-2.25l-2.12 2.75 11.21 1.67a1.88 1.88 0 0 0 1-3.62 73.6 73.6 0 0 0-13.58-3v3.75a284.273 284.273 0 0 1 24.42-.67 1.88 1.88 0 0 0 .5-3.68 97.33 97.33 0 0 0-19.19-2.83 1.89 1.89 0 0 0-1.62 2.82c3.25 5.38 13.73 4.37 19 4.58v-3.75l-19.13.5a1.88 1.88 0 0 0 0 3.75L342 392v-3.75a135.53 135.53 0 0 1-28.87-.45v3.75c6.757.573 13.54.774 20.32.6a1.88 1.88 0 0 0 0-3.75c-13.21-.81-26.39 0-39.58.95l.5 3.68a86.6 86.6 0 0 1 15.32-1.9l-.5-3.68a124.08 124.08 0 0 1-18.6 1.55v3.75a459.113 459.113 0 0 1 34-2.32c2-.06 2.64-3.21.5-3.68-8-1.76-16-1.89-24.13-1.86-2 0-2.65 3.35-.5 3.68a120.3 120.3 0 0 0 26.74 1.19 1.88 1.88 0 0 0 .5-3.68c-9.83-3.52-20.07-2.07-30.13-.41-1.87.31-1.71 3.19 0 3.62a84.14 84.14 0 0 0 28.9 2.08c1.94-.2 2.73-3.21.5-3.68a82.64 82.64 0 0 0-27.35-1.23c-1.95.24-2.72 3.12-.5 3.68a57.82 57.82 0 0 0 25.33.73c2-.38 1.57-3 0-3.62-8.34-3.08-19.43-.89-28.08 0a1.88 1.88 0 0 0 0 3.75c8.94.48 17.883.543 26.83.19 2.4-.09 2.42-3.81 0-3.75-9.82.227-19.643.287-29.47.18v3.75c7.86-.63 15.65-1.49 23.27-3.63 2.34-.66 1.35-4.1-1-3.62a689.06 689.06 0 0 0-42.83 10.35l1 3.62a149.53 149.53 0 0 1 24.08-5.54l-1-3.62a122.55 122.55 0 0 1-24.43 5.36l.5 3.68a158.06 158.06 0 0 0 24.58-6.44c1.85-.65 1.73-4-.5-3.68a159.3 159.3 0 0 0-25 5.32 1.88 1.88 0 0 0 1 3.62l23.73-5.06c2.37-.51 1.36-4.08-1-3.62l-21.65 4.22 1 3.62 27-7.34c2.4-.65 1.32-3.94-1-3.62l-25.98 3.61 1 3.62 22.16-3.89-1-3.62c-11.56 3-23.13 5.9-35.18 5.32-1.77-.09-2.67 2.74-.95 3.49a62.59 62.59 0 0 0 7.83 2.85c2 .58 3.34-2.41 1.44-3.43a70.39 70.39 0 0 0-8.87-4c-1.77-.65-3.16 1.91-1.82 3.13 4.15 3.81 9.72 3.7 14.43 6.43l.95-3.49c-3.86-.324-7.7-.865-11.5-1.62-2.36-.47-3.37 3.15-1 3.62l.01.04z"/></g></svg> -------------------------------------------------------------------------------- /assets/wouter.svg: -------------------------------------------------------------------------------- 1 | <svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1088 952"><defs><style>.cls-1,.cls-2{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;stroke-width:4px}.cls-2{fill:#fff}</style></defs><path d="M99.4 644.39C44 607.42 34.68 500.23 35 442c.54-85.13 20.63-173.91 60.81-249.26C141 108 209 58.94 304.16 54.23c61.51-3 121.26-3.29 182.72-1.73l121.57 3.09c20.94.54 42.39 1.17 61.86 8.91 33.37 13.25 55.94 45.91 66.06 80.36s9.61 71 9 106.9q-1.12 67.94-2.26 135.88c60.22.59 124-1 184.12-4.19-.65-34.45-2.64-72.51-3.29-107-6.2 24.19-24.78 45.71-48.94 52s-52.74-5.06-63.14-27.77c-6.13-13.39-5.79-28.8-4.08-43.42.92-7.92 2.39-16.23 7.52-22.35s15.05-8.87 21.21-3.81c3.53 2.9 5 7.53 6.64 11.81 4.73 12.57 14.28 25.66 27.71 25.77 11.07.09 20.18-8.89 25.63-18.52 8.48-15 11-34.4 2.48-49.37s-30.06-22.4-44.24-12.6c-9.14 6.32-14.67 18.53-25.6 20.56-9.26 1.73-18.46-5.51-21.53-14.41s-1.24-18.79 2.08-27.59c7.63-20.23 24.09-37.85 45.06-43.1s45.76 4.25 54.8 23.89c1.89 4.1-2-2.2.55 1.53 6.51-41.33-33.32-65.91-69.22-65.92-9.53 0-19.47.45-28.13-3.52-42-19.31 22.25-57.16 46.43-53.4 21.7 3.37 40.51 18.22 52.31 36.74s17.3 40.44 19.85 62.26a81.84 81.84 0 00-8.83-73.92c-5.48-8-12.83-16.66-10.65-26.12 2.72-11.77 18.51-15.27 30-11.64 17.45 5.55 31.35 21.99 36.73 39.15 3.35 10.69 2.72 23.7 3.7 34.88l3.33 37.73 6.76-64.79c.65-6.22 1.32-12.5 3.25-18.46 6-18.61 24.63-23.64 33.94-5.42 6.19 12.12 8.51 27.05 9.11 40.55q2.44 55.32 3.66 110.68 2.49 112.71 0 225.44c-.44 19.94-1 40-5.41 59.49-9.07 40.18-29.74 69.35-71 81.6 26 9.45 45.15 32.72 55.62 58.33s13.36 53.6 15.32 81.2c4.6 65.09 2.48 136.81-39.91 186.43-18.1 21.18-61.06 21.81-86.75 24a4157.15 4157.15 0 01-456.23 13.58c-14.84-.37-29.88-.85-44.12-5a88.11 88.11 0 01-51.62-41.44c-44 14.83-89.49 35.78-133.5 50.61-28.08 9.46-56.5 19-86 21.4s-60.75-3-84.33-21c-9-6.84-16.88-15.64-20.79-26.25-8.62-23.45 3.84-49.67 20.59-68.2 14.83-16.41 33-29.42 51.67-41.33 42.81-27.38 95.84-59.5 146.43-69-18.87-4.77-36.18-9.59-50-24.28a125.34 125.34 0 01-13.92-18.27c-2.13-3.3-4.16-6.66-6.15-10-.43-.72-3.86-7.44-4.23-7.43-14.27.28-28.59.56-42.83-.77a76.28 76.28 0 01-35.77-12.59z"/><path d="M530.35 44.55a24.79 24.79 0 0119.32.47 18 18 0 018.61 7.13c1.89 3.28 2.18 7.63.1 10.79-2.21 3.35-6.41 4.7-10.36 5.41-8 1.45-20.94 1.63-26.86-5.24s2.84-15.86 9.19-18.56z"/><path class="cls-1" d="M204.67 509.63c6-85.14 14.83-171.24 25.62-255.9 6.25-49 13.68-99.18 37.15-142.7 3.45-6.39 7.55-12.92 14-16.19 4.94-2.5 12.95-2.55 16.86 2 4.53 5.24 2.93 14.06 1.66 20.14-4.25 20.47-10.28 56.09-14.53 76.55 3.32-19.64 9.8-54.21 15.82-73.2C305 108.66 309 97.13 319.1 89.4c7.71-5.9 21.06-9.81 26.42 1 3.65 7.39-.76 17.46-3.88 24.27-4 8.66-6.56 17.86-9.32 27q-8.43 27.76-14.82 56.11c5.37-16 9.35-32.55 15.24-48.41 6.61-17.77 15.62-35.86 28.46-49.87 8.24-9 26.38-26.57 39.67-16.5 15.95 12.1-6.2 37.31-13.07 47.72-14.23 21.59-28 43.7-37.94 67.64 12.3-26.85 27.89-55 48.64-76.28 9.7-9.94 21.37-21 35.45-23.92 13.05-2.69 32.59 6.64 25.05 22.52-2.79 5.9-8.24 10-13.44 14-18.52 14.09-36.44 29.19-51.5 46.93-13.29 15.65-24.36 33.47-30.8 53-7.12 21.68-9.65 44.74-11.86 67.41-3.79 38.95-6.36 78.11-4.82 117.21 1.21 30.63 4.94 61.23 3.61 91.85s-8.16 61.95-26 86.87a111.66 111.66 0 01-28.64 27.76c-25.24 16.89-56.09 22.74-86.11 27.35-28.36 4.37-57.59 7.93-85.52 1.34S69.3 629 61 601.55M597.46 323.84l-3.27 80.77c-.8 19.82-1.6 39.81 1.68 59.37s10.94 38.93 25 53C646.28 542.5 686 545.38 722 546.73L940.89 555c19 .71 37.3-.62 53.77-10.49 15.15-9.07 27.86-21.87 38.42-35.9M803.15 189.63c2.65 5.13 9 7.78 14.7 6.75s10.48-5.33 12.85-10.6a26.38 26.38 0 001.15-17c-2.46-10.16-10.48-19.33-20.79-21.09M796.63 49c.13 4.28 2.89 8.16 6.42 10.59 8.77 6.05 21.82 3.44 28.88-4.54s8.42-20 5-30.09c-.65-1.91-2.1-4.14-4.07-3.71M936.23 8A19.26 19.26 0 00933 25.45a19.26 19.26 0 0012.6 12.46 23.23 23.23 0 0015.48-1.26 40.38 40.38 0 0012.81-9.15M1026.27 25.54c-6.4 5.34-11 13.83-9 21.92 1.58 6.48 7.21 11.51 13.6 13.43s13.37 1 19.6-1.36M806.57 239.72c13.1-8.58 19.36 3.92 20.81 15.52a32.83 32.83 0 01.08 9.18 21.9 21.9 0 01-29.74 17"/><path class="cls-1" d="M459.3 120.07c45.51-4 59 48.75 65.82 82.84 36.93 184.18-6.68 376.91 26.61 561.78 96.74-.76 198.22 6.94 294.74 13.52 48.88 3.33 154.15.54 159.26 68.22 4.21 55.72-66 68.75-107.37 76.85"/><path class="cls-1" d="M357.16 848c20.48 33.42 65.06 36.68 99.36 41.85 17.69 2.66 35.67 2.45 53.55 2.23l384.33-4.73c20.66-.25 41.57-.54 61.52-5.91s39-16.33 49.9-33.71M541 675.29c116.43 3.26 186.81 5 342.34 6M539 544c51.32.74 114.57 1.19 165.89 1.93M63.65 810.45a940 940 0 01139-75.37c49.29-21.59 102.28-37.16 153.5-53.76M911.65 655.94a149.89 149.89 0 00-27.91 25.21c7.48 11.27 16.76 22.06 27.39 30.44M631.11 48.32c14.66 9 28 17 35.85 33 7 14.29 8.71 30.73 7.16 46.56-.64 6.52-1.87 13.12-5 18.86-4.42 8-12.35 13.7-21 16.83s-17.89 3.89-27 3.88a82.94 82.94 0 01-1.38 21.73c-1.05 5.3-2.9 10.89-7.29 14-4.23 3-10.06 3.06-14.92 1.2s-8.91-5.38-12.53-9.06a101.39 101.39 0 01-17.52-24.25c-.23 4.27-3.19 8.67-6.87 11.6A89.28 89.28 0 01525 199.84"/><path class="cls-1" d="M517.75 58.23c2.64-9.71 10.8-16.68 21.36-16.21C551 42.55 560 52.56 567.24 62a661.65 661.65 0 0138 54.8"/><path class="cls-2" d="M622.39 112.21c-1.25.11-2.4.26-3.38.4a45.31 45.31 0 00-23.76 10.77c-4.67 4.2-9 9.91-8.21 16.15.66 5.29 5 9.61 10.05 11.36s10.6 1.28 15.75-.07c7.33-1.91 15.43-3.88 21.22-9.42 6.78-6.48 13.4-21.17 1.93-26.94a26 26 0 00-13.6-2.25zM623.32 169.38c-2.59 3.13-2.16 9.57-1.83 13.05a7.41 7.41 0 002.31 5c1.52 1.29 3.62 1.63 5.62 1.79A49.65 49.65 0 00653.9 185c5-2.29 11.89-7.35 12.55-13.37.73-6.77-7-11.16-12.46-9.56-7.7 2.24-14.46 5.89-22.77 5.63a24.57 24.57 0 00-5 0 4.79 4.79 0 00-2.9 1.68z"/><path class="cls-1" d="M620.3 144.65a8 8 0 015.27 1.79 24.14 24.14 0 019.11 26M264.72 117.08c4.93 2.47 11.22 1.51 15.56-1.87a19.13 19.13 0 007-14.43c.07-2.4-.33-5-2-6.73M308 103.64a16.18 16.18 0 0015.63 5.52 17.05 17.05 0 004.92-1.91 18.59 18.59 0 007.74-23.65M367.87 93a10.94 10.94 0 005.37 5.34 19.93 19.93 0 0024-5.17c2.18-2.75 3.61-6.4 2.64-9.77M196.88 766.45c20.56 26.08 39.8 53.71 51.69 84.71M255.29 737.75A285.07 285.07 0 01305 829.63M310.66 713.63a812.1 812.1 0 0147.07 77.6M624 636.51c3.39-5.66 7.43-11.32 13.34-14.26s14.12-2.2 17.84 3.24c11.26 16.46-8.77 55.37-14.74 71.67-9.56 26.11-19.87 52.65-20.54 80.82.71-29.86 19.52-66.19 33.62-91.73 10.44-18.92 20.82-59.58 46.18-63a9.52 9.52 0 015.83.71c2.18 1.18 3.39 3.6 4.11 6 3.48 11.37-1.37 23.5-6.69 34.14a330 330 0 01-35.66 56.25c-7.86 10-16.32 19.49-24.17 29.48-6.71 8.55-13.2 24.19-22 29.81-15.85-12.4-17.95-51.85-17.59-70.53a146.94 146.94 0 0120.47-72.6zM687.22 768.51q-3.51 33.12-8.82 66M752.19 774C750 798.47 747 816.65 743 841.9M819.55 776.94l-11.17 69.46M543.76 193.25c-16.74-26-14.92-61.2 3.91-85.5M188.56 531.29c5.34-7.94 10.76-13.72 16.11-21.66 4 8.23 8 14.3 12 22.53M414.14 107.73c12.62 9.43 31.53 9.3 44-.31M684 90.09a29.06 29.06 0 01-9.32 16.38c5.22-1.33 10.82 1.58 14.07 5.87s4.61 9.71 5.54 15M586.52 306.94c4 5.39 7.41 11.2 11 16.86A151.07 151.07 0 01613 303.61"/><path d="M53.3 835c-13.3 16.33-23.58 37.59-18.71 58.13 4.61 19.43 21.88 33.65 40.41 41.08 47.48 19 103.78 5.29 150.26-10.35 16.09-5.41 31.81-11.82 47.53-18.23l66.62-27.14c6.47-2.64 13-5.28 19.29-8.26" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" fill="none"/><path class="cls-1" d="M379.38 903.5c-23.23-18.18-22-37.3-21.61-64.12 1.49-98.06-2-232-7.46-330.88"/><path class="cls-1" d="M357.16 848C278 895 184.61 923.66 92.77 916.75c-12.8-1-25.72-2.64-37.64-7.4S31.31 897.22 29 883.76a106.53 106.53 0 011.2-42M921.85 293.15q64.59-35.92 130.43-69.51M920.4 310.67q65.19-39.76 132.19-76.47M224.11 304.24c45.67-14 87.46-32.88 133.72-49M222.41 329q66.59-26.37 131.93-55.53M358.28 748.35c11.6-1.2 23.1 3.36 33 9.48s18.8 13.83 28.73 19.95A107.77 107.77 0 00487.88 793c23.46-2.62 47.32-11.35 64.2-27.86M42.12 902.38a131.85 131.85 0 012.25 26.68M59.05 912l2.11 21.84M84 916.8l1 26.64M118.36 919.47a204.08 204.08 0 002 34.67M150.2 918.39q1.61 16.74 1.8 33.61M180.7 913.6l.82 28.78M207 909.35a153.66 153.66 0 001.11 24M233 903l1.88 19.15M256.47 895.51a137.76 137.76 0 01.71 23.94M274.39 889.61a107.33 107.33 0 01.5 22.38M290.48 883.26l1.12 18.64M303.56 876.88q.45 9.06 1.65 18.06M319.61 870.45a132.37 132.37 0 00.46 19.71M330.13 864.07a111.86 111.86 0 002.46 22.93M340.39 858.71a165.22 165.22 0 011.89 21.88M350.65 853.43l1.84 29.28M367.76 861.41q.48 15.23 2.24 30.36M381.06 871l2.54 39.43M401.3 880.59l1 40.49M436 888.06a252.41 252.41 0 002.47 45.3M470.37 891.25a103.72 103.72 0 002.21 28M505.58 892a188.61 188.61 0 011.33 27.2M548.19 892q.94 15.84 1.49 31.7M589.13 892q1 16.1.58 32.23M632.38 891.51q-.2 16.9.37 33.83M671.44 890.44q.45 16.78 1.48 33.56M713.11 890.45q.51 16.77-.57 33.55M993.35 862.67v30.17M978.08 873.06a237 237 0 01-3 29.35M959.13 880.85A167.46 167.46 0 01957 907M932.58 885.45q-.66 13.57-.53 27.17M893.44 887.34A266.55 266.55 0 01894 932M838.46 888.84l2 43.94M796.48 889.36c.84 14.65 2 30.38 2.88 45M755.08 889.36c.69 12.79 1.1 26.65 1.79 39.44"/></svg> -------------------------------------------------------------------------------- /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 <molefrog@gmail.com>", 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 <molefrog@gmail.com>", 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 | <Router ssrPath="/ssr/preact"> 15 | <LocationPrinter /> 16 | </Router> 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 | <nav> 27 | <Link href="/albums/all" onClick={fn} data-testid="index-link"> 28 | The Best Albums Ever 29 | </Link> 30 | 31 | <Link 32 | to="/albums/london-calling" 33 | asChild 34 | onClick={handleAsChildClick} 35 | > 36 | <a data-testid="featured-link"> 37 | Featured Now: London Calling, Clash 38 | </a> 39 | </Link> 40 | </nav> 41 | 42 | <main data-testid="routes"> 43 | <Switch> 44 | <>Welcome to the list of {100} greatest albums of all time!</> 45 | <Route path="/albums/all">Rolling Stones Best 100 Albums</Route> 46 | <Route path="/albums/:name"> 47 | {(params) => `Album ${params.name}`} 48 | </Route> 49 | <Route path="*">Nothing was found!</Route> 50 | </Switch> 51 | </main> 52 | </> 53 | ); 54 | }; 55 | 56 | let node = render(<App />, 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<T extends string> = RouteParams<T> & { 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 extends DefaultParams = DefaultParams> = T; 46 | 47 | export type MatchWithParams<T extends DefaultParams = DefaultParams> = [ 48 | true, 49 | Params<T> 50 | ]; 51 | export type NoMatch = [false, null]; 52 | export type Match<T extends DefaultParams = DefaultParams> = 53 | | MatchWithParams<T> 54 | | NoMatch; 55 | 56 | /* 57 | * Components: <Route /> 58 | */ 59 | 60 | export interface RouteComponentProps<T extends DefaultParams = DefaultParams> { 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<RoutePath> 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<RoutePath> 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<T, RoutePath>): ReturnType<FunctionComponent>; 94 | 95 | /* 96 | * Components: <Link /> & <Redirect /> 97 | */ 98 | 99 | export type NavigationalProps< 100 | H extends BaseLocationHook = BrowserLocationHook 101 | > = ({ to: Path; href?: never } | { href: Path; to?: never }) & 102 | HookNavigationOptions<H>; 103 | 104 | type AsChildProps<ComponentProps, DefaultElementProps> = 105 | | ({ asChild?: false } & DefaultElementProps) 106 | | ({ asChild: true } & ComponentProps); 107 | 108 | type HTMLLinkAttributes = Omit<JSX.HTMLAttributes, "className"> & { 109 | className?: string | undefined | ((isActive: boolean) => string | undefined); 110 | }; 111 | 112 | export type LinkProps<H extends BaseLocationHook = BrowserLocationHook> = 113 | NavigationalProps<H> & 114 | AsChildProps< 115 | { children: ComponentChildren; onClick?: JSX.MouseEventHandler<Element> }, 116 | HTMLLinkAttributes 117 | >; 118 | 119 | export type RedirectProps<H extends BaseLocationHook = BrowserLocationHook> = 120 | NavigationalProps<H> & { 121 | children?: never; 122 | }; 123 | 124 | export function Redirect<H extends BaseLocationHook = BrowserLocationHook>( 125 | props: RedirectProps<H>, 126 | context?: any 127 | ): null; 128 | 129 | export function Link<H extends BaseLocationHook = BrowserLocationHook>( 130 | props: LinkProps<H>, 131 | context?: any 132 | ): ReturnType<FunctionComponent>; 133 | 134 | /* 135 | * Components: <Switch /> 136 | */ 137 | 138 | export interface SwitchProps { 139 | location?: string; 140 | children: ComponentChildren; 141 | } 142 | export const Switch: FunctionComponent<SwitchProps>; 143 | 144 | /* 145 | * Components: <Router /> 146 | */ 147 | 148 | export type RouterProps = RouterOptions & { 149 | children: ComponentChildren; 150 | }; 151 | 152 | export const Router: FunctionComponent<RouterProps>; 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<RoutePath> 170 | : RegexRouteParams 171 | >; 172 | 173 | export function useLocation< 174 | H extends BaseLocationHook = BrowserLocationHook 175 | >(): HookReturnValue<H>; 176 | 177 | export function useSearch< 178 | H extends BaseSearchHook = BrowserSearchHook 179 | >(): ReturnType<H>; 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 = undefined>(): T extends string 195 | ? StringRouteParams<T> 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<RoutePath> 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<H extends BaseLocationHook> = ReturnType<H>; 25 | 26 | // Returns the type of the navigation options that hook's push function accepts. 27 | export type HookNavigationOptions<H extends BaseLocationHook> = 28 | HookReturnValue<H>[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<S = any> = ( 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: <S extends Primitive>( 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 = any>() => T; 18 | 19 | export const navigate: <S = any>( 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<S = any>( 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 <molefrog@gmail.com>", 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 <Router /> 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 <Link /> 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 <AnimatedRoute />. 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 | <Router ssrSearch="?foo=1"> 18 | <App /> 19 | </Router> 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(<App />); 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 | <Router ssrPath="/hello-from-server"> 43 | <App /> 44 | </Router> 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("<Link /> types", () => { 11 | it("should have required prop href", () => { 12 | // @ts-expect-error 13 | <Link>test</Link>; 14 | <Link href="/">test</Link>; 15 | }); 16 | 17 | it("does not allow `to` and `href` props to be used at the same time", () => { 18 | // @ts-expect-error 19 | <Link to="/hello" href="/world"> 20 | Hello 21 | </Link>; 22 | }); 23 | 24 | it("should inherit props from `HTMLAnchorElement`", () => { 25 | <Link to="/hello" className="hello"> 26 | Hello 27 | </Link>; 28 | 29 | <Link to="/hello" style={{}}> 30 | Hello 31 | </Link>; 32 | 33 | <Link to="/hello" target="_blank"> 34 | Hello 35 | </Link>; 36 | 37 | <Link to="/hello" download ping="he-he"> 38 | Hello 39 | </Link>; 40 | }); 41 | 42 | it("can accept function as `className`", () => { 43 | <Link 44 | href="/" 45 | className={(isActive) => (isActive ? "active" : "non-active")} 46 | />; 47 | 48 | <Link 49 | href="/" 50 | className={(isActive) => (isActive ? "active" : undefined)} 51 | />; 52 | }); 53 | 54 | it("should support other navigation params", () => { 55 | <Link href="/" state={{ a: "foo" }}> 56 | test 57 | </Link>; 58 | 59 | <Link href="/" replace> 60 | test 61 | </Link>; 62 | 63 | // @ts-expect-error 64 | <Link to="/" replace={{ nope: 1 }}> 65 | Hello 66 | </Link>; 67 | 68 | <Link href="/" state={undefined}> 69 | test 70 | </Link>; 71 | }); 72 | 73 | it("should work with generic type", () => { 74 | <Link<NetworkLocationHook> href="/" host="wouter.com"> 75 | test 76 | </Link>; 77 | 78 | // @ts-expect-error 79 | <Link<NetworkLocationHook> href="/">test</Link>; 80 | 81 | <Link<NetworkLocationHook> href="/" host="wouter.com" retries={4}> 82 | test 83 | </Link>; 84 | }); 85 | }); 86 | 87 | describe("<Link /> with ref", () => { 88 | it("should work", () => { 89 | const ref = React.useRef<HTMLAnchorElement>(null); 90 | 91 | <Link to="/" ref={ref}> 92 | Hello 93 | </Link>; 94 | }); 95 | 96 | it("should have error when type is `unknown`", () => { 97 | const ref = React.useRef(); 98 | 99 | // @ts-expect-error 100 | <Link to="/" ref={ref}> 101 | Hello 102 | </Link>; 103 | }); 104 | 105 | it("should have error when type is miss matched", () => { 106 | const ref = React.useRef<HTMLAreaElement>(null); 107 | 108 | // @ts-expect-error 109 | <Link to="/" ref={ref}> 110 | Hello 111 | </Link>; 112 | }); 113 | }); 114 | 115 | describe("<Link /> with `asChild` prop", () => { 116 | it("should work", () => { 117 | <Link to="/" asChild> 118 | <a>Hello</a> 119 | </Link>; 120 | }); 121 | 122 | it("does not allow `to` and `href` props to be used at the same time", () => { 123 | // @ts-expect-error 124 | <Link to="/hello" href="/world" asChild> 125 | <a>Hello</a> 126 | </Link>; 127 | }); 128 | 129 | it("can only have valid element as a child", () => { 130 | // @ts-expect-error strings are not valid children 131 | <Link to="/" asChild> 132 | {true ? "Hello" : "World"} 133 | </Link>; 134 | 135 | // @ts-expect-error can't use multiple nodes as children 136 | <Link to="/" asChild> 137 | <a>Link</a> 138 | <div>icon</div> 139 | </Link>; 140 | }); 141 | 142 | it("does not allow other props", () => { 143 | // @ts-expect-error 144 | <Link to="/" asChild className=""> 145 | <a>Hello</a> 146 | </Link>; 147 | 148 | // @ts-expect-error 149 | <Link to="/" asChild style={{}}> 150 | <a>Hello</a> 151 | </Link>; 152 | 153 | // @ts-expect-error 154 | <Link to="/" asChild unknown={10}> 155 | <a>Hello</a> 156 | </Link>; 157 | 158 | // @ts-expect-error 159 | <Link to="/" asChild ref={null}> 160 | <a>Hello</a> 161 | </Link>; 162 | }); 163 | 164 | it("should support other navigation params", () => { 165 | <Link to="/" asChild replace> 166 | <a>Hello</a> 167 | </Link>; 168 | 169 | // @ts-expect-error 170 | <Link to="/" asChild replace={12}> 171 | <a>Hello</a> 172 | </Link>; 173 | 174 | <Link to="/" asChild state={{ hello: "world" }}> 175 | <a>Hello</a> 176 | </Link>; 177 | }); 178 | 179 | it("should work with generic type", () => { 180 | <Link<NetworkLocationHook> asChild to="/" host="wouter.com"> 181 | <div>test</div> 182 | </Link>; 183 | 184 | // @ts-expect-error 185 | <Link<NetworkLocationHook> asChild to="/"> 186 | <div>test</div> 187 | </Link>; 188 | 189 | <Link<NetworkLocationHook> asChild to="/" host="wouter.com" retries={4}> 190 | <div>test</div> 191 | </Link>; 192 | }); 193 | 194 | it("accepts `onClick` prop that overwrites child's handler", () => { 195 | <Link 196 | to="/" 197 | asChild 198 | onClick={(e) => { 199 | expectTypeOf(e).toEqualTypeOf<React.MouseEvent>(); 200 | }} 201 | > 202 | <a>Hello</a> 203 | </Link>; 204 | }); 205 | 206 | it("should work with `ComponentProps`", () => { 207 | type LinkComponentProps = React.ComponentProps<typeof Link>; 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<LinkComponentProps>().toMatchTypeOf<LinkProps>(); 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("<Link />", () => { 11 | it("renders a link with proper attributes", () => { 12 | const { getByText } = render( 13 | <Link href="/about" className="link--active"> 14 | Click Me 15 | </Link> 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 <a />", () => { 26 | const refCallback = vi.fn<[HTMLAnchorElement], void>(); 27 | const { getByText } = render( 28 | <Link href="/" ref={refCallback}> 29 | Testing 30 | </Link> 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(<Link href="/about" data-testid="link" />); 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(<Link to="/about">Hello</Link>); 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 | <Link href="/goo-baz" data-testid="link"> 63 | link 64 | </Link> 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 | <Link href="/goo-baz" replace data-testid="link"> 75 | link 76 | </Link> 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 | <Link href="/users" data-testid="link"> 90 | click 91 | </Link> 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 | <Link href="/users" data-testid="link" onClick={clickHandler}> 115 | click 116 | </Link> 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 | <Link href="/" onClick={clickHandler} data-testid="link" /> 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 | <Router base="/app"> 137 | <Link href="/dashboard" data-testid="link" /> 138 | </Router> 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 | <Router base="/app"> 148 | <Link href="~/home" data-testid="link" /> 149 | </Router> 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 | <Link href="/goo-baz" state={testState} data-testid="link"> 160 | link 161 | </Link> 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 | <Router hrefs={formatter}> 175 | <Link href="/" data-testid="root" /> 176 | <Link href="/home" data-testid="home" /> 177 | </Router> 178 | 179 | <Router base="/app" hrefs={formatter}> 180 | <Link href="~/home" data-testid="absolute" /> 181 | </Router> 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 | <Link href="/" className="link--active warning"> 195 | Click Me 196 | </Link> 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 | <Router hook={hook}> 208 | <Link 209 | href="/" 210 | className={(isActive) => { 211 | return [isActive ? "active" : "", "link"].join(" "); 212 | }} 213 | > 214 | Click Me 215 | </Link> 216 | </Router> 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 | <Router hook={hook} hrefs={formatter}> 236 | <Link 237 | href="/" 238 | className={(isActive) => { 239 | return [isActive ? "active" : "", "link"].join(" "); 240 | }} 241 | > 242 | Click Me 243 | </Link> 244 | </Router> 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("<Link /> with `asChild` prop", () => { 260 | it("when `asChild` is not specified, wraps the children in an <a />", () => { 261 | const { getByText } = render( 262 | <Link href="/about"> 263 | <div className="link--wannabe">Click Me</div> 264 | </Link> 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 <a />", () => { 279 | const { getByText } = render( 280 | /* @ts-expect-error */ 281 | <Link href="/about" asChild> 282 | Click Me 283 | </Link> 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 <a />", async () => { 294 | const { getByText } = render( 295 | /* @ts-expect-error */ 296 | <Link href="/about" asChild> 297 | <span>1</span> 298 | <span>2</span> 299 | <span>3</span> 300 | </Link> 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 | <Link href="/about" asChild> 314 | <div className="link--wannabe">Click Me</div> 315 | </Link> 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 | <Link>Click Me</Link> 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<typeof hook>; 11 | 12 | expectTypeOf<Options>().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<typeof hook>; 26 | 27 | // @ts-expect-error 28 | expectTypeOf<Options>().toEqualTypeOf<{ 29 | replace: boolean; 30 | foo: string; 31 | }>(); 32 | 33 | expectTypeOf<Options>().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<typeof hook>; 47 | 48 | expectTypeOf<Options>().toEqualTypeOf<{ 49 | replace: boolean; 50 | }>(); 51 | }); 52 | 53 | it("should only support valid hooks", () => { 54 | // @ts-expect-error 55 | type A = HookNavigationOptions<string>; 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<BaseLocationHook>; 64 | expectTypeOf<Options>().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<boolean>(); 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<null>(); 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<BaseLocationHook>(); 9 | 10 | const [location, navigate] = hook(); 11 | 12 | assertType<string>(location); 13 | assertType<Function>(navigate); 14 | }); 15 | 16 | it("should return `navigate` method for navigating outside of components", () => { 17 | const { navigate } = memoryLocation(); 18 | 19 | assertType<Function>(navigate); 20 | }); 21 | 22 | it("should support `record` option for saving the navigation history", () => { 23 | const { history, reset } = memoryLocation({ record: true }); 24 | 25 | assertType<string[]>(history); 26 | assertType<Function>(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<BaseLocationHook>(); 40 | }); 41 | 42 | it("should support `static` option", () => { 43 | const { hook } = memoryLocation({ static: true }); 44 | 45 | expectTypeOf(hook).toMatchTypeOf<BaseLocationHook>(); 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(<Route nest>matched!</Route>); 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 | <Router hook={hook}> 18 | <Route path="/posts/:slug" nest> 19 | matched! 20 | </Route> 21 | </Router> 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 | <Router 39 | hook={ 40 | memoryLocation({ path: "/posts/13/2012/sort", static: true }).hook 41 | } 42 | > 43 | <Switch> 44 | <Route path="/about">about</Route> 45 | <Route path="/posts/:slug" nest> 46 | nested 47 | </Route> 48 | <Route>default</Route> 49 | </Switch> 50 | </Router> 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 | <Router 60 | hook={memoryLocation({ path: "/2012/04/posts", static: true }).hook} 61 | > 62 | <Route path="/:year/:month" nest> 63 | <Route path="/posts">{props.children}</Route> 64 | </Route> 65 | </Router> 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 | <Router 75 | base="/app" 76 | hook={ 77 | memoryLocation({ 78 | path: "/app/users/alexey/settings/all", 79 | static: true, 80 | }).hook 81 | } 82 | > 83 | <Route path="/users/:name" nest> 84 | <Route path="/settings">should not be rendered</Route> 85 | 86 | <Route path="/settings" nest> 87 | <Route path="/all">All settings</Route> 88 | </Route> 89 | </Route> 90 | </Router> 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 | <Router hook={hook}> 105 | <Route path="/app/:company" nest={nested}> 106 | matched! 107 | </Route> 108 | </Router> 109 | ); 110 | }; 111 | 112 | const { container, rerender } = render(<App nested={true} />); 113 | expect(container.innerHTML).toBe("matched!"); 114 | 115 | rerender(<App nested={false} />); 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 | <Router hook={hook}> 127 | <Route path="/:version?" nest> 128 | {({ version }) => version ?? "default"} 129 | </Route> 130 | </Router> 131 | ); 132 | }; 133 | 134 | const { container } = render(<App />); 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 | <Router parser={pathToRegexpParser}>{children}</Router> 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 | <Router 34 | hook={memoryLocation({ path: "/home/users/10/bio" }).hook} 35 | parser={pathToRegexpParser} 36 | > 37 | {children} 38 | </Router> 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(<Redirect />); 8 | assertType(<Redirect href="/" />); 9 | }); 10 | 11 | it("should support state prop", () => { 12 | assertType(<Redirect href="/" state={{ a: "foo" }} />); 13 | assertType(<Redirect href="/" state={null} />); 14 | assertType(<Redirect href="/" state={undefined} />); 15 | assertType(<Redirect href="/" state="string" />); 16 | }); 17 | 18 | it("always renders nothing", () => { 19 | // can be used in JSX 20 | <div> 21 | <Redirect href="/" /> 22 | </div>; 23 | 24 | assertType<null>(Redirect({ href: "/" })); 25 | }); 26 | 27 | it("can not accept children", () => { 28 | // @ts-expect-error 29 | <Redirect href="/">hi!</Redirect>; 30 | 31 | // prettier-ignore 32 | // @ts-expect-error 33 | <Redirect href="/"><><div>Fragment</div></></Redirect>; 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(<Redirect to="/users" />); 21 | expect(container.childNodes.length).toBe(0); 22 | unmount(); 23 | }); 24 | 25 | it("results in change of current location", () => { 26 | const { unmount } = render(<Redirect to="/users" />); 27 | 28 | expect(location.pathname).toBe("/users"); 29 | unmount(); 30 | }); 31 | 32 | it("supports `base` routers with relative path", () => { 33 | const { unmount } = render( 34 | <Router base="/app"> 35 | <Redirect to="/nested" /> 36 | </Router> 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 | <Router base="/app"> 46 | <Redirect to="~/absolute" /> 47 | </Router> 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(<Redirect to="/users" replace />); 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(<Redirect to="/users" state={testState} />); 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 | <Router hook={customHookWithReturn()}> 77 | <Redirect to="/users" replace /> 78 | </Router> 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(<Route />); 9 | }); 10 | 11 | it("should be a string or RegExp", () => { 12 | let a: ComponentProps<typeof Route>["path"]; 13 | expectTypeOf(a).toMatchTypeOf<string | RegExp | undefined>(); 14 | }); 15 | }); 16 | 17 | it("accepts the optional boolean `nest` prop", () => { 18 | assertType(<Route nest />); 19 | assertType(<Route nest={false} />); 20 | 21 | // @ts-expect-error - should be boolean 22 | assertType(<Route nest={"true"} />); 23 | }); 24 | 25 | it("renders a component provided in the `component` prop", () => { 26 | const Header = () => <div />; 27 | const Profile = () => null; 28 | 29 | <Route path="/header" component={Header} />; 30 | <Route path="/profile/:id" component={Profile} />; 31 | 32 | // @ts-expect-error must be a component, not JSX 33 | <Route path="/header" component={<a />} />; 34 | }); 35 | 36 | it("accepts class components in the `component` prop", () => { 37 | class A extends React.Component<{ params: {} }> { 38 | render() { 39 | return <div />; 40 | } 41 | } 42 | 43 | <Route path="/app" component={A} />; 44 | }); 45 | 46 | it("accepts children", () => { 47 | <Route path="/app"> 48 | <div /> 49 | </Route>; 50 | 51 | <Route path="/app"> 52 | This is a <b>mixed</b> content 53 | </Route>; 54 | 55 | <Route> 56 | <> 57 | <div /> 58 | </> 59 | </Route>; 60 | }); 61 | 62 | it("supports functions as children", () => { 63 | <Route path="/users/:id"> 64 | {(params) => { 65 | expectTypeOf(params).toMatchTypeOf<{}>(); 66 | return <div />; 67 | }} 68 | </Route>; 69 | 70 | <Route path="/users/:id">{({ id }) => `User id: ${id}`}</Route>; 71 | 72 | <Route path="/users/:id"> 73 | {({ age }: { age: string }) => `User age: ${age}`} 74 | </Route>; 75 | 76 | // @ts-expect-error function should return valid JSX 77 | <Route path="/app">{() => {}}</Route>; 78 | 79 | // prettier-ignore 80 | // @ts-expect-error you can't use JSX together with render function 81 | <Route path="/">{() => <div />}<a>Link</a></Route>; 82 | }); 83 | 84 | describe("parameter inference", () => { 85 | it("can infer type of params from the path given", () => { 86 | <Route path="/path/:first/:second/another/:third"> 87 | {({ first, second, third }) => { 88 | expectTypeOf(first).toEqualTypeOf<string>(); 89 | return <div>{`${first}, ${second}, ${third}`}</div>; 90 | }} 91 | </Route>; 92 | 93 | <Route path="/users/:name/"> 94 | {/* @ts-expect-error - `age` param is not present in the pattern */} 95 | {({ name, age }) => { 96 | return <div>{`Hello, ${name}`}</div>; 97 | }} 98 | </Route>; 99 | }); 100 | 101 | it("extract wildcard params into `wild` property", () => { 102 | <Route path="/users/*/settings"> 103 | {({ wild }) => { 104 | expectTypeOf(wild).toEqualTypeOf<string>(); 105 | return <div>The path is {wild}</div>; 106 | }} 107 | </Route>; 108 | }); 109 | 110 | it("allows to customize type of params via generic parameter", () => { 111 | <Route<{ name: string; lastName: string }> path="/users/:name/:age"> 112 | {(params) => { 113 | expectTypeOf(params.lastName).toEqualTypeOf<string>(); 114 | return <div>This really is undefined {params.lastName}</div>; 115 | }} 116 | </Route>; 117 | }); 118 | 119 | it("can't infer the type when the path isn't known at compile time", () => { 120 | <Route path={JSON.parse('"/home/:section"')}> 121 | {(params) => { 122 | // @ts-expect-error 123 | params.section; 124 | return <div />; 125 | }} 126 | </Route>; 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 | <Router hook={memoryLocation({ path: initialPath }).hook}>{jsx}</Router> 14 | ); 15 | }; 16 | 17 | it("always renders its content when `path` is empty", () => { 18 | const { container } = testRouteRender( 19 | "/nothing", 20 | <Route> 21 | <h1>Hello!</h1> 22 | </Route> 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 | <Route path="/foo"> 34 | <h1>Hello!</h1> 35 | </Route> 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 | <Route path="/foo">{() => <h1>Hello!</h1>}</Route> 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 | <Route path="/users/:name">{(params) => <h1>{params.name}</h1>}</Route> 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 | <Route path="/foo"> 69 | <div>Hi!</div> 70 | </Route> 71 | ); 72 | 73 | expect(container.querySelector("div")).not.toBeInTheDocument(); 74 | }); 75 | 76 | it("supports `component` prop similar to React-Router", () => { 77 | const Users = () => <h2>All users</h2>; 78 | 79 | const { container } = testRouteRender( 80 | "/foo", 81 | <Route path="/foo" component={Users} /> 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 | <Router base="/app"> 92 | <Route path="/nested"> 93 | <h1>Nested</h1> 94 | </Route> 95 | <Route path="~/absolute"> 96 | <h2>Absolute</h2> 97 | </Route> 98 | </Router> 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 | <Route path={/[/]foo/}> 113 | <h1>Hello!</h1> 114 | </Route> 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 | <Route path={/[/]users[/](?<name>[a-z]+)/}> 126 | {(params) => <h1>{params.name}</h1>} 127 | </Route> 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 | <Route path={/[/]users[/]([a-z]+)/}> 139 | {(params) => <h1>{params[0]}</h1>} 140 | </Route> 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 | <Route path={/[/]users[/](?<name>[a-z]+)/}> 152 | {(params) => <h1>{params.name}</h1>} 153 | </Route> 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 | <Router />; 15 | }); 16 | 17 | it("accepts valid elements as children", () => { 18 | const Header = ({ title }: { title: string }) => <h1>{title}</h1>; 19 | 20 | <Router> 21 | <Route path="/" /> 22 | <b>Hello!</b> 23 | </Router>; 24 | 25 | <Router> 26 | Hello, we have <Header title="foo" /> and some {1337} numbers here. 27 | </Router>; 28 | 29 | <Router> 30 | <>Fragments!</> 31 | </Router>; 32 | 33 | <Router> 34 | {/* @ts-expect-error should be a valid element */} 35 | {() => <div />} 36 | </Router>; 37 | }); 38 | 39 | it("can be customized with router properties passed as props", () => { 40 | // @ts-expect-error 41 | <Router hook="wat?" />; 42 | 43 | const useFakeLocation: BaseLocationHook = () => ["/foo", () => {}]; 44 | <Router hook={useFakeLocation}>this is a valid router</Router>; 45 | 46 | let fn: ComponentProps<typeof Router>["hook"]; 47 | expectTypeOf(fn).exclude<undefined>().toBeFunction(); 48 | 49 | <Router base="/app">Hello World!</Router>; 50 | 51 | <Router ssrPath="/foo">SSR</Router>; 52 | 53 | <Router base="/users" ssrPath="/users/all" hook={useFakeLocation}> 54 | Custom 55 | </Router>; 56 | }); 57 | 58 | it("accepts `hrefs` function for transforming href strings", () => { 59 | const router = useRouter(); 60 | expectTypeOf(router.hrefs).toBeFunction(); 61 | 62 | <Router hrefs={(href: string) => href + "1"}>0</Router>; 63 | 64 | <Router 65 | hrefs={(href, router) => { 66 | expectTypeOf(router).toEqualTypeOf<typeof router>(); 67 | return href + router.base; 68 | }} 69 | > 70 | routers as a second argument 71 | </Router>; 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 ? "(?=$|[/])" : "[/]quot;}`), 78 | keys: [], 79 | }; 80 | }; 81 | 82 | <Router parser={parser}>this is a valid router</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 | <Router parent={router}>Parent router</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 <Router /> rerenders", () => { 26 | const { result, rerender } = renderHook(() => useRouter(), { 27 | wrapper: (props) => <Router>{props.children}</Router>, 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 | <Router parser={newParser} hook={hook}> 42 | {props.children} 43 | </Router> 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 | <Router ssrPath="/users" ssrSearch="a=b&c=d"> 57 | {props.children} 58 | </Router> 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 | <Router ssrPath={ssrPath} ssrSearch={ssrSearch}> 73 | {props.children} 74 | </Router> 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 <div data-testid={`router-${index}`} />; 99 | }; 100 | 101 | render( 102 | <> 103 | <RouterGetter index={0} /> 104 | <RouterGetter index={1} /> 105 | <RouterGetter index={2} /> 106 | <RouterGetter index={3} /> 107 | </> 108 | ); 109 | 110 | const uniqRouters = [...new Set<DefaultParams>(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) => <Router base="/foo">{props.children}</Router>, 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 | <Router base="/baz"> 131 | <Router base="/foo"> 132 | <Router base="/bar">{props.children}</Router> 133 | </Router> 134 | </Router> 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 | <Router base="/app" parser={newParser}> 151 | <Router hook={customHook} base="/bar"> 152 | {props.children} 153 | </Router> 154 | </Router> 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) => <Router hrefs={formatter}>{props.children}</Router>, 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 | <Router hook={hook} hrefs={hrefsRouterOption}> 190 | {props.children} 191 | </Router> 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<ComponentProps<typeof Router>> = { 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 | <Router base="/app"> 222 | <Memoized /> 223 | </Router> 224 | ); 225 | 226 | expect(state.renders).toEqual(1); 227 | expect(state.base).toBe("/app"); 228 | 229 | rerender( 230 | <Router base="/app"> 231 | <Memoized /> 232 | </Router> 233 | ); 234 | expect(state.renders).toEqual(1); // nothing changed 235 | 236 | // should re-render the hook 237 | const newHook: BaseLocationHook = () => ["/location", () => {}]; 238 | rerender( 239 | <Router hook={newHook} base="/app"> 240 | <Memoized /> 241 | </Router> 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 | <Router hook={newHook} base=""> 250 | <Memoized /> 251 | </Router> 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 | <Router hook={newHook} base=""> 260 | <Memoized /> 261 | </Router> 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 | <Router ssrPath="/users/baz"> 22 | <Route path="/users/baz">foo</Route> 23 | <Route path="/users/:any*">bar</Route> 24 | <Route path="/users/:id">{(params) => params.id}</Route> 25 | <Route path="/about">should not be rendered</Route> 26 | </Router> 27 | ); 28 | 29 | const rendered = renderToStaticMarkup(<App />); 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 | <Router ssrPath="/pages/intro"> 41 | <HookRoute /> 42 | </Router> 43 | ); 44 | 45 | const rendered = renderToStaticMarkup(<App />); 46 | expect(rendered).toBe("Welcome to intro!"); 47 | }); 48 | 49 | it("renders valid and accessible link elements", () => { 50 | const App = () => ( 51 | <Router ssrPath="/"> 52 | <Link href="/users/1" title="Profile"> 53 | Mark 54 | </Link> 55 | </Router> 56 | ); 57 | 58 | const rendered = renderToStaticMarkup(<App />); 59 | expect(rendered).toBe(`<a title="Profile" href="/users/1">Mark</a>`); 60 | }); 61 | 62 | it("renders redirects however they have effect only on a client-side", () => { 63 | const App = () => ( 64 | <Router ssrPath="/"> 65 | <Route path="/"> 66 | <Redirect to="/foo" /> 67 | </Route> 68 | 69 | <Route path="/foo">You won't see that in SSR page</Route> 70 | </Router> 71 | ); 72 | 73 | const rendered = renderToStaticMarkup(<App />); 74 | expect(rendered).toBe(""); 75 | }); 76 | 77 | it("update ssr context", () => { 78 | const context: SsrContext = {}; 79 | const App = () => ( 80 | <Router ssrPath="/" ssrContext={context}> 81 | <Route path="/"> 82 | <Redirect to="/foo" /> 83 | </Route> 84 | </Router> 85 | ); 86 | 87 | renderToStaticMarkup(<App />); 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 | <Router ssrPath="/"> 97 | <PrintSearch /> 98 | </Router> 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 | <Router ssrPath="/catalog" ssrSearch="sort=created_at"> 118 | <App /> 119 | </Router> 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 | <Router hook={memoryLocation({ path: initialPath }).hook}>{jsx}</Router> 17 | ); 18 | }; 19 | 20 | it("works well when nothing is provided", () => { 21 | const { container } = testRouteRender("/users/12", <Switch>{null}</Switch>); 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 | <Switch> 30 | <Route path="/users/home"> 31 | <h1 /> 32 | </Route> 33 | <Route path="/users/:id"> 34 | <h2 /> 35 | </Route> 36 | <Route path="/users/:rest*"> 37 | <h3 /> 38 | </Route> 39 | </Switch> 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 | <Switch> 53 | Here is a<Route path="/users">route</Route> 54 | route 55 | </Switch> 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 | <Switch> 68 | {""} 69 | {false} 70 | {null} 71 | {undefined} 72 | <Route path="/users">route</Route> 73 | </Switch> 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 | <Switch> 89 | <Dummy path="/">Component</Dummy> 90 | <b>Bold</b> 91 | </Switch> 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 | <Switch location="/users"> 103 | <Route path="/users">route</Route> 104 | </Switch> 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 | <Switch> 116 | <Route path="/foo/:id"> 117 | {(params) => { 118 | if (!params) 119 | throw new Error("Render prop is called with falsy params!"); 120 | return null; 121 | }} 122 | </Route> 123 | </Switch> 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 | <Switch> 138 | <Route path="/users"> 139 | <h1 /> 140 | </Route> 141 | <Route path="/:anything*"> 142 | <h2 /> 143 | </Route> 144 | </Switch> 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 | <Switch> 157 | <Route path="/users"> 158 | <h1 /> 159 | </Route> 160 | <Route> 161 | <h2 /> 162 | </Route> 163 | </Switch> 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 | <Switch> 176 | {[1, 2, 3].map((i) => { 177 | const H = `h${i}` as keyof JSX.IntrinsicElements; 178 | return ( 179 | <Route key={i} path={"/in-array-" + i}> 180 | <H /> 181 | </Route> 182 | ); 183 | })} 184 | <Route> 185 | <h4 /> 186 | </Route> 187 | </Switch> 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 | <Switch> 202 | <> 203 | {[1, 2, 3].map((i) => { 204 | const H = `h${i}` as keyof JSX.IntrinsicElements; 205 | return ( 206 | <Route key={i} path={"/in-fragment-" + i}> 207 | <H /> 208 | </Route> 209 | ); 210 | })} 211 | </> 212 | <Route> 213 | <h4 /> 214 | </Route> 215 | </Switch> 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<void>((resolve, reject) => { 10 | let timeout: ReturnType<typeof setTimeout>; 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<string>(loc); 13 | assertType<Function>(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<typeof useSearch>; 42 | const search = useSearch(); 43 | 44 | assertType<string>(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<TestCase>(); 53 | 54 | expectTypeOf(state).toEqualTypeOf<TestCase>(); 55 | }); 56 | 57 | it("should fallback to any when type doesn't provided", () => { 58 | const state = useHistoryState(); 59 | 60 | expectTypeOf(state).toEqualTypeOf<any>(); 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<BaseLocationHook>(); 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<void>(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(<App />); 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 <Route path="/a" />. 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 | <Router hook={useHashLocation}> 182 | <Route path="/a"> 183 | <InterceptAndStopHashchange> 184 | <LogLocations /> 185 | </InterceptAndStopHashchange> 186 | </Route> 187 | </Router> 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 | <Router hook={useHashLocation}> 205 | <Link href="/app" data-testid="link" /> 206 | </Router> 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<ComponentProps<typeof Router>, "children"> = {} 20 | ) { 21 | return ({ children }: { children: ReactNode }) => ( 22 | <Router {...options}>{children}</Router> 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<typeof useParams>().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<string | undefined>(); 13 | expectTypeOf(params[0]).toEqualTypeOf<string | undefined>(); 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 <Route />", () => { 8 | const { result } = renderHook(() => useParams()); 9 | expect(result.current).toEqual({}); 10 | }); 11 | 12 | it("contains a * parameter when used inside an empty <Route />", () => { 13 | const { result } = renderHook(() => useParams(), { 14 | wrapper: (props) => ( 15 | <Router hook={memoryLocation({ path: "/app-2/goods/tees" }).hook}> 16 | <Route>{props.children}</Route> 17 | </Router> 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) => <Route path="/">{props.children}</Route>, 30 | }); 31 | 32 | expect(result.current).toEqual({}); 33 | }); 34 | 35 | it("contains parameters from the closest parent <Route />", () => { 36 | const { result } = renderHook(() => useParams(), { 37 | wrapper: (props) => ( 38 | <Router hook={memoryLocation({ path: "/app/users/1/maria" }).hook}> 39 | <Route path="/app/:foo/*"> 40 | <Route path="/app/users/:id/:name">{props.children}</Route> 41 | </Route> 42 | </Router> 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 | <Router 58 | hook={ 59 | memoryLocation({ path: "/dash/users/10/alex/bio/john/summary-1" }) 60 | .hook 61 | } 62 | > 63 | <Route path="/:page" nest> 64 | <Route path="/users/:id/:name" nest> 65 | <Route path="/bio/:name/*">{props.children}</Route> 66 | </Route> 67 | </Route> 68 | </Router> 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 | <Router hook={hook}> 89 | <Route path="/:a/:b">{props.children}</Route> 90 | </Router> 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 | <Router hook={hook}> 122 | <Route path="/:version/:chain?" nest> 123 | {props.children} 124 | </Route> 125 | </Router> 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 | <Router hook={hook}> 143 | <Route path="/:a/:b/*?">{props.children}</Route> 144 | </Router> 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 | <Router hook={hook}> 159 | <Route path="/:id">{props.children}</Route> 160 | </Router> 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 | <Router hook={hook}> 174 | <Switch> 175 | <Route path="/posts">{props.children}</Route> 176 | <Route path="/posts/:a">{props.children}</Route> 177 | </Switch> 178 | </Router> 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 | <Router hook={hook}> 197 | <Switch> 198 | <Route path="/products/categories/:category">{props.children}</Route> 199 | <Route path="/products/categories/:category/page/:page"> 200 | {props.children} 201 | </Route> 202 | </Switch> 203 | </Router> 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<boolean>(); 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<null>(); 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(/[/](?<param>[a-z]+)/, "/bar", { 0: "bar", param: "bar" }); 160 | assertRoute(/[/](?<param>[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 | <Router 169 | hook={ 170 | memoryLocation({ path: "/blog/products/40/read-all", static: true }) 171 | .hook 172 | } 173 | {...props} 174 | /> 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) => <Router hook={hook} {...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<string, string | undefined> 241 | ) => { 242 | const { result } = renderHook(() => useRoute(pattern), { 243 | wrapper: (props) => ( 244 | <Router 245 | hook={memoryLocation({ path: location, static: true }).hook} 246 | {...props} 247 | /> 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 <Router searchHook={customSearchHook}>{props.children}</Router>; 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 <Router searchHook={customSearchHook}>{props.children}</Router>; 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 <Router searchHook={searchHook}>{props.children}</Router>; 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 <Router searchHook={searchHook}>{props.children}</Router>; 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<T extends string> = RouteParams<T> & { 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 extends DefaultParams = DefaultParams> = T; 50 | 51 | export type MatchWithParams<T extends DefaultParams = DefaultParams> = [ 52 | true, 53 | Params<T> 54 | ]; 55 | export type NoMatch = [false, null]; 56 | export type Match<T extends DefaultParams = DefaultParams> = 57 | | MatchWithParams<T> 58 | | NoMatch; 59 | 60 | /* 61 | * Components: <Route /> 62 | */ 63 | 64 | export interface RouteComponentProps<T extends DefaultParams = DefaultParams> { 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<RoutePath> 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<RoutePath> 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<T, RoutePath>): ReturnType<FunctionComponent>; 98 | 99 | /* 100 | * Components: <Link /> & <Redirect /> 101 | */ 102 | 103 | export type NavigationalProps< 104 | H extends BaseLocationHook = BrowserLocationHook 105 | > = ({ to: Path; href?: never } | { href: Path; to?: never }) & 106 | HookNavigationOptions<H>; 107 | 108 | export type RedirectProps<H extends BaseLocationHook = BrowserLocationHook> = 109 | NavigationalProps<H> & { 110 | children?: never; 111 | }; 112 | 113 | export function Redirect<H extends BaseLocationHook = BrowserLocationHook>( 114 | props: RedirectProps<H>, 115 | context?: any 116 | ): null; 117 | 118 | type AsChildProps<ComponentProps, DefaultElementProps> = 119 | | ({ asChild?: false } & DefaultElementProps) 120 | | ({ asChild: true } & ComponentProps); 121 | 122 | type HTMLLinkAttributes = Omit< 123 | AnchorHTMLAttributes<HTMLAnchorElement>, 124 | "className" 125 | > & { 126 | className?: string | undefined | ((isActive: boolean) => string | undefined); 127 | }; 128 | 129 | export type LinkProps<H extends BaseLocationHook = BrowserLocationHook> = 130 | NavigationalProps<H> & 131 | AsChildProps< 132 | { children: ReactElement; onClick?: MouseEventHandler }, 133 | HTMLLinkAttributes & RefAttributes<HTMLAnchorElement> 134 | >; 135 | 136 | export function Link<H extends BaseLocationHook = BrowserLocationHook>( 137 | props: LinkProps<H>, 138 | context?: any 139 | ): ReturnType<FunctionComponent>; 140 | 141 | /* 142 | * Components: <Switch /> 143 | */ 144 | 145 | export interface SwitchProps { 146 | location?: string; 147 | children: ReactNode; 148 | } 149 | export const Switch: FunctionComponent<SwitchProps>; 150 | 151 | /* 152 | * Components: <Router /> 153 | */ 154 | 155 | export type RouterProps = RouterOptions & { 156 | children: ReactNode; 157 | }; 158 | 159 | export const Router: FunctionComponent<RouterProps>; 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<RoutePath> 177 | : RegexRouteParams 178 | >; 179 | 180 | export function useLocation< 181 | H extends BaseLocationHook = BrowserLocationHook 182 | >(): HookReturnValue<H>; 183 | 184 | export function useSearch< 185 | H extends BaseSearchHook = BrowserSearchHook 186 | >(): ReturnType<H>; 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 = undefined>(): T extends string 201 | ? StringRouteParams<T> 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<RoutePath> 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<H extends BaseLocationHook> = ReturnType<H>; 25 | 26 | // Utility type that allows us to handle cases like `any` and `never` 27 | type EmptyInterfaceWhenAnyOrNever<T> = 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<H extends BaseLocationHook> = 35 | EmptyInterfaceWhenAnyOrNever< 36 | NonNullable<Parameters<HookReturnValue<H>[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<S = any> = ( 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: <S extends Primitive>( 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 = any>() => T; 18 | 19 | export const navigate: <S = any>( 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<S = any>( 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 | --------------------------------------------------------------------------------