From fb7e9dc6a59e4703c3259b3f5aff448c43bb61ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B3=A2?= <> Date: Wed, 9 Dec 2020 16:05:37 +0800 Subject: [PATCH] update docs --- .github/ISSUE_TEMPLATE/config.yml | 11 + .github/commit-convention.md | 91 + .github/contributing.md | 116 + .github/funding.yml | 2 + .github/workflows/release-tag.yml | 23 + .github/workflows/size-check.yml | 22 + .gitignore | 19 + .prettierrc | 6 + .vscode/launch.json | 21 + .vscode/settings.json | 3 + CHANGELOG.md | 603 + LICENSE | 21 + README.md | 28 + __tests__/RouterLink.spec.ts | 954 + __tests__/RouterView.spec.ts | 442 + .../__snapshots__/RouterLink.spec.ts.snap | 3 + __tests__/encoding.spec.ts | 146 + __tests__/errors.spec.ts | 424 + __tests__/guards/afterEach.spec.ts | 68 + __tests__/guards/beforeEach.spec.ts | 222 + __tests__/guards/beforeEnter.spec.ts | 192 + __tests__/guards/beforeResolve.spec.ts | 25 + __tests__/guards/beforeRouteEnter.spec.ts | 204 + .../guards/beforeRouteEnterCallback.spec.ts | 94 + __tests__/guards/beforeRouteLeave.spec.ts | 196 + __tests__/guards/beforeRouteUpdate.spec.ts | 68 + .../guards/extractComponentsGuards.spec.ts | 99 + __tests__/guards/guardToPromiseFn.spec.ts | 281 + __tests__/guards/guardsContext.spec.ts | 272 + __tests__/guards/onBeforeRouteLeave.spec.ts | 50 + __tests__/guards/onBeforeRouteUpdate.spec.ts | 50 + __tests__/history/hash.spec.ts | 107 + __tests__/history/html5.spec.ts | 121 + __tests__/history/memory.spec.ts | 169 + __tests__/initialNavigation.spec.ts | 76 + __tests__/lazyLoading.spec.ts | 321 + __tests__/location.spec.ts | 351 + .../__snapshots__/resolve.spec.ts.snap | 13 + __tests__/matcher/addingRemoving.spec.ts | 400 + __tests__/matcher/pathParser.spec.ts | 787 + __tests__/matcher/pathRanking.spec.ts | 261 + __tests__/matcher/records.spec.ts | 89 + __tests__/matcher/resolve.spec.ts | 1280 ++ __tests__/mount.ts | 213 + __tests__/multipleApps.spec.ts | 55 + __tests__/parseQuery.spec.ts | 88 + __tests__/router.spec.ts | 899 + __tests__/scrollBehavior.spec.ts | 197 + __tests__/ssr.spec.ts | 100 + __tests__/stringifyQuery.spec.ts | 50 + __tests__/urlEncoding.spec.ts | 165 + __tests__/utils.ts | 169 + __tests__/warnings.spec.ts | 230 + api-extractor.json | 49 + circle.yml | 114 + docs/.vitepress/components/HomeSponsors.vue | 108 + docs/.vitepress/components/sponsors.json | 14 + docs/.vitepress/config.js | 203 + docs/.vitepress/style.styl | 66 + docs/.vitepress/theme/Layout.vue | 77 + .../theme/components/AlgoliaSearchBox.vue | 158 + .../theme/components/BuySellAds.vue | 124 + .../.vitepress/theme/components/CarbonAds.vue | 109 + docs/.vitepress/theme/index.js | 14 + docs/api/index.md | 1085 ++ docs/guide/advanced/composition-api.md | 107 + docs/guide/advanced/data-fetching.md | 106 + docs/guide/advanced/dynamic-routing.md | 103 + docs/guide/advanced/extending-router-link.md | 90 + docs/guide/advanced/lazy-loading.md | 51 + docs/guide/advanced/meta.md | 67 + docs/guide/advanced/navigation-failures.md | 94 + docs/guide/advanced/navigation-guards.md | 247 + docs/guide/advanced/scroll-behavior.md | 109 + docs/guide/advanced/transitions.md | 67 + docs/guide/essentials/dynamic-matching.md | 106 + docs/guide/essentials/history-mode.md | 175 + docs/guide/essentials/named-routes.md | 36 + docs/guide/essentials/named-views.md | 89 + docs/guide/essentials/navigation.md | 101 + docs/guide/essentials/nested-routes.md | 95 + docs/guide/essentials/passing-props.md | 76 + docs/guide/essentials/redirect-and-alias.md | 105 + .../guide/essentials/route-matching-syntax.md | 89 + docs/guide/index.md | 95 + docs/guide/migration/index.md | 409 + docs/index.md | 7 + docs/installation.md | 29 + docs/introduction.md | 18 + docs/public/logo.png | Bin 0 -> 3451 bytes e2e/browserstack-send-status.js | 86 + e2e/devServer.js | 12 + e2e/encoding/index.html | 17 + e2e/encoding/index.ts | 130 + e2e/global.css | 31 + e2e/guards-instances/index.html | 27 + e2e/guards-instances/index.ts | 239 + e2e/hash/index.html | 17 + e2e/hash/index.ts | 87 + e2e/index.html | 20 + e2e/index.ts | 28 + e2e/keep-alive/index.html | 17 + e2e/keep-alive/index.ts | 102 + e2e/modal/index.html | 46 + e2e/modal/index.ts | 190 + e2e/multi-app/index.html | 35 + e2e/multi-app/index.ts | 132 + e2e/nightwatch.browserstack.js | 125 + e2e/nightwatch.config.js | 91 + e2e/runner.js | 162 + e2e/scroll-behavior/index.html | 33 + e2e/scroll-behavior/index.ts | 124 + e2e/scroll-behavior/scrollWaiter.ts | 30 + e2e/specs/encoding.js | 60 + e2e/specs/guards-instances.js | 285 + e2e/specs/hash.js | 111 + e2e/specs/keep-alive.js | 41 + e2e/specs/modal.js | 157 + e2e/specs/multi-app.js | 124 + e2e/specs/scroll-behavior.js | 217 + e2e/specs/transitions.js | 70 + e2e/staticServer.js | 32 + e2e/transitions/index.html | 44 + e2e/transitions/index.ts | 141 + e2e/tsconfig.json | 23 + e2e/webpack.config.js | 101 + jest.config.js | 19 + netlify.toml | 4 + package-lock.json | 15074 ++++++++++++++++ package.json | 113 + playground/App.vue | 224 + playground/api/index.ts | 11 + playground/index.html | 58 + playground/main.ts | 25 + playground/router.ts | 262 + playground/scrollWaiter.ts | 22 + playground/shim.d.ts | 5 + playground/store.ts | 5 + playground/tsconfig.json | 20 + playground/views/ComponentWithData.vue | 38 + playground/views/Dynamic.vue | 11 + playground/views/Generic.vue | 11 + playground/views/GuardedWithLeave.vue | 30 + playground/views/Home.vue | 40 + playground/views/LongView.vue | 27 + playground/views/Nested.vue | 77 + playground/views/NestedWithId.vue | 36 + playground/views/NotFound.vue | 16 + playground/views/RepeatedParams.vue | 39 + playground/views/User.vue | 21 + rollup.config.js | 197 + scripts/check-size.js | 25 + scripts/docs-check.sh | 6 + scripts/release.sh | 39 + scripts/verifyCommit.js | 29 + size-checks/rollup.config.js | 59 + size-checks/webRouter.js | 6 + size-checks/webRouterAndVue.js | 12 + src/RouterLink.ts | 271 + src/RouterView.ts | 184 + src/devtools.ts | 474 + src/encoding.ts | 146 + src/errors.ts | 192 + src/global.d.ts | 5 + src/globalExtensions.ts | 58 + src/history/common.ts | 171 + src/history/hash.ts | 45 + src/history/html5.ts | 334 + src/history/memory.ts | 102 + src/index.ts | 75 + src/injectionSymbols.ts | 65 + src/location.ts | 231 + src/matcher/index.ts | 450 + src/matcher/pathMatcher.ts | 55 + src/matcher/pathParserRanker.ts | 324 + src/matcher/pathTokenizer.ts | 200 + src/matcher/types.ts | 86 + src/navigationGuards.ts | 324 + src/query.ts | 134 + src/router.ts | 1160 ++ src/scrollBehavior.ts | 191 + src/types/index.ts | 383 + src/types/typeGuards.ts | 9 + src/useApi.ts | 20 + src/utils/README.md | 3 + src/utils/callbacks.ts | 24 + src/utils/env.ts | 1 + src/utils/index.ts | 26 + src/warning.ts | 9 + test-dts/components.test-d.tsx | 31 + test-dts/createRouter.test-d.ts | 61 + test-dts/index.d.ts | 6 + test-dts/legacy.test-d.ts | 11 + test-dts/meta.test-d.ts | 43 + test-dts/navigationGuards.test-d.ts | 53 + test-dts/routeRecords.test-d.ts | 31 + test-dts/tsconfig.json | 13 + tsconfig.json | 31 + vetur/attributes.json | 32 + vetur/tags.json | 10 + webpack.config.js | 71 + yarn.lock | 10177 +++++++++++ 202 files changed, 51559 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/commit-convention.md create mode 100644 .github/contributing.md create mode 100644 .github/funding.yml create mode 100644 .github/workflows/release-tag.yml create mode 100644 .github/workflows/size-check.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __tests__/RouterLink.spec.ts create mode 100644 __tests__/RouterView.spec.ts create mode 100644 __tests__/__snapshots__/RouterLink.spec.ts.snap create mode 100644 __tests__/encoding.spec.ts create mode 100644 __tests__/errors.spec.ts create mode 100644 __tests__/guards/afterEach.spec.ts create mode 100644 __tests__/guards/beforeEach.spec.ts create mode 100644 __tests__/guards/beforeEnter.spec.ts create mode 100644 __tests__/guards/beforeResolve.spec.ts create mode 100644 __tests__/guards/beforeRouteEnter.spec.ts create mode 100644 __tests__/guards/beforeRouteEnterCallback.spec.ts create mode 100644 __tests__/guards/beforeRouteLeave.spec.ts create mode 100644 __tests__/guards/beforeRouteUpdate.spec.ts create mode 100644 __tests__/guards/extractComponentsGuards.spec.ts create mode 100644 __tests__/guards/guardToPromiseFn.spec.ts create mode 100644 __tests__/guards/guardsContext.spec.ts create mode 100644 __tests__/guards/onBeforeRouteLeave.spec.ts create mode 100644 __tests__/guards/onBeforeRouteUpdate.spec.ts create mode 100644 __tests__/history/hash.spec.ts create mode 100644 __tests__/history/html5.spec.ts create mode 100644 __tests__/history/memory.spec.ts create mode 100644 __tests__/initialNavigation.spec.ts create mode 100644 __tests__/lazyLoading.spec.ts create mode 100644 __tests__/location.spec.ts create mode 100644 __tests__/matcher/__snapshots__/resolve.spec.ts.snap create mode 100644 __tests__/matcher/addingRemoving.spec.ts create mode 100644 __tests__/matcher/pathParser.spec.ts create mode 100644 __tests__/matcher/pathRanking.spec.ts create mode 100644 __tests__/matcher/records.spec.ts create mode 100644 __tests__/matcher/resolve.spec.ts create mode 100644 __tests__/mount.ts create mode 100644 __tests__/multipleApps.spec.ts create mode 100644 __tests__/parseQuery.spec.ts create mode 100644 __tests__/router.spec.ts create mode 100644 __tests__/scrollBehavior.spec.ts create mode 100644 __tests__/ssr.spec.ts create mode 100644 __tests__/stringifyQuery.spec.ts create mode 100644 __tests__/urlEncoding.spec.ts create mode 100644 __tests__/utils.ts create mode 100644 __tests__/warnings.spec.ts create mode 100644 api-extractor.json create mode 100644 circle.yml create mode 100644 docs/.vitepress/components/HomeSponsors.vue create mode 100644 docs/.vitepress/components/sponsors.json create mode 100644 docs/.vitepress/config.js create mode 100644 docs/.vitepress/style.styl create mode 100644 docs/.vitepress/theme/Layout.vue create mode 100644 docs/.vitepress/theme/components/AlgoliaSearchBox.vue create mode 100644 docs/.vitepress/theme/components/BuySellAds.vue create mode 100644 docs/.vitepress/theme/components/CarbonAds.vue create mode 100644 docs/.vitepress/theme/index.js create mode 100644 docs/api/index.md create mode 100644 docs/guide/advanced/composition-api.md create mode 100644 docs/guide/advanced/data-fetching.md create mode 100644 docs/guide/advanced/dynamic-routing.md create mode 100644 docs/guide/advanced/extending-router-link.md create mode 100644 docs/guide/advanced/lazy-loading.md create mode 100644 docs/guide/advanced/meta.md create mode 100644 docs/guide/advanced/navigation-failures.md create mode 100644 docs/guide/advanced/navigation-guards.md create mode 100644 docs/guide/advanced/scroll-behavior.md create mode 100644 docs/guide/advanced/transitions.md create mode 100644 docs/guide/essentials/dynamic-matching.md create mode 100644 docs/guide/essentials/history-mode.md create mode 100644 docs/guide/essentials/named-routes.md create mode 100644 docs/guide/essentials/named-views.md create mode 100644 docs/guide/essentials/navigation.md create mode 100644 docs/guide/essentials/nested-routes.md create mode 100644 docs/guide/essentials/passing-props.md create mode 100644 docs/guide/essentials/redirect-and-alias.md create mode 100644 docs/guide/essentials/route-matching-syntax.md create mode 100644 docs/guide/index.md create mode 100644 docs/guide/migration/index.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/introduction.md create mode 100644 docs/public/logo.png create mode 100644 e2e/browserstack-send-status.js create mode 100644 e2e/devServer.js create mode 100644 e2e/encoding/index.html create mode 100644 e2e/encoding/index.ts create mode 100644 e2e/global.css create mode 100644 e2e/guards-instances/index.html create mode 100644 e2e/guards-instances/index.ts create mode 100644 e2e/hash/index.html create mode 100644 e2e/hash/index.ts create mode 100644 e2e/index.html create mode 100644 e2e/index.ts create mode 100644 e2e/keep-alive/index.html create mode 100644 e2e/keep-alive/index.ts create mode 100644 e2e/modal/index.html create mode 100644 e2e/modal/index.ts create mode 100644 e2e/multi-app/index.html create mode 100644 e2e/multi-app/index.ts create mode 100644 e2e/nightwatch.browserstack.js create mode 100644 e2e/nightwatch.config.js create mode 100644 e2e/runner.js create mode 100644 e2e/scroll-behavior/index.html create mode 100644 e2e/scroll-behavior/index.ts create mode 100644 e2e/scroll-behavior/scrollWaiter.ts create mode 100644 e2e/specs/encoding.js create mode 100644 e2e/specs/guards-instances.js create mode 100644 e2e/specs/hash.js create mode 100644 e2e/specs/keep-alive.js create mode 100644 e2e/specs/modal.js create mode 100644 e2e/specs/multi-app.js create mode 100644 e2e/specs/scroll-behavior.js create mode 100644 e2e/specs/transitions.js create mode 100644 e2e/staticServer.js create mode 100644 e2e/transitions/index.html create mode 100644 e2e/transitions/index.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/webpack.config.js create mode 100644 jest.config.js create mode 100644 netlify.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playground/App.vue create mode 100644 playground/api/index.ts create mode 100644 playground/index.html create mode 100644 playground/main.ts create mode 100644 playground/router.ts create mode 100644 playground/scrollWaiter.ts create mode 100644 playground/shim.d.ts create mode 100644 playground/store.ts create mode 100644 playground/tsconfig.json create mode 100644 playground/views/ComponentWithData.vue create mode 100644 playground/views/Dynamic.vue create mode 100644 playground/views/Generic.vue create mode 100644 playground/views/GuardedWithLeave.vue create mode 100644 playground/views/Home.vue create mode 100644 playground/views/LongView.vue create mode 100644 playground/views/Nested.vue create mode 100644 playground/views/NestedWithId.vue create mode 100644 playground/views/NotFound.vue create mode 100644 playground/views/RepeatedParams.vue create mode 100644 playground/views/User.vue create mode 100644 rollup.config.js create mode 100644 scripts/check-size.js create mode 100755 scripts/docs-check.sh create mode 100644 scripts/release.sh create mode 100644 scripts/verifyCommit.js create mode 100644 size-checks/rollup.config.js create mode 100644 size-checks/webRouter.js create mode 100644 size-checks/webRouterAndVue.js create mode 100644 src/RouterLink.ts create mode 100644 src/RouterView.ts create mode 100644 src/devtools.ts create mode 100644 src/encoding.ts create mode 100644 src/errors.ts create mode 100644 src/global.d.ts create mode 100644 src/globalExtensions.ts create mode 100644 src/history/common.ts create mode 100644 src/history/hash.ts create mode 100644 src/history/html5.ts create mode 100644 src/history/memory.ts create mode 100644 src/index.ts create mode 100644 src/injectionSymbols.ts create mode 100644 src/location.ts create mode 100644 src/matcher/index.ts create mode 100644 src/matcher/pathMatcher.ts create mode 100644 src/matcher/pathParserRanker.ts create mode 100644 src/matcher/pathTokenizer.ts create mode 100644 src/matcher/types.ts create mode 100644 src/navigationGuards.ts create mode 100644 src/query.ts create mode 100644 src/router.ts create mode 100644 src/scrollBehavior.ts create mode 100644 src/types/index.ts create mode 100644 src/types/typeGuards.ts create mode 100644 src/useApi.ts create mode 100644 src/utils/README.md create mode 100644 src/utils/callbacks.ts create mode 100644 src/utils/env.ts create mode 100644 src/utils/index.ts create mode 100644 src/warning.ts create mode 100644 test-dts/components.test-d.tsx create mode 100644 test-dts/createRouter.test-d.ts create mode 100644 test-dts/index.d.ts create mode 100644 test-dts/legacy.test-d.ts create mode 100644 test-dts/meta.test-d.ts create mode 100644 test-dts/navigationGuards.test-d.ts create mode 100644 test-dts/routeRecords.test-d.ts create mode 100644 test-dts/tsconfig.json create mode 100644 tsconfig.json create mode 100644 vetur/attributes.json create mode 100644 vetur/tags.json create mode 100644 webpack.config.js create mode 100644 yarn.lock diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..94af3fbf4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Create new issue + url: https://new-issue.vuejs.org/?repo=vuejs/vue-router-next + about: Please use the following link to create a new issue. + - name: Github Sponsors + url: https://github.com/sponsors/posva + about: Love Vue.js? Please consider supporting us via Github Sponsors. + - name: Open Collective + url: https://opencollective.com/vuejs/donate + about: Love Vue.js? Please consider supporting us via Open Collective. diff --git a/.github/commit-convention.md b/.github/commit-convention.md new file mode 100644 index 000000000..665b1da33 --- /dev/null +++ b/.github/commit-convention.md @@ -0,0 +1,91 @@ +## Git Commit Message Convention + +> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). + +#### TL;DR: + +Messages must be matched by the following regex: + +```text +/^(revert: )?(feat|fix|docs|dx|style|refactor|perf|test|workflow|build|ci|chore|types|wip)(\(.+\))?: .{1,50}/ +``` + +#### Examples + +Appears under "Features" header, `link` subheader: + +``` +feat(link): add `force` option +``` + +Appears under "Bug Fixes" header, `view` subheader, with a link to issue #28: + +``` +fix(view): handle keep-alive with aborted navigations + +close #28 +``` + +Appears under "Performance Improvements" header, and under "Breaking Changes" with the breaking change explanation: + +``` +perf: improve guard extraction + +BREAKING CHANGE: The 'beforeRouteEnter' option has been removed. +``` + +The following commit and commit `667ecc1` do not appear in the changelog if they are under the same release. If not, the revert commit appears under the "Reverts" header. + +``` +revert: feat(compiler): add 'comments' option + +This reverts commit 667ecc1654a317a13331b17617d973392f415f02. +``` + +### Full Message Format + +A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: + +``` +(): + + + +
+``` + +The **header** is mandatory and the **scope** of the header is optional. + +### Revert + +If the commit reverts a previous commit, it should begin with `revert: `, followed by the header of the reverted commit. In the body, it should say: `This reverts commit .`, where the hash is the SHA of the commit being reverted. + +### Type + +If the prefix is `feat`, `fix` or `perf`, it will appear in the changelog. However, if there is any [BREAKING CHANGE](#footer), the commit will always appear in the changelog. + +Other prefixes are up to your discretion. Suggested prefixes are `docs`, `chore`, `style`, `refactor`, and `test` for non-changelog related tasks. + +### Scope + +The scope could be anything specifying the place of the commit change. For example `core`, `compiler`, `ssr`, `v-model`, `transition` etc... + +### Subject + +The subject contains a succinct description of the change: + +- use the imperative, present tense: "change" not "changed" nor "changes" +- don't capitalize the first letter +- no dot (.) at the end + +### Body + +Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". +The body should include the motivation for the change and contrast this with previous behavior. + +### Footer + +The footer should contain any information about **Breaking Changes** and is also the place to +reference GitHub issues that this commit **Closes**. + +**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. diff --git a/.github/contributing.md b/.github/contributing.md new file mode 100644 index 000000000..c2ed3d7d2 --- /dev/null +++ b/.github/contributing.md @@ -0,0 +1,116 @@ +# Vue Router Contributing Guide + +Hi! I'm really excited that you are interested in contributing to Vue Router. Before submitting your contribution, please make sure to take a moment and read through the following guidelines: + +- [Code of Conduct](https://github.com/vuejs/vue/blob/dev/.github/CODE_OF_CONDUCT.md) +- [Issue Reporting Guidelines](#issue-reporting-guidelines) +- [Pull Request Guidelines](#pull-request-guidelines) +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Contributing Tests](#contributing-tests) +- [Financial Contribution](#financial-contribution) + +## Issue Reporting Guidelines + +- Always use [https://new-issue.vuejs.org/](https://new-issue.vuejs.org/) to create new issues. +- Here is a template to report a bug: [https://codesandbox.io/s/vue-router-v4-reproduction-tk1y7](https://codesandbox.io/s/vue-router-v4-reproduction-tk1y7). Please use it when reporting a bug + +## Pull Request Guidelines + +- Checkout a topic branch from a base branch, e.g. `master`, and merge back against that branch. + +- If adding a new feature: + + - Add accompanying test case. + - Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it. + +- If fixing bug: + + - If you are resolving a special issue, add `(fix #xxxx[,#xxxx])` (#xxxx is the issue id) in your PR title for a better release log, e.g. `update entities encoding/decoding (fix #3899)`. + - Provide a detailed description of the bug in the PR. Live demo preferred. + - Add appropriate test coverage if applicable. You can check the coverage of your code addition by running `yarn test --coverage`. + +- It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging. + +- Make sure tests pass! + +- Commit messages must follow the [commit message convention](./commit-convention.md) so that the changelog can be automatically generated. Commit messages are automatically validated before commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [yorkie](https://github.com/yyx990803/yorkie)). + +- No need to worry about code style as long as you have installed the dev dependencies - modified files are automatically formatted with Prettier on commit (by invoking [Git Hooks](https://git-scm.com/docs/githooks) via [yorkie](https://github.com/yyx990803/yorkie)). + +## Development Setup + +You will need [Node.js](http://nodejs.org) **version 10+**, and [Yarn](https://classic.yarnpkg.com/en/docs/install). + +After cloning the repo, run: + +```bash +$ yarn # install the dependencies of the project +``` + +A high level overview of tools used: + +- [TypeScript](https://www.typescriptlang.org/) as the development language +- [Rollup](https://rollupjs.org) for bundling +- [Jest](https://jestjs.io/) for unit testing +- [Prettier](https://prettier.io/) for code formatting + +## Scripts + +### `yarn build` + +The `build` script builds vue-router + +### `yarn dev` + +The `dev` scripts starts a playground project located at `playground/` that allows you to test things on a browser. + +```bash +$ yarn dev +``` + +### `yarn test` + +The `yarn test` script runs all checks: + +- _Typings_: `test:types` +- _Linting_: `test:lint` +- _Unit tests_: `test:unit` +- _Building_: `build` + +```bash +# run all tests +$ yarn test + +# run unit tests in watch mode +$ yarn jest --watch +``` + +## Project Structure + +Vue Router source code can be found in the `src` directory: + +- `src/history`: history implementations that are instantiable with `create*History()`. This folder contains code related to using the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API). +- `src/matcher`: RouteMatcher implementation. Contains the code that transforms paths like `/users/:id` into regexps and handle the transformation of locations like `{ name: 'UserDetail', params: { id: '2' } }` to strings. It contains path ranking logic and the part of dynamic routing that concerns matching urls in the right order. +- `src/utils`: contains small utility functions that are used across other sections of the router but are not contained by them. +- `src/router`: contains the router creation, navigation execution, using the matcher, the history implementation. It runs navigation guards. +- `src/location`: helpers related to route location and urls +- `src/encoding`: helpers related to url encoding +- `src/errors`: different internal and external errors with their messages +- `src/index`: contains all public API as exports. +- `src/types`: contains global types that are used across multiple sections of the router. + +## Contributing Tests + +Unit tests are located inside `__tests__`. Consult the [Jest docs](https://jestjs.io/docs/en/using-matchers) and existing test cases for how to write new test specs. Here are some additional guidelines: + +- Use the minimal API needed for a test case. For example, if a test can be written without involving the reactivity system or a component, it should be written so. This limits the test's exposure to changes in unrelated parts and makes it more stable. +- Use the minimal API needed for a test case. For example, if a test concerns the `router-link` component, don't create a router instance, mock the needed properties instead. +- Write a unit test whenever possible +- If a test is specific to a browser, create an e2e (end to end) test and make sure to indicate it on the test + +## Credits + +Thank you to all the people who have already contributed to Vue Router! + + diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 000000000..063af581a --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,2 @@ +github: [posva] +open_collective: vuejs diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 000000000..7fdbae077 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,23 @@ +on: + push: + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Create Release + +jobs: + build: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@master + - name: Create Release for Tag + id: release_tag + uses: yyx990803/release-tag@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + body: | + Please refer to [CHANGELOG.md](https://github.com/vuejs/vue-router-next/blob/master/CHANGELOG.md) for details. diff --git a/.github/workflows/size-check.yml b/.github/workflows/size-check.yml new file mode 100644 index 000000000..6915abea0 --- /dev/null +++ b/.github/workflows/size-check.yml @@ -0,0 +1,22 @@ +name: 'size' +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + size: + runs-on: ubuntu-latest + env: + CI_JOB_NUMBER: 1 + steps: + - uses: actions/checkout@v1 + - uses: bahmutov/npm-install@v1 + + - uses: posva/size-check-action@v1.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + build_script: build:size + files: dist/vue-router.global.prod.js size-checks/dist/webRouter.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ed70eac5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +dist +examples_dist +node_modules +coverage +.nyc_output +.rpt2_cache +.env +local.log +.DS_Store +e2e/reports +e2e/screenshots +__build__ +playground_dist +yarn-error.log +temp +markdown +explorations +selenium-server.log +browserstack.err diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..12911d3ec --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "trailingComma": "es5", + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..6cc109649 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "name": "vscode-jest-tests", + "request": "launch", + "args": [ + "--runInBand" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "program": "${workspaceFolder}/node_modules/jest/bin/jest" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..3662b3700 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d9cdf6dee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,603 @@ +## [4.0.1](https://github.com/vuejs/vue-router-next/compare/v4.0.0...v4.0.1) (2020-12-07) + +### Bug Fixes + +- **build:** rollback rollup plugin commonjs ([9486950](https://github.com/vuejs/vue-router-next/commit/9486950f3399bda34ab2840b83fd123ac5ce7ce9)) + +# [4.0.0](https://github.com/vuejs/vue-router-next/compare/v4.0.0-rc.6...v4.0.0) (2020-12-07) + +### Bug Fixes + +- **router-view:** properly use route prop when nested ([b74051a](https://github.com/vuejs/vue-router-next/commit/b74051a6bde7524d1a7cc6cc1daacb213987faa0)) +- **router-view:** return one node when possible ([d18e500](https://github.com/vuejs/vue-router-next/commit/d18e500da2ed017be30871628a5cc59324bec15c)), closes [#537](https://github.com/vuejs/vue-router-next/issues/537) + +### Features + +- expose routerViewLocationKey as internal ([f498646](https://github.com/vuejs/vue-router-next/commit/f498646c3bc2ad480be7a3d0f11aa11710729911)) + +# [4.0.0-rc.6](https://github.com/vuejs/vue-router-next/compare/v4.0.0-rc.5...v4.0.0-rc.6) (2020-11-30) + +### Bug Fixes + +- **guards:** correctly reuse guards ([#616](https://github.com/vuejs/vue-router-next/issues/616)) ([95d44c8](https://github.com/vuejs/vue-router-next/commit/95d44c8ff2a961e052fd67b2160b87fb32d0ffb4)), closes [#614](https://github.com/vuejs/vue-router-next/issues/614) + +### Features + +- **devtools:** improve active + match in routes inspector ([9f59489](https://github.com/vuejs/vue-router-next/commit/9f59489f04cedfca5ba55da019b2dc790e926fd7)) +- **types:** expose `LocationQueryValueRaw` as internal ([dc02850](https://github.com/vuejs/vue-router-next/commit/dc028500c3e931ed5fd6beedf58b5425f5115b52)) + +# [4.0.0-rc.5](https://github.com/vuejs/vue-router-next/compare/v4.0.0-rc.4...v4.0.0-rc.5) (2020-11-21) + +### Features + +- **scroll:** allow modifying scrollBehavior in options ([#602](https://github.com/vuejs/vue-router-next/issues/602)) ([d6651f5](https://github.com/vuejs/vue-router-next/commit/d6651f5f954c8ecaf1a77ec209d5aba06343e867)) + +# [4.0.0-rc.4](https://github.com/vuejs/vue-router-next/compare/v4.0.0-rc.3...v4.0.0-rc.4) (2020-11-20) + +### Features + +- expose symbols as internals ([ef62d96](https://github.com/vuejs/vue-router-next/commit/ef62d9645c456f069699480ae3f2c3dd97b9d30d)) + +# [4.0.0-rc.3](https://github.com/vuejs/vue-router-next/compare/v4.0.0-rc.2...v4.0.0-rc.3) (2020-11-14) + +### Bug Fixes + +- trigger redirect on popstate ([#592](https://github.com/vuejs/vue-router-next/issues/592)) ([18dbdc2](https://github.com/vuejs/vue-router-next/commit/18dbdc2745cf7bd2516d4576a8d6a21de78516ec)) +- **query:** encode space as + ([4d3dd5f](https://github.com/vuejs/vue-router-next/commit/4d3dd5fd523cefc675aa7e61ed9b06b66e42b80c)), closes [#561](https://github.com/vuejs/vue-router-next/issues/561) + +# [4.0.0-rc.2](https://github.com/vuejs/vue-router-next/compare/v4.0.0-rc.1...v4.0.0-rc.2) (2020-11-05) + +### Features + +- expose injection symbols as internals ([0056aca](https://github.com/vuejs/vue-router-next/commit/0056aca5b251df2a18bab79e18874a18e0204b4d)) +- **devtools:** add devtools plugin ([894d50d](https://github.com/vuejs/vue-router-next/commit/894d50d351a40df95a3227840f5485f7e8b90432)) +- **devtools:** add more ([ee07302](https://github.com/vuejs/vue-router-next/commit/ee0730254522d6162114968e4d62b93e8b6f7f93)) +- **devtools:** better search ([5d68a29](https://github.com/vuejs/vue-router-next/commit/5d68a29386f34363b38c4138fbeae01ec538285e)) +- **devtools:** support multiple router instances ([2e5d0d4](https://github.com/vuejs/vue-router-next/commit/2e5d0d4d726ee6329745f34ca463a74820c5aa29)) + +# [4.0.0-rc.1](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.13...v4.0.0-rc.1) (2020-10-23) + +### Features + +- **warn:** improve warning for invalid components ([5985b65](https://github.com/vuejs/vue-router-next/commit/5985b6560d40412d67311df10343ee6a119a0535)), closes [#517](https://github.com/vuejs/vue-router-next/issues/517) + +# [4.0.0-beta.13](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.12...v4.0.0-beta.13) (2020-10-02) + +### Bug Fixes + +- **encoding:** decode hash in string location ([11acb3d](https://github.com/vuejs/vue-router-next/commit/11acb3dea072592f00a23b912d39c3fcf72dc6c3)) +- **encoding:** differentiate keys and values in query ([a967e42](https://github.com/vuejs/vue-router-next/commit/a967e427ab3bc5c1e6236b01f484a87b74a92be1)) +- **encoding:** keep decoded hash when resolving ([1a8ffc1](https://github.com/vuejs/vue-router-next/commit/1a8ffc19b0d2bfc17daec4cb04b96d174c73dd9d)) +- **hash:** only pushState the hash part ([2a14c19](https://github.com/vuejs/vue-router-next/commit/2a14c19e4f0313996fd075a6821f85d30c5cad66)), closes [#495](https://github.com/vuejs/vue-router-next/issues/495) + +### Features + +- **warn:** help migrating catch all routes ([14e1eb9](https://github.com/vuejs/vue-router-next/commit/14e1eb96485f74669f582a87f522d3b13b567c9c)) +- print errors from lazy loading ([f6db91a](https://github.com/vuejs/vue-router-next/commit/f6db91aaf496b85c80e74727575cc1c2b1d06282)), closes [#497](https://github.com/vuejs/vue-router-next/issues/497) + +# [4.0.0-beta.12](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.11...v4.0.0-beta.12) (2020-09-25) + +### Bug Fixes + +- **types:** extend @vue/runtime-core module ([#473](https://github.com/vuejs/vue-router-next/issues/473)) ([556cd4b](https://github.com/vuejs/vue-router-next/commit/556cd4b4af3d7ac1aa1c66848f5ab1bc33d13153)) + +# [4.0.0-beta.11](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.10...v4.0.0-beta.11) (2020-09-20) + +### Bug Fixes + +- use post flush in modal example ([2024281](https://github.com/vuejs/vue-router-next/commit/2024281902d62454d9159c87d4288d691cd0bce8)) +- **guards:** use post watcher for instances ([3234c59](https://github.com/vuejs/vue-router-next/commit/3234c5924f39fd9497866bfd160407256dc91bfe)) + +# [4.0.0-beta.10](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.9...v4.0.0-beta.10) (2020-09-18) + +### Bug Fixes + +- **history:** gracefully handle empty state ([cbcf2a9](https://github.com/vuejs/vue-router-next/commit/cbcf2a95a2af001c8aea96f3c76c4c4ef139219f)), closes [#366](https://github.com/vuejs/vue-router-next/issues/366) +- **types:** better type for navigate ([0384cb0](https://github.com/vuejs/vue-router-next/commit/0384cb062d50f6be37512410b4c2d170896dc9cb)) +- **types:** explicit types on navigate ([36d218c](https://github.com/vuejs/vue-router-next/commit/36d218c15268d0d3d15d4ed3adc75c8cb09ed68b)) +- **types:** fix types for redirect records ([a77f148](https://github.com/vuejs/vue-router-next/commit/a77f1485323ef3b654077ecb227fd5a0373d3a2f)) +- **warn:** correctly warn against unused next ([47cd7b9](https://github.com/vuejs/vue-router-next/commit/47cd7b97bb7a3999178a26a4ca1af955178ea5d6)) + +### Code Refactoring + +- **types:** Rename ScrollBehavior to RouterScrollBehavior ([9fc0996](https://github.com/vuejs/vue-router-next/commit/9fc09969db854bc0201454fbecd546637b76213a)) + +### Features + +- **router:** remove partial Promise from router.go ([6ed6eee](https://github.com/vuejs/vue-router-next/commit/6ed6eee38b59eb0b6dec0bcb7d73e24203e20ba4)) +- **types:** allow extending meta fields ([#407](https://github.com/vuejs/vue-router-next/issues/407)) ([706e84f](https://github.com/vuejs/vue-router-next/commit/706e84f0099a2a04485dfa98449fdc875442bb49)) +- **warn:** point to scrollBehavior in message ([70ce7fe](https://github.com/vuejs/vue-router-next/commit/70ce7feefac3fddd2a9641fcc2ccc66b4b108775)) + +### BREAKING CHANGES + +- **router:** The `router.go()` methods doesn't return anything + (like in Vue Router 3) anymore. The existing implementation was wrong as it + would resolve the promise for the following navigation if `router.go()` + was called with something that wasn't possible e.g. `router.go(-20)` + right after entering the application would not do anything. Even worse, + the promise returned by that call would resolve **after the next + navigation**. There is no proper native API to implement this + promise-based api properly, but one can write a version that should work + in most scenarios by setting up multiple hooks right before calling + `router.go()`: + +```js +export function go(delta) { + return new Promise((resolve, reject) => { + function popStateListener() { + clearTimeout(timeout) + } + window.addEventListener('popstate', popStateListener) + + function clearHooks() { + removeAfterEach() + removeOnError() + window.removeEventListener('popstate', popStateListener) + } + + // if the popstate event is not called, consider this a failure + const timeout = setTimeout(() => { + clearHooks() + reject(new Error('Failed to use router.go()')) + // It's unclear of what value would always work here + }, 10) + + setImmediate + + const removeAfterEach = router.afterEach((_to, _from, failure) => { + clearHooks() + resolve(failure) + }) + const removeOnError = router.onError(err => { + clearHooks() + reject(err) + }) + + router.go(delta) + }) +} +``` + +- **types:** there is already an existing type named `ScrollBehavior`, + so we are renaming our type to avoid any confusions and allow the user + to use both types at the same type (which given what the existing + `ScrollBehavior` type is designed for, will likely happen). + +# [4.0.0-beta.9](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.8...v4.0.0-beta.9) (2020-09-01) + +Build related fixes + +# [4.0.0-beta.8](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.7...v4.0.0-beta.8) (2020-09-01) + +### Bug Fixes + +- **router-view:** reuse saved instances in different records ([#446](https://github.com/vuejs/vue-router-next/issues/446)) ([6554171](https://github.com/vuejs/vue-router-next/commit/65541718b0d5af665fd87dc0e48770cba832a2bb)) +- **types:** add HTML attributes for JSX ([06f3f8f](https://github.com/vuejs/vue-router-next/commit/06f3f8fd7c3a32da331802fe5d3d19ced17200a3)), closes [#435](https://github.com/vuejs/vue-router-next/issues/435) +- **types:** allow components defined via defineComponent ([#421](https://github.com/vuejs/vue-router-next/issues/421)) ([e47c84c](https://github.com/vuejs/vue-router-next/commit/e47c84c74a97ae7bb9095ea75f98a6fa8a216532)) + +### BREAKING CHANGES + +- **router-view:** `onBeforeRouteLeave` and `onBeforeRouteUpdate` used to + have access to the component instance through `instance.proxy` but given + that: + 1. It has been marked as `internal` (https://github.com/vuejs/vue-next/pull/1849) + 2. When using `setup`, all variables are accessible on the scope (and + should be accessed that way because the code minimizes better) + It has been removed to prevent wrong usage and lighten Vue Router + +# [4.0.0-beta.7](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.6...v4.0.0-beta.7) (2020-08-19) + +### Bug Fixes + +- **encoding:** encode partial params ([eb04117](https://github.com/vuejs/vue-router-next/commit/eb041175c02ab0dac093823574a85bbbbf2056eb)) +- **matcher:** avoid trailing slash with optional params ([faf0aab](https://github.com/vuejs/vue-router-next/commit/faf0aab6451848e5b4330e1d01033137a0c42a5a)) +- **types:** append declare module ([50ad404](https://github.com/vuejs/vue-router-next/commit/50ad404ae45086f051b01ac552e4a3ab98535633)), closes [#419](https://github.com/vuejs/vue-router-next/issues/419) +- **vetur:** update tags/attributes definition ([#408](https://github.com/vuejs/vue-router-next/issues/408)) ([df8b2b1](https://github.com/vuejs/vue-router-next/commit/df8b2b140155d1e4ad5d00cd17d57ab2046a75e2)) + +### Features + +- **warn:** warn against infinite redirections ([e3dcc8d](https://github.com/vuejs/vue-router-next/commit/e3dcc8d9477e17f9b92e22787b750edc4658b77a)) + +# [4.0.0-beta.6](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.5...v4.0.0-beta.6) (2020-08-05) + +### Bug Fixes + +- **router:** stack overflow with redirect ([3594011](https://github.com/vuejs/vue-router-next/commit/359401107078348f0410abbd36cffb3b8d4d8f85)), closes [#404](https://github.com/vuejs/vue-router-next/issues/404) + +### Features + +- **router-link:** add ariaCurrentValue prop ([23e6e9c](https://github.com/vuejs/vue-router-next/commit/23e6e9c10b4f9cb9f074ebb4f56d2d99acac9097)) +- add Vetur support ([1f1189f](https://github.com/vuejs/vue-router-next/commit/1f1189fd23dc6ec318edd5d7e8f225b467d4d386)), closes [#381](https://github.com/vuejs/vue-router-next/issues/381) + +# [4.0.0-beta.5](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.4...v4.0.0-beta.5) (2020-08-03) + +### Features + +- resolve simple relative links ([af1deaa](https://github.com/vuejs/vue-router-next/commit/af1deaab5e0fd1597a7cf7ee9a6d01cac507970d)) +- **url:** simple resolve relative location ([69c44db](https://github.com/vuejs/vue-router-next/commit/69c44db3fd5363a833675b4b0ef14f97ac691af6)) +- **warn:** warn if guard returns without calling next ([6e16bdd](https://github.com/vuejs/vue-router-next/commit/6e16bdd6338ea3b7da1f8a0b3000ec880be840d6)) + +# [4.0.0-beta.4](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.3...v4.0.0-beta.4) (2020-07-25) + +### Bug Fixes + +- **router-view:** render the slot when there is no match ([bae42d4](https://github.com/vuejs/vue-router-next/commit/bae42d41c2240947e5b649e568cad274214c6346)), closes [#385](https://github.com/vuejs/vue-router-next/issues/385) +- work on Edge by adding an argument to catch ([#383](https://github.com/vuejs/vue-router-next/issues/383)) ([9580bea](https://github.com/vuejs/vue-router-next/commit/9580bead1f03f1be95473e965daa1f1ee78921f3)) + +# [4.0.0-beta.3](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.2...v4.0.0-beta.3) (2020-07-21) + +### Bug Fixes + +- **guards:** call beforeRouteEnter once per named view ([f2846ff](https://github.com/vuejs/vue-router-next/commit/f2846ff2a0796e58a9b04593909f7a30b7b68bb1)) +- **guards:** remove registered update guards after leaving ([41bffda](https://github.com/vuejs/vue-router-next/commit/41bffda49c24d560cfe555aa88bcebbbd1d03d68)) +- **guards:** skip update and leave guards of unmounted views ([f22e70a](https://github.com/vuejs/vue-router-next/commit/f22e70a6d15ce9834c9eb841d9fe9547c5d21e24)) +- **hash:** allow url to contain search params before hash ([ae8b289](https://github.com/vuejs/vue-router-next/commit/ae8b28934b1c9a092174ebd6fb5aa10aefe1de44)), closes [#378](https://github.com/vuejs/vue-router-next/issues/378) + +### Features + +- **errors:** export isNavigationFailure ([28a9b25](https://github.com/vuejs/vue-router-next/commit/28a9b25d976c325d3193cada8034a6e42297e665)) +- **guards:** allow guards to return a value instead of calling next ([#343](https://github.com/vuejs/vue-router-next/issues/343)) ([5cb209f](https://github.com/vuejs/vue-router-next/commit/5cb209f3bb53ac0ddf62152f695da610facf4724)) +- **guards:** wip context support in multi apps ([34d7390](https://github.com/vuejs/vue-router-next/commit/34d7390b946644a128ab6fd03fd821a91fd4782c)) + +# [4.0.0-beta.2](https://github.com/vuejs/vue-router-next/compare/v4.0.0-beta.1...v4.0.0-beta.2) (2020-07-07) + +Fix build cache issues + +# [4.0.0-beta.1](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.14...v4.0.0-beta.1) (2020-07-03) + +### Bug Fixes + +- **hash:** manual changes should trigger a navigation ([93891ab](https://github.com/vuejs/vue-router-next/commit/93891abf02fc24d66c6f43926a28f275560fb714)), closes [#346](https://github.com/vuejs/vue-router-next/issues/346) +- **router-link:** add missing prop custom in jsx ([c6274ae](https://github.com/vuejs/vue-router-next/commit/c6274aeaf5ad4ba4f97c82aad3e1819ef20f5d69)) +- **router-view:** preserve keep-alive route guard this context ([#344](https://github.com/vuejs/vue-router-next/issues/344)) ([994c073](https://github.com/vuejs/vue-router-next/commit/994c073fd90add30bf16b5268332277f8b082a74)) +- **warn:** warn when RouterView is wrapped with transition ([e4b3fbe](https://github.com/vuejs/vue-router-next/commit/e4b3fbe8b799b6621537afe365267a18eab9d3cd)) + +### Code Refactoring + +- **history:** simplify location as a string ([10a071c](https://github.com/vuejs/vue-router-next/commit/10a071c85c62b6674929162aa36220bd8c167f27)) +- **router:** remove history property ([aba3a3f](https://github.com/vuejs/vue-router-next/commit/aba3a3f3a0d860f76d75938ae09616a329c7c13c)) + +### Features + +- **guards:** next callback beforeRouteEnter ([d9dad0b](https://github.com/vuejs/vue-router-next/commit/d9dad0b9467fee9478406899043ee35f30cdf1fb)) + +### BREAKING CHANGES + +- **router:** the history property was marked as internal already. Since we + need to pass the history instance to the router, we always have access to it, + differently from Vue Router 3 where the history was instantiated internally. + The history API was also internal (it wasn't documented), so this change + shouldn't be a problem as people shouldn't be relying on `router.history` in + their apps. If you think this property is needed, please open an issue to + discuss the use case. Note it's already accessible as you have to create it: + +```js +export const history = createWebHistory() +export const router = createRouter({ history, routes: [] }) +``` + +- **history:** HistoryLocation is just a string now. It was pretty much an + internal property but it could be used inside `history.state`. It used to be an + object `{ fullPath: '/the-url' }`. And it's now just the `fullPath` property. + +# [4.0.0-alpha.14](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.13...v4.0.0-alpha.14) (2020-07-01) + +### Bug Fixes + +- **hash:** use relative links in hash mode ([32c9590](https://github.com/vuejs/vue-router-next/commit/32c9590db89e69c8f7c61905a5eaf19df2054e42)), closes [#342](https://github.com/vuejs/vue-router-next/issues/342) +- **query:** do not normalize query with custom stringifyQuery ([ea65066](https://github.com/vuejs/vue-router-next/commit/ea65066e8511d8320ad8de37b32ea9a8028fa9d5)), closes [#328](https://github.com/vuejs/vue-router-next/issues/328) +- **query:** isSameRouteLocation compares queries by string ([6e1f0ea](https://github.com/vuejs/vue-router-next/commit/6e1f0eacf60c7e3d465dd0af68f79dc649269b17)), closes [#328](https://github.com/vuejs/vue-router-next/issues/328) + +### Features + +- **redirect:** allow redirect on routes witch children ([e57b875](https://github.com/vuejs/vue-router-next/commit/e57b875dd9d375778a847627434803f4ec79a818)) +- **router:** support multiple apps at the same time ([565ec9d](https://github.com/vuejs/vue-router-next/commit/565ec9d489b4aad347ee466b781ca85aff76bf2d)) + +# [4.0.0-alpha.13](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.12...v4.0.0-alpha.13) (2020-06-18) + +### Bug Fixes + +- allow arbitrary selectors starting with # ([14b859d](https://github.com/vuejs/vue-router-next/commit/14b859dfa6fa5ccefe42c6f834ddd24dd9921a1b)) +- use assign to align with Vue browser support ([#311](https://github.com/vuejs/vue-router-next/issues/311)) ([f80b670](https://github.com/vuejs/vue-router-next/commit/f80b670d4dac30323221fcb2f93137ffd874c51b)), closes [#304](https://github.com/vuejs/vue-router-next/issues/304) +- **hash:** use location.pathname ([0078147](https://github.com/vuejs/vue-router-next/commit/007814745dd98bb8cfa53f44d5c308193b2fbb60)), closes [#261](https://github.com/vuejs/vue-router-next/issues/261) +- **matcher:** correct check when removing existing records on add ([2c267f5](https://github.com/vuejs/vue-router-next/commit/2c267f5aceec899c84514571e4fa75dc61441ed4)) +- **matcher:** override records by name when adding ([07100fc](https://github.com/vuejs/vue-router-next/commit/07100fc1386fb636da3eb1c8196a36f6538eb91f)) +- **scroll:** avoid reusing scroll position ([dfc1fb3](https://github.com/vuejs/vue-router-next/commit/dfc1fb34a761138a3390ccd5a8a042863018222a)) + +### Features + +- **scroll:** allow passing behavior option ([12e9209](https://github.com/vuejs/vue-router-next/commit/12e92094df46129ddf75d0fa8e3d9816644200de)) +- **scroll:** replace selector with el ([ab8a01c](https://github.com/vuejs/vue-router-next/commit/ab8a01c0a6eda1bafc293b39cb6c77ed10fb359e)) +- **warn:** warn if component is a promise ([4b2bfa8](https://github.com/vuejs/vue-router-next/commit/4b2bfa80cd3440441d71e690ca85d0532a4b8428)) +- **warn:** warn when routes are not found ([#279](https://github.com/vuejs/vue-router-next/issues/279)) ([d125356](https://github.com/vuejs/vue-router-next/commit/d125356e0f67f906f5f602f0b485f9e1e4f5bf51)) +- allow props for named views ([dbe2344](https://github.com/vuejs/vue-router-next/commit/dbe2344af5fed39aa4aa8fbfe48b195580d9538b)) +- **warn:** warn multiple params with same name ([5c8cd6e](https://github.com/vuejs/vue-router-next/commit/5c8cd6e8ae1223e9871252cc617b19424f01c5c2)) + +### BREAKING CHANGES + +- **scroll:** this change follows the RFC at + https://github.com/vuejs/rfcs/pull/176: + +* `selector` is renamed into `el` +* `el` also accepts an `Element` +* `left` and `top` are passed along `el` instead of inside an object + passed as `offset` + +- **scroll:** `scrollBehavior` doesn't accept an object with `x` and `y` + coordinates anymore. Instead it accepts an object like + [`ScrollToOptions`](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions) + with `left` and `top` properties. You can now also pass the + [`behavior`](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior) + property to enable smooth scrolling in most browsers. +- It is now necessary to escape id selectors like + explained at https://mathiasbynens.be/notes/css-escapes. This was + necessary to allow selectors like `#container > child`. + +# [4.0.0-alpha.12](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.11...v4.0.0-alpha.12) (2020-05-19) + +### Bug Fixes + +- **hash:** allow base with non trailing slash ([f5cc050](https://github.com/vuejs/vue-router-next/commit/f5cc0505f9e0cc30ff94e362ceb24d300afd684d)), closes [#247](https://github.com/vuejs/vue-router-next/issues/247) +- prevent error on initial navigation to //invalid ([e72e4ba](https://github.com/vuejs/vue-router-next/commit/e72e4ba1cc7b80aa44d3958db259d9e3a351d0fd)) + +### Features + +- **warn:** warn multiple leading slashes ([87c5e53](https://github.com/vuejs/vue-router-next/commit/87c5e53b43c218c83f9db986ac7538d74525ea5b)) + +### BREAKING CHANGES + +- **hash:** When providing a base for hash histories, it is now necessary + to include a trailing slash to create a url that starts with `/#/`, otherwise it + will result in a url starting with `#/`. This allows users to use the routing + system directly in simple files without needing to configure a server at all: + - `https://example.com/file.html` + `base: 'file.html` will produce a final + url of `https://example.com/file.html#/` + - `https://example.com/folder` + `base: 'folder` will produce a final url of + `https://example.com/folder#/` + - `https://example.com/folder` + `base: 'folder/` will produce a final url of + `https://example.com/folder/#/` + +# [4.0.0-alpha.11](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.10...v4.0.0-alpha.11) (2020-05-12) + +### Bug Fixes + +- **scroll:** change scrollRestoration if scrollBehavior is provided ([5cf2e61](https://github.com/vuejs/vue-router-next/commit/5cf2e611de2477e92699121573cb162ff98a7b8d)) +- match base in a non-sensitive way ([7087bbc](https://github.com/vuejs/vue-router-next/commit/7087bbc9c479f2955381d8a823a3ef8f9eed7b5a)) +- **router:** allow multiple router instance ([24d3d49](https://github.com/vuejs/vue-router-next/commit/24d3d49babcdea751f4c4e7e9a87625f8744a122)) +- **router:** unique first navigation with multi app ([33172af](https://github.com/vuejs/vue-router-next/commit/33172aff03b7c302699753a8abe5750094bdde26)) + +### Features + +- **types:** export NavigationGuardNext ([#229](https://github.com/vuejs/vue-router-next/issues/229)) ([888bf4d](https://github.com/vuejs/vue-router-next/commit/888bf4df33d718d74e5835e99d0f1ac4ce3a0ccf)) +- explicit injection symbols in dev mode ([#228](https://github.com/vuejs/vue-router-next/issues/228)) ([fab88ee](https://github.com/vuejs/vue-router-next/commit/fab88ee261c49b739545918deab583757aab561e)) +- support jsx and tsx for RouterLink and RouterView ([1d3dce3](https://github.com/vuejs/vue-router-next/commit/1d3dce3106af700fc95a403f1c229644fe8d85b8)), closes [#226](https://github.com/vuejs/vue-router-next/issues/226) +- **router:** allow functional components for routes ([096d864](https://github.com/vuejs/vue-router-next/commit/096d86498e954345c6bd4d8e82fe54c37d3f869b)) +- **scroll:** scroll to the same location like regular links ([5f22d4f](https://github.com/vuejs/vue-router-next/commit/5f22d4fa39171906802cc20ada00ec57bdfce880)) +- **warn:** warn if next was called multiple times ([dce2612](https://github.com/vuejs/vue-router-next/commit/dce2612e495b1d5789cd993a54d24599967a8cf4)) + +# [4.0.0-alpha.10](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.9...v4.0.0-alpha.10) (2020-05-05) + +### Bug Fixes + +- **scroll:** do not restore on push ([3f79195](https://github.com/vuejs/vue-router-next/commit/3f7919585117048c379b6dee8af1cc1de5996af0)) + +### Features + +- **warn:** warn invalid hash ([fcf2365](https://github.com/vuejs/vue-router-next/commit/fcf2365556dffa87153c13d31a684070f123ea0e)) +- allow numbers as params ([ef0920a](https://github.com/vuejs/vue-router-next/commit/ef0920a86574bca10836214015c2317ed11a29b7)), closes [#206](https://github.com/vuejs/vue-router-next/issues/206) +- **router:** allow global router classes ([388735b](https://github.com/vuejs/vue-router-next/commit/388735bc752852e2a9a24f971207fd81fae45fcf)) +- **router:** go, back and forward can be awaited ([eb87757](https://github.com/vuejs/vue-router-next/commit/eb87757ed189958c8c9955a10ece9306fa99f6d8)) +- **warn:** detect missing param in nested absolute paths ([f5b5949](https://github.com/vuejs/vue-router-next/commit/f5b59493a4e27bf07bd5a0d2e109bc6750f6f1a9)) +- **warn:** warn for invalid path+params and redirect ([91f4de9](https://github.com/vuejs/vue-router-next/commit/91f4de9aab99231fb39ed4cc5b4052979afda216)) +- **warn:** warn missing params in alias ([186e275](https://github.com/vuejs/vue-router-next/commit/186e2755ec0488ff80bdde11a53b0ddc9ee9fc03)) +- **warn:** warn when params are provided alongside path ([8a8ddf1](https://github.com/vuejs/vue-router-next/commit/8a8ddf1a5e5f2d29733da4fe25e4ddb447b0df30)) + +# [4.0.0-alpha.9](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.8...v4.0.0-alpha.9) (2020-04-29) + +- Removed sourcemaps from build + +# [4.0.0-alpha.8](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.7...v4.0.0-alpha.8) (2020-04-29) + +### Bug Fixes + +- default matcher options ([cea397b](https://github.com/vuejs/vue-router-next/commit/cea397b7402cd27ff06013f846bf35966aff6952)) +- **guards:** preserve navigation options when redirecting ([9effd81](https://github.com/vuejs/vue-router-next/commit/9effd816c51b58cb1103d878799aed6992f78454)) +- **html5:** correctly preserve current history.state ([0586394](https://github.com/vuejs/vue-router-next/commit/05863948ee86e0f1c9c9ec31c02ad7af17923743)), closes [#180](https://github.com/vuejs/vue-router-next/issues/180) +- **link:** make alias of empty child active ([cfe5993](https://github.com/vuejs/vue-router-next/commit/cfe5993332cc7dc94c5de2f2edb7f2e15c9b7049)) +- encode hash ([85bb7e1](https://github.com/vuejs/vue-router-next/commit/85bb7e11b1a4326f5048a823ae7d49654b308cdd)) +- **link:** preserve the alias path ([fffa585](https://github.com/vuejs/vue-router-next/commit/fffa58585ac89e9fb6b648e61e499a9ee3a9e217)) +- **matcher:** merge params ([d8a6b25](https://github.com/vuejs/vue-router-next/commit/d8a6b2591ac2e37388fb7f4ce8c70922389cedb5)), closes [#189](https://github.com/vuejs/vue-router-next/issues/189) +- **router:** make redirect relative to target location ([e878e91](https://github.com/vuejs/vue-router-next/commit/e878e91af217fde6d2e934857ce895e7abbd5920)) +- **router:** preserve navigation options with redirects ([9732758](https://github.com/vuejs/vue-router-next/commit/9732758d076eef252f2940ffa44e44fa94e794a0)) +- **view:** render slot with no match ([5873296](https://github.com/vuejs/vue-router-next/commit/5873296ec96df15f13b0cf02b685ebb36f4e0a41)) + +### Code Refactoring + +- Link and View renamed to RouterLink and RouterView ([030bbc4](https://github.com/vuejs/vue-router-next/commit/030bbc4c3f68d29a9e9d23ee01603394427427a3)) + +### Features + +- **link:** make empty child active with adjacent children ([4b813b1](https://github.com/vuejs/vue-router-next/commit/4b813b1ec387f8be9506f1400b7e83fd5794c7af)) +- **router:** add global pathOptions ([7383564](https://github.com/vuejs/vue-router-next/commit/73835649f450ffc378b906c72aa5ae8a6a03feb2)) +- add navigation duplicated failure ([9570416](https://github.com/vuejs/vue-router-next/commit/9570416c75f904a172af07bcf10956fe3385ec13)) +- add onBeforeRouteUpdate ([96c9503](https://github.com/vuejs/vue-router-next/commit/96c95035653a52f94781808fccbf262a02a3cd79)) +- resolve relative paths ([eae833e](https://github.com/vuejs/vue-router-next/commit/eae833e0fc1c8e549f2b4cd47b3dcb90484d17d5)) +- **router:** add back,forward,go ([5e927b5](https://github.com/vuejs/vue-router-next/commit/5e927b5ab8a09c2941edbec7c6af145323c6d3eb)) +- **router:** add beforeResolve ([9697134](https://github.com/vuejs/vue-router-next/commit/9697134c05f0f4c6fde48a773880946074e95666)) +- **scroll:** handle scroll on reload ([617f131](https://github.com/vuejs/vue-router-next/commit/617f131d2473952072f345000c3d43556dfe9761)) + +### Performance Improvements + +- use index access for strings ([971fea4](https://github.com/vuejs/vue-router-next/commit/971fea415fcce84ce86d8ace67b65115af3b7ac2)) + +### BREAKING CHANGES + +- exported components Link and View have been renamed to be + include the _Router_ prefix and to have the same export name as their component + name + +# [4.0.0-alpha.7](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.6...v4.0.0-alpha.7) (2020-04-17) + +### Features + +- add `$route` and `$router` types ([a4f80aa](https://github.com/vuejs/vue-router-next/commit/a4f80aaaafb1bf29a3f4d992e8c6a2bec0f70d62)) +- add guards types ([c7ccd5a](https://github.com/vuejs/vue-router-next/commit/c7ccd5a0e67d88467fc661474308fbdf55b947ec)) +- refactor navigation to comply with vuejs/rfcs[#150](https://github.com/vuejs/vue-router-next/issues/150) ([290c3be](https://github.com/vuejs/vue-router-next/commit/290c3be1f6cb476016f23b77d6fc49987dd84751)) + +### BREAKING CHANGES + +- This follows the RFC at https://github.com/vuejs/rfcs/pull/150 + Summary: `router.afterEach` and `router.onError` are now the global equivalent of + `router.push`/`router.replace` as well as navigation through the interface + (`history.go()`). A navigation only rejects if there was an unexpected error. + A navigation failure will still resolve the promise returned by `router.push` + and be exposed as the resolved value. + +# [4.0.0-alpha.6](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.5...v4.0.0-alpha.6) (2020-04-17) + +### Bug Fixes + +- **history:** allow base with / and base tag ([d7c71b5](https://github.com/vuejs/vue-router-next/commit/d7c71b55ee4a11ecaf3a72f25eb126d118829d3f)), closes [#164](https://github.com/vuejs/vue-router-next/issues/164) +- **history:** allow hash history with no origin ([760d216](https://github.com/vuejs/vue-router-next/commit/760d21672051b6338d40f2cdfdac80dc16209e13)), closes [#163](https://github.com/vuejs/vue-router-next/issues/163) +- **scroll:** only apply on browser ([cf53192](https://github.com/vuejs/vue-router-next/commit/cf53192b77d619b1e43c8decda76d4083d9c17ea)) +- revert history navigation if navigation is cancelled ([d8a0d11](https://github.com/vuejs/vue-router-next/commit/d8a0d117dbede9b177f06c8ebab201d12dfca0c0)) + +### Code Refactoring + +- **router:** merge createHref into resolve ([66b2db9](https://github.com/vuejs/vue-router-next/commit/66b2db95b6b73433dc3abbe6c6f7f07959429d78)) + +### Features + +- add this.\$route ([92dc18d](https://github.com/vuejs/vue-router-next/commit/92dc18d448ffeb57d9b3f3b303b8ec2991175eb5)) +- add this.\$router ([1807f30](https://github.com/vuejs/vue-router-next/commit/1807f301053ac93db1e50991f67dcf532990d5c9)) +- **scroll:** handle scroll on popstate ([181efe9](https://github.com/vuejs/vue-router-next/commit/181efe9f29a200b03e2d8f4759e7854047936824)) +- merge meta fields ([72a052f](https://github.com/vuejs/vue-router-next/commit/72a052fdf4a198e3ac72779f1b7b8b80d0ac018d)) +- **guards:** support errors in navigation guards ([23ed08d](https://github.com/vuejs/vue-router-next/commit/23ed08d983f308b7b118f2a235e58d29bf1994ec)) +- **router:** hasRoute ([ca02444](https://github.com/vuejs/vue-router-next/commit/ca02444c91c8f6b21caf6a71dee5d0f2e3f7e51b)) + +### Reverts + +- Revert "test: only call browser.end on the last test" ([d3221f1](https://github.com/vuejs/vue-router-next/commit/d3221f16978186b09531f7ea0cb5b92b20147181)) + +### BREAKING CHANGES + +- **router:** createHref is removed from the router. Instead, resolve + returns a location object with the corresponding `href` property + +# [4.0.0-alpha.5](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.4...v4.0.0-alpha.5) (2020-04-08) + +### Bug Fixes + +- **link:** not active when matched is empty ([acd644d](https://github.com/vuejs/vue-router-next/commit/acd644db70793da7719b321b2dcdd537ec358f9c)) +- check query and hash when navigating ([3862ad9](https://github.com/vuejs/vue-router-next/commit/3862ad924bbc734a835577c3a3c71bc3550db29c)) +- ignore order of keys in query and params ([643bd15](https://github.com/vuejs/vue-router-next/commit/643bd15ceaf9d6314434b15b169171b599b58e1c)) +- skip initial guards with static redirect ([c76bb93](https://github.com/vuejs/vue-router-next/commit/c76bb938a2c9a1790be98b6ce44ccd153a342141)) +- **types:** add missing exported types ([ec241f7](https://github.com/vuejs/vue-router-next/commit/ec241f7a93107815d9ffd25d36cbf00b47cb7318)), closes [#147](https://github.com/vuejs/vue-router-next/issues/147) + +### Features + +- allow symbols as route record name ([f42ab3f](https://github.com/vuejs/vue-router-next/commit/f42ab3fecfaecddcef0ccf8bb0f7f44ca24d6160)) +- **link:** activeClass and exactActiveClass props ([d53b383](https://github.com/vuejs/vue-router-next/commit/d53b3832b50131cb83b8c567015780e60addb6c8)) +- **link:** allow `custom` prop ([874510b](https://github.com/vuejs/vue-router-next/commit/874510be69c3b068970e8a90ae251cf487d6acf9)) + +### BREAKING CHANGES + +- Renamed types by removing suffix Normalized and using Raw instead + - `RouteLocation` -> `RouteLocationRaw` + - `RouteLocationNormalized` -> `RouteLocation` + - `RouteLocationNormalized` is now a location that can be displayed (not a static redirect) + - `RouteLocationNormalizedResolved` -> `RouteLocationNormalizedLoaded` + - `RouteRecord` -> `RouteRecordRaw` + - `RouteRecordNormalized` -> `RouteRecord` + - `RouteRecordNormalized` is now a record that is not a static redirect + +# [4.0.0-alpha.4](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.3...v4.0.0-alpha.4) (2020-03-28) + +### Bug Fixes + +- **history:** use current history state when replacing ([5d80209](https://github.com/vuejs/vue-router-next/commit/5d802094923851102557bfb2583835cc135e16b8)) +- export more types ([1583d48](https://github.com/vuejs/vue-router-next/commit/1583d480fff2da1caa35c2dd7892c36b57dad734)), closes [#137](https://github.com/vuejs/vue-router-next/issues/137) +- **guards:** free instances only if navigation is confirmed ([d0514e1](https://github.com/vuejs/vue-router-next/commit/d0514e192839c54c4181f80286602e9d37459f4d)) +- **hash:** fix base position for hash routing ([ba40b8f](https://github.com/vuejs/vue-router-next/commit/ba40b8f0cf2d6d85533e0e7e7daaadd088298f19)) +- initial location with base ([d05208b](https://github.com/vuejs/vue-router-next/commit/d05208b6c9457931bda8205ba6d9f1d5e39a54c7)) +- **router:** prevent duplicated navigation on aliases ([e825586](https://github.com/vuejs/vue-router-next/commit/e82558684c0b6b688065032df65604b2c245d395)) + +### Features + +- allow passing state to history ([ac1c96f](https://github.com/vuejs/vue-router-next/commit/ac1c96f176dcad8aac03a86a1dccfbaab4b66520)) +- improve route access ([baf266c](https://github.com/vuejs/vue-router-next/commit/baf266cd1bd6cafd32d244f185e340bee10af32c)) +- **history:** expose state on html5 ([3f83607](https://github.com/vuejs/vue-router-next/commit/3f83607c8798960f49cdb5eed8fdfe8adc52fabf)) +- **matcher:** remove aliases alongside the original record ([26b71b2](https://github.com/vuejs/vue-router-next/commit/26b71b285b743ab8af94b9297fa7037872ae0de6)) +- **router:** support custom parseQuery and stringifyQuery ([#136](https://github.com/vuejs/vue-router-next/issues/136)) ([5dce7bc](https://github.com/vuejs/vue-router-next/commit/5dce7bcbfbb4a80bd1edbe061a250fa646f2afd7)) +- **view:** add props option as boolean ([7fe1e7d](https://github.com/vuejs/vue-router-next/commit/7fe1e7dc7406bddd0924bf7f01709b9113582472)) +- **view:** allow passing props as a function ([494fc5e](https://github.com/vuejs/vue-router-next/commit/494fc5efb6add93c68ed467bb9a8dc7b3b149fff)) +- **view:** useView to customize router-view ([06b0c34](https://github.com/vuejs/vue-router-next/commit/06b0c34ee5018aa9d76c0bfcd32ff2c12cd94277)) +- allow true in `next` ([d76c6aa](https://github.com/vuejs/vue-router-next/commit/d76c6aae115110e2d9c4c072748bd9403080c8bd)) +- invoke guards with the right context ([7053413](https://github.com/vuejs/vue-router-next/commit/7053413c93bc715d5c2179378367dc12f60a118d)) +- lazy loading ([6ecdc70](https://github.com/vuejs/vue-router-next/commit/6ecdc70baa6361b8614368196ff2652560b6a0ba)) +- **view:** allow props as object in record ([fd4dc06](https://github.com/vuejs/vue-router-next/commit/fd4dc0630bdf856f972ed6e9020b70a70ac582b4)) + +### BREAKING CHANGES + +- `useRoute` now retrieves a reactive RouteLocationNormalized instead of a Ref. + This means there is no need to use `.value` when accessing the route. You still need to wrap it with `toRefs` if you want to expose parts of the route: + ```js + setup () { + return { params: toRefs(useRoute()).params } + } + ``` + +# [4.0.0-alpha.3](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.2...v4.0.0-alpha.3) (2020-03-14) + +### Bug Fixes + +- add missing type definitions + +# [4.0.0-alpha.2](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.1...v4.0.0-alpha.2) (2020-03-14) + +### Bug Fixes + +- **history:** correct url when replacing current location ([704b45e](https://github.com/vuejs/vue-router-next/commit/704b45ea52b10099a765c93ced37d03393a72d17)) +- **link:** allow attrs to override behavior ([4cae9db](https://github.com/vuejs/vue-router-next/commit/4cae9dbede993a79577691e1df4444a8fe5ca3a0)) +- **link:** allow custom classes ([#134](https://github.com/vuejs/vue-router-next/issues/134)) ([392c295](https://github.com/vuejs/vue-router-next/commit/392c295552e5b7dbe1d494c1c3168571e3339153)), closes [#133](https://github.com/vuejs/vue-router-next/issues/133) +- **link:** navigate to the alias path ([3284110](https://github.com/vuejs/vue-router-next/commit/328411079e1aa8a5dc3903ae76a55d634946d9fd)) +- **link:** non active repeatable params ([0ccbc1e](https://github.com/vuejs/vue-router-next/commit/0ccbc1e9af07a30a149ab14c007f63cbc35a8126)) + +### Features + +- add aliasOf to normalized records ([d9f3174](https://github.com/vuejs/vue-router-next/commit/d9f31748802c39572254691108b0667cfd40e911)) +- handle active/exact in Link ([6f49dce](https://github.com/vuejs/vue-router-next/commit/6f49dcea35a63785ae08d08787913ab8391cae67)) +- **matcher:** link aliases to their original record ([e9eb648](https://github.com/vuejs/vue-router-next/commit/e9eb6481e21de61080a96f66fbd8640157d0fd27)) + +# [4.0.0-alpha.1](https://github.com/vuejs/vue-router-next/compare/v4.0.0-alpha.0...v4.0.0-alpha.1) (2020-02-26) + +### Code Refactoring + +- rename createHistory and createHashHistory ([7dbebb6](https://github.com/vuejs/vue-router-next/commit/7dbebb6e2d75ab4aa77019712f2ed251ad62464f)) + +### Features + +- add dynamic routing at router level ([a7943c6](https://github.com/vuejs/vue-router-next/commit/a7943c64383bced7ff90ae92c0498827acdb71f6)) + +### BREAKING CHANGES + +- `createHistory` is now named `createWebHistory`. + `createHashHistory` is now named `createWebHashHistory`. + + Both createHistory and createHashHistory are renamed to + better reflect that they must be used in a browser environment while + createMemoryHistory doesn't. + +# [4.0.0-alpha.0](https://github.com/vuejs/vue-router-next/compare/v0.0.11...v4.0.0-alpha.0) (2020-02-26) + +## Known issues + +### Breaking changes compared to vue-router@3.x + +- `mode: 'history'` -> `history: createHistory()` +- Catch all routes (`/*`) must now be defined using a parameter with a custom regex: `/:catchAll(.*)` + +### Missing features + +- `keep-alive` is not yet supported +- Partial support of per-component navigation guards. No `beforeRouteEnter` yet diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..2d297f23e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Eduardo San Martin Morote + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..db0ad8a58 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# vue-router-next [![release candidate](https://img.shields.io/npm/v/vue-router/next.svg)](https://www.npmjs.com/package/vue-router/v/next) [![CircleCI](https://circleci.com/gh/vuejs/vue-router-next.svg?style=svg)](https://circleci.com/gh/vuejs/vue-router-next) + +This is the repository for Vue Router 4 (for Vue 3) + +## Quickstart + +- Via CDN: `` +- In-browser playground on [CodeSandbox](https://codesandbox.io/s/vue-router-4-reproduction-hb9lh) +- Add it to an existing Vue Project: + ```bash + npm install vue-router@4 + ``` + +## Changes from Vue Router 3 + +Please consult the [Migration Guide](https://next.router.vuejs.org/guide/migration/). + +## Contributing + +See [Contributing Guide](https://github.com/vuejs/vue-router-next/blob/master/.github/contributing.md). + +## Special Thanks + + + BrowserStack Logo + + +Special thanks to [BrowserStack](https://www.browserstack.com) for letting the maintainers use their service to debug browser specific issues. diff --git a/__tests__/RouterLink.spec.ts b/__tests__/RouterLink.spec.ts new file mode 100644 index 000000000..c2af1951a --- /dev/null +++ b/__tests__/RouterLink.spec.ts @@ -0,0 +1,954 @@ +/** + * @jest-environment jsdom + */ +import { RouterLink, RouterLinkProps } from '../src/RouterLink' +import { + START_LOCATION_NORMALIZED, + RouteQueryAndHash, + MatcherLocationRaw, + RouteLocationNormalized, +} from '../src/types' +import { createMemoryHistory, RouterOptions } from '../src' +import { mount, createMockedRoute } from './mount' +import { defineComponent, nextTick, PropType } from 'vue' +import { RouteRecordNormalized } from '../src/matcher/types' +import { routerKey } from '../src/injectionSymbols' +import { tick } from './utils' + +const records = { + home: {} as RouteRecordNormalized, + homeAlias: {} as RouteRecordNormalized, + foo: {} as RouteRecordNormalized, + parent: {} as RouteRecordNormalized, + childEmpty: {} as RouteRecordNormalized, + childEmptyAlias: {} as RouteRecordNormalized, + child: {} as RouteRecordNormalized, + childChild: {} as RouteRecordNormalized, + parentAlias: {} as RouteRecordNormalized, + childAlias: {} as RouteRecordNormalized, +} + +// fix the aliasOf +records.homeAlias = { aliasOf: records.home } as RouteRecordNormalized +records.parentAlias = { + aliasOf: records.parent, +} as RouteRecordNormalized +records.childAlias = { aliasOf: records.child } as RouteRecordNormalized +records.childEmptyAlias.aliasOf = records.childEmpty + +type RouteLocationResolved = RouteLocationNormalized & { href: string } + +function createLocations< + T extends Record< + string, + { + string: string + normalized: RouteLocationResolved + toResolve?: MatcherLocationRaw & Required + } + > +>(locs: T) { + return locs +} + +const locations = createLocations({ + basic: { + string: '/home', + // toResolve: { path: '/home', fullPath: '/home', undefined, query: {}, hash: '' }, + normalized: { + href: '/home', + fullPath: '/home', + path: '/home', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.home], + redirectedFrom: undefined, + name: 'home', + }, + }, + foo: { + string: '/foo', + // toResolve: { path: '/home', fullPath: '/home', undefined, query: {}, hash: '' }, + normalized: { + fullPath: '/foo', + href: '/foo', + path: '/foo', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.foo], + redirectedFrom: undefined, + name: undefined, + }, + }, + withQuery: { + string: '/home?foo=a&bar=b', + // toResolve: { path: '/home', fullPath: '/home', undefined, query: {}, hash: '' }, + normalized: { + fullPath: '/home?foo=a&bar=b', + href: '/home?foo=a&bar=b', + path: '/home', + params: {}, + meta: {}, + query: { foo: 'a', bar: 'b' }, + hash: '', + matched: [records.home], + redirectedFrom: undefined, + name: undefined, + }, + }, + singleStringParams: { + string: '/p/1', + normalized: { + fullPath: '/p/1', + href: '/p/1', + path: '/p/1', + params: { p: '1' }, + meta: {}, + query: {}, + hash: '', + matched: [records.home], + redirectedFrom: undefined, + name: undefined, + }, + }, + repeatedParams2: { + string: '/p/1/2', + normalized: { + fullPath: '/p/1/2', + href: '/p/1/2', + path: '/p/1/2', + params: { p: ['1', '2'] }, + meta: {}, + query: {}, + hash: '', + matched: [records.home], + redirectedFrom: undefined, + name: undefined, + }, + }, + anotherRepeatedParams2: { + string: '/p/1/3', + normalized: { + fullPath: '/p/1/3', + href: '/p/1/3', + path: '/p/1/3', + params: { p: ['1', '3'] }, + meta: {}, + query: {}, + hash: '', + matched: [records.home], + redirectedFrom: undefined, + name: undefined, + }, + }, + repeatedParams3: { + string: '/p/1/2/3', + normalized: { + fullPath: '/p/1/2/3', + href: '/p/1/2/3', + path: '/p/1/2/3', + params: { p: ['1', '2', '3'] }, + meta: {}, + query: {}, + hash: '', + matched: [records.home], + redirectedFrom: undefined, + name: undefined, + }, + }, + alias: { + string: '/alias', + normalized: { + fullPath: '/alias', + href: '/alias', + path: '/alias', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.homeAlias], + redirectedFrom: undefined, + name: 'home', + }, + }, + + // nested routes + parent: { + string: '/parent', + normalized: { + fullPath: '/parent', + href: '/parent', + path: '/parent', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent], + redirectedFrom: undefined, + name: undefined, + }, + }, + parentAlias: { + string: '/p', + normalized: { + fullPath: '/p', + href: '/p', + path: '/p', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parentAlias], + redirectedFrom: undefined, + name: undefined, + }, + }, + + childEmpty: { + string: '/parent', + normalized: { + fullPath: '/parent', + href: '/parent', + path: '/parent', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent, records.childEmpty], + redirectedFrom: undefined, + name: undefined, + }, + }, + childEmptyAlias: { + string: '/parent/alias', + normalized: { + fullPath: '/parent/alias', + href: '/parent/alias', + path: '/parent/alias', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent, records.childEmptyAlias], + redirectedFrom: undefined, + name: undefined, + }, + }, + child: { + string: '/parent/child', + normalized: { + fullPath: '/parent/child', + href: '/parent/child', + path: '/parent/child', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent, records.child], + redirectedFrom: undefined, + name: undefined, + }, + }, + childChild: { + string: '/parent/child/child', + normalized: { + fullPath: '/parent/child/child', + href: '/parent/child/child', + path: '/parent/child/child', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent, records.child, records.childChild], + redirectedFrom: undefined, + name: undefined, + }, + }, + childAsAbsolute: { + string: '/absolute-child', + normalized: { + fullPath: '/absolute-child', + href: '/absolute-child', + path: '/absolute-child', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent, records.child], + redirectedFrom: undefined, + name: undefined, + }, + }, + childParentAlias: { + string: '/p/child', + normalized: { + fullPath: '/p/child', + href: '/p/child', + path: '/p/child', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parentAlias, records.child], + redirectedFrom: undefined, + name: undefined, + }, + }, + childAlias: { + string: '/parent/c', + normalized: { + fullPath: '/parent/c', + href: '/parent/c', + path: '/parent/c', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parent, records.childAlias], + redirectedFrom: undefined, + name: undefined, + }, + }, + childDoubleAlias: { + string: '/p/c', + normalized: { + fullPath: '/p/c', + href: '/p/c', + path: '/p/c', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [records.parentAlias, records.childAlias], + redirectedFrom: undefined, + name: undefined, + }, + }, + notFound: { + string: '/not-found', + normalized: { + fullPath: '/not-found', + href: '/not-found', + path: '/not-found', + params: {}, + meta: {}, + query: {}, + hash: '', + matched: [], + redirectedFrom: undefined, + name: undefined, + }, + }, +}) + +// add paths to records because they are used to check isActive +for (let record in records) { + let location = locations[record as keyof typeof locations] + if (location) { + records[record as keyof typeof records].path = location.normalized.path + } +} + +async function factory( + currentLocation: RouteLocationNormalized, + propsData: any, + resolvedLocation: RouteLocationResolved, + slotTemplate: string = '' +) { + const route = createMockedRoute(currentLocation) + const router = { + history: createMemoryHistory(), + createHref(to: RouteLocationNormalized): string { + return this.history.base + to.fullPath + }, + options: {} as Partial, + resolve: jest.fn(), + push: jest.fn().mockResolvedValue(resolvedLocation), + } + router.resolve.mockReturnValueOnce(resolvedLocation) + + const wrapper = await mount(RouterLink, { + propsData, + provide: { + [routerKey as any]: router, + ...route.provides, + }, + slots: { default: slotTemplate }, + }) + + return { router, wrapper, route } +} + +describe('RouterLink', () => { + it('displays a link with a string prop', async () => { + const { wrapper } = await factory( + START_LOCATION_NORMALIZED, + { to: locations.basic.string }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.getAttribute('href')).toBe('/home') + }) + + it('can change the value', async () => { + const { wrapper, router } = await factory( + START_LOCATION_NORMALIZED, + { to: locations.basic.string }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.getAttribute('href')).toBe('/home') + router.resolve.mockReturnValueOnce(locations.foo.normalized) + await wrapper.setProps({ to: locations.foo.string }) + expect(wrapper.find('a')!.getAttribute('href')).toBe('/foo') + }) + + it('displays a link with an object with path prop', async () => { + const { wrapper } = await factory( + START_LOCATION_NORMALIZED, + { to: { path: locations.basic.string } }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.getAttribute('href')).toBe('/home') + }) + + it('can be active', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + }) + + it('sets aria-current to page by default when exact active', async () => { + const { wrapper, route } = await factory( + locations.parent.normalized, + { to: locations.parent.string }, + locations.parent.normalized + ) + expect(wrapper.find('a')!.getAttribute('aria-current')).toBe('page') + route.set(locations.child.normalized) + await tick() + expect(wrapper.find('a')!.getAttribute('aria-current')).not.toBe('page') + }) + + it('can customize aria-current value', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string, ariaCurrentValue: 'time' }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.getAttribute('aria-current')).toBe('time') + }) + + it('can customize active class', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string, activeClass: 'is-active' }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.className).not.toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('is-active') + }) + + it('prop classes take over global', async () => { + const { wrapper, router } = await factory( + locations.basic.normalized, + // wrong location to set it later + { + to: locations.foo.string, + activeClass: 'is-active', + exactActiveClass: 'is-exact', + }, + locations.foo.normalized + ) + router.options.linkActiveClass = 'custom' + router.options.linkExactActiveClass = 'custom-exact' + // force render because options is not reactive + router.resolve.mockReturnValueOnce(locations.basic.normalized) + await wrapper.setProps({ to: locations.basic.string }) + expect(wrapper.find('a')!.className).not.toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + expect(wrapper.find('a')!.className).not.toContain('custom') + expect(wrapper.find('a')!.className).not.toContain('custom-exact') + expect(wrapper.find('a')!.className).toContain('is-active') + expect(wrapper.find('a')!.className).toContain('is-exact') + }) + + it('can globally customize active class', async () => { + const { wrapper, router } = await factory( + locations.basic.normalized, + // wrong location to set it later + { to: locations.foo.string }, + locations.foo.normalized + ) + router.options.linkActiveClass = 'custom' + // force render because options is not reactive + router.resolve.mockReturnValueOnce(locations.basic.normalized) + await wrapper.setProps({ to: locations.basic.string }) + expect(wrapper.find('a')!.className).not.toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('custom') + }) + + it('can globally customize exact active class', async () => { + const { wrapper, router } = await factory( + locations.basic.normalized, + // wrong location to set it later + { to: locations.foo.string }, + locations.foo.normalized + ) + router.options.linkExactActiveClass = 'custom' + // force render because options is not reactive + router.resolve.mockReturnValueOnce(locations.basic.normalized) + await wrapper.setProps({ to: locations.basic.string }) + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + expect(wrapper.find('a')!.className).toContain('custom') + }) + + it('can customize exact active class', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string, exactActiveClass: 'is-active' }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + expect(wrapper.find('a')!.className).toContain('is-active') + }) + + it('can be active with custom class', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string, class: 'nav-item' }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('nav-item') + }) + + it('is not active on a non matched location', async () => { + const { wrapper } = await factory( + locations.notFound.normalized, + { to: locations.basic.string }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.className).toBe('') + }) + + it('is not active with different params type', async () => { + const { wrapper } = await factory( + locations.repeatedParams2.normalized, + { to: locations.singleStringParams.string }, + locations.singleStringParams.normalized + ) + expect(wrapper.find('a')!.className).toBe('') + }) + + it('is not active with different repeated params', async () => { + const { wrapper } = await factory( + locations.repeatedParams2.normalized, + { to: locations.anotherRepeatedParams2.string }, + locations.anotherRepeatedParams2.normalized + ) + expect(wrapper.find('a')!.className).toBe('') + }) + + it('is not active with more repeated params', async () => { + const { wrapper } = await factory( + locations.repeatedParams2.normalized, + { to: locations.repeatedParams3.string }, + locations.repeatedParams3.normalized + ) + expect(wrapper.find('a')!.className).toBe('') + }) + + it('is not active with partial repeated params', async () => { + const { wrapper } = await factory( + locations.repeatedParams3.normalized, + { to: locations.repeatedParams2.string }, + locations.repeatedParams2.normalized + ) + expect(wrapper.find('a')!.className).toBe('') + }) + + it('can be active as an alias', async () => { + let { wrapper } = await factory( + locations.basic.normalized, + { to: locations.alias.string }, + locations.alias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + wrapper = ( + await factory( + locations.alias.normalized, + { to: locations.basic.string }, + locations.basic.normalized + ) + ).wrapper + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + }) + + it('is active when a child is active', async () => { + const { wrapper } = await factory( + locations.child.normalized, + { to: locations.parent.string }, + locations.parent.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('only the children is exact-active', async () => { + const { wrapper } = await factory( + locations.child.normalized, + { to: locations.child.string }, + locations.child.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + }) + + it('child is not active if the parent is active', async () => { + const { wrapper } = await factory( + locations.parent.normalized, + { to: locations.child.string }, + locations.child.normalized + ) + expect(wrapper.find('a')!.className).not.toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('parent is active if the child is an absolute path', async () => { + const { wrapper } = await factory( + locations.childAsAbsolute.normalized, + { to: locations.parent.string }, + locations.parent.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('empty path child is active as if it was the parent when on adjacent child', async () => { + const { wrapper } = await factory( + locations.child.normalized, + { to: locations.childEmpty.string }, + locations.childEmpty.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('alias of empty path child is active as if it was the parent when on adjacent child', async () => { + const { wrapper } = await factory( + locations.child.normalized, + { to: locations.childEmptyAlias.string }, + locations.childEmptyAlias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('empty path child is active as if it was the parent when on adjacent nested child', async () => { + const { wrapper } = await factory( + locations.childChild.normalized, + { to: locations.childEmpty.string }, + locations.childEmpty.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('alias of empty path child is active as if it was the parent when on adjacent nested nested child', async () => { + const { wrapper } = await factory( + locations.childChild.normalized, + { to: locations.childEmptyAlias.string }, + locations.childEmptyAlias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('alias parent is active if the child is an absolute path', async () => { + const { wrapper } = await factory( + locations.childAsAbsolute.normalized, + { to: locations.parentAlias.string }, + locations.parentAlias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('alias parent is active when a child is active', async () => { + let { wrapper } = await factory( + locations.child.normalized, + { to: locations.parentAlias.string }, + locations.parentAlias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + wrapper = ( + await factory( + locations.childDoubleAlias.normalized, + { to: locations.parentAlias.string }, + locations.parentAlias.normalized + ) + ).wrapper + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).not.toContain( + 'router-link-exact-active' + ) + }) + + it('alias parent is active', async () => { + let { wrapper } = await factory( + locations.parent.normalized, + { to: locations.parentAlias.string }, + locations.parentAlias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + + wrapper = ( + await factory( + locations.parentAlias.normalized, + { to: locations.parent.string }, + locations.parent.normalized + ) + ).wrapper + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + }) + + it('child and parent with alias', async () => { + let { wrapper } = await factory( + locations.child.normalized, + { to: locations.childDoubleAlias.string }, + locations.childDoubleAlias.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + + wrapper = ( + await factory( + locations.child.normalized, + { to: locations.childParentAlias.string }, + locations.childParentAlias.normalized + ) + ).wrapper + expect(wrapper.find('a')!.className).toContain('router-link-active') + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + }) + + it('can be exact-active', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string }, + locations.basic.normalized + ) + expect(wrapper.find('a')!.className).toContain('router-link-exact-active') + }) + + it('calls ensureLocation', async () => { + const { router } = await factory( + START_LOCATION_NORMALIZED, + { to: locations.basic.string }, + locations.basic.normalized + ) + expect(router.resolve).toHaveBeenCalledTimes(1) + expect(router.resolve).toHaveBeenCalledWith(locations.basic.string) + }) + + it('calls router.push when clicked', async () => { + const { router, wrapper } = await factory( + START_LOCATION_NORMALIZED, + { to: locations.basic.string }, + locations.basic.normalized + ) + wrapper.find('a')!.click() + await nextTick() + expect(router.push).toHaveBeenCalledTimes(1) + }) + + it('calls router.push with the correct location for aliases', async () => { + const { router, wrapper } = await factory( + START_LOCATION_NORMALIZED, + { to: locations.alias.string }, + locations.alias.normalized + ) + wrapper.find('a')!.click() + await nextTick() + expect(router.push).toHaveBeenCalledTimes(1) + expect(router.push).not.toHaveBeenCalledWith( + expect.objectContaining({ + // this is the original name but if we push with this location, we will + // not have the alias on the url + name: 'home', + }) + ) + }) + + describe('v-slot', () => { + const slotTemplate = ` + + route: {{ JSON.stringify(route) }} + href: "{{ href }}" + isActive: "{{ isActive }}" + isExactActive: "{{ isExactActive }}" + + ` + + it('provides information on v-slot', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string }, + locations.basic.normalized, + slotTemplate + ) + + expect(wrapper.html()).toMatchSnapshot() + }) + + it('renders an anchor by default', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string }, + locations.basic.normalized, + slotTemplate + ) + + expect(wrapper.rootEl.children[0].tagName).toBe('A') + expect(wrapper.rootEl.children).toHaveLength(1) + }) + + it('can customize the rendering and remove the wrapping `a`', async () => { + const { wrapper } = await factory( + locations.basic.normalized, + { to: locations.basic.string, custom: true }, + locations.basic.normalized, + slotTemplate + ) + + expect(wrapper.html()).not.toContain('') + }) + + describe('Extending RouterLink', () => { + const AppLink = defineComponent({ + template: ` + + + + + + + + + `, + components: { RouterLink }, + name: 'AppLink', + + // @ts-ignore + props: { + ...((RouterLink as any).props as RouterLinkProps), + inactiveClass: String as PropType, + }, + + computed: { + isExternalLink(): boolean { + // @ts-ignore + return typeof this.to === 'string' && this.to.startsWith('http') + }, + }, + }) + + async function factoryCustom( + currentLocation: RouteLocationNormalized, + propsData: any, + resolvedLocation: RouteLocationResolved, + slotTemplate: string = '' + ) { + const route = createMockedRoute(currentLocation) + const router = { + history: createMemoryHistory(), + createHref(to: RouteLocationNormalized): string { + return this.history.base + to.fullPath + }, + options: {} as Partial, + resolve: jest.fn(), + push: jest.fn().mockResolvedValue(resolvedLocation), + } + router.resolve.mockReturnValueOnce(resolvedLocation) + + const wrapper = await mount(AppLink, { + propsData, + provide: { + [routerKey as any]: router, + ...route.provides, + }, + slots: { default: slotTemplate }, + }) + + return { router, wrapper, route } + } + + it('can extend RouterLink with inactive class', async () => { + const { wrapper } = await factoryCustom( + locations.basic.normalized, + { + to: locations.basic.string, + inactiveClass: 'inactive', + activeClass: 'active', + }, + locations.foo.normalized + ) + + expect(wrapper.find('a')!.className).toEqual('inactive') + }) + + it('can extend RouterLink with external link', async () => { + const { wrapper } = await factoryCustom( + locations.basic.normalized, + { + to: 'https://esm.dev', + }, + locations.foo.normalized + ) + + expect(wrapper.find('a')!.className).toEqual('') + expect(wrapper.find('a')!.href).toEqual('https://esm.dev/') + }) + }) + }) +}) diff --git a/__tests__/RouterView.spec.ts b/__tests__/RouterView.spec.ts new file mode 100644 index 000000000..1803d7e92 --- /dev/null +++ b/__tests__/RouterView.spec.ts @@ -0,0 +1,442 @@ +/** + * @jest-environment jsdom + */ +import { RouterView } from '../src/RouterView' +import { components, RouteLocationNormalizedLoose } from './utils' +import { + START_LOCATION_NORMALIZED, + RouteLocationNormalized, +} from '../src/types' +import { markRaw } from 'vue' +import { mount, createMockedRoute } from './mount' +import { mockWarn } from 'jest-mock-warn' + +// to have autocompletion +function createRoutes>( + routes: T +): T { + let nonReactiveRoutes: T = {} as T + + for (let key in routes) { + nonReactiveRoutes[key] = markRaw(routes[key]) + } + + return nonReactiveRoutes +} + +const props = { default: false } + +const routes = createRoutes({ + root: { + fullPath: '/', + name: 'home', + path: '/', + query: {}, + params: {}, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.Home }, + instances: {}, + enterCallbacks: {}, + path: '/', + props, + }, + ], + }, + foo: { + fullPath: '/foo', + name: undefined, + path: '/foo', + query: {}, + params: {}, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.Foo }, + instances: {}, + enterCallbacks: {}, + path: '/foo', + props, + }, + ], + }, + nested: { + fullPath: '/a', + name: undefined, + path: '/a', + query: {}, + params: {}, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.Nested }, + instances: {}, + enterCallbacks: {}, + path: '/', + props, + }, + { + components: { default: components.Foo }, + instances: {}, + enterCallbacks: {}, + path: 'a', + props, + }, + ], + }, + nestedNested: { + fullPath: '/a/b', + name: undefined, + path: '/a/b', + query: {}, + params: {}, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.Nested }, + instances: {}, + enterCallbacks: {}, + path: '/', + props, + }, + { + components: { default: components.Nested }, + instances: {}, + enterCallbacks: {}, + path: 'a', + props, + }, + { + components: { default: components.Foo }, + instances: {}, + enterCallbacks: {}, + path: 'b', + props, + }, + ], + }, + named: { + fullPath: '/', + name: undefined, + path: '/', + query: {}, + params: {}, + hash: '', + meta: {}, + matched: [ + { + components: { foo: components.Foo }, + instances: {}, + enterCallbacks: {}, + path: '/', + props, + }, + ], + }, + withParams: { + fullPath: '/users/1', + name: undefined, + path: '/users/1', + query: {}, + params: { id: '1' }, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.User }, + + instances: {}, + enterCallbacks: {}, + path: '/users/:id', + props: { default: true }, + }, + ], + }, + withIdAndOther: { + fullPath: '/props/1', + name: undefined, + path: '/props/1', + query: {}, + params: { id: '1' }, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.WithProps }, + + instances: {}, + enterCallbacks: {}, + path: '/props/:id', + props: { default: { id: 'foo', other: 'fixed' } }, + }, + ], + }, + + withFnProps: { + fullPath: '/props/1', + name: undefined, + path: '/props/1', + query: { q: 'page' }, + params: { id: '1' }, + hash: '', + meta: {}, + matched: [ + { + components: { default: components.WithProps }, + + instances: {}, + enterCallbacks: {}, + path: '/props/:id', + props: { + default: (to: RouteLocationNormalized) => ({ + id: Number(to.params.id) * 2, + other: to.query.q, + }), + }, + }, + ], + }, +}) + +describe('RouterView', () => { + mockWarn() + + async function factory( + initialRoute: RouteLocationNormalizedLoose, + propsData: any = {} + ) { + const route = createMockedRoute(initialRoute) + const wrapper = await mount(RouterView, { + propsData, + provide: route.provides, + components: { RouterView }, + }) + + return { route, wrapper } + } + + it('displays current route component', async () => { + const { wrapper } = await factory(routes.root) + expect(wrapper.html()).toBe(`
Home
`) + }) + + it('displays named views', async () => { + const { wrapper } = await factory(routes.named, { name: 'foo' }) + expect(wrapper.html()).toBe(`
Foo
`) + }) + + it('displays nothing when route is unmatched', async () => { + const { wrapper } = await factory(START_LOCATION_NORMALIZED as any) + // NOTE: I wonder if this will stay stable in future releases + expect('Router').not.toHaveBeenWarned() + expect(wrapper.rootEl.childElementCount).toBe(0) + }) + + it('displays nested views', async () => { + const { wrapper } = await factory(routes.nested) + expect(wrapper.html()).toBe(`

Nested

Foo
`) + }) + + it('displays deeply nested views', async () => { + const { wrapper } = await factory(routes.nestedNested) + expect(wrapper.html()).toBe( + `

Nested

Nested

Foo
` + ) + }) + + it('renders when the location changes', async () => { + const { route, wrapper } = await factory(routes.root) + expect(wrapper.html()).toBe(`
Home
`) + await route.set(routes.foo) + expect(wrapper.html()).toBe(`
Foo
`) + }) + + it('does not pass params as props by default', async () => { + let noPropsWithParams = { + ...routes.withParams, + matched: [ + { + components: { default: components.User }, + instances: {}, + enterCallbacks: {}, + path: '/users/:id', + props, + }, + ], + } + const { wrapper, route } = await factory(noPropsWithParams) + expect(wrapper.html()).toBe(`
User: default
`) + await route.set({ + ...noPropsWithParams, + params: { id: '4' }, + }) + expect(wrapper.html()).toBe(`
User: default
`) + }) + + it('passes params as props with props: true', async () => { + const { wrapper, route } = await factory(routes.withParams) + expect(wrapper.html()).toBe(`
User: 1
`) + await route.set({ + ...routes.withParams, + params: { id: '4' }, + }) + expect(wrapper.html()).toBe(`
User: 4
`) + }) + + it('can pass an object as props', async () => { + const { wrapper } = await factory(routes.withIdAndOther) + expect(wrapper.html()).toBe(`
id:foo;other:fixed
`) + }) + + it('can pass a function as props', async () => { + const { wrapper } = await factory(routes.withFnProps) + expect(wrapper.html()).toBe(`
id:2;other:page
`) + }) + + describe('warnings', () => { + it('does not warn RouterView is wrapped', async () => { + const route = createMockedRoute(routes.root) + const wrapper = await mount( + { + template: ` +
+ +
+ `, + }, + { + propsData: {}, + provide: route.provides, + components: { RouterView }, + } + ) + expect(wrapper.html()).toBe(`
Home
`) + expect('can no longer be used directly inside').not.toHaveBeenWarned() + }) + + it('warns if KeepAlive wraps a RouterView', async () => { + const route = createMockedRoute(routes.root) + const wrapper = await mount( + { + template: ` + + + + `, + }, + { + propsData: {}, + provide: route.provides, + components: { RouterView }, + } + ) + expect(wrapper.html()).toBe(`
Home
`) + expect('can no longer be used directly inside').toHaveBeenWarned() + }) + + it('warns if KeepAlive and Transition wrap a RouterView', async () => { + const route = createMockedRoute(routes.root) + const wrapper = await mount( + { + template: ` + + + + + + `, + }, + { + propsData: {}, + provide: route.provides, + components: { RouterView }, + } + ) + expect(wrapper.html()).toBe(`
Home
`) + expect('can no longer be used directly inside').toHaveBeenWarned() + }) + + it('warns if Transition wraps a RouterView', async () => { + const route = createMockedRoute(routes.root) + const wrapper = await mount( + { + template: ` + + + + `, + }, + { + propsData: {}, + provide: route.provides, + components: { RouterView }, + } + ) + expect(wrapper.html()).toBe(`
Home
`) + expect('can no longer be used directly inside').toHaveBeenWarned() + }) + }) + + describe('v-slot', () => { + async function factory( + initialRoute: RouteLocationNormalizedLoose, + propsData: any = {} + ) { + const route = createMockedRoute(initialRoute) + const wrapper = await mount(RouterView, { + propsData, + provide: route.provides, + components: { RouterView }, + slots: { + default: ` + {{ route.name }} + + `, + }, + }) + + return { route, wrapper } + } + + it('passes a Component and route', async () => { + const { wrapper } = await factory(routes.root) + expect(wrapper.html()).toBe(`home
Home
`) + }) + }) + + describe('KeepAlive', () => { + async function factory( + initialRoute: RouteLocationNormalizedLoose, + propsData: any = {} + ) { + const route = createMockedRoute(initialRoute) + const wrapper = await mount(RouterView, { + propsData, + provide: route.provides, + components: { RouterView }, + slots: { + default: ` + + + + `, + }, + }) + + return { route, wrapper } + } + + // TODO: maybe migrating to VTU 2 to handle this properly + it.skip('works', async () => { + const { route, wrapper } = await factory(routes.root) + expect(wrapper.html()).toMatchInlineSnapshot(`"
Home
"`) + await route.set(routes.foo) + expect(wrapper.html()).toMatchInlineSnapshot(`"
Foo
"`) + }) + }) +}) diff --git a/__tests__/__snapshots__/RouterLink.spec.ts.snap b/__tests__/__snapshots__/RouterLink.spec.ts.snap new file mode 100644 index 000000000..360a57a37 --- /dev/null +++ b/__tests__/__snapshots__/RouterLink.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RouterLink v-slot provides information on v-slot 1`] = `" route: {\\"href\\":\\"/home\\",\\"fullPath\\":\\"/home\\",\\"path\\":\\"/home\\",\\"params\\":{},\\"meta\\":{},\\"query\\":{},\\"hash\\":\\"\\",\\"matched\\":[{}],\\"name\\":\\"home\\"} href: \\"/home\\" isActive: \\"true\\" isExactActive: \\"true\\" "`; diff --git a/__tests__/encoding.spec.ts b/__tests__/encoding.spec.ts new file mode 100644 index 000000000..d299ac3a8 --- /dev/null +++ b/__tests__/encoding.spec.ts @@ -0,0 +1,146 @@ +import { + encodeHash, + encodeParam, + encodeQueryKey, + encodeQueryValue, + // decode, +} from '../src/encoding' + +describe('Encoding', () => { + // all ascii chars with a non ascii char at the beginning + // let allChars = '' + // for (let i = 32; i < 127; i++) allChars += String.fromCharCode(i) + + // per RFC 3986 (2005), strictest safe set + const unreservedSet = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~' + // Other safePerSpec sets are defined by following the URL Living standard https://url.spec.whatwg.org without chars from unreservedSet + + let nonPrintableASCII = '' + let encodedNonPrintableASCII = '' + for (let i = 0; i < 32; i++) { + nonPrintableASCII += String.fromCharCode(i) + const hex = i.toString(16).toUpperCase() + encodedNonPrintableASCII += '%' + (hex.length > 1 ? hex : '0' + hex) + } + + describe('params', () => { + // excludes ^ and ` even though they are safe per spec because all browsers encode it when manually entered + const safePerSpec = "!$&'()*+,:;=@[]_|" + const toEncode = ' "<>#?{}/^`' + const encodedToEncode = toEncode + .split('') + .map(c => { + const hex = c.charCodeAt(0).toString(16).toUpperCase() + return '%' + (hex.length > 1 ? hex : '0' + hex) + }) + .join('') + + it('does not encode safe chars', () => { + expect(encodeParam(unreservedSet)).toBe(unreservedSet) + }) + + it('encodes non-ascii', () => { + expect(encodeParam('é')).toBe('%C3%A9') + }) + + it('encodes non-printable ascii', () => { + expect(encodeParam(nonPrintableASCII)).toBe(encodedNonPrintableASCII) + }) + + it('does not encode a safe set', () => { + expect(encodeParam(safePerSpec)).toBe(safePerSpec) + }) + + it('encodes a specific charset', () => { + expect(encodeParam(toEncode)).toBe(encodedToEncode) + }) + }) + + describe('query params', () => { + const safePerSpec = "!$'*,:;@[]_|?/{}^()`" + const toEncodeForKey = '"<>#&=' + const toEncodeForValue = '"<>#&' + const encodedToEncodeForKey = toEncodeForKey + .split('') + .map(c => { + const hex = c.charCodeAt(0).toString(16).toUpperCase() + return '%' + (hex.length > 1 ? hex : '0' + hex) + }) + .join('') + const encodedToEncodeForValue = toEncodeForValue + .split('') + .map(c => { + const hex = c.charCodeAt(0).toString(16).toUpperCase() + return '%' + (hex.length > 1 ? hex : '0' + hex) + }) + .join('') + + it('does not encode safe chars', () => { + expect(encodeQueryValue(unreservedSet)).toBe(unreservedSet) + expect(encodeQueryKey(unreservedSet)).toBe(unreservedSet) + }) + + it('encodes non-ascii', () => { + expect(encodeQueryValue('é')).toBe('%C3%A9') + expect(encodeQueryKey('é')).toBe('%C3%A9') + }) + + it('encodes non-printable ascii', () => { + expect(encodeQueryValue(nonPrintableASCII)).toBe(encodedNonPrintableASCII) + expect(encodeQueryKey(nonPrintableASCII)).toBe(encodedNonPrintableASCII) + }) + + it('does not encode a safe set', () => { + expect(encodeQueryValue(safePerSpec)).toBe(safePerSpec) + expect(encodeQueryKey(safePerSpec)).toBe(safePerSpec) + }) + + it('encodes a specific charset', () => { + expect(encodeQueryKey(toEncodeForKey)).toBe(encodedToEncodeForKey) + expect(encodeQueryValue(toEncodeForValue)).toBe(encodedToEncodeForValue) + }) + + it('encodes space as +', () => { + expect(encodeQueryKey(' ')).toBe('+') + expect(encodeQueryValue(' ')).toBe('+') + }) + + it('encodes +', () => { + expect(encodeQueryKey('+')).toBe('%2B') + expect(encodeQueryValue('+')).toBe('%2B') + }) + }) + + describe('hash', () => { + const safePerSpec = "!$'*+,:;@[]_|?/{}^()#&=" + const toEncode = ' "<>`' + const encodedToEncode = toEncode + .split('') + .map(c => { + const hex = c.charCodeAt(0).toString(16).toUpperCase() + return '%' + (hex.length > 1 ? hex : '0' + hex) + }) + .join('') + + it('does not encode safe chars', () => { + expect(encodeHash(unreservedSet)).toBe(unreservedSet) + }) + + it('encodes non-ascii', () => { + expect(encodeHash('é')).toBe('%C3%A9') + }) + + it('encodes non-printable ascii', () => { + expect(encodeHash(nonPrintableASCII)).toBe(encodedNonPrintableASCII) + }) + + it('does not encode a safe set', () => { + expect(encodeHash(safePerSpec)).toBe(safePerSpec) + }) + + it('encodes a specific charset', () => { + expect(encodeHash(toEncode)).toBe(encodedToEncode) + }) + }) +}) diff --git a/__tests__/errors.spec.ts b/__tests__/errors.spec.ts new file mode 100644 index 000000000..ba85b09ae --- /dev/null +++ b/__tests__/errors.spec.ts @@ -0,0 +1,424 @@ +import fakePromise from 'faked-promise' +import { createRouter as newRouter, createMemoryHistory } from '../src' +import { + NavigationFailure, + NavigationFailureType, + isNavigationFailure, + createRouterError, + ErrorTypes, +} from '../src/errors' +import { components, tick } from './utils' +import { + RouteRecordRaw, + NavigationGuard, + RouteLocationRaw, + START_LOCATION_NORMALIZED, +} from '../src/types' + +const routes: RouteRecordRaw[] = [ + { path: '/', component: components.Home }, + { path: '/redirect', redirect: '/' }, + { path: '/foo', component: components.Foo, name: 'Foo' }, + // prevent the log of no match warnings + { path: '/:pathMatch(.*)', component: components.Home }, +] + +const onError = jest.fn() +const afterEach = jest.fn() +function createRouter() { + const history = createMemoryHistory() + const router = newRouter({ + history, + routes, + }) + + router.onError(onError) + router.afterEach(afterEach) + return { router, history } +} + +describe('Errors & Navigation failures', () => { + beforeEach(() => { + onError.mockReset() + afterEach.mockReset() + }) + + it('next(false) triggers afterEach', async () => { + await testNavigation( + false, + expect.objectContaining({ + type: NavigationFailureType.aborted, + }) + ) + }) + + it('Duplicated navigation triggers afterEach', async () => { + let expectedFailure = expect.objectContaining({ + type: NavigationFailureType.duplicated, + to: expect.objectContaining({ path: '/' }), + from: expect.objectContaining({ path: '/' }), + }) + + const { router } = createRouter() + + await expect(router.push('/')).resolves.toEqual(undefined) + expect(afterEach).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(0) + + await expect(router.push('/')).resolves.toEqual(expectedFailure) + expect(afterEach).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledTimes(0) + + expect(afterEach).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expectedFailure + ) + }) + + it('next("/location") triggers afterEach', async () => { + await testNavigation( + ((to, from, next) => { + if (to.path === '/location') next() + else next('/location') + }) as NavigationGuard, + undefined + ) + }) + + it('redirect triggers afterEach', async () => { + await testNavigation(undefined, undefined, '/redirect') + }) + + it('next() triggers afterEach', async () => { + await testNavigation(undefined, undefined) + }) + + it('next(true) triggers afterEach', async () => { + await testNavigation(true, undefined) + }) + + it('triggers afterEach if a new navigation happens', async () => { + const { router } = createRouter() + const [promise, resolve] = fakePromise() + router.beforeEach((to, from, next) => { + // let it hang otherwise + if (to.path === '/') next() + else promise.then(() => next()) + }) + + let from = router.currentRoute.value + + // should hang + let navigationPromise = router.push('/foo') + + expect(afterEach).toHaveBeenCalledTimes(0) + await expect(router.push('/')).resolves.toEqual(undefined) + expect(onError).toHaveBeenCalledTimes(0) + + resolve() + await navigationPromise + expect(afterEach).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledTimes(0) + + expect(afterEach).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ path: '/foo' }), + from, + expect.objectContaining({ type: NavigationFailureType.cancelled }) + ) + }) + + it('next(new Error()) triggers onError', async () => { + let error = new Error() + await testError(error, error) + }) + + it('triggers onError with thrown errors', async () => { + let error = new Error() + await testError(() => { + throw error + }, error) + }) + + it('triggers onError with rejected promises', async () => { + let error = new Error() + await testError(async () => { + throw error + }, error) + }) + + describe('history navigation', () => { + it('triggers afterEach with history.back', async () => { + const { router, history } = createRouter() + + await router.push('/') + await router.push('/foo') + + afterEach.mockReset() + onError.mockReset() + + const [promise, resolve] = fakePromise() + router.beforeEach((to, from, next) => { + // let it hang otherwise + if (to.path === '/') next() + else promise.then(() => next()) + }) + + let from = router.currentRoute.value + + // should hang + let navigationPromise = router.push('/bar') + + // goes from /foo to / + expect(afterEach).toHaveBeenCalledTimes(0) + history.go(-1) + + await tick() + + expect(onError).toHaveBeenCalledTimes(0) + resolve() + await expect(navigationPromise).resolves.toEqual( + expect.objectContaining({ type: NavigationFailureType.cancelled }) + ) + + expect(afterEach).toHaveBeenCalledTimes(2) + expect(onError).toHaveBeenCalledTimes(0) + + expect(afterEach).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ path: '/bar' }), + from, + expect.objectContaining({ type: NavigationFailureType.cancelled }) + ) + + expect(afterEach).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ path: '/' }), + from, + undefined + ) + }) + + it('next(false) triggers afterEach with history.back', async () => { + await testHistoryNavigation( + false, + expect.objectContaining({ type: NavigationFailureType.aborted }) + ) + }) + + it('next("/location") triggers afterEach with history.back', async () => { + await testHistoryNavigation( + ((to, from, next) => { + if (to.path === '/location') next() + else next('/location') + }) as NavigationGuard, + undefined + ) + }) + + it('next() triggers afterEach with history.back', async () => { + await testHistoryNavigation(undefined, undefined) + }) + + it('next(true) triggers afterEach with history.back', async () => { + await testHistoryNavigation(true, undefined) + }) + + it('next(new Error()) triggers onError with history.back', async () => { + let error = new Error() + await testHistoryError(error, error) + }) + + it('triggers onError with thrown errors with history.back', async () => { + let error = new Error() + await testHistoryError(() => { + throw error + }, error) + }) + + it('triggers onError with rejected promises with history.back', async () => { + let error = new Error() + await testHistoryError(async () => { + throw error + }, error) + }) + }) +}) + +describe('isNavigationFailure', () => { + const from = START_LOCATION_NORMALIZED + const to = from + it('non objects', () => { + expect(isNavigationFailure(null)).toBe(false) + expect(isNavigationFailure(true)).toBe(false) + expect(isNavigationFailure(false)).toBe(false) + }) + + it('errors', () => { + expect(isNavigationFailure(new Error())).toBe(false) + }) + + it('any navigation failure', () => { + expect( + isNavigationFailure( + createRouterError(ErrorTypes.NAVIGATION_ABORTED, { + from, + to, + }) + ) + ).toBe(true) + }) + + it('specific navigation failure', () => { + expect( + isNavigationFailure( + createRouterError(ErrorTypes.NAVIGATION_ABORTED, { + from, + to, + }), + NavigationFailureType.aborted + ) + ).toBe(true) + }) + + it('multiple navigation failure types', () => { + expect( + isNavigationFailure( + createRouterError(ErrorTypes.NAVIGATION_ABORTED, { + from, + to, + }), + NavigationFailureType.aborted | NavigationFailureType.cancelled + ) + ).toBe(true) + expect( + isNavigationFailure( + createRouterError(ErrorTypes.NAVIGATION_CANCELLED, { + from, + to, + }), + NavigationFailureType.aborted | NavigationFailureType.cancelled + ) + ).toBe(true) + expect( + isNavigationFailure( + createRouterError(ErrorTypes.NAVIGATION_DUPLICATED, { + from, + to, + }), + NavigationFailureType.aborted | NavigationFailureType.cancelled + ) + ).toBe(false) + }) +}) + +async function testError( + nextArgument: any | NavigationGuard, + expectedError: Error | void = undefined, + to: RouteLocationRaw = '/foo' +) { + const { router } = createRouter() + router.beforeEach( + typeof nextArgument === 'function' + ? nextArgument + : (to, from, next) => { + next(nextArgument) + } + ) + + await expect(router.push(to)).rejects.toEqual(expectedError) + + expect(afterEach).toHaveBeenCalledTimes(0) + expect(onError).toHaveBeenCalledTimes(1) + + expect(onError).toHaveBeenCalledWith(expectedError) +} + +async function testNavigation( + nextArgument: any | NavigationGuard, + expectedFailure: NavigationFailure | void = undefined, + to: RouteLocationRaw = '/foo' +) { + const { router } = createRouter() + router.beforeEach( + typeof nextArgument === 'function' + ? nextArgument + : (to, from, next) => { + next(nextArgument) + } + ) + + await expect(router.push(to)).resolves.toEqual(expectedFailure) + + expect(afterEach).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(0) + + expect(afterEach).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expectedFailure + ) +} + +async function testHistoryNavigation( + nextArgument: any | NavigationGuard, + expectedFailure: NavigationFailure | void = undefined, + to: RouteLocationRaw = '/foo' +) { + const { router, history } = createRouter() + await router.push(to) + + router.beforeEach( + typeof nextArgument === 'function' + ? nextArgument + : (to, from, next) => { + next(nextArgument) + } + ) + + afterEach.mockReset() + onError.mockReset() + + history.go(-1) + + await tick() + + expect(afterEach).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(0) + + expect(afterEach).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expectedFailure + ) +} + +async function testHistoryError( + nextArgument: any | NavigationGuard, + expectedError: Error | void = undefined, + to: RouteLocationRaw = '/foo' +) { + const { router, history } = createRouter() + await router.push(to) + + router.beforeEach( + typeof nextArgument === 'function' + ? nextArgument + : (to, from, next) => { + next(nextArgument) + } + ) + + afterEach.mockReset() + onError.mockReset() + + history.go(-1) + + await tick() + + expect(afterEach).toHaveBeenCalledTimes(0) + expect(onError).toHaveBeenCalledTimes(1) + + expect(onError).toHaveBeenCalledWith(expectedError) +} diff --git a/__tests__/guards/afterEach.spec.ts b/__tests__/guards/afterEach.spec.ts new file mode 100644 index 000000000..01a9d44f4 --- /dev/null +++ b/__tests__/guards/afterEach.spec.ts @@ -0,0 +1,68 @@ +import { createDom, newRouter as createRouter } from '../utils' +import { RouteRecordRaw } from 'src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } +const Nested = { template: `
Nested
` } + +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, + { + path: '/nested', + component: Nested, + children: [ + { path: '', name: 'nested-default', component: Foo }, + { path: 'home', name: 'nested-home', component: Home }, + ], + }, +] + +describe('router.afterEach', () => { + beforeAll(() => { + createDom() + }) + + it('calls afterEach guards on push', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + router.afterEach(spy) + await router.push('/foo') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ fullPath: '/foo' }), + expect.objectContaining({ fullPath: '/' }), + undefined + ) + }) + + it('can be removed', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + const remove = router.afterEach(spy) + remove() + await router.push('/foo') + expect(spy).not.toHaveBeenCalled() + }) + + it('calls afterEach guards on multiple push', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + await router.push('/nested') + router.afterEach(spy) + await router.push('/nested/home') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'nested-home' }), + expect.objectContaining({ name: 'nested-default' }), + undefined + ) + await router.push('/nested') + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'nested-default' }), + expect.objectContaining({ name: 'nested-home' }), + undefined + ) + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/__tests__/guards/beforeEach.spec.ts b/__tests__/guards/beforeEach.spec.ts new file mode 100644 index 000000000..4cc45e089 --- /dev/null +++ b/__tests__/guards/beforeEach.spec.ts @@ -0,0 +1,222 @@ +import fakePromise from 'faked-promise' +import { createDom, tick, noGuard, newRouter as createRouter } from '../utils' +import { RouteRecordRaw, RouteLocationRaw } from '../../src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } +const Nested = { template: `
Nested
` } + +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, + { path: '/other', component: Foo }, + { path: '/n/:i', name: 'n', component: Home, meta: { requiresLogin: true } }, + { + path: '/nested', + component: Nested, + children: [ + { path: '', name: 'nested-default', component: Foo }, + { path: 'home', name: 'nested-home', component: Home }, + ], + }, +] + +describe('router.beforeEach', () => { + beforeAll(() => { + createDom() + }) + + it('calls beforeEach guards on navigation', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + router.beforeEach(spy) + spy.mockImplementationOnce(noGuard) + await router.push('/foo') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('can be removed', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + const remove = router.beforeEach(spy) + remove() + spy.mockImplementationOnce(noGuard) + await router.push('/foo') + expect(spy).not.toHaveBeenCalled() + }) + + it('does not call beforeEach guard if we were already on the page', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + await router.push('/foo') + router.beforeEach(spy) + spy.mockImplementationOnce(noGuard) + await router.push('/foo') + expect(spy).not.toHaveBeenCalled() + }) + + it('calls beforeEach guards on navigation between children routes', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + await router.push('/nested') + router.beforeEach(spy) + spy.mockImplementation(noGuard) + await router.push('/nested/home') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'nested-home' }), + expect.objectContaining({ name: 'nested-default' }), + expect.any(Function) + ) + await router.push('/nested') + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'nested-default' }), + expect.objectContaining({ name: 'nested-home' }), + expect.any(Function) + ) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('can redirect to a different location', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + await router.push('/foo') + spy.mockImplementation((to, from, next) => { + // only allow going to /other + if (to.fullPath !== '/other') next('/other') + else next() + }) + router.beforeEach(spy) + expect(spy).not.toHaveBeenCalled() + await router.push('/') + expect(spy).toHaveBeenCalledTimes(2) + // called before redirect + expect(spy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ path: '/' }), + expect.objectContaining({ path: '/foo' }), + expect.any(Function) + ) + expect(spy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ path: '/other' }), + expect.objectContaining({ path: '/foo' }), + expect.any(Function) + ) + expect(router.currentRoute.value.fullPath).toBe('/other') + }) + + async function assertRedirect(redirectFn: (i: string) => RouteLocationRaw) { + const spy = jest.fn() + const router = createRouter({ routes }) + await router.push('/') + spy.mockImplementation((to, from, next) => { + // only allow going to /other + const i = Number(to.params.i) + if (i >= 3) next() + else next(redirectFn(String(i + 1))) + }) + router.beforeEach(spy) + expect(spy).not.toHaveBeenCalled() + await router.push('/n/0') + expect(spy).toHaveBeenCalledTimes(4) + expect(router.currentRoute.value.fullPath).toBe('/n/3') + } + + it('can redirect multiple times with string redirect', async () => { + await assertRedirect(i => '/n/' + i) + }) + + it('can redirect multiple times with path object', async () => { + await assertRedirect(i => ({ path: '/n/' + i })) + }) + + it('can redirect multiple times with named route', async () => { + await assertRedirect(i => ({ name: 'n', params: { i } })) + }) + + it('is called when changing params', async () => { + const spy = jest.fn() + const router = createRouter({ routes: [...routes] }) + await router.push('/n/2') + spy.mockImplementation(noGuard) + router.beforeEach(spy) + spy.mockImplementationOnce(noGuard) + await router.push('/n/1') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('is not called with same params', async () => { + const spy = jest.fn() + const router = createRouter({ routes: [...routes] }) + await router.push('/n/2') + spy.mockImplementation(noGuard) + router.beforeEach(spy) + spy.mockImplementationOnce(noGuard) + await router.push('/n/2') + expect(spy).not.toHaveBeenCalled() + }) + + it('waits before navigating', async () => { + const [promise, resolve] = fakePromise() + const router = createRouter({ routes }) + router.beforeEach(async (to, from, next) => { + await promise + next() + }) + const p = router.push('/foo') + expect(router.currentRoute.value.fullPath).toBe('/') + resolve() + await p + expect(router.currentRoute.value.fullPath).toBe('/foo') + }) + + it('waits in the right order', async () => { + const [p1, r1] = fakePromise() + const [p2, r2] = fakePromise() + const router = createRouter({ routes }) + const guard1 = jest.fn() + let order = 0 + guard1.mockImplementationOnce(async (to, from, next) => { + expect(order++).toBe(0) + await p1 + next() + }) + router.beforeEach(guard1) + const guard2 = jest.fn() + guard2.mockImplementationOnce(async (to, from, next) => { + expect(order++).toBe(1) + await p2 + next() + }) + router.beforeEach(guard2) + let navigation = router.push('/foo') + expect(router.currentRoute.value.fullPath).toBe('/') + expect(guard1).not.toHaveBeenCalled() + expect(guard2).not.toHaveBeenCalled() + r1() // resolve the first guard + await tick() // wait a tick + await tick() // mocha requires an extra tick here + expect(guard1).toHaveBeenCalled() + // we haven't resolved the second gurad yet + expect(router.currentRoute.value.fullPath).toBe('/') + r2() + await navigation + expect(guard2).toHaveBeenCalled() + expect(router.currentRoute.value.fullPath).toBe('/foo') + }) + + it('adds meta information', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + router.beforeEach(spy) + spy.mockImplementationOnce(noGuard) + await router.push('/n/2') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ meta: { requiresLogin: true } }), + expect.objectContaining({ meta: {} }), + expect.any(Function) + ) + }) +}) diff --git a/__tests__/guards/beforeEnter.spec.ts b/__tests__/guards/beforeEnter.spec.ts new file mode 100644 index 000000000..913b0d041 --- /dev/null +++ b/__tests__/guards/beforeEnter.spec.ts @@ -0,0 +1,192 @@ +import fakePromise from 'faked-promise' +import { createDom, noGuard, tick, newRouter as createRouter } from '../utils' +import { RouteRecordRaw } from '../../src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } + +const beforeEnter = jest.fn() +const beforeEnters = [jest.fn(), jest.fn()] +const nested = { + parent: jest.fn(), + nestedEmpty: jest.fn(), + nestedA: jest.fn(), + nestedAbs: jest.fn(), + nestedNested: jest.fn(), + nestedNestedFoo: jest.fn(), + nestedNestedParam: jest.fn(), +} + +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/home', component: Home, beforeEnter }, + { path: '/foo', component: Foo }, + { + path: '/guard/:n', + component: Foo, + beforeEnter, + }, + { + path: '/multiple', + beforeEnter: beforeEnters, + component: Foo, + }, + { + path: '/nested', + component: { + ...Home, + beforeRouteEnter: nested.parent, + }, + children: [ + { + path: '', + name: 'nested-empty-path', + component: { ...Home, beforeRouteEnter: nested.nestedEmpty }, + }, + { + path: 'a', + name: 'nested-path', + component: { ...Home, beforeRouteEnter: nested.nestedA }, + }, + { + path: '/abs-nested', + name: 'absolute-nested', + component: { ...Home, beforeRouteEnter: nested.nestedAbs }, + }, + { + path: 'nested', + name: 'nested-nested', + component: { ...Home, beforeRouteEnter: nested.nestedNested }, + children: [ + { + path: 'foo', + name: 'nested-nested-foo', + component: { ...Home, beforeRouteEnter: nested.nestedNestedFoo }, + }, + { + path: 'param/:p', + name: 'nested-nested-param', + component: { ...Home, beforeRouteEnter: nested.nestedNestedParam }, + }, + ], + }, + ], + }, +] + +function resetMocks() { + beforeEnter.mockReset() + beforeEnters.forEach(spy => { + spy.mockReset() + spy.mockImplementationOnce(noGuard) + }) + for (const key in nested) { + nested[key as keyof typeof nested].mockReset() + nested[key as keyof typeof nested].mockImplementation(noGuard) + } +} + +beforeEach(() => { + resetMocks() +}) + +describe('beforeEnter', () => { + beforeAll(() => { + createDom() + }) + + it('calls beforeEnter guards on navigation', async () => { + const router = createRouter({ routes }) + beforeEnter.mockImplementationOnce(noGuard) + await router.push('/guard/valid') + expect(beforeEnter).toHaveBeenCalledTimes(1) + }) + + it('supports an array of beforeEnter', async () => { + const router = createRouter({ routes }) + await router.push('/multiple') + expect(beforeEnters[0]).toHaveBeenCalledTimes(1) + expect(beforeEnters[1]).toHaveBeenCalledTimes(1) + expect(beforeEnters[0]).toHaveBeenCalledWith( + expect.objectContaining({ path: '/multiple' }), + expect.objectContaining({ path: '/' }), + expect.any(Function) + ) + }) + + it('call beforeEnter in nested views', async () => { + const router = createRouter({ routes }) + await router.push('/nested/a') + resetMocks() + await router.push('/nested/nested/foo') + expect(nested.parent).not.toHaveBeenCalled() + expect(nested.nestedA).not.toHaveBeenCalled() + expect(nested.nestedNested).toHaveBeenCalledTimes(1) + expect(nested.nestedNestedFoo).toHaveBeenCalledTimes(1) + expect(nested.nestedNested).toHaveBeenCalledWith( + expect.objectContaining({ path: '/nested/nested/foo' }), + expect.objectContaining({ path: '/nested/a' }), + expect.any(Function) + ) + expect(nested.nestedNestedFoo).toHaveBeenCalledWith( + expect.objectContaining({ path: '/nested/nested/foo' }), + expect.objectContaining({ path: '/nested/a' }), + expect.any(Function) + ) + }) + + it('calls beforeEnter different records, same component', async () => { + const router = createRouter({ routes }) + beforeEnter.mockImplementationOnce(noGuard) + await router.push('/') + expect(beforeEnter).not.toHaveBeenCalled() + await router.push('/home') + expect(beforeEnter).toHaveBeenCalledTimes(1) + }) + + it('does not call beforeEnter guard if we were already on the page', async () => { + const router = createRouter({ routes }) + beforeEnter.mockImplementation(noGuard) + await router.push('/guard/one') + expect(beforeEnter).toHaveBeenCalledTimes(1) + await router.push('/guard/one') + expect(beforeEnter).toHaveBeenCalledTimes(1) + }) + + it('waits before navigating', async () => { + const [promise, resolve] = fakePromise() + const router = createRouter({ routes }) + beforeEnter.mockImplementationOnce(async (to, from, next) => { + await promise + next() + }) + const p = router.push('/foo') + expect(router.currentRoute.value.fullPath).toBe('/') + resolve() + await p + expect(router.currentRoute.value.fullPath).toBe('/foo') + }) + + it('waits before navigating in an array of beforeEnter', async () => { + const [p1, r1] = fakePromise() + const [p2, r2] = fakePromise() + const router = createRouter({ routes }) + beforeEnters[0].mockImplementationOnce(async (to, from, next) => { + await p1 + next() + }) + beforeEnters[1].mockImplementationOnce(async (to, from, next) => { + await p2 + next() + }) + const p = router.push('/multiple') + expect(router.currentRoute.value.fullPath).toBe('/') + expect(beforeEnters[1]).not.toHaveBeenCalled() + r1() + await p1 + await tick() + r2() + await p + expect(router.currentRoute.value.fullPath).toBe('/multiple') + }) +}) diff --git a/__tests__/guards/beforeResolve.spec.ts b/__tests__/guards/beforeResolve.spec.ts new file mode 100644 index 000000000..a0dbeaed4 --- /dev/null +++ b/__tests__/guards/beforeResolve.spec.ts @@ -0,0 +1,25 @@ +import { createDom, noGuard, newRouter as createRouter } from '../utils' +import { RouteRecordRaw } from '../../src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } + +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, +] + +describe('router.beforeEach', () => { + beforeAll(() => { + createDom() + }) + + it('calls beforeEach guards on navigation', async () => { + const spy = jest.fn() + const router = createRouter({ routes }) + router.beforeResolve(spy) + spy.mockImplementationOnce(noGuard) + await router.push('/foo') + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/guards/beforeRouteEnter.spec.ts b/__tests__/guards/beforeRouteEnter.spec.ts new file mode 100644 index 000000000..1a0a3b7e1 --- /dev/null +++ b/__tests__/guards/beforeRouteEnter.spec.ts @@ -0,0 +1,204 @@ +import fakePromise from 'faked-promise' +import { createDom, noGuard, newRouter as createRouter } from '../utils' +import { RouteRecordRaw, NavigationGuard } from '../../src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } + +const beforeRouteEnter = jest.fn< + ReturnType, + Parameters +>() +const named = { + default: jest.fn(), + other: jest.fn(), +} + +const nested = { + parent: jest.fn(), + nestedEmpty: jest.fn(), + nestedA: jest.fn(), + nestedAbs: jest.fn(), + nestedNested: jest.fn(), + nestedNestedFoo: jest.fn(), + nestedNestedParam: jest.fn(), +} + +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, + { + path: '/guard/:n', + component: { + ...Foo, + beforeRouteEnter, + }, + }, + { + path: '/named', + components: { + default: { + ...Home, + beforeRouteEnter: named.default, + }, + other: { + ...Foo, + beforeRouteEnter: named.other, + }, + }, + }, + { + path: '/nested', + component: { + ...Home, + beforeRouteEnter: nested.parent, + }, + children: [ + { + path: '', + name: 'nested-empty-path', + component: { ...Home, beforeRouteEnter: nested.nestedEmpty }, + }, + { + path: 'a', + name: 'nested-path', + component: { ...Home, beforeRouteEnter: nested.nestedA }, + }, + { + path: '/abs-nested', + name: 'absolute-nested', + component: { ...Home, beforeRouteEnter: nested.nestedAbs }, + }, + { + path: 'nested', + name: 'nested-nested', + component: { ...Home, beforeRouteEnter: nested.nestedNested }, + children: [ + { + path: 'foo', + name: 'nested-nested-foo', + component: { ...Home, beforeRouteEnter: nested.nestedNestedFoo }, + }, + { + path: 'param/:p', + name: 'nested-nested-param', + component: { ...Home, beforeRouteEnter: nested.nestedNestedParam }, + }, + ], + }, + ], + }, +] + +function resetMocks() { + beforeRouteEnter.mockReset() + for (const key in named) { + named[key as keyof typeof named].mockReset() + } + for (const key in nested) { + nested[key as keyof typeof nested].mockReset() + nested[key as keyof typeof nested].mockImplementation(noGuard) + } +} + +beforeEach(() => { + resetMocks() +}) + +describe('beforeRouteEnter', () => { + beforeAll(() => { + createDom() + }) + + it('calls beforeRouteEnter guards on navigation', async () => { + const router = createRouter({ routes }) + beforeRouteEnter.mockImplementationOnce((to, from, next) => { + if (to.params.n !== 'valid') return next(false) + next() + }) + await router.push('/guard/valid') + expect(beforeRouteEnter).toHaveBeenCalledTimes(1) + }) + + it('calls beforeRouteEnter guards on navigation for nested views', async () => { + const router = createRouter({ routes }) + await router.push('/nested/nested/foo') + expect(nested.parent).toHaveBeenCalledTimes(1) + expect(nested.nestedNested).toHaveBeenCalledTimes(1) + expect(nested.nestedNestedFoo).toHaveBeenCalledTimes(1) + expect(nested.nestedAbs).not.toHaveBeenCalled() + expect(nested.nestedA).not.toHaveBeenCalled() + }) + + it('calls beforeRouteEnter guards on navigation for nested views', async () => { + const router = createRouter({ routes }) + await router.push('/nested/nested/foo') + expect(nested.parent).toHaveBeenCalledTimes(1) + expect(nested.nestedNested).toHaveBeenCalledTimes(1) + expect(nested.nestedNestedFoo).toHaveBeenCalledTimes(1) + }) + + it('calls beforeRouteEnter guards on non-entered nested routes', async () => { + const router = createRouter({ routes }) + await router.push('/nested/nested') + resetMocks() + await router.push('/nested/nested/foo') + expect(nested.parent).not.toHaveBeenCalled() + expect(nested.nestedNested).not.toHaveBeenCalled() + expect(nested.nestedNestedFoo).toHaveBeenCalledTimes(1) + }) + + it('does not call beforeRouteEnter guards on param change', async () => { + const router = createRouter({ routes }) + await router.push('/nested/nested/param/1') + resetMocks() + await router.push('/nested/nested/param/2') + expect(nested.parent).not.toHaveBeenCalled() + expect(nested.nestedNested).not.toHaveBeenCalled() + expect(nested.nestedNestedParam).not.toHaveBeenCalled() + }) + + it('calls beforeRouteEnter guards on navigation for named views', async () => { + const router = createRouter({ routes }) + named.default.mockImplementationOnce(noGuard) + named.other.mockImplementationOnce(noGuard) + await router.push('/named') + expect(named.default).toHaveBeenCalledTimes(1) + expect(named.other).toHaveBeenCalledTimes(1) + expect(router.currentRoute.value.fullPath).toBe('/named') + }) + + it('aborts navigation if one of the named views aborts', async () => { + const router = createRouter({ routes }) + named.default.mockImplementationOnce((to, from, next) => { + next(false) + }) + named.other.mockImplementationOnce(noGuard) + await router.push('/named').catch(err => {}) // catch abort + expect(named.default).toHaveBeenCalledTimes(1) + expect(router.currentRoute.value.fullPath).not.toBe('/named') + }) + + it('does not call beforeRouteEnter if we were already on the page', async () => { + const router = createRouter({ routes }) + beforeRouteEnter.mockImplementation(noGuard) + await router.push('/guard/one') + expect(beforeRouteEnter).toHaveBeenCalledTimes(1) + await router.push('/guard/one') + expect(beforeRouteEnter).toHaveBeenCalledTimes(1) + }) + + it('waits before navigating', async () => { + const [promise, resolve] = fakePromise() + const router = createRouter({ routes }) + beforeRouteEnter.mockImplementationOnce(async (to, from, next) => { + await promise + next() + }) + const p = router.push('/foo') + expect(router.currentRoute.value.fullPath).toBe('/') + resolve() + await p + expect(router.currentRoute.value.fullPath).toBe('/foo') + }) +}) diff --git a/__tests__/guards/beforeRouteEnterCallback.spec.ts b/__tests__/guards/beforeRouteEnterCallback.spec.ts new file mode 100644 index 000000000..b223c7709 --- /dev/null +++ b/__tests__/guards/beforeRouteEnterCallback.spec.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ +import { defineComponent, h } from 'vue' +import { mount } from '../mount' +import { + createRouter, + RouterView, + createMemoryHistory, + RouterOptions, +} from '../../src' + +const nextCallbacks = { + Default: jest.fn(), + Other: jest.fn(), +} +const Default = defineComponent({ + beforeRouteEnter(to, from, next) { + next(nextCallbacks.Default) + }, + name: 'Default', + setup() { + return () => h('div', 'Default content') + }, +}) + +const Other = defineComponent({ + beforeRouteEnter(to, from, next) { + next(nextCallbacks.Other) + }, + name: 'Other', + setup() { + return () => h('div', 'Other content') + }, +}) + +const Third = defineComponent({ + name: 'Third', + setup() { + return () => h('div', 'Third content') + }, +}) + +beforeEach(() => { + for (const key in nextCallbacks) { + nextCallbacks[key as keyof typeof nextCallbacks].mockClear() + } +}) + +describe('beforeRouteEnter next callback', () => { + async function factory(options: Partial) { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [], + ...options, + }) + + const wrapper = await mount( + { + template: ` +
+ + +
+ `, + components: { RouterView }, + }, + { router } + ) + + return { wrapper, router } + } + + it('calls each beforeRouteEnter callback once', async () => { + const { router } = await factory({ + routes: [ + { + path: '/:p(.*)', + components: { + default: Default, + other: Other, + third: Third, + }, + }, + ], + }) + + await router.isReady() + + expect(nextCallbacks.Default).toHaveBeenCalledTimes(1) + expect(nextCallbacks.Other).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/guards/beforeRouteLeave.spec.ts b/__tests__/guards/beforeRouteLeave.spec.ts new file mode 100644 index 000000000..ff5618337 --- /dev/null +++ b/__tests__/guards/beforeRouteLeave.spec.ts @@ -0,0 +1,196 @@ +import { createDom, noGuard, newRouter as createRouter } from '../utils' +import { RouteRecordRaw } from '../../src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } + +const nested = { + parent: jest.fn(), + nestedEmpty: jest.fn(), + nestedA: jest.fn(), + nestedB: jest.fn(), + nestedAbs: jest.fn(), + nestedNested: jest.fn(), + nestedNestedFoo: jest.fn(), + nestedNestedParam: jest.fn(), +} +const beforeRouteLeave = jest.fn() + +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, + { + path: '/guard', + component: { + ...Foo, + beforeRouteLeave, + }, + }, + { + path: '/nested', + component: { + ...Home, + beforeRouteLeave: nested.parent, + }, + children: [ + { + path: '', + name: 'nested-empty-path', + component: { ...Home, beforeRouteLeave: nested.nestedEmpty }, + }, + { + path: 'a', + name: 'nested-path', + component: { ...Home, beforeRouteLeave: nested.nestedA }, + }, + { + path: 'b', + name: 'nested-path-b', + component: { ...Home, beforeRouteLeave: nested.nestedB }, + }, + { + path: '/abs-nested', + name: 'absolute-nested', + component: { ...Home, beforeRouteLeave: nested.nestedAbs }, + }, + { + path: 'nested', + name: 'nested-nested', + component: { ...Home, beforeRouteLeave: nested.nestedNested }, + children: [ + { + path: 'foo', + name: 'nested-nested-foo', + component: { ...Home, beforeRouteLeave: nested.nestedNestedFoo }, + }, + { + path: 'param/:p', + name: 'nested-nested-param', + component: { ...Home, beforeRouteLeave: nested.nestedNestedParam }, + }, + ], + }, + ], + }, +] + +function resetMocks() { + beforeRouteLeave.mockReset() + for (const key in nested) { + nested[key as keyof typeof nested].mockReset() + nested[key as keyof typeof nested].mockImplementation(noGuard) + } +} + +beforeEach(() => { + resetMocks() +}) + +describe('beforeRouteLeave', () => { + beforeAll(() => { + createDom() + }) + + it('calls beforeRouteLeave guard on navigation', async () => { + const router = createRouter({ routes }) + beforeRouteLeave.mockImplementationOnce((to, from, next) => { + if (to.path === 'foo') next(false) + else next() + }) + await router.push('/guard') + expect(beforeRouteLeave).not.toHaveBeenCalled() + + // simulate a mounted route component + router.currentRoute.value.matched[0].instances.default = {} as any + + await router.push('/foo') + expect(beforeRouteLeave).toHaveBeenCalledTimes(1) + }) + + it('does not call beforeRouteLeave guard if the view is not mounted', async () => { + const router = createRouter({ routes }) + beforeRouteLeave.mockImplementationOnce((to, from, next) => { + next() + }) + await router.push('/guard') + expect(beforeRouteLeave).not.toHaveBeenCalled() + + // usually we would have to simulate a mounted route component + // router.currentRoute.value.matched[0].instances.default = {} as any + + await router.push('/foo') + expect(beforeRouteLeave).not.toHaveBeenCalled() + }) + + it('calls beforeRouteLeave guard on navigation between children', async () => { + const router = createRouter({ routes }) + await router.push({ name: 'nested-path' }) + + // simulate a mounted route component + router.currentRoute.value.matched[0].instances.default = {} as any + router.currentRoute.value.matched[1].instances.default = {} as any + + resetMocks() + await router.push({ name: 'nested-path-b' }) + expect(nested.nestedEmpty).not.toHaveBeenCalled() + expect(nested.nestedAbs).not.toHaveBeenCalled() + expect(nested.nestedB).not.toHaveBeenCalled() + expect(nested.nestedNestedFoo).not.toHaveBeenCalled() + expect(nested.parent).not.toHaveBeenCalled() + expect(nested.nestedNested).not.toHaveBeenCalled() + expect(nested.nestedA).toHaveBeenCalledTimes(1) + expect(nested.nestedA).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'nested-path-b', + fullPath: '/nested/b', + }), + expect.objectContaining({ + name: 'nested-path', + fullPath: '/nested/a', + }), + expect.any(Function) + ) + }) + + it('calls beforeRouteLeave guard on navigation between children in order', async () => { + const router = createRouter({ routes }) + await router.push({ name: 'nested-nested-foo' }) + resetMocks() + let count = 0 + nested.nestedNestedFoo.mockImplementation((to, from, next) => { + expect(count++).toBe(0) + next() + }) + nested.nestedNested.mockImplementation((to, from, next) => { + expect(count++).toBe(1) + next() + }) + nested.parent.mockImplementation((to, from, next) => { + expect(count++).toBe(2) + next() + }) + + // simulate a mounted route component + router.currentRoute.value.matched[0].instances.default = {} as any + router.currentRoute.value.matched[1].instances.default = {} as any + router.currentRoute.value.matched[2].instances.default = {} as any + + await router.push('/') + expect(nested.parent).toHaveBeenCalledTimes(1) + expect(nested.nestedNested).toHaveBeenCalledTimes(1) + expect(nested.nestedNestedFoo).toHaveBeenCalledTimes(1) + }) + + it('can cancel navigation', async () => { + const router = createRouter({ routes }) + beforeRouteLeave.mockImplementationOnce(async (to, from, next) => { + next(false) + }) + await router.push('/guard') + const p = router.push('/') + const currentRoute = router.currentRoute.value + expect(currentRoute.fullPath).toBe('/guard') + await p.catch(err => {}) // catch the navigation abortion + expect(currentRoute.fullPath).toBe('/guard') + }) +}) diff --git a/__tests__/guards/beforeRouteUpdate.spec.ts b/__tests__/guards/beforeRouteUpdate.spec.ts new file mode 100644 index 000000000..2b539b6eb --- /dev/null +++ b/__tests__/guards/beforeRouteUpdate.spec.ts @@ -0,0 +1,68 @@ +import fakePromise from 'faked-promise' +import { createDom, noGuard, newRouter as createRouter } from '../utils' +import { RouteRecordRaw } from '../../src/types' + +const Home = { template: `
Home
` } +const Foo = { template: `
Foo
` } + +const beforeRouteUpdate = jest.fn() +const routes: RouteRecordRaw[] = [ + { path: '/', component: Home }, + { path: '/foo', component: Foo }, + { + path: '/guard/:go', + component: { + ...Foo, + beforeRouteUpdate, + }, + }, +] + +beforeEach(() => { + beforeRouteUpdate.mockReset() +}) + +describe('beforeRouteUpdate', () => { + beforeAll(() => { + createDom() + }) + + it('calls beforeRouteUpdate guards when changing params', async () => { + const router = createRouter({ routes }) + beforeRouteUpdate.mockImplementationOnce(noGuard) + await router.push('/guard/valid') + // not called on initial navigation + expect(beforeRouteUpdate).not.toHaveBeenCalled() + // simulate a mounted route component + router.currentRoute.value.matched[0].instances.default = {} as any + await router.push('/guard/other') + expect(beforeRouteUpdate).toHaveBeenCalledTimes(1) + }) + + it('does not call beforeRouteUpdate guard if the view is not mounted', async () => { + const router = createRouter({ routes }) + beforeRouteUpdate.mockImplementationOnce(noGuard) + await router.push('/guard/valid') + // not called on initial navigation + expect(beforeRouteUpdate).not.toHaveBeenCalled() + // usually we would have to simulate a mounted route component + // router.currentRoute.value.matched[0].instances.default = {} as any + await router.push('/guard/other') + expect(beforeRouteUpdate).not.toHaveBeenCalled() + }) + + it('waits before navigating', async () => { + const [promise, resolve] = fakePromise() + const router = createRouter({ routes }) + beforeRouteUpdate.mockImplementationOnce(async (to, from, next) => { + await promise + next() + }) + await router.push('/guard/one') + const p = router.push('/guard/foo') + expect(router.currentRoute.value.fullPath).toBe('/guard/one') + resolve() + await p + expect(router.currentRoute.value.fullPath).toBe('/guard/foo') + }) +}) diff --git a/__tests__/guards/extractComponentsGuards.spec.ts b/__tests__/guards/extractComponentsGuards.spec.ts new file mode 100644 index 000000000..7301ea47d --- /dev/null +++ b/__tests__/guards/extractComponentsGuards.spec.ts @@ -0,0 +1,99 @@ +import { extractComponentsGuards } from '../../src/navigationGuards' +import { START_LOCATION_NORMALIZED, RouteRecordRaw } from '../../src/types' +import { components } from '../utils' +import { normalizeRouteRecord } from '../../src/matcher' +import { RouteRecordNormalized } from 'src/matcher/types' +import { mockWarn } from 'jest-mock-warn' + +const beforeRouteEnter = jest.fn() + +// stub those two +const to = START_LOCATION_NORMALIZED +const from = START_LOCATION_NORMALIZED + +const NoGuard: RouteRecordRaw = { path: '/', component: components.Home } +const InvalidRoute: RouteRecordRaw = { + path: '/', + // @ts-ignore: intended error + component: null, +} +const WrongLazyRoute: RouteRecordRaw = { + path: '/', + // @ts-ignore: intended error + component: Promise.resolve(components.Home), +} +const SingleGuard: RouteRecordRaw = { + path: '/', + component: { ...components.Home, beforeRouteEnter }, +} +const SingleGuardNamed: RouteRecordRaw = { + path: '/', + components: { + default: { ...components.Home, beforeRouteEnter }, + other: { ...components.Foo, beforeRouteEnter }, + }, +} + +beforeEach(() => { + beforeRouteEnter.mockReset() + beforeRouteEnter.mockImplementation((to, from, next) => { + next() + }) +}) + +async function checkGuards( + components: Exclude[], + n: number, + guardsLength: number = n +) { + beforeRouteEnter.mockClear() + const guards = await extractComponentsGuards( + // type is fine as we excluded RouteRecordRedirect in components argument + components.map(normalizeRouteRecord) as RouteRecordNormalized[], + 'beforeRouteEnter', + to, + from + ) + expect(guards).toHaveLength(guardsLength) + for (const guard of guards) { + expect(guard).toBeInstanceOf(Function) + expect(await guard()) + } + expect(beforeRouteEnter).toHaveBeenCalledTimes(n) +} + +describe('extractComponentsGuards', () => { + mockWarn() + + it('extracts guards from one single component', async () => { + await checkGuards([SingleGuard], 1) + }) + + it('extracts guards from multiple components (named views)', async () => { + await checkGuards([SingleGuardNamed], 2) + }) + + it('handles no guards', async () => { + await checkGuards([NoGuard], 0) + }) + + it('handles mixed things', async () => { + await checkGuards([SingleGuard, SingleGuardNamed], 3) + await checkGuards([SingleGuard, SingleGuard], 2) + await checkGuards([SingleGuardNamed, SingleGuardNamed], 4) + }) + + it('throws if component is null', async () => { + // @ts-ignore + await expect(checkGuards([InvalidRoute], 2)).rejects.toHaveProperty( + 'message', + expect.stringMatching('Invalid route component') + ) + expect('is not a valid component').toHaveBeenWarned() + }) + + it('warns wrong lazy component', async () => { + await checkGuards([WrongLazyRoute], 0, 1) + expect('Promise instead of a function').toHaveBeenWarned() + }) +}) diff --git a/__tests__/guards/guardToPromiseFn.spec.ts b/__tests__/guards/guardToPromiseFn.spec.ts new file mode 100644 index 000000000..d2b596f98 --- /dev/null +++ b/__tests__/guards/guardToPromiseFn.spec.ts @@ -0,0 +1,281 @@ +import { guardToPromiseFn } from '../../src/navigationGuards' +import { START_LOCATION_NORMALIZED } from '../../src/types' +import { ErrorTypes } from '../../src/errors' +import { mockWarn } from 'jest-mock-warn' + +// stub those two +const to = START_LOCATION_NORMALIZED +const from = { + ...START_LOCATION_NORMALIZED, + path: '/other', + fullPath: '/other', +} + +describe('guardToPromiseFn', () => { + mockWarn() + it('calls the guard with to, from and, next', async () => { + expect.assertions(2) + const spy = jest.fn((to, from, next) => next()) + await expect(guardToPromiseFn(spy, to, from)()).resolves.toEqual(undefined) + expect(spy).toHaveBeenCalledWith(to, from, expect.any(Function)) + }) + + it('resolves if next is called with no arguments', async () => { + expect.assertions(1) + await expect( + guardToPromiseFn((to, from, next) => next(), to, from)() + ).resolves.toEqual(undefined) + }) + + it('resolves if next is called with true', async () => { + expect.assertions(1) + await expect( + guardToPromiseFn((to, from, next) => next(true), to, from)() + ).resolves.toEqual(undefined) + }) + + it('rejects if next is called with false', async () => { + expect.assertions(1) + try { + await guardToPromiseFn((to, from, next) => next(false), to, from)() + } catch (err) { + expect(err).toMatchObject({ + from, + to, + type: ErrorTypes.NAVIGATION_ABORTED, + }) + } + }) + + it('rejects if next is called with a string location', async () => { + expect.assertions(1) + try { + await guardToPromiseFn((to, from, next) => next('/new'), to, from)() + } catch (err) { + expect(err).toMatchObject({ + from: to, + to: '/new', + type: ErrorTypes.NAVIGATION_GUARD_REDIRECT, + }) + } + }) + + it('rejects if next is called with an object location', async () => { + expect.assertions(1) + let redirectTo = { path: '/new' } + try { + await guardToPromiseFn((to, from, next) => next(redirectTo), to, from)() + } catch (err) { + expect(err).toMatchObject({ + from: to, + to: redirectTo, + type: ErrorTypes.NAVIGATION_GUARD_REDIRECT, + }) + } + }) + + it('rejects if next is called with an error', async () => { + expect.assertions(1) + let error = new Error('nope') + await expect( + guardToPromiseFn((to, from, next) => next(error), to, from)() + ).rejects.toBe(error) + }) + + it('rejects if guard rejects a Promise', async () => { + expect.assertions(1) + await expect( + guardToPromiseFn( + async (to, from, next) => { + throw new Error() + }, + to, + from + )() + ).rejects.toThrowError() + }) + + it('rejects if guard throws an error', async () => { + expect.assertions(1) + let error = new Error('nope') + await expect( + guardToPromiseFn( + (to, from, next) => { + throw error + }, + to, + from + )() + ).rejects.toBe(error) + }) + + describe('no next argument', () => { + it('rejects if returns false', async () => { + expect.assertions(1) + try { + await guardToPromiseFn((to, from) => false, to, from)() + } catch (err) { + expect(err).toMatchObject({ + from, + to, + type: ErrorTypes.NAVIGATION_ABORTED, + }) + } + }) + + it('resolves no value is returned', async () => { + expect.assertions(1) + await expect( + guardToPromiseFn((to, from) => {}, to, from)() + ).resolves.toEqual(undefined) + }) + + it('resolves if true is returned', async () => { + expect.assertions(1) + await expect( + guardToPromiseFn((to, from) => true, to, from)() + ).resolves.toEqual(undefined) + }) + + it('rejects if false is returned', async () => { + expect.assertions(1) + try { + await guardToPromiseFn((to, from) => false, to, from)() + } catch (err) { + expect(err).toMatchObject({ + from, + to, + type: ErrorTypes.NAVIGATION_ABORTED, + }) + } + }) + + it('rejects if async false is returned', async () => { + expect.assertions(1) + try { + await guardToPromiseFn(async (to, from) => false, to, from)() + } catch (err) { + expect(err).toMatchObject({ + from, + to, + type: ErrorTypes.NAVIGATION_ABORTED, + }) + } + }) + + it('rejects if a string location is returned', async () => { + expect.assertions(1) + try { + await guardToPromiseFn((to, from) => '/new', to, from)() + } catch (err) { + expect(err).toMatchObject({ + from: to, + to: '/new', + type: ErrorTypes.NAVIGATION_GUARD_REDIRECT, + }) + } + }) + + it('rejects if an object location is returned', async () => { + expect.assertions(1) + let redirectTo = { path: '/new' } + try { + await guardToPromiseFn((to, from) => redirectTo, to, from)() + } catch (err) { + expect(err).toMatchObject({ + from: to, + to: redirectTo, + type: ErrorTypes.NAVIGATION_GUARD_REDIRECT, + }) + } + }) + + it('rejects if an error is returned', async () => { + expect.assertions(1) + let error = new Error('nope') + await expect( + guardToPromiseFn((to, from) => error, to, from)() + ).rejects.toBe(error) + }) + + it('rejects if guard rejects a Promise', async () => { + expect.assertions(1) + let error = new Error('nope') + await expect( + guardToPromiseFn( + async (to, from) => { + throw error + }, + to, + from + )() + ).rejects.toBe(error) + }) + + it('rejects if guard throws an error', async () => { + let error = new Error('nope') + await expect( + guardToPromiseFn( + (to, from) => { + throw error + }, + to, + from + )() + ).rejects.toBe(error) + }) + }) + + it('warns if guard resolves without calling next', async () => { + expect.assertions(2) + await expect( + guardToPromiseFn( + async (to, from, next) => { + // oops not called next + }, + to, + from + )() + ).rejects.toThrowError() + expect('callback was never called').toHaveBeenWarned() + }) + + it('does not warn if guard rejects without calling next', async () => { + expect.assertions(2) + await expect( + guardToPromiseFn( + async (to, from, next) => { + // oops not called next + throw new Error('nope') + }, + to, + from + )() + ).rejects.toThrowError() + expect('callback was never called').not.toHaveBeenWarned() + }) + + it('warns if guard returns without calling next', async () => { + expect.assertions(2) + await expect( + guardToPromiseFn((to, from, next) => false, to, from)() + ).rejects.toThrowError() + expect('callback was never called').toHaveBeenWarned() + }) + + it('does not warn if guard returns undefined', async () => { + expect.assertions(2) + await expect( + guardToPromiseFn( + (to, from, next) => { + // there could be a callback somewhere + setTimeout(next, 10) + }, + to, + from + )() + ).resolves.toEqual(undefined) + + expect('callback was never called').not.toHaveBeenWarned() + }) +}) diff --git a/__tests__/guards/guardsContext.spec.ts b/__tests__/guards/guardsContext.spec.ts new file mode 100644 index 000000000..7900f9f3b --- /dev/null +++ b/__tests__/guards/guardsContext.spec.ts @@ -0,0 +1,272 @@ +/** + * @jest-environment jsdom + */ +import { createRouter, createMemoryHistory } from '../../src' +import { createApp, defineComponent } from 'vue' + +const component = { + template: '
Generic
', +} + +describe('beforeRouteLeave', () => { + it('invokes with the component context', async () => { + expect.assertions(2) + const spy = jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }) + const WithLeave = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: spy, + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { path: '/leave', component: WithLeave as any }, + ], + }) + const app = createApp({ + template: ` + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/leave') + await router.push('/') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('invokes with the component context with named views', async () => { + expect.assertions(2) + const WithLeaveOne = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + const WithLeaveTwo = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { + path: '/leave', + components: { + one: WithLeaveOne as any, + two: WithLeaveTwo as any, + }, + }, + ], + }) + const app = createApp({ + template: ` + + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/leave') + await router.push('/') + }) + + it('invokes with the component context with nested views', async () => { + expect.assertions(2) + const WithLeaveParent = defineComponent({ + template: ``, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + const WithLeave = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { + path: '/leave', + component: WithLeaveParent as any, + children: [ + { + path: '', + component: WithLeave as any, + }, + ], + }, + ], + }) + const app = createApp({ + template: ` + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/leave') + await router.push('/') + }) + + it('invokes with the component context with nested named views', async () => { + expect.assertions(3) + const WithLeaveParent = defineComponent({ + template: ` + + + `, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + const WithLeaveOne = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + const WithLeaveTwo = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteLeave: jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }), + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { + path: '/leave', + component: WithLeaveParent as any, + children: [ + { + path: '', + components: { + one: WithLeaveOne as any, + two: WithLeaveTwo as any, + }, + }, + ], + }, + ], + }) + const app = createApp({ + template: ` + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/leave') + await router.push('/') + }) +}) + +describe('beforeRouteUpdate', () => { + it('invokes with the component context', async () => { + expect.assertions(2) + const spy = jest + .fn() + .mockImplementationOnce(function (this: any, to, from, next) { + expect(typeof this.counter).toBe('number') + next() + }) + const WithParam = defineComponent({ + template: `text`, + // we use data to check if the context is the right one because saving `this` in a variable logs a few warnings + data: () => ({ counter: 0 }), + beforeRouteUpdate: spy, + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { path: '/:id', component: WithParam }, + ], + }) + const app = createApp({ + template: ` + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/one') + await router.push('/foo') + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/guards/onBeforeRouteLeave.spec.ts b/__tests__/guards/onBeforeRouteLeave.spec.ts new file mode 100644 index 000000000..7fd60c632 --- /dev/null +++ b/__tests__/guards/onBeforeRouteLeave.spec.ts @@ -0,0 +1,50 @@ +/** + * @jest-environment jsdom + */ +import { + createRouter, + createMemoryHistory, + onBeforeRouteLeave, +} from '../../src' +import { createApp, defineComponent } from 'vue' + +const component = { + template: '
Generic
', +} + +describe('onBeforeRouteLeave', () => { + it('removes guards when leaving the route', async () => { + const spy = jest.fn() + const WithLeave = defineComponent({ + template: `text`, + setup() { + onBeforeRouteLeave(spy) + }, + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { path: '/leave', component: WithLeave as any }, + ], + }) + const app = createApp({ + template: ` + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/leave') + await router.push('/') + expect(spy).toHaveBeenCalledTimes(1) + await router.push('/leave') + await router.push('/') + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/__tests__/guards/onBeforeRouteUpdate.spec.ts b/__tests__/guards/onBeforeRouteUpdate.spec.ts new file mode 100644 index 000000000..dab3d99a0 --- /dev/null +++ b/__tests__/guards/onBeforeRouteUpdate.spec.ts @@ -0,0 +1,50 @@ +/** + * @jest-environment jsdom + */ +import { + createRouter, + createMemoryHistory, + onBeforeRouteUpdate, +} from '../../src' +import { createApp, defineComponent } from 'vue' + +const component = { + template: '
Generic
', +} + +describe('onBeforeRouteUpdate', () => { + it('removes update guards when leaving', async () => { + const spy = jest.fn() + const WithLeave = defineComponent({ + template: `text`, + setup() { + onBeforeRouteUpdate(spy) + }, + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { path: '/foo', component: WithLeave as any }, + ], + }) + const app = createApp({ + template: ` + + `, + }) + app.use(router) + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + app.mount(rootEl) + + await router.isReady() + await router.push('/foo') + await router.push('/foo?q') + await router.push('/') + await router.push('/foo') + await router.push('/foo?q') + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/__tests__/history/hash.spec.ts b/__tests__/history/hash.spec.ts new file mode 100644 index 000000000..43a299c15 --- /dev/null +++ b/__tests__/history/hash.spec.ts @@ -0,0 +1,107 @@ +import { JSDOM } from 'jsdom' +import { createWebHashHistory } from '../../src/history/hash' +import { createWebHistory } from '../../src/history/html5' +import { createDom } from '../utils' +import { mockWarn } from 'jest-mock-warn' + +jest.mock('../../src/history/html5') +// override the value of isBrowser because the variable is created before JSDOM +// is created +jest.mock('../../src/utils/env', () => ({ + isBrowser: true, +})) + +describe('History Hash', () => { + let dom: JSDOM + beforeAll(() => { + dom = createDom() + }) + mockWarn() + + beforeEach(() => { + ;(createWebHistory as jest.Mock).mockClear() + }) + + afterAll(() => { + dom.window.close() + }) + + afterEach(() => { + // ensure no base element is left after a test as only the first is + // respected + for (let element of Array.from(document.getElementsByTagName('base'))) + element.remove() + }) + + describe('url', () => { + beforeEach(() => { + dom.reconfigure({ url: 'https://example.com' }) + }) + + it('should use a correct base', () => { + dom.reconfigure({ url: 'https://esm.dev' }) + createWebHashHistory() + // starts with a `/` + expect(createWebHistory).toHaveBeenCalledWith('/#') + }) + + it('warns if there is anything but a slash after the # in a provided base', () => { + createWebHashHistory('/#/') + createWebHashHistory('/#') + createWebHashHistory('/base/#') + expect('').not.toHaveBeenWarned() + createWebHashHistory('/#/app') + expect('should be "/#"').toHaveBeenWarned() + }) + + it('should be able to provide a base', () => { + createWebHashHistory('/folder/') + expect(createWebHistory).toHaveBeenCalledWith('/folder/#') + }) + + it('should be able to provide a base with no trailing slash', () => { + createWebHashHistory('/folder') + expect(createWebHistory).toHaveBeenCalledWith('/folder#') + }) + + it('should use the base option over the base tag', () => { + const baseEl = document.createElement('base') + baseEl.href = '/foo/' + document.head.appendChild(baseEl) + createWebHashHistory('/bar/') + expect(createWebHistory).toHaveBeenCalledWith('/bar/#') + }) + + describe('url with pathname', () => { + it('keeps the pathname as base', () => { + dom.reconfigure({ url: 'https://esm.dev/subfolder' }) + createWebHashHistory() + expect(createWebHistory).toHaveBeenCalledWith('/subfolder#') + }) + + it('keeps the pathname without a trailing slash as base', () => { + dom.reconfigure({ url: 'https://esm.dev/subfolder#/foo' }) + createWebHashHistory() + expect(createWebHistory).toHaveBeenCalledWith('/subfolder#') + }) + + it('keeps the pathname with trailing slash as base', () => { + dom.reconfigure({ url: 'https://esm.dev/subfolder/#/foo' }) + createWebHashHistory() + expect(createWebHistory).toHaveBeenCalledWith('/subfolder/#') + }) + }) + }) + + describe('file://', () => { + beforeEach(() => { + dom.reconfigure({ url: 'file:///usr/some-file.html' }) + }) + + it('should use a correct base', () => { + createWebHashHistory() + // both, a trailing / and none work + expect(createWebHistory).toHaveBeenCalledWith('#') + }) + }) +}) diff --git a/__tests__/history/html5.spec.ts b/__tests__/history/html5.spec.ts new file mode 100644 index 000000000..c92ce0d62 --- /dev/null +++ b/__tests__/history/html5.spec.ts @@ -0,0 +1,121 @@ +import { JSDOM } from 'jsdom' +import { createWebHistory } from '../../src/history/html5' +import { createDom } from '../utils' + +// override the value of isBrowser because the variable is created before JSDOM +// is created +jest.mock('../../src/utils/env', () => ({ + isBrowser: true, +})) + +// These unit tests are supposed to tests very specific scenarios that are easier to setup +// on a unit test than an e2e tests +describe('History HTMl5', () => { + let dom: JSDOM + beforeAll(() => { + dom = createDom() + }) + + afterAll(() => { + dom.window.close() + }) + + afterEach(() => { + // ensure no base element is left after a test as only the first is + // respected + for (let element of Array.from(document.getElementsByTagName('base'))) + element.remove() + }) + + it('handles a basic base', () => { + expect(createWebHistory().base).toBe('') + expect(createWebHistory('/').base).toBe('') + }) + + it('handles a base tag', () => { + const baseEl = document.createElement('base') + baseEl.href = '/foo/' + document.head.appendChild(baseEl) + expect(createWebHistory().base).toBe('/foo') + }) + + it('handles a base tag with origin', () => { + const baseEl = document.createElement('base') + baseEl.href = 'https://example.com/foo/' + document.head.appendChild(baseEl) + expect(createWebHistory().base).toBe('/foo') + }) + + it('handles a base tag with origin without trailing slash', () => { + const baseEl = document.createElement('base') + baseEl.href = 'https://example.com/bar' + document.head.appendChild(baseEl) + expect(createWebHistory().base).toBe('/bar') + }) + + it('ignores base tag if base is provided', () => { + const baseEl = document.createElement('base') + baseEl.href = '/foo/' + document.head.appendChild(baseEl) + expect(createWebHistory('/bar/').base).toBe('/bar') + }) + + it('handles a non-empty base', () => { + expect(createWebHistory('/foo/').base).toBe('/foo') + expect(createWebHistory('/foo').base).toBe('/foo') + }) + + it('handles a single hash base', () => { + expect(createWebHistory('#').base).toBe('#') + expect(createWebHistory('#/').base).toBe('#') + }) + + it('handles a non-empty hash base', () => { + expect(createWebHistory('#/bar').base).toBe('#/bar') + expect(createWebHistory('#/bar/').base).toBe('#/bar') + }) + + it('prepends the host to support // urls', () => { + let history = createWebHistory() + let spy = jest.spyOn(window.history, 'pushState') + history.push('/foo') + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + 'https://example.com/foo' + ) + history.push('//foo') + expect(spy).toHaveBeenLastCalledWith( + expect.anything(), + expect.any(String), + 'https://example.com//foo' + ) + spy.mockRestore() + }) + + it('calls push with hash part of the url with a base', () => { + dom.reconfigure({ url: 'file:///usr/etc/index.html' }) + let history = createWebHistory('/usr/etc/index.html#/') + let spy = jest.spyOn(window.history, 'pushState') + history.push('/foo') + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + '#/foo' + ) + spy.mockRestore() + }) + + it('works with something after the hash in the base', () => { + dom.reconfigure({ url: 'file:///usr/etc/index.html' }) + let history = createWebHistory('#something') + let spy = jest.spyOn(window.history, 'pushState') + history.push('/foo') + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + '#something/foo' + ) + spy.mockRestore() + }) +}) diff --git a/__tests__/history/memory.spec.ts b/__tests__/history/memory.spec.ts new file mode 100644 index 000000000..4afa14544 --- /dev/null +++ b/__tests__/history/memory.spec.ts @@ -0,0 +1,169 @@ +import { createMemoryHistory } from '../../src/history/memory' +import { START, HistoryLocation } from '../../src/history/common' + +const loc: HistoryLocation = '/foo' + +const loc2: HistoryLocation = '/bar' + +describe('Memory history', () => { + it('starts in nowhere', () => { + const history = createMemoryHistory() + expect(history.location).toEqual(START) + }) + + it('can push a location', () => { + const history = createMemoryHistory() + history.push('/somewhere?foo=foo#hey') + expect(history.location).toEqual('/somewhere?foo=foo#hey') + }) + + it('can replace a location', () => { + const history = createMemoryHistory() + // partial version + history.replace('/somewhere?foo=foo#hey') + expect(history.location).toEqual('/somewhere?foo=foo#hey') + }) + + it('does not trigger listeners with push', () => { + const history = createMemoryHistory() + const spy = jest.fn() + history.listen(spy) + history.push(loc) + expect(spy).not.toHaveBeenCalled() + }) + + it('does not trigger listeners with replace', () => { + const history = createMemoryHistory() + const spy = jest.fn() + history.listen(spy) + history.replace(loc) + expect(spy).not.toHaveBeenCalled() + }) + + it('can go back', () => { + const history = createMemoryHistory() + history.push(loc) + history.push(loc2) + history.go(-1) + expect(history.location).toEqual(loc) + history.go(-1) + expect(history.location).toEqual(START) + }) + + it('does nothing with back if queue contains only one element', () => { + const history = createMemoryHistory() + history.go(-1) + expect(history.location).toEqual(START) + }) + + it('does nothing with forward if at end of log', () => { + const history = createMemoryHistory() + history.go(1) + expect(history.location).toEqual(START) + }) + + it('can moves back and forth in history queue', () => { + const history = createMemoryHistory() + history.push(loc) + history.push(loc2) + history.go(-1) + history.go(-1) + expect(history.location).toEqual(START) + history.go(1) + expect(history.location).toEqual(loc) + history.go(1) + expect(history.location).toEqual(loc2) + }) + + it('can push in the middle of the history', () => { + const history = createMemoryHistory() + history.push(loc) + history.push(loc2) + history.go(-1) + history.go(-1) + expect(history.location).toEqual(START) + history.push(loc2) + expect(history.location).toEqual(loc2) + // does nothing + history.go(1) + expect(history.location).toEqual(loc2) + }) + + it('can listen to navigations', () => { + const history = createMemoryHistory() + const spy = jest.fn() + history.listen(spy) + history.push(loc) + history.go(-1) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith(START, loc, { + direction: 'back', + delta: -1, + type: 'pop', + }) + history.go(1) + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenLastCalledWith(loc, START, { + direction: 'forward', + delta: 1, + type: 'pop', + }) + }) + + it('can stop listening to navigation', () => { + const history = createMemoryHistory() + const spy = jest.fn() + const spy2 = jest.fn() + // remove right away + history.listen(spy)() + const remove = history.listen(spy2) + history.push(loc) + history.go(-1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + remove() + history.go(1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + }) + + it('removing the same listener is a noop', () => { + const history = createMemoryHistory() + const spy = jest.fn() + const spy2 = jest.fn() + const rem = history.listen(spy) + const rem2 = history.listen(spy2) + rem() + rem() + history.push(loc) + history.go(-1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + rem2() + rem2() + history.go(1) + expect(spy).not.toHaveBeenCalled() + expect(spy2).toHaveBeenCalledTimes(1) + }) + + it('removes all listeners with destroy', () => { + const history = createMemoryHistory() + history.push('/other') + const spy = jest.fn() + history.listen(spy) + history.destroy() + history.go(-1) + expect(spy).not.toHaveBeenCalled() + }) + + it('can avoid listeners with back and forward', () => { + const history = createMemoryHistory() + const spy = jest.fn() + history.listen(spy) + history.push(loc) + history.go(-1, false) + expect(spy).not.toHaveBeenCalled() + history.go(1, false) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/initialNavigation.spec.ts b/__tests__/initialNavigation.spec.ts new file mode 100644 index 000000000..9625e365b --- /dev/null +++ b/__tests__/initialNavigation.spec.ts @@ -0,0 +1,76 @@ +import { JSDOM } from 'jsdom' +import { createRouter, createWebHistory } from '../src' +import { createDom, components, nextNavigation } from './utils' +import { RouteRecordRaw } from '../src/types' + +// override the value of isBrowser because the variable is created before JSDOM +// is created +jest.mock('../src/utils/env', () => ({ + isBrowser: true, +})) + +// generic component because we are not displaying anything so it doesn't matter +const component = components.Home + +const routes: RouteRecordRaw[] = [ + { path: '/home', redirect: '/' }, + { path: '/', component }, + { + path: '/home-before', + component, + beforeEnter: (to, from, next) => { + next('/') + }, + }, + { path: '/bar', component }, + { path: '/foo', component, name: 'Foo' }, + { path: '/to-foo', redirect: '/foo' }, +] + +describe('Initial Navigation', () => { + let dom: JSDOM + function newRouter( + url: string, + options: Partial[0]> = {} + ) { + dom.reconfigure({ url: 'https://example.com' + url }) + const history = options.history || createWebHistory() + const router = createRouter({ history, routes, ...options }) + + return { history, router } + } + + beforeAll(() => { + dom = createDom() + }) + + afterAll(() => { + dom.window.close() + }) + + it('handles initial navigation with redirect', async () => { + const { history, router } = newRouter('/home') + expect(history.location).toBe('/home') + // this is done automatically on install but there is none here + await router.push(history.location) + expect(router.currentRoute.value).toMatchObject({ path: '/' }) + await router.push('/foo') + expect(router.currentRoute.value).toMatchObject({ path: '/foo' }) + history.go(-1) + await nextNavigation(router) + expect(router.currentRoute.value).toMatchObject({ path: '/' }) + }) + + it('handles initial navigation with beforEnter', async () => { + const { history, router } = newRouter('/home-before') + expect(history.location).toBe('/home-before') + // this is done automatically on mount but there is no mount here + await router.push(history.location) + expect(router.currentRoute.value).toMatchObject({ path: '/' }) + await router.push('/foo') + expect(router.currentRoute.value).toMatchObject({ path: '/foo' }) + history.go(-1) + await nextNavigation(router) + expect(router.currentRoute.value).toMatchObject({ path: '/' }) + }) +}) diff --git a/__tests__/lazyLoading.spec.ts b/__tests__/lazyLoading.spec.ts new file mode 100644 index 000000000..62a4c3b92 --- /dev/null +++ b/__tests__/lazyLoading.spec.ts @@ -0,0 +1,321 @@ +import fakePromise from 'faked-promise' +import { createRouter, createMemoryHistory } from '../src' +import { RouterOptions } from '../src/router' +import { RouteComponent } from '../src/types' +import { ticks } from './utils' +import { FunctionalComponent, h } from 'vue' +import { mockWarn } from 'jest-mock-warn' + +function newRouter(options: Partial = {}) { + let history = createMemoryHistory() + const router = createRouter({ history, routes: [], ...options }) + + return { history, router } +} + +function createLazyComponent() { + const [promise, resolve, reject] = fakePromise() + + return { + component: jest.fn(() => promise.then(() => ({} as RouteComponent))), + promise, + resolve, + reject, + } +} + +describe('Lazy Loading', () => { + mockWarn() + it('works', async () => { + const { component, resolve } = createLazyComponent() + const { router } = newRouter({ + routes: [{ path: '/foo', component }], + }) + + let p = router.push('/foo') + await ticks(1) + + expect(component).toHaveBeenCalledTimes(1) + resolve() + + await p + expect(router.currentRoute.value).toMatchObject({ + path: '/foo', + matched: [{}], + }) + }) + + it('works with nested routes', async () => { + const parent = createLazyComponent() + const child = createLazyComponent() + const { router } = newRouter({ + routes: [ + { + path: '/foo', + component: parent.component, + children: [{ path: 'bar', component: child.component }], + }, + ], + }) + + parent.resolve() + child.resolve() + await router.push('/foo/bar') + + expect(parent.component).toHaveBeenCalled() + expect(child.component).toHaveBeenCalled() + + expect(router.currentRoute.value).toMatchObject({ + path: '/foo/bar', + }) + expect(router.currentRoute.value.matched).toHaveLength(2) + }) + + it('caches lazy loaded components', async () => { + const { component, resolve } = createLazyComponent() + const { router } = newRouter({ + routes: [ + { path: '/foo', component }, + { path: '/', component: {} }, + ], + }) + + resolve() + + await router.push('/foo') + await router.push('/') + await router.push('/foo') + + expect(component).toHaveBeenCalledTimes(1) + }) + + it('uses the same cache for aliases', async () => { + const { component, resolve } = createLazyComponent() + const { router } = newRouter({ + routes: [ + { path: '/foo', alias: ['/bar', '/baz'], component }, + { path: '/', component: {} }, + ], + }) + + resolve() + + await router.push('/foo') + await router.push('/') + await router.push('/bar') + await router.push('/') + await router.push('/baz') + + expect(component).toHaveBeenCalledTimes(1) + }) + + it('uses the same cache for nested aliases', async () => { + const { component, resolve } = createLazyComponent() + const c2 = createLazyComponent() + const { router } = newRouter({ + routes: [ + { + path: '/foo', + alias: ['/bar', '/baz'], + component, + children: [ + { path: 'child', alias: ['c1', 'c2'], component: c2.component }, + ], + }, + { path: '/', component: {} }, + ], + }) + + resolve() + c2.resolve() + + await router.push('/baz/c2') + await router.push('/') + await router.push('/foo/c2') + await router.push('/') + await router.push('/foo/child') + + expect(component).toHaveBeenCalledTimes(1) + expect(c2.component).toHaveBeenCalledTimes(1) + }) + + it('avoid fetching async component if navigation is cancelled through beforeEnter', async () => { + const { component, resolve } = createLazyComponent() + const spy = jest.fn((to, from, next) => next(false)) + const { router } = newRouter({ + routes: [ + { + path: '/foo', + component, + beforeEnter: spy, + }, + ], + }) + + resolve() + await router.push('/foo').catch(() => {}) + expect(spy).toHaveBeenCalledTimes(1) + expect(component).toHaveBeenCalledTimes(0) + }) + + it('avoid fetching async component if navigation is cancelled through router.beforeEach', async () => { + const { component, resolve } = createLazyComponent() + const { router } = newRouter({ + routes: [ + { + path: '/foo', + component, + }, + ], + }) + + const spy = jest.fn((to, from, next) => next(false)) + + router.beforeEach(spy) + + resolve() + await router.push('/foo').catch(() => {}) + expect(spy).toHaveBeenCalledTimes(1) + expect(component).toHaveBeenCalledTimes(0) + }) + + it('invokes beforeRouteEnter after lazy loading the component', async () => { + const { promise, resolve } = createLazyComponent() + const spy = jest.fn((to, from, next) => next()) + const component = jest.fn(() => + promise.then(() => ({ beforeRouteEnter: spy })) + ) + const { router } = newRouter({ + routes: [{ path: '/foo', component }], + }) + + resolve() + await router.push('/foo') + expect(component).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('beforeRouteLeave works on a lazy loaded component', async () => { + const { promise, resolve } = createLazyComponent() + const spy = jest.fn((to, from, next) => next()) + const component = jest.fn(() => + promise.then(() => ({ beforeRouteLeave: spy })) + ) + const { router } = newRouter({ + routes: [ + { path: '/foo', component }, + { path: '/', component: {} }, + ], + }) + + resolve() + await router.push('/foo') + expect(component).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledTimes(0) + + // simulate a mounted route component + router.currentRoute.value.matched[0].instances.default = {} as any + + await router.push('/') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('beforeRouteUpdate works on a lazy loaded component', async () => { + const { promise, resolve } = createLazyComponent() + const spy = jest.fn((to, from, next) => next()) + const component = jest.fn(() => + promise.then(() => ({ beforeRouteUpdate: spy })) + ) + const { router } = newRouter({ + routes: [{ path: '/:id', component }], + }) + + resolve() + await router.push('/foo') + expect(component).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledTimes(0) + + // simulate a mounted route component + router.currentRoute.value.matched[0].instances.default = {} as any + + await router.push('/bar') + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('prints the error when lazy load fails', async () => { + const { component, reject } = createLazyComponent() + const { router } = newRouter({ + routes: [{ path: '/foo', component }], + }) + + const spy = jest.fn() + + reject(new Error('fail')) + await router.push('/foo').catch(spy) + + expect(spy).toHaveBeenCalled() + expect('fail').toHaveBeenWarned() + + expect(router.currentRoute.value).toMatchObject({ + path: '/', + matched: [], + }) + }) + + it('aborts the navigation if async fails', async () => { + const { component, reject } = createLazyComponent() + const { router } = newRouter({ + routes: [{ path: '/foo', component }], + }) + + const spy = jest.fn() + + reject() + await router.push('/foo').catch(spy) + + expect(spy).toHaveBeenCalled() + + expect(router.currentRoute.value).toMatchObject({ + path: '/', + matched: [], + }) + }) + + it('aborts the navigation if nested async fails', async () => { + const parent = createLazyComponent() + const child = createLazyComponent() + const { router } = newRouter({ + routes: [ + { + path: '/foo', + component: parent.component, + children: [{ path: '', component: child.component }], + }, + ], + }) + + const spy = jest.fn() + + parent.resolve() + child.reject() + await router.push('/foo').catch(spy) + + expect(spy).toHaveBeenCalledWith(expect.any(Error)) + + expect(router.currentRoute.value).toMatchObject({ + path: '/', + matched: [], + }) + }) + + it('works with functional components', async () => { + const Functional: FunctionalComponent = () => h('div', 'functional') + Functional.displayName = 'Functional' + + const { router } = newRouter({ + routes: [{ path: '/foo', component: Functional }], + }) + + await expect(router.push('/foo')).resolves.toBe(undefined) + }) +}) diff --git a/__tests__/location.spec.ts b/__tests__/location.spec.ts new file mode 100644 index 000000000..a770e900d --- /dev/null +++ b/__tests__/location.spec.ts @@ -0,0 +1,351 @@ +import { parseQuery, stringifyQuery } from '../src/query' +import { + parseURL as originalParseURL, + stringifyURL as originalStringifyURL, + stripBase, + isSameRouteLocationParams, + isSameRouteLocation, + resolveRelativePath, +} from '../src/location' +import { RouteLocationNormalizedLoaded } from 'src' +import { mockWarn } from 'jest-mock-warn' + +describe('parseURL', () => { + let parseURL = originalParseURL.bind(null, parseQuery) + + it('works with no query no hash', () => { + expect(parseURL('/foo')).toEqual({ + fullPath: '/foo', + path: '/foo', + hash: '', + query: {}, + }) + }) + + it('works with partial path with no query', () => { + expect(parseURL('foo#hash')).toEqual({ + fullPath: '/foo#hash', + path: '/foo', + hash: '#hash', + query: {}, + }) + }) + + it('works with partial path', () => { + expect(parseURL('foo?f=foo#hash')).toEqual({ + fullPath: '/foo?f=foo#hash', + path: '/foo', + hash: '#hash', + query: { f: 'foo' }, + }) + }) + + it('works with only query', () => { + expect(parseURL('?f=foo')).toEqual({ + fullPath: '/?f=foo', + path: '/', + hash: '', + query: { f: 'foo' }, + }) + }) + + it('works with only hash', () => { + expect(parseURL('#foo')).toEqual({ + fullPath: '/#foo', + path: '/', + hash: '#foo', + query: {}, + }) + }) + + it('works with partial path and current location', () => { + expect(parseURL('foo', '/parent/bar')).toEqual({ + fullPath: '/parent/foo', + path: '/parent/foo', + hash: '', + query: {}, + }) + }) + + it('works with partial path with query and hash and current location', () => { + expect(parseURL('foo?f=foo#hash', '/parent/bar')).toEqual({ + fullPath: '/parent/foo?f=foo#hash', + path: '/parent/foo', + hash: '#hash', + query: { f: 'foo' }, + }) + }) + + it('works with relative query and current location', () => { + expect(parseURL('?f=foo', '/parent/bar')).toEqual({ + fullPath: '/parent/bar?f=foo', + path: '/parent/bar', + hash: '', + query: { f: 'foo' }, + }) + }) + + it('works with relative hash and current location', () => { + expect(parseURL('#hash', '/parent/bar')).toEqual({ + fullPath: '/parent/bar#hash', + path: '/parent/bar', + hash: '#hash', + query: {}, + }) + }) + + it('extracts the query', () => { + expect(parseURL('/foo?a=one&b=two')).toEqual({ + fullPath: '/foo?a=one&b=two', + path: '/foo', + hash: '', + query: { + a: 'one', + b: 'two', + }, + }) + }) + + it('extracts the hash', () => { + expect(parseURL('/foo#bar')).toEqual({ + fullPath: '/foo#bar', + path: '/foo', + hash: '#bar', + query: {}, + }) + }) + + it('extracts query and hash', () => { + expect(parseURL('/foo?a=one#bar')).toEqual({ + fullPath: '/foo?a=one#bar', + path: '/foo', + hash: '#bar', + query: { a: 'one' }, + }) + }) + + it('extracts multiple query parameters as an array', () => { + expect(parseURL('/foo?a=one&a=two&a=three')).toEqual({ + fullPath: '/foo?a=one&a=two&a=three', + path: '/foo', + hash: '', + query: { a: ['one', 'two', 'three'] }, + }) + }) + + it('calls parseQuery', () => { + const parseQuery = jest.fn() + originalParseURL(parseQuery, '/?é=é&é=a') + expect(parseQuery).toHaveBeenCalledTimes(1) + expect(parseQuery).toHaveBeenCalledWith('é=é&é=a') + }) +}) + +describe('stringifyURL', () => { + let stringifyURL = originalStringifyURL.bind(null, stringifyQuery) + + it('stringifies a path', () => { + expect( + stringifyURL({ + path: '/some-path', + }) + ).toBe('/some-path') + }) + + it('stringifies a query with arrays', () => { + expect( + stringifyURL({ + path: '/path', + query: { + foo: ['a1', 'a2'], + bar: 'b', + }, + }) + ).toBe('/path?foo=a1&foo=a2&bar=b') + }) + + it('stringifies a query', () => { + expect( + stringifyURL({ + path: '/path', + query: { + foo: 'a', + bar: 'b', + }, + }) + ).toBe('/path?foo=a&bar=b') + }) + + it('stringifies a hash', () => { + expect( + stringifyURL({ + path: '/path', + hash: '#hey', + }) + ).toBe('/path#hey') + }) + + it('stringifies a query and a hash', () => { + expect( + stringifyURL({ + path: '/path', + query: { + foo: 'a', + bar: 'b', + }, + hash: '#hey', + }) + ).toBe('/path?foo=a&bar=b#hey') + }) + + it('calls stringifyQuery', () => { + const stringifyQuery = jest.fn() + originalStringifyURL(stringifyQuery, { + path: '/', + query: { é: 'é', b: 'a' }, + }) + expect(stringifyQuery).toHaveBeenCalledTimes(1) + expect(stringifyQuery).toHaveBeenCalledWith({ é: 'é', b: 'a' }) + }) +}) + +describe('stripBase', () => { + it('returns the pathname if no base', () => { + expect(stripBase('', '')).toBe('') + expect(stripBase('/', '')).toBe('/') + expect(stripBase('/thing', '')).toBe('/thing') + }) + + it('is case insensitive', () => { + expect(stripBase('/Base/foo', '/base')).toBe('/foo') + expect(stripBase('/Basé/foo', '/base')).toBe('/Basé/foo') + expect(stripBase('/Basé/foo', '/basé')).toBe('/foo') + expect(stripBase('/base/foo', '/Base')).toBe('/foo') + expect(stripBase('/base/foo', '/Basé')).toBe('/base/foo') + expect(stripBase('/basé/foo', '/Basé')).toBe('/foo') + }) + + it('returns the pathname without the base', () => { + expect(stripBase('/base', '/base')).toBe('/') + expect(stripBase('/base/', '/base')).toBe('/') + expect(stripBase('/base/foo', '/base')).toBe('/foo') + }) +}) + +describe('isSameRouteLocationParams', () => { + it('compare simple values', () => { + expect(isSameRouteLocationParams({ a: '2' }, { a: '2' })).toBe(true) + expect(isSameRouteLocationParams({ a: '3' }, { a: '2' })).toBe(false) + // different order + expect( + isSameRouteLocationParams({ a: '2', b: '3' }, { b: '3', a: '2' }) + ).toBe(true) + expect( + isSameRouteLocationParams({ a: '3', b: '3' }, { b: '3', a: '2' }) + ).toBe(false) + }) + + it('compare array values', () => { + expect(isSameRouteLocationParams({ a: ['2'] }, { a: ['2'] })).toBe(true) + expect(isSameRouteLocationParams({ a: ['3'] }, { a: ['2'] })).toBe(false) + // different order + expect( + isSameRouteLocationParams({ a: ['2'], b: ['3'] }, { b: ['3'], a: ['2'] }) + ).toBe(true) + expect( + isSameRouteLocationParams({ a: ['3'], b: ['3'] }, { b: ['3'], a: ['2'] }) + ).toBe(false) + }) + + it('considers arrays of one item same as the item itself', () => { + expect(isSameRouteLocationParams({ a: ['2'] }, { a: '2' })).toBe(true) + expect(isSameRouteLocationParams({ a: ['3'] }, { a: '2' })).toBe(false) + }) +}) + +describe('isSameRouteLocation', () => { + it('compares queries as strings', () => { + const location: RouteLocationNormalizedLoaded = { + path: '/', + params: {}, + name: 'home', + matched: [{} as any], + fullPath: '/', + hash: '', + meta: {}, + query: {}, + redirectedFrom: undefined, + } + expect( + isSameRouteLocation( + () => 'fake query', + { ...location, query: { a: 'a' } }, + { ...location, query: { b: 'b' } } + ) + ).toBe(true) + }) +}) + +describe('resolveRelativePath', () => { + mockWarn() + it('resolves relative direct path', () => { + expect(resolveRelativePath('add', '/users/posva')).toBe('/users/add') + expect(resolveRelativePath('add', '/users/posva/')).toBe('/users/posva/add') + expect(resolveRelativePath('add', '/users/posva/thing')).toBe( + '/users/posva/add' + ) + }) + + it('resolves relative direct path with .', () => { + expect(resolveRelativePath('./add', '/users/posva')).toBe('/users/add') + expect(resolveRelativePath('./add', '/users/posva/')).toBe( + '/users/posva/add' + ) + expect(resolveRelativePath('./add', '/users/posva/thing')).toBe( + '/users/posva/add' + ) + }) + + it('resolves relative path with ..', () => { + expect(resolveRelativePath('../add', '/users/posva')).toBe('/add') + expect(resolveRelativePath('../add', '/users/posva/')).toBe('/users/add') + expect(resolveRelativePath('../add', '/users/posva/thing')).toBe( + '/users/add' + ) + }) + + it('resolves multiple relative paths with ..', () => { + expect(resolveRelativePath('../../add', '/users/posva')).toBe('/add') + expect(resolveRelativePath('../../add', '/users/posva/')).toBe('/add') + expect(resolveRelativePath('../../add', '/users/posva/thing')).toBe('/add') + expect(resolveRelativePath('../../../add', '/users/posva')).toBe('/add') + }) + + it('works with the root', () => { + expect(resolveRelativePath('add', '/')).toBe('/add') + expect(resolveRelativePath('./add', '/')).toBe('/add') + expect(resolveRelativePath('../add', '/')).toBe('/add') + expect(resolveRelativePath('.././add', '/')).toBe('/add') + expect(resolveRelativePath('./../add', '/')).toBe('/add') + expect(resolveRelativePath('../../add', '/')).toBe('/add') + expect(resolveRelativePath('../../../add', '/')).toBe('/add') + }) + + it('ignores it location is absolute', () => { + expect(resolveRelativePath('/add', '/users/posva')).toBe('/add') + }) + + it('resolves empty path', () => { + expect(resolveRelativePath('', '/users/posva')).toBe('/users/posva') + expect(resolveRelativePath('', '/users')).toBe('/users') + expect(resolveRelativePath('', '/')).toBe('/') + }) + + it('warns if from path is not absolute', () => { + resolveRelativePath('path', 'other') + resolveRelativePath('path', './other') + resolveRelativePath('path', '../other') + + expect('Cannot resolve').toHaveBeenWarnedTimes(3) + }) +}) diff --git a/__tests__/matcher/__snapshots__/resolve.spec.ts.snap b/__tests__/matcher/__snapshots__/resolve.spec.ts.snap new file mode 100644 index 000000000..d2ffdb9c2 --- /dev/null +++ b/__tests__/matcher/__snapshots__/resolve.spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RouterMatcher.resolve LocationAsName throws if the named route does not exists 1`] = ` +[Error: No match for + {"name":"Home"}] +`; + +exports[`RouterMatcher.resolve LocationAsRelative throws if the current named route does not exists 1`] = ` +[Error: No match for + {"params":{"a":"foo"}} +while being at +{"name":"home","params":{},"path":"/","meta":{}}] +`; diff --git a/__tests__/matcher/addingRemoving.spec.ts b/__tests__/matcher/addingRemoving.spec.ts new file mode 100644 index 000000000..6130754f4 --- /dev/null +++ b/__tests__/matcher/addingRemoving.spec.ts @@ -0,0 +1,400 @@ +import { createRouterMatcher } from '../../src/matcher' +import { MatcherLocation } from '../../src/types' +import { mockWarn } from 'jest-mock-warn' + +const currentLocation = { path: '/' } as MatcherLocation +// @ts-ignore +const component: RouteComponent = null + +describe('Matcher: adding and removing records', () => { + it('can add records', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ path: '/', component, name: 'home' }) + expect(matcher.resolve({ path: '/' }, currentLocation)).toMatchObject({ + name: 'home', + }) + }) + + it('throws when adding *', () => { + const matcher = createRouterMatcher([], {}) + expect(() => { + matcher.addRoute({ path: '*', component }) + }).toThrowError('Catch all') + }) + + it('does not throw when adding * in children', () => { + const matcher = createRouterMatcher([], {}) + expect(() => { + matcher.addRoute({ + path: '/something', + component, + children: [{ path: '*', component }], + }) + }).not.toThrow() + }) + + it('adds children', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ path: '/parent', component, name: 'home' }) + const parent = matcher.getRecordMatcher('home') + matcher.addRoute({ path: 'foo', component, name: 'foo' }, parent) + expect( + matcher.resolve({ path: '/parent/foo' }, currentLocation) + ).toMatchObject({ + name: 'foo', + matched: [ + expect.objectContaining({ name: 'home' }), + expect.objectContaining({ name: 'foo' }), + ], + }) + }) + + describe('addRoute returned function', () => { + it('remove records', () => { + const matcher = createRouterMatcher([], {}) + const remove = matcher.addRoute({ path: '/', component, name: 'home' }) + remove() + expect(matcher.resolve({ path: '/' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + }) + + it('remove children but not parent', () => { + const matcher = createRouterMatcher( + [{ path: '/', component, name: 'home' }], + {} + ) + const remove = matcher.addRoute( + { path: 'foo', component, name: 'child' }, + matcher.getRecordMatcher('home') + ) + remove() + expect(matcher.resolve({ path: '/' }, currentLocation)).toMatchObject({ + name: 'home', + }) + expect(matcher.resolve({ path: '/foo' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + }) + + it('remove aliases', () => { + const matcher = createRouterMatcher([], {}) + const remove = matcher.addRoute({ + path: '/', + component, + name: 'home', + alias: ['/home', '/start'], + }) + remove() + expect(matcher.resolve({ path: '/' }, currentLocation)).toMatchObject({ + path: '/', + name: undefined, + matched: [], + }) + expect(matcher.resolve({ path: '/home' }, currentLocation)).toMatchObject( + { + path: '/home', + name: undefined, + matched: [], + } + ) + expect( + matcher.resolve({ path: '/start' }, currentLocation) + ).toMatchObject({ + path: '/start', + name: undefined, + matched: [], + }) + }) + + it('remove aliases children', () => { + const matcher = createRouterMatcher([], {}) + const remove = matcher.addRoute({ + path: '/', + component, + name: 'home', + alias: ['/home', '/start'], + children: [ + { + path: 'one', + alias: ['o, o2'], + component, + children: [{ path: 'two', alias: ['t', 't2'], component }], + }, + ], + }) + remove() + ;[ + '/', + '/start', + '/home', + '/one/two', + '/start/one/two', + '/home/o/two', + '/home/one/t2', + '/o2/t', + ].forEach(path => { + expect(matcher.resolve({ path }, currentLocation)).toMatchObject({ + path, + name: undefined, + matched: [], + }) + }) + }) + + it('remove children when removing the parent', () => { + const matcher = createRouterMatcher([], {}) + const remove = matcher.addRoute({ + path: '/', + component, + name: 'home', + children: [ + // absolute path so it can work out + { path: '/about', name: 'child', component }, + ], + }) + + remove() + + expect( + matcher.resolve({ path: '/about' }, currentLocation) + ).toMatchObject({ + name: undefined, + matched: [], + }) + + expect(matcher.getRecordMatcher('child')).toBe(undefined) + expect(() => { + matcher.resolve({ name: 'child' }, currentLocation) + }).toThrow() + }) + }) + + it('can remove records by name', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ path: '/', component, name: 'home' }) + matcher.removeRoute('home') + expect(matcher.getRoutes()).toHaveLength(0) + expect(matcher.resolve({ path: '/' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + }) + + it('removes children when removing the parent', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ + path: '/', + component, + name: 'home', + children: [ + // absolute path so it can work out + { path: '/about', name: 'child', component }, + ], + }) + + matcher.removeRoute('home') + expect(matcher.resolve({ path: '/about' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + + expect(matcher.getRecordMatcher('child')).toBe(undefined) + expect(() => { + matcher.resolve({ name: 'child' }, currentLocation) + }).toThrow() + }) + + it('removes children by name', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ + path: '/', + component, + name: 'home', + children: [ + // absolute path so it can work out + { path: '/about', name: 'child', component }, + ], + }) + + expect(matcher.getRoutes()).toHaveLength(2) + matcher.removeRoute('child') + expect(matcher.getRoutes()).toHaveLength(1) + + expect(matcher.resolve({ path: '/about' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + + expect(matcher.getRecordMatcher('child')).toBe(undefined) + expect(() => { + matcher.resolve({ name: 'child' }, currentLocation) + }).toThrow() + + expect(matcher.resolve({ path: '/' }, currentLocation)).toMatchObject({ + name: 'home', + }) + }) + + it('removes children by name from parent', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ + path: '/', + component, + name: 'home', + children: [ + // absolute path so it can work out + { path: '/about', name: 'child', component }, + ], + }) + + matcher.removeRoute('home') + expect(matcher.getRoutes()).toHaveLength(0) + + expect(matcher.resolve({ path: '/about' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + + expect(matcher.getRecordMatcher('child')).toBe(undefined) + }) + + it('removes alias (and original) by name', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ + path: '/', + alias: '/start', + component, + name: 'home', + }) + + matcher.removeRoute('home') + expect(matcher.getRoutes()).toHaveLength(0) + + expect(matcher.resolve({ path: '/start' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + }) + + it('removes all children alias when removing parent by name', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ + path: '/', + alias: ['/start', '/home'], + component, + name: 'home', + children: [ + { + path: 'one', + alias: ['o', 'o2'], + component, + children: [{ path: 'two', alias: ['t', 't2'], component }], + }, + { + path: 'xxx', + alias: ['x', 'x2'], + component, + children: [ + { path: 'yyy', alias: ['y', 'y2'], component }, + { path: 'zzz', alias: ['z', 'z2'], component }, + ], + }, + ], + }) + + matcher.removeRoute('home') + expect(matcher.getRoutes()).toHaveLength(0) + ;[ + '/', + '/start', + '/home', + '/one/two', + '/start/one/two', + '/home/o/two', + '/home/one/t2', + '/o2/t', + '/xxx/yyy', + '/x/yyy', + '/x2/yyy', + '/x2/y', + '/x2/y2', + '/x2/zzz', + '/x2/z', + '/x2/z2', + '/start/xxx/yyy', + '/home/xxx/yyy', + '/home/xxx/z2', + '/home/x2/z2', + ].forEach(path => { + expect(matcher.resolve({ path }, currentLocation)).toMatchObject({ + path, + name: undefined, + matched: [], + }) + }) + }) + + it('removes children alias (and original) by name', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ + path: '/', + alias: '/start', + component, + name: 'home', + children: [{ path: 'about', alias: 'two', name: 'child', component }], + }) + + matcher.removeRoute('child') + + expect(matcher.getRoutes()).toHaveLength(2) + + expect(matcher.resolve({ path: '/about' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + + expect(matcher.resolve({ path: '/two' }, currentLocation)).toMatchObject({ + name: undefined, + matched: [], + }) + + expect( + matcher.resolve({ path: '/start/about' }, currentLocation) + ).toMatchObject({ + name: undefined, + matched: [], + }) + + expect( + matcher.resolve({ path: '/start/two' }, currentLocation) + ).toMatchObject({ + name: undefined, + matched: [], + }) + + expect(matcher.getRecordMatcher('child')).toBe(undefined) + }) + + it('removes existing record when adding with the same name', () => { + const matcher = createRouterMatcher([], {}) + matcher.addRoute({ path: '/', component, name: 'home' }) + matcher.addRoute({ path: '/home', component, name: 'home' }) + expect(matcher.getRoutes()).toHaveLength(1) + expect(matcher.resolve({ path: '/home' }, currentLocation)).toMatchObject({ + name: 'home', + }) + }) + + describe('warnings', () => { + mockWarn() + + // TODO: add warnings for invalid records + it.skip('warns if alias is missing a required param', () => { + createRouterMatcher([{ path: '/:id', alias: '/no-id', component }], {}) + expect('TODO').toHaveBeenWarned() + }) + }) +}) diff --git a/__tests__/matcher/pathParser.spec.ts b/__tests__/matcher/pathParser.spec.ts new file mode 100644 index 000000000..b897243bb --- /dev/null +++ b/__tests__/matcher/pathParser.spec.ts @@ -0,0 +1,787 @@ +import { tokenizePath, TokenType } from '../../src/matcher/pathTokenizer' +import { tokensToParser } from '../../src/matcher/pathParserRanker' + +describe('Path parser', () => { + describe('tokenizer', () => { + it('root', () => { + expect(tokenizePath('/')).toEqual([ + [{ type: TokenType.Static, value: '' }], + ]) + }) + + it('empty', () => { + expect(tokenizePath('')).toEqual([[]]) + }) + + it('not start with /', () => { + expect(() => tokenizePath('a')).toThrowError(`"a" should be "/a"`) + }) + + it('escapes :', () => { + expect(tokenizePath('/\\:')).toEqual([ + [{ type: TokenType.Static, value: ':' }], + ]) + }) + + it('escapes {', () => { + expect(tokenizePath('/\\{')).toEqual([ + [{ type: TokenType.Static, value: '{' }], + ]) + }) + + // not sure how useful this is and if it's worth supporting because of the + // cost to support the ranking as well + it.skip('groups', () => { + expect(tokenizePath('/one{-b_:id}')).toEqual([ + [ + { type: TokenType.Static, value: 'one' }, + { + type: TokenType.Group, + groups: [ + { type: TokenType.Static, value: '-b_' }, + { type: TokenType.Param, value: 'id' }, + ], + }, + ], + ]) + }) + + // same as above + it.skip('escapes } inside group', () => { + expect(tokenizePath('/{\\{}')).toEqual([ + [{ type: TokenType.Static, value: '{' }], + ]) + }) + + it('escapes ( inside custom re', () => { + expect(tokenizePath('/:a(\\))')).toEqual([ + [ + { + type: TokenType.Param, + value: 'a', + regexp: ')', + optional: false, + repeatable: false, + }, + ], + ]) + }) + + it('static single', () => { + expect(tokenizePath('/home')).toEqual([ + [{ type: TokenType.Static, value: 'home' }], + ]) + }) + + it('static multiple', () => { + expect(tokenizePath('/one/two/three')).toEqual([ + [{ type: TokenType.Static, value: 'one' }], + [{ type: TokenType.Static, value: 'two' }], + [{ type: TokenType.Static, value: 'three' }], + ]) + }) + + it('param single', () => { + expect(tokenizePath('/:id')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param custom re', () => { + expect(tokenizePath('/:id(\\d+)')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param custom re followed by static', () => { + expect(tokenizePath('/:id(\\d+)hey')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: false, + }, + { + type: TokenType.Static, + value: 'hey', + }, + ], + ]) + }) + + it('param custom re followed by new segment', () => { + expect(tokenizePath('/:id(\\d+)/new')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: false, + }, + ], + [ + { + type: TokenType.Static, + value: 'new', + }, + ], + ]) + }) + + it('param custom re?', () => { + expect(tokenizePath('/:id(\\d+)?')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: true, + }, + ], + ]) + }) + + it('param custom re? followed by static', () => { + expect(tokenizePath('/:id(\\d+)?hey')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: true, + }, + { + type: TokenType.Static, + value: 'hey', + }, + ], + ]) + }) + + it('param custom re? followed by new segment', () => { + expect(tokenizePath('/:id(\\d+)?/new')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: true, + }, + ], + [ + { + type: TokenType.Static, + value: 'new', + }, + ], + ]) + }) + + it('param single?', () => { + expect(tokenizePath('/:id?')).toEqual([ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '', + repeatable: false, + optional: true, + }, + ], + ]) + }) + + it('param single+', () => { + expect(tokenizePath('/:id+')).toMatchObject([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: true, + optional: false, + }, + ], + ]) + }) + + it('param single*', () => { + expect(tokenizePath('/:id*')).toMatchObject([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: true, + optional: true, + }, + ], + ]) + }) + + it('param multiple', () => { + expect(tokenizePath('/:id/:other')).toMatchObject([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + [ + { + type: TokenType.Param, + value: 'other', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param multiple together', () => { + expect(tokenizePath('/:id:other:more')).toMatchObject([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + { + type: TokenType.Param, + value: 'other', + repeatable: false, + optional: false, + }, + { + type: TokenType.Param, + value: 'more', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param with static in between', () => { + expect(tokenizePath('/:id-:other')).toMatchObject([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + { + type: TokenType.Static, + value: '-', + }, + { + type: TokenType.Param, + value: 'other', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param with static beginning', () => { + expect(tokenizePath('/hey-:id')).toMatchObject([ + [ + { + type: TokenType.Static, + value: 'hey-', + }, + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param with static end', () => { + expect(tokenizePath('/:id-end')).toMatchObject([ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + { + type: TokenType.Static, + value: '-end', + }, + ], + ]) + }) + // end of describe token + }) + + describe('tokensToParser', () => { + function matchRegExp( + expectedRe: string, + ...args: Parameters + ) { + const pathParser = tokensToParser(...args) + const options = args[1] || {} + expect( + pathParser.re + .toString() + // remove the starting and ending slash of RegExp as well as any modifier + // /^\\/home$/i -> ^\\@home$ + .replace(/(:?^\/|\/\w*$)/g, '') + // remove escaped / to make it easier to write in tests + .replace(/\\\//g, '/') + // only check the trailing slash if we provided a strict option + .replace(/\/\?\$?$/, 'strict' in options ? '$&' : '$') + ).toBe(expectedRe) + } + + it('static single', () => { + matchRegExp('^/?$', [[]], { strict: false }) + matchRegExp('^/$', [[]], { strict: true }) + }) + + it('regex special characters', () => { + matchRegExp('^/foo\\+\\.\\*\\?$', [ + [{ type: TokenType.Static, value: 'foo+.*?' }], + ]) + matchRegExp('^/foo\\$\\^$', [ + [{ type: TokenType.Static, value: 'foo$^' }], + ]) + matchRegExp('^/foo\\[ea\\]$', [ + [{ type: TokenType.Static, value: 'foo[ea]' }], + ]) + matchRegExp('^/foo\\(e|a\\)$', [ + [{ type: TokenType.Static, value: 'foo(e|a)' }], + ]) + matchRegExp('^/(\\d+)\\{2\\}$', [ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: false, + }, + { type: TokenType.Static, value: '{2}' }, + ], + ]) + }) + + it('strict /', () => { + matchRegExp('^/$', [[{ type: TokenType.Static, value: '' }]], { + strict: true, + }) + }) + + it('static single', () => { + matchRegExp('^/home$', [[{ type: TokenType.Static, value: 'home' }]]) + }) + + it('static multiple', () => { + matchRegExp('^/home/other$', [ + [{ type: TokenType.Static, value: 'home' }], + [{ type: TokenType.Static, value: 'other' }], + ]) + }) + + it('param single', () => { + matchRegExp('^/([^/]+?)$', [ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param multiple', () => { + matchRegExp('^/([^/]+?)/([^/]+?)$', [ + [ + { + type: TokenType.Param, + value: 'id', + repeatable: false, + optional: false, + }, + ], + [ + { + type: TokenType.Param, + value: 'two', + repeatable: false, + optional: false, + }, + ], + ]) + }) + + it('param*', () => { + matchRegExp('^(?:/((?:\\d+)(?:/(?:\\d+))*))?$', [ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: true, + optional: true, + }, + ], + ]) + }) + + it('param?', () => { + matchRegExp( + '^(?:/(\\d+))?/?$', + [ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: true, + }, + ], + ], + { strict: false } + ) + }) + + it('static and param?', () => { + matchRegExp('^/ab(?:/(\\d+))?$', [ + [ + { + type: TokenType.Static, + value: 'ab', + }, + ], + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: false, + optional: true, + }, + ], + ]) + }) + + it('param+', () => { + matchRegExp('^/((?:\\d+)(?:/(?:\\d+))*)$', [ + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: true, + optional: false, + }, + ], + ]) + }) + + it('static and param+', () => { + matchRegExp('^/ab/((?:\\d+)(?:/(?:\\d+))*)$', [ + [ + { + type: TokenType.Static, + value: 'ab', + }, + ], + [ + { + type: TokenType.Param, + value: 'id', + regexp: '\\d+', + repeatable: true, + optional: false, + }, + ], + ]) + }) + // end of describe + }) + + describe('parsing urls', () => { + function matchParams( + path: string, + pathToTest: string, + params: ReturnType['parse']>, + options?: Parameters[1] + ) { + const pathParser = tokensToParser(tokenizePath(path), options) + + expect(pathParser.parse(pathToTest)).toEqual(params) + } + + it('returns null if no match', () => { + matchParams('/home', '/', null) + }) + + it('allows an empty root', () => { + matchParams('', '/', {}) + }) + + it('makes the difference between "" and "/" when strict', () => { + matchParams('/foo', '/foo/', null, { strict: true }) + matchParams('/foo/', '/foo', null, { strict: true }) + }) + + it('allows a trailing slash', () => { + matchParams('/home', '/home/', {}) + matchParams('/a/b', '/a/b/', {}) + }) + + it('enforces a trailing slash', () => { + matchParams('/home/', '/home', null, { strict: true }) + }) + + it('allow a trailing slash in repeated params', () => { + matchParams('/a/:id+', '/a/b/c/d/', { id: ['b', 'c', 'd'] }) + matchParams('/a/:id*', '/a/b/c/d/', { id: ['b', 'c', 'd'] }) + matchParams('/a/:id*', '/a/', { id: '' }) + matchParams('/a/:id*', '/a', { id: '' }) + }) + + it('allow no slash', () => { + matchParams('/home', '/home/', null, { strict: true }) + matchParams('/home', '/home', {}, { strict: true }) + }) + + it('is insensitive by default', () => { + matchParams('/home', '/HOMe', {}) + }) + + it('can be sensitive', () => { + matchParams('/home', '/HOMe', null, { sensitive: true }) + matchParams('/home', '/home', {}, { sensitive: true }) + }) + + it('can not match the beginning', () => { + matchParams('/home', '/other/home', null, { start: true }) + matchParams('/home', '/other/home', {}, { start: false }) + }) + + it('can not match the end', () => { + matchParams('/home', '/home/other', null, { end: true }) + matchParams('/home', '/home/other', {}, { end: false }) + }) + + it('returns an empty object with no keys', () => { + matchParams('/home', '/home', {}) + }) + + it('param single', () => { + matchParams('/:id', '/a', { id: 'a' }) + }) + + it('param combined', () => { + matchParams('/hey:a', '/heyedu', { + a: 'edu', + }) + }) + + // TODO: better syntax? like /a/{b-:param}+ + // also to allow repeatable because otherwise groups are meaningless + it('groups (extract a part of the param)', () => { + matchParams('/a/:a(?:b-([^/]+\\)?)', '/a/b-one', { + a: 'one', + }) + matchParams('/a/:a(?:b-([^/]+\\)?)', '/a/b-', { + a: '', + }) + // non optional + matchParams('/a/:a(?:b-([^/]+\\))', '/a/b-one', { + a: 'one', + }) + }) + + it('catch all', () => { + matchParams('/:rest(.*)', '/a/b/c', { rest: 'a/b/c' }) + matchParams('/:rest(.*)/no', '/a/b/c/no', { rest: 'a/b/c' }) + }) + + it('catch all non-greedy', () => { + matchParams('/:rest(.*?)/b/:other(.*)', '/a/b/c', { + rest: 'a', + other: 'c', + }) + }) + + it('param multiple', () => { + matchParams('/:a-:b-:c', '/one-two-three', { + a: 'one', + b: 'two', + c: 'three', + }) + }) + + it('param optional', () => { + matchParams('/:a?', '/one', { a: 'one' }) + matchParams('/:a*', '/one', { a: ['one'] }) + }) + + it('empty param optional', () => { + matchParams('/:a?', '/', { a: '' }) + matchParams('/:a*', '/', { a: '' }) + }) + + it('static then empty param optional', () => { + matchParams('/a/:a?', '/a', { a: '' }) + matchParams('/a/:a?', '/a/a', { a: 'a' }) + matchParams('/a/:a?', '/a/a/', { a: 'a' }) + matchParams('/a/:a?', '/a/', { a: '' }) + matchParams('/a/:a*', '/a', { a: '' }) + matchParams('/a/:a*', '/a/', { a: '' }) + }) + + it('static then param optional', () => { + matchParams('/one/:a?', '/one/two', { a: 'two' }) + matchParams('/one/:a?', '/one/', { a: '' }) + // can only match one time + matchParams('/one/:a?', '/one/two/three', null) + matchParams('/one/:a*', '/one/two', { a: ['two'] }) + }) + + it('param optional followed by static', () => { + matchParams('/:a?/one', '/two/one', { a: 'two' }) + // since the first one is optional + matchParams('/:a?/one', '/one', { a: '' }) + matchParams('/:a?/one', '/two', null) + // can only match one time + matchParams('/:a?/one', '/two/three/one', null) + matchParams('/:a*/one', '/two/one', { a: ['two'] }) + }) + + it('param repeatable', () => { + matchParams('/:a+', '/one/two', { + a: ['one', 'two'], + }) + matchParams('/:a*', '/one/two', { + a: ['one', 'two'], + }) + }) + + it('param repeatable with static', () => { + matchParams('/one/:a+', '/one/two', { + a: ['two'], + }) + matchParams('/one/:a+', '/one/two/three', { + a: ['two', 'three'], + }) + matchParams('/one/:a*', '/one/two', { + a: ['two'], + }) + matchParams('/one/:a*', '/one/two/three', { + a: ['two', 'three'], + }) + }) + + // end of parsing urls + }) + + describe('generating urls', () => { + function matchStringify( + path: string, + params: Exclude< + ReturnType['parse']>, + null + >, + expectedUrl: string, + options?: Parameters[1] + ) { + const pathParser = tokensToParser(tokenizePath(path), options) + + expect(pathParser.stringify(params)).toEqual(expectedUrl) + } + + it('no params one segment', () => { + matchStringify('/home', {}, '/home') + }) + + it('works with trailing slash', () => { + matchStringify('/home/', {}, '/home/') + matchStringify('/home/', {}, '/home/', { strict: true }) + }) + + it('single param one segment', () => { + matchStringify('/:id', { id: 'one' }, '/one') + }) + + it('params with custom regexp', () => { + matchStringify('/:id(\\d+)-:w(\\w+)', { id: '2', w: 'hey' }, '/2-hey') + }) + + it('multiple param one segment', () => { + matchStringify('/:a-:b', { a: 'one', b: 'two' }, '/one-two') + }) + + it('repeatable params+', () => { + matchStringify('/:a+', { a: ['one', 'two'] }, '/one/two') + }) + + it('repeatable params+ with extra segment', () => { + matchStringify('/:a+/other', { a: ['one', 'two'] }, '/one/two/other') + }) + + it('repeatable params*', () => { + matchStringify('/:a*', { a: ['one', 'two'] }, '/one/two') + }) + + it('static then optional param?', () => { + matchStringify('/a/:a?', { a: '' }, '/a') + matchStringify('/a/:a?', {}, '/a') + }) + + it('optional param?', () => { + matchStringify('/:a?/other', { a: '' }, '/other') + matchStringify('/:a?/other', {}, '/other') + }) + + it('optional param? with static segment', () => { + matchStringify('/b-:a?/other', { a: '' }, '/b-/other') + matchStringify('/b-:a?/other', {}, '/b-/other') + }) + + it('optional param*', () => { + matchStringify('/:a*/other', { a: '' }, '/other') + matchStringify('/:a*/other', { a: [] }, '/other') + matchStringify('/:a*/other', {}, '/other') + }) + + // end of generating urls + }) +}) diff --git a/__tests__/matcher/pathRanking.spec.ts b/__tests__/matcher/pathRanking.spec.ts new file mode 100644 index 000000000..86e9151a1 --- /dev/null +++ b/__tests__/matcher/pathRanking.spec.ts @@ -0,0 +1,261 @@ +import { tokenizePath } from '../../src/matcher/pathTokenizer' +import { + tokensToParser, + comparePathParserScore, +} from '../../src/matcher/pathParserRanker' + +type PathParserOptions = Parameters[1] + +describe('Path ranking', () => { + describe('comparePathParser', () => { + function compare(a: number[][], b: number[][]): number { + return comparePathParserScore( + { + score: a, + re: /a/, + // @ts-ignore + stringify: v => v, + // @ts-ignore + parse: v => v, + keys: [], + }, + { + score: b, + re: /a/, + // @ts-ignore + stringify: v => v, + // @ts-ignore + parse: v => v, + keys: [], + } + ) + } + + it('same length', () => { + expect(compare([[2]], [[3]])).toEqual(1) + expect(compare([[2]], [[2]])).toEqual(0) + expect(compare([[4]], [[3]])).toEqual(-1) + }) + it('longer', () => { + expect(compare([[2]], [[3, 1]])).toEqual(1) + // NOTE: we are assuming we never pass end: false + expect(compare([[3]], [[3, 1]])).toEqual(1) + expect(compare([[1, 3]], [[2]])).toEqual(1) + expect(compare([[4]], [[3]])).toEqual(-1) + expect(compare([], [[3]])).toEqual(1) + }) + }) + + const possibleOptions: PathParserOptions[] = [ + undefined, + { strict: true, sensitive: false }, + { strict: false, sensitive: true }, + { strict: true, sensitive: true }, + ] + + function joinScore(score: number[][]): string { + return score.map(s => `[${s.join(', ')}]`).join(' ') + } + + function checkPathOrder(paths: Array) { + const normalizedPaths = paths.map(pathOrArray => { + let path: string + let options: PathParserOptions + if (typeof pathOrArray === 'string') { + path = pathOrArray + } else { + path = pathOrArray[0] + options = pathOrArray[1] + } + + return { + id: path + (options ? JSON.stringify(options) : ''), + path, + options, + } + }) + + // reverse the array to force some reordering + const parsers = normalizedPaths + .slice() + .reverse() + .map(({ id, path, options }) => ({ + ...tokensToParser(tokenizePath(path), options), + id, + })) + + parsers.sort((a, b) => comparePathParserScore(a, b)) + + for (let i = 0; i < parsers.length - 1; i++) { + const a = parsers[i] + const b = parsers[i + 1] + + try { + expect(a.score).not.toEqual(b.score) + } catch (err) { + console.warn( + 'Different routes should not have the same score:\n' + + `${a.id} -> ${joinScore(a.score)}\n${b.id} -> ${joinScore(b.score)}` + ) + + throw err + } + } + + try { + expect(parsers.map(parser => parser.id)).toEqual( + normalizedPaths.map(path => path.id) + ) + } catch (err) { + console.warn( + parsers + .map(parser => `${parser.id} -> ${joinScore(parser.score)}`) + .join('\n') + ) + throw err + } + } + + it('works', () => { + checkPathOrder([ + '/a/b/c', + '/a/b', + '/a/:b/c', + '/a/:b', + '/a', + '/a-:b-:c', + '/a-:b', + '/a-:w(.*)', + '/:a-:b-:c', + '/:a-:b', + '/:a-:b(.*)', + '/:a/-:b', + '/:a/:b', + '/:w', + '/:w+', + ]) + }) + + it('puts the slash before optional parameters', () => { + possibleOptions.forEach(options => { + checkPathOrder(['/', ['/:a?', options]]) + checkPathOrder(['/', ['/:a*', options]]) + checkPathOrder(['/', ['/:a(\\d+)?', options]]) + checkPathOrder(['/', ['/:a(\\d+)*', options]]) + }) + }) + + it('sensitive should go before non sensitive', () => { + checkPathOrder([ + ['/Home', { sensitive: true }], + ['/home', {}], + ]) + checkPathOrder([ + ['/:w', { sensitive: true }], + ['/:w', {}], + ]) + }) + + it('strict should go before non strict', () => { + checkPathOrder([ + ['/home', { strict: true }], + ['/home', {}], + ]) + }) + + it('orders repeatable and optional', () => { + possibleOptions.forEach(options => { + checkPathOrder(['/:w', ['/:w?', options]]) + checkPathOrder(['/:w?', ['/:w+', options]]) + checkPathOrder(['/:w+', ['/:w*', options]]) + checkPathOrder(['/:w+', ['/:w(.*)', options]]) + }) + }) + + it('orders static before params', () => { + possibleOptions.forEach(options => { + checkPathOrder(['/a', ['/:id', options]]) + }) + }) + + it('empty path before slash', () => { + possibleOptions.forEach(options => { + checkPathOrder(['', ['/', options]]) + }) + }) + + it('works with long paths', () => { + checkPathOrder(['/a/b/c/d/e', '/:k/b/c/d/e', '/:k/b/c/d/:j']) + }) + + it('prioritizes custom regex', () => { + checkPathOrder(['/:a(\\d+)', '/:a', '/:a(.*)']) + checkPathOrder(['/b-:a(\\d+)', '/b-:a', '/b-:a(.*)']) + }) + + it('prioritizes ending slashes', () => { + checkPathOrder([ + // no strict + '/a/', + '/a', + ]) + checkPathOrder([ + // no strict + '/a/b/', + '/a/b', + ]) + + checkPathOrder([['/a/', { strict: true }], '/a/']) + checkPathOrder([['/a', { strict: true }], '/a']) + }) + + it('puts the wildcard at the end', () => { + possibleOptions.forEach(options => { + checkPathOrder([['', options], '/:rest(.*)']) + checkPathOrder([['/', options], '/:rest(.*)']) + checkPathOrder([['/ab', options], '/:rest(.*)']) + checkPathOrder([['/:a', options], '/:rest(.*)']) + checkPathOrder([['/:a?', options], '/:rest(.*)']) + checkPathOrder([['/:a+', options], '/:rest(.*)']) + checkPathOrder([['/:a*', options], '/:rest(.*)']) + checkPathOrder([['/:a(\\d+)', options], '/:rest(.*)']) + checkPathOrder([['/:a(\\d+)?', options], '/:rest(.*)']) + checkPathOrder([['/:a(\\d+)+', options], '/:rest(.*)']) + checkPathOrder([['/:a(\\d+)*', options], '/:rest(.*)']) + }) + }) + + it('handles sub segments', () => { + checkPathOrder([ + '/a/_2_', + // something like /a/_23_ + '/a/_:b(\\d)other', + '/a/_:b(\\d)?other', + '/a/_:b-other', // the _ is escaped but b can be also letters + '/a/a_:b', + ]) + }) + + it('handles repeatable and optional in sub segments', () => { + checkPathOrder([ + '/a/_:b-other', + '/a/_:b?-other', + '/a/_:b+-other', + '/a/_:b*-other', + ]) + checkPathOrder([ + '/a/_:b(\\d)-other', + '/a/_:b(\\d)?-other', + '/a/_:b(\\d)+-other', + '/a/_:b(\\d)*-other', + ]) + }) + + it('ending slashes less than params', () => { + checkPathOrder([ + ['/a/b', { strict: false }], + ['/a/:b', { strict: true }], + ['/a/:b/', { strict: true }], + ]) + }) +}) diff --git a/__tests__/matcher/records.spec.ts b/__tests__/matcher/records.spec.ts new file mode 100644 index 000000000..698edcdfb --- /dev/null +++ b/__tests__/matcher/records.spec.ts @@ -0,0 +1,89 @@ +import { normalizeRouteRecord } from '../../src/matcher' + +describe('normalizeRouteRecord', () => { + it('transforms a single view into multiple views', () => { + const record = normalizeRouteRecord({ + path: '/home', + component: {}, + }) + expect(record).toMatchObject({ + beforeEnter: undefined, + children: [], + aliasOf: undefined, + components: { default: {} }, + leaveGuards: expect.any(Set), + updateGuards: expect.any(Set), + instances: {}, + meta: {}, + name: undefined, + path: '/home', + props: { default: false }, + }) + }) + + it('keeps original values in single view', () => { + const beforeEnter = jest.fn() + const record = normalizeRouteRecord({ + path: '/home', + beforeEnter, + children: [{ path: '/child' } as any], + meta: { foo: true }, + name: 'name', + component: {}, + }) + expect(record).toMatchObject({ + beforeEnter, + children: [{ path: '/child' }], + components: { default: {} }, + leaveGuards: expect.any(Set), + updateGuards: expect.any(Set), + instances: {}, + meta: { foo: true }, + name: 'name', + path: '/home', + props: { default: false }, + }) + }) + + it('keeps original values in redirect', () => { + const record = normalizeRouteRecord({ + path: '/redirect', + redirect: '/home', + meta: { foo: true }, + name: 'name', + }) + + expect(record).toMatchObject({ + aliasOf: undefined, + components: {}, + meta: { foo: true }, + name: 'name', + path: '/redirect', + redirect: '/home', + }) + }) + + it('keeps original values in multiple views', () => { + const beforeEnter = jest.fn() + const record = normalizeRouteRecord({ + path: '/home', + beforeEnter, + children: [{ path: '/child' } as any], + meta: { foo: true }, + name: 'name', + components: { one: {} }, + }) + expect(record).toMatchObject({ + beforeEnter, + children: [{ path: '/child' }], + components: { one: {} }, + leaveGuards: expect.any(Set), + updateGuards: expect.any(Set), + instances: {}, + meta: { foo: true }, + name: 'name', + path: '/home', + props: { one: false }, + }) + }) +}) diff --git a/__tests__/matcher/resolve.spec.ts b/__tests__/matcher/resolve.spec.ts new file mode 100644 index 000000000..1a5b1edea --- /dev/null +++ b/__tests__/matcher/resolve.spec.ts @@ -0,0 +1,1280 @@ +import { createRouterMatcher, normalizeRouteRecord } from '../../src/matcher' +import { + START_LOCATION_NORMALIZED, + RouteComponent, + RouteRecordRaw, + MatcherLocationRaw, + MatcherLocation, +} from '../../src/types' +import { MatcherLocationNormalizedLoose } from '../utils' +import { mockWarn } from 'jest-mock-warn' + +// @ts-ignore +const component: RouteComponent = null + +// for normalized records +const components = { default: component } + +describe('RouterMatcher.resolve', () => { + function assertRecordMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + resolved: Partial, + start: MatcherLocation = START_LOCATION_NORMALIZED + ) { + record = Array.isArray(record) ? record : [record] + const matcher = createRouterMatcher(record, {}) + + if (!('meta' in resolved)) { + resolved.meta = record[0].meta || {} + } + + if (!('name' in resolved)) { + resolved.name = undefined + } + + // add location if provided as it should be the same value + if ('path' in location && !('path' in resolved)) { + resolved.path = location.path + } + + if ('redirect' in record) { + throw new Error('not handled') + } else { + // use one single record + if (!resolved.matched) + // @ts-ignore + resolved.matched = record.map(normalizeRouteRecord) + // allow passing an expect.any(Array) + else if (Array.isArray(resolved.matched)) + resolved.matched = resolved.matched.map(m => ({ + ...normalizeRouteRecord(m as any), + aliasOf: m.aliasOf, + })) + } + + // allows not passing params + resolved.params = + resolved.params || ('params' in location ? location.params : {}) + + const startCopy: MatcherLocation = { + ...start, + matched: start.matched.map(m => ({ + ...normalizeRouteRecord(m), + aliasOf: m.aliasOf, + })) as MatcherLocation['matched'], + } + + // make matched non enumerable + Object.defineProperty(startCopy, 'matched', { enumerable: false }) + + const result = matcher.resolve(location, startCopy) + expect(result).toEqual(resolved) + } + + /** + * + * @param record - Record or records we are testing the matcher against + * @param location - location we want to reolve against + * @param [start] Optional currentLocation used when resolving + * @returns error + */ + function assertErrorMatch( + record: RouteRecordRaw | RouteRecordRaw[], + location: MatcherLocationRaw, + start: MatcherLocation = START_LOCATION_NORMALIZED + ): any { + try { + assertRecordMatch(record, location, {}, start) + } catch (error) { + return error + } + throw new Error('Expected Error to be thrown') + } + + describe('alias', () => { + it('resolves an alias', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('multiple aliases', () => { + const record = { + path: '/', + alias: ['/home', '/start'], + name: 'Home', + components, + meta: { foo: true }, + } + + assertRecordMatch( + record, + { path: '/' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/home' }, + { + name: 'Home', + path: '/home', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/home', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + assertRecordMatch( + record, + { path: '/start' }, + { + name: 'Home', + path: '/start', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/start', + name: 'Home', + components, + aliasOf: expect.objectContaining({ name: 'Home', path: '/' }), + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves the original record by name', () => { + assertRecordMatch( + { + path: '/', + alias: '/home', + name: 'Home', + components, + meta: { foo: true }, + }, + { name: 'Home' }, + { + name: 'Home', + path: '/', + params: {}, + meta: { foo: true }, + matched: [ + { + path: '/', + name: 'Home', + components, + aliasOf: undefined, + meta: { foo: true }, + }, + ], + } + ) + }) + + it('resolves an alias with children to the alias when using the path', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { path: '/p/one' }, + { + path: '/p/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/p', + children, + components, + aliasOf: expect.objectContaining({ path: '/parent' }), + }, + { + path: '/p/one', + name: 'nested', + components, + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }, + ], + } + ) + }) + + describe('nested aliases', () => { + const children = [ + { + path: 'one', + component, + name: 'nested', + alias: 'o', + children: [ + { path: 'two', alias: 't', name: 'nestednested', component }, + ], + }, + { + path: 'other', + alias: 'otherAlias', + component, + name: 'other', + }, + ] + const record = { + path: '/parent', + name: 'parent', + alias: '/p', + component, + children, + } + + it('resolves the parent as an alias', () => { + assertRecordMatch( + record, + { path: '/p' }, + expect.objectContaining({ + path: '/p', + name: 'parent', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + ], + }) + ) + }) + + describe('multiple children', () => { + // tests concerning the /parent/other path and its aliases + + it('resolves the alias parent', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias child', () => { + assertRecordMatch( + record, + { path: '/parent/otherAlias' }, + expect.objectContaining({ + path: '/parent/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves the alias parent and child', () => { + assertRecordMatch( + record, + { path: '/p/otherAlias' }, + expect.objectContaining({ + path: '/p/otherAlias', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/otherAlias', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original one with no aliases', () => { + assertRecordMatch( + record, + { path: '/parent/one/two' }, + expect.objectContaining({ + path: '/parent/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/two', + aliasOf: undefined, + }), + ], + }) + ) + }) + + it.todo('resolves when parent is an alias and child has an absolute path') + + it('resolves when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/one/two' }, + expect.objectContaining({ + path: '/p/one/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves a different child when parent is an alias', () => { + assertRecordMatch( + record, + { path: '/p/other' }, + expect.objectContaining({ + path: '/p/other', + name: 'other', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/other', + aliasOf: expect.objectContaining({ path: '/parent/other' }), + }), + ], + }) + ) + }) + + it('resolves when the first child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/o/two' }, + expect.objectContaining({ + path: '/parent/o/two', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/two', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the second child is an alias', () => { + assertRecordMatch( + record, + { path: '/parent/one/t' }, + expect.objectContaining({ + path: '/parent/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when the two last children are aliases', () => { + assertRecordMatch( + record, + { path: '/parent/o/t' }, + expect.objectContaining({ + path: '/parent/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/parent', + aliasOf: undefined, + }), + expect.objectContaining({ + path: '/parent/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/parent/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when all are aliases', () => { + assertRecordMatch( + record, + { path: '/p/o/t' }, + expect.objectContaining({ + path: '/p/o/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/o', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/o/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + + it('resolves when first and last are aliases', () => { + assertRecordMatch( + record, + { path: '/p/one/t' }, + expect.objectContaining({ + path: '/p/one/t', + name: 'nestednested', + matched: [ + expect.objectContaining({ + path: '/p', + aliasOf: expect.objectContaining({ path: '/parent' }), + }), + expect.objectContaining({ + path: '/p/one', + aliasOf: expect.objectContaining({ path: '/parent/one' }), + }), + expect.objectContaining({ + path: '/p/one/t', + aliasOf: expect.objectContaining({ path: '/parent/one/two' }), + }), + ], + }) + ) + }) + }) + + it('resolves the original path of the named children of a route with an alias', () => { + const children = [{ path: 'one', component, name: 'nested' }] + assertRecordMatch( + { + path: '/parent', + alias: '/p', + component, + children, + }, + { name: 'nested' }, + { + path: '/parent/one', + name: 'nested', + params: {}, + matched: [ + { + path: '/parent', + children, + components, + aliasOf: undefined, + }, + { path: '/parent/one', name: 'nested', components }, + ], + } + ) + }) + }) + + describe('LocationAsPath', () => { + it('resolves a normal path', () => { + assertRecordMatch( + { path: '/', name: 'Home', components }, + { path: '/' }, + { name: 'Home', path: '/', params: {} } + ) + }) + + it('resolves a normal path without name', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/' }, + { name: undefined, path: '/', params: {} } + ) + }) + + it('resolves a path with params', () => { + assertRecordMatch( + { path: '/users/:id', name: 'User', components }, + { path: '/users/posva' }, + { name: 'User', params: { id: 'posva' } } + ) + }) + + it('resolves an array of params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: ['b', 'c', 'd'] } }, + { name: 'a', path: '/a/b/c/d', params: { p: ['b', 'c', 'd'] } } + ) + }) + + it('resolves single params for a repeatable params', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { name: 'a', params: { p: 'b' } }, + { name: 'a', path: '/a/b', params: { p: 'b' } } + ) + }) + + it('keeps repeated params as a single one when provided through path', () => { + assertRecordMatch( + { path: '/a/:p+', name: 'a', components }, + { path: '/a/b/c' }, + { name: 'a', params: { p: ['b', 'c'] } } + ) + }) + + it('resolves a path with multiple params', () => { + assertRecordMatch( + { path: '/users/:id/:other', name: 'User', components }, + { path: '/users/posva/hey' }, + { name: 'User', params: { id: 'posva', other: 'hey' } } + ) + }) + + it('resolves a path with multiple params but no name', () => { + assertRecordMatch( + { path: '/users/:id/:other', components }, + { path: '/users/posva/hey' }, + { name: undefined, params: { id: 'posva', other: 'hey' } } + ) + }) + + it('returns an empty match when the path does not exist', () => { + assertRecordMatch( + { path: '/', components }, + { path: '/foo' }, + { name: undefined, params: {}, path: '/foo', matched: [] } + ) + }) + + it('allows an optional trailing slash', () => { + assertRecordMatch( + { path: '/home/', name: 'Home', components }, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('allows an optional trailing slash with optional param', () => { + assertRecordMatch( + { path: '/:a', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: 'a' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a', components, name: 'a' }, + { path: '/a/a/' }, + { path: '/a/a/', params: { a: 'a' }, name: 'a' } + ) + }) + + it('allows an optional trailing slash with missing optional param', () => { + assertRecordMatch( + { path: '/:a?', components, name: 'a' }, + { path: '/' }, + { path: '/', params: { a: '' }, name: 'a' } + ) + assertRecordMatch( + { path: '/a/:a?', components, name: 'a' }, + { path: '/a/' }, + { path: '/a/', params: { a: '' }, name: 'a' } + ) + }) + + // FIXME: + it.skip('keeps required trailing slash (strict: true)', () => { + const record = { + path: '/home/', + name: 'Home', + components, + options: { strict: true }, + } + assertErrorMatch(record, { path: '/home' }) + assertRecordMatch( + record, + { path: '/home/' }, + { name: 'Home', path: '/home/', matched: expect.any(Array) } + ) + }) + + it('rejects a trailing slash when strict', () => { + const record = { + path: '/home', + name: 'Home', + components, + options: { strict: true }, + } + assertRecordMatch( + record, + { path: '/home' }, + { name: 'Home', path: '/home', matched: expect.any(Array) } + ) + assertErrorMatch(record, { path: '/home/' }) + }) + }) + + describe('LocationAsName', () => { + it('matches a name', () => { + assertRecordMatch( + { path: '/home', name: 'Home', components }, + { name: 'Home' }, + { name: 'Home', path: '/home' } + ) + }) + + it('matches a name and fill params', () => { + assertRecordMatch( + { path: '/users/:id/m/:role', name: 'UserEdit', components }, + { name: 'UserEdit', params: { id: 'posva', role: 'admin' } }, + { name: 'UserEdit', path: '/users/posva/m/admin' } + ) + }) + + it('throws if the named route does not exists', () => { + expect( + assertErrorMatch({ path: '/', components }, { name: 'Home' }) + ).toMatchSnapshot() + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { name: 'p', params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + params: { a: 'a' }, + path: '/a', + matched: [], + meta: {}, + name: undefined, + } + ) + }) + + it('only keep existing params', () => { + assertRecordMatch( + { path: '/:a/:b', name: 'p', components }, + { name: 'p', params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + params: { a: 'a', c: 'c' }, + path: '/a', + matched: [], + meta: {}, + name: undefined, + } + ) + }) + + it('drops optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { name: 'p', params: { a: 'b' } }, + { name: 'p', path: '/b', params: { a: 'b' } }, + { + params: { a: 'a', b: 'b' }, + path: '/a', + matched: [], + meta: {}, + name: undefined, + } + ) + }) + }) + + describe('LocationAsRelative', () => { + mockWarn() + it('warns if a path isn not absolute', () => { + const record = { + path: '/parent', + components, + } + const matcher = createRouterMatcher([record], {}) + matcher.resolve( + { path: 'two' }, + { + path: '/parent/one', + name: undefined, + params: {}, + matched: [] as any, + meta: {}, + } + ) + expect('received "two"').toHaveBeenWarned() + }) + + it('matches with nothing', () => { + const record = { path: '/home', name: 'Home', components } + assertRecordMatch( + record, + {}, + { name: 'Home', path: '/home' }, + { + name: 'Home', + params: {}, + path: '/home', + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: undefined, path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('replace params', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + { params: { id: 'posva', role: 'admin' } }, + { name: 'UserEdit', path: '/users/posva/m/admin' }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [], + meta: {}, + } + ) + }) + + it('keep params if not provided', () => { + const record = { + path: '/users/:id/m/:role', + name: 'UserEdit', + components, + } + assertRecordMatch( + record, + {}, + { + name: 'UserEdit', + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: 'UserEdit', + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('keep params if not provided even with no name', () => { + const record = { path: '/users/:id/m/:role', components } + assertRecordMatch( + record, + {}, + { + name: undefined, + path: '/users/ed/m/user', + params: { id: 'ed', role: 'user' }, + }, + { + path: '/users/ed/m/user', + name: undefined, + params: { id: 'ed', role: 'user' }, + matched: [record] as any, + meta: {}, + } + ) + }) + + it('merges params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { b: 'b' } }, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a' }, + path: '/a', + matched: [], + meta: {}, + } + ) + }) + + it('keep optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + {}, + { name: 'p', path: '/a/b', params: { a: 'a', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('merges optional params', () => { + assertRecordMatch( + { path: '/:a/:b?', name: 'p', components }, + { params: { a: 'c' } }, + { name: 'p', path: '/c/b', params: { a: 'c', b: 'b' } }, + { + name: 'p', + params: { a: 'a', b: 'b' }, + path: '/a/b', + matched: [], + meta: {}, + } + ) + }) + + it('throws if the current named route does not exists', () => { + const record = { path: '/', components } + const start = { + name: 'home', + params: {}, + path: '/', + matched: [record], + } + // the property should be non enumerable + Object.defineProperty(start, 'matched', { enumerable: false }) + expect( + assertErrorMatch( + record, + { params: { a: 'foo' } }, + { + ...start, + matched: start.matched.map(normalizeRouteRecord), + meta: {}, + } + ) + ).toMatchSnapshot() + }) + }) + + describe('children', () => { + const ChildA = { path: 'a', name: 'child-a', components } + const ChildB = { path: 'b', name: 'child-b', components } + const ChildC = { path: 'c', name: 'child-c', components } + const ChildD = { path: '/absolute', name: 'absolute', components } + const ChildWithParam = { path: ':p', name: 'child-params', components } + const NestedChildWithParam = { + ...ChildWithParam, + name: 'nested-child-params', + } + const NestedChildA = { ...ChildA, name: 'nested-child-a' } + const NestedChildB = { ...ChildB, name: 'nested-child-b' } + const NestedChildC = { ...ChildC, name: 'nested-child-c' } + const Nested = { + path: 'nested', + name: 'nested', + components, + children: [NestedChildA, NestedChildB, NestedChildC], + } + const NestedWithParam = { + path: 'nested/:n', + name: 'nested', + components, + children: [NestedChildWithParam], + } + + it('resolves children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildB, ChildC], + } + assertRecordMatch( + Foo, + { path: '/foo/b' }, + { + name: 'child-b', + path: '/foo/b', + params: {}, + matched: [Foo, { ...ChildB, path: `${Foo.path}/${ChildB.path}` }], + } + ) + }) + + it('resolves children with empty paths', () => { + const Nested = { path: '', name: 'nested', components } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [Foo, { ...Nested, path: `${Foo.path}` }], + } + ) + }) + + it('resolves nested children with empty paths', () => { + const NestedNested = { path: '', name: 'nested', components } + const Nested = { + path: '', + name: 'nested-nested', + components, + children: [NestedNested], + } + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo' }, + { + name: 'nested', + path: '/foo', + params: {}, + matched: [ + Foo, + { ...Nested, path: `${Foo.path}` }, + { ...NestedNested, path: `${Foo.path}` }, + ], + } + ) + }) + + it('resolves nested children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + { name: 'nested-child-a' }, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with relative location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [Nested], + } + assertRecordMatch( + Foo, + {}, + { + name: 'nested-child-a', + path: '/foo/nested/a', + params: {}, + matched: [ + Foo, + { ...Nested, path: `${Foo.path}/${Nested.path}` }, + { + ...NestedChildA, + path: `${Foo.path}/${Nested.path}/${NestedChildA.path}`, + }, + ], + }, + { + name: 'nested-child-a', + matched: [], + params: {}, + path: '/foo/nested/a', + meta: {}, + } + ) + }) + + it('resolves nested children with params', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { path: '/foo/nested/a/b' }, + { + name: 'nested-child-params', + path: '/foo/nested/a/b', + params: { p: 'b', n: 'a' }, + matched: [ + Foo, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves nested children with params with named location', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [NestedWithParam], + } + assertRecordMatch( + Foo, + { name: 'nested-child-params', params: { p: 'a', n: 'b' } }, + { + name: 'nested-child-params', + path: '/foo/nested/b/a', + params: { p: 'a', n: 'b' }, + matched: [ + Foo, + { + ...NestedWithParam, + path: `${Foo.path}/${NestedWithParam.path}`, + }, + { + ...NestedChildWithParam, + path: `${Foo.path}/${NestedWithParam.path}/${NestedChildWithParam.path}`, + }, + ], + } + ) + }) + + it('resolves absolute path children', () => { + const Foo = { + path: '/foo', + name: 'Foo', + components, + children: [ChildA, ChildD], + } + assertRecordMatch( + Foo, + { path: '/absolute' }, + { + name: 'absolute', + path: '/absolute', + params: {}, + matched: [Foo, ChildD], + } + ) + }) + + it('resolves children with root as the parent', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/nested' }, + { + name: 'nested', + path: '/nested', + params: {}, + matched: [Parent, { ...Nested, path: `/nested` }], + } + ) + }) + + it('resolves children with parent with trailing slash', () => { + const Nested = { path: 'nested', name: 'nested', components } + const Parent = { + path: '/parent/', + name: 'parent', + components, + children: [Nested], + } + assertRecordMatch( + Parent, + { path: '/parent/nested' }, + { + name: 'nested', + path: '/parent/nested', + params: {}, + matched: [Parent, { ...Nested, path: `/parent/nested` }], + } + ) + }) + }) +}) diff --git a/__tests__/mount.ts b/__tests__/mount.ts new file mode 100644 index 000000000..41976e433 --- /dev/null +++ b/__tests__/mount.ts @@ -0,0 +1,213 @@ +import { + createApp, + defineComponent, + h, + ComponentPublicInstance, + reactive, + nextTick, + ComponentObjectPropsOptions, + ComputedRef, + computed, + App, + VNode, + shallowRef, + ComponentOptions, +} from 'vue' +import { compile } from '@vue/compiler-dom' +import * as runtimeDom from '@vue/runtime-dom' +import { RouteLocationNormalizedLoose } from './utils' +import { + routeLocationKey, + routerViewLocationKey, +} from '../src/injectionSymbols' +import { Router } from '../src' + +export interface MountOptions { + propsData: Record + provide: Record + components: ComponentOptions['components'] + slots: Record + router?: Router +} + +interface Wrapper { + app: App + vm: ComponentPublicInstance + rootEl: HTMLDivElement + setProps(props: MountOptions['propsData']): Promise + html(): string + find: typeof document['querySelector'] +} + +function initialProps

(propsOption: ComponentObjectPropsOptions

) { + let copy = {} as ComponentPublicInstance['$props'] + + for (let key in propsOption) { + const prop = propsOption[key]! + // @ts-ignore + if (!prop.required && prop.default) + // @ts-ignore + copy[key] = prop.default + } + + return copy +} + +// cleanup wrappers after a suite runs +let activeWrapperRemovers: Array<() => void> = [] +afterAll(() => { + activeWrapperRemovers.forEach(remove => remove()) + activeWrapperRemovers = [] +}) + +export function mount( + targetComponent: Parameters[0], + options: Partial = {} +): Promise { + const TargetComponent = targetComponent + return new Promise(resolve => { + // NOTE: only supports props as an object + const propsData = reactive( + Object.assign( + initialProps( + // @ts-ignore + TargetComponent.props || {} + ), + options.propsData + ) + ) + + function setProps(partialProps: Record) { + Object.assign(propsData, partialProps) + return nextTick() + } + + let slots: Record VNode> = {} + + const Wrapper = defineComponent({ + setup(_props, { emit }) { + const componentInstanceRef = shallowRef() + + return () => { + return h( + TargetComponent as any, + { + ref: componentInstanceRef, + onVnodeMounted() { + emit('ready', componentInstanceRef.value) + }, + ...propsData, + }, + slots + ) + } + }, + }) + + const app = createApp(Wrapper, { + onReady: (instance: ComponentPublicInstance) => { + resolve({ app, vm: instance!, rootEl, setProps, html, find }) + }, + }) + + if (options.provide) { + const keys = getKeys(options.provide) + + for (let key of keys) { + app.provide(key, options.provide[key as any]) + } + } + + if (options.components) { + for (let key in options.components) { + app.component(key, options.components[key]) + } + } + + if (options.slots) { + for (let key in options.slots) { + slots[key] = compileSlot(options.slots[key]) + } + } + + const rootEl = document.createElement('div') + document.body.appendChild(rootEl) + + function html() { + return rootEl.innerHTML + } + + function find(selector: string) { + return rootEl.querySelector(selector) + } + + if (options.router) app.use(options.router) + + app.mount(rootEl) + + activeWrapperRemovers.push(() => { + app.unmount(rootEl) + rootEl.remove() + }) + }) +} + +function getKeys(object: Record): Array { + return (Object.getOwnPropertyNames(object) as Array).concat( + Object.getOwnPropertySymbols(object) + ) +} + +export function createMockedRoute(initialValue: RouteLocationNormalizedLoose) { + const route = {} as { + [k in keyof RouteLocationNormalizedLoose]: ComputedRef< + RouteLocationNormalizedLoose[k] + > + } + + const routeRef = shallowRef(initialValue) + + function set(newRoute: RouteLocationNormalizedLoose) { + routeRef.value = newRoute + return nextTick() + } + + for (let key in initialValue) { + // @ts-ignore + route[key] = + // new line to still get errors here + computed(() => routeRef.value[key as keyof RouteLocationNormalizedLoose]) + } + + const value = reactive(route) + + return { + value, + set, + provides: { + [routeLocationKey as symbol]: value, + [routerViewLocationKey as symbol]: routeRef, + }, + } +} + +function compileSlot(template: string) { + const codegen = compile(template, { + mode: 'function', + hoistStatic: true, + prefixIdentifiers: true, + }) + + const render = new Function('Vue', codegen.code)(runtimeDom) + + const ToRender = defineComponent({ + render, + inheritAttrs: false, + + setup(props, { attrs }) { + return { ...attrs } + }, + }) + + return (propsData: any) => h(ToRender, { ...propsData }) +} diff --git a/__tests__/multipleApps.spec.ts b/__tests__/multipleApps.spec.ts new file mode 100644 index 000000000..1158048e8 --- /dev/null +++ b/__tests__/multipleApps.spec.ts @@ -0,0 +1,55 @@ +import { createRouter, createMemoryHistory } from '../src' +import { h } from 'vue' +import { createDom } from './utils' +// import { mockWarn } from 'jest-mock-warn' + +const delay = (t: number) => new Promise(resolve => setTimeout(resolve, t)) + +function newRouter(options: Partial[0]> = {}) { + const history = options.history || createMemoryHistory() + const router = createRouter({ + history, + routes: [ + { + path: '/:pathMatch(.*)', + component: { + render: () => h('div', 'any route'), + }, + }, + ], + ...options, + }) + + return { history, router } +} + +describe('Multiple apps', () => { + beforeAll(() => { + createDom() + const rootEl = document.createElement('div') + rootEl.id = 'app' + document.body.appendChild(rootEl) + }) + + it('does not listen to url changes before being ready', async () => { + const { router, history } = newRouter() + + const spy = jest.fn((to, from, next) => { + next() + }) + router.beforeEach(spy) + + history.push('/foo') + history.push('/bar') + history.go(-1, true) + + await delay(5) + expect(spy).not.toHaveBeenCalled() + + await router.push('/baz') + + history.go(-1, true) + await delay(5) + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/__tests__/parseQuery.spec.ts b/__tests__/parseQuery.spec.ts new file mode 100644 index 000000000..b16c86245 --- /dev/null +++ b/__tests__/parseQuery.spec.ts @@ -0,0 +1,88 @@ +import { parseQuery } from '../src/query' +import { mockWarn } from 'jest-mock-warn' + +describe('parseQuery', () => { + mockWarn() + + it('works with leading ?', () => { + expect(parseQuery('?foo=a')).toEqual({ + foo: 'a', + }) + }) + + it('works without leading ?', () => { + expect(parseQuery('foo=a')).toEqual({ + foo: 'a', + }) + }) + + it('works with an empty string', () => { + const emptyQuery = parseQuery('') + expect(Object.keys(emptyQuery)).toHaveLength(0) + expect(emptyQuery).toEqual({}) + expect(parseQuery('?')).toEqual({}) + }) + + it('decodes values in query', () => { + expect(parseQuery('e=%25')).toEqual({ + e: '%', + }) + }) + + it('parses empty string values', () => { + expect(parseQuery('e=&c=a')).toEqual({ + e: '', + c: 'a', + }) + }) + + it('allows = inside values', () => { + expect(parseQuery('e=c=a')).toEqual({ + e: 'c=a', + }) + }) + + it('parses empty values as null', () => { + expect(parseQuery('e&b&c=a')).toEqual({ + e: null, + b: null, + c: 'a', + }) + }) + + it('parses empty values as null in arrays', () => { + expect(parseQuery('e&e&e=a')).toEqual({ + e: [null, null, 'a'], + }) + }) + + it('decodes array values in query', () => { + expect(parseQuery('e=%25&e=%22')).toEqual({ + e: ['%', '"'], + }) + expect(parseQuery('e=%25&e=a')).toEqual({ + e: ['%', 'a'], + }) + }) + + it('decodes the + as space', () => { + expect(parseQuery('a+b=c+d')).toEqual({ + 'a b': 'c d', + }) + }) + + it('decodes the encoded + as +', () => { + expect(parseQuery('a%2Bb=c%2Bd')).toEqual({ + 'a+b': 'c+d', + }) + }) + + // this is for browsers like IE that allow invalid characters + it('keep invalid values as is', () => { + expect(parseQuery('e=%&e=%25')).toEqual({ + e: ['%', '%'], + }) + + expect('decoding "%"').toHaveBeenWarnedTimes(1) + }) +}) diff --git a/__tests__/router.spec.ts b/__tests__/router.spec.ts new file mode 100644 index 000000000..84d420683 --- /dev/null +++ b/__tests__/router.spec.ts @@ -0,0 +1,899 @@ +import fakePromise from 'faked-promise' +import { + createRouter, + createMemoryHistory, + createWebHistory, + createWebHashHistory, +} from '../src' +import { NavigationFailureType } from '../src/errors' +import { createDom, components, tick, nextNavigation } from './utils' +import { + RouteRecordRaw, + RouteLocationRaw, + START_LOCATION_NORMALIZED, +} from '../src/types' +import { mockWarn } from 'jest-mock-warn' + +declare var __DEV__: boolean + +const routes: RouteRecordRaw[] = [ + { path: '/', component: components.Home, name: 'home' }, + { path: '/home', redirect: '/' }, + { + path: '/home-before', + component: components.Home, + beforeEnter: (to, from, next) => { + next('/') + }, + }, + { path: '/search', component: components.Home }, + { path: '/foo', component: components.Foo, name: 'Foo' }, + { path: '/to-foo', redirect: '/foo' }, + { path: '/to-foo-named', redirect: { name: 'Foo' } }, + { path: '/to-foo2', redirect: '/to-foo' }, + { path: '/to-p/:p', redirect: { name: 'Param' } }, + { path: '/p/:p', name: 'Param', component: components.Bar }, + { path: '/repeat/:r+', name: 'repeat', component: components.Bar }, + { path: '/to-p/:p', redirect: to => `/p/${to.params.p}` }, + { path: '/before-leave', component: components.BeforeLeave }, + { + path: '/parent', + meta: { fromParent: 'foo' }, + component: components.Foo, + children: [ + { path: 'child', meta: { fromChild: 'bar' }, component: components.Foo }, + ], + }, + { + path: '/inc-query-hash', + redirect: to => ({ + name: 'Foo', + query: { n: to.query.n + '-2' }, + hash: to.hash + '-2', + }), + }, + { + path: '/basic', + alias: '/basic-alias', + component: components.Foo, + }, + { + path: '/aliases', + alias: ['/aliases1', '/aliases2'], + component: components.Nested, + children: [ + { + path: 'one', + alias: ['o', 'o2'], + component: components.Foo, + children: [ + { path: 'two', alias: ['t', 't2'], component: components.Bar }, + ], + }, + ], + }, + { path: '/:pathMatch(.*)', component: components.Home, name: 'catch-all' }, +] + +async function newRouter( + options: Partial[0]> = {} +) { + const history = options.history || createMemoryHistory() + const router = createRouter({ history, routes, ...options }) + await router.push('/') + + return { history, router } +} + +describe('Router', () => { + mockWarn() + + beforeAll(() => { + createDom() + }) + + it('starts at START_LOCATION', () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + expect(router.currentRoute.value).toEqual(START_LOCATION_NORMALIZED) + }) + + it('calls history.push with router.push', async () => { + const { router, history } = await newRouter() + jest.spyOn(history, 'push') + await router.push('/foo') + expect(history.push).toHaveBeenCalledTimes(1) + expect(history.push).toHaveBeenCalledWith('/foo', undefined) + }) + + it('calls history.replace with router.replace', async () => { + const history = createMemoryHistory() + const { router } = await newRouter({ history }) + jest.spyOn(history, 'replace') + await router.replace('/foo') + expect(history.replace).toHaveBeenCalledTimes(1) + expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything()) + }) + + it('replaces if a guard redirects', async () => { + const history = createMemoryHistory() + const { router } = await newRouter({ history }) + // move somewhere else + await router.push('/search') + jest.spyOn(history, 'replace') + await router.replace('/home-before') + expect(history.replace).toHaveBeenCalledTimes(1) + expect(history.replace).toHaveBeenCalledWith('/', expect.anything()) + }) + + it('allows to customize parseQuery', async () => { + const parseQuery = jest.fn() + const { router } = await newRouter({ parseQuery }) + router.resolve('/foo?bar=baz') + expect(parseQuery).toHaveBeenCalledWith('bar=baz') + }) + + it('allows to customize stringifyQuery', async () => { + const stringifyQuery = jest.fn() + const { router } = await newRouter({ stringifyQuery }) + router.resolve({ query: { foo: 'bar' } }) + expect(stringifyQuery).toHaveBeenCalledWith({ foo: 'bar' }) + }) + + it('merges meta properties from parent to child', async () => { + const { router } = await newRouter() + expect(router.resolve('/parent')).toMatchObject({ + meta: { fromParent: 'foo' }, + }) + expect(router.resolve('/parent/child')).toMatchObject({ + meta: { fromParent: 'foo', fromChild: 'bar' }, + }) + }) + + it('can do initial navigation to /', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: components.Home }], + }) + expect(router.currentRoute.value).toBe(START_LOCATION_NORMALIZED) + await router.push('/') + expect(router.currentRoute.value).not.toBe(START_LOCATION_NORMALIZED) + }) + + it('resolves hash history as a relative hash link', async () => { + let history = createWebHashHistory() + let { router } = await newRouter({ history }) + expect(router.resolve('/foo?bar=baz#hey')).toMatchObject({ + fullPath: '/foo?bar=baz#hey', + href: '#/foo?bar=baz#hey', + }) + history = createWebHashHistory('/with/base/') + ;({ router } = await newRouter({ history })) + expect(router.resolve('/foo?bar=baz#hey')).toMatchObject({ + fullPath: '/foo?bar=baz#hey', + href: '#/foo?bar=baz#hey', + }) + }) + + it('can pass replace option to push', async () => { + const { router, history } = await newRouter() + jest.spyOn(history, 'replace') + await router.push({ path: '/foo', replace: true }) + expect(history.replace).toHaveBeenCalledTimes(1) + expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything()) + }) + + it('can replaces current location with a string location', async () => { + const { router, history } = await newRouter() + jest.spyOn(history, 'replace') + await router.replace('/foo') + expect(history.replace).toHaveBeenCalledTimes(1) + expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything()) + }) + + it('can replaces current location with an object location', async () => { + const { router, history } = await newRouter() + jest.spyOn(history, 'replace') + await router.replace({ path: '/foo' }) + expect(history.replace).toHaveBeenCalledTimes(1) + expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything()) + }) + + it('navigates if the location does not exist', async () => { + const { router } = await newRouter({ routes: [routes[0]] }) + const spy = jest.fn((to, from, next) => next()) + router.beforeEach(spy) + await router.push('/idontexist') + expect(spy).toHaveBeenCalledTimes(1) + expect(router.currentRoute.value).toMatchObject({ matched: [] }) + spy.mockClear() + await router.push('/me-neither') + expect(router.currentRoute.value).toMatchObject({ matched: [] }) + expect(spy).toHaveBeenCalledTimes(1) + expect('No match found').toHaveBeenWarnedTimes(2) + }) + + it('casts number params to string', async () => { + const { router } = await newRouter() + await router.push({ name: 'Param', params: { p: 0 } }) + expect(router.currentRoute.value).toMatchObject({ params: { p: '0' } }) + }) + + it('navigates to same route record but different query', async () => { + const { router } = await newRouter() + await router.push('/?q=1') + expect(router.currentRoute.value.query).toEqual({ q: '1' }) + await router.push('/?q=2') + expect(router.currentRoute.value.query).toEqual({ q: '2' }) + }) + + it('navigates to same route record but different hash', async () => { + const { router } = await newRouter() + await router.push('/#one') + expect(router.currentRoute.value.hash).toBe('#one') + await router.push('/#two') + expect(router.currentRoute.value.hash).toBe('#two') + }) + + it('fails if required params are missing', async () => { + const { router } = await newRouter() + expect(() => router.resolve({ name: 'Param', params: {} })).toThrowError( + /missing required param "p"/i + ) + expect(() => + router.resolve({ name: 'Param', params: { p: 'po' } }) + ).not.toThrow() + }) + + it('fails if required repeated params are missing', async () => { + const { router } = await newRouter() + expect(() => router.resolve({ name: 'repeat', params: {} })).toThrowError( + /missing required param "r"/i + ) + expect(() => + router.resolve({ name: 'repeat', params: { r: [] } }) + ).toThrowError(/missing required param "r"/i) + expect(() => + router.resolve({ name: 'repeat', params: { r: ['a'] } }) + ).not.toThrow() + }) + + it('fails with arrays for non repeatable params', async () => { + const { router } = await newRouter() + router.addRoute({ path: '/r1/:r', name: 'r1', component: components.Bar }) + router.addRoute({ path: '/r2/:r?', name: 'r2', component: components.Bar }) + expect(() => + router.resolve({ name: 'r1', params: { r: [] } }) + ).toThrowError(/"r" is an array but it is not repeatable/i) + expect(() => + router.resolve({ name: 'r2', params: { r: [] } }) + ).toThrowError(/"r" is an array but it is not repeatable/i) + expect(() => + router.resolve({ name: 'r1', params: { r: 'a' } }) + ).not.toThrow() + }) + + it('does not fail for optional params', async () => { + const { router } = await newRouter() + router.addRoute({ path: '/r1/:r*', name: 'r1', component: components.Bar }) + router.addRoute({ path: '/r2/:r?', name: 'r2', component: components.Bar }) + expect(() => router.resolve({ name: 'r1', params: {} })).not.toThrow() + expect(() => router.resolve({ name: 'r2', params: {} })).not.toThrow() + }) + + it('can redirect to a star route when encoding the param', () => { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [ + { name: 'notfound', path: '/:path(.*)+', component: components.Home }, + ], + }) + let path = 'not/found%2Fha' + let href = '/' + path + expect(router.resolve(href)).toMatchObject({ + name: 'notfound', + fullPath: href, + path: href, + href: href, + }) + expect( + router.resolve({ + name: 'notfound', + params: { + path: path + .split('/') + // we need to provide the value unencoded + .map(segment => segment.replace('%2F', '/')), + }, + }) + ).toMatchObject({ + name: 'notfound', + fullPath: href, + path: href, + href: href, + }) + }) + + it('resolves relative locations', async () => { + const { router } = await newRouter() + await router.push('/users/posva') + await router.push('add') + expect(router.currentRoute.value.path).toBe('/users/add') + await router.push('/users/posva') + await router.push('./add') + expect(router.currentRoute.value.path).toBe('/users/add') + }) + + it('resolves parent relative locations', async () => { + const { router } = await newRouter() + await router.push('/users/posva') + await router.push('../add') + expect(router.currentRoute.value.path).toBe('/add') + await router.push('/users/posva') + await router.push('../../../add') + expect(router.currentRoute.value.path).toBe('/add') + }) + + describe('alias', () => { + it('does not navigate to alias if already on original record', async () => { + const { router } = await newRouter() + const spy = jest.fn((to, from, next) => next()) + await router.push('/basic') + router.beforeEach(spy) + await router.push('/basic-alias') + expect(spy).not.toHaveBeenCalled() + }) + + it('does not navigate to alias with children if already on original record', async () => { + const { router } = await newRouter() + const spy = jest.fn((to, from, next) => next()) + await router.push('/aliases') + router.beforeEach(spy) + await router.push('/aliases1') + expect(spy).not.toHaveBeenCalled() + await router.push('/aliases2') + expect(spy).not.toHaveBeenCalled() + }) + + it('does not navigate to child alias if already on original record', async () => { + const { router } = await newRouter() + const spy = jest.fn((to, from, next) => next()) + await router.push('/aliases/one') + router.beforeEach(spy) + await router.push('/aliases1/one') + expect(spy).not.toHaveBeenCalled() + await router.push('/aliases2/one') + expect(spy).not.toHaveBeenCalled() + await router.push('/aliases2/o') + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('navigation cancelled', () => { + async function checkNavigationCancelledOnPush( + target?: RouteLocationRaw | false + ) { + const [p1, r1] = fakePromise() + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + router.beforeEach(async (to, from, next) => { + if (to.name !== 'Param') return next() + // the first navigation gets passed target + if (to.params.p === 'a') { + await p1 + target ? next(target) : next() + } else { + // the second one just passes + next() + } + }) + const from = router.currentRoute.value + const pA = router.push('/p/a') + // we resolve the second navigation first then the first one + // and the first navigation should be ignored because at that time + // the second one will have already been resolved + await expect(router.push('/p/b')).resolves.toEqual(undefined) + expect(router.currentRoute.value.fullPath).toBe('/p/b') + r1() + await expect(pA).resolves.toEqual( + expect.objectContaining({ + to: expect.objectContaining({ path: '/p/a' }), + from, + type: NavigationFailureType.cancelled, + }) + ) + expect(router.currentRoute.value.fullPath).toBe('/p/b') + } + + it('cancels navigation abort if a newer one is finished on push', async () => { + await checkNavigationCancelledOnPush(false) + }) + + it('cancels pending in-guard navigations if a newer one is finished on push', async () => { + await checkNavigationCancelledOnPush('/foo') + }) + + it('cancels pending navigations if a newer one is finished on push', async () => { + await checkNavigationCancelledOnPush(undefined) + }) + + async function checkNavigationCancelledOnPopstate( + target?: RouteLocationRaw | false + ) { + const [p1, r1] = fakePromise() + const [p2, r2] = fakePromise() + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + + // navigate first to add entries to the history stack + await router.push('/foo') + await router.push('/p/a') + await router.push('/p/b') + + router.beforeEach(async (to, from, next) => { + if (to.name !== 'Param') return next() + if (to.fullPath === '/foo') { + await p1 + next() + } else if (from.fullPath === '/p/b') { + await p2 + // @ts-ignore: same as function above + next(target) + } else { + next() + } + }) + + // trigger to history.back() + history.go(-1) + history.go(-1) + + expect(router.currentRoute.value.fullPath).toBe('/p/b') + // resolves the last call to history.back() first + // so we end up on /p/initial + r1() + await tick() + expect(router.currentRoute.value.fullPath).toBe('/foo') + // resolves the pending navigation, this should be cancelled + r2() + await tick() + expect(router.currentRoute.value.fullPath).toBe('/foo') + } + + it('cancels pending navigations if a newer one is finished on user navigation (from history)', async () => { + await checkNavigationCancelledOnPopstate(undefined) + }) + + it('cancels pending in-guard navigations if a newer one is finished on user navigation (from history)', async () => { + await checkNavigationCancelledOnPopstate('/p/other-place') + }) + + it('cancels navigation abort if a newer one is finished on user navigation (from history)', async () => { + await checkNavigationCancelledOnPush(undefined) + }) + }) + + describe('redirectedFrom', () => { + it('adds a redirectedFrom property with a redirect in record', async () => { + const { router } = await newRouter({ history: createMemoryHistory() }) + // go to a different route first + await router.push('/foo') + await router.push('/home') + expect(router.currentRoute.value).toMatchObject({ + path: '/', + name: 'home', + redirectedFrom: { path: '/home' }, + }) + }) + + it('adds a redirectedFrom property with beforeEnter', async () => { + const { router } = await newRouter({ history: createMemoryHistory() }) + // go to a different route first + await router.push('/foo') + await router.push('/home-before') + expect(router.currentRoute.value).toMatchObject({ + path: '/', + name: 'home', + redirectedFrom: { path: '/home-before' }, + }) + }) + }) + + describe('redirect', () => { + it('handles one redirect from route record', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await expect(router.push('/to-foo')).resolves.toEqual(undefined) + const loc = router.currentRoute.value + expect(loc.name).toBe('Foo') + expect(loc.redirectedFrom).toMatchObject({ + path: '/to-foo', + }) + }) + + it('only triggers guards once with a redirect option', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + const spy = jest.fn((to, from, next) => next()) + router.beforeEach(spy) + await router.push('/to-foo') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ path: '/foo' }), + expect.objectContaining({ path: '/' }), + expect.any(Function) + ) + }) + + it('handles a double redirect from route record', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await expect(router.push('/to-foo2')).resolves.toEqual(undefined) + const loc = router.currentRoute.value + expect(loc.name).toBe('Foo') + expect(loc.redirectedFrom).toMatchObject({ + path: '/to-foo2', + }) + }) + + it('keeps query and hash when redirect is a string', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await expect(router.push('/to-foo?hey=foo#fa')).resolves.toEqual( + undefined + ) + expect(router.currentRoute.value).toMatchObject({ + name: 'Foo', + path: '/foo', + params: {}, + query: { hey: 'foo' }, + hash: '#fa', + redirectedFrom: expect.objectContaining({ + fullPath: '/to-foo?hey=foo#fa', + }), + }) + }) + + it('keeps params, query and hash from targetLocation on redirect', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await expect(router.push('/to-p/1?hey=foo#fa')).resolves.toEqual( + undefined + ) + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + params: { p: '1' }, + query: { hey: 'foo' }, + hash: '#fa', + redirectedFrom: expect.objectContaining({ + fullPath: '/to-p/1?hey=foo#fa', + }), + }) + }) + + it('allows object in redirect', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await expect(router.push('/to-foo-named')).resolves.toEqual(undefined) + const loc = router.currentRoute.value + expect(loc.name).toBe('Foo') + expect(loc.redirectedFrom).toMatchObject({ + path: '/to-foo-named', + }) + }) + + it('keeps original replace if redirect', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await router.push('/search') + + await expect(router.replace('/to-foo')).resolves.toEqual(undefined) + expect(router.currentRoute.value).toMatchObject({ + path: '/foo', + redirectedFrom: expect.objectContaining({ path: '/to-foo' }), + }) + + history.go(-1) + await nextNavigation(router) + expect(router.currentRoute.value).not.toMatchObject({ + path: '/search', + }) + }) + + it('can pass on query and hash when redirecting', async () => { + const history = createMemoryHistory() + const router = createRouter({ history, routes }) + await router.push('/inc-query-hash?n=3#fa') + const loc = router.currentRoute.value + expect(loc).toMatchObject({ + name: 'Foo', + query: { + n: '3-2', + }, + hash: '#fa-2', + }) + expect(loc.redirectedFrom).toMatchObject({ + fullPath: '/inc-query-hash?n=3#fa', + query: { n: '3' }, + hash: '#fa', + path: '/inc-query-hash', + }) + }) + + it('allows a redirect with children', async () => { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [ + { + path: '/parent', + redirect: { name: 'child' }, + component: components.Home, + name: 'parent', + children: [{ name: 'child', path: '', component: components.Home }], + }, + ], + }) + await expect(router.push({ name: 'parent' })).resolves.toEqual(undefined) + const loc = router.currentRoute.value + expect(loc.name).toBe('child') + expect(loc.path).toBe('/parent') + expect(loc.redirectedFrom).toMatchObject({ + name: 'parent', + path: '/parent', + }) + }) + + // https://github.com/vuejs/vue-router-next/issues/404 + it('works with named routes', async () => { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [ + { name: 'foo', path: '/foo', redirect: '/bar' }, + { path: '/bar', component: components.Bar }, + ], + }) + await expect(router.push('/foo')).resolves.toEqual(undefined) + const loc = router.currentRoute.value + expect(loc.name).toBe(undefined) + expect(loc.path).toBe('/bar') + expect(loc.redirectedFrom).toMatchObject({ + name: 'foo', + path: '/foo', + }) + }) + }) + + describe('base', () => { + it('allows base option in abstract history', async () => { + const history = createMemoryHistory('/app/') + const router = createRouter({ history, routes }) + expect(router.currentRoute.value).toMatchObject({ + name: undefined, + fullPath: '/', + hash: '', + params: {}, + path: '/', + query: {}, + meta: {}, + }) + await router.replace('/foo') + expect(router.currentRoute.value).toMatchObject({ + name: 'Foo', + fullPath: '/foo', + hash: '', + params: {}, + path: '/foo', + query: {}, + }) + }) + + it('allows base option with html5 history', async () => { + const history = createWebHistory('/app/') + const router = createRouter({ history, routes }) + expect(router.currentRoute.value).toMatchObject({ + name: undefined, + fullPath: '/', + hash: '', + params: {}, + path: '/', + query: {}, + meta: {}, + }) + await router.replace('/foo') + expect(router.currentRoute.value).toMatchObject({ + name: 'Foo', + fullPath: '/foo', + hash: '', + params: {}, + path: '/foo', + query: {}, + }) + }) + }) + + describe('Dynamic Routing', () => { + it('resolves new added routes', async () => { + const { router } = await newRouter({ routes: [] }) + expect(router.resolve('/new-route')).toMatchObject({ + name: undefined, + matched: [], + }) + expect('No match found').toHaveBeenWarned() + router.addRoute({ + path: '/new-route', + component: components.Foo, + name: 'new route', + }) + expect(router.resolve('/new-route')).toMatchObject({ + name: 'new route', + }) + }) + + it('checks if a route exists', async () => { + const { router } = await newRouter() + router.addRoute({ + name: 'new-route', + path: '/new-route', + component: components.Foo, + }) + expect(router.hasRoute('new-route')).toBe(true) + expect(router.hasRoute('no')).toBe(false) + router.removeRoute('new-route') + expect(router.hasRoute('new-route')).toBe(false) + }) + + it('can redirect to children in the middle of navigation', async () => { + const { router } = await newRouter({ routes: [] }) + expect(router.resolve('/new-route')).toMatchObject({ + name: undefined, + matched: [], + }) + expect('No match found').toHaveBeenWarned() + let removeRoute: (() => void) | undefined + router.addRoute({ + path: '/dynamic', + component: components.Nested, + name: 'dynamic parent', + end: false, + strict: true, + beforeEnter(to, from, next) { + if (!removeRoute) { + removeRoute = router.addRoute('dynamic parent', { + path: 'child', + name: 'dynamic child', + component: components.Foo, + }) + next(to.fullPath) + } else next() + }, + }) + + router.push('/dynamic/child').catch(() => {}) + await tick() + expect(router.currentRoute.value).toMatchObject({ + name: 'dynamic child', + }) + }) + + it('can reroute to a replaced route with the same component', async () => { + const { router } = await newRouter() + router.addRoute({ + path: '/new/foo', + component: components.Foo, + name: 'new', + }) + // navigate to the route we just added + await router.replace({ name: 'new' }) + // replace it + router.addRoute({ + path: '/new/bar', + component: components.Foo, + name: 'new', + }) + // navigate again + await router.replace({ name: 'new' }) + expect(router.currentRoute.value).toMatchObject({ + path: '/new/bar', + name: 'new', + }) + }) + + it('can reroute to child', async () => { + const { router } = await newRouter({ routes: [] }) + router.addRoute({ + path: '/new', + component: components.Foo, + children: [], + name: 'new', + }) + // navigate to the route we just added + await router.replace('/new/child') + // replace it + router.addRoute('new', { + path: 'child', + component: components.Bar, + name: 'new-child', + }) + // navigate again + await router.replace('/new/child') + expect('No match found').toHaveBeenWarned() + expect(router.currentRoute.value).toMatchObject({ + name: 'new-child', + }) + }) + + it('can reroute when adding a new route', async () => { + const { router } = await newRouter() + await router.push('/p/p') + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + }) + router.addRoute({ + path: '/p/p', + component: components.Foo, + name: 'pp', + }) + await router.replace(router.currentRoute.value.fullPath) + expect(router.currentRoute.value).toMatchObject({ + name: 'pp', + }) + }) + + it('stops resolving removed routes', async () => { + const { router } = await newRouter({ + routes: [routes.find(route => route.name === 'Foo')!], + }) + // regular route + router.removeRoute('Foo') + expect(router.resolve('/foo')).toMatchObject({ + name: undefined, + matched: [], + }) + // dynamic route + const removeRoute = router.addRoute({ + path: '/new-route', + component: components.Foo, + name: 'new route', + }) + removeRoute() + expect(router.resolve('/new-route')).toMatchObject({ + name: undefined, + matched: [], + }) + expect('No match found').toHaveBeenWarned() + }) + + it('can reroute when removing route', async () => { + const { router } = await newRouter() + router.addRoute({ + path: '/p/p', + component: components.Foo, + name: 'pp', + }) + await router.push('/p/p') + router.removeRoute('pp') + await router.replace(router.currentRoute.value.fullPath) + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + }) + }) + + it('can reroute when removing route through returned function', async () => { + const { router } = await newRouter() + const remove = router.addRoute({ + path: '/p/p', + component: components.Foo, + name: 'pp', + }) + await router.push('/p/p') + remove() + await router.push('/p/p') + expect(router.currentRoute.value).toMatchObject({ + name: 'Param', + }) + }) + }) +}) diff --git a/__tests__/scrollBehavior.spec.ts b/__tests__/scrollBehavior.spec.ts new file mode 100644 index 000000000..96b5dd1c1 --- /dev/null +++ b/__tests__/scrollBehavior.spec.ts @@ -0,0 +1,197 @@ +import { JSDOM } from 'jsdom' +import { scrollToPosition } from '../src/scrollBehavior' +import { createDom } from './utils' +import { mockWarn } from 'jest-mock-warn' + +describe('scrollBehavior', () => { + mockWarn() + let dom: JSDOM + let scrollTo: jest.SpyInstance + let getElementById: jest.SpyInstance + let querySelector: jest.SpyInstance + + beforeAll(() => { + dom = createDom() + scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}) + getElementById = jest.spyOn(document, 'getElementById') + querySelector = jest.spyOn(document, 'querySelector') + + // #text + let el = document.createElement('div') + el.id = 'text' + document.documentElement.appendChild(el) + + // [data-scroll] + el = document.createElement('div') + el.setAttribute('data-scroll', 'true') + document.documentElement.appendChild(el) + + // #special~characters + el = document.createElement('div') + el.id = 'special~characters' + document.documentElement.appendChild(el) + + // #text .container + el = document.createElement('div') + let child = document.createElement('div') + child.classList.add('container') + el.id = 'text' + el.append(child) + document.documentElement.appendChild(el) + + // .container #1 + el = document.createElement('div') + child = document.createElement('div') + el.classList.add('container') + child.id = '1' + el.append(child) + document.documentElement.appendChild(el) + }) + + beforeEach(() => { + scrollTo.mockClear() + getElementById.mockClear() + querySelector.mockClear() + __DEV__ = false + }) + + afterAll(() => { + __DEV__ = true + }) + + afterAll(() => { + dom.window.close() + scrollTo.mockRestore() + getElementById.mockRestore() + querySelector.mockRestore() + }) + + describe('left and top', () => { + it('scrolls to a position', () => { + scrollToPosition({ left: 10, top: 100 }) + expect(getElementById).not.toHaveBeenCalled() + expect(getElementById).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 10, + top: 100, + behavior: undefined, + }) + }) + + it('scrolls to a partial position top', () => { + scrollToPosition({ top: 10 }) + expect(getElementById).not.toHaveBeenCalled() + expect(getElementById).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + top: 10, + behavior: undefined, + }) + }) + + it('scrolls to a partial position left', () => { + scrollToPosition({ left: 10 }) + expect(getElementById).not.toHaveBeenCalled() + expect(getElementById).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 10, + behavior: undefined, + }) + }) + }) + + describe('el option', () => { + it('scrolls to an id', () => { + scrollToPosition({ el: '#text' }) + expect(getElementById).toHaveBeenCalledWith('text') + expect(querySelector).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 0, + top: 0, + behavior: undefined, + }) + }) + + it('scrolls to an element using querySelector', () => { + scrollToPosition({ el: '[data-scroll=true]' }) + expect(querySelector).toHaveBeenCalledWith('[data-scroll=true]') + expect(getElementById).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 0, + top: 0, + behavior: undefined, + }) + }) + + it('scrolls to an id with special characters', () => { + scrollToPosition({ el: '#special~characters' }) + expect(getElementById).toHaveBeenCalledWith('special~characters') + expect(querySelector).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 0, + top: 0, + behavior: undefined, + }) + }) + + it('scrolls to an id with special characters', () => { + scrollToPosition({ el: '#special~characters' }) + expect(getElementById).toHaveBeenCalledWith('special~characters') + expect(querySelector).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 0, + top: 0, + behavior: undefined, + }) + }) + + it('accepts a raw element', () => { + scrollToPosition({ el: document.getElementById('special~characters')! }) + expect(getElementById).toHaveBeenCalledWith('special~characters') + expect(querySelector).not.toHaveBeenCalled() + expect(scrollTo).toHaveBeenCalledWith({ + left: 0, + top: 0, + behavior: undefined, + }) + }) + + describe('warnings', () => { + beforeEach(() => { + __DEV__ = true + }) + + it('warns if element cannot be found with id', () => { + scrollToPosition({ el: '#not-found' }) + expect( + `Couldn't find element using selector "#not-found"` + ).toHaveBeenWarned() + }) + + it('warns if element cannot be found with selector', () => { + scrollToPosition({ el: '.not-found' }) + expect( + `Couldn't find element using selector ".not-found"` + ).toHaveBeenWarned() + }) + + it('warns if element cannot be found with id but can with selector', () => { + scrollToPosition({ el: '#text .container' }) + expect( + `selector "#text .container" should be passed as "el: document.querySelector('#text .container')"` + ).toHaveBeenWarned() + }) + + it('warns if element cannot be found with id but can with selector', () => { + scrollToPosition({ el: '#text .container' }) + expect( + `selector "#text .container" should be passed as "el: document.querySelector('#text .container')"` + ).toHaveBeenWarned() + }) + + it('warns if querySelector throws', () => { + scrollToPosition({ el: '.container #1' }) + expect(`selector ".container #1" is invalid`).toHaveBeenWarned() + }) + }) + }) +}) diff --git a/__tests__/ssr.spec.ts b/__tests__/ssr.spec.ts new file mode 100644 index 000000000..660c1ae02 --- /dev/null +++ b/__tests__/ssr.spec.ts @@ -0,0 +1,100 @@ +/** + * @jest-environment node + */ +import { createRouter, createMemoryHistory } from '../src' +import { createSSRApp, resolveComponent, Component } from 'vue' +import { + renderToString, + ssrInterpolate, + ssrRenderComponent, +} from '@vue/server-renderer' + +const delay = (t: number) => new Promise(resolve => setTimeout(resolve, t)) + +describe('SSR', () => { + const Home = { + ssrRender(ctx: any, push: any) { + push('Home') + }, + } + const Page = { + ssrRender(ctx: any, push: any) { + push(`${ssrInterpolate(ctx.$route.fullPath)}`) + }, + } + + const AsyncPage = async () => { + await delay(10) + return Page + } + + it('works', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Home }, + { + path: '/:id', + component: Page, + }, + ], + }) + const App = { + ssrRender(ctx: any, push: any, parent: any) { + push( + ssrRenderComponent( + resolveComponent('router-view') as Component, + null, + null, + parent + ) + ) + }, + } + const app = createSSRApp(App) + app.use(router) + // const rootEl = document.createElement('div') + // document.body.appendChild(rootEl) + + router.push('/hello') + await router.isReady() + + const xxx = await renderToString(app) + expect(xxx).toMatchInlineSnapshot(`"/hello"`) + }) + + it('handles async components', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Home }, + { + path: '/:id', + component: AsyncPage, + }, + ], + }) + const App = { + ssrRender(ctx: any, push: any, parent: any) { + push( + ssrRenderComponent( + resolveComponent('router-view') as Component, + null, + null, + parent + ) + ) + }, + } + const app = createSSRApp(App) + app.use(router) + // const rootEl = document.createElement('div') + // document.body.appendChild(rootEl) + + router.push('/hello') + await router.isReady() + + const xxx = await renderToString(app) + expect(xxx).toMatchInlineSnapshot(`"/hello"`) + }) +}) diff --git a/__tests__/stringifyQuery.spec.ts b/__tests__/stringifyQuery.spec.ts new file mode 100644 index 000000000..c39d1b4e8 --- /dev/null +++ b/__tests__/stringifyQuery.spec.ts @@ -0,0 +1,50 @@ +import { stringifyQuery } from '../src/query' +import { mockWarn } from 'jest-mock-warn' + +describe('stringifyQuery', () => { + mockWarn() + + it('stringifies multiple values', () => { + expect(stringifyQuery({ e: 'a', b: 'c' })).toEqual('e=a&b=c') + }) + + it('stringifies null values', () => { + expect(stringifyQuery({ e: null })).toEqual('e') + expect(stringifyQuery({ e: null, b: null })).toEqual('e&b') + }) + + it('stringifies null values in arrays', () => { + expect(stringifyQuery({ e: [null] })).toEqual('e') + expect(stringifyQuery({ e: [null, 'c'] })).toEqual('e&e=c') + }) + + it('stringifies numbers', () => { + expect(stringifyQuery({ e: 2 })).toEqual('e=2') + expect(stringifyQuery({ e: [2, 'b'] })).toEqual('e=2&e=b') + }) + + it('ignores undefined values', () => { + expect(stringifyQuery({ e: undefined })).toEqual('') + expect(stringifyQuery({ e: undefined, b: 'a' })).toEqual('b=a') + }) + + it('stringifies arrays', () => { + expect(stringifyQuery({ e: ['b', 'a'] })).toEqual('e=b&e=a') + }) + + it('encodes values', () => { + expect(stringifyQuery({ e: '%', b: 'c' })).toEqual('e=%25&b=c') + }) + + it('encodes values in arrays', () => { + expect(stringifyQuery({ e: ['%', 'a'], b: 'c' })).toEqual('e=%25&e=a&b=c') + }) + + it('encodes = in key', () => { + expect(stringifyQuery({ '=': 'a' })).toEqual('%3D=a') + }) + + it('keeps = in value', () => { + expect(stringifyQuery({ a: '=' })).toEqual('a==') + }) +}) diff --git a/__tests__/urlEncoding.spec.ts b/__tests__/urlEncoding.spec.ts new file mode 100644 index 000000000..5c34f0bc6 --- /dev/null +++ b/__tests__/urlEncoding.spec.ts @@ -0,0 +1,165 @@ +import { createRouter as newRouter } from '../src/router' +import { components } from './utils' +import { RouteRecordRaw } from '../src/types' +import { createMemoryHistory } from '../src' +import * as encoding from '../src/encoding' + +jest.mock('../src/encoding') + +const routes: RouteRecordRaw[] = [ + { path: '/', name: 'home', component: components.Home }, + { path: '/%25', name: 'percent', component: components.Home }, + { path: '/to-p/:p', redirect: to => `/p/${to.params.p}` }, + { path: '/p/:p', component: components.Bar, name: 'params' }, + { path: '/p/:p+', component: components.Bar, name: 'repeat' }, + { path: '/optional/:a/:b?', component: components.Bar, name: 'optional' }, +] + +function createRouter() { + const history = createMemoryHistory() + const router = newRouter({ history, routes }) + return router +} + +describe('URL Encoding', () => { + beforeEach(() => { + // mock all encoding functions + for (const key in encoding) { + // @ts-ignore + const value = encoding[key] + // @ts-ignore + if (typeof value === 'function') encoding[key] = jest.fn((v: string) => v) + // @ts-ignore + else if (key === 'PLUS_RE') encoding[key] = /\+/g + } + }) + + it('calls encodeParam with params object', async () => { + const router = createRouter() + await router.push({ name: 'params', params: { p: 'foo' } }) + expect(encoding.encodeParam).toHaveBeenCalledTimes(1) + expect(encoding.encodeParam).toHaveBeenCalledWith('foo') + }) + + it('calls encodeParam with relative location', async () => { + const router = createRouter() + await router.push('/p/bar') + await router.push({ params: { p: 'foo' } }) + expect(encoding.encodeParam).toHaveBeenCalledTimes(2) + expect(encoding.encodeParam).toHaveBeenCalledWith('bar') + expect(encoding.encodeParam).toHaveBeenCalledWith('foo') + }) + + it('calls encodeParam with params object with arrays', async () => { + const router = createRouter() + await router.push({ name: 'repeat', params: { p: ['foo', 'bar'] } }) + expect(encoding.encodeParam).toHaveBeenCalledTimes(2) + expect(encoding.encodeParam).toHaveBeenNthCalledWith(1, 'foo', 0, [ + 'foo', + 'bar', + ]) + expect(encoding.encodeParam).toHaveBeenNthCalledWith(2, 'bar', 1, [ + 'foo', + 'bar', + ]) + }) + + it('calls decode with a path', async () => { + const router = createRouter() + await router.push('/p/foo') + // one extra time for hash + expect(encoding.decode).toHaveBeenCalledTimes(2) + expect(encoding.decode).toHaveBeenNthCalledWith(1, 'foo') + }) + + it('calls decode with a path with repeatable params', async () => { + const router = createRouter() + await router.push('/p/foo/bar') + // one extra time for hash + expect(encoding.decode).toHaveBeenCalledTimes(3) + expect(encoding.decode).toHaveBeenNthCalledWith(1, 'foo', 0, ['foo', 'bar']) + expect(encoding.decode).toHaveBeenNthCalledWith(2, 'bar', 1, ['foo', 'bar']) + }) + + it('decodes values in params', async () => { + // @ts-ignore: override to make the difference + encoding.decode = () => 'd' + // @ts-ignore + encoding.encodeParam = () => 'e' + const router = createRouter() + await router.push({ name: 'optional', params: { a: 'a%' } }) + await router.push({ params: { b: 'b%' } }) + expect(router.currentRoute.value).toMatchObject({ + fullPath: '/optional/e/e', + params: { b: 'd', a: 'd' }, + }) + }) + + it('calls encodeQueryProperty with query', async () => { + const router = createRouter() + await router.push({ name: 'home', query: { p: 'foo' } }) + expect(encoding.encodeQueryValue).toHaveBeenCalledTimes(1) + expect(encoding.encodeQueryKey).toHaveBeenCalledTimes(1) + expect(encoding.encodeQueryKey).toHaveBeenNthCalledWith(1, 'p') + expect(encoding.encodeQueryValue).toHaveBeenNthCalledWith(1, 'foo') + }) + + it('calls decode with query', async () => { + const router = createRouter() + await router.push('/?p=foo') + // one extra time for hash + expect(encoding.decode).toHaveBeenCalledTimes(3) + expect(encoding.decode).toHaveBeenNthCalledWith(1, 'p') + expect(encoding.decode).toHaveBeenNthCalledWith(2, 'foo') + }) + + it('calls encodeQueryProperty with arrays in query', async () => { + const router = createRouter() + await router.push({ name: 'home', query: { p: ['foo', 'bar'] } }) + expect(encoding.encodeQueryValue).toHaveBeenCalledTimes(2) + expect(encoding.encodeQueryKey).toHaveBeenCalledTimes(1) + expect(encoding.encodeQueryKey).toHaveBeenNthCalledWith(1, 'p') + expect(encoding.encodeQueryValue).toHaveBeenNthCalledWith(1, 'foo') + expect(encoding.encodeQueryValue).toHaveBeenNthCalledWith(2, 'bar') + }) + + it('keeps decoded values in query', async () => { + // @ts-ignore: override to make the difference + encoding.decode = () => 'd' + // @ts-ignore + encoding.encodeQueryValue = () => 'ev' + // @ts-ignore + encoding.encodeQueryKey = () => 'ek' + const router = createRouter() + await router.push({ name: 'home', query: { p: '%' } }) + expect(router.currentRoute.value).toMatchObject({ + fullPath: '/?ek=ev', + query: { p: '%' }, + }) + }) + + it('keeps decoded values in hash', async () => { + // @ts-ignore: override to make the difference + encoding.decode = () => 'd' + // @ts-ignore + encoding.encodeHash = () => '#e' + const router = createRouter() + await router.push({ name: 'home', hash: '#%' }) + expect(router.currentRoute.value).toMatchObject({ + fullPath: '/#e', + hash: '#%', + }) + }) + it('decodes hash', async () => { + // @ts-ignore: override to make the difference + encoding.decode = () => '#d' + // @ts-ignore + encoding.encodeHash = () => '#e' + const router = createRouter() + await router.push('#%20') + expect(router.currentRoute.value).toMatchObject({ + fullPath: '/#%20', + hash: '#d', + }) + }) +}) diff --git a/__tests__/utils.ts b/__tests__/utils.ts new file mode 100644 index 000000000..b51ddbdf2 --- /dev/null +++ b/__tests__/utils.ts @@ -0,0 +1,169 @@ +import { JSDOM, ConstructorOptions } from 'jsdom' +import { + NavigationGuard, + RouteRecordMultipleViews, + MatcherLocation, + RouteLocationNormalized, + _RouteRecordBase, + RouteComponent, + RouteRecordRaw, + RouteRecordName, + _RouteRecordProps, +} from '../src/types' +import { h, ComponentOptions } from 'vue' +import { + RouterOptions, + createWebHistory, + createRouter, + Router, + RouterView, +} from '../src' + +export const tick = (time?: number) => + new Promise(resolve => { + if (time) setTimeout(resolve, time) + else process.nextTick(resolve) + }) + +export async function ticks(n: number) { + for (let i = 0; i < n; i++) { + await tick() + } +} + +export function nextNavigation(router: Router) { + return new Promise((resolve, reject) => { + let removeAfter = router.afterEach((_to, _from, failure) => { + removeAfter() + removeError() + resolve(failure) + }) + let removeError = router.onError(err => { + removeAfter() + removeError() + reject(err) + }) + }) +} + +export interface RouteRecordViewLoose + extends Pick< + RouteRecordMultipleViews, + 'path' | 'name' | 'components' | 'children' | 'meta' | 'beforeEnter' + > { + leaveGuards?: any + instances: Record + enterCallbacks: Record + props: Record + aliasOf: RouteRecordViewLoose | undefined +} + +// @ts-ignore we are intentionally overriding the type +export interface RouteLocationNormalizedLoose extends RouteLocationNormalized { + name: RouteRecordName | null | undefined + path: string + // record? + params: any + redirectedFrom?: Partial + meta: any + matched: Partial[] +} + +export interface MatcherLocationNormalizedLoose { + name: string + path: string + // record? + params: any + redirectedFrom?: Partial + meta: any + matched: Partial[] + instances: Record +} + +declare global { + namespace NodeJS { + interface Global { + window: JSDOM['window'] + location: JSDOM['window']['location'] + history: JSDOM['window']['history'] + document: JSDOM['window']['document'] + before?: Function + } + } +} + +export function createDom(options?: ConstructorOptions) { + const dom = new JSDOM( + ``, + { + url: 'https://example.com/', + referrer: 'https://example.com/', + contentType: 'text/html', + ...options, + } + ) + + // @ts-ignore: needed for jsdom + global.window = dom.window + global.location = dom.window.location + global.history = dom.window.history + global.document = dom.window.document + + return dom +} + +export const noGuard: NavigationGuard = (to, from, next) => { + next() +} + +export const components = { + Home: { render: () => h('div', {}, 'Home') }, + Foo: { render: () => h('div', {}, 'Foo') }, + Bar: { render: () => h('div', {}, 'Bar') }, + User: { + props: { + id: { + default: 'default', + }, + }, + render() { + return h('div', {}, 'User: ' + this.id) + }, + } as ComponentOptions, + WithProps: { + props: { + id: { + default: 'default', + }, + other: { + default: 'other', + }, + }, + render() { + return h('div', {}, `id:${this.id};other:${this.other}`) + }, + } as RouteComponent, + Nested: { + render: () => { + return h('div', {}, [ + h('h2', {}, 'Nested'), + RouterView ? h(RouterView) : [], + ]) + }, + }, + BeforeLeave: { + render: () => h('div', {}, 'before leave'), + beforeRouteLeave(to, from, next) { + next() + }, + } as RouteComponent, +} + +export function newRouter( + options: Partial & { routes: RouteRecordRaw[] } +) { + return createRouter({ + history: options.history || createWebHistory(), + ...options, + }) +} diff --git a/__tests__/warnings.spec.ts b/__tests__/warnings.spec.ts new file mode 100644 index 000000000..5273b0fbb --- /dev/null +++ b/__tests__/warnings.spec.ts @@ -0,0 +1,230 @@ +import { mockWarn } from 'jest-mock-warn' +import { createMemoryHistory, createRouter } from '../src' +import { defineComponent, FunctionalComponent, h } from 'vue' + +let component = defineComponent({}) + +describe('warnings', () => { + mockWarn() + it('warns on missing name and path for redirect', async () => { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [ + { path: '/', component }, + { path: '/redirect', redirect: { params: { foo: 'f' } } }, + ], + }) + try { + await router.push('/redirect') + } catch (err) {} + expect('Invalid redirect found').toHaveBeenWarned() + }) + + it('warns when resolving a route with path and params', async () => { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [{ path: '/:p', name: 'p', component }], + }) + router.resolve({ path: '/p', params: { p: 'p' } }) + expect('Path "/p" was passed with params').toHaveBeenWarned() + }) + + it('does not warn when resolving a route with path, params and name', async () => { + const history = createMemoryHistory() + const router = createRouter({ + history, + routes: [{ path: '/:p', name: 'p', component }], + }) + router.resolve({ path: '/p', name: 'p', params: { p: 'p' } }) + expect('Path "/" was passed with params').not.toHaveBeenWarned() + }) + + it('warns if an alias is missing params', async () => { + createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:p/:c', alias: ['/:p/c'], component }], + }) + expect( + 'Alias "/:p/c" and the original record: "/:p/:c" should have the exact same param named "c"' + ).toHaveBeenWarned() + }) + + it('warns if a child with absolute path is missing a parent param', async () => { + createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/:a', + component, + children: [ + { + path: ':b', + component, + children: [{ path: '/:a/b', component }], + }, + ], + }, + ], + }) + expect( + `Absolute path "/:a/b" should have the exact same param named "b" as its parent "/:a/:b".` + ).toHaveBeenWarned() + }) + + it('warns if an alias has a param with the same name but different', async () => { + createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:p/:c', alias: ['/:p/:c+'], component }], + }) + expect( + 'Alias "/:p/:c+" and the original record: "/:p/:c" should have the exact same param named "c"' + ).toHaveBeenWarned() + }) + + it('warns if an alias has extra params', async () => { + createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/:p/c', alias: ['/:p/:c'], component }], + }) + expect( + 'Alias "/:p/:c" and the original record: "/:p/c" should have the exact same param named "c"' + ).toHaveBeenWarned() + }) + + it('warns if next is called multiple times in one navigation guard', done => { + expect.assertions(3) + let router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'a', component }, + { path: '/b', name: 'a', component }, + ], + }) + + router.beforeEach((to, from, next) => { + next() + expect('').not.toHaveBeenWarned() + next() + expect('called more than once').toHaveBeenWarnedTimes(1) + next() + expect('called more than once').toHaveBeenWarnedTimes(1) + done() + }) + + router.push('/b') + }) + + it('warns if a non valid function is passed as a component', async () => { + const Functional: FunctionalComponent = () => h('div', 'functional') + // Functional should have a displayName to avoid the warning + + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/foo', component: Functional }], + }) + + await expect(router.push('/foo')).resolves.toBe(undefined) + expect('with path "/foo" is a function').toHaveBeenWarned() + }) + + it('should warn if multiple leading slashes with raw location', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component }], + }) + + await expect(router.push('//not-valid')).resolves.toBe(undefined) + expect('cannot start with multiple slashes').toHaveBeenWarned() + }) + + it('should warn if multiple leading slashes with object location', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component }], + }) + + await expect(router.push({ path: '//not-valid' })).resolves.toBe(undefined) + expect('cannot start with multiple slashes').toHaveBeenWarned() + }) + + it('warns if path contains the same param multiple times', () => { + const history = createMemoryHistory() + createRouter({ + history, + routes: [ + { + path: '/:id', + component, + children: [{ path: ':id', component }], + }, + ], + }) + expect( + 'duplicated params with name "id" for path "/:id/:id"' + ).toHaveBeenWarned() + }) + + it('warns if component is a promise instead of a function that returns a promise', async () => { + const router = createRouter({ + history: createMemoryHistory(), + // simulates import('./component.vue') + routes: [{ path: '/foo', component: Promise.resolve(component) }], + }) + + await expect(router.push({ path: '/foo' })).resolves.toBe(undefined) + expect('"/foo" is a Promise instead of a function').toHaveBeenWarned() + }) + + it('warns if no route matched', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'a', component }], + }) + + await expect(router.push('/foo')).resolves.toBe(undefined) + expect(`No match found for location with path "/foo"`).toHaveBeenWarned() + + await expect(router.push({ path: '/foo2' })).resolves.toBe(undefined) + expect(`No match found for location with path "/foo2"`).toHaveBeenWarned() + }) + + it('warns if next is called with the same location too many times', async () => { + let router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'a', component }, + { path: '/b', component }, + ], + }) + + router.beforeEach(to => { + if (to.path === '/b') return '/b' + return + }) + + await router.push('/b').catch(() => {}) + expect( + 'Detected an infinite redirection in a navigation guard when going from "/" to "/b"' + ).toHaveBeenWarned() + }) + + it('warns if `next` is called twice', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component }, + { path: '/foo', component }, + ], + }) + router.beforeEach((to, from, next) => { + next() + next() + }) + await router.push('/foo') + expect( + 'It should be called exactly one time in each navigation guard' + ).toHaveBeenWarned() + }) +}) diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 000000000..ca11cda83 --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,49 @@ +// this the shared base config for all packages. +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + "mainEntryPointFilePath": "./dist/src/index.d.ts", + + "apiReport": { + "enabled": true, + "reportFolder": "/temp/" + }, + + "docModel": { + "enabled": true + }, + + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "./dist/.d.ts" + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "default": { + "logLevel": "warning", + "addToApiReportFile": true + }, + + "ae-missing-release-tag": { + "logLevel": "none" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/circle.yml b/circle.yml new file mode 100644 index 000000000..402562287 --- /dev/null +++ b/circle.yml @@ -0,0 +1,114 @@ +# Javascript Node CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-javascript/ for more details +# +version: 2.1 + +defaults: &defaults + working_directory: ~/project/vue-router + docker: + - image: circleci/node:lts-browsers + +jobs: + install: + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - v3-dependencies-cache-{{ .Branch }}-{{ checksum "yarn.lock" }} + - v3-dependencies-cache-{{ .Branch }}- + - v3-dependencies-cache- + - run: yarn install --frozen-lockfile + - persist_to_workspace: + root: ~/project + paths: + - vue-router + + build-e2e: + <<: *defaults + steps: + - attach_workspace: + at: ~/project + - run: yarn run build:e2e + - persist_to_workspace: + root: ~/project + paths: + - vue-router/e2e/__build__ + + test-e2e: + <<: *defaults + steps: + - attach_workspace: + at: ~/project + - run: yarn test:e2e:ci + - store_artifacts: + path: e2e/reports + - store_artifacts: + path: e2e/screenshots + - store_test_results: + path: e2e/reports + + test-e2e-bs: + <<: *defaults + steps: + - attach_workspace: + at: ~/project + - run: yarn test:e2e:bs + - store_test_results: + path: e2e/reports + + test-unit: + <<: *defaults + steps: + - attach_workspace: + at: ~/project + - run: yarn test:unit --maxWorkers=2 + - store_artifacts: + path: coverage + + build-lint: + <<: *defaults + steps: + - attach_workspace: + at: ~/project + - run: yarn lint + - run: yarn test:types + - run: yarn build + - run: yarn build:dts + - run: yarn run test:dts + # Save cache after this task. At this point e2e are still running + - save_cache: + key: v3-dependencies-cache-{{ .Branch }}-{{ checksum "yarn.lock" }} + paths: + - node_modules + + coverage: + <<: *defaults + steps: + - attach_workspace: + at: ~/project + - run: + name: Send code coverage + command: yarn run codecov + +workflows: + version: 2 + install-and-parallel-test: + jobs: + - install + - test-e2e: + requires: + - install + - test-e2e-bs: + requires: + - install + - test-unit: + requires: + - install + - build-lint: + requires: + - install + - coverage: + requires: + - test-unit diff --git a/docs/.vitepress/components/HomeSponsors.vue b/docs/.vitepress/components/HomeSponsors.vue new file mode 100644 index 000000000..eb8a7b317 --- /dev/null +++ b/docs/.vitepress/components/HomeSponsors.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/docs/.vitepress/components/sponsors.json b/docs/.vitepress/components/sponsors.json new file mode 100644 index 000000000..206e892e1 --- /dev/null +++ b/docs/.vitepress/components/sponsors.json @@ -0,0 +1,14 @@ +{ + "silver": [ + { + "href": "https://www.vuemastery.com/", + "alt": "VueMastery", + "imgSrc": "https://www.vuemastery.com/images/vuemastery.svg" + }, + { + "imgSrc": "https://cdn.vuetifyjs.com/docs/images/logos/vuetify-logo-light-text.svg", + "href": "https://www.vuetifyjs.com/", + "alt": "Vuetify" + } + ] +} diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 000000000..8b84b0072 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,203 @@ +/** @typedef {import('vitepress').UserConfig} UserConfig */ + +/** @type {UserConfig['head']} */ +const head = [['link', { rel: 'icon', href: `/logo.png` }]] + +if (process.env.NODE_ENV === 'production') { + head.push([ + 'script', + { + src: 'https://unpkg.com/thesemetrics@latest', + async: '', + }, + ]) +} + +/** @type {UserConfig} */ +const config = { + lang: 'en-US', + title: 'Vue Router Next', + description: 'The official router for Vue.js.', + base: '/router4/', + locales: { + '/': { + lang: 'en-US', + title: 'Vue Router', + description: 'The official router for Vue.js.', + }, + // '/es/': { + // lang: 'es-ES', + // title: 'Vue Router', + // description: 'El router oficial par Vue.js', + // }, + }, + head, + // serviceWorker: true, + themeConfig: { + repo: 'vuejs/vue-router-next', + docsRepo: 'vuejs/vue-router-next', + docsDir: 'docs', + docsBranch: 'master', + editLinks: true, + + carbonAds: { + carbon: 'CEBICK3I', + custom: 'CEBICK3M', + placement: 'routervuejsorg', + }, + + algolia: { + apiKey: '07ed552fc16926cc57c9eb0862c1a7f9', + indexName: 'next_router_vuejs', + algoliaOptions: { facetFilters: ['tags:guide,api'] }, + }, + + locales: { + // English + '/': { + nav: [ + { + text: 'Guide', + link: '/guide/', + }, + { + text: 'API Reference', + link: '/api/', + }, + { + text: 'Changelog', + link: + 'https://github.com/vuejs/vue-router-next/blob/master/CHANGELOG.md', + }, + ], + + sidebar: [ + { + text: 'Introduction', + link: '/introduction.html', + }, + { + text: 'Installation', + link: '/installation.html', + }, + { + text: 'Essentials', + collapsable: false, + children: [ + { + text: 'Getting Started', + link: '/guide/', + }, + { + text: 'Dynamic Route Matching', + link: '/guide/essentials/dynamic-matching.html', + }, + { + text: "Routes' Matching Syntax", + link: '/guide/essentials/route-matching-syntax.html', + }, + { + text: 'Nested Routes', + link: '/guide/essentials/nested-routes.html', + }, + { + text: 'Programmatic Navigation', + link: '/guide/essentials/navigation.html', + }, + { + text: 'Named Routes', + link: '/guide/essentials/named-routes.html', + }, + { + text: 'Named Views', + link: '/guide/essentials/named-views.html', + }, + { + text: 'Redirect and Alias', + link: '/guide/essentials/redirect-and-alias.html', + }, + { + text: 'Passing Props to Route Components', + link: '/guide/essentials/passing-props.html', + }, + { + text: 'Different History modes', + link: '/guide/essentials/history-mode.html', + }, + ], + }, + { + text: 'Advanced', + collapsable: false, + children: [ + { + text: 'Navigation guards', + link: '/guide/advanced/navigation-guards.html', + }, + { + text: 'Route Meta Fields', + link: '/guide/advanced/meta.html', + }, + { + text: 'Data Fetching', + link: '/guide/advanced/data-fetching.html', + }, + { + text: 'Composition API', + link: '/guide/advanced/composition-api.html', + }, + { + text: 'Transitions', + link: '/guide/advanced/transitions.html', + }, + { + text: 'Scroll Behavior', + link: '/guide/advanced/scroll-behavior.html', + }, + { + text: 'Lazy Loading Routes', + link: '/guide/advanced/lazy-loading.html', + }, + { + text: 'Extending RouterLink', + link: '/guide/advanced/extending-router-link.html', + }, + { + text: 'Navigation Failures', + link: '/guide/advanced/navigation-failures.html', + }, + { + text: 'Dynamic Routing', + link: '/guide/advanced/dynamic-routing.html', + }, + ], + }, + { + text: 'Migrating from Vue 2', + link: '/guide/migration/index.html', + }, + ], + }, + }, + + // '/es/': { + // nav: [ + // { + // text: 'Guía', + // link: '/guide/', + // }, + // { + // text: 'API', + // link: '/api/', + // }, + // { + // text: 'Cambios', + // link: + // 'https://github.com/vuejs/vue-router-next/blob/master/CHANGELOG.md', + // }, + // ], + // }, + }, +} + +module.exports = config diff --git a/docs/.vitepress/style.styl b/docs/.vitepress/style.styl new file mode 100644 index 000000000..395006696 --- /dev/null +++ b/docs/.vitepress/style.styl @@ -0,0 +1,66 @@ +.bit-sponsor + font-weight 600 + background-color #f3f6f8 + padding 0.6em 1.2em + border-radius 8px + display inline-block + margin 1em 0 !important + + a + color #999 + + img + height 40px + margin-left 15px + + img, span + vertical-align middle + +.vueschool + background-color #e7ecf3 + padding 1em 1.25em + border-radius 2px + color #486491 + position relative + display flex + + a + color #486491 !important + position relative + padding-left 36px + + &:before + content '' + position absolute + display block + width 30px + height 30px + top calc(50% - 15px); + left -4px + border-radius 50% + background-color #73abfe + + &:after + content '' + position absolute + display block + width 0 + height 0 + top calc(50% - 5px) + left 8px + border-top 5px solid transparent + border-bottom 5px solid transparent + border-left 8px solid #fff +#ad + width: 125px; + position: fixed; + z-index: $z-header -1; + right: 10px; + top: 120px; + padding: 10px; + background-color: #fff; + border-radius: 3px; + font-size: 13px; + text-align: center; + img + width: 100%; \ No newline at end of file diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue new file mode 100644 index 000000000..6df984891 --- /dev/null +++ b/docs/.vitepress/theme/Layout.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/docs/.vitepress/theme/components/AlgoliaSearchBox.vue b/docs/.vitepress/theme/components/AlgoliaSearchBox.vue new file mode 100644 index 000000000..298e27063 --- /dev/null +++ b/docs/.vitepress/theme/components/AlgoliaSearchBox.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/docs/.vitepress/theme/components/BuySellAds.vue b/docs/.vitepress/theme/components/BuySellAds.vue new file mode 100644 index 000000000..ccd4cfcac --- /dev/null +++ b/docs/.vitepress/theme/components/BuySellAds.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/docs/.vitepress/theme/components/CarbonAds.vue b/docs/.vitepress/theme/components/CarbonAds.vue new file mode 100644 index 000000000..94814564c --- /dev/null +++ b/docs/.vitepress/theme/components/CarbonAds.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 000000000..6782a554d --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,14 @@ +import DefaultTheme from 'vitepress/dist/client/theme-default' +import Layout from './Layout.vue' +import HomeSponsors from '../components/HomeSponsors.vue' + +export default { + ...DefaultTheme, + Layout, + enhanceApp({ app, router, siteData }) { + app.component('HomeSponsors', HomeSponsors) + // app is the Vue 3 app instance from createApp() + // router is VitePress' custom router (see `lib/app/router.js`) + // siteData is a ref of current site-level metadata. + }, +} diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 000000000..2ced3c4ad --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,1085 @@ +--- +sidebar: auto +--- + +# API Reference + +## `` Props + +### to + +- **Type**: [`RouteLocationRaw`](#routelocationraw) +- **Details**: + + Denotes the target route of the link. When clicked, the value of the `to` prop will be passed to `router.push()` internally, so it can either be a `string` or a [route location object](#routelocationraw). + +```html + +Home + +Home + + +Home + + +Home + + +User + + + + Register + +``` + +### replace + +- **Type**: `boolean` +- **Default**: `false` +- **Details**: + + Setting `replace` prop will call `router.replace()` instead of `router.push()` when clicked, so the navigation will not leave a history record. + +```html + +``` + +### active-class + +- **Type**: `string` +- **Default**: `"router-link-active"` (or global [`linkActiveClass`](#linkactiveclass)) +- **Details**: + + Class to apply on the rendered `` when the link is active. + +### aria-current-value + +- **Type**: `'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'` (`string`) +- **Default**: `"page"` +- **Details**: + + Value passed to the attribute `aria-current` when the link is exactly active. + +### custom + +- **Type**: `boolean` +- **Default**: `false` +- **Details**: + + Whether `` should not wrap its content in an `` element. Useful when using [`v-slot`](#router-link-s-v-slot) to create a custom RouterLink. By default, `` will render its content wrapped in an `` element, even when using `v-slot`. Passing the `custom` prop, removes that behavior. + +- **Examples**: + + ```html + + {{ route.fullPath }} + + ``` + + Renders `/home`. + + ```html + + {{ route.fullPath }} + + ``` + + Renders `/home`. + +### exact-active-class + +- **Type**: `string` +- **Default**: `"router-link-exact-active"` (or global [`linkExactActiveClass`](#linkexactactiveclass)) +- **Details**: + + Class to apply on the rendered `` when the link is exact active. + +## ``'s `v-slot` + +`` exposes a low level customization through a [scoped slot](https://v3.vuejs.org/guide/component-slots.html#scoped-slots). This is a more advanced API that primarily targets library authors but can come in handy for developers as well, to build a custom component like a _NavLink_ or other. + +:::tip +Remember to pass the `custom` option to `` to prevent it from wrapping its content inside of an `` element. +::: + +```html + + + {{ route.fullPath }} + + +``` + +- `href`: resolved url. This would be the `href` attribute of an `` element. It contains the `base` if any was provided. +- `route`: resolved normalized location. +- `navigate`: function to trigger the navigation. **It will automatically prevent events when necessary**, the same way `router-link` does, e.g. `ctrl` or `cmd` + click will still be ignored by `navigate`. +- `isActive`: `true` if the [active class](#active-class) should be applied. Allows to apply an arbitrary class. +- `isExactActive`: `true` if the [exact active class](#exact-active-class) should be applied. Allows to apply an arbitrary class. + +### Example: Applying Active Class to Outer Element + +Sometimes we may want the active class to be applied to an outer element rather than the `` element itself, in that case, you can wrap that element inside a `router-link` and use the `v-slot` properties to create your link: + +```html + +

  • + {{ route.fullPath }} +
  • + +``` + +:::tip +If you add a `target="_blank"` to your `a` element, you must omit the `@click="navigate"` handler. +::: + +## `` Props + +### name + +- **Type**: `string` +- **Default**: `"default"` +- **Details**: + + When a `` has a `name`, it will render the component with the corresponding name in the matched route record's `components` option. + +- **See Also**: [Named Views](/guide/essentials/named-views.md) + +### route + +- **Type**: [`RouteLocationNormalized`](#routelocationnormalized) +- **Details**: + + A route location that has all of its component resolved (if any was lazy loaded) so it can be displayed. + +## ``'s `v-slot` + +`` exposes a `v-slot` API mainly to wrap your route components with `` and `` components. + +```html + + + +``` + +- `Component`: VNodes to be passed to a ``'s `is` prop. +- `route`: resolved normalized [route location](#routelocationnormalized). + +## createRouter + +Creates a Router instance that can be used by a Vue app. Check the [`RouterOptions`](#routeroptions) for a list of all the properties that can be passed. + +**Signature:** + +```typescript +export declare function createRouter(options: RouterOptions): Router +``` + +### Parameters + +| Parameter | Type | Description | +| --------- | ------------------------------- | -------------------------------- | +| options | [RouterOptions](#routeroptions) | Options to initialize the router | + +## createWebHistory + +Creates an HTML5 history. Most common history for single page applications. The application must be served through the http protocol. + +**Signature:** + +```typescript +export declare function createWebHistory(base?: string): RouterHistory +``` + +### Parameters + +| Parameter | Type | Description | +| --------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| base | `string` | optional base to provide. Useful when the application is hosted inside of a folder like `https://example.com/folder/` | + +### Examples + +```js +createWebHistory() // No base, the app is hosted at the root of the domain `https://example.com` +createWebHistory('/folder/') // gives a url of `https://example.com/folder/` +``` + +## createWebHashHistory + +Creates a hash history. Useful for web applications with no host (e.g. `file://`) or when configuring a server to handle any URL isn't an option. **Note you should use [`createWebHistory`](#createwebhistory) if SEO matters to you**. + +**Signature:** + +```typescript +export declare function createWebHashHistory(base?: string): RouterHistory +``` + +### Parameters + +| Parameter | Type | Description | +| --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| base | `string` | optional base to provide. Defaults to `location.pathname` or `/` if at root. If there is a `base` tag in the `head`, its value will be **ignored**. | + +### Examples + +```js +// at https://example.com/folder +createWebHashHistory() // gives a url of `https://example.com/folder#` +createWebHashHistory('/folder/') // gives a url of `https://example.com/folder/#` +// if the `#` is provided in the base, it won't be added by `createWebHashHistory` +createWebHashHistory('/folder/#/app/') // gives a url of `https://example.com/folder/#/app/` +// you should avoid doing this because it changes the original url and breaks copying urls +createWebHashHistory('/other-folder/') // gives a url of `https://example.com/other-folder/#` + +// at file:///usr/etc/folder/index.html +// for locations with no `host`, the base is ignored +createWebHashHistory('/iAmIgnored') // gives a url of `file:///usr/etc/folder/index.html#` +``` + +## createMemoryHistory + +Creates a in-memory based history. The main purpose of this history is to handle SSR. It starts in a special location that is nowhere. If the user is not on a browser context, it's up to them to replace that location with the starter location by either calling `router.push()` or `router.replace()`. + +**Signature:** + +```typescript +export declare function createMemoryHistory(base?: string): RouterHistory +``` + +### Parameters + +| Parameter | Type | Description | +| --------- | -------- | ----------------------------------------- | +| base | `string` | Base applied to all urls, defaults to '/' | + +### Returns + +A history object that can be passed to the router constructor + +## NavigationFailureType + +Enumeration with all possible types for navigation failures. Can be passed to [isNavigationFailure](#isnavigationfailure) to check for specific failures. **Never use any of the numerical values**, always use the variables like `NavigationFailureType.aborted`. + +**Signature:** + +```typescript +export declare enum NavigationFailureType +``` + +### Members + +| Member | Value | Description | +| ---------- | ----- | -------------------------------------------------------------------------------------------------------------------------------- | +| aborted | 4 | An aborted navigation is a navigation that failed because a navigation guard returned `false` or called `next(false)` | +| cancelled | 8 | A cancelled navigation is a navigation that failed because a more recent navigation finished started (not necessarily finished). | +| duplicated | 16 | A duplicated navigation is a navigation that failed because it was initiated while already being at the exact same location. | + +## START_LOCATION + +- **Type**: [`RouteLocationNormalized`](#routelocationnormalized) +- **Details**: + + Initial route location where the router is. Can be used in navigation guards to differentiate the initial navigation. + + ```js + import { START_LOCATION } from 'vue-router' + + router.beforeEach((to, from) => { + if (from === START_LOCATION) { + // initial navigation + } + }) + ``` + +## Composition API + +### onBeforeRouteLeave + +Add a navigation guard that triggers whenever the component for the current location is about to be left. Similar to `beforeRouteLeave` but can be used in any component. The guard is removed when the component is unmounted. + +**Signature:** + +```typescript +export declare function onBeforeRouteLeave(leaveGuard: NavigationGuard): void +``` + +#### Parameters + +| Parameter | Type | Description | +| ---------- | ------------------------------------- | ----------------------- | +| leaveGuard | [`NavigationGuard`](#navigationguard) | Navigation guard to add | + +### onBeforeRouteUpdate + +Add a navigation guard that triggers whenever the current location is about to be updated. Similar to `beforeRouteUpdate` but can be used in any component. The guard is removed when the component is unmounted. + +**Signature:** + +```typescript +export declare function onBeforeRouteUpdate(updateGuard: NavigationGuard): void +``` + +#### Parameters + +| Parameter | Type | Description | +| ----------- | ------------------------------------- | ----------------------- | +| updateGuard | [`NavigationGuard`](#navigationguard) | Navigation guard to add | + +### useLink + +Returns everything exposed by the [`v-slot` API](#router-link-s-v-slot). + +**Signature:** + +```typescript +export declare function useLink(props: RouterLinkOptions): { + route: ComputedRef, + href: ComputedRef, + isActive: ComputedRef, + isExactActive: ComputedRef, + navigate: (event?: MouseEvent) => Promise(NavigationFailure | void), +} +``` + +#### Parameters + +| Parameter | Type | Description | +| --------- | ----------------- | ------------------------------------------------------------------------------------- | +| props | RouterLinkOptions | props object that can be passed to ``. Accepts `Ref`s and `ComputedRef`s | + +### useRoute + +Returns the current route location. Equivalent to using `$route` inside templates. Must be called inside of `setup()`. + +**Signature:** + +```typescript +export declare function useRoute(): RouteLocationNormalized +``` + +### useRouter + +Returns the [router](#Router) instance. Equivalent to using `$router` inside templates. Must be called inside of `setup()`. + +**Signature:** + +```typescript +export declare function useRouter(): Router +``` + +## TypeScript + +Here are some of the interfaces and types used by Vue Router. The documentation references them to give you an idea of the existing properties in objects. + +## Router Properties + +### currentRoute + +- **Type**: [`Ref`](#routelocationnormalized) +- **Details**: + + Current route location. Readonly. + +### options + +- **Type**: [`RouterOptions`](#routeroptions) +- **Details**: + + Original options object passed to create the Router. Readonly. + +## Router Methods + +### addRoute + +Add a new [Route Record](#routerecordraw) as the child of an existing route. If the route has a `name` and there is already an existing one with the same one, it removes it first. + +**Signature:** + +```typescript +addRoute(parentName: string | symbol, route: RouteRecordRaw): () => void +``` + +_Parameters_ + +| Parameter | Type | Description | +| ---------- | ----------------------------------- | ------------------- | +| parentName | `string | symbol` | Parent Route Record where `route` should be appended at | +| route | [`RouteRecordRaw`](#routerecordraw) | Route Record to add | + +### addRoute + +Add a new [route record](#routerecordraw) to the router. If the route has a `name` and there is already an existing one with the same one, it removes it first. + +**Signature:** + +```typescript +addRoute(route: RouteRecordRaw): () => void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | ----------------------------------- | ------------------- | +| route | [`RouteRecordRaw`](#routerecordraw) | Route Record to add | + +:::tip +Note adding routes does not trigger a new navigation, meaning that the added route will not be displayed unless a new navigation is triggered. +::: + +### afterEach + +Add a navigation hook that is executed after every navigation. Returns a function that removes the registered hook. + +**Signature:** + +```typescript +afterEach(guard: NavigationHookAfter): () => void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | ------------------- | ---------------------- | +| guard | NavigationHookAfter | navigation hook to add | + +#### Examples + +```js +router.afterEach((to, from, failure) => { + if (isNavigationFailure(failure)) { + console.log('failed navigation', failure) + } +}) +``` + +### back + +Go back in history if possible by calling `history.back()`. Equivalent to `router.go(-1)`. + +**Signature:** + +```typescript +back(): void +``` + +### beforeEach + +Add a navigation guard that executes before any navigation. Returns a function that removes the registered guard. + +**Signature:** + +```typescript +beforeEach(guard: NavigationGuard): () => void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | ------------------------------------- | ----------------------- | +| guard | [`NavigationGuard`](#navigationguard) | navigation guard to add | + +### beforeResolve + +Add a navigation guard that executes before navigation is about to be resolved. At this state all component have been fetched and other navigation guards have been successful. Returns a function that removes the registered guard. + +**Signature:** + +```typescript +beforeResolve(guard: NavigationGuard): () => void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | ------------------------------------- | ----------------------- | +| guard | [`NavigationGuard`](#navigationguard) | navigation guard to add | + +#### Examples + +```js +router.beforeEach(to => { + if (to.meta.requiresAuth && !isAuthenticated) return false +}) +``` + +### forward + +Go forward in history if possible by calling `history.forward()`. Equivalent to `router.go(1)`. + +**Signature:** + +```typescript +forward(): void +``` + +### getRoutes + +Get a full list of all the [route records](#routerecord). + +**Signature:** + +```typescript +getRoutes(): RouteRecord[] +``` + +### go + +Allows you to move forward or backward through the history. + +**Signature:** + +```typescript +go(delta: number): void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | -------- | ----------------------------------------------------------------------------------- | +| delta | `number` | The position in the history to which you want to move, relative to the current page | + +### hasRoute + +Checks if a route with a given name exists + +**Signature:** + +```typescript +hasRoute(name: string | symbol): boolean +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | ------- | ----------- | +| name | `string | symbol` | Name of the route to check | + +### isReady + +Returns a Promise that resolves when the router has completed the initial navigation, which means it has resolved all async enter hooks and async components that are associated with the initial route. If the initial navigation already happened, the promise resolves immediately.This is useful in server-side rendering to ensure consistent output on both the server and the client. Note that on server side, you need to manually push the initial location while on client side, the router automatically picks it up from the URL. + +**Signature:** + +```typescript +isReady(): Promise +``` + +### onError + +Adds an error handler that is called every time a non caught error happens during navigation. This includes errors thrown synchronously and asynchronously, errors returned or passed to `next` in any navigation guard, and errors occurred when trying to resolve an async component that is required to render a route. + +**Signature:** + +```typescript +onError(handler: (error: any) => any): () => void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | --------------------- | ------------------------- | +| handler | `(error: any) => any` | error handler to register | + +### push + +Programmatically navigate to a new URL by pushing an entry in the history stack. + +**Signature:** + +```typescript +push(to: RouteLocationRaw): Promise +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | --------------------------------------- | ----------------------------- | +| to | [`RouteLocationRaw`](#routelocationraw) | Route location to navigate to | + +### removeRoute + +Remove an existing route by its name. + +**Signature:** + +```typescript +removeRoute(name: string | symbol): void +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | ------- | ----------- | +| name | `string | symbol` | Name of the route to remove | + +### replace + +Programmatically navigate to a new URL by replacing the current entry in the history stack. + +**Signature:** + +```typescript +replace(to: RouteLocationRaw): Promise +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | --------------------------------------- | ----------------------------- | +| to | [`RouteLocationRaw`](#routelocationraw) | Route location to navigate to | + +### resolve + +Returns the [normalized version](#routelocation) of a [route location](#routelocationraw). Also includes an `href` property that includes any existing `base`. + +**Signature:** + +```typescript +resolve(to: RouteLocationRaw): RouteLocation & { + href: string +} +``` + +_Parameters_ + +| Parameter | Type | Description | +| --------- | --------------------------------------- | ----------------------------- | +| to | [`RouteLocationRaw`](#routelocationraw) | Raw route location to resolve | + +## RouterOptions + +### history + +History implementation used by the router. Most web applications should use `createWebHistory` but it requires the server to be properly configured. You can also use a _hash_ based history with `createWebHashHistory` that does not require any configuration on the server but isn't handled at all by search engines and does poorly on SEO. + +**Signature:** + +```typescript +history: RouterHistory +``` + +#### Examples + +```js +createRouter({ + history: createWebHistory(), + // other options... +}) +``` + +### linkActiveClass + +Default class applied to active [RouterLink](#router-link-props). If none is provided, `router-link-active` will be applied. + +**Signature:** + +```typescript +linkActiveClass?: string +``` + +### linkExactActiveClass + +Default class applied to exact active [RouterLink](#router-link-props). If none is provided, `router-link-exact-active` will be applied. + +**Signature:** + +```typescript +linkExactActiveClass?: string +``` + +### parseQuery + +Custom implementation to parse a query. Must decode query keys and values. See its counterpart, [stringifyQuery](#stringifyquery). + +**Signature:** + +```typescript +parseQuery?: (searchQuery: string) => Record +``` + +#### Examples + +Let's say you want to use the package [qs](https://github.com/ljharb/qs) to parse queries, you can provide both `parseQuery` and `stringifyQuery`: + +```js +import qs from 'qs' + +createRouter({ + // other options... + parseQuery: qs.parse, + stringifyQuery: qs.stringify, +}) +``` + +### routes + +Initial list of routes that should be added to the router. + +**Signature:** + +```typescript +routes: RouteRecordRaw[] +``` + +### scrollBehavior + +Function to control scrolling when navigating between pages. Can return a Promise to delay scrolling. Check . + +**Signature:** + +```typescript +scrollBehavior?: ScrollBehavior +``` + +#### Examples + +```js +function scrollBehavior(to, from, savedPosition) { + // `to` and `from` are both route locations + // `savedPosition` can be null if there isn't one +} +``` + +### stringifyQuery + +Custom implementation to stringify a query object. Should not prepend a leading `?`. Should properly encode query keys and values. [parseQuery](#parsequery) counterpart to handle query parsing. + +**Signature:** + +```typescript +stringifyQuery?: ( + query: Record< + string | number, + string | number | null | undefined | (string | number | null | undefined)[] + > +) => string +``` + +## RouteRecordRaw + +Route record that can be provided by the user when adding routes via the [`routes` option](#routeroptions) or via [`router.addRoutes()`](#addroutes). There are three different kind of route records: + +- Single views records: have a `component` option +- Multiple views records ([named views](/guide/essentials/named-views.md)): have a `components` option +- Redirect records: cannot have `component` or `components` option because a redirect record is never reached. + +### path + +- **Type**: `string` +- **Details**: + + Path of the record. Should start with `/` unless the record is the child of another record. + Can define parameters: `/users/:id` matches `/users/1` as well as `/users/posva`. + +- **See Also**: [Dynamic Route Matching](/guide/essentials/dynamic-matching.md) + +### redirect + +- **Type**: `RouteLocationRaw | (to: RouteLocationNormalized) => RouteLocationRaw` (Optional) +- **Details**: + + Where to redirect if the route is directly matched. The redirection happens + before any navigation guard and triggers a new navigation with the new target + location. Can also be a function that receives the target route location and + returns the location we should redirect to. + +### children + +- **Type**: Array of [`RouteRecordRaw`](#routerecordraw) (Optional) +- **Details**: + + Nested routes of the current record. + +- **See Also**: [Nested Routes](/guide/essentials/nested-routes.md) + +### alias + +- **Type**: `string | string[]` (Optional) +- **Details**: + + Aliases for the route. Allows defining extra paths that will behave like a + copy of the record. This enables paths shorthands like `/users/:id` and + `/u/:id`. **All `alias` and `path` values must share the same params**. + +### name + +- **Type**: `string | symbol` (Optional) +- **Details**: + + Unique name for the route record. + +### beforeEnter + +- **Type**: [`NavigationGuard | NavigationGuard[]`](#navigationguard) (Optional) +- **Details**: + + Before enter guard specific to this record. Note `beforeEnter` has no effect if the record has a `redirect` property. + +### props + +- **Type**: `boolean | Record | (to: RouteLocationNormalized) => Record` (Optional) +- **Details**: + + Allows passing down params as props to the component rendered by `router-view`. When passed to a _multiple views record_, it should be an object with the same keys as `components` or a `boolean` to be applied to each component. + target location. + +- **See Also**: [Passing props to Route Components](/guide/essentials/passing-props.md) + +### meta + +- **Type**: [`RouteMeta`](#routemeta) (Optional) +- **Details**: + + Custom data attached to the record. + +- **See Also**: [Meta fields](/guide/advanced/meta.md) + +## RouteRecordNormalized + +Normalized version of a [Route Record](#routerecordraw) + +### aliasOf + +- **Type**: `RouteRecordNormalized | undefined` +- **Details**: + + Defines if this record is the alias of another one. This property is `undefined` if the record is the original one. + +### beforeEnter + +- **Type**: [`NavigationGuard`](#navigationguard) +- **Details**: + + Navigation guard applied when entering this record from somewhere else. + +- **See Also**: [Navigation guards](/guide/advanced/navigation-guards.md) + +### children + +- **Type**: Array of normalized [route records](#routerecordnormalized) +- **Details**: + + Children route records of the current route. Empty array if none. + +### components + +- **Type**: `Record` +- **Details**: + + Dictionary of named views, if none, contains an object with the key `default`. + +### meta + +- **Type**: `RouteMeta` +- **Details**: + + Arbitrary data attached to the record. + +- **See also**: [Meta fields](/guide/advanced/meta.md) + +### name + +- **Type**: `string | symbol | undefined` +- **Details**: + + Name for the route record. `undefined` if none was provided. + +### path + +- **Type**: `string` +- **Details**: + + Normalized path of the record. Includes any parent's `path`. + +### props + +- **Type**: `Record>` +- **Details**: + + Dictionary of the [`props` option](#props) for each named view. If none, it will contain only one property named `default`. + +### redirect + +- **Type**: [`RouteLocationRaw`](#routelocationraw) +- **Details**: + + Where to redirect if the route is directly matched. The redirection happens before any navigation guard and triggers a new navigation with the new target location. + +## RouteLocationRaw + +User-level route location that can be passed to `router.push()`, `redirect`, and returned in [Navigation Guards](/guide/advanced/navigation-guards.md). + +A raw location can either be a `string` like `/users/posva#bio` or an object: + +```js +// these three forms are equivalent +router.push('/users/posva#bio') +router.push({ path: '/users/posva', hash: '#bio' }) +router.push({ name: 'users', params: { username: 'posva' }, hash: '#bio' }) +// only change the hash +router.push({ hash: '#bio' }) +// only change query +router.push({ query: { page: '2' } }) +// change one param +router.push({ params: { username: 'jolyne' } }) +``` + +Note `path` must be provided encoded (e.g. `phantom blood` becomes `phantom%20blood`) while `params`, `query` and `hash` must not, they are encoded by the router. + +Raw route locations also support an extra option `replace` to call `router.replace()` instead of `router.push()` in navigation guards. Note this also internally calls `router.replace()` even when calling `router.push()`: + +```js +router.push({ hash: '#bio', replace: true }) +// equivalent to +router.replace({ hash: '#bio' }) +``` + +## RouteLocation + +Resolved [RouteLocationRaw](#routelocationraw) that can contain [redirect records](#routerecordraw). Apart from that it has the same properties as [RouteLocationNormalized](#routelocationnormalized). + +## RouteLocationNormalized + +Normalized route location. Does not have any [redirect records](#routerecordraw). In navigation guards, `to` and `from` are always of this type. + +### fullPath + +- **Type**: `string` +- **Details**: + + Encoded URL associated to the route location. Contains `path`, `query` and `hash`. + +### hash + +- **Type**: `string` +- **Details**: + + Decoded `hash` section of the URL. Always starts with a `#`. Empty string if there is no `hash` in the URL. + +### query + +- **Type**: `Record` +- **Details**: + + Dictionary of decoded query params extracted from the `search` section of the URL. + +### matched + +- **Type**: [`RouteRecordNormalized[]`](#routerecordnormalized) +- **Details**: + + Array of [normalized route records](#routerecord) that were matched with the given route location. + +### meta + +- **Type**: `RouteMeta` +- **Details**: + + Arbitrary data attached to all matched records merged (non recursively) from parent to child. + +- **See also**: [Meta fields](/guide/advanced/meta.md) + +### name + +- **Type**: `string | symbol | undefined | null` +- **Details**: + + Name for the route record. `undefined` if none was provided. + +### params + +- **Type**: `Record` +- **Details**: + + Dictionary of decoded params extracted from `path`. + +### path + +- **Type**: `string` +- **Details**: + + Encoded `pathname` section of the URL associated to the route location. + +### redirectedFrom + +- **Type**: [`RouteLocation`](#routelocation) +- **Details**: + + Route location we were initially trying to access before ending up on the current location when a `redirect` option was found or a navigation guard called `next()` with a route location. `undefined` if there was no redirection. + +## NavigationFailure + +### from + +- **Type**: [`RouteLocationNormalized`](#routelocationnormalized) +- **Details**: + + Route location we were navigating from + +### to + +- **Type**: [`RouteLocationNormalized`](#routelocationnormalized) +- **Details**: + + Route location we were navigating to + +### type + +- **Type**: [`NavigationFailureType`](#navigationfailuretype) +- **Details**: + + Type of the navigation failure. + +- **See Also**: [Navigation Failures](/guide/advanced/navigation-failures.md) + +## NavigationGuard + +- **Arguments**: + + - [`RouteLocationNormalized`](#routelocationnormalized) to - Route location we are navigating to + - [`RouteLocationNormalized`](#routelocationnormalized) from - Route location we are navigating from + - `Function` next (Optional) - Callback to validate the navigation + +- **Details**: + + Function that can be passed to control a router navigation. The `next` callback can be omitted if you return a value (or a Promise) instead, which is encouraged. Possible return values (and parameters for `next`) are: + + - `undefined | void | true`: validates the navigation + - `false`: cancels the navigation + - [`RouteLocationRaw`](#routelocationraw): redirects to a different location + - `(vm: ComponentPublicInstance) => any` **only for `beforeRouteEnter`**: A callback to be executed once the navigation completes. Receives the route component instance as the parameter. + +- **See Also**: [Navigation Guards](/guide/advanced/navigation-guards.md) + +## Component Injections + +### Component Injected Properties + +These properties are injected into every child component by calling `app.use(router)`. + +- **this.\$router** + + The router instance. + +- **this.\$route** + + The current active [route location](#routelocationnormalized). This property is read-only and its properties are immutable, but it can be watched. + +### Component Enabled Options + +- **beforeRouteEnter** +- **beforeRouteUpdate** +- **beforeRouteLeave** + +See [In Component Guards](/guide/advanced/navigation-guards.md#in-component-guards). diff --git a/docs/guide/advanced/composition-api.md b/docs/guide/advanced/composition-api.md new file mode 100644 index 000000000..fcd6e3311 --- /dev/null +++ b/docs/guide/advanced/composition-api.md @@ -0,0 +1,107 @@ +# Vue Router and the Composition API + +The introduction of `setup` and Vue's [Composition API](https://v3.vuejs.org/guide/composition-api-introduction.html), open up new possibilities but to be able to get the full potential out of Vue Router, we will need to use a few new functions to replace access to `this` and in-component navigation guards. + +## Accessing the Router and current Route inside `setup` + +Because we don't have access to `this` inside of `setup`, we cannot directly access `this.$router` or `this.$route` anymore. Instead we use the `useRouter` function: + +```js +export default { + setup() { + const router = useRouter() + const route = useRoute() + + function pushWithQuery(query) { + router.push({ + name: 'search', + query: { + ...route.query, + }, + }) + } + }, +} +``` + +The `route` object is a reactive object, so any of its properties can be watched and you should **avoid watching the whole `route`** object: + +```js +export default { + setup() { + const route = useRoute() + const userData = ref() + + // fetch the user information when params change + watch( + () => route.params, + async newParams => { + userData.value = await fetchUser(newParams.id) + } + ) + }, +} +``` + +Note we still have access to `$router` and `$route` in templates, so there is no need to return `router` or `route` inside of `setup`. + +## Navigation Guards + +While you can still use in-component navigation guards with a `setup` function, Vue Router exposes update and leave guards as Composition API functions: + +```js +import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router' + +export default { + setup() { + // same as beforeRouteLeave option with no access to `this` + onBeforeRouteLeave((to, from) => { + const answer = window.confirm( + 'Do you really want to leave? you have unsaved changes!' + ) + // cancel the navigation and stay on the same page + if (!answer) return false + }) + + const userData = ref() + + // same as beforeRouteUpdate option with no access to `this` + onBeforeRouteUpdate(async (to, from) => { + // only fetch the user if the id changed as maybe only the query or the hash changed + if (to.params.id !== from.params.id) { + userData.value = await fetchUser(to.params.id) + } + }) + }, +} +``` + +Composition API guards can also be used in any component rendered by ``, they don't have to be used directly on the route component like in-component guards. + +## `useLink` + +Vue Router exposes the internal behavior of RouterLink as a Composition API function. It gives access the same properties as the [`v-slot` API](/api/#router-link-s-v-slot): + +```js +import { RouterLink, useLink } from 'vue-router' + +export default { + name: 'AppLink', + + props: { + // add @ts-ignore if using TypeScript + ...RouterLink.props, + inactiveClass: String, + }, + + setup(props) { + const { route, href, isActive, isExactActive, navigate } = useLink(props) + + const isExternalLink = computed( + () => typeof props.to === 'string' && props.to.startsWith('http') + ) + + return { isExternalLink, href, navigate, isActive } + }, +} +``` diff --git a/docs/guide/advanced/data-fetching.md b/docs/guide/advanced/data-fetching.md new file mode 100644 index 000000000..8a3882d10 --- /dev/null +++ b/docs/guide/advanced/data-fetching.md @@ -0,0 +1,106 @@ +# Data Fetching + +Sometimes you need to fetch data from the server when a route is activated. For example, before rendering a user profile, you need to fetch the user's data from the server. We can achieve this in two different ways: + +- **Fetching After Navigation**: perform the navigation first, and fetch data in the incoming component's lifecycle hook. Display a loading state while data is being fetched. + +- **Fetching Before Navigation**: Fetch data before navigation in the route enter guard, and perform the navigation after data has been fetched. + +Technically, both are valid choices - it ultimately depends on the user experience you are aiming for. + +## Fetching After Navigation + +When using this approach, we navigate and render the incoming component immediately, and fetch data in the component's `created` hook. It gives us the opportunity to display a loading state while the data is being fetched over the network, and we can also handle loading differently for each view. + +Let's assume we have a `Post` component that needs to fetch the data for a post based on `$route.params.id`: + +```html + +``` + +```js +export default { + data() { + return { + loading: false, + post: null, + error: null, + } + }, + created() { + // watch the params of the route to fetch the data again + this.$watch( + () => this.$route.params, + () => { + this.fetchData() + }, + // fetch the data when the view is created and the data is + // already being observed + { immediate: true } + ) + }, + methods: { + fetchData() { + this.error = this.post = null + this.loading = true + // replace `getPost` with your data fetching util / API wrapper + getPost(this.$route.params.id, (err, post) => { + this.loading = false + if (err) { + this.error = err.toString() + } else { + this.post = post + } + }) + }, + }, +} +``` + +## Fetching Before Navigation + +With this approach we fetch the data before actually navigating to the new +route. We can perform the data fetching in the `beforeRouteEnter` guard in the incoming component, and only call `next` when the fetch is complete: + +```js +export default { + data() { + return { + post: null, + error: null, + } + }, + beforeRouteEnter(to, from, next) { + getPost(to.params.id, (err, post) => { + next(vm => vm.setData(err, post)) + }) + }, + // when route changes and this component is already rendered, + // the logic will be slightly different. + async beforeRouteUpdate(to, from) { + this.post = null + try { + this.post = await getPost(to.params.id) + } catch (error) { + this.error = error.toString() + } + }, +} +``` + +The user will stay on the previous view while the resource is being fetched for the incoming view. It is therefore recommended to display a progress bar or some kind of indicator while the data is being fetched. If the data fetch fails, it's also necessary to display some kind of global warning message. + + + + diff --git a/docs/guide/advanced/dynamic-routing.md b/docs/guide/advanced/dynamic-routing.md new file mode 100644 index 000000000..41f634fee --- /dev/null +++ b/docs/guide/advanced/dynamic-routing.md @@ -0,0 +1,103 @@ +# Dynamic Routing + +Adding routes to your router is usually done via the [`routes` option](/api/#routes) but in some situations, you might want to add or remove routes while the application is already running. Application with extensible interfaces like [Vue CLI UI](https://cli.vuejs.org/dev-guide/ui-api.html) can use this to make the application grow. + +## Adding Routes + +Dynamic routing is achieved mainly via two functions: `router.addRoute()` and `router.removeRoute()`. They **only** register a new route, meaning that if the newly added route matches the current location, it would require you to **manually navigate** with `router.push()` or `router.replace()` to display that new route. Let's take a look at an example: + +Imagine having the following router with one single route: + +```js +const router = createRouter({ + history: createWebHistory(), + routes: [{ path: '/:articleName', component: Article }], +}) +``` + +Going to any page, `/about`, `/store`, or `/3-tricks-to-improve-your-routing-code` ends up rendering the `Article` component. If we are on `/about` and we are a new route: + +```js +router.addRoute({ path: '/about', component: About }) +``` + +The page will still show the `Article` component, we need to manually call `router.replace()` to change the current location and overwrite where we were (instead of pushing a new entry, ending up in the same location twice in our history): + +```js +router.addRoute({ path: '/about', component: About }) +// we could also use this.$route or route = useRoute() (inside a setup) +router.replace(router.currentRoute.value.fullPath) +``` + +Remember you can `await router.replace()` if you need to wait for the new route to be displayed. + +## Adding Routes inside navigation guards + +If you decide to add or remove routes inside of a navigation guard, you should not call `router.replace()` but trigger a redirection by returning the new location: + +```js +router.beforeEach(to => { + if (!hasNecessaryRoute(to)) { + router.addRoute(generateRoute(to)) + // trigger a redirection + return to.fullPath + } +}) +``` + +The example above assumes two things: first, the newly added route record will match the `to` location, effectively resulting in a different location from the one we were trying to access. Second, `hasNecessaryRoute()` returns `false` after adding the new route to avoid an infinite redirection. + +Because we are redirecting, we are replacing the ongoing navigation, effectively behaving like the example shown before. In real world scenarios, adding is more likely to happen outside of navigation guards, e.g. when a view component mounts, it register new routes. + +## Removing routes + +There are few different ways to remove existing routes: + +- By adding a route with a conflicting name. If you add a route that has the same name as an existing route, it will remove the route first and then add the route: + ```js + router.addRoute({ path: '/about', name: 'about', component: About }) + // this will remove the previously added route because they have the same name and names are unique + router.addRoute({ path: '/other', name: 'about', component: Other }) + ``` +- By calling the callback returned by `router.addRoute()`: + ```js + const removeRoute = router.addRoute(routeRecord) + removeRoute() // removes the route if it exists + ``` + This is useful when the routes do not have a name +- By using `router.removeRoute()` to remove a route by its name: + ```js + router.addRoute({ path: '/about', name: 'about', component: About }) + // remove the route + router.removeRoute('about') + ``` + Note you can use `Symbol`s for names in routes if you wish to use this function but want to avoid conflicts in names. + +Whenever a route is removed, **all of its aliases and children** are removed with it. + +## Adding nested routes + +To add nested routes to an existing route, you can pass the _name_ of the route as its first parameter to `router.addRoute()`, this will effectively add the route as if it was added through `children`: + +```js +router.addRoute({ name: 'admin', path: '/admin', component: Admin }) +router.addRoute('admin', { path: 'settings', component: AdminSettings }) +``` + +This is equivalent to: + +```js +router.addRoute({ + name: 'admin', + path: '/admin', + component: Admin, + children: [{ path: 'settings', component: AdminSettings }], +}) +``` + +## Looking at existing routes + +Vue Router gives you two functions to look at existing routes: + +- [`router.hasRoute()`](/api/#hasroute): check if a route exists +- [`router.getRoutes()`](/api/#getroutes): get an array with all the route records. diff --git a/docs/guide/advanced/extending-router-link.md b/docs/guide/advanced/extending-router-link.md new file mode 100644 index 000000000..46973c5c1 --- /dev/null +++ b/docs/guide/advanced/extending-router-link.md @@ -0,0 +1,90 @@ +# Extending RouterLink + +The RouterLink component exposes enough `props` to suffice most basic applications but it doesn't try to cover every possible use case and you will likely find yourself using `v-slot` for some advanced cases. In most medium to large sized applications, it's worth creating one if not multiple custom RouterLink components to reuse them across your application. Some examples are Links in a Navigation Menu, handling external links, adding an `inactive-class`, etc. + +Let's extend RouterLink to handle external links as well and adding a custom `inactive-class` in an `AppLink.vue` file: + +```vue + + + +``` + +If you prefer using a render function or create `computed` properties, you can use the `useLink` from the [Composition API](./composition-api.md): + +```js +import { RouterLink, useLink } from 'vue-router' + +export default { + name: 'AppLink', + + props: { + // add @ts-ignore if using TypeScript + ...RouterLink.props, + inactiveClass: String, + }, + + setup(props) { + // toRef allows us to extract one prop and keep it reactive + // https://v3.vuejs.org/api/refs-api.html#toref + const { navigate, href route, isActive, isExactActive } = useLink(toRef(props, 'to')) + + + // profit! + + return { isExternalLink } + } +} +``` + +In practice, you might want to use your `AppLink` component for different parts of your application. e.g. using [Tailwind CSS](https://tailwindcss.com), you could create a `NavLink.vue` component with all the classes: + +```vue + +``` diff --git a/docs/guide/advanced/lazy-loading.md b/docs/guide/advanced/lazy-loading.md new file mode 100644 index 000000000..71bf16775 --- /dev/null +++ b/docs/guide/advanced/lazy-loading.md @@ -0,0 +1,51 @@ +# Lazy Loading Routes + +When building apps with a bundler, the JavaScript bundle can become quite large, and thus affect the page load time. It would be more efficient if we can split each route's components into a separate chunks, and only load them when the route is visited. + +Vue Router supports [dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Dynamic_Imports) out of the box, meaning you can replace static imports with dynamic ones: + +```js +// replace +// import UserDetails from './views/UserDetails' +// with +const UserDetails = () => import('./views/UserDetails') + +const router = createRouter({ + // ... + routes: [{ path: '/users/:id', component: UserDetails }], +}) +``` + +The `component` (and `components`) option accepts a function that returns a Promise of a component and Vue Router **will only fetch it when entering the page for the first time**, then use the cached version. Which means you can also have more complex functions as long as they return a Promise: + +```js +const UserDetails = () => + Promise.resolve({ + /* component definition */ + }) +``` + +In general, it's a good idea **to always use dynamic imports** for all your routes. + +::: tip Note +Do **not** use [Async components](https://v3.vuejs.org/guide/component-dynamic-async.html#async-components) for routes. Async components can still be used inside route components but route component themselves are just dynamic imports. +::: + +When using a bundler like webpack, this will automatically benefit from [code splitting](https://webpack.js.org/guides/code-splitting/) + +When using Babel, you will need to add the [syntax-dynamic-import](https://babeljs.io/docs/plugins/syntax-dynamic-import/) plugin so that Babel can properly parse the syntax. + +## Grouping Components in the Same Chunk + +Sometimes we may want to group all the components nested under the same route into the same async chunk. To achieve that we need to use [named chunks](https://webpack.js.org/guides/code-splitting/#dynamic-imports) by providing a chunk name using a special comment syntax (requires webpack > 2.4): + +```js +const UserDetails = () => + import(/* webpackChunkName: "group-user" */ './UserDetails.vue') +const UserDashboard = () => + import(/* webpackChunkName: "group-user" */ './UserDashboard.vue') +const UserProfileEdit = () => + import(/* webpackChunkName: "group-user" */ './UserProfileEdit.vue') +``` + +webpack will group any async module with the same chunk name into the same async chunk. diff --git a/docs/guide/advanced/meta.md b/docs/guide/advanced/meta.md new file mode 100644 index 000000000..07ecb5fc6 --- /dev/null +++ b/docs/guide/advanced/meta.md @@ -0,0 +1,67 @@ +# Route Meta Fields + +Sometimes, you might want to attach arbitrary information to routes like transition names, who can access the route, etc. This can be achieved through the `meta` property which accepts an object of properties and can be accessed on the route location and navigation guards. You can define `meta` properties like this: + +```js +const routes = [ + { + path: '/posts', + component: PostsLayout, + children: [ + { + path: 'new', + component: PostsNew, + // only authenticated users can create posts + meta: { requiresAuth: true } + }, + { + path: ':id', + component: PostsDetail + // anybody can read a post + meta: { requiresAuth: false } + } + ] + } +] +``` + +So how do we access this `meta` field? + + + +First, each route object in the `routes` configuration is called a **route record**. Route records may be nested. Therefore when a route is matched, it can potentially match more than one route record. + +For example, with the above route config, the URL `/posts/new` will match both the parent route record (`path: '/posts'`) and the child route record (`path: 'new'`). + +All route records matched by a route are exposed on the `$route` object (and also route objects in navigation guards) as the `$route.matched` Array. We could loop through that array to check all `meta` fields, but Vue Router also provides you a `$route.meta` that is a non-recursive merge of **all `meta`** fields from parent to child. Meaning you can simply write + +```js +router.beforeEach((to, from) => { + // instead of having to check every route record with + // to.matched.some(record => record.meta.requiresAuth) + if (to.meta.requiresAuth && !auth.isLoggedIn()) { + // this route requires auth, check if logged in + // if not, redirect to login page. + return { + path: '/login', + // save the location we were at to come back later + query: { redirect: to.fullPath }, + } + } +}) +``` + +## TypeScript + +It is possible to type the meta field by extending the `RouteMeta` interface: + +```ts +declare module 'vue-router' { + interface RouteMeta { + // is optional + isAdmin?: boolean + // must be declared by every route + requiresAuth: boolean + } +} +``` diff --git a/docs/guide/advanced/navigation-failures.md b/docs/guide/advanced/navigation-failures.md new file mode 100644 index 000000000..e5e096f7c --- /dev/null +++ b/docs/guide/advanced/navigation-failures.md @@ -0,0 +1,94 @@ +# Waiting for the result of a Navigation + +When using `router-link`, Vue Router calls `router.push` to trigger a navigation. While the expected behavior for most links is to navigate a user to a new page, there are a few situations where users will remain on the same page: + +- Users are already on the page that they are trying to navigate to. +- A [navigation guard](./navigation-guards.md) aborts the navigation by doing `return false`. +- A new navigation guard takes place while the previous one not finished. +- A [navigation guard](./navigation-guards.md) redirects somewhere else by returning a new location (e.g. `return '/login'`). +- A [navigation guard](./navigation-guards.md) throws an `Error`. + +If we want to do something after a navigation is finished, we need a way to wait after calling `router.push`. Imagine we have a mobile menu that allows us to go to different pages and we only want to hide the menu once we have navigated to the new page, we might want to do something like this: + +```js +router.push('/my-profile') +this.isMenuOpen = false +``` + +But this will close the menu right away because **navigations are asynchronous**, we need to `await` the promise returned by `router.push`: + +```js +await router.push('/my-profile') +this.isMenuOpen = false +``` + +Now the menu will close once the navigation is finished but it will also close if the navigation was prevented. We need a way to detect if we actually changed the page we are one or not. + +## Detecting Navigation Failures + +If a navigation is prevented, resulting in the user staying on the same page, the resolved value of the `Promise` returned by `router.push` will be a _Navigation Failure_. Otherwise, it will be a _falsy_ value (usually `undefined`). This allows us to differentiate the case where we navigated away from where we are or not: + +```js +const navigationResult = await router.push('/my-profile') + +if (navigationResult) { + // navigation prevented +} else { + // navigation succeeded (this includes the case of a redirection) + this.isMenuOpen = false +} +``` + +_Navigation Failures_ are `Error` instances with a few extra properties that gives us enough information to know what navigation was prevented and why. To check the nature of a navigation result, use the `isNavigationFailure` function: + +```js +import { NavigationFailureType, isNavigationFailure } from 'vue-router' + +// trying to leave the editing page of an article without saving +const failure = await router.push('/articles/2') + +if (isNavigationFailure(failure, NavigationFailureType.aborted)) { + // show a small notification to the user + showToast('You have unsaved changes, discard and leave anyway?') +} +``` + +::: tip +If you omit the second parameter: `isNavigationFailure(failure)`, it will only check if `failure` is a _Navigation Failure_. +::: + +## Differentiating Navigation Failures + +As we said at the beginning, there are different situations aborting a navigation, all of them resulting in different _Navigation Failures_. They can be differentiated using the `isNavigationFailure` and `NavigationFailureType`. There are four different types: + +- `aborted`: `false` was returned inside of a navigation guard to the navigation. +- `cancelled`: A new navigation took place before the current navigation could finish. e.g. `router.push` was called while waiting inside of a navigation guard. +- `duplicated`: The navigation was prevented because we are already at the target location. + +## _Navigation Failures_'s properties + +All navigation failures expose `to` and `from` properties to reflect the current location as well as the target location for the navigation that failed: + +```js +// trying to access the admin page +router.push('/admin').catch(failure => { + if (isNavigationFailure(failure, NavigationFailureType.redirected)) { + failure.to.path // '/admin' + failure.from.path // '/' + } +}) +``` + +In all cases, `to` and `from` are normalized route locations. + +## Detecting Redirections + +When returning a new location inside of a Navigation Guard, we are triggering a new navigation that overrides the ongoing one. Differently from other return values, a redirection doesn't prevent a navigation, **it creates a new one**. It is therefore checked differently, by reading the `redirectedFrom` property in a Route Location: + +```js +await router.push('/my-profile') +if (router.currentRoute.value.redirectedFrom) { + // redirectedFrom is resolved route location like to and from in navigation + // guards +} +``` diff --git a/docs/guide/advanced/navigation-guards.md b/docs/guide/advanced/navigation-guards.md new file mode 100644 index 000000000..124f3d117 --- /dev/null +++ b/docs/guide/advanced/navigation-guards.md @@ -0,0 +1,247 @@ +# Navigation Guards + +As the name suggests, the navigation guards provided by Vue router are primarily used to guard navigations either by redirecting it or canceling it. There are a number of ways to hook into the route navigation process: globally, per-route, or in-component. + +## Global Before Guards + +You can register global before guards using `router.beforeEach`: + +```js +const router = createRouter({ ... }) + +router.beforeEach((to, from) => { + // ... + // explicitly return false to cancel the navigation + return false +}) +``` + +Global before guards are called in creation order, whenever a navigation is triggered. Guards may be resolved asynchronously, and the navigation is considered **pending** before all hooks have been resolved. + +Every guard function receives two arguments: + +- **`to`**: the target route location [in a normalized format](/api/#routelocationnormalized) being navigated to. +- **`from`**: the current route location [in a normalized format](/api/#routelocationnormalized) being navigated away from. + +And can optionally return any of the following values: + +- `false`: cancel the current navigation. If the browser URL was changed (either manually by the user or via back button), it will be reset to that of the `from` route. +- A [Route Location](/api/#routelocationraw): Redirect to a different location by passing a route location as if you were calling [`router.push()`](/api/#push), which allows you to pass options like `replace: true` or `name: 'home'`. The current navigation is dropped and a new one is created with the same `from`. + +It's also possible to throw an `Error` if an unexpected situation was met. This will also cancel the navigation and call any callback registered via [`router.onError()`](/api/#onerror). + +If nothing, `undefined` or `true` is returned, **the navigation is validated**, and the next navigation guard is called. + +All of the the things above **work the same way with `async` functions** and Promises: + +```js +router.beforeEach(async (to, from) => { + // canUserAccess() returns `true` or `false` + return await canUserAccess(to) +}) +``` + +### Optional third argument `next` + +In previous versions of Vue Router, it was also possible to use a _third argument_ `next`, this was a common source of mistakes and went through an [RFC](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0037-router-return-guards.md#motivation) to remove it. However, it is still supported, meaning you can pass a third argument to any navigation guard. In that case, **you must call `next` exactly once** in any given pass through a navigation guard. It can appear more than once, but only if the logical paths have no overlap, otherwise the hook will never be resolved or produce errors. Here is **a bad example** of redirecting to user to `/login` if they are not authenticated: + +```js +// BAD +router.beforeEach((to, from, next) => { + if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' }) + // if the user is not authenticated, `next` is called twice + next() +}) +``` + +Here is the correct version: + +```js +// GOOD +router.beforeEach((to, from, next) => { + if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' }) + else next() +}) +``` + +## Global Resolve Guards + +You can register a global guard with `router.beforeResolve`. This is similar to `router.beforeEach` because it triggers on **every navigation**, but resolve guards are called right before the navigation is confirmed, **after all in-component guards and async route components are resolved**. Here is an example that ensures the user has given access to the Camera for routes that [have defined a custom meta](./meta.md) property `requiresCamera`: + +```js +router.beforeResolve(async to => { + if (to.meta.requiresCamera) { + try { + await askForCameraPermission() + } catch (error) { + if (error instanceof NotAllowedError) { + // ... handle the error and then cancel the navigation + return false + } else { + // unexpected error, cancel the navigation and pass the error to the global handler + throw error + } + } + } +}) +``` + +`router.beforeResolve` is the ideal spot to fetch data or do any other operation that you want to avoid doing if the user cannot enter a page. + + + +## Global After Hooks + +You can also register global after hooks, however unlike guards, these hooks do not get a `next` function and cannot affect the navigation: + +```js +router.afterEach((to, from) => { + sendToAnalytics(to.fullPath) +}) +``` + + + +They are useful for analytics, changing the title of the page, accessibility features like announcing the page and many other things. + +They also reflect [navigation failures](./navigation-failures.md) as the third argument: + +```js +router.afterEach((to, from, failure) => { + if (!failure) sendToAnalytics(to.fullPath) +}) +``` + +Learn more about navigation failures on [its guide](./navigation-failures.md). + +## Per-Route Guard + +You can define `beforeEnter` guards directly on a route's configuration object: + +```js +const routes = [ + { + path: '/users/:id', + component: UserDetails, + beforeEnter: (to, from) => { + // reject the navigation + return false + }, + }, +] +``` + +`beforeEnter` guards **only trigger when entering the route**, they don't trigger when the `params`, `query` or `hash` change e.g. going from `/users/2` to `/users/3` or going from `/users/2#info` to `/users/2#projects`. They are only triggered when navigating **from a different** route. + +You can also pass an array of functions to `beforeEnter`, this is useful when reusing guards for different routes: + +```js +function removeQueryParams(to) { + if (Object.keys(to.query).length) + return { path: to.path, query: {}, hash: to.hash } +} + +function removeHash(to) { + if (to.hash) return { path: to.path, query: to.query, hash: '' } +} + +const routes = [ + { + path: '/users/:id', + component: UserDetails, + beforeEnter: [removeQueryParams, removeHash], + }, + { + path: '/about', + component: UserDetails, + beforeEnter: [removeQueryParams], + }, +] +``` + +Note it is possible to achieve a similar behavior by using [route meta fields](./meta.md) and [global navigation guards](#global-before-guards). + +## In-Component Guards + +Finally, you can directly define route navigation guards inside route components (the ones passed to the router configuration) + +### Using the options API + +You can add the following options to route components: + +- `beforeRouteEnter` +- `beforeRouteUpdate` +- `beforeRouteLeave` + +```js +const UserDetails = { + template: `...`, + beforeRouteEnter(to, from) { + // called before the route that renders this component is confirmed. + // does NOT have access to `this` component instance, + // because it has not been created yet when this guard is called! + }, + beforeRouteUpdate(to, from) { + // called when the route that renders this component has changed, + // but this component is reused in the new route. + // For example, given a route with params `/users/:id`, when we + // navigate between `/users/1` and `/users/2`, the same `UserDetails` component instance + // will be reused, and this hook will be called when that happens. + // Because the component is mounted while this happens, the navigation guard has access to `this` component instance. + }, + beforeRouteLeave(to, from) { + // called when the route that renders this component is about to + // be navigated away from. + // As with `beforeRouteUpdate`, it has access to `this` component instance. + }, +} +``` + +The `beforeRouteEnter` guard does **NOT** have access to `this`, because the guard is called before the navigation is confirmed, thus the new entering component has not even been created yet. + +However, you can access the instance by passing a callback to `next`. The callback will be called when the navigation is confirmed, and the component instance will be passed to the callback as the argument: + +```js +beforeRouteEnter (to, from, next) { + next(vm => { + // access to component public instance via `vm` + }) +} +``` + +Note that `beforeRouteEnter` is the only guard that supports passing a callback to `next`. For `beforeRouteUpdate` and `beforeRouteLeave`, `this` is already available, so passing a callback is unnecessary and therefore _not supported_: + +```js +beforeRouteUpdate (to, from) { + // just use `this` + this.name = to.params.name +} +``` + +The **leave guard** is usually used to prevent the user from accidentally leaving the route with unsaved edits. The navigation can be canceled by returning `false`. + +```js +beforeRouteLeave (to, from) { + const answer = window.confirm('Do you really want to leave? you have unsaved changes!') + if (!answer) return false +} +``` + +### Using the composition API + +If you are writing your component using the [composition API and a `setup` function](https://v3.vuejs.org/guide/composition-api-setup.html#setup), you can add update and leave guards through `onBeforeRouteUpdate` and `onBeforeRouteLeave` respectively. Please refer to the [Composition API section](./composition-api.md#navigation-guards) for more details. + +## The Full Navigation Resolution Flow + +1. Navigation triggered. +2. Call `beforeRouteLeave` guards in deactivated components. +3. Call global `beforeEach` guards. +4. Call `beforeRouteUpdate` guards in reused components. +5. Call `beforeEnter` in route configs. +6. Resolve async route components. +7. Call `beforeRouteEnter` in activated components. +8. Call global `beforeResolve` guards. +9. Navigation is confirmed. +10. Call global `afterEach` hooks. +11. DOM updates triggered. +12. Call callbacks passed to `next` in `beforeRouteEnter` guards with instantiated instances. diff --git a/docs/guide/advanced/scroll-behavior.md b/docs/guide/advanced/scroll-behavior.md new file mode 100644 index 000000000..c90126180 --- /dev/null +++ b/docs/guide/advanced/scroll-behavior.md @@ -0,0 +1,109 @@ +# Scroll Behavior + +When using client-side routing, we may want to scroll to top when navigating to a new route, or preserve the scrolling position of history entries just like real page reload does. Vue Router allows you to achieve these and even better, allows you to completely customize the scroll behavior on route navigation. + +**Note: this feature only works if the browser supports `history.pushState`.** + +When creating the router instance, you can provide the `scrollBehavior` function: + +```js +const router = createRouter({ + history: createWebHashHistory(), + routes: [...], + scrollBehavior (to, from, savedPosition) { + // return desired position + } +}) +``` + +The `scrollBehavior` function receives the `to` and `from` route objects, like [Navigation Guards](./navigation-guards.md). The third argument, `savedPosition`, is only available if this is a `popstate` navigation (triggered by the browser's back/forward buttons). + +The function can return a [`ScrollToOptions`](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions) position object: + +```js +const router = createRouter({ + scrollBehavior(to, from, savedPosition) { + // always scroll to top + return { top: 0 } + }, +}) +``` + +You can also pass a CSS selector or a DOM element via `el`. In that scenario, `top` and `left` will be treated as relative offsets to that element. + +```js +const router = createRouter({ + scrollBehavior(to, from, savedPosition) { + // always scroll 10px above the element #main + return { + // could also be + // el: document.getElementById('main'), + el: '#main', + top: -10, + } + }, +}) +``` + +If a falsy value or an empty object is returned, no scrolling will happen. + +Returning the `savedPosition` will result in a native-like behavior when navigating with back/forward buttons: + +```js +const router = createRouter({ + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition + } else { + return { top: 0 } + } + }, +}) +``` + +If you want to simulate the "scroll to anchor" behavior: + +```js +const router = createRouter({ + scrollBehavior(to, from, savedPosition) { + if (to.hash) { + return { + el: to.hash, + } + } + }, +}) +``` + +If your browser supports [scroll behavior](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior), you can make it smooth: + +```js +const router = createRouter({ + scrollBehavior(to, from, savedPosition) { + if (to.hash) { + return { + el: to.hash + behavior: 'smooth', + } + } + } +}) +``` + +## Delaying the scroll + +Sometimes we need to wait a bit before scrolling in the page. For example, when dealing with transitions, we want to wait for the transition to finish before scrolling. To do this you can return a Promise that returns the desired position descriptor. Here is an example where we wait 500ms before scrolling: + +```js +const router = createRouter({ + scrollBehavior(to, from, savedPosition) { + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve({ left: 0, top: 0 }) + }, 500) + }) + }, +}) +``` + +It's possible to hook this up with events from a page-level transition component to make the scroll behavior play nicely with your page transitions, but due to the possible variance and complexity in use cases, we simply provide this primitive to enable specific userland implementations. diff --git a/docs/guide/advanced/transitions.md b/docs/guide/advanced/transitions.md new file mode 100644 index 000000000..334d9b912 --- /dev/null +++ b/docs/guide/advanced/transitions.md @@ -0,0 +1,67 @@ +# Transitions + +In order to use transitions on your route components and animate navigations, you need to use the [v-slot API](/api/#router-view-s-v-slot): + +```html + + + + + +``` + +[All transition APIs](https://v3.vuejs.org/guide/transitions-enterleave.html) work the same here. + +## Per-Route Transition + +The above usage will apply the same transition for all routes. If you want each route's component to have different transitions, you can instead combine [meta fields](./meta.md) and a dynamic `name` on ``: + +```js +const routes = [ + { + path: '/custom-transition', + component: PanelLeft, + meta: { transition: 'slide-left' }, + }, + { + path: '/other-transition', + component: PanelRight, + meta: { transition: 'slide-right' }, + }, +] +``` + +```html + + + + + + +``` + +## Route-Based Dynamic Transition + +It is also possible to determine the transition to use dynamically based on the relationship between the target route and current route. Using a very similar snippet to the one just before: + +```html + + + + + + +``` + +We can add an [after navigation hook](./navigation-guards.md#global-after-hooks) to dynamically add information to the `meta` field based on the depth of the route + +```js +router.afterEach((to, from) => { + const toDepth = to.path.split('/').length + const fromDepth = from.path.split('/').length + to.meta.transitionName = toDepth < fromDepth ? 'slide-right' : 'slide-left' +}) +``` + + + diff --git a/docs/guide/essentials/dynamic-matching.md b/docs/guide/essentials/dynamic-matching.md new file mode 100644 index 000000000..6dd128315 --- /dev/null +++ b/docs/guide/essentials/dynamic-matching.md @@ -0,0 +1,106 @@ +# Dynamic Route Matching with Params + +Very often we will need to map routes with the given pattern to the same component. For example we may have a `User` component which should be rendered for all users but with different user IDs. In Vue Router we can use a dynamic segment in the path to achieve that, we call that a _param_: + +```js +const User = { + template: '
    User
    ', +} + +// these are passed to `createRouter` +const routes = [ + // dynamic segments start with a colon + { path: '/users/:id', component: User }, +] +``` + +Now URLs like `/users/johnny` and `/users/jolyne` will both map to the same route. + +A _param_ is denoted by a colon `:`. When a route is matched, the value of its _params_ will be exposed as `this.$route.params` in every component. Therefore, we can render the current user ID by updating `User`'s template to this: + +```js +const User = { + template: '
    User {{ $route.params.id }}
    ', +} +``` + +You can have multiple _params_ in the same route, and they will map to corresponding fields on `$route.params`. Examples: + +| pattern | matched path | \$route.params | +| ------------------------------ | ------------------------ | ---------------------------------------- | +| /users/:username | /users/eduardo | `{ username: 'eduardo' }` | +| /users/:username/posts/:postId | /users/eduardo/posts/123 | `{ username: 'eduardo', postId: '123' }` | + +In addition to `$route.params`, the `$route` object also exposes other useful information such as `$route.query` (if there is a query in the URL), `$route.hash`, etc. You can check out the full details in the [API Reference](/api/#routelocationnormalized). + +A working demo of this example can be found [here](https://codesandbox.io/s/route-params-vue-router-examples-mlb14?from-embed&initialpath=%2Fusers%2Feduardo%2Fposts%2F1). + + + +## Reacting to Params Changes + +One thing to note when using routes with params is that when the user navigates from `/users/johnny` to `/users/jolyne`, **the same component instance will be reused**. Since both routes render the same component, this is more efficient than destroying the old instance and then creating a new one. **However, this also means that the lifecycle hooks of the component will not be called**. + +To react to params changes in the same component, you can simply watch anything on the `$route` object, in this scenario, the `$route.params`: + +```js +const User = { + template: '...', + created() { + this.$watch( + () => this.$route.params, + (toParams, previousParams) => { + // react to route changes... + } + ) + }, +} +``` + +Or, use the `beforeRouteUpdate` [navigation guard](../advanced/navigation-guards.md), which also allows to cancel the navigation: + +```js +const User = { + template: '...', + async beforeRouteUpdate(to, from) { + // react to route changes... + this.userData = await fetchUser(to.params.id) + }, +} +``` + +## Catch all / 404 Not found Route + +Regular params will only match characters in between url fragments, separated by `/`. If we want to match **anything**, we can use a custom _param_ regexp by adding the regexp inside parentheses right after the _param_: + +```js +const routes = [ + // will match everything and put it under `$route.params.pathMatch` + { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }, + // will match anything starting with `/user-` and put it under `$route.params.afterUser` + { path: '/user-:afterUser(.*)', component: UserGeneric }, +] +``` + +In this specific scenario we are using a [custom regexp](/guide/advanced/path-matching.md#custom-regexp) between parentheses and marking the `pathMatch` param as [optionally repeatable](/guide/advanced/path-matching.md#zero-or-more). This is to allow us to directly navigate to the route if we need to by splitting the `path` into an array: + +```js +this.$router.push({ + name: 'NotFound', + params: { pathMatch: this.$route.path.split('/') }, +}) +``` + +See more in the [repeated params](/guide/advanced/path-matching.md#zero-or-more) section. + +If you are using [History mode](./history-mode.md), make sure to follow the instructions to correctly configure your server as well. + +## Advanced Matching Patterns + +Vue Router uses its own path matching syntax, inspired by the one used by `express`, so it supports many advanced matching patterns such as optional params, zero or more / one or more requirements, and even custom regex patterns. Please check the [Advanced Matching](./route-matching-syntax.md) documentation to explore them. diff --git a/docs/guide/essentials/history-mode.md b/docs/guide/essentials/history-mode.md new file mode 100644 index 000000000..5cc9ca5db --- /dev/null +++ b/docs/guide/essentials/history-mode.md @@ -0,0 +1,175 @@ +# Different History modes + +The `history` option when creating the router instance allows us to choose among different history modes. + +## Hash Mode + +The hash history mode is created with `createWebHashHistory()`: + +```js +import { createRouter, createWebHashHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + //... + ], +}) +``` + +It uses a hash character (`#`) before the actual URL that is internally passed. Because this section of the URL is never sent to the server, it doesn't require any special treatment on the server level. **It does however have a bad impact in SEO**. If that's a concern for you, use the HTML5 history mode. + +## HTML5 Mode + +The HTML5 mode is created with `createWebHistory()` and is the recommend mode: + +```js +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + //... + ], +}) +``` + +When using history mode, the URL will look "normal," e.g. `https://example.com/user/id`. Beautiful! + +Here comes a problem, though: Since our app is a single page client side app, without a proper server configuration, the users will get a 404 error if they access `https://example.com/user/id` directly in their browser. Now that's ugly. + +Not to worry: To fix the issue, all you need to do is add a simple catch-all fallback route to your server. If the URL doesn't match any static assets, it should serve the same `index.html` page that your app lives in. Beautiful, again! + +## Example Server Configurations + +**Note**: The following examples assume you are serving your app from the root folder. If you deploy to a subfolder, you should use [the `publicPath` option of Vue CLI](https://cli.vuejs.org/config/#publicpath) and the related [`base` property of the router](/api/#createwebhistory). You also need to adjust the examples below to use the subfolder instead of the root folder (e.g. replacing `RewriteBase /` with `RewriteBase /name-of-your-subfolder/`). + +### Apache + +```apacheconf + + RewriteEngine On + RewriteBase / + RewriteRule ^index\.html$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule . /index.html [L] + +``` + +Instead of `mod_rewrite`, you could also use [`FallbackResource`](https://httpd.apache.org/docs/2.2/mod/mod_dir.html#fallbackresource). + +### nginx + +```nginx +location / { + try_files $uri $uri/ /index.html; +} +``` + +### Native Node.js + +```js +const http = require('http') +const fs = require('fs') +const httpPort = 80 + +http + .createServer((req, res) => { + fs.readFile('index.htm', 'utf-8', (err, content) => { + if (err) { + console.log('We cannot open "index.htm" file.') + } + + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + }) + + res.end(content) + }) + }) + .listen(httpPort, () => { + console.log('Server listening on: http://localhost:%s', httpPort) + }) +``` + +### Express with Node.js + +For Node.js/Express, consider using [connect-history-api-fallback middleware](https://github.com/bripkens/connect-history-api-fallback). + +### Internet Information Services (IIS) + +1. Install [IIS UrlRewrite](https://www.iis.net/downloads/microsoft/url-rewrite) +2. Create a `web.config` file in the root directory of your site with the following: + +```xml + + + + + + + + + + + + + + + + + +``` + +### Caddy + +``` +rewrite { + regexp .* + to {path} / +} +``` + +### Firebase hosting + +Add this to your `firebase.json`: + +```json +{ + "hosting": { + "public": "dist", + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} +``` + +### Netlify + +Create a `_redirects` file that is included with your deployed files: + +``` +/* /index.html 200 +``` + +In vue-cli, nuxt, and vite projects, this file usually goes under a folder named `static` or `public`. + +You can more about the syntax on [Netlify documentation](https://docs.netlify.com/routing/redirects/rewrites-proxies/#history-pushstate-and-single-page-apps). You can also [create a `netlify.toml`](https://docs.netlify.com/configure-builds/file-based-configuration/) to combine _redirections_ with other Netlify features. + +## Caveat + +There is a caveat to this: Your server will no longer report 404 errors as all not-found paths now serve up your `index.html` file. To get around the issue, you should implement a catch-all route within your Vue app to show a 404 page: + +```js +const router = createRouter({ + history: createWebHistory(), + routes: [{ path: '/:pathMatch(.*)', component: NotFoundComponent }], +}) +``` + +Alternatively, if you are using a Node.js server, you can implement the fallback by using the router on the server side to match the incoming URL and respond with 404 if no route is matched. Check out the [Vue server side rendering documentation](https://ssr.vuejs.org/en/) for more information. diff --git a/docs/guide/essentials/named-routes.md b/docs/guide/essentials/named-routes.md new file mode 100644 index 000000000..b02a93a82 --- /dev/null +++ b/docs/guide/essentials/named-routes.md @@ -0,0 +1,36 @@ +# Named Routes + +Alongside the `path`, you can provide a `name` to any route. This has the following advantages: + +- No hardcoded URLs +- Automatic encoding/decoding of `params` +- Prevents you from having a typo in the url +- Bypassing path ranking (e.g. to display a ) + +```js +const routes = [ + { + path: '/user/:username', + name: 'user', + component: User + } +] +``` + +To link to a named route, you can pass an object to the `router-link` component's `to` prop: + +```html + + User + +``` + +This is the exact same object used programmatically with `router.push()`: + +```js +router.push({ name: 'user', params: { username: 'erina' } }) +``` + +In both cases, the router will navigate to the path `/user/erina`. + +Full example [here](https://github.com/vuejs/vue-router/blob/dev/examples/named-routes/app.js). diff --git a/docs/guide/essentials/named-views.md b/docs/guide/essentials/named-views.md new file mode 100644 index 000000000..0fc6f0ae2 --- /dev/null +++ b/docs/guide/essentials/named-views.md @@ -0,0 +1,89 @@ +# Named Views + +Sometimes you need to display multiple views at the same time instead of nesting them, e.g. creating a layout with a `sidebar` view and a `main` view. This is where named views come in handy. Instead of having one single outlet in your view, you can have multiple and give each of them a name. A `router-view` without a name will be given `default` as its name. + +```html + + + +``` + +A view is rendered by using a component, therefore multiple views require +multiple components for the same route. Make sure to use the `components` (with +an **s**) option: + +```js +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + components: { + default: Home, + // short for LeftSidebar: LeftSidebar + LeftSidebar, + // they match the `name` attribute on `` + RightSidebar, + }, + }, + ], +}) +``` + +A working demo of this example can be found [here](https://codesandbox.io/s/named-views-vue-router-4-examples-rd20l). + +## Nested Named Views + +It is possible to create complex layouts using named views with nested views. When doing so, you will also need to give nested `router-view` a name. Let's take a Settings panel example: + +``` +/settings/emails /settings/profile ++-----------------------------------+ +------------------------------+ +| UserSettings | | UserSettings | +| +-----+-------------------------+ | | +-----+--------------------+ | +| | Nav | UserEmailsSubscriptions | | +------------> | | Nav | UserProfile | | +| | +-------------------------+ | | | +--------------------+ | +| | | | | | | | UserProfilePreview | | +| +-----+-------------------------+ | | +-----+--------------------+ | ++-----------------------------------+ +------------------------------+ +``` + +- `Nav` is just a regular component +- `UserSettings` is the parent view component +- `UserEmailsSubscriptions`, `UserProfile`, `UserProfilePreview` are nested view components + +**Note**: _Let's forget about how the HTML/CSS should look like to represent such layout and focus on the components used._ + +The `