diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 00000000..7f723f31 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,13 @@ +# Fixed ES2022 browser baseline for Wouter 4; see specs/browser-support.md. +# Packages ship source. This is a consumer/tooling target, not a build step. +Chrome >= 94 +and_chr >= 94 +Edge >= 94 +Firefox >= 93 +and_ff >= 93 +Safari >= 16.4 +ios_saf >= 16.4 +Opera >= 80 +op_mob >= 66 +Samsung >= 17 +Android >= 94 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index b727ae64..591b292d 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -25,7 +25,7 @@ jobs: run: bun install --frozen-lockfile - name: Prepare wouter-preact (copy source files) - run: cd packages/wouter-preact && npm run prepublishOnly + run: bun run --cwd packages/wouter-preact prepublishOnly - name: Run test run: bun test --coverage --coverage-reporter=lcov --coverage-reporter=text @@ -40,3 +40,39 @@ jobs: uses: coverallsapp/github-action@v2 with: file: ./coverage/lcov.info + + react-compatibility: + runs-on: ubuntu-latest + strategy: + matrix: + react: ["18.2.0", "18.3.1", "19.0.0"] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - name: Install the React version under test + run: bun add --dev --exact react@${{ matrix.react }} react-dom@${{ matrix.react }} + - name: Test supported React versions + run: bun test packages/wouter/test + + typescript-consumers: + runs-on: ubuntu-latest + strategy: + matrix: + typescript: ["5.2.2", "workspace"] + react-types: ["18", "19"] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install --frozen-lockfile + - name: Install React declarations under test + run: bun add --dev @types/react@${{ matrix.react-types }} @types/react-dom@${{ matrix.react-types }} + - name: Install the minimum supported TypeScript version + if: matrix.typescript != 'workspace' + run: bun add --dev --exact typescript@${{ matrix.typescript }} + - name: Check strict React and Preact consumers + run: bun run test-types:consumer diff --git a/README.md b/README.md index 16ebc563..fd580959 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ by Katya Simacheva -- Minimum dependencies, only **2.1 KB** gzipped vs 18.7KB - [React Router](https://github.com/ReactTraining/react-router). +- Minimum dependencies, about **2.4 kB** minified and gzipped (React excluded). - Supports both **React** and **[Preact](https://preactjs.com/)**! Read _["Preact support" section](#preact-support)_ for more details. - No top-level `` component, it is **fully optional**. @@ -135,7 +134,11 @@ const App = () => ( ### Browser Support -This library is designed for **ES2020+** compatibility. If you need to support older browsers, make sure that you transpile `node_modules`. Additionally, the minimum supported TypeScript version is 4.1 in order to support route parameter inference. +Wouter targets modern browsers with **ES2022 support** and native URL, History, +and DOM event APIs. Packages ship as ES modules without transpilation or polyfills. + +Requires **React 18.2+** or **Preact 10.x** with `wouter-preact`. +TypeScript projects require **TypeScript 5.2+** and matching framework types. ## Wouter API @@ -814,8 +817,48 @@ It's the same function that is used internally. ### Can I use _wouter_ in my TypeScript project? -Yes! Although the project isn't written in TypeScript, the type definition files are bundled with -the package. +Yes! Both packages include declarations for TypeScript 5.2+. Route literals infer +required and optional parameters, filename suffixes, and wildcard captures: + +```tsx +import { useRoute } from "wouter"; + +const [matches, params] = useRoute("/files/:name.txt/*?"); +if (matches) { + params.name; // string + params["*"]; // string | undefined +} +``` + +You can also provide a parameter interface to `useRoute`, `useParams`, or `Route`. +This is an assertion about your route or custom parser, not runtime validation. +For paths only known at runtime, named parameter lookups return `string | undefined`. + +Custom location hooks retain their navigation options and state types when supplied +as the hook's type argument. For example, inside a router using this memory hook: + +```tsx +import { useSearchParams } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; + +const memory = memoryLocation<{ from: string }>(); + +function SearchControls() { + const [, setSearch] = useSearchParams(); + return ( + + ); +} +``` + +Use the same type argument with `useLocation`, `Link`, and `Redirect`. The router +context does not automatically infer a custom hook's types in child components. +The declarations are checked with bundler, NodeNext, and legacy node resolution, +including `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`. ### How can add animated route transitions? @@ -1094,7 +1137,15 @@ Wouter's motto is **"Minimalist-friendly"**. - `wouter-preact` reuses the same source except for `react-deps.js` (Preact-specific hooks) - Type definitions are duplicated between packages (not ideal, but works for now) -**Development:** Tests run directly from source files (no build required). Run `npm run test` for interactive mode or `npm run test -- --run` for a single run. Use `npm run build` to build the distributable package before publishing. +**Development:** Run `bun install`, then `bun test` (or `bun test --watch`), +`bun run test-types`, and `bun run lint`. Type tests live alongside runtime tests +in `packages/*/test/*.test-d.ts` and `*.test-d.tsx`. Run +`bun run test-types:consumer` for the stricter public API checks under all three +module resolution modes. Tests and published packages use the +source files directly; no compilation step is required. After tests, run +`bun run --cwd packages/wouter-preact prepublishOnly` to refresh Preact's shared +sources before `bun run size` or packaging. `bun run size:dependencies` reports +raw, gzip, and Brotli sizes including non-peer dependencies. ## Acknowledgements diff --git a/bun.lock b/bun.lock index 868be107..0918497c 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "copyfiles": "^2.4.1", - "eslint": "^7.19.0", + "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "happy-dom": "^20.0.10", "husky": "^4.3.0", @@ -45,18 +45,17 @@ }, "packages/wouter": { "name": "wouter", - "version": "3.11.0", + "version": "4.0.0-next.0", "dependencies": { "regexparam": "^3.0.0", - "use-sync-external-store": "^1.0.0", }, "peerDependencies": { - "react": ">=16.8.0", + "react": ">=18.2.0", }, }, "packages/wouter-preact": { "name": "wouter-preact", - "version": "3.11.0", + "version": "4.0.0-next.0", "dependencies": { "regexparam": "^3.0.0", }, @@ -75,8 +74,6 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - "@babel/highlight": ["@babel/highlight@7.25.9", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "chalk": "^2.4.2", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw=="], - "@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="], "@dr.pogodin/react-helmet": ["@dr.pogodin/react-helmet@3.0.4", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "react": "19" } }, "sha512-TesfNpzO12qcbyqKyWGDIYTdwVxD3pJv75rE/zhKUq/k9yxeP0BpOdHQ5cc1zA3j/GyY7CuIZjAUXmsxqI1/yw=="], @@ -131,13 +128,27 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@0.4.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.1.1", "espree": "^7.3.0", "globals": "^13.9.0", "ignore": "^4.0.6", "import-fresh": "^3.2.1", "js-yaml": "^3.13.1", "minimatch": "^3.0.4", "strip-json-comments": "^3.1.1" } }, "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.10" } }, "sha512-GU0UBt9lJKhZlY/U0Bivj9ZVepDIQoAUupAAl/90THG4/urkzXNglkVYETsnt2pGBDgQ+4vBjMAbLu6XzcKcQA=="], - "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.5.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^1.2.0", "debug": "^4.1.1", "minimatch": "^3.0.4" } }, "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg=="], + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], - "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@1.2.1", "", {}, "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], @@ -187,24 +198,22 @@ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - "acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], @@ -259,8 +268,6 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], @@ -269,25 +276,21 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@7.32.0", "", { "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", "@humanwhocodes/config-array": "^0.5.0", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.0.1", "doctrine": "^3.0.0", "enquirer": "^2.3.5", "escape-string-regexp": "^4.0.0", "eslint-scope": "^5.1.1", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", "esquery": "^1.4.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "functional-red-black-tree": "^1.0.1", "glob-parent": "^5.1.2", "globals": "^13.6.0", "ignore": "^4.0.6", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "js-yaml": "^3.13.1", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.0.4", "natural-compare": "^1.4.0", "optionator": "^0.9.1", "progress": "^2.0.0", "regexpp": "^3.1.0", "semver": "^7.2.1", "strip-ansi": "^6.0.0", "strip-json-comments": "^3.1.0", "table": "^6.0.9", "text-table": "^0.2.0", "v8-compile-cache": "^2.0.3" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA=="], + "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@4.6.2", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ=="], - "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], - - "eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@2.1.0", "", {}, "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw=="], + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], - "espree": ["espree@7.3.1", "", { "dependencies": { "acorn": "^7.4.0", "acorn-jsx": "^5.3.1", "eslint-visitor-keys": "^1.3.0" } }, "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g=="], + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -297,7 +300,7 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], + "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], "fdir": ["fdir@6.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="], @@ -313,23 +316,23 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "functional-red-black-tree": ["functional-red-black-tree@1.0.1", "", {}, "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "husky": ["husky@4.3.8", "", { "dependencies": { "chalk": "^4.0.0", "ci-info": "^2.0.0", "compare-versions": "^3.6.0", "cosmiconfig": "^7.0.0", "find-versions": "^4.0.0", "opencollective-postinstall": "^2.0.2", "pkg-dir": "^5.0.0", "please-upgrade-node": "^3.2.0", "slash": "^3.0.0", "which-pm-runs": "^1.0.0" }, "bin": { "husky-run": "bin/run.js", "husky-upgrade": "lib/upgrader/bin.js" } }, "sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow=="], - "ignore": ["ignore@4.0.6", "", {}, "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -349,6 +352,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -357,7 +362,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -381,8 +386,6 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - "lodash.truncate": ["lodash.truncate@4.4.2", "", {}, "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw=="], - "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magazin": ["magazin@workspace:packages/magazin"], @@ -447,10 +450,10 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], @@ -465,22 +468,20 @@ "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], - "regexpp": ["regexpp@3.2.0", "", {}, "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], "semver-regex": ["semver-regex@3.1.4", "", {}, "sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA=="], @@ -493,10 +494,6 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], - - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], @@ -509,8 +506,6 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], - "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], @@ -531,12 +526,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "v8-compile-cache": ["v8-compile-cache@2.4.0", "", {}, "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -565,8 +556,6 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@babel/highlight/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - "@dr.pogodin/react-helmet/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -577,44 +566,18 @@ "bun-types/@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="], - "eslint/@babel/code-frame": ["@babel/code-frame@7.12.11", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw=="], - - "eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@1.3.0", "", {}, "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ=="], - - "espree/eslint-visitor-keys": ["eslint-visitor-keys@1.3.0", "", {}, "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ=="], - - "esquery/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - "magazin/@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "table/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "@babel/highlight/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], - - "@babel/highlight/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], - - "@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], - "magazin/@types/bun/bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], - "table/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "through2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "through2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], - - "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "magazin/@types/bun/bun-types/@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="], - - "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], } } diff --git a/bunfig.toml b/bunfig.toml index ede45b06..47d482f1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -10,7 +10,5 @@ coveragePathIgnorePatterns = [ "packages/wouter-preact/src/memory-location.js", "packages/wouter-preact/src/use-browser-location.js", "packages/wouter-preact/src/use-hash-location.js", - "packages/wouter-preact/src/use-sync-external-store.js", - "packages/wouter-preact/src/use-sync-external-store.native.js", ] coverageThreshold = { lines = 0.99 } diff --git a/package.json b/package.json index c35342eb..dc8e492f 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "fix:p": "prettier --write \"./**/*.(js|ts){x,}\"", "test": "bun test", "test-types": "tsc --noEmit", + "test-types:consumer": "tsc -p tsconfig.consumer.json && tsc -p tsconfig.consumer.json --module NodeNext --moduleResolution NodeNext && tsc -p tsconfig.consumer.json --moduleResolution Node", "size": "size-limit", - "lint": "eslint packages/**/*.js", + "size:dependencies": "bun scripts/measure-size.ts", + "lint": "eslint packages/wouter/src/*.js packages/wouter-preact/src/react-deps.js", "build": ":" }, "author": "Alexey Taktarov ", @@ -30,40 +32,36 @@ "size-limit": [ { "path": "packages/wouter/src/index.js", - "limit": "2500 B", + "limit": "2200 B", "ignore": [ - "react", - "use-sync-external-store" + "react" ] }, { "path": "packages/wouter/src/use-browser-location.js", - "limit": "1000 B", + "limit": "400 B", "import": "{ useBrowserLocation }", "ignore": [ - "react", - "use-sync-external-store" + "react" ] }, { "path": "packages/wouter/src/memory-location.js", - "limit": "1000 B", + "limit": "550 B", "ignore": [ - "react", - "use-sync-external-store" + "react" ] }, { "path": "packages/wouter/src/use-hash-location.js", - "limit": "1000 B", + "limit": "500 B", "ignore": [ - "react", - "use-sync-external-store" + "react" ] }, { "path": "packages/wouter-preact/src/index.js", - "limit": "2500 B", + "limit": "2300 B", "ignore": [ "preact", "preact/hooks" @@ -71,7 +69,7 @@ }, { "path": "packages/wouter-preact/src/use-browser-location.js", - "limit": "1000 B", + "limit": "500 B", "import": "{ useBrowserLocation }", "ignore": [ "preact", @@ -80,7 +78,7 @@ }, { "path": "packages/wouter-preact/src/use-hash-location.js", - "limit": "1000 B", + "limit": "650 B", "ignore": [ "preact", "preact/hooks" @@ -88,7 +86,7 @@ }, { "path": "packages/wouter-preact/src/memory-location.js", - "limit": "1000 B", + "limit": "700 B", "ignore": [ "preact", "preact/hooks" @@ -97,19 +95,20 @@ ], "husky": { "hooks": { - "commit-msg": "npm run fix:p" + "commit-msg": "bun run fix:p" } }, "eslintConfig": { "extends": "eslint:recommended", "parserOptions": { + "ecmaVersion": 2022, "sourceType": "module", "ecmaFeatures": { "jsx": true } }, "env": { - "es2020": true, + "es2022": true, "browser": true, "node": true }, @@ -141,7 +140,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "copyfiles": "^2.4.1", - "eslint": "^7.19.0", + "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "happy-dom": "^20.0.10", "husky": "^4.3.0", diff --git a/packages/wouter-preact/.gitignore b/packages/wouter-preact/.gitignore index d671cdd5..a0aeed10 100644 --- a/packages/wouter-preact/.gitignore +++ b/packages/wouter-preact/.gitignore @@ -4,5 +4,3 @@ src/memory-location.js src/paths.js src/use-browser-location.js src/use-hash-location.js -src/use-sync-external-store.js -src/use-sync-external-store.native.js diff --git a/packages/wouter-preact/package.json b/packages/wouter-preact/package.json index 8632ae13..59283ef7 100644 --- a/packages/wouter-preact/package.json +++ b/packages/wouter-preact/package.json @@ -1,7 +1,7 @@ { "name": "wouter-preact", - "version": "3.11.0", - "description": "Minimalist-friendly ~1.5KB router for Preact", + "version": "4.0.0-next.0", + "description": "Minimalist-friendly ~2.5KB router for Preact", "type": "module", "keywords": [ "react", @@ -38,7 +38,7 @@ }, "types": "types/index.d.ts", "typesVersions": { - ">=4.1": { + ">=5.2": { "types/index.d.ts": [ "types/index.d.ts" ], @@ -54,7 +54,7 @@ } }, "scripts": { - "prepublishOnly": "cp ../wouter/src/index.js ../wouter/src/memory-location.js ../wouter/src/paths.js ../wouter/src/use-browser-location.js ../wouter/src/use-hash-location.js ../wouter/src/use-sync-external-store.js ../wouter/src/use-sync-external-store.native.js src && cp ../../README.md ." + "prepublishOnly": "cp ../wouter/src/index.js ../wouter/src/memory-location.js ../wouter/src/paths.js ../wouter/src/use-browser-location.js ../wouter/src/use-hash-location.js src && cp ../../README.md ." }, "author": "Alexey Taktarov ", "repository": { diff --git a/packages/wouter-preact/src/react-deps.js b/packages/wouter-preact/src/react-deps.js index 2be1465f..e546ef70 100644 --- a/packages/wouter-preact/src/react-deps.js +++ b/packages/wouter-preact/src/react-deps.js @@ -10,8 +10,6 @@ export { useMemo, useRef, useLayoutEffect as useIsomorphicLayoutEffect, - useLayoutEffect as useInsertionEffect, - useState, useContext, } from "preact/hooks"; @@ -23,10 +21,8 @@ const canUseDOM = !!( typeof window.document.createElement !== "undefined" ); -// TODO: switch to `export { useSyncExternalStore } from "preact/compat"` once we update Preact to >= 10.11.3 -function is(x, y) { - return (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y); -} +// Keep this small adapter: preact/compat adds unrelated compatibility code and +// its store hook does not use the server snapshot. See specs/browser-support.md. export function useSyncExternalStore(subscribe, getSnapshot, getSSRSnapshot) { if (getSSRSnapshot && !canUseDOM) getSnapshot = getSSRSnapshot; const value = getSnapshot(); @@ -39,18 +35,18 @@ export function useSyncExternalStore(subscribe, getSnapshot, getSSRSnapshot) { _instance._value = value; _instance._getSnapshot = getSnapshot; - if (!is(_instance._value, getSnapshot())) { + if (!Object.is(_instance._value, getSnapshot())) { forceUpdate({ _instance }); } }, [subscribe, value, getSnapshot]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - if (!is(_instance._value, _instance._getSnapshot())) { + if (!Object.is(_instance._value, _instance._getSnapshot())) { forceUpdate({ _instance }); } return subscribe(() => { - if (!is(_instance._value, _instance._getSnapshot())) { + if (!Object.is(_instance._value, _instance._getSnapshot())) { forceUpdate({ _instance }); } }); @@ -59,18 +55,12 @@ export function useSyncExternalStore(subscribe, getSnapshot, getSSRSnapshot) { return value; } -// provide forwardRef stub for preact +// Preact passes legacy context as the second argument, not a forwarded ref. export function forwardRef(component) { - // Preact passes legacy context as the second argument, not a forwarded ref. return (props) => component(props); } -// Userland polyfill while we wait for the forthcoming -// https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md -// Note: "A high-fidelty polyfill for useEvent is not possible because -// there is no lifecycle or Hook in React that we can use to switch -// .current at the right timing." -// So we will have to make do with this "close enough" approach for now. +// Keep callbacks stable while using the latest committed handler. export const useEvent = (fn) => { const ref = useRef([fn, (...args) => ref[0](...args)]).current; useLayoutEffect(() => { diff --git a/packages/wouter-preact/test/fixtures/ssr.tsx b/packages/wouter-preact/test/fixtures/ssr.tsx new file mode 100644 index 00000000..729e4dc8 --- /dev/null +++ b/packages/wouter-preact/test/fixtures/ssr.tsx @@ -0,0 +1,42 @@ +import { h } from "preact"; +import renderToString from "preact-render-to-string"; +import { Router, useLocation, useSearch } from "wouter-preact"; +import { useHashLocation } from "wouter-preact/use-hash-location"; + +function Location() { + const [path] = useLocation(); + const search = useSearch(); + return h("p", null, `${path}?${search}`); +} + +console.log( + JSON.stringify({ + browserGlobals: [ + typeof window, + typeof document, + typeof location, + typeof history, + ], + browser: renderToString( + h(Router, { + ssrPath: "/ssr/preact?from=path", + children: h(Location, null), + }) + ), + search: renderToString( + h(Router, { + ssrPath: "/ssr/search", + ssrSearch: "?from=search", + children: h(Location, null), + }) + ), + hash: renderToString( + h(Router, { + hook: useHashLocation, + ssrPath: "/ssr/hash", + ssrSearch: "?from=hash", + children: h(Location, null), + }) + ), + }) +); diff --git a/packages/wouter-preact/test/memory-location.test-d.ts b/packages/wouter-preact/test/memory-location.test-d.ts new file mode 100644 index 00000000..6672d3cb --- /dev/null +++ b/packages/wouter-preact/test/memory-location.test-d.ts @@ -0,0 +1,42 @@ +import { memoryLocation } from "../types/memory-location.js"; +import { useLocation } from "../types/index.js"; +import { useLocationProperty } from "../types/use-browser-location.js"; +import { useHashLocation } from "../types/use-hash-location.js"; + +const memory = memoryLocation({ searchPath: "?tab=1" }); +const search: string = memory.searchHook(); +const recorded = memoryLocation({ searchPath: search, record: true }); +const recordedSearch: string = recorded.searchHook(); +recorded.navigate("/next?" + recordedSearch); + +// @ts-expect-error - searchPath is a string, just like in the React package +memoryLocation({ searchPath: 42 }); +// @ts-expect-error - searchHook returns a string +const invalid: number = memory.searchHook(); + +const typed = memoryLocation({ state: { count: 0 } }); +const [, navigate] = typed.hook(); +navigate("/next", { state: { count: 1 } }); +const [, navigateThroughRouter] = useLocation(); +navigateThroughRouter("/next", { state: { count: 2 } }); +const inheritedSearch: string = typed.hook.searchHook(); + +// @ts-expect-error - the memory hook preserves the inferred state type +navigate("/next", { state: "wrong" }); +// @ts-expect-error - useLocation preserves the inferred state type +navigateThroughRouter("/next", { state: { count: "wrong" } }); + +const dynamic = memoryLocation<{ count: number }>({ + record: Math.random() > 0.5, +}); +const maybeHistory: string[] | undefined = dynamic.history; +dynamic.reset?.(); +dynamic.navigate("/next", { state: { count: 1 } }); +// @ts-expect-error - recording may be disabled +dynamic.reset(); + +const cachedSnapshot = { count: 1 }; +const snapshot: { count: number } = useLocationProperty(() => cachedSnapshot); +const hashHref: string = useHashLocation.hrefs("/next"); +// @ts-expect-error - hrefs formats string paths +useHashLocation.hrefs(42); diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx index fa0fd4d7..42dad122 100644 --- a/packages/wouter-preact/test/preact.test.tsx +++ b/packages/wouter-preact/test/preact.test.tsx @@ -14,7 +14,6 @@ import { } from "bun:test"; import { render } from "preact"; import { act, setupRerender, teardown } from "preact/test-utils"; -import renderToString from "preact-render-to-string"; import { copyFile, rm } from "fs/promises"; import { join } from "path"; import type * as WouterPreact from "../types/index.js"; @@ -27,8 +26,6 @@ const filesToCopy = [ "paths.js", "use-browser-location.js", "use-hash-location.js", - "use-sync-external-store.js", - "use-sync-external-store.native.js", "index.js", ]; @@ -194,6 +191,60 @@ describe("Preact support", () => { }); describe("useSyncExternalStore shim", () => { + beforeEach(setupRerender); + afterEach(teardown); + + test("ignores repeated NaN snapshots but observes signed zero changes", async () => { + const { useSyncExternalStore } = (await import( + // @ts-expect-error - the internal hook mirrors React's signature + "../src/react-deps.js" + )) as { + useSyncExternalStore: ( + subscribe: (cb: () => void) => () => void, + getSnapshot: () => T + ) => T; + }; + let value = NaN; + const listeners = new Set<() => void>(); + const subscribe = (callback: () => void) => { + listeners.add(callback); + return () => listeners.delete(callback); + }; + const snapshots: number[] = []; + const Reader = () => { + const snapshot = useSyncExternalStore(subscribe, () => value); + snapshots.push(snapshot); + return <>{String(snapshot)}; + }; + const container = document.body.appendChild(document.createElement("div")); + const update = (next: number) => { + act(() => { + value = next; + listeners.forEach((callback) => callback()); + }); + }; + + try { + act(() => render(, container)); + expect(snapshots).toHaveLength(1); + update(NaN); + expect(snapshots).toHaveLength(1); + update(0); + expect(snapshots).toHaveLength(2); + expect(Object.is(snapshots[1], 0)).toBe(true); + update(-0); + expect(snapshots).toHaveLength(3); + expect(Object.is(snapshots[2], -0)).toBe(true); + update(NaN); + expect(snapshots).toHaveLength(4); + update(NaN); + expect(snapshots).toHaveLength(4); + } finally { + act(() => render(null, container)); + container.remove(); + } + }); + test("re-renders when the store mutates between render and layout effect", async () => { // the internal shim is untyped, it mirrors the React useSyncExternalStore signature const { useSyncExternalStore } = (await import( @@ -244,20 +295,25 @@ describe("useSyncExternalStore shim", () => { }); describe("Preact SSR", () => { - test.skip("supports SSR (fix: useSyncExternalStore polyfill in Bun)", async () => { - const { Router, useLocation } = await loadPreact(); - - const LocationPrinter = () => { - const [location] = useLocation(); - return <>location = {location}; - }; - - const rendered = renderToString( - - - + test("imports and renders browser and hash routes without browser globals", async () => { + // A plain Bun process does not load the test suite's happy-dom preload. + const child = Bun.spawn( + [process.execPath, join(import.meta.dir, "fixtures/ssr.tsx")], + { stdout: "pipe", stderr: "pipe" } ); - - expect(rendered).toContain("/ssr/preact"); + const [output, errors, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + + expect(errors).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(output)).toEqual({ + browserGlobals: ["undefined", "undefined", "undefined", "undefined"], + browser: "

/ssr/preact?from=path

", + search: "

/ssr/search?from=search

", + hash: "

/ssr/hash?from=hash

", + }); }); }); diff --git a/packages/wouter-preact/test/public-api.test-d.tsx b/packages/wouter-preact/test/public-api.test-d.tsx new file mode 100644 index 00000000..1954ed1c --- /dev/null +++ b/packages/wouter-preact/test/public-api.test-d.tsx @@ -0,0 +1,254 @@ +/** @jsxImportSource preact */ +import { + Link, + Redirect, + Route, + Router, + Switch, + matchRoute, + useLocation, + useParams, + useRoute, + useSearch, + useSearchParams, +} from "wouter-preact"; +import type { + Parser, + RouteComponentProps, + RouterObject, + RouterOptions, + StringRouteParams, +} from "wouter-preact"; +import { memoryLocation } from "wouter-preact/memory-location"; + +const location = memoryLocation({ path: "/users/42", record: true }); +const routerOptions = { + hook: location.hook, + searchHook: location.searchHook, + ssrPath: "/users/42", +} satisfies RouterOptions; + +function User({ params }: RouteComponentProps<{ id: string }>) { + return

{params.id.toUpperCase()}

; +} + +function Hooks() { + const [matched, params] = useRoute("/users/:id/:tab?"); + if (matched) { + params.id.toUpperCase(); + params.tab?.toUpperCase(); + } else { + const absent: null = params; + void absent; + } + + useParams<"/users/:id">().id.toUpperCase(); + const [path, navigate] = useLocation(); + navigate(path, { replace: true, state: { from: "/" } }); + useSearch().toUpperCase(); + const [search, setSearch] = useSearchParams(); + setSearch( + (previous) => { + previous.set("q", search.get("q") ?? ""); + return previous; + }, + { replace: true } + ); + + return null; +} + +export const application = ( + + User + (active ? "active" : undefined)}> + Home + + + + + {(params) =>

{params.slug.toUpperCase()}

} +
+ + + +
+ +
+); + +// @ts-expect-error Choose either href or to. +export const ambiguousLink = ; + +// @ts-expect-error A destination is required. +export const missingRedirect = ; + +interface UserParams { + id: string; + tab?: string; +} + +export const interfaceRoute = ( + path="/users/:id/:tab?"> + {(params) => ( +

+ {params.id.toUpperCase()} + {params.tab?.toUpperCase()} +

+ )} + +); + +export const optionalDuplicate: StringRouteParams<"/:id/:id?"> = { + id: undefined, +}; +export const requiredDuplicate: StringRouteParams<"/:id?/:id"> = { + id: "42", +}; + +// @ts-expect-error The final required capture cannot be omitted. +export const missingDuplicate: StringRouteParams<"/:id?/:id"> = {}; + +const missingKeys: Parser = () => ({ pattern: /^\/users\/(\w+)/ }); +const falseKeys: Parser = () => ({ + pattern: /^\/users\/(\w+)/, + keys: false, +}); +const readonlyKeys: Parser = () => ({ + pattern: /^\/users\/(\w+)/, + keys: ["id"] as const, +}); + +export const customParsers: RouterOptions[] = [ + { parser: missingKeys }, + { parser: falseKeys }, + { parser: readonlyKeys }, + { + hrefs: (href, router) => { + const currentRouter: RouterObject = router; + // @ts-expect-error RouterObject has no arbitrary properties. + router.missingProperty; + return currentRouter.base + href; + }, + }, +]; + +function ImprovedRouteInference(dynamicPattern: string) { + const [, explicit] = useRoute("/users/:id/:tab?"); + if (explicit) { + const id: string = explicit.id; + const optional: string | undefined = explicit.tab; + void [id, optional]; + } + + const [, wildcard] = useRoute("/files/*/edit"); + if (wildcard) { + const capture: string = wildcard["*"]; + // @ts-expect-error Runtime names wildcard captures "*", not "wild". + wildcard.wild; + void capture; + } + + const [, filename] = useRoute("/files/:name.json/:version?.txt"); + if (filename) { + const name: string = filename.name; + const version: string | undefined = filename.version; + // @ts-expect-error The extension is not part of the parameter name. + filename["name.json"]; + void [name, version]; + } + + const [, optional] = useRoute("/:id/:id?"); + if (optional) { + const lastCapture: string | undefined = optional.id; + // @ts-expect-error The final optional capture can be absent. + const required: string = optional.id; + void [lastCapture, required]; + } + + const [, dynamic] = useRoute(dynamicPattern); + if (dynamic) { + const named: string | undefined = dynamic.id; + const numbered: string | undefined = dynamic[0]; + // @ts-expect-error A dynamic pattern does not guarantee named parameters. + const required: string = dynamic.id; + void [named, numbered, required]; + } + + const [matched, params, base] = matchRoute( + readonlyKeys, + "/users/:id", + "/users/42/details", + true + ); + if (matched) { + const id: string = params.id; + const matchedBase: string = base; + void [id, matchedBase]; + } else { + const missingParams: null = params; + const missingBase: undefined = base; + void [missingParams, missingBase]; + } +} + +const statefulMemory = memoryLocation({ state: { page: 1 } }); + +function StatefulHooks() { + const [, directlyNavigate] = statefulMemory.hook(); + directlyNavigate("/users", { state: { page: 2 } }); + // @ts-expect-error Memory state retains the initial object's shape. + directlyNavigate("/users", { state: { page: "two" } }); + + const [, navigate] = useLocation(); + navigate("/users", { state: { page: 2 } }); + // @ts-expect-error useLocation preserves custom navigation options. + navigate("/users", { state: { page: "two" } }); + + const [, setSearch] = useSearchParams(); + const entries = [["page", "2"]] as const; + setSearch(entries, { state: { page: 2 } }); + setSearch(() => entries); + // @ts-expect-error Search updates use the location hook's state type. + setSearch(entries, { state: { page: "two" } }); + + const [, setBrowserSearch] = useSearchParams(); + setBrowserSearch(entries, { transition: true }); + return null; +} + +void [ImprovedRouteInference, StatefulHooks]; + +type NullableOptionsHook = () => [ + string, + (to: string, options?: { token: string } | null) => void +]; +type RequiredOptionsHook = () => [ + string, + (to: string, options: { token: string }) => void +]; +type OptionalOptionsHook = () => [ + string, + (to: string, options?: { token: string }) => void +]; + +function CustomSearchOptions() { + const [, setNullableSearch] = useSearchParams(); + setNullableSearch("q=test", null); + setNullableSearch("q=test", undefined); + setNullableSearch("q=test"); + setNullableSearch("q=test", { token: "session" }); + // @ts-expect-error Only the hook's declared options are allowed. + setNullableSearch("q=test", 123); + + const [, setUnionSearch] = useSearchParams< + RequiredOptionsHook | OptionalOptionsHook + >(); + setUnionSearch("q=test", { token: "session" }); + // @ts-expect-error The options must be safe for either possible hook. + setUnionSearch("q=test"); + // @ts-expect-error The required branch does not accept undefined. + setUnionSearch("q=test", undefined); +} + +void CustomSearchOptions; diff --git a/packages/wouter-preact/test/public-subpaths.test-d.ts b/packages/wouter-preact/test/public-subpaths.test-d.ts new file mode 100644 index 00000000..01c10f4a --- /dev/null +++ b/packages/wouter-preact/test/public-subpaths.test-d.ts @@ -0,0 +1,34 @@ +import { + navigate, + useBrowserLocation, + useHistoryState, + useLocationProperty, + usePathname, + useSearch, +} from "wouter-preact/use-browser-location"; +import { + navigate as navigateHash, + useHashLocation, +} from "wouter-preact/use-hash-location"; +import { memoryLocation } from "wouter-preact/memory-location"; + +// Compile through the package's public exports, rather than source-relative +// imports or path aliases. These functions are never executed. +function browserHooks() { + navigate(new URL("https://example.com"), { state: { page: 1 } }); + navigateHash("/page", { replace: true }); + useBrowserLocation({ ssrPath: "/" })[0].toUpperCase(); + useHashLocation({ ssrPath: "/" })[0].toUpperCase(); + usePathname({ ssrPath: "/" }).toUpperCase(); + useSearch({ ssrSearch: "q=test" }).toUpperCase(); + useHistoryState<{ page: number }>()?.page.toFixed(); + const literal: "online" = useLocationProperty(() => "online" as const); + return literal; +} + +const memory = memoryLocation({ path: "/", record: true }); +memory.navigate("/next"); +memory.history[0]?.toUpperCase(); +memory.reset(); + +void browserHooks; diff --git a/packages/wouter-preact/types/index.d.ts b/packages/wouter-preact/types/index.d.ts index d615e7fb..d227ba5d 100644 --- a/packages/wouter-preact/types/index.d.ts +++ b/packages/wouter-preact/types/index.d.ts @@ -1,14 +1,14 @@ -// Minimum TypeScript Version: 4.1 +// Minimum TypeScript Version: 5.2 // tslint:disable:no-unnecessary-generics -import { +import type { JSX, FunctionComponent, ComponentType, ComponentChildren, } from "preact"; -import { +import type { Path, PathPattern, BaseLocationHook, @@ -16,23 +16,21 @@ import { HookNavigationOptions, BaseSearchHook, } from "./location-hook.js"; -import { +import type { BrowserLocationHook, BrowserSearchHook, } from "./use-browser-location.js"; -import { RouterObject, RouterOptions, Parser } from "./router.js"; +import type { RouterObject, RouterOptions, Parser } from "./router.js"; -// these files only export types, so we can re-export them as-is -// in TS 5.0 we'll be able to use `export type * from ...` -export * from "./location-hook.js"; -export * from "./router.js"; +export type * from "./location-hook.js"; +export type * from "./router.js"; -import { RouteParams } from "regexparam"; +import type { ExtractRouteParams } from "./route-params.js"; -export type StringRouteParams = RouteParams & { - [param: number]: string | undefined; -}; +export type StringRouteParams = string extends T + ? DefaultParams + : ExtractRouteParams & { [param: number]: string | undefined }; export type RegexRouteParams = { [key: string | number]: string | undefined }; /** @@ -42,14 +40,23 @@ export interface DefaultParams { readonly [paramName: string | number]: string | undefined; } -export type Params = T; +export type Params = T; + +type RouteParamsFor< + T extends object | undefined, + P extends PathPattern +> = T extends object + ? T + : P extends string + ? StringRouteParams

+ : RegexRouteParams; -export type MatchWithParams = [ +export type MatchWithParams = [ true, Params ]; export type NoMatch = [false, null]; -export type Match = +export type Match = | MatchWithParams | NoMatch; @@ -57,38 +64,24 @@ export type Match = * Components: */ -export interface RouteComponentProps { +export interface RouteComponentProps { params: T; } export interface RouteProps< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern > { children?: - | (( - params: T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams - ) => ComponentChildren) + | ((params: RouteParamsFor) => ComponentChildren) | ComponentChildren; path?: RoutePath; - component?: ComponentType< - RouteComponentProps< - T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams - > - >; + component?: ComponentType>>; nest?: boolean; } export function Route< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern >(props: RouteProps): ReturnType; @@ -125,13 +118,11 @@ export type RedirectProps = }; export function Redirect( - props: RedirectProps, - context?: any + props: RedirectProps ): null; export function Link( - props: LinkProps, - context?: any + props: LinkProps ): ReturnType; /* @@ -161,17 +152,9 @@ export const Router: FunctionComponent; export function useRouter(): RouterObject; export function useRoute< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern ->( - pattern: RoutePath -): Match< - T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams ->; +>(pattern: RoutePath): Match>; export function useLocation< H extends BaseLocationHook = BrowserLocationHook @@ -181,20 +164,36 @@ export function useSearch< H extends BaseSearchHook = BrowserSearchHook >(): ReturnType; -export type URLSearchParamsInit = ConstructorParameters< - typeof URLSearchParams ->[0]; - -export type SetSearchParams = ( - nextInit: - | URLSearchParamsInit - | ((prev: URLSearchParams) => URLSearchParamsInit), - options?: { replace?: boolean; state?: any } -) => void; - -export function useSearchParams(): [URLSearchParams, SetSearchParams]; +export type URLSearchParamsInit = + | ConstructorParameters[0] + | ReadonlyArray; + +// Preserve custom hooks' required options without accepting arguments that +// useSearchParams does not forward to navigate. +type SearchParamsNavigationArgs = Extract< + Parameters[1]>, + [unknown, unknown, ...unknown[]] +> extends never + ? [options?: Parameters[1]>[1]] + : HookReturnValue[1] extends (to: Path, options: infer Options) => unknown + ? [options: Options] + : never; + +export type SetSearchParams = + ( + nextInit: + | URLSearchParamsInit + | ((prev: URLSearchParams) => URLSearchParamsInit), + ...args: SearchParamsNavigationArgs + ) => void; + +export function useSearchParams< + H extends BaseLocationHook = BrowserLocationHook +>(): [URLSearchParams, SetSearchParams]; -export function useParams(): T extends string +export function useParams< + T extends string | object | undefined = undefined +>(): T extends string ? StringRouteParams : T extends undefined ? DefaultParams @@ -204,20 +203,46 @@ export function useParams(): T extends string * Helpers */ +export type MatchWithBase = [ + true, + Params, + string +]; +// The optional slot lets TS 5.2 consumers destructure the base even on a miss. +type NoMatchWithBase = [false, null, undefined?]; +export type LooseMatch = + | MatchWithBase + | NoMatchWithBase; +type OptionalBaseMatch = + | [true, Params, string?] + | NoMatchWithBase; + +export function matchRoute< + T extends object | undefined = undefined, + RoutePath extends PathPattern = PathPattern +>( + parser: Parser, + pattern: RoutePath, + path: string, + loose: true +): LooseMatch>; +export function matchRoute< + T extends object | undefined = undefined, + RoutePath extends PathPattern = PathPattern +>( + parser: Parser, + pattern: RoutePath, + path: string, + loose?: false +): Match>; export function matchRoute< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern >( parser: Parser, pattern: RoutePath, path: string, - loose?: boolean -): Match< - T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams ->; + loose: boolean | undefined +): OptionalBaseMatch>; // tslint:enable:no-unnecessary-generics diff --git a/packages/wouter-preact/types/location-hook.d.ts b/packages/wouter-preact/types/location-hook.d.ts index b3ed50f9..744bd0cd 100644 --- a/packages/wouter-preact/types/location-hook.d.ts +++ b/packages/wouter-preact/types/location-hook.d.ts @@ -1,3 +1,5 @@ +import type { RouterObject } from "./router.js"; + /* * Foundation: useLocation and paths */ @@ -8,7 +10,7 @@ export type PathPattern = string | RegExp; export type SearchString = string; -export type HrefsFormatter = (href: string, router?: any) => string; +export type HrefsFormatter = (href: string, router: RouterObject) => string; // the base useLocation hook type. Any custom hook (including the // default one) should inherit from it. @@ -27,14 +29,15 @@ export type BaseSearchHook = (...args: any[]) => SearchString; // Returns the type of the location tuple of the given hook. export type HookReturnValue = ReturnType; +// Utility type that allows us to handle cases like `any` and `never` +type EmptyInterfaceWhenAnyOrNever = 0 extends 1 & T + ? {} + : [T] extends [never] + ? {} + : T; + // Returns the type of the navigation options that hook's push function accepts. export type HookNavigationOptions = - HookReturnValue[1] extends ( - path: Path, - options: infer R, - ...rest: any[] - ) => any - ? R extends { [k: string]: any } - ? R - : {} - : {}; + EmptyInterfaceWhenAnyOrNever< + NonNullable[1]>[1]> // get's the second argument of a tuple returned by the hook + >; diff --git a/packages/wouter-preact/types/memory-location.d.ts b/packages/wouter-preact/types/memory-location.d.ts index 27e4bc43..e79f588d 100644 --- a/packages/wouter-preact/types/memory-location.d.ts +++ b/packages/wouter-preact/types/memory-location.d.ts @@ -1,26 +1,33 @@ -import { BaseLocationHook, Path } from "./location-hook.js"; +import type { Path, SearchString } from "./location-hook.js"; +import type { NavigateOptions } from "./router.js"; -type Navigate = ( - to: Path, - options?: { replace?: boolean; state?: S; transition?: boolean } -) => void; +type Navigate = (to: Path, options?: NavigateOptions) => void; +type SearchHook = () => SearchString; type HookReturnValue = { - hook: BaseLocationHook; + hook: { + (): [Path, Navigate]; + searchHook: SearchHook; + }; + searchHook: SearchHook; navigate: Navigate; readonly state: S | null; }; type StubHistory = { history: Path[]; reset: () => void }; -export function memoryLocation(options?: { +type MemoryLocationOptions = { path?: Path; + searchPath?: SearchString; state?: S; static?: boolean; - record?: false; -}): HookReturnValue; -export function memoryLocation(options?: { - path?: Path; - state?: S; - static?: boolean; - record: true; -}): HookReturnValue & StubHistory; +}; + +export function memoryLocation( + options?: MemoryLocationOptions & { record?: false } +): HookReturnValue; +export function memoryLocation( + options: MemoryLocationOptions & { record: true } +): HookReturnValue & StubHistory; +export function memoryLocation( + options: MemoryLocationOptions & { record?: boolean } +): HookReturnValue & Partial; diff --git a/packages/wouter-preact/types/route-params.d.ts b/packages/wouter-preact/types/route-params.d.ts new file mode 100644 index 00000000..c401d23e --- /dev/null +++ b/packages/wouter-preact/types/route-params.d.ts @@ -0,0 +1,37 @@ +// Mirror regexparam's segment parser rather than interpreting colons in literals. +type SegmentParams = Segment extends `*${infer Rest}` + ? Rest extends `?${string}` + ? { "*"?: string | undefined } + : { "*": string } + : Segment extends `:${infer Name}` + ? Name extends `${infer Key}?${string}` + ? { [Param in Key]?: string | undefined } + : Name extends `${infer Key}.${string}` + ? { [Param in Key]: string } + : { [Param in Name]: string } + : {}; + +// Repeated capture names are assigned in order; the last value wins. +type AddSegment = Omit< + Params, + keyof SegmentParams +> & + SegmentParams; + +type ParseSegments = Path extends "" + ? Params + : Path extends `${infer Segment}/${infer Rest}` + ? Segment extends "" + ? Params + : ParseSegments> + : AddSegment; + +// An empty route is the catch-all; only one leading slash is removed by the +// parser, and an empty interior segment ends parsing. +export type ExtractRouteParams = Path extends unknown + ? ParseSegments< + Path extends "" ? "*" : Path extends `/${infer Rest}` ? Rest : Path + > extends infer Params + ? { [Param in keyof Params]: Params[Param] } + : never + : never; diff --git a/packages/wouter-preact/types/router.d.ts b/packages/wouter-preact/types/router.d.ts index 56a0872e..3bf16277 100644 --- a/packages/wouter-preact/types/router.d.ts +++ b/packages/wouter-preact/types/router.d.ts @@ -1,4 +1,4 @@ -import { +import type { Path, SearchString, BaseLocationHook, @@ -9,7 +9,7 @@ import { export type Parser = ( route: Path, loose?: boolean -) => { pattern: RegExp; keys: string[] }; +) => { pattern: RegExp; keys?: readonly string[] | false | undefined }; // Standard navigation options supported by all built-in location hooks export type NavigateOptions = { @@ -31,7 +31,6 @@ export interface RouterObject { readonly hook: BaseLocationHook; readonly searchHook: BaseSearchHook; readonly base: Path; - readonly ownBase: Path; readonly parser: Parser; readonly ssrPath?: Path; readonly ssrSearch?: SearchString; diff --git a/packages/wouter-preact/types/use-browser-location.d.ts b/packages/wouter-preact/types/use-browser-location.d.ts index 5b23c2e2..8de053b0 100644 --- a/packages/wouter-preact/types/use-browser-location.d.ts +++ b/packages/wouter-preact/types/use-browser-location.d.ts @@ -1,10 +1,7 @@ -import { Path, SearchString } from "./location-hook.js"; +import type { Path, SearchString } from "./location-hook.js"; +import type { NavigateOptions } from "./router.js"; -type Primitive = string | number | bigint | boolean | null | undefined | symbol; -export const useLocationProperty: ( - fn: () => S, - ssrFn?: () => S -) => S; +export const useLocationProperty: (fn: () => S, ssrFn?: () => S) => S; export type BrowserSearchHook = (options?: { ssrSearch?: SearchString; @@ -18,7 +15,7 @@ export const useHistoryState: () => T; export const navigate: ( to: string | URL, - options?: { replace?: boolean; state?: S; transition?: boolean } + options?: NavigateOptions ) => void; /* diff --git a/packages/wouter-preact/types/use-hash-location.d.ts b/packages/wouter-preact/types/use-hash-location.d.ts index 0fb32d3f..ff094014 100644 --- a/packages/wouter-preact/types/use-hash-location.d.ts +++ b/packages/wouter-preact/types/use-hash-location.d.ts @@ -1,10 +1,11 @@ -import { Path } from "./location-hook.js"; +import type { Path } from "./location-hook.js"; +import type { NavigateOptions } from "./router.js"; -export function navigate( - to: Path, - options?: { state?: S; replace?: boolean; transition?: boolean } -): void; +export function navigate(to: Path, options?: NavigateOptions): void; -export function useHashLocation(options?: { - ssrPath?: Path; -}): [Path, typeof navigate]; +export type HashLocationHook = { + (options?: { ssrPath?: Path }): [Path, typeof navigate]; + hrefs: (href: Path) => Path; +}; + +export const useHashLocation: HashLocationHook; diff --git a/packages/wouter/package.json b/packages/wouter/package.json index a900c98a..b0db7034 100644 --- a/packages/wouter/package.json +++ b/packages/wouter/package.json @@ -1,7 +1,7 @@ { "name": "wouter", - "version": "3.11.0", - "description": "Minimalist-friendly ~1.5KB router for React", + "version": "4.0.0-next.0", + "description": "Minimalist-friendly ~2.4KB router for React", "type": "module", "keywords": [ "react", @@ -38,7 +38,7 @@ }, "types": "types/index.d.ts", "typesVersions": { - ">=4.1": { + ">=5.2": { "types/index.d.ts": [ "types/index.d.ts" ], @@ -63,10 +63,9 @@ }, "license": "Unlicense", "peerDependencies": { - "react": ">=16.8.0" + "react": ">=18.2.0" }, "dependencies": { - "regexparam": "^3.0.0", - "use-sync-external-store": "^1.0.0" + "regexparam": "^3.0.0" } } diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 7c8f549e..a07e0a52 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -146,10 +146,10 @@ export const Router = ({ children, ...props }) => { } // hooks can define their own `href` formatter (e.g. for hash location) - props.hrefs = props.hrefs ?? props.hook?.hrefs; + props.hrefs ??= props.hook?.hrefs; // hooks can define their own search hook (e.g. for memory location) - props.searchHook = props.searchHook ?? props.hook?.searchHook; + props.searchHook ??= props.hook?.searchHook; // what is happening below: to avoid unnecessary rerenders in child components, // we ensure that the router object reference is stable, unless there are any @@ -198,9 +198,9 @@ const useCachedParams = (value) => { const curr = prev.current, keys = Object.keys(value); return (prev.current = - // Update cache if number of params changed or any value changed + // Update cache if parameter names or values changed keys.length !== Object.keys(curr).length || - keys.some((k) => value[k] !== curr[k]) + keys.some((k) => !Object.hasOwn(curr, k) || value[k] !== curr[k]) ? value // Return new value if there are changes : curr); // Return cached value if nothing changed }; diff --git a/packages/wouter/src/react-deps.js b/packages/wouter/src/react-deps.js index 573fbd1d..77ceb005 100644 --- a/packages/wouter/src/react-deps.js +++ b/packages/wouter/src/react-deps.js @@ -1,13 +1,14 @@ -import * as React from "react"; - -// React.useInsertionEffect is not available in React <18 -// This hack fixes a transpilation issue on some apps -const useBuiltinInsertionEffect = React["useInsertion" + "Effect"]; +import { + useRef, + useInsertionEffect, + useLayoutEffect, + useEffect, + useSyncExternalStore as useReactSyncExternalStore, +} from "react"; export { useMemo, useRef, - useState, useContext, createContext, isValidElement, @@ -17,43 +18,32 @@ export { forwardRef, } from "react"; -// To resolve webpack 5 errors, while not presenting problems for native, -// we copy the approaches from https://github.com/TanStack/query/pull/3561 -// and https://github.com/TanStack/query/pull/3601 -// ~ Show this aging PR some love to remove the need for this hack: -// https://github.com/facebook/react/pull/25231 ~ -export { useSyncExternalStore } from "./use-sync-external-store.js"; +// A fresh getter makes React 18 refresh its store instance after render-phase +// state updates. Without it, returning to the initial location can be missed. +// https://github.com/facebook/react/pull/25578 +export const useSyncExternalStore = ( + subscribe, + getSnapshot, + getServerSnapshot +) => + useReactSyncExternalStore(subscribe, () => getSnapshot(), getServerSnapshot); -// Copied from: -// https://github.com/facebook/react/blob/main/packages/shared/ExecutionEnvironment.js +// React 18 warns when useLayoutEffect runs during server rendering. +// Redirects still need a layout effect in the browser, before the next paint. const canUseDOM = !!( typeof window !== "undefined" && typeof window.document !== "undefined" && typeof window.document.createElement !== "undefined" ); - -// Copied from: -// https://github.com/reduxjs/react-redux/blob/master/src/utils/useIsomorphicLayoutEffect.ts -// "React currently throws a warning when using useLayoutEffect on the server. -// To get around it, we can conditionally useEffect on the server (no-op) and -// useLayoutEffect in the browser." export const useIsomorphicLayoutEffect = canUseDOM - ? React.useLayoutEffect - : React.useEffect; - -// useInsertionEffect is already a noop on the server. -// See: https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFizzHooks.js -export const useInsertionEffect = - useBuiltinInsertionEffect || useIsomorphicLayoutEffect; + ? useLayoutEffect + : useEffect; -// Userland polyfill while we wait for the forthcoming -// https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md -// Note: "A high-fidelity polyfill for useEvent is not possible because -// there is no lifecycle or Hook in React that we can use to switch -// .current at the right timing." -// So we will have to make do with this "close enough" approach for now. +// Keep callbacks stable while using the latest committed handler. +// React's useEffectEvent is restricted to Effects, so it cannot replace the +// callbacks passed to components and called by click/navigation handlers here. export const useEvent = (fn) => { - const ref = React.useRef([fn, (...args) => ref[0](...args)]).current; + const ref = useRef([fn, (...args) => ref[0](...args)]).current; // Per Dan Abramov: useInsertionEffect executes marginally closer to the // correct timing for ref synchronization than useLayoutEffect on React 18. // See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360 diff --git a/packages/wouter/src/use-hash-location.js b/packages/wouter/src/use-hash-location.js index 130b886b..c3288192 100644 --- a/packages/wouter/src/use-hash-location.js +++ b/packages/wouter/src/use-hash-location.js @@ -34,12 +34,7 @@ export const navigate = (to, { state = null, replace = false } = {}) => { history[replace ? "replaceState" : "pushState"](state, "", newURL); - const event = - typeof HashChangeEvent !== "undefined" - ? new HashChangeEvent("hashchange", { oldURL, newURL }) - : new Event("hashchange", { detail: { oldURL, newURL } }); - - dispatchEvent(event); + dispatchEvent(new HashChangeEvent("hashchange", { oldURL, newURL })); }; export const useHashLocation = ({ ssrPath = "/" } = {}) => [ diff --git a/packages/wouter/src/use-sync-external-store.js b/packages/wouter/src/use-sync-external-store.js deleted file mode 100644 index 885d7624..00000000 --- a/packages/wouter/src/use-sync-external-store.js +++ /dev/null @@ -1 +0,0 @@ -export { useSyncExternalStore } from "use-sync-external-store/shim/index.js"; diff --git a/packages/wouter/src/use-sync-external-store.native.js b/packages/wouter/src/use-sync-external-store.native.js deleted file mode 100644 index 53206504..00000000 --- a/packages/wouter/src/use-sync-external-store.native.js +++ /dev/null @@ -1 +0,0 @@ -export { useSyncExternalStore } from "use-sync-external-store/shim/index.native.js"; diff --git a/packages/wouter/test/fixtures/ssr.tsx b/packages/wouter/test/fixtures/ssr.tsx new file mode 100644 index 00000000..16c9cb7e --- /dev/null +++ b/packages/wouter/test/fixtures/ssr.tsx @@ -0,0 +1,58 @@ +import { createElement as h } from "react"; +import { renderToString } from "react-dom/server"; +import { + Link, + Redirect, + Router, + useLocation, + useSearch, + type SsrContext, +} from "wouter"; +import { useHashLocation } from "wouter/use-hash-location"; + +function Location() { + const [path] = useLocation(); + const search = useSearch(); + return h("p", null, `${path}?${search}`); +} + +const context: SsrContext = {}; + +console.log( + JSON.stringify({ + browserGlobals: [ + typeof window, + typeof document, + typeof location, + typeof history, + ], + browser: renderToString( + h(Router, { + ssrPath: "/ssr/react?from=path", + children: h(Location), + }) + ), + hash: renderToString( + h(Router, { + hook: useHashLocation, + ssrPath: "/ssr/hash", + ssrSearch: "?from=hash", + children: h(Location), + }) + ), + link: renderToString( + h(Router, { + ssrPath: "/", + children: h(Link, { href: "/about" }, "About"), + }) + ), + redirect: renderToString( + h(Router, { + ssrPath: "/", + ssrContext: context, + children: h(Redirect, { to: "/about" }), + }) + ), + context, + }) +); diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx index a8acfcb0..879a36b2 100644 --- a/packages/wouter/test/link.test.tsx +++ b/packages/wouter/test/link.test.tsx @@ -37,6 +37,30 @@ describe("", () => { expect(refCallback).toHaveBeenCalledWith(element); }); + test.each([false, true])( + "clears the callback ref on unmount (asChild: %s)", + (asChild) => { + const ref = mock<(element: HTMLAnchorElement | null) => void>(); + const { getByRole, unmount } = render( + asChild ? ( + + Home + + ) : ( + + Home + + ) + ); + + expect(ref).toHaveBeenCalledWith(getByRole("link", { name: "Home" })); + expect(ref).toHaveBeenCalledTimes(1); + unmount(); + expect(ref.mock.calls.at(-1)?.[0]).toBe(null); + expect(ref).toHaveBeenCalledTimes(2); + } + ); + test("still creates a plain link when nothing is passed", () => { const { getByTestId } = render(); diff --git a/packages/wouter/test/match-route.test-d.ts b/packages/wouter/test/match-route.test-d.ts index 7b8e0279..295c7463 100644 --- a/packages/wouter/test/match-route.test-d.ts +++ b/packages/wouter/test/match-route.test-d.ts @@ -4,12 +4,13 @@ import { matchRoute, useRouter } from "../src/index.js"; const assertType = (_value: T): void => {}; const { parser } = useRouter(); -test("should only accept strings", () => { +test("accepts string and regular expression patterns", () => { // @ts-expect-error assertType(matchRoute(parser, Symbol(), "")); // @ts-expect-error assertType(matchRoute(parser, undefined, "")); assertType(matchRoute(parser, "/", "")); + assertType(matchRoute(parser, /\/users\/(\d+)/, "")); }); test('has a boolean "match" result as a first returned value', () => { @@ -50,7 +51,117 @@ test("infers parameters from the route path", () => { 2?: string; name?: string; id: string; - wildcard?: string; + "*"?: string; }>(); } }); + +test("returns the matched base only for a successful loose match", () => { + const result = matchRoute(parser, "/users/:id", "/users/123/edit", true); + + if (result[0]) { + expectTypeOf(result[1].id).toEqualTypeOf(); + expectTypeOf(result[2]).toEqualTypeOf(); + expectTypeOf(result.length).toEqualTypeOf<3>(); + } else { + expectTypeOf(result).toEqualTypeOf<[false, null, undefined?]>(); + expectTypeOf(result[2]).toEqualTypeOf(); + } +}); + +test("narrows a destructured loose match and its base together", () => { + const [matched, params, base] = matchRoute( + parser, + "/users/:id", + "/users/123/edit", + true + ); + + if (matched) { + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(base).toEqualTypeOf(); + } else { + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(base).toEqualTypeOf(); + } +}); + +test("strict matches have exactly two tuple elements", () => { + const omitted = matchRoute(parser, "/users/:id", "/users/123"); + const explicit = matchRoute(parser, "/users/:id", "/users/123", false); + + expectTypeOf(omitted.length).toEqualTypeOf<2>(); + expectTypeOf(explicit.length).toEqualTypeOf<2>(); + // @ts-expect-error strict matches have no base tuple element + omitted[2]; + // @ts-expect-error strict matches have no base tuple element + explicit[2]; +}); + +test("handles a dynamic loose option", () => { + const loose: boolean = Math.random() > 0.5; + const result = matchRoute(parser, "/users/:id", "/users/123", loose); + + if (result[0]) { + expectTypeOf(result[1].id).toEqualTypeOf(); + expectTypeOf(result[2]).toEqualTypeOf(); + } else { + expectTypeOf(result).toEqualTypeOf<[false, null, undefined?]>(); + expectTypeOf(result[2]).toEqualTypeOf(); + } + + const optional: boolean | undefined = Math.random() > 0.5 ? loose : undefined; + const optionalResult = matchRoute( + parser, + "/users/:id", + "/users/123", + optional + ); + if (optionalResult[0]) { + expectTypeOf(optionalResult[2]).toEqualTypeOf(); + } + + const [matched, params, base] = matchRoute( + parser, + "/users/:id", + "/users/123", + loose + ); + if (matched) { + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(base).toEqualTypeOf(); + } else { + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(base).toEqualTypeOf(); + } +}); + +test("treats an empty pattern as a catch-all", () => { + const [, params] = matchRoute(parser, "", "/anything"); + + if (params) { + expectTypeOf(params["*"]).toEqualTypeOf(); + } +}); + +test("accepts interface parameters for strict and loose matches", () => { + interface UserParams { + id: string; + } + + const strict = matchRoute(parser, "/users/:id", "/users/123"); + const loose = matchRoute( + parser, + "/users/:id", + "/users/123", + true + ); + + if (strict[0]) { + expectTypeOf(strict[1]).toEqualTypeOf(); + } + if (loose[0]) { + expectTypeOf(loose[1]).toEqualTypeOf(); + expectTypeOf(loose[2]).toEqualTypeOf(); + } +}); diff --git a/packages/wouter/test/match-route.test.ts b/packages/wouter/test/match-route.test.ts index e4603dc4..35ed143b 100644 --- a/packages/wouter/test/match-route.test.ts +++ b/packages/wouter/test/match-route.test.ts @@ -1,20 +1,17 @@ import { test, expect } from "bun:test"; import { parse } from "regexparam"; import { matchRoute } from "../src/index.js"; +import type { RegexRouteParams } from "../src/index.js"; test("keeps empty matches and only includes the base in loose mode", () => { expect(matchRoute(parse, /^/, "/users")).toEqual([true, {}]); - expect(matchRoute(parse, /^/, "/users", true)).toEqual([ - true, - {}, - "", - ]); + expect(matchRoute(parse, /^/, "/users", true)).toEqual([true, {}, ""]); expect(matchRoute(parse, "/users", "/other", true)).toEqual([false, null]); }); test("named parser keys take precedence over positional captures", () => { const parser = () => ({ pattern: /^\/(\w+)\/(\w+)$/, keys: ["1", "0"] }); - expect(matchRoute(parser, "", "/first/second")).toEqual([ + expect(matchRoute(parser, "", "/first/second")).toEqual([ true, { 0: "second", 1: "first" }, ]); @@ -25,7 +22,7 @@ test("duplicate names keep the last capture, including missing captures", () => true, { 0: "first", 1: "second", id: "second" }, ]); - expect(matchRoute(parse, "/:id/:id?", "/first")).toStrictEqual([ + expect(matchRoute(parse, "/:id/:id?", "/first")).toStrictEqual([ true, { 0: "first", 1: undefined, id: undefined }, ]); @@ -35,7 +32,7 @@ test("regex routes bypass the parser and retain optional named captures", () => const parser = () => { throw new Error("Regex routes should not be parsed"); }; - expect( + expect( matchRoute(parser, /^\/(?\w+)(?:\/(?\w+))?/, "/first", true) ).toStrictEqual([ true, diff --git a/packages/wouter/test/memory-location.test-d.ts b/packages/wouter/test/memory-location.test-d.ts index 347cba3a..5a13caff 100644 --- a/packages/wouter/test/memory-location.test-d.ts +++ b/packages/wouter/test/memory-location.test-d.ts @@ -1,6 +1,6 @@ import { test, expectTypeOf } from "bun:test"; import { memoryLocation } from "../src/memory-location.js"; -import { BaseLocationHook } from "../src/index.js"; +import { BaseLocationHook, useLocation } from "../src/index.js"; const assertType = (_value: T): void => {}; @@ -28,7 +28,7 @@ test("should support `record` option for saving the navigation history", () => { assertType(reset); }); -test("should have history only wheen record is true", () => { +test("should have history only when record is true", () => { // @ts-expect-error const { history, reset } = memoryLocation({ record: false }); assertType(history); @@ -50,6 +50,73 @@ test("should support state", () => { expectTypeOf(memory.state).toEqualTypeOf<{ from: string } | null>(); }); +test("should preserve inferred state in both navigation functions", () => { + const memory = memoryLocation({ state: { from: "initial" } }); + const [, navigate] = memory.hook(); + + expectTypeOf(navigate).toEqualTypeOf(memory.navigate); + navigate("/next", { state: { from: "previous" }, transition: true }); + memory.navigate("/next", { state: { from: "previous" }, replace: true }); + + // @ts-expect-error - the hook must preserve the inferred state shape + navigate("/next", { state: { missing: "from" } }); + // @ts-expect-error - external navigation uses the same state shape + memory.navigate("/next", { state: 42 }); + // @ts-expect-error - state is read-only + memory.state = { from: "next" }; +}); + +test("should preserve explicit state through useLocation", () => { + const memory = memoryLocation<{ count: number }>({ record: true }); + const [, navigate] = useLocation(); + + expectTypeOf(memory.state).toEqualTypeOf<{ count: number } | null>(); + navigate("/next", { state: { count: 1 } }); + memory.navigate("/next", { state: { count: 2 } }); + memory.reset(); + + // @ts-expect-error - explicitly typed state survives the public hook + navigate("/next", { state: { count: "wrong" } }); +}); + +test("should expose the search hook attached to the location hook", () => { + const memory = memoryLocation({ searchPath: "tab=1" }); + + expectTypeOf(memory.hook.searchHook).toEqualTypeOf(memory.searchHook); + expectTypeOf(memory.hook.searchHook()).toEqualTypeOf(); + + // @ts-expect-error - attached searchHook is a function + const search: string = memory.hook.searchHook; +}); + +test("should allow dynamic recording without guaranteeing history", () => { + const record: boolean = Math.random() > 0.5; + const memory = memoryLocation({ record, state: { count: 0 } }); + + expectTypeOf(memory.history).toEqualTypeOf(); + expectTypeOf(memory.reset).toEqualTypeOf<(() => void) | undefined>(); + memory.reset?.(); + memory.hook()[1]("/next", { state: { count: 1 } }); + + // @ts-expect-error - recording may be disabled + memory.reset(); + // @ts-expect-error - recording may be disabled + const history: string[] = memory.history; + // @ts-expect-error - dynamic recording must not erase the state type + memory.hook()[1]("/next", { state: "wrong" }); +}); + +test("should accept optional recording configuration", () => { + const options: { record?: boolean } = {}; + const memory = memoryLocation<{ count: number }>(options); + + expectTypeOf(memory.history).toEqualTypeOf(); + memory.navigate("/next", { state: { count: 1 } }); + + // @ts-expect-error - record only accepts a boolean + memoryLocation({ record: "yes" }); +}); + test("should support `static` option", () => { const { hook } = memoryLocation({ static: true }); diff --git a/packages/wouter/test/public-api.test-d.tsx b/packages/wouter/test/public-api.test-d.tsx new file mode 100644 index 00000000..f09e3d14 --- /dev/null +++ b/packages/wouter/test/public-api.test-d.tsx @@ -0,0 +1,294 @@ +import { createRef } from "react"; +import { + Link, + Redirect, + Route, + Router, + Switch, + matchRoute, + useLocation, + useParams, + useRoute, + useSearch, + useSearchParams, +} from "wouter"; +import type { + Parser, + RouteComponentProps, + RouterObject, + RouterOptions, + StringRouteParams, +} from "wouter"; +import { memoryLocation } from "wouter/memory-location"; + +const location = memoryLocation({ path: "/users/42", record: true }); +const routerOptions = { + hook: location.hook, + searchHook: location.searchHook, + ssrPath: "/users/42", +} satisfies RouterOptions; + +function User({ params }: RouteComponentProps<{ id: string }>) { + return

{params.id.toUpperCase()}

; +} + +function Hooks() { + const [matched, params] = useRoute("/users/:id/:tab?"); + if (matched) { + params.id.toUpperCase(); + params.tab?.toUpperCase(); + } else { + const absent: null = params; + void absent; + } + + const paramsFromContext = useParams<"/users/:id">(); + paramsFromContext.id.toUpperCase(); + + const [path, navigate] = useLocation(); + navigate(path, { replace: true, state: { from: "/" } }); + useSearch().toUpperCase(); + const [search, setSearch] = useSearchParams(); + setSearch( + (previous) => { + previous.set("q", search.get("q") ?? ""); + return previous; + }, + { replace: true } + ); + + return null; +} + +export const application = ( + + ()}> + User + + (active ? "active" : undefined)}> + Home + + + + + {(params) =>

{params.slug.toUpperCase()}

} +
+ + + +
+ +
+); + +// Both destination props at once would be ambiguous. +// @ts-expect-error Choose either href or to. +export const ambiguousLink = ; + +// @ts-expect-error A destination is required. +export const missingRedirect = ; + +export const callbackRefLink = ( + { + const anchor: HTMLAnchorElement | null = element; + anchor?.href.toUpperCase(); + // @ts-expect-error Anchor refs are not button elements. + element?.disabled; + // React 18 calls the same callback with null when detaching the ref. + const attached: boolean = anchor !== null; + void attached; + }} + /> +); + +export const callbackRefChildLink = ( + { + const child: HTMLElement | null = element; + child?.focus(); + // @ts-expect-error An arbitrary child need not be an anchor. + element?.href; + }} + > + + +); + +export const invalidRefElement = ( + // @ts-expect-error A regular Link forwards its ref to an anchor, not a button. + ()} /> +); + +interface UserParams { + id: string; + tab?: string; +} + +export const interfaceRoute = ( + path="/users/:id/:tab?"> + {(params) => ( +

+ {params.id.toUpperCase()} + {params.tab?.toUpperCase()} +

+ )} + +); + +export const optionalDuplicate: StringRouteParams<"/:id/:id?"> = { + id: undefined, +}; +export const requiredDuplicate: StringRouteParams<"/:id?/:id"> = { + id: "42", +}; + +// @ts-expect-error The final required capture cannot be omitted. +export const missingDuplicate: StringRouteParams<"/:id?/:id"> = {}; + +const missingKeys: Parser = () => ({ pattern: /^\/users\/(\w+)/ }); +const falseKeys: Parser = () => ({ + pattern: /^\/users\/(\w+)/, + keys: false, +}); +const readonlyKeys: Parser = () => ({ + pattern: /^\/users\/(\w+)/, + keys: ["id"] as const, +}); + +export const customParsers: RouterOptions[] = [ + { parser: missingKeys }, + { parser: falseKeys }, + { parser: readonlyKeys }, + { + hrefs: (href, router) => { + const currentRouter: RouterObject = router; + // @ts-expect-error RouterObject has no arbitrary properties. + router.missingProperty; + return currentRouter.base + href; + }, + }, +]; + +function ImprovedRouteInference(dynamicPattern: string) { + const [, explicit] = useRoute("/users/:id/:tab?"); + if (explicit) { + const id: string = explicit.id; + const optional: string | undefined = explicit.tab; + void [id, optional]; + } + + const [, wildcard] = useRoute("/files/*/edit"); + if (wildcard) { + const capture: string = wildcard["*"]; + // @ts-expect-error Runtime names wildcard captures "*", not "wild". + wildcard.wild; + void capture; + } + + const [, filename] = useRoute("/files/:name.json/:version?.txt"); + if (filename) { + const name: string = filename.name; + const version: string | undefined = filename.version; + // @ts-expect-error The extension is not part of the parameter name. + filename["name.json"]; + void [name, version]; + } + + const [, optional] = useRoute("/:id/:id?"); + if (optional) { + const lastCapture: string | undefined = optional.id; + // @ts-expect-error The final optional capture can be absent. + const required: string = optional.id; + void [lastCapture, required]; + } + + const [, dynamic] = useRoute(dynamicPattern); + if (dynamic) { + const named: string | undefined = dynamic.id; + const numbered: string | undefined = dynamic[0]; + // @ts-expect-error A dynamic pattern does not guarantee named parameters. + const required: string = dynamic.id; + void [named, numbered, required]; + } + + const [matched, params, base] = matchRoute( + readonlyKeys, + "/users/:id", + "/users/42/details", + true + ); + if (matched) { + const id: string = params.id; + const matchedBase: string = base; + void [id, matchedBase]; + } else { + const missingParams: null = params; + const missingBase: undefined = base; + void [missingParams, missingBase]; + } +} + +const statefulMemory = memoryLocation({ state: { page: 1 } }); + +function StatefulHooks() { + const [, directlyNavigate] = statefulMemory.hook(); + directlyNavigate("/users", { state: { page: 2 } }); + // @ts-expect-error Memory state retains the initial object's shape. + directlyNavigate("/users", { state: { page: "two" } }); + + const [, navigate] = useLocation(); + navigate("/users", { state: { page: 2 } }); + // @ts-expect-error useLocation preserves custom navigation options. + navigate("/users", { state: { page: "two" } }); + + const [, setSearch] = useSearchParams(); + const entries = [["page", "2"]] as const; + setSearch(entries, { state: { page: 2 } }); + setSearch(() => entries); + // @ts-expect-error Search updates use the location hook's state type. + setSearch(entries, { state: { page: "two" } }); + + const [, setBrowserSearch] = useSearchParams(); + setBrowserSearch(entries, { transition: true }); + return null; +} + +void [ImprovedRouteInference, StatefulHooks]; + +type NullableOptionsHook = () => [ + string, + (to: string, options?: { token: string } | null) => void +]; +type RequiredOptionsHook = () => [ + string, + (to: string, options: { token: string }) => void +]; +type OptionalOptionsHook = () => [ + string, + (to: string, options?: { token: string }) => void +]; + +function CustomSearchOptions() { + const [, setNullableSearch] = useSearchParams(); + setNullableSearch("q=test", null); + setNullableSearch("q=test", undefined); + setNullableSearch("q=test"); + setNullableSearch("q=test", { token: "session" }); + // @ts-expect-error Only the hook's declared options are allowed. + setNullableSearch("q=test", 123); + + const [, setUnionSearch] = useSearchParams< + RequiredOptionsHook | OptionalOptionsHook + >(); + setUnionSearch("q=test", { token: "session" }); + // @ts-expect-error The options must be safe for either possible hook. + setUnionSearch("q=test"); + // @ts-expect-error The required branch does not accept undefined. + setUnionSearch("q=test", undefined); +} + +void CustomSearchOptions; diff --git a/packages/wouter/test/public-subpaths.test-d.ts b/packages/wouter/test/public-subpaths.test-d.ts new file mode 100644 index 00000000..c8e1dde0 --- /dev/null +++ b/packages/wouter/test/public-subpaths.test-d.ts @@ -0,0 +1,34 @@ +import { + navigate, + useBrowserLocation, + useHistoryState, + useLocationProperty, + usePathname, + useSearch, +} from "wouter/use-browser-location"; +import { + navigate as navigateHash, + useHashLocation, +} from "wouter/use-hash-location"; +import { memoryLocation } from "wouter/memory-location"; + +// Compile through the package's public exports, rather than source-relative +// imports or path aliases. These functions are never executed. +function browserHooks() { + navigate(new URL("https://example.com"), { state: { page: 1 } }); + navigateHash("/page", { replace: true }); + useBrowserLocation({ ssrPath: "/" })[0].toUpperCase(); + useHashLocation({ ssrPath: "/" })[0].toUpperCase(); + usePathname({ ssrPath: "/" }).toUpperCase(); + useSearch({ ssrSearch: "q=test" }).toUpperCase(); + useHistoryState<{ page: number }>()?.page.toFixed(); + const literal: "online" = useLocationProperty(() => "online" as const); + return literal; +} + +const memory = memoryLocation({ path: "/", record: true }); +memory.navigate("/next"); +memory.history[0]?.toUpperCase(); +memory.reset(); + +void browserHooks; diff --git a/packages/wouter/test/route.test-d.tsx b/packages/wouter/test/route.test-d.tsx index f820da4b..db1b1a89 100644 --- a/packages/wouter/test/route.test-d.tsx +++ b/packages/wouter/test/route.test-d.tsx @@ -109,11 +109,26 @@ describe("parameter inference", () => { ; }); - test("extract wildcard params into `wild` property", () => { + test("extracts wildcard params into the `*` property", () => { - {({ wild }) => { - expectTypeOf(wild).toEqualTypeOf(); - return
The path is {wild}
; + {(params) => { + expectTypeOf(params["*"]).toEqualTypeOf(); + // @ts-expect-error wildcard captures are named `*`, not `wild` + params.wild; + return
The path is {params["*"]}
; + }} +
; + }); + + test("infers optional parameters and file extensions", () => { + + {(params) => { + expectTypeOf(params.name).toEqualTypeOf(); + expectTypeOf(params.revision).toEqualTypeOf(); + expectTypeOf(params["*"]).toEqualTypeOf(); + // @ts-expect-error regexparam treats the extension as literal text + params.format; + return null; }} ; }); @@ -127,11 +142,33 @@ describe("parameter inference", () => { ; }); - test("can't infer the type when the path isn't known at compile time", () => { + test("supports interface parameters without an index signature", () => { + interface UserParams { + id: string; + section?: string; + } + + path="/users/:id/:section?"> + {(params) => { + expectTypeOf(params).toEqualTypeOf(); + return params.id; + }} + ; + }); + + test("uses optional string parameters for dynamic and any paths", () => { + const path: string = "/home/:section"; + + + {(params) => { + expectTypeOf(params.section).toEqualTypeOf(); + return
; + }} + ; + {(params) => { - // @ts-expect-error - params.section; + expectTypeOf(params.section).toEqualTypeOf(); return
; }} ; diff --git a/packages/wouter/test/router.test-d.tsx b/packages/wouter/test/router.test-d.tsx index 1908862d..f56c9d83 100644 --- a/packages/wouter/test/router.test-d.tsx +++ b/packages/wouter/test/router.test-d.tsx @@ -7,6 +7,7 @@ import { useRouter, Parser, Path, + RouterObject, } from "../src/index.js"; test("should have at least one child", () => { @@ -63,7 +64,9 @@ test("accepts `hrefs` function for transforming href strings", () => { { - expectTypeOf(router).toEqualTypeOf(); + expectTypeOf(router).toEqualTypeOf(); + // @ts-expect-error This field does not exist on the router at runtime. + router.ownBase; return href + router.base; }} > @@ -80,6 +83,14 @@ test("accepts `parser` function for generating regular expressions", () => { }; this is a valid router; + + ({ pattern: /(?\w+)/ })}>Named groups; + ({ pattern: /(?\w+)/, keys: false })}> + Named groups without keys + ; + ({ pattern: /(\w+)/, keys: ["id"] as const })}> + Readonly parser keys + ; }); test("does not accept other props", () => { diff --git a/packages/wouter/test/ssr-environment.test.ts b/packages/wouter/test/ssr-environment.test.ts new file mode 100644 index 00000000..6435e6ab --- /dev/null +++ b/packages/wouter/test/ssr-environment.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { join } from "path"; + +test("renders links, redirects, and locations without browser globals or SSR warnings", async () => { + // A plain Bun process does not load the test suite's happy-dom preload. + const child = Bun.spawn( + [process.execPath, join(import.meta.dir, "fixtures/ssr.tsx")], + { stdout: "pipe", stderr: "pipe" } + ); + const [output, errors, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + + expect(errors).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(output)).toEqual({ + browserGlobals: ["undefined", "undefined", "undefined", "undefined"], + browser: "

/ssr/react?from=path

", + hash: "

/ssr/hash?from=hash

", + link: 'About', + redirect: "", + context: { redirectTo: "/about" }, + }); +}); diff --git a/packages/wouter/test/use-browser-location.test-d.ts b/packages/wouter/test/use-browser-location.test-d.ts index 3e990870..fe0aa54c 100644 --- a/packages/wouter/test/use-browser-location.test-d.ts +++ b/packages/wouter/test/use-browser-location.test-d.ts @@ -3,6 +3,7 @@ import { useBrowserLocation, useSearch, useHistoryState, + useLocationProperty, } from "../src/use-browser-location.js"; const assertType = (_value: T): void => {}; @@ -27,6 +28,13 @@ describe("useBrowserLocation", () => { assertType(navigate(null)); assertType(navigate("/path", { replace: true })); + assertType(navigate(new URL("https://example.com/next"))); + navigate<{ count: number }>("/path", { + state: { count: 1 }, + transition: true, + }); + // @ts-expect-error - explicit state shapes are checked + navigate<{ count: number }>("/path", { state: { count: "wrong" } }); // @ts-expect-error assertType(navigate("/path", { unknownOption: true })); }); @@ -38,6 +46,27 @@ describe("useBrowserLocation", () => { }); }); +describe("useLocationProperty", () => { + test("should preserve object and nullable snapshot types", () => { + const snapshot = { from: "/previous", scroll: [0, 100] as const }; + const state = useLocationProperty(() => snapshot); + + expectTypeOf(state).toEqualTypeOf(); + + const nullable = useLocationProperty( + () => snapshot, + () => null + ); + expectTypeOf(nullable).toEqualTypeOf(); + + useLocationProperty( + () => snapshot, + // @ts-expect-error - the server snapshot must match the chosen type + () => "wrong" + ); + }); +}); + describe("useSearch", () => { test("should return string", () => { type Search = ReturnType; diff --git a/packages/wouter/test/use-hash-location.test-d.ts b/packages/wouter/test/use-hash-location.test-d.ts index 8144be85..e4464091 100644 --- a/packages/wouter/test/use-hash-location.test-d.ts +++ b/packages/wouter/test/use-hash-location.test-d.ts @@ -19,6 +19,13 @@ test("accepts a `ssrPath` path option", () => { useHashLocation({ unknown: "/base" }); }); +test("exposes its href formatter", () => { + expectTypeOf(useHashLocation.hrefs("/users")).toEqualTypeOf(); + + // @ts-expect-error - hrefs formats string paths + useHashLocation.hrefs(new URL("https://example.com")); +}); + describe("`navigate` function", () => { test("accepts an arbitrary `state` option", () => { navigate("/object", { state: { foo: "bar" } }); @@ -30,4 +37,18 @@ describe("`navigate` function", () => { test("returns nothing", () => { assertType(navigate("/foo")); }); + + test("preserves explicit state types through the hook", () => { + const [, navigateFromHook] = useHashLocation(); + navigateFromHook<{ count: number }>("/next", { + state: { count: 1 }, + replace: true, + transition: true, + }); + + // @ts-expect-error - explicit state shapes are checked + navigateFromHook<{ count: number }>("/next", { state: "wrong" }); + // @ts-expect-error - hash navigation only accepts string paths + navigate(new URL("https://example.com")); + }); }); diff --git a/packages/wouter/test/use-params.test-d.ts b/packages/wouter/test/use-params.test-d.ts index 82d3f4c8..ae90c749 100644 --- a/packages/wouter/test/use-params.test-d.ts +++ b/packages/wouter/test/use-params.test-d.ts @@ -1,5 +1,5 @@ import { test, expectTypeOf } from "bun:test"; -import { useParams } from "../src/index.js"; +import { useParams, StringRouteParams } from "../src/index.js"; test("does not accept any arguments", () => { expectTypeOf().parameters.toEqualTypeOf<[]>(); @@ -35,3 +35,49 @@ test("can accept the custom type of parameters as a generic argument", () => { //@ts-expect-error return params.notFound; }); + +test("accepts interface parameters without an index signature", () => { + interface UserParams { + id: string; + page?: number; + } + + expectTypeOf(useParams()).toEqualTypeOf(); +}); + +test("rejects primitives that are neither patterns nor parameter objects", () => { + // @ts-expect-error a number cannot describe route parameters + useParams(); + // @ts-expect-error a boolean cannot describe route parameters + useParams(); + // @ts-expect-error null cannot describe route parameters + useParams(); + // @ts-expect-error void cannot describe route parameters + useParams(); +}); + +test("accepts explicitly undefined optional capture values", () => { + const params: StringRouteParams<"/:id?/*?"> = { + id: undefined, + "*": undefined, + }; + + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(params["*"]).toEqualTypeOf(); +}); + +test("provides optional string values for an unspecified route string", () => { + const params = useParams(); + + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(params[0]).toEqualTypeOf(); +}); + +test("preserves regexparam's names for optional extension captures", () => { + const params = useParams<"/files/:name.json?/:revision?.txt">(); + + expectTypeOf(params["name.json"]).toEqualTypeOf(); + expectTypeOf(params.revision).toEqualTypeOf(); + // @ts-expect-error regexparam uses all text before `?` as the parameter name + params.name; +}); diff --git a/packages/wouter/test/use-params.test.tsx b/packages/wouter/test/use-params.test.tsx index eb433d21..573fa1e3 100644 --- a/packages/wouter/test/use-params.test.tsx +++ b/packages/wouter/test/use-params.test.tsx @@ -150,6 +150,25 @@ test("keeps the object ref the same if params haven't changed", () => { expect(result.current).toBe(firstRenderedParams); }); +test("updates parameter names when optional values remain undefined", () => { + const { hook } = memoryLocation({ path: "/" }); + let path = "/:first?"; + + const { result, rerender } = renderHook(() => useParams(), { + wrapper: (props) => ( + + {props.children} + + ), + }); + + expect(result.current).toStrictEqual({ 0: undefined, first: undefined }); + + path = "/:second?"; + rerender(); + expect(result.current).toStrictEqual({ 0: undefined, second: undefined }); +}); + test("works when the route becomes matching", () => { const { hook, navigate } = memoryLocation({ path: "/" }); diff --git a/packages/wouter/test/use-route.test-d.ts b/packages/wouter/test/use-route.test-d.ts index 57261bca..e7346043 100644 --- a/packages/wouter/test/use-route.test-d.ts +++ b/packages/wouter/test/use-route.test-d.ts @@ -3,12 +3,13 @@ import { useRoute } from "../src/index.js"; const assertType = (_value: T): void => {}; -test("should only accept strings", () => { +test("accepts string and regular expression patterns", () => { // @ts-expect-error assertType(useRoute(Symbol())); // @ts-expect-error assertType(useRoute()); assertType(useRoute("/")); + assertType(useRoute(/\/users\/(\d+)/)); }); test('has a boolean "match" result as a first returned value', () => { @@ -47,11 +48,97 @@ test("infers parameters from the route path", () => { 2?: string; name?: string; id: string; - wildcard?: string; + "*"?: string; }>(); } }); +test("uses the runtime wildcard key in every position", () => { + const [match, params] = useRoute("/files/*/edit"); + + if (match) { + expectTypeOf(params["*"]).toEqualTypeOf(); + // @ts-expect-error middle wildcard captures are still named `*` + params.wild; + } + + const [, optional] = useRoute("/files/*?"); + if (optional) { + expectTypeOf(optional["*"]).toEqualTypeOf(); + } +}); + +test("extracts parameter names before file extensions", () => { + const [, params] = useRoute("/files/:name.json/:version?.txt"); + + if (params) { + expectTypeOf(params.name).toEqualTypeOf(); + expectTypeOf(params.version).toEqualTypeOf(); + // @ts-expect-error the file extension is not part of the key + params["name.json"]; + } +}); + +test("lets the last duplicate capture determine optionality", () => { + const [, required] = useRoute("/:id?/:id"); + const [, optional] = useRoute("/:id/:id?"); + + if (required) { + expectTypeOf(required.id).toEqualTypeOf(); + } + if (optional) { + expectTypeOf(optional.id).toEqualTypeOf(); + } +}); + +test("ignores embedded colons and segments after an empty segment", () => { + const [, params] = useRoute("/literal:name/:id//:ignored"); + + if (params) { + expectTypeOf(params.id).toEqualTypeOf(); + // @ts-expect-error a colon must begin a segment to introduce a parameter + params.name; + // @ts-expect-error the parser stops at the empty segment + params.ignored; + } +}); + +test("returns optional string parameters for dynamic paths", () => { + const path: string = "/users/:id"; + const [, params] = useRoute(path); + + if (params) { + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(params[0]).toEqualTypeOf(); + } +}); + +test("distributes inferred parameters over route unions", () => { + const pattern = Math.random() ? "/users/:id" : "/posts/:slug"; + const [, params] = useRoute(pattern); + + if (params && "id" in params) { + expectTypeOf(params.id).toEqualTypeOf(); + // @ts-expect-error the other route's parameter is not present + params.slug; + } + if (params && "slug" in params) { + expectTypeOf(params.slug).toEqualTypeOf(); + } +}); + +test("accepts interface parameters without an index signature", () => { + interface UserParams { + id: string; + name?: string; + } + + const [match, params] = useRoute("/users/:id/:name?"); + if (match) { + expectTypeOf(params).toEqualTypeOf(); + } +}); + test("infers parameters from absolute route patterns", () => { const [match, params] = useRoute("~/app/users/:name?/:id"); diff --git a/packages/wouter/test/use-search-params.test-d.ts b/packages/wouter/test/use-search-params.test-d.ts new file mode 100644 index 00000000..9e41e8ff --- /dev/null +++ b/packages/wouter/test/use-search-params.test-d.ts @@ -0,0 +1,67 @@ +import { test, expectTypeOf } from "bun:test"; +import { useSearchParams, type SetSearchParams } from "../src/index.js"; +import { memoryLocation } from "../src/memory-location.js"; + +test("accepts readonly search entries and all standard navigation options", () => { + const [params, setParams] = useSearchParams(); + expectTypeOf(params).toEqualTypeOf(); + expectTypeOf(setParams).toEqualTypeOf(); + setParams([["q", "hello"]] as const, { transition: true }); + setParams(() => [["page", "2"]] as const, { replace: true }); + setParams(undefined); +}); + +test("preserves the navigation state type of a memory hook", () => { + const memory = memoryLocation<{ from: string }>(); + const [, setParams] = useSearchParams(); + setParams("q=hello", { state: { from: "/" }, transition: true }); + setParams({ q: "hello" }); + // @ts-expect-error State must conform to the custom hook. + setParams("q=hello", { state: { from: 123 } }); +}); + +test("preserves required options for a custom navigation hook", () => { + type CustomHook = () => [ + string, + (to: string, options: { token: string }) => void + ]; + const [, setParams] = useSearchParams(); + setParams("q=hello", { token: "session" }); + // @ts-expect-error This hook requires navigation options. + setParams("q=hello"); + // @ts-expect-error This hook requires a token. + setParams("q=hello", { replace: true }); + // @ts-expect-error The setter only forwards the options argument. + setParams("q=hello", { token: "session" }, "extra"); +}); + +test("forwards nullable navigation options unchanged", () => { + type CustomHook = () => [ + string, + (to: string, options?: { token: string } | null) => void + ]; + const [, setParams] = useSearchParams(); + setParams("q=hello", null); + setParams("q=hello", undefined); + setParams("q=hello"); + setParams("q=hello", { token: "session" }); + // @ts-expect-error The hook does not accept numbers. + setParams("q=hello", 123); +}); + +test("keeps options required when a hook union contains a required branch", () => { + type RequiredHook = () => [ + string, + (to: string, options: { token: string }) => void + ]; + type OptionalHook = () => [ + string, + (to: string, options?: { token: string }) => void + ]; + const [, setParams] = useSearchParams(); + setParams("q=hello", { token: "session" }); + // @ts-expect-error Options must be safe for either possible hook. + setParams("q=hello"); + // @ts-expect-error The required hook does not accept undefined. + setParams("q=hello", undefined); +}); diff --git a/packages/wouter/test/use-search.test.tsx b/packages/wouter/test/use-search.test.tsx index 9b5bb02d..d238e3e9 100644 --- a/packages/wouter/test/use-search.test.tsx +++ b/packages/wouter/test/use-search.test.tsx @@ -11,7 +11,7 @@ test("revisits the initial search after a render-phase state update (#393)", () const { result } = renderHook(() => { const search = useSearch(); // Minimal equivalent of urql synchronizing query state during render. - // React 18 drops this update; fixed in React 19: + // React 18 drops this update without our fresh-snapshot-getter adapter: // https://github.com/facebook/react/pull/25578 const [previousSearch, setPreviousSearch] = useState(search); if (previousSearch !== search) setPreviousSearch(search); diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts index cada6813..18abb5b7 100644 --- a/packages/wouter/types/index.d.ts +++ b/packages/wouter/types/index.d.ts @@ -1,19 +1,18 @@ -// Minimum TypeScript Version: 4.1 +// Minimum TypeScript Version: 5.2 // tslint:disable:no-unnecessary-generics -import { +import type { AnchorHTMLAttributes, FunctionComponent, RefAttributes, - ComponentType, ReactNode, ReactElement, MouseEventHandler, JSXElementConstructor, } from "react"; -import { +import type { Path, PathPattern, BaseLocationHook, @@ -21,23 +20,21 @@ import { HookNavigationOptions, BaseSearchHook, } from "./location-hook.js"; -import { +import type { BrowserLocationHook, BrowserSearchHook, } from "./use-browser-location.js"; -import { Parser, RouterObject, RouterOptions } from "./router.js"; +import type { Parser, RouterObject, RouterOptions } from "./router.js"; -// these files only export types, so we can re-export them as-is -// in TS 5.0 we'll be able to use `export type * from ...` -export * from "./location-hook.js"; -export * from "./router.js"; +export type * from "./location-hook.js"; +export type * from "./router.js"; -import { RouteParams } from "regexparam"; +import type { ExtractRouteParams } from "./route-params.js"; -export type StringRouteParams = RouteParams & { - [param: number]: string | undefined; -}; +export type StringRouteParams = string extends T + ? DefaultParams + : ExtractRouteParams & { [param: number]: string | undefined }; export type RegexRouteParams = { [key: string | number]: string | undefined }; /** @@ -47,14 +44,23 @@ export interface DefaultParams { readonly [paramName: string | number]: string | undefined; } -export type Params = T; +export type Params = T; + +type RouteParamsFor< + T extends object | undefined, + P extends PathPattern +> = T extends object + ? T + : P extends string + ? StringRouteParams

+ : RegexRouteParams; -export type MatchWithParams = [ +export type MatchWithParams = [ true, Params ]; export type NoMatch = [false, null]; -export type Match = +export type Match = | MatchWithParams | NoMatch; @@ -62,38 +68,24 @@ export type Match = * Components: */ -export interface RouteComponentProps { +export interface RouteComponentProps { params: T; } export interface RouteProps< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern > { - children?: - | (( - params: T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams - ) => ReactNode) - | ReactNode; + children?: ((params: RouteParamsFor) => ReactNode) | ReactNode; path?: RoutePath; component?: JSXElementConstructor< - RouteComponentProps< - T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams - > + RouteComponentProps> >; nest?: boolean; } export function Route< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern >(props: RouteProps): ReturnType; @@ -112,8 +104,7 @@ export type RedirectProps = }; export function Redirect( - props: RedirectProps, - context?: any + props: RedirectProps ): null; type AsChildProps = @@ -139,8 +130,7 @@ export type LinkProps = >; export function Link( - props: LinkProps, - context?: any + props: LinkProps ): ReturnType; /* @@ -170,17 +160,9 @@ export const Router: FunctionComponent; export function useRouter(): RouterObject; export function useRoute< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern ->( - pattern: RoutePath -): Match< - T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams ->; +>(pattern: RoutePath): Match>; export function useLocation< H extends BaseLocationHook = BrowserLocationHook @@ -190,19 +172,36 @@ export function useSearch< H extends BaseSearchHook = BrowserSearchHook >(): ReturnType; -export type URLSearchParamsInit = ConstructorParameters< - typeof URLSearchParams ->[0]; -export type SetSearchParams = ( - nextInit: - | URLSearchParamsInit - | ((prev: URLSearchParams) => URLSearchParamsInit), - options?: { replace?: boolean; state?: any } -) => void; - -export function useSearchParams(): [URLSearchParams, SetSearchParams]; +export type URLSearchParamsInit = + | ConstructorParameters[0] + | ReadonlyArray; + +// Preserve custom hooks' required options without accepting arguments that +// useSearchParams does not forward to navigate. +type SearchParamsNavigationArgs = Extract< + Parameters[1]>, + [unknown, unknown, ...unknown[]] +> extends never + ? [options?: Parameters[1]>[1]] + : HookReturnValue[1] extends (to: Path, options: infer Options) => unknown + ? [options: Options] + : never; + +export type SetSearchParams = + ( + nextInit: + | URLSearchParamsInit + | ((prev: URLSearchParams) => URLSearchParamsInit), + ...args: SearchParamsNavigationArgs + ) => void; + +export function useSearchParams< + H extends BaseLocationHook = BrowserLocationHook +>(): [URLSearchParams, SetSearchParams]; -export function useParams(): T extends string +export function useParams< + T extends string | object | undefined = undefined +>(): T extends string ? StringRouteParams : T extends undefined ? DefaultParams @@ -212,20 +211,46 @@ export function useParams(): T extends string * Helpers */ +export type MatchWithBase = [ + true, + Params, + string +]; +// The optional slot lets TS 5.2 consumers destructure the base even on a miss. +type NoMatchWithBase = [false, null, undefined?]; +export type LooseMatch = + | MatchWithBase + | NoMatchWithBase; +type OptionalBaseMatch = + | [true, Params, string?] + | NoMatchWithBase; + +export function matchRoute< + T extends object | undefined = undefined, + RoutePath extends PathPattern = PathPattern +>( + parser: Parser, + pattern: RoutePath, + path: string, + loose: true +): LooseMatch>; +export function matchRoute< + T extends object | undefined = undefined, + RoutePath extends PathPattern = PathPattern +>( + parser: Parser, + pattern: RoutePath, + path: string, + loose?: false +): Match>; export function matchRoute< - T extends DefaultParams | undefined = undefined, + T extends object | undefined = undefined, RoutePath extends PathPattern = PathPattern >( parser: Parser, pattern: RoutePath, path: string, - loose?: boolean -): Match< - T extends DefaultParams - ? T - : RoutePath extends string - ? StringRouteParams - : RegexRouteParams ->; + loose: boolean | undefined +): OptionalBaseMatch>; // tslint:enable:no-unnecessary-generics diff --git a/packages/wouter/types/location-hook.d.ts b/packages/wouter/types/location-hook.d.ts index e7e0db03..744bd0cd 100644 --- a/packages/wouter/types/location-hook.d.ts +++ b/packages/wouter/types/location-hook.d.ts @@ -1,3 +1,5 @@ +import type { RouterObject } from "./router.js"; + /* * Foundation: useLocation and paths */ @@ -8,7 +10,7 @@ export type PathPattern = string | RegExp; export type SearchString = string; -export type HrefsFormatter = (href: string, router?: any) => string; +export type HrefsFormatter = (href: string, router: RouterObject) => string; // the base useLocation hook type. Any custom hook (including the // default one) should inherit from it. diff --git a/packages/wouter/types/memory-location.d.ts b/packages/wouter/types/memory-location.d.ts index 18446b90..e79f588d 100644 --- a/packages/wouter/types/memory-location.d.ts +++ b/packages/wouter/types/memory-location.d.ts @@ -1,34 +1,33 @@ -import { - BaseLocationHook, - BaseSearchHook, - Path, - SearchString, -} from "./location-hook.js"; +import type { Path, SearchString } from "./location-hook.js"; +import type { NavigateOptions } from "./router.js"; -type Navigate = ( - to: Path, - options?: { replace?: boolean; state?: S; transition?: boolean } -) => void; +type Navigate = (to: Path, options?: NavigateOptions) => void; +type SearchHook = () => SearchString; type HookReturnValue = { - hook: BaseLocationHook; - searchHook: BaseSearchHook; + hook: { + (): [Path, Navigate]; + searchHook: SearchHook; + }; + searchHook: SearchHook; navigate: Navigate; readonly state: S | null; }; type StubHistory = { history: Path[]; reset: () => void }; -export function memoryLocation(options?: { +type MemoryLocationOptions = { path?: Path; searchPath?: SearchString; state?: S; static?: boolean; - record?: false; -}): HookReturnValue; -export function memoryLocation(options?: { - path?: Path; - searchPath?: SearchString; - state?: S; - static?: boolean; - record: true; -}): HookReturnValue & StubHistory; +}; + +export function memoryLocation( + options?: MemoryLocationOptions & { record?: false } +): HookReturnValue; +export function memoryLocation( + options: MemoryLocationOptions & { record: true } +): HookReturnValue & StubHistory; +export function memoryLocation( + options: MemoryLocationOptions & { record?: boolean } +): HookReturnValue & Partial; diff --git a/packages/wouter/types/route-params.d.ts b/packages/wouter/types/route-params.d.ts new file mode 100644 index 00000000..c401d23e --- /dev/null +++ b/packages/wouter/types/route-params.d.ts @@ -0,0 +1,37 @@ +// Mirror regexparam's segment parser rather than interpreting colons in literals. +type SegmentParams = Segment extends `*${infer Rest}` + ? Rest extends `?${string}` + ? { "*"?: string | undefined } + : { "*": string } + : Segment extends `:${infer Name}` + ? Name extends `${infer Key}?${string}` + ? { [Param in Key]?: string | undefined } + : Name extends `${infer Key}.${string}` + ? { [Param in Key]: string } + : { [Param in Name]: string } + : {}; + +// Repeated capture names are assigned in order; the last value wins. +type AddSegment = Omit< + Params, + keyof SegmentParams +> & + SegmentParams; + +type ParseSegments = Path extends "" + ? Params + : Path extends `${infer Segment}/${infer Rest}` + ? Segment extends "" + ? Params + : ParseSegments> + : AddSegment; + +// An empty route is the catch-all; only one leading slash is removed by the +// parser, and an empty interior segment ends parsing. +export type ExtractRouteParams = Path extends unknown + ? ParseSegments< + Path extends "" ? "*" : Path extends `/${infer Rest}` ? Rest : Path + > extends infer Params + ? { [Param in keyof Params]: Params[Param] } + : never + : never; diff --git a/packages/wouter/types/router.d.ts b/packages/wouter/types/router.d.ts index 56a0872e..3bf16277 100644 --- a/packages/wouter/types/router.d.ts +++ b/packages/wouter/types/router.d.ts @@ -1,4 +1,4 @@ -import { +import type { Path, SearchString, BaseLocationHook, @@ -9,7 +9,7 @@ import { export type Parser = ( route: Path, loose?: boolean -) => { pattern: RegExp; keys: string[] }; +) => { pattern: RegExp; keys?: readonly string[] | false | undefined }; // Standard navigation options supported by all built-in location hooks export type NavigateOptions = { @@ -31,7 +31,6 @@ export interface RouterObject { readonly hook: BaseLocationHook; readonly searchHook: BaseSearchHook; readonly base: Path; - readonly ownBase: Path; readonly parser: Parser; readonly ssrPath?: Path; readonly ssrSearch?: SearchString; diff --git a/packages/wouter/types/use-browser-location.d.ts b/packages/wouter/types/use-browser-location.d.ts index 5b23c2e2..8de053b0 100644 --- a/packages/wouter/types/use-browser-location.d.ts +++ b/packages/wouter/types/use-browser-location.d.ts @@ -1,10 +1,7 @@ -import { Path, SearchString } from "./location-hook.js"; +import type { Path, SearchString } from "./location-hook.js"; +import type { NavigateOptions } from "./router.js"; -type Primitive = string | number | bigint | boolean | null | undefined | symbol; -export const useLocationProperty: ( - fn: () => S, - ssrFn?: () => S -) => S; +export const useLocationProperty: (fn: () => S, ssrFn?: () => S) => S; export type BrowserSearchHook = (options?: { ssrSearch?: SearchString; @@ -18,7 +15,7 @@ export const useHistoryState: () => T; export const navigate: ( to: string | URL, - options?: { replace?: boolean; state?: S; transition?: boolean } + options?: NavigateOptions ) => void; /* diff --git a/packages/wouter/types/use-hash-location.d.ts b/packages/wouter/types/use-hash-location.d.ts index 0fb32d3f..ff094014 100644 --- a/packages/wouter/types/use-hash-location.d.ts +++ b/packages/wouter/types/use-hash-location.d.ts @@ -1,10 +1,11 @@ -import { Path } from "./location-hook.js"; +import type { Path } from "./location-hook.js"; +import type { NavigateOptions } from "./router.js"; -export function navigate( - to: Path, - options?: { state?: S; replace?: boolean; transition?: boolean } -): void; +export function navigate(to: Path, options?: NavigateOptions): void; -export function useHashLocation(options?: { - ssrPath?: Path; -}): [Path, typeof navigate]; +export type HashLocationHook = { + (options?: { ssrPath?: Path }): [Path, typeof navigate]; + hrefs: (href: Path) => Path; +}; + +export const useHashLocation: HashLocationHook; diff --git a/scripts/measure-size.ts b/scripts/measure-size.ts new file mode 100644 index 00000000..bf89d99a --- /dev/null +++ b/scripts/measure-size.ts @@ -0,0 +1,116 @@ +import { resolve } from "node:path"; +import { brotliCompressSync, constants, gzipSync } from "node:zlib"; + +// Use the same dependencies and Bun version for both source trees. An optional +// second path points at a separate dependency installation for historical code. +// Unlike size-limit, this includes compatibility dependencies and reports ESM +// output without subtracting bundler overhead. The two metrics are distinct. +const checkoutRoot = resolve(import.meta.dir, ".."); +const sourceRoot = resolve(process.argv[2] || checkoutRoot); +const dependencyRoot = resolve(process.argv[3] || checkoutRoot); +const entries = [ + { file: "index.js", exports: "*" }, + { file: "use-browser-location.js", exports: "{ useBrowserLocation }" }, + { file: "memory-location.js", exports: "*" }, + { file: "use-hash-location.js", exports: "*" }, +]; + +const results = []; +for (const framework of ["react", "preact"]) { + const external = + framework === "react" ? ["react"] : ["preact", "preact/hooks"]; + + for (const entry of entries) { + const sourcePath = resolve(sourceRoot, "packages/wouter/src", entry.file); + const build = await Bun.build({ + entrypoints: ["wouter-size:entry"], + target: "browser", + format: "esm", + minify: true, + sourcemap: "none", + define: { "process.env.NODE_ENV": '"production"' }, + plugins: [ + { + name: "measure-shared-source", + setup(builder) { + builder.onResolve({ filter: /^wouter-size:/ }, () => ({ + path: "entry", + namespace: "wouter-size", + })); + builder.onLoad({ filter: /.*/, namespace: "wouter-size" }, () => ({ + contents: `export ${entry.exports} from ${JSON.stringify( + sourcePath + )};`, + loader: "js", + })); + + // Preact publishes the shared React sources with this one adapter + // replaced. Resolve it directly, so tests can copy/delete generated + // Preact files without changing the measurement. + if (framework === "preact") { + builder.onResolve({ filter: /^\.\/react-deps\.js$/ }, () => ({ + path: resolve( + sourceRoot, + "packages/wouter-preact/src/react-deps.js" + ), + })); + } + + builder.onResolve({ filter: /^[^./]/ }, ({ path }) => { + // Exact matches matter: Bun's external: ["preact"] also excludes + // preact/compat, concealing its cost if the adapter imports it. + if (external.includes(path)) return { path, external: true }; + return { path: Bun.resolveSync(path, dependencyRoot) }; + }); + }, + }, + ], + }); + + if (!build.success) throw new AggregateError(build.logs, "Bundle failed"); + if (build.outputs.length !== 1) throw new Error("Expected one ESM bundle"); + const bytes = new Uint8Array(await build.outputs[0].arrayBuffer()); + results.push({ + package: framework === "react" ? "wouter" : "wouter-preact", + entry: entry.file, + exports: entry.exports, + external, + raw: bytes.byteLength, + gzip: gzipSync(bytes, { level: 9 }).byteLength, + brotli: brotliCompressSync(bytes, { + params: { [constants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength, + }); + } +} + +const dependencyVersion = async (name: string) => { + try { + const path = Bun.resolveSync(`${name}/package.json`, dependencyRoot); + return (await Bun.file(path).json()).version; + } catch { + return null; + } +}; + +console.log( + JSON.stringify( + { + metric: "Bun minified production ESM, dependency-inclusive, bytes", + bun: Bun.version, + sourceRoot, + dependencyRoot, + compression: { gzipLevel: 9, brotliQuality: 11 }, + dependencies: { + regexparam: await dependencyVersion("regexparam"), + preact: await dependencyVersion("preact"), + "use-sync-external-store": await dependencyVersion( + "use-sync-external-store" + ), + }, + results, + }, + null, + 2 + ) +); diff --git a/specs/browser-support.md b/specs/browser-support.md new file mode 100644 index 00000000..94ac6ee1 --- /dev/null +++ b/specs/browser-support.md @@ -0,0 +1,393 @@ +# Browser support for Wouter 4 + +Audit date: **2026-09-05**. Source baseline: `d3711ad` (Wouter 3.10.0). +This change prepares **4.0.0-next.0**; it does not publish a release. + +## Decision + +**Raise the JavaScript source target from ES2020 to ES2022.** Use features +supported by the fixed browser baseline below. Router defaults now use `??=` +(ES2021). Parameter caching uses `Object.hasOwn()` (ES2022) to distinguish a +missing key from an optional parameter whose value is `undefined`, so renaming +optional parameters updates the cached keys correctly. `.at()` is available +for future edits but has no useful substitution in the current implementation. +The React compatibility cleanup accounts for most of the bundle reduction. +Removing the `Object.is` polyfill and `HashChangeEvent` fallback was already +safe within the previous browser target. + +| Browser family | Minimum | +| --- | ---: | +| Chrome, Edge, Chrome Android, Android WebView | 94 | +| Firefox, Firefox Android | 93 | +| Safari, iOS/iPadOS Safari | 16.4 | +| Opera | 80 | +| Opera Android | 66 | +| Samsung Internet | 17 | + +Keep these minimums fixed throughout Wouter 4. ES2022 static class blocks +determine the main engine floors; the target covers the other ordinary ES2022 +syntax and APIs, with the top-level-await exception below. The root +`.browserslistrc` records the policy for tools that read Browserslist; Wouter +itself ships source and has no compilation step. A Browserslist file neither +transpiles code nor supplies missing APIs. +Browserslist datasets enumerate only current Chrome/Firefox Android versions; +the historical Android floors here come from API data, not from that resolver. + +Require **React 18.2+** and retain **Preact 10+**. Framework support is a +separate decision from browser support. React 18 supplies native +`useSyncExternalStore` and `useInsertionEffect`, allowing removal of the +external-store shim dependency, its two resolver files, and old missing-export +fallbacks. The 18.2 patch baseline incorporates hydration, Suspense, and Safari +fixes; the hooks themselves arrived in 18.0. React 18.3 adds no API that lets +Wouter remove more code. + +Keep `forwardRef` for `Link` and the small server-safe layout-effect adapter. +These are still needed on React 18. The measured React 19-only candidate saved +only about 60 additional bytes gzip, while excluding every React 18 app. +A small wrapper around the native store hook fixes React 18's render-phase +snapshot regression without restoring the external shim. React 19 remains +supported, and applications do not need to adopt its ref-prop semantics. + +Require TypeScript 5.2+ for typed consumers, replacing the previous 4.1 floor. +The improved declarations work with both React 18 and React 19 type definitions; +use the declarations matching the application's React major version. The +TypeScript floor is independent of the React runtime floor. + +ES2022 is an authoring target, not a claim of exhaustive standards conformance. +Published modules must retain synchronous initialization: Safari has a known +[top-level-await module-dependency bug](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/operators/await.json) +within this range. Wouter does not use top-level await. Browser versions below +the table, IE11, and legacy Edge are outside this policy. Webviews must supply +the same capabilities; Baseline does not independently certify embedded webviews. + +## What the previous version targeted + +The README advertised **ES2020+**, with consumer transpilation for older +browsers. It did not name minimum browser versions or guarantee runtime +polyfills. Both package export maps point directly to `src/*.js`; the root +`build` script is a no-op. `tsconfig.json` is used for no-emit typechecking, +not browser output. Neither old generated `esm/` files nor the +size-check bundler establishes a supported browser range. + +Earlier documentation mentioned IE11 with Babel transpilation and an `Event` +polyfill. Commit `e02333f` replaced that guidance with ES2019 in January 2024; +`6abe383` corrected the wording to ES2020 in June 2024. Wouter 4 now raises that +existing modern-browser target to ES2022; this is not a fresh transition from +native IE11 support. +Applications that transpile for older engines remain responsible for the +required browser APIs, including the two whose fallbacks are removed here. + +The package peers were React `>=16.8.0` and Preact `^10.0.0`. The ordinary test +installation used React 19.2.0 and Preact 10.26.6, rather than a minimum-version +matrix. + +## Feature map + +Versions below are from [MDN browser-compat-data 8.1.0](https://github.com/mdn/browser-compat-data/tree/v8.1.0), +published September 3, 2026. Rows link to the pinned primary data. These are +capability requirements, not claims that historical browser binaries were run. +Chrome, Edge, Firefox, and Safari columns give desktop introduction versions; +Android introduction versions can differ. The selected Chrome Android 94 / +Firefox Android 93 floors cover all listed features. Rows marked as available +for future edits describe allowed features that the current source does not use. + +| Feature | Used for | Chrome | Edge | Firefox | Safari | iOS Safari | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| [ES modules](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/statements.json) | Every published entry | 61 | 16 | 60 | 10.1 | 10.3 | +| [Object spread/rest](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/operators/spread.json) | Router options, params, props | 60 | 79 | 55 | 11.1 | 11.3 | +| [Optional chaining](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/operators/optional_chaining.json) | Hooks, callbacks, link classes | 80 | 80 | 74 | 13.1 | 13.4 | +| [Nullish coalescing](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/operators/nullish_coalescing.json) | Router inheritance and SSR defaults | 80 | 80 | 72 | 13.1 | 13.4 | +| [Nullish assignment (ES2021)](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/operators/nullish_coalescing_assignment.json) | Router hook defaults (`??=`) | 85 | 85 | 79 | 14 | 14 | +| [Named RegExp captures](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/regular_expressions.json) | User-supplied RegExp routes | 64 | 79 | 78 / Android 79 | 11.1 | 11.3 | +| [Symbol.for](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/builtins/Symbol.json) | Deduplicate History API patch | 40 | 12 | 36 | 9 | 9 | +| [URL constructor](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/URL.json) | Hash navigation URL updates | 19 | 12 | 26 | 6 | 6 | +| [URLSearchParams constructor](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/URLSearchParams.json) | Search params hook | 49 | 17 | 29 | 10.1 | 10.3 | +| URLSearchParams object input | `setSearchParams({ key: value })` | 61 | 17 | 54 | 11 | 11 | +| URLSearchParams sequence input | `setSearchParams([[key, value]])` | 58 | 17 | 53 | 11 | 11 | +| [Event constructor](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/Event.json) | Synthetic History API notifications | 15 | 12 | 11 | 6 | 6 | +| [HashChangeEvent constructor](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/HashChangeEvent.json) | Hash notifications, including old/new URLs | 16 | 12 | 11 | 6 | 6 | +| [Object.is](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/builtins/Object.json) | Preact snapshot comparisons | 19 | 12 | 22 | 9 | 9 | +| [Array/String `.at()`](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/builtins/Array.json) | Available for future edits | 92 | 92 | 90 | 15.4 | 15.4 | +| [Object.hasOwn](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/builtins/Object.json) | Check keys when caching matched parameters | 93 | 93 | 92 | 15.4 | 15.4 | +| [Static class blocks](https://github.com/mdn/browser-compat-data/blob/v8.1.0/javascript/classes.json) | Sets the broader ES2022 floor; currently unused | 94 | 94 | 93 | 16.4 | 16.4 | + +History `pushState`/`replaceState`/`state`, `popstate`, `hashchange`, event +listeners, `Object.assign`/`keys`/`defineProperty`, `Array.isArray` and ordinary +array methods, string `includes`, and `decodeURI` also remain required. They +predate the chosen floor. Frameworks and application code can have additional +requirements. + +### Web standards + +ES2022 specifies JavaScript syntax and built-ins; it does not define browser +APIs. Wouter also requires the URL standard (`URL`, `URLSearchParams`), HTML +history/location APIs, and DOM events. The uses listed above are supported +throughout the fixed browser range, so they do not raise its minimum versions. + +The browser-level cleanup in this change is using native `HashChangeEvent` +directly, removing the generic-event fallback while preserving `oldURL` and +`newURL`. This API was already available within the old target. The other +URL, History, and event APIs were already used natively; the ES2022 upgrade +does not newly enable them. + +Keep the History API patch: `pushState` and `replaceState` do not produce the +notifications Wouter needs. Even changing a fragment through `pushState` +[does not fire `hashchange`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState). +The Navigation API could enable a different design, but exceeds the chosen +browser range and requires a separate behavior audit. + +## Estimated browser coverage + +Using **August 2026** global usage, checked on **2026-09-05**, the fixed ES2022 +query covers approximately **94.19%**. The previous source's required features +cover **94.77%** with the same browser families and dataset: a reduction of +**0.58 percentage points**. The narrower `.at()` + `Object.hasOwn()` combination +alone would cover 94.64%; that is not the coverage of this broader ES2022 target. + +Calculated with Browserslist **4.28.9**, caniuse-lite **1.0.30001810**, and browser +minima from MDN browser-compat-data **8.1.0**. To refresh the current policy's +estimate from the repository root: + +```sh +bun x browserslist@latest --coverage +``` + +[Can I Use's usage data](https://caniuse.com/usage-table) comes from StatCounter +and extrapolates some mobile versions. Approximately **4.10%** is unclassified +in this dataset; do not count all of the remaining 5.81% as known incompatible +traffic. Some Android families group usage under a single current version, +so this is an estimate rather than evidence about every installed mobile +browser. Check application analytics with +[Browserslist custom usage data](https://github.com/browserslist/browserslist#custom-usage-data) +for an audience-specific decision. Future dataset updates change percentages, +not Wouter 4's fixed minimum versions. + +## Alternatives and why they were not adopted + +[Baseline Widely available](https://github.com/web-platform-dx/web-features/blob/main/docs/baseline.md) +requires interoperability across the core browser set for at least 30 months +and accounts for Firefox ESR. It moves over time. A library should freeze a +date or explicit versions, rather than silently drop browsers in minor releases. + +Frozen on this audit date, the stronger alternative is Chrome/Edge 121, +Firefox 123, and Safari/iOS 17.4, including their corresponding Android engines. +This was derived from `web-features@3.37.0` and the +[WebDX mapping timeline](https://github.com/web-platform-dx/baseline-browser-mapping/blob/v2.11.21/src/data/timeline.js). +Safari 17.4 reaches its 30-month boundary on September 5, 2026. That stronger +floor excludes more users without enabling further worthwhile deletions here. + +| Candidate | Decision and reason | +| --- | --- | +| Native `HashChangeEvent` | Adopt. Its constructor is much older than the floor. Remove the fallback, retaining URL payloads and synchronous notification. | +| Native `Object.is` | Adopt for Preact. Removes an obsolete browser polyfill while preserving NaN and signed-zero semantics. | +| ES2021 `??=` | Adopt for the two Router hook defaults. Preserves nullish fallback behavior and saves a few bytes. | +| ES2022 `Object.hasOwn()` | Adopt in the parameter cache. Comparing values alone misses renamed optional parameters when both values are `undefined`; also check that each key exists. | +| ES2022 `.at()` | Allowed by the new target. No current last-element reads benefit from replacement; memory history needs indexed assignment. | +| React's native store/insertion hooks | Adopt. Remove the dependency, two shim resolver files, and missing-export/fallback machinery. | +| Native `preact/compat` store hook | Reject. It ignores the server snapshot argument and imports unrelated compatibility code. With an SSR wrapper, the measured core adds 3,986 bytes gzip when core/hooks are external. Keep Preact 10 support. | +| Listener cleanup with `AbortController` / `{ signal }` | Supported within the floor (Chrome 90, Firefox 86, Safari 15), but keeps the subscriber bookkeeping and adds a controller lifecycle. Retain the simpler explicit cleanup. | +| `queueMicrotask` notifications | Supported within the floor, but defers notifications that currently run synchronously. It is not a replacement for the current batching behavior. | +| `EventTarget` for internal subscriptions | Supported within the floor, but adds event objects, changes listener-removal and exception behavior, and adds a browser API dependency to the portable memory store. Keep the small subscriber arrays. | +| `URLSearchParams.size` | Requires Chrome 113, Firefox 112, Safari 17. Keep the existing serialized-string emptiness check: the string is needed for navigation anyway. | +| `URL.canParse()` | Requires Chrome 120, Firefox 115, Safari 17. Current URL construction uses valid `location.href`, so this removes no validation code. | +| `Array.flat`/`flatMap` | Already available within the floor, but array flattening alone does not implement React/Preact fragment traversal. Retain the allocation-conscious recursive helper. | +| `Object.fromEntries` | Already available within the floor, but rebuilding route params through mapped entry arrays adds allocations; current loops preserve numeric, duplicate, and named capture precedence. | +| Native `URLPattern` | Reject as a required parser. Support is newer, its pattern semantics differ, and it cannot replace custom parsers and supplied RegExp objects transparently. | +| Navigation API replacing History patch | Reject. New interoperability does not make it widely available or preserve Wouter's existing event/interception semantics. | +| `RegExp.escape` | Not needed by current implementation; do not add a new minimum just to introduce it. | +| Unconditional view transitions | Reject. Keep the optional `aroundNav` recipe and its feature check. | +| React `useEffectEvent` replacing `useEvent` | Reject. It is for Effect logic, not stable callbacks passed to components and called from click/navigation handlers. | +| React 19-only ref props | Defer. Retain `forwardRef` so React 18 applications keep ordinary and `asChild` refs. The measured React 19-only candidate saved only about 60 additional bytes gzip. | +| React's server-safe layout-effect adapter | Retain. React 18 warns about layout effects during SSR; `Redirect` still needs layout timing in the browser. | +| Preact 11 | Defer while it is a release candidate. Its store hook still lives in compat, and its type changes would require a separate migration. Stable 10.29.8 provides no smaller public store hook. | +| Other DOM/SSR guards or URI decoding catch | Retain. Preact still needs server snapshot selection, browser hooks need safe imports without browser globals, and malformed percent escapes still occur on current browsers. | +| Remove listener batching or patch deduplication | Reject. Native events can allow microtasks between listeners; multiple installed Wouter copies still share the History API. Keep the `wouter_v3` symbol to avoid double-patching mixed-major applications. | +| Mark whole packages side-effect-free | Reject without a separate design: importing browser routing installs the History patch. | + +Additional Web API support data: +[event listener options](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/EventTarget.json), +[URLSearchParams](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/URLSearchParams.json), +[URL](https://github.com/mdn/browser-compat-data/blob/v8.1.0/api/URL.json). +Behavior references: +[DOM event dispatch](https://dom.spec.whatwg.org/#concept-event-listener-invoke), +[HTML microtask queuing](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#microtask-queuing). + +These candidates are not Baseline Widely available on the audit date: + +| Basic API | Chrome/Edge | Firefox | Safari/iOS | Newly interoperable | +| --- | ---: | ---: | ---: | --- | +| [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) | 102 | 147 | 26.2 | January 2026 | +| [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) | 95 | 142 | 26 | September 2025 | +| [startViewTransition](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) | 111 | 144 | 18 | October 2025 | +| [RegExp.escape](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape) | 136 | 134 | 18.2 | May 2025 | + +React hook rationale: [React 18 native hooks](https://react.dev/blog/2022/03/29/react-v18), +[React 18.2 fixes](https://github.com/facebook/react/releases/tag/v18.2.0), +[React 18.3 upgrade warnings](https://react.dev/blog/2024/04/25/react-19-upgrade-guide), +[React 19 ref props](https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop), +[removal of the server layout-effect warning](https://github.com/facebook/react/pull/26395), +[useEffectEvent restrictions](https://react.dev/reference/react/useEffectEvent). + +## Size evidence + +Compared against v3.11.0 using Bun 1.4.0, regexparam 3.0.0, Preact 10.26.6, and +the baseline's use-sync-external-store 1.5.0. `bun scripts/measure-size.ts` bundles +production ESM using Bun, retains all exports except where indicated, includes non-peer +dependencies, and measures gzip level 9 / Brotli quality 11. Exactly `react`, +or `preact` and `preact/hooks`, are external. Broadly excluding `preact/*` +would conceal the cost of the rejected compat import. + +| Entry | Before gzip | After gzip | Saved | Before Brotli | After Brotli | +| --- | ---: | ---: | ---: | ---: | ---: | +| React core | 2,641 | 2,388 | 253 (9.6%) | 2,380 | 2,141 | +| React `useBrowserLocation` only | 878 | 621 | 257 (29.3%) | 771 | 535 | +| React memory | 859 | 601 | 258 (30.0%) | 767 | 545 | +| React hash | 871 | 580 | 291 (33.4%) | 762 | 507 | +| Preact core | 2,510 | 2,482 | 28 (1.1%) | 2,253 | 2,228 | +| Preact `useBrowserLocation` only | 756 | 715 | 41 (5.4%) | 653 | 621 | +| Preact memory | 737 | 694 | 43 (5.8%) | 653 | 622 | +| Preact hash | 739 | 680 | 59 (8.0%) | 647 | 596 | + +These entries overlap; do not add their savings together. Framework code is +excluded, so an application's percentage saving depends on its own bundle. + +The existing `bun run size` is a separate esbuild/Brotli metric with bundler +overhead adjustments. Its original React checks also excluded the store shim. +Its core result improves from 2,341 to 2,131 bytes for React and 2,230 to 2,227 +for Preact. Its limits are tightened in this change. Do not label this metric +gzip or compare it directly to the Bun table. + +For a source-revision comparison, run the same script/Bun/dependencies on both +trees. The optional second directory supplies a separate dependency installation +so a fresh checkout can still measure historical code that requires the removed +shim: + +```sh +baseline_dir=$(mktemp -d) +git archive v3.11.0 | tar -x -C "$baseline_dir" +deps_dir=$(mktemp -d) +bun add --cwd "$deps_dir" regexparam@3.0.0 preact@10.26.6 use-sync-external-store@1.5.0 +bun scripts/measure-size.ts "$baseline_dir" "$deps_dir" +bun scripts/measure-size.ts . "$deps_dir" +``` + +## Migration and validation + +- Target ES2022 browsers using the fixed versions above. Published source now + includes `??=`; older engines may need consumer-side transpilation as well + as runtime polyfills and are outside Wouter 4's supported range. +- React 18.2+ applications can adopt Wouter 4 without upgrading to React 19. + React 16/17 applications need React 18.2+ or should stay on Wouter 3.x. + Preact users keep using the separate `wouter-preact` package with Preact 10+. +- Upgrade TypeScript to 5.2+ and use types matching the React major. Preact's + memory-location types now include the existing runtime's search options and + search hook, matching the React package. +- Remove app-level event/Object.is polyfills only if the rest of the app and + its dependencies do not need them. Wouter removing a fallback does not make + an application-wide polyfill automatically redundant. +- Keep feature detection in view-transition integrations. The core does not + require Navigation API, URLPattern, or view transitions. +- Test server rendering and hydration after upgrading the framework. Native + React hooks preserve server snapshots; no `.native.js` shim resolver is + needed. This does not turn browser location hooks into native navigation. + +Run checks in this order: the Preact tests create and then remove their shared +source copies, so regenerate them before package checks or the existing size +command. The Bun measurement script resolves shared sources directly. + +```sh +bun install --frozen-lockfile +bun test --coverage --coverage-reporter=text +bun run test-types +bun run --cwd packages/wouter-preact prepublishOnly +bun run lint +bun run size +bun run size:dependencies +``` + +The revised implementation passes all **251 tests** on the existing React +19.2.0 installation, with Preact 10.26.6 and **100% source line coverage**. +The React compatibility changes were also checked on React 18.3.1. +The Preact SSR test and the new React +SSR test run in separate Bun processes without browser globals. They verify +server path/query/hash snapshots; the React check also covers `Link`, redirect +context, and the absence of server warnings. Snapshot tests cover repeated NaN +and signed-zero changes. Ordinary and `asChild` ref tests check mounting and +null callbacks on unmount, which work on both React 18 and React 19. + +The optional-parameter cache regression changes `/:first?` to `/:second?` at +`/`, checking that the keys update even though both values are `undefined`. +It failed before the `Object.hasOwn()` fix and now passes on React 19.2.0 and +the exact React 18.2.0 minimum. The existing test for unchanged parameter object +identity also passes. This correctness fix adds 12 bytes gzip to each core. + +React 18's [render-phase store bug](https://github.com/facebook/react/pull/25578) +can leave a snapshot stale when revisiting an earlier value after a render-phase +state update. The native-store adapter supplies a fresh snapshot getter on each +render, causing React to refresh its committed store cache while keeping the +subscription stable. The regression test stays enabled and passes on 18.2.0, +18.3.1, and 19.0.0; +raising the framework floor to 19 is no longer necessary to fix it. + +Fresh, isolated installs of exact React/ReactDOM **18.2.0**, **18.3.1**, and +**19.0.0** each pass all **245 React tests**, including the DOM-less SSR fixture +with no stderr and the memory pathname/query regressions inherited from v3.11.0. +A packed Wouter consumer on React 18.2.0 also passes public-entry imports, +memory navigation, hash formatting, browser/hash SSR, links, redirect context, +and strict TypeScript 5.2.2 NodeNext checks with invalid-state rejection. + +CI covers the exact React 18.2.0 floor, React 18.3.1, and React 19.0.0 separately +from the repository's existing development/demo React 19 installation. The +private SSR demo already uses a React 19-only Helmet package, so its existing +runtime is retained; it does not set the library's peer requirement. + +Type tests live in `packages/*/test/*.test-d.ts` and `*.test-d.tsx`; the main +`bun run test-types` command checks them through the root ES2022 configuration. +The `public-api.test-d.tsx` and `public-subpaths.test-d.ts` files in those same +directories also run through `tsconfig.consumer.json`, without Bun test types, +to check all public entry points with React 18 and React 19 declarations under +TypeScript 5.2.2 and the workspace compiler. All 12 combinations of compiler, +React declarations, and bundler/NodeNext/legacy node resolution pass. +CI runs this matrix with `skipLibCheck: false`, +`exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, and +`verbatimModuleSyntax`. It verifies route inference, navigation options, +callback-ref element/null typing, and invalid prop/state rejection. + +The consumer configuration intentionally omits Bun's declarations: the test +runner's current types require a newer compiler than the library's TypeScript +5.2 minimum. All type assertions still follow the existing package test layout. + +### Declaration improvements for the major release + +- Route literal inference now matches the default parser for `"*"` captures, + filename suffixes, repeated parameter names, and colons in literal segments. + Repeated names use the last capture, including an absent optional capture. + Dynamic string routes allow named lookups as `string | undefined`. +- Parameter interfaces work without an index signature. Explicit parameter + types remain caller assertions; neither string literals nor TypeScript can + prove the behavior of a custom parser or infer a parent route from context. +- Loose `matchRoute` calls expose their third tuple entry, the matched base. + The optional missing-base slot supports destructuring on TypeScript 5.2. +- Memory hooks retain their navigation state type through `useLocation`, + `Link`, `Redirect`, and the now-generic `useSearchParams`. Dynamic `record` + flags produce optional history/reset fields. Search setters also accept + readonly entry tuples and preserve required or nullable custom options. +- The router argument to `hrefs` is typed. Parsers can return readonly keys, + `false`, or omit keys to use regular-expression named captures. Hash hooks + expose their attached `hrefs` formatter, and `useLocationProperty` accepts + stable object snapshots as well as primitives. + +Type-only migration notes: replace the formerly inferred `params.wild` with +`params["*"]`; access `params.file` for `/:file.txt`; handle possibly missing +values for repeated optional names. `RouterObject.ownBase` was removed because +it does not exist at runtime; use `base`. Custom `hrefs` callbacks receive the +router as their second argument, so direct calls to a value typed as +`HrefsFormatter` must supply it. Invalid primitive `useParams` type arguments +and incorrect memory navigation state now produce errors. These declaration +changes add no runtime code or bundle bytes. + +Happy DOM and Bun do not prove historical browser support. Browser minimums +above are derived from the pinned feature data; running current browser engines +is additional behavioral evidence, not a substitute for testing those old +binary versions. The global usage estimate above is not a test result or a +guarantee of coverage for any application's audience. diff --git a/tsconfig.consumer.json b/tsconfig.consumer.json new file mode 100644 index 00000000..c67357df --- /dev/null +++ b/tsconfig.consumer.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "types": [], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "skipLibCheck": false, + "noEmit": true + }, + "include": [ + "packages/*/test/public-*.test-d.ts", + "packages/*/test/public-*.test-d.tsx" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 756bfcbc..9ce531db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ESNext", - "lib": ["ESNext", "DOM"], + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "moduleResolution": "bundler", "module": "Preserve", "moduleDetection": "force",