From e8fe1448da3810493b0c5b117cc0c6d9f2405a12 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Sat, 5 Sep 2026 13:10:15 +0300 Subject: [PATCH] feat!: prepare Wouter 4 compatibility baseline Target ES2022, require React 18.2 and TypeScript 5.2, and retain Preact 10 support. Remove obsolete compatibility code, fix optional parameter caching, and improve route and navigation declarations. Add framework and type compatibility matrices, SSR coverage, and dependency-inclusive size measurements. Verified runtime tests, all supported React versions, strict type consumers, lint, formatting, package exports, and bundle limits. --- .browserslistrc | 13 + .github/workflows/ci-tests.yml | 38 +- README.md | 63 ++- bun.lock | 125 ++---- bunfig.toml | 2 - package.json | 39 +- packages/wouter-preact/.gitignore | 2 - packages/wouter-preact/package.json | 8 +- packages/wouter-preact/src/react-deps.js | 24 +- packages/wouter-preact/test/fixtures/ssr.tsx | 42 ++ .../test/memory-location.test-d.ts | 42 ++ packages/wouter-preact/test/preact.test.tsx | 90 +++- .../wouter-preact/test/public-api.test-d.tsx | 254 +++++++++++ .../test/public-subpaths.test-d.ts | 34 ++ packages/wouter-preact/types/index.d.ts | 167 ++++---- .../wouter-preact/types/location-hook.d.ts | 23 +- .../wouter-preact/types/memory-location.d.ts | 37 +- .../wouter-preact/types/route-params.d.ts | 37 ++ packages/wouter-preact/types/router.d.ts | 5 +- .../types/use-browser-location.d.ts | 11 +- .../types/use-hash-location.d.ts | 17 +- packages/wouter/package.json | 11 +- packages/wouter/src/index.js | 8 +- packages/wouter/src/react-deps.js | 58 ++- packages/wouter/src/use-hash-location.js | 7 +- .../wouter/src/use-sync-external-store.js | 1 - .../src/use-sync-external-store.native.js | 1 - packages/wouter/test/fixtures/ssr.tsx | 58 +++ packages/wouter/test/link.test.tsx | 24 ++ packages/wouter/test/match-route.test-d.ts | 115 ++++- packages/wouter/test/match-route.test.ts | 13 +- .../wouter/test/memory-location.test-d.ts | 71 +++- packages/wouter/test/public-api.test-d.tsx | 294 +++++++++++++ .../wouter/test/public-subpaths.test-d.ts | 34 ++ packages/wouter/test/route.test-d.tsx | 51 ++- packages/wouter/test/router.test-d.tsx | 13 +- packages/wouter/test/ssr-environment.test.ts | 26 ++ .../test/use-browser-location.test-d.ts | 29 ++ .../wouter/test/use-hash-location.test-d.ts | 21 + packages/wouter/test/use-params.test-d.ts | 48 ++- packages/wouter/test/use-params.test.tsx | 19 + packages/wouter/test/use-route.test-d.ts | 91 +++- .../wouter/test/use-search-params.test-d.ts | 67 +++ packages/wouter/test/use-search.test.tsx | 2 +- packages/wouter/types/index.d.ts | 167 ++++---- packages/wouter/types/location-hook.d.ts | 4 +- packages/wouter/types/memory-location.d.ts | 43 +- packages/wouter/types/route-params.d.ts | 37 ++ packages/wouter/types/router.d.ts | 5 +- .../wouter/types/use-browser-location.d.ts | 11 +- packages/wouter/types/use-hash-location.d.ts | 17 +- scripts/measure-size.ts | 116 ++++++ specs/browser-support.md | 393 ++++++++++++++++++ tsconfig.consumer.json | 20 + tsconfig.json | 4 +- 55 files changed, 2498 insertions(+), 454 deletions(-) create mode 100644 .browserslistrc create mode 100644 packages/wouter-preact/test/fixtures/ssr.tsx create mode 100644 packages/wouter-preact/test/memory-location.test-d.ts create mode 100644 packages/wouter-preact/test/public-api.test-d.tsx create mode 100644 packages/wouter-preact/test/public-subpaths.test-d.ts create mode 100644 packages/wouter-preact/types/route-params.d.ts delete mode 100644 packages/wouter/src/use-sync-external-store.js delete mode 100644 packages/wouter/src/use-sync-external-store.native.js create mode 100644 packages/wouter/test/fixtures/ssr.tsx create mode 100644 packages/wouter/test/public-api.test-d.tsx create mode 100644 packages/wouter/test/public-subpaths.test-d.ts create mode 100644 packages/wouter/test/ssr-environment.test.ts create mode 100644 packages/wouter/test/use-search-params.test-d.ts create mode 100644 packages/wouter/types/route-params.d.ts create mode 100644 scripts/measure-size.ts create mode 100644 specs/browser-support.md create mode 100644 tsconfig.consumer.json 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",